From e9a768650950d2b0cfe441e371480df74e13090c Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 20:55:19 +0300 Subject: [PATCH 1/8] feat: added main lab3 task and added gh ecrets --- .github/workflows/python-ci.yml | 108 +++++++++++++++++++++++++++++ app_python/requirements-dev.txt | 3 + app_python/tests/conftest.py | 12 ++++ app_python/tests/test_endpoints.py | 81 ++++++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 .github/workflows/python-ci.yml create mode 100644 app_python/requirements-dev.txt create mode 100644 app_python/tests/conftest.py create mode 100644 app_python/tests/test_endpoints.py diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..0ad8c5e5ef --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,108 @@ +name: python-ci + +on: + push: + branches: ["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: | + ruff check app_python + + - name: Tests (pytest) + coverage + run: | + pytest -q app_python/tests --cov=app_python --cov-report=term-missing --cov-report=xml + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + + - name: Snyk scan (dependencies) + uses: snyk/actions/python-3@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high --file=app_python/requirements.txt + + docker-build-and-push: + runs-on: ubuntu-latest + needs: test-and-lint + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + 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/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..46c6e18e80 --- /dev/null +++ b/app_python/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest + +from app_python.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..39c24f8927 --- /dev/null +++ b/app_python/tests/test_endpoints.py @@ -0,0 +1,81 @@ +import pytest + +import app_python.app as app_module +from app_python.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) + + # Top-level keys + for key in ("service", "system", "runtime", "request", "endpoints"): + assert key in data + + # Service block + service = data["service"] + assert service["name"] == "devops-info-service" + assert "version" in service + assert "framework" in service + + # Runtime block + runtime = data["runtime"] + assert runtime["timezone"] == "UTC" + assert isinstance(runtime["uptime_seconds"], int) + assert runtime["uptime_seconds"] >= 0 + assert isinstance(runtime["current_time"], str) + + # Request block + req = data["request"] + assert req["method"] == "GET" + assert req["path"] == "/" + assert isinstance(req["client_ip"], str) + assert isinstance(req["user_agent"], str) + + # Endpoints list contains expected items + endpoints = data["endpoints"] + assert any(e["path"] == "/" and e["method"] == "GET" for e in endpoints) + assert any(e["path"] == "/health" and e["method"] == "GET" for e in endpoints) + + +def test_health_ok(client): + resp = client.get("/health") + assert resp.status_code == 200 + + data = resp.get_json() + assert data["status"] == "healthy" + assert isinstance(data["timestamp"], str) + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + +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" + assert "Endpoint does not exist" in data["message"] + assert isinstance(data["timestamp"], str) + + +def test_500_error_shape(monkeypatch): + # In TESTING mode Flask often propagates exceptions instead of using error handlers, + # so we explicitly disable propagation to assert JSON error handler behavior. + 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" + assert "unexpected error" in data["message"].lower() + assert isinstance(data["timestamp"], str) From 6842685f64c557d9c67db737aee6e9a28dab77b6 Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 20:56:53 +0300 Subject: [PATCH 2/8] fix: added correct branches to ci work --- .github/workflows/python-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 0ad8c5e5ef..643ea55c48 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -2,7 +2,7 @@ name: python-ci on: push: - branches: ["master"] + branches: ["lab03", "master"] paths: - "app_python/**" - ".github/workflows/python-ci.yml" From aab12d7dd1f782c5887169910875888046f06a0b Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 20:58:27 +0300 Subject: [PATCH 3/8] fix: fixing snyk work --- .github/workflows/python-ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 643ea55c48..8f5d7e67ec 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -60,12 +60,14 @@ jobs: path: coverage.xml - name: Snyk scan (dependencies) - uses: snyk/actions/python-3@master + uses: snyk/actions/python@master continue-on-error: true env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: - args: --severity-threshold=high --file=app_python/requirements.txt + command: test + args: --severity-threshold=high --file=requirements.txt + working-directory: app_python docker-build-and-push: runs-on: ubuntu-latest From 56f81695241bf274176eda8cb0ccb04f56cf3ce0 Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 20:59:29 +0300 Subject: [PATCH 4/8] fix: fixing snyk work 2 --- .github/workflows/python-ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 8f5d7e67ec..689858d271 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -59,15 +59,16 @@ jobs: name: coverage-xml path: coverage.xml + - name: Install Snyk CLI + run: npm install -g snyk + - name: Snyk scan (dependencies) - uses: snyk/actions/python@master continue-on-error: true env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - with: - command: test - args: --severity-threshold=high --file=requirements.txt - working-directory: app_python + run: | + cd app_python + snyk test --severity-threshold=high --file=requirements.txt docker-build-and-push: runs-on: ubuntu-latest From 34ca629a148a3f363ad18260434f94a98cda5bd7 Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 21:01:07 +0300 Subject: [PATCH 5/8] fix: removing unused libraries from py files --- app_python/app.py | 2 +- app_python/tests/test_endpoints.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) 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/tests/test_endpoints.py b/app_python/tests/test_endpoints.py index 39c24f8927..d5614bad11 100644 --- a/app_python/tests/test_endpoints.py +++ b/app_python/tests/test_endpoints.py @@ -1,5 +1,3 @@ -import pytest - import app_python.app as app_module from app_python.app import app as flask_app From d715cc028ff5b9dd092d50dc651ab97047727069 Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 21:03:17 +0300 Subject: [PATCH 6/8] fix: fixing app_python folder missing in commands --- .github/workflows/python-ci.yml | 6 +++-- app_python/tests/conftest.py | 7 ++--- app_python/tests/test_endpoints.py | 43 ++---------------------------- 3 files changed, 8 insertions(+), 48 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 689858d271..fd25fecd8b 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -47,11 +47,13 @@ jobs: - name: Lint (ruff) run: | - ruff check app_python + cd app_python + ruff check . - name: Tests (pytest) + coverage run: | - pytest -q app_python/tests --cov=app_python --cov-report=term-missing --cov-report=xml + cd app_python + pytest -q tests --cov=. --cov-report=term-missing --cov-report=xml - name: Upload coverage artifact uses: actions/upload-artifact@v4 diff --git a/app_python/tests/conftest.py b/app_python/tests/conftest.py index 46c6e18e80..29d0202acc 100644 --- a/app_python/tests/conftest.py +++ b/app_python/tests/conftest.py @@ -1,12 +1,9 @@ import pytest - -from app_python.app import app as flask_app +from app import app as flask_app @pytest.fixture() def client(): - flask_app.config.update( - TESTING=True, - ) + 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 index d5614bad11..8a6e199c1d 100644 --- a/app_python/tests/test_endpoints.py +++ b/app_python/tests/test_endpoints.py @@ -1,68 +1,31 @@ -import app_python.app as app_module -from app_python.app import app as flask_app +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) - - # Top-level keys for key in ("service", "system", "runtime", "request", "endpoints"): assert key in data - # Service block - service = data["service"] - assert service["name"] == "devops-info-service" - assert "version" in service - assert "framework" in service - - # Runtime block - runtime = data["runtime"] - assert runtime["timezone"] == "UTC" - assert isinstance(runtime["uptime_seconds"], int) - assert runtime["uptime_seconds"] >= 0 - assert isinstance(runtime["current_time"], str) - - # Request block - req = data["request"] - assert req["method"] == "GET" - assert req["path"] == "/" - assert isinstance(req["client_ip"], str) - assert isinstance(req["user_agent"], str) - - # Endpoints list contains expected items - endpoints = data["endpoints"] - assert any(e["path"] == "/" and e["method"] == "GET" for e in endpoints) - assert any(e["path"] == "/health" and e["method"] == "GET" for e in endpoints) - def test_health_ok(client): resp = client.get("/health") assert resp.status_code == 200 - data = resp.get_json() assert data["status"] == "healthy" - assert isinstance(data["timestamp"], str) - assert isinstance(data["uptime_seconds"], int) - assert data["uptime_seconds"] >= 0 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" - assert "Endpoint does not exist" in data["message"] - assert isinstance(data["timestamp"], str) def test_500_error_shape(monkeypatch): - # In TESTING mode Flask often propagates exceptions instead of using error handlers, - # so we explicitly disable propagation to assert JSON error handler behavior. flask_app.config.update(TESTING=False, PROPAGATE_EXCEPTIONS=False) def boom(): @@ -75,5 +38,3 @@ def boom(): assert resp.status_code == 500 data = resp.get_json() assert data["error"] == "Internal Server Error" - assert "unexpected error" in data["message"].lower() - assert isinstance(data["timestamp"], str) From e99bb2c5f1b1d51552e9cc0c520071cf9e4e3d30 Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 21:08:05 +0300 Subject: [PATCH 7/8] fix: added coverage.xml and added lab03 branch to docker work --- .github/workflows/python-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index fd25fecd8b..3b096c2cd4 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -59,7 +59,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: coverage-xml - path: coverage.xml + path: app_python/coverage.xml - name: Install Snyk CLI run: npm install -g snyk @@ -75,7 +75,7 @@ jobs: docker-build-and-push: runs-on: ubuntu-latest needs: test-and-lint - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03') permissions: contents: read steps: From 21ce340cf51ed87541b298e16491ffe5a863bc2f Mon Sep 17 00:00:00 2001 From: dorley174 Date: Wed, 11 Feb 2026 21:23:11 +0300 Subject: [PATCH 8/8] feat: finalize lab work, added lab03.md, added ci badge to readme --- app_python/README.md | 61 +++++++++++++++++++++------------ app_python/docs/LAB03.md | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 21 deletions(-) create mode 100644 app_python/docs/LAB03.md 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/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)