diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000000..dd2f24ece5 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,116 @@ +name: Go CI + +on: + push: + branches: [main, master] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + pull_request: + branches: [main, master] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + +env: + DOCKER_IMAGE: mashfeii/devops-info-service-go + GO_VERSION: '1.21' + +jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + app: ${{ steps.filter.outputs.app }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + app: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + + test: + name: Lint and Test + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.app == 'true' + defaults: + run: + working-directory: app_go + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: app_go/go.mod + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + working-directory: app_go + version: latest + + - name: Run tests with coverage + run: | + go test -v -coverprofile=coverage.out -covermode=atomic ./... + go tool cover -func=coverage.out + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: app_go/coverage.out + flags: go + token: ${{ secrets.CODECOV_TOKEN }} + if: always() + + build: + name: Build and Push Docker + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate CalVer version + id: version + run: | + echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_OUTPUT + echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_IMAGE }} + tags: | + type=raw,value=${{ steps.version.outputs.VERSION }} + type=raw,value=latest + type=sha,prefix=,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: app_go + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..e744427d2d --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,147 @@ +name: Python CI + +on: + push: + branches: [main, master] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [main, master] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + +env: + DOCKER_IMAGE: mashfeii/devops-info-service + PYTHON_VERSION: '3.13' + +jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + app: ${{ steps.filter.outputs.app }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + app: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + + test: + name: Lint and Test + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.app == 'true' + defaults: + run: + working-directory: app_python + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint with ruff + run: ruff check . --output-format=github + + - name: Run tests with coverage + run: pytest --cov=. --cov-report=xml --cov-report=term --cov-fail-under=70 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: app_python/coverage.xml + flags: python + token: ${{ secrets.CODECOV_TOKEN }} + if: always() + + security: + name: Security Scan + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --file=app_python/requirements.txt --severity-threshold=high + continue-on-error: true + + build: + name: Build and Push Docker + runs-on: ubuntu-latest + needs: [test, security] + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate CalVer version + id: version + run: | + echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_OUTPUT + echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_IMAGE }} + tags: | + type=raw,value=${{ steps.version.outputs.VERSION }} + type=raw,value=latest + type=sha,prefix=,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: app_python + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 30d74d2584..30e291380e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -test \ No newline at end of file +*.exe + +.vscode/ +.idea/ + +.DS_Store diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..59fc7e65e4 --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,11 @@ +.git/ +.gitignore +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db +docs/ +README.md +devops-info-service diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..b288c343b0 --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,11 @@ +devops-info-service +*.exe + +.vscode/ +.idea/ + +.DS_Store + +# Test coverage +coverage.out +coverage.html diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..1f3de0e975 --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /build + +COPY go.mod . +COPY main.go . + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o devops-info-service . + +FROM scratch + +WORKDIR /app + +COPY --from=builder /build/devops-info-service . + +EXPOSE 8080 + +ENTRYPOINT ["/app/devops-info-service"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..53b64ede27 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,168 @@ +![Go CI](https://github.com/mashfeii/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg) +![Coverage](https://codecov.io/gh/mashfeii/DevOps-Core-Course/branch/master/graph/badge.svg?flag=go) + +# devops info service (go) + +a go web service that provides detailed information about itself and its runtime environment + +## overview + +this service exposes two endpoints that return json data about the system, service metadata, and health status + +## prerequisites + +- go 1.21 or higher + +## building + +```bash +# build the binary +go build -o devops-info-service main.go + +# or run directly +go run main.go +``` + +## running the application + +```bash +# default configuration (0.0.0.0:8080) +go run main.go + +# or with binary +./devops-info-service + +# custom port +PORT=3000 go run main.go + +# custom host and port +HOST=127.0.0.1 PORT=3000 go run main.go +``` + +## api endpoints + +### get / + +returns comprehensive service and system information + +**response example:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "net/http" + }, + "system": { + "hostname": "my-laptop", + "platform": "darwin", + "architecture": "arm64", + "cpu_count": 8, + "go_version": "go1.21.0" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hours, 0 minutes", + "current_time": "2026-01-27T14:30:00Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1:52345", + "user_agent": "curl/8.1.2", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### get /health + +returns service health status for monitoring + +**response example:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-27T14:30:00Z", + "uptime_seconds": 3600 +} +``` + +## configuration + +| variable | default | description | +|----------|---------|-------------| +| HOST | 0.0.0.0 | server bind address | +| PORT | 8080 | server port | + +## testing + +### unit tests + +```bash +# run tests +go test -v + +# run tests with coverage +go test -coverprofile=coverage.out +go tool cover -func=coverage.out + +# generate html coverage report +go tool cover -html=coverage.out -o coverage.html +``` + +### what's tested + +- `GET /` endpoint: response structure, service info, system info, runtime info +- `GET /health` endpoint: status code, health status, uptime +- 404 handler: error response format +- helper functions: uptime calculation + +### manual testing + +```bash +# test main endpoint +curl http://localhost:8080/ + +# test health endpoint +curl http://localhost:8080/health + +# pretty print json output +curl http://localhost:8080/ | python -m json.tool +``` + +## binary size comparison + +the compiled go binary is significantly smaller than a python application with its dependencies: + +| implementation | size | +|----------------|------| +| go binary | ~6-8 mb | +| python + flask | ~50+ mb (with venv) | + +## docker + +### building the image + +```bash +docker build -t devops-info-service-go . +``` + +### running the container + +```bash +docker run -p 8080:8080 devops-info-service-go +``` + +### multi-stage build + +the dockerfile uses multi-stage build: +- stage 1 (builder): compiles the binary using golang:1.21-alpine +- stage 2 (runtime): copies only the binary to scratch image + +this results in a final image of ~5-8 mb instead of ~300+ mb diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..266806c5cf --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,61 @@ +# go language justification + +## why go for the bonus task + +### compiled language benefits + +| criteria | go | python | +|----------|-----|--------| +| compilation | produces single binary | interpreted, requires runtime | +| deployment | copy one file | install python + pip + dependencies | +| startup time | milliseconds | seconds | +| memory usage | lower | higher | +| binary size | ~6-8 mb | n/a (needs interpreter) | + +### go specific advantages + +**1 simple concurrency model** +- goroutines for handling multiple requests +- built into the language, not a library + +**2 standard library http server** +- no external dependencies needed +- production-ready out of the box + +**3 cross-compilation** +- build for any platform from any platform +- `GOOS=linux GOARCH=amd64 go build` + +**4 docker optimization** +- multi-stage builds produce tiny images +- scratch or alpine base images possible +- typical final image: 10-20 mb vs 100+ mb for python + +### comparison with other compiled languages + +| language | learning curve | build time | binary size | ecosystem | +|----------|----------------|------------|-------------|-----------| +| go | gentle | fast | small | mature | +| rust | steep | slow | smaller | growing | +| java | moderate | slow | large | enterprise | +| c# | moderate | moderate | medium | enterprise | + +### why go over rust for this lab + +- simpler syntax, faster to learn +- faster compilation times +- excellent for web services and devops tooling +- kubernetes, docker, terraform all written in go + +## implementation notes + +the go implementation uses only standard library packages: +- `encoding/json` for json serialization +- `net/http` for the http server +- `os` for environment variables and hostname +- `runtime` for system information +- `time` for timestamps and uptime +- `fmt` for string formatting +- `log` for logging + +no external dependencies required diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..64648a5f31 --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,114 @@ +# lab 01 submission (go bonus) + +## implementation overview + +this is the bonus go implementation of the devops info service, providing the same functionality as the python version with identical json response structures + +## endpoints implemented + +### get / + +returns comprehensive service and system information including: +- service metadata (name, version, description, framework) +- system info (hostname, platform, architecture, cpu count, go version) +- runtime info (uptime, current time, timezone) +- request info (client ip, user agent, method, path) +- available endpoints list + +### get /health + +returns health check information: +- status: healthy +- timestamp in iso format +- uptime in seconds + +## code structure + +```go +// struct definitions for json responses +type ServiceInfo struct { ... } +type SystemInfo struct { ... } +type RuntimeInfo struct { ... } +type RequestInfo struct { ... } +type MainResponse struct { ... } +type HealthResponse struct { ... } +type ErrorResponse struct { ... } + +// helper function for uptime calculation +func getUptime() (int, string) { ... } + +// http handlers +func mainHandler(w http.ResponseWriter, r *http.Request) { ... } +func healthHandler(w http.ResponseWriter, r *http.Request) { ... } +func notFoundHandler(w http.ResponseWriter, r *http.Request) { ... } + +// application entry point +func main() { ... } +``` + +## features + +### environment variable configuration + +```go +port := os.Getenv("PORT") +if port == "" { + port = "8080" +} + +host := os.Getenv("HOST") +if host == "" { + host = "0.0.0.0" +} +``` + +### error handling + +custom 404 handler returns json error response: + +```go +func notFoundHandler(w http.ResponseWriter, r *http.Request) { + response := ErrorResponse{ + Error: "Not Found", + Message: "The requested endpoint does not exist", + Path: r.URL.Path, + } + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(response) +} +``` + +### logging + +request logging using standard log package: + +```go +log.Printf("Request received: %s %s", r.Method, r.URL.Path) +``` + +## testing evidence + +![[fullscreen with two terminals: left running go app on port 8080, right showing curl responses for main and health endpoints]](screenshots/go-implementation.png) + +## build and run + +```bash +# build +go build -o devops-info-service main.go + +# run +./devops-info-service + +# or directly +go run main.go +``` + +## differences from python version + +| aspect | python | go | +|--------|--------|-----| +| framework field | Flask | net/http | +| python_version | included | replaced with go_version | +| default port | 5000 | 8080 | +| timestamp format | isoformat with microseconds | rfc3339 | +| client_ip | ip only | ip:port | diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..16a265dbd1 --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,86 @@ +# Lab 02 - Docker Multi-Stage Build (Bonus) + +## Multi-stage build strategy + +### builder stage + +- uses golang:1.21-alpine as base (~300mb) +- contains full go toolchain for compilation +- compiles application with static linking +- produces single binary file + +### runtime stage + +- uses scratch (empty image, 0 bytes base) +- contains only the compiled binary +- no shell, no package manager, no extra files +- minimal attack surface + +### why scratch works for go + +- go can produce fully static binaries with CGO_ENABLED=0 +- no runtime dependencies needed (unlike python or java) +- all libraries compiled into single executable +- binary is self-contained and portable + +## Size comparison + +### image sizes + +![[image sizes]](screenshots/image_sizes.png) + +### size reduction analysis + +- builder image: 221 mb (includes go compiler, tools, libraries) +- final image: 6.52 mb (only compiled binary) +- reduction: ~97% smaller than builder +- reason: discarded compiler, source code, build tools after compilation + +## Technical explanation + +### CGO_ENABLED=0 + +- disables c go interface +- produces pure go binary without c dependencies +- required for scratch base image (no libc available) +- ensures binary works without any system libraries + +### ldflags stripping + +- -s removes symbol table +- -w removes dwarf debugging information +- reduces binary size by ~30% +- no impact on runtime functionality + +### static compilation benefits + +- single file deployment +- no dependency resolution at runtime +- works on any linux system +- compatible with minimal base images + +## Security benefits + +### minimal attack surface + +- no shell means no shell injection possible +- no package manager means no supply chain attacks via container +- no extra utilities means fewer potential vulnerabilities +- only your code runs in the container + +### no shell in scratch + +- cannot exec into container with shell +- attackers cannot install tools if they gain access +- forces immutable infrastructure pattern +- debugging requires different approaches (logging, metrics) + +## Build and run process + +### build output + +![[multi-stage docker build]](screenshots/go_build.png) + +### endpoint testing + +![[curl tests]](screenshots/run_curl_go.png) diff --git a/app_go/docs/LAB03.md b/app_go/docs/LAB03.md new file mode 100644 index 0000000000..36a92831dc --- /dev/null +++ b/app_go/docs/LAB03.md @@ -0,0 +1,147 @@ +# Lab 03 - CI/CD with GitHub Actions (Go Bonus) + +## Overview + +This document covers the Go CI workflow implementation as part of the Lab 03 bonus task for multi-app CI with path filters. + +### Testing Framework: Go Standard Library + +**Why Go's built-in testing:** + +- **Zero dependencies**: Part of Go standard library (`testing` package) +- **Convention-based**: Files ending in `_test.go` are automatically tests +- **Built-in coverage**: `go test -cover` works out of the box +- **Fast execution**: Compiled tests run extremely fast + +### Endpoints Tested + +| Endpoint | Tests Count | What's Validated | +| ------------- | ----------- | ---------------------------------------------------------------------- | +| `GET /` | 6 tests | Status code, JSON content-type, service/system/runtime info, endpoints | +| `GET /health` | 4 tests | Status code, JSON format, health status, uptime | +| 404 handler | 3 tests | Status code, error format, path inclusion | +| Helper | 1 test | getUptime() returns valid values | + +**Total: 14 tests** covering all handlers and helper functions. + +### Workflow Triggers + +```yaml +on: + push: + branches: [main, master] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + pull_request: + branches: [main, master] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' +``` + +**Path filters ensure:** + +- Go CI only runs when Go code changes +- Python CI only runs when Python code changes +- Both can run in parallel if both change + +--- + +## Workflow Evidence + +### 1. Go CI Workflow Run + +![go-workflow-run](screenshots/go-workflow-run.png) + +--- + +### 2. Go Tests Passing Locally + +![go-tests-passing](screenshots/go-tests-passing.png) + +--- + +### 3. Go Coverage Report + +![go-coverage](screenshots/go-coverage.png) + +**Coverage breakdown:** + +- `getUptime`: 100% +- `mainHandler`: 100% +- `healthHandler`: 100% +- `notFoundHandler`: 100% +- `main`: 0% (entry point, expected) +- **Total: ~68%** + +--- + +### 4. Path Filters Working + +I tried many times to refactor pipeline to validate paths, but none of them is successfull :( + +--- + +### 5. Docker Hub Go Images + +![docker-hub-go](screenshots/docker-hub-go.png) + +**Docker Hub URL:** https://hub.docker.com/r/mashfeii/devops-info-service-go + +--- + +### 6. Caching Performance + +![cache-miss](screenshots/cache-miss.png) +![cache-hit](screenshots/cache-hit.png) + +**Metrics:** + +- Without cache (first run): 41 seconds +- With cache (subsequent): 29 seconds +- **Time saved:** 12 seconds (~30% improvement) + +--- + +## CI Workflow Comparison + +| Aspect | Python CI | Go CI | +| -------------------- | ---------------------------- | ------------------------------- | +| **Language Setup** | actions/setup-python@v5 | actions/setup-go@v5 | +| **Linting** | ruff check | golangci-lint-action | +| **Testing** | pytest | go test | +| **Coverage Tool** | pytest-cov | go test -coverprofile | +| **Coverage Upload** | codecov-action (xml) | codecov-action (out) | +| **Docker Image** | mashfeii/devops-info-service | mashfeii/devops-info-service-go | +| **Final Image Size** | ~150MB (python:3.13-slim) | ~6MB (scratch) | + +--- + +## Benefits of Path-Based Triggers + +1. **Resource efficiency**: Only relevant CI runs, saving compute time +2. **Faster feedback**: Don't wait for unrelated tests +3. **Clear ownership**: Each app has its own CI configuration +4. **Independent deployment**: Can deploy Python without touching Go + +--- + +## Challenges + +### Challenge: golangci-lint Configuration + +**Problem:** golangci-lint flagged some style issues in auto-generated code. + +**Solution:** Either fix the issues or configure `.golangci.yml` to exclude specific checks. + +### Challenge: Coverage File Format + +**Problem:** Go coverage output is `.out` format, not `.xml`. + +**Solution:** Codecov supports Go coverage format natively: + +```yaml +files: app_go/coverage.out +flags: go +``` diff --git a/app_go/docs/screenshots/cache-hit.png b/app_go/docs/screenshots/cache-hit.png new file mode 100644 index 0000000000..7b04613d82 Binary files /dev/null and b/app_go/docs/screenshots/cache-hit.png differ diff --git a/app_go/docs/screenshots/cache-miss.png b/app_go/docs/screenshots/cache-miss.png new file mode 100644 index 0000000000..bae2da9eb9 Binary files /dev/null and b/app_go/docs/screenshots/cache-miss.png differ diff --git a/app_go/docs/screenshots/docker-hub-go.png b/app_go/docs/screenshots/docker-hub-go.png new file mode 100644 index 0000000000..02cf3c9d79 Binary files /dev/null and b/app_go/docs/screenshots/docker-hub-go.png differ diff --git a/app_go/docs/screenshots/docker_hub.png b/app_go/docs/screenshots/docker_hub.png new file mode 100644 index 0000000000..e7d9d8db7f Binary files /dev/null and b/app_go/docs/screenshots/docker_hub.png differ diff --git a/app_go/docs/screenshots/docker_push.png b/app_go/docs/screenshots/docker_push.png new file mode 100644 index 0000000000..298e3c8fb5 Binary files /dev/null and b/app_go/docs/screenshots/docker_push.png differ diff --git a/app_go/docs/screenshots/go-coverage.png b/app_go/docs/screenshots/go-coverage.png new file mode 100644 index 0000000000..edca441ad8 Binary files /dev/null and b/app_go/docs/screenshots/go-coverage.png differ diff --git a/app_go/docs/screenshots/go-implementation.png b/app_go/docs/screenshots/go-implementation.png new file mode 100644 index 0000000000..a0f3729cf5 Binary files /dev/null and b/app_go/docs/screenshots/go-implementation.png differ diff --git a/app_go/docs/screenshots/go-tests-passing.png b/app_go/docs/screenshots/go-tests-passing.png new file mode 100644 index 0000000000..bc07287ebb Binary files /dev/null and b/app_go/docs/screenshots/go-tests-passing.png differ diff --git a/app_go/docs/screenshots/go-workflow-run.png b/app_go/docs/screenshots/go-workflow-run.png new file mode 100644 index 0000000000..9dd9797d2a Binary files /dev/null and b/app_go/docs/screenshots/go-workflow-run.png differ diff --git a/app_go/docs/screenshots/go_build.png b/app_go/docs/screenshots/go_build.png new file mode 100644 index 0000000000..8c6943e3b3 Binary files /dev/null and b/app_go/docs/screenshots/go_build.png differ diff --git a/app_go/docs/screenshots/image_sizes.png b/app_go/docs/screenshots/image_sizes.png new file mode 100644 index 0000000000..defb93c5d5 Binary files /dev/null and b/app_go/docs/screenshots/image_sizes.png differ diff --git a/app_go/docs/screenshots/run_curl_go.png b/app_go/docs/screenshots/run_curl_go.png new file mode 100644 index 0000000000..5b2b947caf Binary files /dev/null and b/app_go/docs/screenshots/run_curl_go.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..307ce0d1c5 --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module devops-info-service + +go 1.21 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..099b080c16 --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "runtime" + "time" +) + +var startTime = time.Now() + +type ServiceInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Framework string `json:"framework"` +} + +type SystemInfo struct { + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Architecture string `json:"architecture"` + CPUCount int `json:"cpu_count"` + GoVersion string `json:"go_version"` +} + +type RuntimeInfo struct { + UptimeSeconds int `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + CurrentTime string `json:"current_time"` + Timezone string `json:"timezone"` +} + +type RequestInfo struct { + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` +} + +type Endpoint struct { + Path string `json:"path"` + Method string `json:"method"` + Description string `json:"description"` +} + +type MainResponse struct { + Service ServiceInfo `json:"service"` + System SystemInfo `json:"system"` + Runtime RuntimeInfo `json:"runtime"` + Request RequestInfo `json:"request"` + Endpoints []Endpoint `json:"endpoints"` +} + +type HealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + UptimeSeconds int `json:"uptime_seconds"` +} + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Path string `json:"path,omitempty"` +} + +func getUptime() (int, string) { + seconds := int(time.Since(startTime).Seconds()) + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + return seconds, fmt.Sprintf("%d hours, %d minutes", hours, minutes) +} + +func mainHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + notFoundHandler(w, r) + return + } + + hostname, _ := os.Hostname() + uptimeSec, uptimeHuman := getUptime() + + response := MainResponse{ + Service: ServiceInfo{ + Name: "devops-info-service", + Version: "1.0.0", + Description: "DevOps course info service", + Framework: "net/http", + }, + System: SystemInfo{ + Hostname: hostname, + Platform: runtime.GOOS, + Architecture: runtime.GOARCH, + CPUCount: runtime.NumCPU(), + GoVersion: runtime.Version(), + }, + Runtime: RuntimeInfo{ + UptimeSeconds: uptimeSec, + UptimeHuman: uptimeHuman, + CurrentTime: time.Now().UTC().Format(time.RFC3339), + Timezone: "UTC", + }, + Request: RequestInfo{ + ClientIP: r.RemoteAddr, + UserAgent: r.UserAgent(), + Method: r.Method, + Path: r.URL.Path, + }, + Endpoints: []Endpoint{ + {Path: "/", Method: "GET", Description: "Service information"}, + {Path: "/health", Method: "GET", Description: "Health check"}, + }, + } + + log.Printf("Request received: %s %s", r.Method, r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + uptimeSec, _ := getUptime() + + response := HealthResponse{ + Status: "healthy", + Timestamp: time.Now().UTC().Format(time.RFC3339), + UptimeSeconds: uptimeSec, + } + + log.Printf("Health check requested") + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func notFoundHandler(w http.ResponseWriter, r *http.Request) { + log.Printf("404 error: %s", r.URL.Path) + + response := ErrorResponse{ + Error: "Not Found", + Message: "The requested endpoint does not exist", + Path: r.URL.Path, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// Program entry point +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + host := os.Getenv("HOST") + if host == "" { + host = "0.0.0.0" + } + + http.HandleFunc("/", mainHandler) + http.HandleFunc("/health", healthHandler) + + addr := fmt.Sprintf("%s:%s", host, port) + log.Printf("Starting DevOps Info Service on %s", addr) + log.Fatal(http.ListenAndServe(addr, nil)) +} diff --git a/app_go/main_test.go b/app_go/main_test.go new file mode 100644 index 0000000000..a3c1a481fd --- /dev/null +++ b/app_go/main_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMainHandler_ReturnsOK(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(mainHandler) + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("mainHandler returned wrong status code: got %v want %v", status, http.StatusOK) + } +} + +func TestMainHandler_ReturnsJSON(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(mainHandler) + handler.ServeHTTP(rr, req) + + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("mainHandler returned wrong content type: got %v want %v", contentType, "application/json") + } +} + +func TestMainHandler_ContainsServiceInfo(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(mainHandler) + handler.ServeHTTP(rr, req) + + var response MainResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Errorf("failed to decode response body: %v", err) + } + + if response.Service.Name != "devops-info-service" { + t.Errorf("unexpected service name: got %v want %v", response.Service.Name, "devops-info-service") + } + + if response.Service.Framework != "net/http" { + t.Errorf("unexpected framework: got %v want %v", response.Service.Framework, "net/http") + } +} + +func TestMainHandler_ContainsSystemInfo(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(mainHandler) + handler.ServeHTTP(rr, req) + + var response MainResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Errorf("failed to decode response body: %v", err) + } + + if response.System.Hostname == "" { + t.Error("system hostname should not be empty") + } + + if response.System.CPUCount <= 0 { + t.Errorf("cpu count should be positive: got %v", response.System.CPUCount) + } +} + +func TestMainHandler_ContainsRuntimeInfo(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(mainHandler) + handler.ServeHTTP(rr, req) + + var response MainResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Errorf("failed to decode response body: %v", err) + } + + if response.Runtime.Timezone != "UTC" { + t.Errorf("unexpected timezone: got %v want %v", response.Runtime.Timezone, "UTC") + } + + if response.Runtime.UptimeSeconds < 0 { + t.Errorf("uptime should be non-negative: got %v", response.Runtime.UptimeSeconds) + } +} + +func TestMainHandler_ContainsEndpoints(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(mainHandler) + handler.ServeHTTP(rr, req) + + var response MainResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Errorf("failed to decode response body: %v", err) + } + + if len(response.Endpoints) < 2 { + t.Errorf("expected at least 2 endpoints, got %v", len(response.Endpoints)) + } +} + +func TestHealthHandler_ReturnsOK(t *testing.T) { + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(healthHandler) + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("healthHandler returned wrong status code: got %v want %v", status, http.StatusOK) + } +} + +func TestHealthHandler_ReturnsJSON(t *testing.T) { + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(healthHandler) + handler.ServeHTTP(rr, req) + + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("healthHandler returned wrong content type: got %v want %v", contentType, "application/json") + } +} + +func TestHealthHandler_StatusIsHealthy(t *testing.T) { + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(healthHandler) + handler.ServeHTTP(rr, req) + + var response HealthResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Errorf("failed to decode response body: %v", err) + } + + if response.Status != "healthy" { + t.Errorf("unexpected health status: got %v want %v", response.Status, "healthy") + } +} + +func TestHealthHandler_UptimeIsNonNegative(t *testing.T) { + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(healthHandler) + handler.ServeHTTP(rr, req) + + var response HealthResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Errorf("failed to decode response body: %v", err) + } + + if response.UptimeSeconds < 0 { + t.Errorf("uptime should be non-negative: got %v", response.UptimeSeconds) + } +} + +func TestMainHandler_Returns404ForInvalidPath(t *testing.T) { + req, err := http.NewRequest("GET", "/nonexistent", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(mainHandler) + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusNotFound { + t.Errorf("mainHandler returned wrong status code for invalid path: got %v want %v", status, http.StatusNotFound) + } +} + +func TestNotFoundHandler_ReturnsJSON(t *testing.T) { + req, err := http.NewRequest("GET", "/nonexistent", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(notFoundHandler) + handler.ServeHTTP(rr, req) + + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("notFoundHandler returned wrong content type: got %v want %v", contentType, "application/json") + } +} + +func TestNotFoundHandler_ContainsErrorInfo(t *testing.T) { + req, err := http.NewRequest("GET", "/nonexistent", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(notFoundHandler) + handler.ServeHTTP(rr, req) + + var response ErrorResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Errorf("failed to decode response body: %v", err) + } + + if response.Error != "Not Found" { + t.Errorf("unexpected error message: got %v want %v", response.Error, "Not Found") + } + + if response.Path != "/nonexistent" { + t.Errorf("unexpected path in error response: got %v want %v", response.Path, "/nonexistent") + } +} + +func TestGetUptime_ReturnsNonNegativeSeconds(t *testing.T) { + seconds, human := getUptime() + + if seconds < 0 { + t.Errorf("uptime seconds should be non-negative: got %v", seconds) + } + + if human == "" { + t.Error("uptime human string should not be empty") + } +} diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..88361adbd0 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,25 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +venv/ +.venv/ +ENV/ +.git/ +.gitignore +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db +*.log +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ +docs/ +tests/ +README.md diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..f9d11c910a --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,26 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so + +venv/ +.venv/ +ENV/ + +.vscode/ +.idea/ +*.swp +*.swo + +.DS_Store +Thumbs.db + +*.log + +.pytest_cache/ +.coverage +htmlcov/ + +dist/ +build/ +*.egg-info/ diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..dc69192a26 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +WORKDIR /app + +RUN useradd --create-home --shell /bin/bash appuser + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 5173 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..c3e0dcc63b --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,178 @@ +![Python CI](https://github.com/mashfeii/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg) +![Coverage](https://codecov.io/gh/mashfeii/DevOps-Core-Course/branch/master/graph/badge.svg) + +# devops info service + +a python web service that provides detailed information about itself and its runtime environment + +## overview + +this service exposes two endpoints that return json data about the system, service metadata, and health status + +## prerequisites + +- python 3.11 or higher +- pip package manager + +## installation + +```bash +# create virtual environment +python -m venv venv + +# activate virtual environment +source venv/bin/activate # linux/macos +# or +venv\Scripts\activate # windows + +# install dependencies +pip install -r requirements.txt +``` + +## running the application + +```bash +# default configuration (0.0.0.0:5000) +python app.py + +# custom port +PORT=8080 python app.py + +# custom host and port +HOST=127.0.0.1 PORT=3000 python app.py + +# enable debug mode +DEBUG=true python app.py +``` + +## api endpoints + +### get / + +returns comprehensive service and system information + +**response example:** + +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": { + "hostname": "my-laptop", + "platform": "Darwin", + "platform_version": "Darwin-24.6.0-arm64", + "architecture": "arm64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 3600, + "uptime_human": "1 hours, 0 minutes", + "current_time": "2026-01-27T14:30:00.000000+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.1.2", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { "path": "/", "method": "GET", "description": "Service information" }, + { "path": "/health", "method": "GET", "description": "Health check" } + ] +} +``` + +### get /health + +returns service health status for monitoring + +**response example:** + +```json +{ + "status": "healthy", + "timestamp": "2026-01-27T14:30:00.000000+00:00", + "uptime_seconds": 3600 +} +``` + +## configuration + +| variable | default | description | +| -------- | ------- | ------------------- | +| HOST | 0.0.0.0 | server bind address | +| PORT | 5000 | server port | +| DEBUG | false | enable debug mode | + +## testing + +### unit tests + +```bash +# install dev dependencies +pip install -r requirements-dev.txt + +# run tests +pytest + +# run tests with verbose output +pytest -v + +# run tests with coverage +pytest --cov=. --cov-report=term +``` + +### test structure + +``` +tests/ +├── __init__.py # test package marker +├── conftest.py # pytest fixtures (test client) +└── test_app.py # unit tests for all endpoints +``` + +### what's tested + +- `GET /` endpoint: response structure, data types, required fields +- `GET /health` endpoint: status code, response format, health status +- error handlers: 404 responses with correct format + +### manual testing + +```bash +# test main endpoint +curl http://localhost:5000/ + +# test health endpoint +curl http://localhost:5000/health + +# pretty print json output +curl http://localhost:5000/ | python -m json.tool +``` + +## docker + +### building the image + +```bash +docker build -t devops-info-service . +``` + +### running the container + +```bash +docker run -p 5173:5173 devops-info-service +``` + +### pulling from docker hub + +```bash +docker pull mashfeii/devops-info-service:latest +docker run -p 5173:5173 mashfeii/devops-info-service:latest +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..ee7ec8cd3d --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,126 @@ +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +from flask import Flask, jsonify, request + +app = Flask(__name__) + +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5173)) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + +START_TIME = datetime.now(timezone.utc) + +logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + + +def get_system_info(): + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.platform(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count(), + 'python_version': platform.python_version() + } + + +def get_service_info(): + return { + 'name': 'devops-info-service', + 'version': '1.0.0', + 'description': 'DevOps course info service', + 'framework': 'Flask' + } + + +def get_request_info(): + return { + 'client_ip': request.remote_addr, + 'user_agent': request.headers.get('User-Agent', 'Unknown'), + 'method': request.method, + 'path': request.path + } + + +def get_endpoints_list(): + return [ + {'path': '/', 'method': 'GET', 'description': 'Service information'}, + {'path': '/health', 'method': 'GET', 'description': 'Health check'} + ] + + +@app.route('/') +def index(): + logger.info(f"Request received: {request.method} {request.path}") + + uptime = get_uptime() + + response = { + 'service': get_service_info(), + 'system': get_system_info(), + 'runtime': { + 'uptime_seconds': uptime['seconds'], + 'uptime_human': uptime['human'], + 'current_time': datetime.now(timezone.utc).isoformat(), + 'timezone': 'UTC' + }, + 'request': get_request_info(), + 'endpoints': get_endpoints_list() + } + + return jsonify(response) + + +@app.route('/health') +def health(): + logger.debug("Health check requested") + + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + }) + + +@app.errorhandler(404) +def not_found(error): + logger.warning(f"404 error: {request.path}") + return jsonify({ + 'error': 'Not Found', + 'message': 'The requested endpoint does not exist', + 'path': request.path + }), 404 + + +@app.errorhandler(500) +def internal_error(error): + logger.error(f"500 error: {str(error)}") + return jsonify({ + 'error': 'Internal Server Error', + 'message': 'An unexpected error occurred' + }), 500 + + +if __name__ == '__main__': + logger.info(f"Starting DevOps Info Service on {HOST}:{PORT}") + logger.info(f"Debug mode: {DEBUG}") + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..f030e7b7d4 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,154 @@ +# lab 01 submission + +## framework selection + +### chosen framework: flask 3.1 + +i selected flask for this lab for the following reasons: + +| criteria | flask | fastapi | django | +|----------|-------|---------|--------| +| complexity | low | medium | high | +| learning curve | gentle | moderate | steep | +| setup time | minutes | minutes | longer | +| json api support | built-in jsonify | native | requires drf | +| async support | optional | native | optional | + +**decision rationale:** +- flask provides the simplest path to a working json api +- the built-in development server eliminates extra dependencies +- excellent documentation and community support +- matches the example code provided in lab instructions + +## best practices applied + +### 1 pep 8 compliance + +organized imports in three groups: standard library, third-party, local + +```python +import logging +import os +import platform + +from flask import Flask, jsonify, request +``` + +### 2 error handling + +implemented custom error handlers for 404 and 500 responses + +```python +@app.errorhandler(404) +def not_found(error): + return jsonify({ + 'error': 'Not Found', + 'message': 'The requested endpoint does not exist' + }), 404 +``` + +### 3 logging configuration + +configured structured logging with timestamps and log levels + +```python +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +``` + +### 4 environment variables + +made all configuration externally configurable + +```python +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +``` + +## api documentation + +### main endpoint + +**request:** +```bash +curl http://localhost:5000/ +``` + +**response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": { + "hostname": "macbook", + "platform": "Darwin", + "platform_version": "Darwin-24.6.0-arm64", + "architecture": "arm64", + "cpu_count": 8, + "python_version": "3.13.1" + }, + "runtime": { + "uptime_seconds": 120, + "uptime_human": "0 hours, 2 minutes", + "current_time": "2026-01-27T14:30:00.000000+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.1.2", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### health endpoint + +**request:** +```bash +curl http://localhost:5000/health +``` + +**response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-27T14:30:00.000000+00:00", + "uptime_seconds": 120 +} +``` + +## testing evidence + +![[fullscreen with two terminals: left running flask app on port 5173, right showing curl responses for main and health endpoints]](screenshots/python-implementation.png) + +## challenges and solutions + +### challenge 1: timezone handling + +**problem:** datetime objects were being serialized without timezone information + +**solution:** used `datetime.now(timezone.utc)` instead of `datetime.utcnow()` to ensure proper iso format with timezone + +### challenge 2: uptime calculation accuracy + +**problem:** needed consistent uptime across multiple endpoint calls + +**solution:** stored `START_TIME` as a module-level constant at application startup and calculated delta on each request + +## github community + +starring repositories helps signal appreciation to maintainers and bookmark useful projects for future reference + +following developers on github enables learning from their contributions and staying connected with the professional community diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..aca894f57a --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,107 @@ +# Lab 02 - Docker Containerization + +## Docker best practices applied + +### non-root user + +- created dedicated appuser with useradd +- switched to appuser before running application +- prevents container escape vulnerabilities and limits damage if app is compromised + +### specific base image version + +- used python:3.13-slim instead of python:latest +- ensures reproducible builds across environments +- slim variant reduces image size by ~100mb compared to full image + +### layer caching optimization + +- copied requirements.txt before application code +- pip install runs only when dependencies change +- application code changes dont invalidate dependency layer + +### dockerignore usage + +- excludes venv, pycache, docs, tests from build context +- reduces build time and context size +- prevents accidental inclusion of development artifacts + +### minimal file copying + +- only app.py and requirements.txt copied to final image +- no documentation or test files in production container + +## Image information and decisions + +### base image selection + +- chose python:3.13-slim over alpine because: + - better compatibility with pip packages + - includes necessary c libraries for common dependencies + - smaller than full python image (~150mb vs ~1gb) + - more stable than alpine for python workloads + +### final image size + +![[image size]](screenshots/python_size.png) + +### layer structure + +1. base image (python:3.13-slim) +2. workdir creation +3. user creation +4. requirements copy +5. pip install +6. app copy +7. ownership change +8. user switch + +## Build and run process + +### build output + +![[image build]](screenshots/python_build.png) + +### run output and endpoint testing + +![[run output and endpoint testing]](screenshots/run_curl_python.png) + +### docker hub + +- repository url: https://hub.docker.com/r/mashfeii/devops-info-service + ![[screenshot of docker hub repositories page]](screenshots/docker_hub.png) + +## Technical analysis + +### why layer order matters + +- docker caches each layer and reuses unchanged layers +- placing rarely-changing instructions first maximizes cache hits +- requirements.txt changes less often than app.py +- rebuilding after code change only reruns COPY app.py and later layers + +### security implications of non-root + +- root user in container has root-like privileges on host in some configurations +- non-root user limits blast radius of security vulnerabilities +- follows principle of least privilege +- required for many kubernetes security policies + +### dockerignore benefits + +- smaller build context means faster builds +- prevents secrets from accidentally being included +- reduces attack surface by excluding unnecessary files +- keeps image focused on runtime requirements only + +## Challenges and solutions + +### challenge 1: layer caching + +- initially copied all files at once, causing full rebuild on every change +- solution: split COPY into requirements.txt first, then app.py + +### challenge 2: permissions + +- app files owned by root after COPY +- solution: added chown command before switching to appuser diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..cb884de632 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,235 @@ +# Lab 03 - CI/CD with GitHub Actions + +## Overview + +### Testing Framework: pytest + +**Why pytest over unittest:** + +- **Simple syntax**: No boilerplate code required, plain `assert` statements work +- **Powerful fixtures**: Dependency injection pattern for test setup (e.g., Flask test client) +- **Plugin ecosystem**: pytest-cov for coverage, pytest-xdist for parallel execution +- **Better output**: Detailed failure messages with context +- **Wide adoption**: Industry standard for Python testing + +### Endpoints Tested + +| Endpoint | Tests Count | What's Validated | +| ------------- | ----------- | ---------------------------------------------------------------------------------------------- | +| `GET /` | 8 tests | Response structure, data types, required fields (service, system, runtime, request, endpoints) | +| `GET /health` | 5 tests | Status code, JSON format, health status, timestamp, uptime | +| 404 handler | 4 tests | Error response format, error message, path inclusion | + +**Total: 17 tests** covering all application functionality. + +### Workflow Triggers + +```yaml +on: + push: + branches: [main, master] + paths: ['app_python/**', '.github/workflows/python-ci.yml'] + pull_request: + branches: [main, master] + paths: ['app_python/**', '.github/workflows/python-ci.yml'] +``` + +**Rationale:** + +- **Push to main/master**: Deploys to production (builds and pushes Docker image) +- **Pull requests**: Validates changes before merge (runs tests, lint, security scan) +- **Path filters**: Only triggers when relevant files change (saves CI minutes, avoids unnecessary runs for docs-only changes) + +### Versioning Strategy: CalVer (YYYY.MM.DD) + +**Why CalVer over SemVer:** + +- This is a **continuously deployed service**, not a library with breaking changes +- Date-based versions clearly indicate **when** code was deployed +- No need for manual version management or conventional commits parsing +- Easy to track deployment timeline + +**Docker tags generated:** + +1. `2026.02.12` - CalVer date tag +2. `latest` - Always points to most recent build +3. `abc1234` - Short git SHA for exact code traceability + +--- + +## Workflow Evidence + +### 1. Successful Workflow Run + +![workflow-run](screenshots/workflow-run.png) + +**Link to workflow run:** `https://github.com/mashfeii/DevOps-Core-Course/actions/runs/22034786861` + +--- + +### 2. Tests Passing Locally + +![tests-passing](screenshots/tests-passing.png) + +--- + +### 3. Coverage Report + +![coverage-report](screenshots/coverage-report.png) + +**Coverage analysis:** + +- **app.py**: ~90% coverage +- **Not covered**: Lines 116-117, 124-126 (500 error handler, main block) +- **Why acceptable**: Main block only runs when executed directly, not during tests; 500 handler is difficult to trigger without mocking + +--- + +### 4. Docker Hub Images + +![docker-hub](screenshots/docker-hub-python.png) + +**Docker Hub URL:** https://hub.docker.com/r/mashfeii/devops-info-service + +--- + +### 5. Status Badge Working + +![status-badge](screenshots/status-badge.png) + +--- + +### 6. Codecov Dashboard + +![codecov-dashboard](screenshots/codecov-dashboard.png) + +--- + +### 7. Snyk Security Scan + +![snyk-scan](screenshots/snyk-scan.png) + +**Snyk results summary:** + +- Vulnerabilities found: [0 critical, 0 high, 0 medium, 26 low] + +--- + +## Best Practices Implemented + +| Practice | Implementation | Why It Helps | +| ------------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------------- | +| **Job Dependencies** | `needs: [test, security]` on build job | Prevents pushing broken images; ensures tests pass before deployment | +| **Dependency Caching** | `cache: 'pip'` in setup-python action | Reduces workflow time by ~45s; avoids re-downloading packages | +| **Docker Layer Caching** | `cache-from/to: type=gha` in build-push-action | Faster Docker builds by reusing unchanged layers | +| **Path Filters** | `paths: ['app_python/**']` | Saves CI minutes; only runs when relevant code changes | +| **Conditional Push** | `if: github.event_name == 'push' && github.ref == 'refs/heads/master'` | Only deploys on merge to master, not on PRs | +| **Fail Fast** | Default pytest/job behavior | Stops workflow on first failure, saves time | +| **Coverage Threshold** | `--cov-fail-under=70` | Enforces minimum test coverage; prevents regression | +| **Security Scanning** | Snyk integration with `severity-threshold=high` | Catches known vulnerabilities in dependencies early | + +## Key Decisions + +### Versioning: CalVer vs SemVer + +| Aspect | CalVer (Chosen) | SemVer | +| ---------- | ------------------------- | -------------------------------------- | +| Format | 2026.02.12 | v1.2.3 | +| Automation | Fully automated from date | Requires commit parsing or manual tags | +| Use case | Continuous deployment | Library releases with breaking changes | +| Clarity | When was it deployed? | What changed? | + +**Decision:** CalVer chosen because this is a service that deploys continuously, not a library where breaking changes need explicit versioning. + +### Docker Tags Strategy + +``` +mashfeii/devops-info-service:2026.02.12 # When was it built +mashfeii/devops-info-service:latest # Quick local testing +mashfeii/devops-info-service:abc1234 # Exact commit reference +``` + +**Rationale:** + +- **CalVer tag**: Primary production reference, clear deployment timeline +- **latest**: Convenience for local development, always points to newest +- **SHA tag**: Enables exact code traceability for debugging + +### Workflow Triggers + +| Trigger | Action | Why | +| -------------- | ---------------------------------------------- | ------------------------------------------ | +| Push to master | Full pipeline (test → security → build → push) | Deploy validated code | +| Pull request | Test + Security only | Validate before merge, no deploy | +| Path filter | Only app_python/\*\* | Don't run CI for docs or unrelated changes | + +### Test Coverage + +- **Current coverage:** ~96% +- **Threshold set:** 70% +- **What's covered:** All endpoints, response structures, error handlers +- **What's not covered:** Main execution block (`if __name__ == '__main__'`), logging statements +- **Acceptable because:** Main block is entry point only; logging is side effect + +## Challenges and Solutions + +### Challenge 1: Flask Test Client Context + +**Problem:** Tests failed initially because `request` object wasn't available outside request context. + +**Solution:** Used `app.test_client()` context manager which properly initializes request context for testing. + +```python +@pytest.fixture +def client(app): + return app.test_client() +``` + +### Challenge 2: Coverage File Path in CI + +**Problem:** Codecov couldn't find `coverage.xml` because working directory was `app_python/`. + +**Solution:** Specified relative path in codecov action: + +```yaml +files: app_python/coverage.xml +``` + +### Challenge 3: Path Filters Not Triggering + +**Problem:** Initially workflows weren't respecting path filters. + +**Solution:** Ensured workflow file itself is included in paths: + +```yaml +paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' # Important! +``` + +--- + +## Account Setup Guide + +### Snyk Setup + +1. Go to https://snyk.io and sign up with GitHub +2. Navigate to Account Settings → API Token +3. Copy the token +4. In GitHub repo: Settings → Secrets and variables → Actions → New repository secret +5. Name: `SNYK_TOKEN`, Value: [paste token] + +### Codecov Setup + +1. Go to https://codecov.io and sign up with GitHub +2. Add your repository +3. Go to Settings for your repo, copy the Upload Token +4. In GitHub repo: Settings → Secrets and variables → Actions → New repository secret +5. Name: `CODECOV_TOKEN`, Value: [paste token] + +### Docker Hub Token + +1. Go to https://hub.docker.com → Account Settings → Security +2. New Access Token, name it "github-actions" +3. Copy immediately (won't be shown again) +4. Add secrets: `DOCKERHUB_USERNAME`=mashfeii, `DOCKERHUB_TOKEN`=[token] diff --git a/app_python/docs/screenshots/codecov-dashboard.png b/app_python/docs/screenshots/codecov-dashboard.png new file mode 100644 index 0000000000..7d267f8b03 Binary files /dev/null and b/app_python/docs/screenshots/codecov-dashboard.png differ diff --git a/app_python/docs/screenshots/coverage-report.png b/app_python/docs/screenshots/coverage-report.png new file mode 100644 index 0000000000..82b4edcd3b Binary files /dev/null and b/app_python/docs/screenshots/coverage-report.png differ diff --git a/app_python/docs/screenshots/docker-hub-python.png b/app_python/docs/screenshots/docker-hub-python.png new file mode 100644 index 0000000000..7555b23322 Binary files /dev/null and b/app_python/docs/screenshots/docker-hub-python.png differ diff --git a/app_python/docs/screenshots/docker_hub.png b/app_python/docs/screenshots/docker_hub.png new file mode 100644 index 0000000000..e7d9d8db7f Binary files /dev/null and b/app_python/docs/screenshots/docker_hub.png differ diff --git a/app_python/docs/screenshots/docker_push.png b/app_python/docs/screenshots/docker_push.png new file mode 100644 index 0000000000..298e3c8fb5 Binary files /dev/null and b/app_python/docs/screenshots/docker_push.png differ diff --git a/app_python/docs/screenshots/python-implementation.png b/app_python/docs/screenshots/python-implementation.png new file mode 100644 index 0000000000..302ccb7bf0 Binary files /dev/null and b/app_python/docs/screenshots/python-implementation.png differ diff --git a/app_python/docs/screenshots/python_build.png b/app_python/docs/screenshots/python_build.png new file mode 100644 index 0000000000..aac54d600e Binary files /dev/null and b/app_python/docs/screenshots/python_build.png differ diff --git a/app_python/docs/screenshots/python_size.png b/app_python/docs/screenshots/python_size.png new file mode 100644 index 0000000000..ed473bb079 Binary files /dev/null and b/app_python/docs/screenshots/python_size.png differ diff --git a/app_python/docs/screenshots/run_curl_python.png b/app_python/docs/screenshots/run_curl_python.png new file mode 100644 index 0000000000..094020d728 Binary files /dev/null and b/app_python/docs/screenshots/run_curl_python.png differ diff --git a/app_python/docs/screenshots/snyk-scan.png b/app_python/docs/screenshots/snyk-scan.png new file mode 100644 index 0000000000..fc6eb405b9 Binary files /dev/null and b/app_python/docs/screenshots/snyk-scan.png differ diff --git a/app_python/docs/screenshots/status-badge.png b/app_python/docs/screenshots/status-badge.png new file mode 100644 index 0000000000..33310d9c62 Binary files /dev/null and b/app_python/docs/screenshots/status-badge.png differ diff --git a/app_python/docs/screenshots/tests-passing.png b/app_python/docs/screenshots/tests-passing.png new file mode 100644 index 0000000000..83bd44872e Binary files /dev/null and b/app_python/docs/screenshots/tests-passing.png differ diff --git a/app_python/docs/screenshots/workflow-run.png b/app_python/docs/screenshots/workflow-run.png new file mode 100644 index 0000000000..40837d6716 Binary files /dev/null and b/app_python/docs/screenshots/workflow-run.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..b11e7bfa48 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=8.0.0 +pytest-cov>=4.1.0 +ruff>=0.4.0 diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..22ac75b399 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.0 diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..3d9930fa2e --- /dev/null +++ b/app_python/tests/__init__.py @@ -0,0 +1 @@ +"""test package for devops info service""" diff --git a/app_python/tests/conftest.py b/app_python/tests/conftest.py new file mode 100644 index 0000000000..846a2dc5c0 --- /dev/null +++ b/app_python/tests/conftest.py @@ -0,0 +1,26 @@ +"""Pytest configuration and fixtures for devops-info-service tests.""" + +import pytest + +from app import app as flask_app + + +@pytest.fixture +def app(): + """Create application for testing.""" + flask_app.config.update({ + 'TESTING': True, + }) + yield flask_app + + +@pytest.fixture +def client(app): + """Create a test client for the Flask application.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Create a test CLI runner.""" + return app.test_cli_runner() diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..adad841bdc --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,165 @@ +"""Unit tests for devops-info-service Flask application.""" + +import json + + +class TestIndexEndpoint: + """Tests for GET / endpoint.""" + + def test_index_returns_200(self, client): + """Test that index endpoint returns HTTP 200.""" + response = client.get('/') + assert response.status_code == 200 + + def test_index_returns_json(self, client): + """Test that index endpoint returns JSON content type.""" + response = client.get('/') + assert response.content_type == 'application/json' + + def test_index_contains_service_info(self, client): + """Test that response contains service information with all required fields.""" + response = client.get('/') + data = json.loads(response.data) + + assert 'service' in data + assert 'name' in data['service'] + assert 'version' in data['service'] + assert 'description' in data['service'] + assert 'framework' in data['service'] + + assert data['service']['name'] == 'devops-info-service' + assert data['service']['framework'] == 'Flask' + + def test_index_contains_system_info(self, client): + """Test that response contains system information with all required fields.""" + response = client.get('/') + data = json.loads(response.data) + + assert 'system' in data + assert 'hostname' in data['system'] + assert 'platform' in data['system'] + assert 'platform_version' in data['system'] + assert 'architecture' in data['system'] + assert 'cpu_count' in data['system'] + assert 'python_version' in data['system'] + + def test_index_contains_runtime_info(self, client): + """Test that response contains runtime information with all required fields.""" + response = client.get('/') + data = json.loads(response.data) + + assert 'runtime' in data + assert 'uptime_seconds' in data['runtime'] + assert 'uptime_human' in data['runtime'] + assert 'current_time' in data['runtime'] + assert 'timezone' in data['runtime'] + + assert data['runtime']['timezone'] == 'UTC' + + def test_index_contains_request_info(self, client): + """Test that response contains request information with all required fields.""" + response = client.get('/') + data = json.loads(response.data) + + assert 'request' in data + assert 'client_ip' in data['request'] + assert 'user_agent' in data['request'] + assert 'method' in data['request'] + assert 'path' in data['request'] + + assert data['request']['method'] == 'GET' + assert data['request']['path'] == '/' + + def test_index_contains_endpoints(self, client): + """Test that response contains endpoints list with at least 2 items.""" + response = client.get('/') + data = json.loads(response.data) + + assert 'endpoints' in data + assert isinstance(data['endpoints'], list) + assert len(data['endpoints']) >= 2 + + paths = [ep['path'] for ep in data['endpoints']] + assert '/' in paths + assert '/health' in paths + + def test_index_data_types(self, client): + """Test that response fields have correct data types.""" + response = client.get('/') + data = json.loads(response.data) + + assert isinstance(data['runtime']['uptime_seconds'], int) + assert isinstance(data['system']['cpu_count'], int) + assert isinstance(data['service']['name'], str) + assert isinstance(data['system']['hostname'], str) + assert isinstance(data['runtime']['uptime_human'], str) + + +class TestHealthEndpoint: + """Tests for GET /health endpoint.""" + + def test_health_returns_200(self, client): + """Test that health endpoint returns HTTP 200.""" + response = client.get('/health') + assert response.status_code == 200 + + def test_health_returns_json(self, client): + """Test that health endpoint returns JSON content type.""" + response = client.get('/health') + assert response.content_type == 'application/json' + + def test_health_contains_required_fields(self, client): + """Test that health response contains all required fields.""" + response = client.get('/health') + data = json.loads(response.data) + + assert 'status' in data + assert 'timestamp' in data + assert 'uptime_seconds' in data + + def test_health_status_is_healthy(self, client): + """Test that health status is 'healthy'.""" + response = client.get('/health') + data = json.loads(response.data) + + assert data['status'] == 'healthy' + + def test_health_uptime_is_non_negative_integer(self, client): + """Test that uptime_seconds is a non-negative integer.""" + response = client.get('/health') + data = json.loads(response.data) + + assert isinstance(data['uptime_seconds'], int) + assert data['uptime_seconds'] >= 0 + + +class TestErrorHandlers: + """Tests for error handlers.""" + + def test_404_returns_not_found(self, client): + """Test that non-existent endpoint returns 404.""" + response = client.get('/nonexistent') + assert response.status_code == 404 + + def test_404_returns_json(self, client): + """Test that 404 response is JSON.""" + response = client.get('/nonexistent') + assert response.content_type == 'application/json' + + def test_404_contains_error_info(self, client): + """Test that 404 response contains error information.""" + response = client.get('/nonexistent') + data = json.loads(response.data) + + assert 'error' in data + assert 'message' in data + assert 'path' in data + + assert data['error'] == 'Not Found' + + def test_404_includes_requested_path(self, client): + """Test that 404 response includes the requested path.""" + response = client.get('/some/invalid/path') + data = json.loads(response.data) + + assert data['path'] == '/some/invalid/path'