diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..035dd65d07 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,90 @@ +name: Python CI + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app_python + + steps: + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v5 + with: + cache: 'pip' + python-version: '3.12' + architecture: 'x64' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Lint flake8 + run: | + flake8 app.py tests --count --max-complexity=15 --max-line-length=127 --statistics + - name: Testing with pytest + run: | + pytest tests/ --doctest-modules --junitxml=junit/test-results.xml + + push_to_docker: + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + defaults: + run: + working-directory: ./app_python + + steps: + - name: Check out the repo + uses: actions/checkout@v5 + + - name: Log in to Docker Hub + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: bulatgazizov/python_app + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + security: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Install Snyk CLI + run: | + curl https://downloads.snyk.io/cli/stable/snyk-linux -o snyk-linux + curl https://downloads.snyk.io/cli/stable/snyk-linux.sha256 -o snyk.sha256 + sha256sum -c snyk.sha256 + chmod +x snyk-linux + sudo mv snyk-linux /usr/local/bin/snyk + - name: Run Snyk to test project dependencies + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: | + cd app_python + pip install -r requirements.txt --break-system-packages + snyk test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 30d74d2584..5a92b74c66 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test \ No newline at end of file +test +.secrets diff --git a/README.md b/README.md index 371d51f456..9915cb0adb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # DevOps Engineering: Core Practices +![Workflow](https://github.com/BulatGazizov-dev/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg) + [![Labs](https://img.shields.io/badge/Labs-18-blue)](#labs) [![Exam](https://img.shields.io/badge/Exam-Optional-green)](#exam-alternative) [![Duration](https://img.shields.io/badge/Duration-18%20Weeks-lightgrey)](#course-roadmap) diff --git a/app_python/app.py b/app_python/app.py index 8d6eb47e22..532bd6786a 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -1,5 +1,3 @@ -from typing import Union - from fastapi import FastAPI, Request from fastapi.responses import JSONResponse import os diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..9505d19ba0 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,76 @@ +# Lab 03 + +## 1. Overview + +### Testing Framework + +* **Framework Chosen:** pytest +* **Rationale:** pytest was chosen for its simple syntax, powerful fixtures, and native integration with many CI tools. +* **Coverage:** My tests cover the following: +* `GET /`: Validates JSON structure, status code 200, and required fields. +* `GET /health`: Verifies the health check response and uptime increments +* `GET /unknown`: Verifies 404 response + + +### CI Workflow Configuration + +* **Trigger Strategy:** The workflow is triggered on: +* `push` to the `main` branch. + + +* **Versioning Strategy:** SemVer +* **Rationale:** SemVer was chosen because it clearly communicates breaking changes to users of the Docker image. + +--- + +## 2. Workflow Evidence + +| Requirement | Evidence/Link | +| --- | --- | +| **Successful Workflow Run** | [Link to GitHub Actions Run](https://github.com/BulatGazizov-dev/DevOps-Core-Course/actions/runs/21883844379) | +| **Docker Hub Image** | [Link to Docker Hub Repository](https://hub.docker.com/r/bulatgazizov/python_app) | + +### Local Terminal Output + +```text +================================================================== test session starts =================================================================== +platform linux -- Python 3.13.9, pytest-9.0.2, pluggy-1.6.0 +rootdir: /home/bulatgazizov/Projects/DevOps-Core-Course/app_python +plugins: anyio-4.9.0 +collected 4 items + +tests/test_main.py .... [100%] + +=================================================================== 4 passed in 1.62s ==================================================================== +``` + +--- + +## 3. Best Practices Implemented + +* **Linting:** Integrated `flake8` to ensure code style consistency. +* **Caching:** Implemented `actions/setup-python` caching. +* **Snyk Security:** Integrated Snyk vulnerability scanning. >> Tested 55 dependencies for known issues, no vulnerable paths found. + +* **Docker Metadata:** Used `docker/metadata-action` for automated, multi-tag management. + +--- + +## 4. Key Decisions + +**Versioning Strategy:** +I chose SemVer because it clearly says whether an update contains bug fixes (patch), new features (minor), or breaking changes (major). + +**Docker Tags:** +My CI generates the following tags: + +* `latest`: Always points to the most recent build +* `vX.Y.Z`: Specific semantic versions based on Git tags. + +**Workflow Triggers:** +The workflow triggers on pushes to main or any other branch. This ensures all code is linted, tested, and scanned for vulnerabilities before integration. + +**Test coverage:** +* `GET /`: Validates JSON structure, status code 200, and required fields. +* `GET /health`: Verifies the health check response and uptime increments +* `GET /unknown`: Verifies 404 response diff --git a/app_python/requirements.txt b/app_python/requirements.txt index d8e68f2cd9..ff8283619c 100644 --- a/app_python/requirements.txt +++ b/app_python/requirements.txt @@ -40,3 +40,11 @@ uvicorn==0.40.0 uvloop==0.22.1 watchfiles==1.1.1 websockets==16.0 +iniconfig==2.3.0 +pluggy==1.6.0 +pytest==9.0.2 +jsonschema==4.23.0 +flake8==7.3.0 +mccabe==0.7.0 +pycodestyle==2.14.0 +pyflakes==3.4.0 \ No newline at end of file diff --git a/app_python/tests/test_main.py b/app_python/tests/test_main.py new file mode 100644 index 0000000000..f1f3071658 --- /dev/null +++ b/app_python/tests/test_main.py @@ -0,0 +1,99 @@ +import time +from fastapi.testclient import TestClient +from jsonschema import validate +from app import app + +client = TestClient(app) + + +def test_read_health(): + response = client.get("/health") + schema = { + "type": "object", + "properties": { + "status": {"type": "string"}, + "timestamp": {"type": "string"}, + "uptime_seconds": {"type": "integer"}, + }, + "required": ["status", "timestamp", "uptime_seconds"] + } + assert response.status_code == 200 + + validate(instance=response.json(), schema=schema) + + +def test_read_root(): + response = client.get("/") + + schema = { + "type": "object", + "properties": { + "service": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "version": {"type": "string"}, + "description": {"type": "string"}, + "framework": {"type": "string"} + }, + "required": ["name", "version", "description", "framework"] + }, + "system": { + "type": "object", + "properties": { + "hostname": {"type": "string"}, + "platform": {"type": "string"}, + "platform_version": {"type": "string"}, + "architecture": {"type": "string"}, + "cpu_count": {"type": "integer"}, + "python_version": {"type": "string"} + }, + "required": [ + "hostname", "platform", "platform_version", + "architecture", "cpu_count", "python_version" + ] + }, + "runtime": { + "type": "object", + "properties": { + "uptime_seconds": {"type": "integer"}, + "uptime_human": {"type": "string"}, + "current_time": {"type": "string"}, + "timezone": {"type": "string"} + }, + "required": [ + "uptime_seconds", "uptime_human", + "current_time", "timezone" + ] + }, + "request": { + "type": "object", + "properties": { + "client_ip": {"type": "string"}, + "user_agent": {"type": "string"}, + "method": {"type": "string"}, + "path": {"type": "string"} + }, + "required": ["client_ip", "user_agent", "method", "path"] + }, + "endpoints": {"type": "array"} + }, + "required": ["service", "system", "runtime", "request", "endpoints"] + } + + assert response.status_code == 200 + + validate(instance=response.json(), schema=schema) + + +def test_404_handler(): + response = client.get("/this-route-does-not-exist") + assert response.status_code == 404 + assert response.json()["error"] == "Not Found" + + +def test_uptime_increments(): + res1 = client.get("/health").json()["uptime_seconds"] + time.sleep(1) + res2 = client.get("/health").json()["uptime_seconds"] + assert res2 > res1