diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..b01208ba29 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,131 @@ +name: Python CI/CD + +on: + push: + branches: + - master + - main + - lab03 + paths: + - 'Lab-1/app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: + - master + - main + paths: + - 'Lab-1/app_python/**' + - '.github/workflows/python-ci.yml' + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality: + name: Lint and tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ['3.11', '3.12'] + + defaults: + run: + working-directory: Lab-1/app_python + + steps: + - name: Checkout source + 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: | + Lab-1/app_python/requirements.txt + Lab-1/app_python/requirements-dev.txt + + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + + - name: Lint with Ruff + run: ruff check . + + - name: Run tests with coverage + run: pytest --cov=. --cov-report=term-missing --cov-fail-under=70 + + security: + name: Snyk dependency scan + runs-on: ubuntu-latest + needs: quality + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + working-directory: Lab-1/app_python + run: pip install -r requirements.txt + + - name: Run Snyk scan + if: ${{ secrets.SNYK_TOKEN != '' }} + uses: snyk/actions/python@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + command: test + args: --file=Lab-1/app_python/requirements.txt --severity-threshold=high + + - name: Snyk token is missing + if: ${{ secrets.SNYK_TOKEN == '' }} + run: echo "SNYK_TOKEN is not configured. Security scan skipped." + + docker: + name: Build and push Docker image + runs-on: ubuntu-latest + needs: + - quality + - security + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') + + steps: + - name: Checkout source + 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 tags + run: | + echo "CALVER=$(date -u +'%Y.%m.%d').${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV" + echo "CALVER_MONTH=$(date -u +'%Y.%m')" >> "$GITHUB_ENV" + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ./Lab-1/app_python + file: ./Lab-1/app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-lab2:${{ env.CALVER }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-lab2:${{ env.CALVER_MONTH }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-lab2:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Lab-1/app_python/.coverage b/Lab-1/app_python/.coverage new file mode 100644 index 0000000000..7f2f91c844 Binary files /dev/null and b/Lab-1/app_python/.coverage differ diff --git a/Lab-1/app_python/.dockerignore b/Lab-1/app_python/.dockerignore new file mode 100644 index 0000000000..d6a3c723bd --- /dev/null +++ b/Lab-1/app_python/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.log +.env +venv/ +.venv/ +.git/ +.gitignore +.vscode/ +.idea/ +docs/ +tests/ +README.md diff --git a/Lab-1/app_python/.gitignore b/Lab-1/app_python/.gitignore new file mode 100644 index 0000000000..4928ef8572 --- /dev/null +++ b/Lab-1/app_python/.gitignore @@ -0,0 +1,13 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log +.env + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/Lab-1/app_python/Dockerfile b/Lab-1/app_python/Dockerfile new file mode 100644 index 0000000000..3de77fcb17 --- /dev/null +++ b/Lab-1/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN addgroup --system app && adduser --system --ingroup app app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5000 + +USER app + +CMD ["python", "app.py"] diff --git a/Lab-1/app_python/README.md b/Lab-1/app_python/README.md new file mode 100644 index 0000000000..b8c51ee71e --- /dev/null +++ b/Lab-1/app_python/README.md @@ -0,0 +1,95 @@ +# DevOps Info Service (Flask) +[![Python CI/CD](https://github.com/Linktur/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=master)](https://github.com/Linktur/DevOps-Core-Course/actions/workflows/python-ci.yml) + +## Overview +A small Flask web service that reports service metadata, system information, runtime details, and request context. It also exposes a health check endpoint and Swagger UI. + +## Prerequisites +- Python 3.11+ +- pip + +## Installation +```bash +python -m venv venv +# Linux/macOS +source venv/bin/activate +# Windows PowerShell +.\venv\Scripts\Activate.ps1 + +pip install -r requirements.txt +``` + +## Configuration via .env (optional) +Create a `.env` file in `app_python/`: +```env +HOST=0.0.0.0 +PORT=5000 +DEBUG=false +``` + +## Running the Application +```bash +python app.py +``` + +With custom configuration: +```bash +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 DEBUG=true python app.py +``` + +Windows PowerShell: +```powershell +$env:PORT=8080; python app.py +$env:HOST='127.0.0.1'; $env:PORT=3000; $env:DEBUG='true'; python app.py +``` + +## Docker +Build image (pattern): +```bash +docker build -t linktur/devops-lab2:v1 . +``` + +Run container (pattern): +```bash +docker run --rm -p 5000:5000 --name devops-lab2 linktur/devops-lab2:v1 +``` + +Pull from Docker Hub (pattern): +```bash +docker pull linktur/devops-lab2:v1 +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check +- `GET /swagger.json` - OpenAPI spec +- `GET /docs` - Swagger UI + +## Local Quality Checks +Install development dependencies: +```bash +pip install -r requirements.txt -r requirements-dev.txt +``` + +Run linter: +```bash +ruff check . +``` + +Run unit tests: +```bash +pytest +``` + +Run tests with coverage threshold (same as CI): +```bash +pytest --cov=. --cov-report=term-missing --cov-fail-under=70 +``` + +## Configuration +| Variable | Default | Description | +|---|---|---| +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `5000` | HTTP port | +| `DEBUG` | `False` | Flask debug mode (`true`/`false`) | diff --git a/Lab-1/app_python/app.py b/Lab-1/app_python/app.py new file mode 100644 index 0000000000..4a6bdfb9a2 --- /dev/null +++ b/Lab-1/app_python/app.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +from dotenv import load_dotenv +from flask import Flask, jsonify, request +from flask_swagger_ui import get_swaggerui_blueprint + +app = Flask(__name__) + +# conf +load_dotenv() +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + +# start time +START_TIME = datetime.now(timezone.utc) + +# Logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) +logger.info('Application starting...') + +# swagger info +SWAGGER_URL = '/docs' +SWAGGER_API_URL = '/swagger.json' + +swaggerui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + SWAGGER_API_URL, + config={'app_name': 'DevOps Info Service'} +) +app.register_blueprint(swaggerui_blueprint, url_prefix=SWAGGER_URL) + + +def _iso_utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + + +def get_uptime() -> dict: + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + + +def get_platform_version() -> str: + system = platform.system() + if system == 'Linux': + try: + os_release = platform.freedesktop_os_release() + return os_release.get('PRETTY_NAME') or os_release.get('NAME') or platform.release() + except (OSError, AttributeError): + return platform.release() + if system == 'Windows': + return platform.version() + return platform.release() + + +def get_system_info() -> dict: + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': get_platform_version(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count() or 0, + 'python_version': platform.python_version() + } + + +def get_request_info() -> dict: + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr or '') + if ',' in client_ip: + client_ip = client_ip.split(',')[0].strip() + + return { + 'client_ip': client_ip, + 'user_agent': request.headers.get('User-Agent', ''), + 'method': request.method, + 'path': request.path + } + + +def get_service_info() -> dict: + """return metadata""" + return { + 'name': 'devops-info-service', + 'version': '1.0.0', + 'description': 'DevOps course info service', + 'framework': 'Flask' + } + + +def get_endpoints() -> list[dict]: + """return a list of available endpoints""" + return [ + {'path': '/', 'method': 'GET', 'description': 'Service information'}, + {'path': '/health', 'method': 'GET', 'description': 'Health check'} + ] + +#API +OPENAPI_SPEC = { + 'openapi': '3.0.3', + 'info': { + 'title': 'DevOps Info Service', + 'version': '1.0.0', + 'description': 'Service and system information API' + }, + 'paths': { + '/': { + 'get': { + 'summary': 'Service information', + 'responses': { + '200': { + 'description': 'Service and system information' + } + } + } + }, + '/health': { + 'get': { + 'summary': 'Health check', + 'responses': { + '200': { + 'description': 'Health status' + } + } + } + } + } +} + + +@app.before_request +def log_request() -> None: + logger.debug('Request: %s %s', request.method, request.path) + + +@app.route('/') +def index(): + """main endpoint""" + uptime = get_uptime() + payload = { + 'service': get_service_info(), + 'system': get_system_info(), + 'runtime': { + 'uptime_seconds': uptime['seconds'], + 'uptime_human': uptime['human'], + 'current_time': _iso_utc_now(), + 'timezone': 'UTC' + }, + 'request': get_request_info(), + 'endpoints': get_endpoints() + } + return jsonify(payload) + + +@app.route('/health') +def health(): + """health check endpoint""" + uptime = get_uptime() + return jsonify({ + 'status': 'healthy', + 'timestamp': _iso_utc_now(), + 'uptime_seconds': uptime['seconds'] + }) + + +@app.route('/swagger.json') +def swagger_json(): + return jsonify(OPENAPI_SPEC) + + +@app.errorhandler(404) +def not_found(error): + return jsonify({ + 'error': 'Not Found', + 'message': 'Endpoint does not exist' + }), 404 + + +@app.errorhandler(500) +def internal_error(error): + return jsonify({ + 'error': 'Internal Server Error', + 'message': 'An unexpected error occurred' + }), 500 + + +if __name__ == '__main__': + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/Lab-1/app_python/docs/LAB01.md b/Lab-1/app_python/docs/LAB01.md new file mode 100644 index 0000000000..f8887f20f8 --- /dev/null +++ b/Lab-1/app_python/docs/LAB01.md @@ -0,0 +1,110 @@ +# LAB01 - DevOps Info Service (Python / Flask) + +## 1. Framework Selection +**Chosen framework:** Flask + +**Why Flask:** +- Minimal setup and easy to understand for a first lab +- Clear request/response handling without extra abstractions) +- I tried Django, regretted it + +**Comparison Table** +| Framework | Pros | Cons | Why Not Chosen | +|---|---|---|---| +| Flask | Lightweight, simple, widely used | Fewer built-in features | Selected due to simplicity | +| FastAPI | Async, auto-docs, type hints | Slightly more setup | Didn't try it, because of luck of time | +| Django | Full-featured, includes ORM | Heavy for small API | Too much for the first time| + +## 2. Best Practices Applied +1. **Clean code organization** - helper functions for system, runtime, and request info +2. **Error handling** - custom 404 and 500 responses +3. **Logging** - structured logging with timestamp and level +4. **Configuration via environment variables** - HOST, PORT, DEBUG +5. **Pinned dependencies** - exact versions in `requirements.txt` + +**Code examples (from `app.py`):** +```python +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +``` + +```python +@app.errorhandler(404) +def not_found(error): + return jsonify({ + 'error': 'Not Found', + 'message': 'Endpoint does not exist' + }), 404 +``` + +```python +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +``` + +## 3. API Documentation +### 3.1 `GET /` +**Description:** Returns service, system, runtime, request info, and endpoints. + +**Example request:** +```bash +curl http://127.0.0.1:5000/ +``` + +**Example response (truncated):** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + } +} +``` + +### 3.2 `GET /health` +**Description:** Health check endpoint for monitoring. + +**Example request:** +```bash +curl http://127.0.0.1:5000/health +``` + +**Example response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T17:31:00.456Z", + "uptime_seconds": 180 +} +``` + +### 3.3 Swagger UI +**OpenAPI spec:** +``` +GET /swagger.json +``` + +**Swagger UI:** +``` +GET /docs +``` +*it was easier to check app with swagger + +## 4. Testing Evidence +Add screenshots to `docs/screenshots/` and embed them here. + +- **Main endpoint:** `screenshots/01-main-endpoint.png` +- **Health check:** `screenshots/02-health-check.png` +- **Pretty-printed output:** `screenshots/03-formatted-output.png` + +## 5. Challenges & Solutions +- **Timezone formatting:** Used UTC with ISO 8601 and `Z` suffix for consistency. +- **Client IP handling:** Added `X-Forwarded-For` fallback for proxy setups. + +## 6. GitHub Community +Starring repositories helps to find useful tools and bookmaer them. Following developers improves collaboration by keeping you aware of classmates' and instructors' work, which supports learning and teamwork. diff --git a/Lab-1/app_python/docs/LAB02.md b/Lab-1/app_python/docs/LAB02.md new file mode 100644 index 0000000000..7bdfec9a5b --- /dev/null +++ b/Lab-1/app_python/docs/LAB02.md @@ -0,0 +1,88 @@ +# LAB02 - Docker Containerization (Python) + +## Docker Best Practices Applied +- Non-root user: I created user `app` and run the app with `USER app` to reduce privileges. +- Fixed base image: `python:3.13-slim` gives a smaller and stable image. +- Layer caching: I copy `requirements.txt` first, then install deps so rebuilds are faster. +- Minimal copy: I only copy `requirements.txt` and `app.py`. +- `.dockerignore`: I exclude `venv/`, `tests/`, `docs/`, VCS, and IDE files to keep the context small. + +Dockerfile snippets: +```dockerfile +FROM python:3.13-slim +``` +Fixed base image keeps builds repeatable and smaller. + +```dockerfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +``` +Install deps before app code so cache works. + +```dockerfile +RUN addgroup --system app && adduser --system --ingroup app app +USER app +``` +Run as non-root for better security. + +## Image Information & Decisions +- Base image: `python:3.13-slim` because it is smaller but still works with `pip` and `glibc`. +- Final image size: `127MB`. +- Layer order: base -> env/workdir -> user -> deps -> app code -> user -> cmd. +- Optimizations: slim image, cached deps, no pip cache. + +## Build & Run Process +Build output: +``` +docker build -t linktur/devops-lab2:v1 . +[+] Building 29.5s (12/12) FINISHED + => [internal] load build definition from Dockerfile + => [internal] load metadata for docker.io/library/python:3.13-slim + => [internal] load .dockerignore + => [1/6] FROM docker.io/library/python:3.13-slim + => [2/6] WORKDIR /app + => [3/6] RUN addgroup --system app && adduser --system --ingroup app app + => [4/6] COPY requirements.txt . + => [5/6] RUN pip install --no-cache-dir -r requirements.txt + => [6/6] COPY app.py . + => exporting to image + => naming to docker.io/linktur/devops-lab2:v1 +``` + +Run output: +``` +docker run --rm -p 5000:5000 --name devops-lab2 linktur/devops-lab2:v1 +2026-02-05 09:12:25,566 - __main__ - INFO - Application starting... + * Serving Flask app 'app' + * Debug mode: off + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5000 + * Running on http://172.17.0.2:5000 +Press CTRL+C to quit +2026-02-05 09:13:00,463 - werkzeug - INFO - 172.17.0.1 - - [05/Feb/2026 09:13:00] "GET /health HTTP/1.1" 200 - +2026-02-05 09:13:05,335 - werkzeug - INFO - 172.17.0.1 - - [05/Feb/2026 09:13:05] "GET / HTTP/1.1" 200 - +``` + +Endpoint tests: +``` +curl http://localhost:5000/health +{"status":"healthy","timestamp":"2026-02-05T09:13:00.463Z","uptime_seconds":34} + +curl http://localhost:5000/ +{"endpoints":[{"description":"Service information","method":"GET","path":"/"},{"description":"Health check","method":"GET","path":"/health"}],"request":{"client_ip":"172.17.0.1","method":"GET","path":"/","user_agent":"curl/8.13.0"},"runtime":{"current_time":"2026-02-05T09:13:05.335Z","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":39},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","cpu_count":12,"hostname":"99a476249f8","platform":"Linux","platform_version":"Debian GNU/Linux 13 (trixie)","python_version":"3.13.12"}} +``` + +Docker Hub repository URL: +``` +https://hub.docker.com/repository/docker/linktur/devops-lab2 +``` +Screenshot with proof: +`screenshots/docker-logs.png` +## Technical Analysis +- The Dockerfile installs deps first, then copies app code. This keeps cache when only code changes. +- If I copy code before deps, every change breaks cache and build is slower. +- Security: non-root user, small base image, no extra tools. +- `.dockerignore` makes the build context smaller and faster. + +## Challenges & Solutions +- What I learned: `I finally registered in Docker Hub.` diff --git a/Lab-1/app_python/docs/LAB03.md b/Lab-1/app_python/docs/LAB03.md new file mode 100644 index 0000000000..99424b788c --- /dev/null +++ b/Lab-1/app_python/docs/LAB03.md @@ -0,0 +1,66 @@ +# LAB03 + +## What I implemented +For this lab, I set up a full basic CI/CD flow for the Python app. +- Testing framework: `pytest`. +- Tests file: `Lab-1/app_python/tests/test_app.py`. +- Covered endpoints and cases: + - `GET /` + - `GET /health` + - `404` error + - `500` error + +## CI workflow +I added one workflow that handles quality checks, security scan, and Docker publishing. +- File: `.github/workflows/python-ci.yml` +- Triggers: `push` and `pull_request` with path filters +- Steps: + - install dependencies + - run linter (`ruff`) + - run tests (`pytest`) + - run security scan (`Snyk`) + - build and push Docker image + +## Versioning strategy +I chose **CalVer** because it is simple and works well for continuous delivery. +- Docker tags: + - `YYYY.MM.DD.RUN_NUMBER` + - `YYYY.MM` + - `latest` + +## Evidence +- Workflow: `https://github.com/Linktur/DevOps-Core-Course/actions/workflows/python-ci.yml` +- Docker Hub: `https://hub.docker.com/r/linktur/devops-lab2/tags` +- Status badge: `Lab-1/app_python/README.md` + +Local checks screenshot: +`screenshots/lab03-local-checks.png` + +## Local commands +```bash +pip install -r requirements.txt -r requirements-dev.txt +ruff check . +pytest --cov=. --cov-report=term-missing --cov-fail-under=70 +``` + +## Local result +Everything passed locally: +- `ruff`: passed +- `pytest`: 4 passed +- coverage: 94% + +## CI best practices used +- Path filters +- Matrix testing (Python 3.11 and 3.12) +- Dependency caching +- Concurrency (cancel outdated runs) +- Job dependencies (`needs`) +- Docker publish only from `main`/`master` + +## Final checklist +Before final submission, only these checks are needed: +- Configure GitHub secrets: + - `DOCKERHUB_USERNAME` + - `DOCKERHUB_TOKEN` + - `SNYK_TOKEN` +- Verify one successful green run in GitHub Actions diff --git a/Lab-1/app_python/docs/screenshots/01-main-endpoint.png b/Lab-1/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..7a1686749a Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/Lab-1/app_python/docs/screenshots/02-health-check.png b/Lab-1/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..0f778c34e5 Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/02-health-check.png differ diff --git a/Lab-1/app_python/docs/screenshots/03-formatted-output.png b/Lab-1/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..8ee9e2626d Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/Lab-1/app_python/docs/screenshots/docker-logs.png b/Lab-1/app_python/docs/screenshots/docker-logs.png new file mode 100644 index 0000000000..5e3b4b816f Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/docker-logs.png differ diff --git a/Lab-1/app_python/docs/screenshots/lab03-local-checks.png b/Lab-1/app_python/docs/screenshots/lab03-local-checks.png new file mode 100644 index 0000000000..6c6df6f2bb Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/lab03-local-checks.png differ diff --git a/Lab-1/app_python/requirements-dev.txt b/Lab-1/app_python/requirements-dev.txt new file mode 100644 index 0000000000..bfdecde632 --- /dev/null +++ b/Lab-1/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==8.3.4 +pytest-cov==6.0.0 +ruff==0.9.10 diff --git a/Lab-1/app_python/requirements.txt b/Lab-1/app_python/requirements.txt new file mode 100644 index 0000000000..7d97f97331 --- /dev/null +++ b/Lab-1/app_python/requirements.txt @@ -0,0 +1,6 @@ +# Web Framework +Flask==3.1.0 +# Swagger UI +flask-swagger-ui==4.11.1 +# Env support +python-dotenv==1.0.1 diff --git a/Lab-1/app_python/tests/__init__.py b/Lab-1/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Lab-1/app_python/tests/test_app.py b/Lab-1/app_python/tests/test_app.py new file mode 100644 index 0000000000..5ec5d231df --- /dev/null +++ b/Lab-1/app_python/tests/test_app.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import app as app_module +import pytest + + +@pytest.fixture() +def client(): + app_module.app.config.update(TESTING=True, PROPAGATE_EXCEPTIONS=False) + with app_module.app.test_client() as test_client: + yield test_client + + +def test_index_returns_required_json_structure(client): + response = client.get( + "/", + headers={ + "User-Agent": "pytest-client", + "X-Forwarded-For": "203.0.113.10, 10.0.0.1", + }, + ) + + assert response.status_code == 200 + payload = response.get_json() + + assert isinstance(payload, dict) + assert {"service", "system", "runtime", "request", "endpoints"}.issubset(payload.keys()) + + assert payload["service"]["name"] == "devops-info-service" + assert payload["request"]["client_ip"] == "203.0.113.10" + assert payload["request"]["user_agent"] == "pytest-client" + assert payload["request"]["method"] == "GET" + assert payload["request"]["path"] == "/" + assert isinstance(payload["endpoints"], list) + + +def test_health_returns_healthy_status(client): + response = client.get("/health") + + assert response.status_code == 200 + payload = response.get_json() + + assert payload["status"] == "healthy" + assert isinstance(payload["timestamp"], str) + assert payload["timestamp"].endswith("Z") + assert isinstance(payload["uptime_seconds"], int) + assert payload["uptime_seconds"] >= 0 + + +def test_not_found_returns_404_json(client): + response = client.get("/missing") + + assert response.status_code == 404 + payload = response.get_json() + assert payload == { + "error": "Not Found", + "message": "Endpoint does not exist", + } + + +def test_internal_server_error_returns_500_json(client, monkeypatch): + def boom(): + raise RuntimeError("forced failure") + + monkeypatch.setattr(app_module, "get_service_info", boom) + + response = client.get("/") + + assert response.status_code == 500 + payload = response.get_json() + assert payload == { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }