diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..3b096c2cd4 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,113 @@ +name: python-ci + +on: + push: + branches: ["lab03", "master"] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + branches: ["master"] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + IMAGE_NAME: devops-info-service + APP_DIR: app_python + +jobs: + test-and-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: "pip" + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + pip install -r app_python/requirements-dev.txt + + - name: Lint (ruff) + run: | + cd app_python + ruff check . + + - name: Tests (pytest) + coverage + run: | + cd app_python + pytest -q tests --cov=. --cov-report=term-missing --cov-report=xml + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: app_python/coverage.xml + + - name: Install Snyk CLI + run: npm install -g snyk + + - name: Snyk scan (dependencies) + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + cd app_python + snyk test --severity-threshold=high --file=requirements.txt + + docker-build-and-push: + runs-on: ubuntu-latest + needs: test-and-lint + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03') + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare CalVer tags + run: | + echo "CALVER_MONTH=$(date -u +'%Y.%m')" >> $GITHUB_ENV + echo "CALVER_BUILD=$(date -u +'%Y.%m').${{ github.run_number }}" >> $GITHUB_ENV + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ${{ env.APP_DIR }} + file: ${{ env.APP_DIR }}/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.CALVER_BUILD }} + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.CALVER_MONTH }} + ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/app_python/README.md b/app_python/README.md index e435234a1b..48cffa6caa 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,5 +1,7 @@ # DevOps Info Service +[![python-ci](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml) + ## Overview DevOps Info Service is a production-ready starter web service for the DevOps course. It reports service metadata, runtime details, and basic system information. @@ -16,14 +18,14 @@ The service exposes two endpoints: ## Installation -``` +```bash python -m venv venv -source venv/bin/activate +# Windows: .\venv\Scripts\activate +# Linux/macOS: source venv/bin/activate + pip install -r requirements.txt ``` - - ## Running the Application ### Default run (port 5000) @@ -31,7 +33,7 @@ pip install -r requirements.txt ```bash python app.py ``` -I will also test IP *192.168.31.32:5000* + ### Custom configuration **Linux/Mac:** @@ -81,31 +83,48 @@ curl -i http://127.0.0.1:5000/health curl -s http://127.0.0.1:5000/ | python -m json.tool ``` -## Configuration +## Testing & Linting (LAB03) -| Variable | Default | Description | -|----------|---------|-------------| -| HOST | 0.0.0.0 | Bind address | -| PORT | 5000 | HTTP port | -| DEBUG | False | Flask debug mode | +> Dev dependencies live in `requirements-dev.txt` (pytest, coverage, ruff). ----- +Install dev deps: +```bash +pip install -r requirements-dev.txt +``` -## Run locally +Run linter: +```bash +ruff check . +``` +Run tests + coverage: ```bash -pip install -r requirements.txt -python app.py +pytest -q tests --cov=. --cov-report=term-missing ``` -By default the service listens on `0.0.0.0:5000`. +## CI/CD Secrets (GitHub Actions) -- `GET /` — service + system + runtime + request info -- `GET /health` — health check +In your GitHub repository: +**Settings → Secrets and variables → Actions → New repository secret** + +Add: +- `DOCKERHUB_USERNAME` — your Docker Hub username +- `DOCKERHUB_TOKEN` — Docker Hub Access Token (Account Settings → Security) +- `SNYK_TOKEN` — Snyk API token (Account settings → API token) + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| HOST | 0.0.0.0 | Bind address | +| PORT | 5000 | HTTP port | +| DEBUG | False | Flask debug mode | + +--- ## Docker -> Patterns below use placeholders like `` and ``. +> Examples below use placeholders like `` and ``. ### Build (local) @@ -119,7 +138,7 @@ docker build -t : . docker run --rm -p 5000:5000 : ``` -(Optionally override env vars) +(Optional: override env vars) ```bash docker run --rm -p 5000:5000 -e PORT=5000 -e DEBUG=false : @@ -137,4 +156,4 @@ docker run --rm -p 5000:5000 /: ```bash curl http://localhost:5000/health curl http://localhost:5000/ -``` \ No newline at end of file +``` diff --git a/app_python/app.py b/app_python/app.py index f84815d96b..931e2ee42b 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -14,7 +14,7 @@ import platform import socket from datetime import datetime, timezone -from typing import Any, Dict, Optional +from typing import Any, Dict from flask import Flask, jsonify, request diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..cd7976fa9b --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,74 @@ +# LAB03 — CI/CD (GitHub Actions) + +## 1) Overview + +### Testing framework +This project uses **pytest** because it provides: +- clean assertions and fixtures +- Flask test client without running a live server +- easy coverage reporting via `pytest-cov` + +### Test coverage (what is tested) +- `GET /` — validates the JSON structure and required fields +- `GET /health` — validates the health-check response +- `GET /does-not-exist` — validates the JSON 404 error handler +- 500-case — forces an internal error and validates the JSON 500 error handler + +### CI triggers +The workflow runs on `push` and `pull_request` for the selected branches, **only when** these paths change: +- `app_python/**` +- `.github/workflows/python-ci.yml` + +### Versioning strategy +We use **CalVer** for Docker image tags: +- monthly tag: `YYYY.MM` (e.g., `2026.02`) +- build tag: `YYYY.MM.` (e.g., `2026.02.31`) +- plus `latest` + +This makes it easy to see *when* an image was built and to find the most recent build. + +--- + +## 2) How to run locally + +From the `app_python` directory: + +```bash +python -m venv .venv +# Windows: .\.venv\Scripts\activate +# Linux/macOS: source .venv/bin/activate + +pip install -r requirements.txt +pip install -r requirements-dev.txt + +ruff check . +pytest -q tests --cov=. --cov-report=term-missing +``` + +--- + +## 3) Workflow evidence (paste links here) + +Add links to prove the pipeline works: + +- ✅ Successful workflow run: [https://github.com/dorley174/DevOps-Core-Course/actions/runs/21917089826/job/63287177794](https://github.com/dorley174/DevOps-Core-Course/actions/runs/21917089826/job/63287177794) +- ✅ Docker Hub image/repo: [https://hub.docker.com/repository/docker/dorley174/devops-info-service/general](https://hub.docker.com/repository/docker/dorley174/devops-info-service/general) +- ✅ Status badge in README: see `README.md` +or go +[![python-ci](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/dorley174/DevOps-Core-Course/actions/workflows/python-ci.yml) + +--- + +## 4) Best practices implemented + +- **Path filters**: CI does not run if changes are outside `app_python/**` +- **Job dependency**: Docker push runs only if lint + tests succeeded +- **Concurrency**: cancels outdated runs on the same branch +- **Least privileges**: `permissions: contents: read` +- **Caching**: + - pip cache via `actions/setup-python` + - Docker layer cache via `cache-to/cache-from type=gha` +- **Snyk scan**: + - scans dependencies from `requirements.txt` + - severity threshold = high + - `continue-on-error: true` (learning mode; does not block pipeline) diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..3365ea3398 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==8.3.4 +pytest-cov==6.0.0 +ruff==0.9.7 \ No newline at end of file diff --git a/app_python/tests/conftest.py b/app_python/tests/conftest.py new file mode 100644 index 0000000000..29d0202acc --- /dev/null +++ b/app_python/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from app import app as flask_app + + +@pytest.fixture() +def client(): + flask_app.config.update(TESTING=True) + with flask_app.test_client() as client: + yield client diff --git a/app_python/tests/test_endpoints.py b/app_python/tests/test_endpoints.py new file mode 100644 index 0000000000..8a6e199c1d --- /dev/null +++ b/app_python/tests/test_endpoints.py @@ -0,0 +1,40 @@ +import app as app_module +from app import app as flask_app + + +def test_root_ok_structure(client): + resp = client.get("/", headers={"User-Agent": "pytest"}) + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + for key in ("service", "system", "runtime", "request", "endpoints"): + assert key in data + + +def test_health_ok(client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "healthy" + + +def test_404_error_shape(client): + resp = client.get("/does-not-exist") + assert resp.status_code == 404 + data = resp.get_json() + assert data["error"] == "Not Found" + + +def test_500_error_shape(monkeypatch): + flask_app.config.update(TESTING=False, PROPAGATE_EXCEPTIONS=False) + + def boom(): + raise RuntimeError("boom") + + monkeypatch.setattr(app_module, "get_system_info", boom) + + with flask_app.test_client() as client: + resp = client.get("/") + assert resp.status_code == 500 + data = resp.get_json() + assert data["error"] == "Internal Server Error"