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/app_go/.gitignore b/app_go/.gitignore index c3cc4aa6d9..b288c343b0 100644 --- a/app_go/.gitignore +++ b/app_go/.gitignore @@ -5,3 +5,7 @@ devops-info-service .idea/ .DS_Store + +# Test coverage +coverage.out +coverage.html diff --git a/app_go/README.md b/app_go/README.md index a47c5edaac..53b64ede27 100644 --- a/app_go/README.md +++ b/app_go/README.md @@ -1,3 +1,6 @@ +![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 @@ -99,6 +102,29 @@ returns service health status for monitoring ## 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/ 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/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-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/main.go b/app_go/main.go index 42f8984faf..099b080c16 100644 --- a/app_go/main.go +++ b/app_go/main.go @@ -118,7 +118,10 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Request received: %s %s", r.Method, r.URL.Path) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + 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) { @@ -133,7 +136,10 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Health check requested") w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + 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) { @@ -147,9 +153,13 @@ func notFoundHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(response) + 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 == "" { 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/README.md b/app_python/README.md index 04e7f34631..c3e0dcc63b 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,3 +1,6 @@ +![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 @@ -109,6 +112,39 @@ returns service health status for monitoring ## 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/ 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/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/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'