diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..834ef8fbc0 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,103 @@ +name: Python CI (tests + docker) + +on: + push: + branches: [ "master", "lab03" ] + 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 + +jobs: + test-and-lint: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install dependencies + working-directory: app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint (ruff) + working-directory: app_python + run: | + ruff check . + + - name: Run tests (pytest) + working-directory: app_python + run: | + pytest -q + + - 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: Set version (CalVer) + run: | + echo "VERSION=$(date -u +%Y.%m.%d)" >> $GITHUB_ENV + + - name: Log in 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: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/app_python/README.md b/app_python/README.md index 9739efcea7..0d4c87ae27 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,3 +1,5 @@ +[![Python CI (tests + docker)](https://github.com/Cdeth567/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03)](https://github.com/Cdeth567/DevOps-Core-Course/actions/workflows/python-ci.yml) + # DevOps Info Service ## Overview @@ -130,3 +132,11 @@ docker run --rm -p :5000 /: > Note (Windows PowerShell): `curl` is an alias for `Invoke-WebRequest`. > For classic curl behavior, use `curl.exe`. + +## Testing +Install dev dependencies: +- python -m pip install -r requirements-dev.txt + +Run tests: +- pytest + diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..eeab592380 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,299 @@ +# LAB03 — Continuous Integration (CI/CD) + +Repository: `Cdeth567/DevOps-Core-Course` +Branch: `lab03` +App: `app_python` (DevOps Info Service) + +--- + +## 1. Overview + +### 1.1 Testing framework choice +**Framework:** `pytest 8.x` + +**Why pytest:** +- Minimal and readable test syntax (simple `assert` statements) +- Great ecosystem and easy CI integration +- Widely used industry standard for Python services + +**Dev dependencies file:** `app_python/requirements-dev.txt` (contains `pytest==8.3.4` and `ruff==0.9.6`). + +--- + +### 1.2 What tests cover +Tests are located in: `app_python/tests/test_app.py` + +Covered behavior: +- **`GET /`** + - Returns HTTP 200 + - Returns JSON with expected structure/fields +- **`GET /health`** + - Returns HTTP 200 + - Returns JSON with `"status": "healthy"` and expected keys +- Includes multiple assertions → not just "smoke tests" + +--- + +### 1.3 CI workflow trigger configuration +Workflow file: `.github/workflows/python-ci.yml` +Workflow name: **Python CI (tests + docker)** + +Triggers: +- **Push** to branches: `master`, `lab03` +- **PRs** targeting `master` +- **Path filters**: runs only if something changed in: + - `app_python/**` + - `.github/workflows/python-ci.yml` + +Why this matters: +- In monorepos, path filters prevent wasting CI minutes when unrelated files change. +- PR checks still run for code changes that matter. + +--- + +### 1.4 Versioning strategy (Docker images) +**Chosen strategy:** **CalVer** (Calendar Versioning) + +Implementation: +- CI generates version on build: `YYYY.MM.DD` (UTC time) + +Docker tags produced by CI: +- `cdeth567/devops-info-service:` +- `cdeth567/devops-info-service:latest` + +Why CalVer is a good fit here: +- This is a service with frequent small changes. +- It’s easy to understand which build is “today’s”. +- No need to manually manage SemVer releases for a lab service. + +--- + +## 2. Workflow Evidence + +### 2.1 Local installation & test evidence (terminal output) + +Install dev deps: +```text +py -m pip install -r requirements-dev.txt +Successfully installed ... pytest-8.3.4 +``` + +Install runtime deps: +```text +py -m pip install -r requirements.txt +Successfully installed Flask-3.1.0 ... Werkzeug-3.1.5 ... +``` + +Run tests (note about Windows PATH): +```text +pytest : The term 'pytest' is not recognized ... +py -m pytest -q +.... [100%] +4 passed in 0.37s +``` + +**Explanation:** `pytest.exe` was installed into Python Scripts directory not included in PATH. Running `py -m pytest` executes pytest as a module and works reliably on Windows. + +--- + +### 2.2 Linting evidence (ruff) + +After installing `ruff` to dev requirements: +```text +py -m pip install -r requirements-dev.txt +Successfully installed ruff-0.9.6 +``` + +Lint run (correct working directory): +```text +py -m ruff check . +All checks passed! +``` + +Note: The earlier error: +```text +py -m ruff check app_python +app_python:1:1: E902 ... file not found +``` +happened because the command was run inside the `app_python/` directory; there is no nested `app_python/app_python` path. Fix was to lint `.`. + +--- + +### 2.3 CI workflow success evidence +GitHub Actions page shows successful runs on `lab03` (green check). +Badge is green for `lab03` branch. + +Workflow URL (Actions): +- `https://github.com/Cdeth567/DevOps-Core-Course/actions/workflows/python-ci.yml` + +Status badge in `app_python/README.md`: +```markdown +[![Python CI (tests + docker)](https://github.com/Cdeth567/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03)](https://github.com/Cdeth567/DevOps-Core-Course/actions/workflows/python-ci.yml) +``` + +--- + +### 2.4 Docker Hub evidence +Docker Hub repository: +- `https://hub.docker.com/r/cdeth567/devops-info-service` + +CI pushes images with two tags: +- daily CalVer tag (e.g., `2026.02.11` format) +- `latest` + +--- + +## 3. Best Practices Implemented (CI + Security) + +### 3.1 Dependency caching (pip) +Implemented using `actions/setup-python@v5` built-in caching: +```yaml +with: + cache: "pip" + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt +``` + +Why it matters: +- Cache hits skip downloading packages again +- Faster workflows on repeated runs (especially after first successful run) + +How to measure: +- Compare “Install dependencies” step time on first run vs next run +- GitHub Actions logs will show whether cache was restored + +--- + +### 3.2 Matrix builds (multiple Python versions) +Tests run on **Python 3.12 and 3.13** via matrix: +```yaml +matrix: + python-version: ["3.12", "3.13"] +``` + +Why it matters: +- Detects version-specific problems early (compatibility across supported versions) +- Good practice for production Python services + +--- + +### 3.3 Fail-fast in matrix +Enabled: +```yaml +fail-fast: true +``` + +Why it matters: +- Stops wasting CI minutes once a matrix job fails +- Speeds feedback loop (you see failure sooner) + +--- + +### 3.4 Concurrency control +Implemented: +```yaml +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true +``` + +Why it matters: +- If you push many commits quickly, old runs are canceled +- Avoids queue buildup and wasted CI time + +--- + +### 3.5 Conditional Docker push (protect secrets + reduce risk) +Docker build/push runs only on **push** to `master` or `lab03`: +```yaml +if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03') +``` + +Why it matters: +- Prevents Docker pushes from PRs (especially forks) +- Helps avoid leaking secrets in untrusted contexts +- Standard CI/CD safety practice + +--- + +### 3.6 Snyk security scanning +Implemented using **Snyk CLI** in the runner environment: +```yaml +- 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 +``` + +Why it matters: +- Detects known vulnerable dependencies early in pipeline +- Gives visibility into supply-chain security risks + +Decision: +- `continue-on-error: true` used so CI doesn’t fully block while still reporting vulnerabilities (appropriate for a lab; in production you might fail builds on high/critical). + +--- + +## 4. Key Decisions + +### 4.1 Versioning strategy +**CalVer** used for Docker images (daily tags). +Rationale: simple automation, no manual release tagging required. + +### 4.2 Docker tags produced +- `/devops-info-service:` +- `/devops-info-service:latest` + +### 4.3 Workflow triggers +- Push/PR triggers with **path filters** ensure workflow runs only for Python app + workflow changes. + +### 4.4 Test coverage +- Endpoints `/` and `/health` are tested via Flask test client (no need to start a real server in CI). +- Coverage tool (pytest-cov) was **not added** in this submission (bonus task), but tests provide functional coverage for both endpoints. + +--- + +## 5. Challenges & Fixes + +### 5.1 `pytest` not recognized on Windows +**Problem:** `pytest` command not found because Python Scripts directory isn’t in PATH. +**Fix:** Use `py -m pytest` which runs pytest as a module. + +### 5.2 `ruff` not recognized / wrong path +**Problem 1:** `ruff` not found → it wasn’t installed yet. +**Fix:** Added `ruff==0.9.6` to `requirements-dev.txt`. + +**Problem 2:** `ruff check app_python` from inside `app_python/` caused file-not-found. +**Fix:** Run `py -m ruff check .` from the `app_python/` directory. + +### 5.3 `.github/workflows` location mistake +Initially workflow file was placed under `app_python/.github/workflows/`, which GitHub Actions does **not** detect. +Fix: moved workflow to repo root: `.github/workflows/python-ci.yml`. + +### 5.4 Snyk scanning issues +There were failures while adjusting working directories. +Final solution: run Snyk CLI and `cd app_python` before scanning requirements. + +--- + +## 6. Files Changed / Added (Summary) + +- `.github/workflows/python-ci.yml` — CI workflow (tests + lint + docker push + Snyk) +- `app_python/tests/test_app.py` — pytest unit tests +- `app_python/requirements-dev.txt` — dev dependencies (`pytest`, `ruff`) +- `app_python/README.md` — added CI status badge + testing instructions +- `app_python/docs/LAB03.md` — this documentation + +--- + +## Appendix — Workflow (reference) +Key jobs: +- `test-and-lint` (matrix: 3.12 + 3.13): install deps, ruff lint, pytest, Snyk scan +- `docker-build-and-push`: build + push to Docker Hub with CalVer + latest diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..148a627bdb --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest==8.3.4 +ruff==0.9.6 \ No newline at end of file diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..1cfc6be361 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,62 @@ +import pytest +from app import app as flask_app + + +@pytest.fixture() +def client(): + flask_app.config["TESTING"] = True + with flask_app.test_client() as client: + yield client + + +def test_root_endpoint_returns_200_and_json(client): + resp = client.get("/", headers={"User-Agent": "pytest"}) + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + + # top-level keys + for key in ["service", "system", "runtime", "request", "endpoints"]: + assert key in data + + # service structure + assert data["service"]["name"] == "devops-info-service" + assert data["service"]["framework"] == "Flask" + + # system structure + for key in ["hostname", "platform", "architecture", "cpu_count", "python_version"]: + assert key in data["system"] + + # runtime + assert "uptime_seconds" in data["runtime"] + assert isinstance(data["runtime"]["uptime_seconds"], int) + + # endpoints list + assert isinstance(data["endpoints"], list) + assert any(e["path"] == "/" for e in data["endpoints"]) + assert any(e["path"] == "/health" for e in data["endpoints"]) + + +def test_health_endpoint_returns_200_and_expected_fields(client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.get_json() + + assert data["status"] == "healthy" + assert "timestamp" in data + assert "uptime_seconds" in data + assert isinstance(data["uptime_seconds"], int) + + +def test_unknown_endpoint_returns_404_json(client): + resp = client.get("/no-such-endpoint") + assert resp.status_code == 404 + data = resp.get_json() + + assert data["error"] == "Not Found" + assert "message" in data + + +def test_method_not_allowed_returns_405(client): + resp = client.post("/health") + assert resp.status_code == 405