diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..56ddc195fc --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,45 @@ +name: CI for metrics app + +on: [push] + +jobs: + ci: + name: CI job + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.14 + cache: pip + - name: Install dependencies + run: pip install -r app_python/requirements.txt -r app_python/requirements-dev.txt + - name: Flake8 lint + run: flake8 --max-line-length 99 app_python/app.py + - name: Run tests + run: pytest app_python + docker: + needs: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ secrets.DOCKER_USERNAME }}/devops-infoservice + tags: | + type=semver,pattern={{version}} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./app_python + push: ${{ startsWith(github.ref, 'refs/tags/') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/app_python/README.md b/app_python/README.md index 139ecf4b59..bbd3be3d1c 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,3 +1,5 @@ +[![CI for metrics app](https://github.com/Error10556/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03)](https://github.com/Error10556/DevOps-Core-Course/actions/workflows/python-ci.yml) + # DevOps Info Service ## Overview diff --git a/app_python/app.py b/app_python/app.py index 07b90cf6c8..06194342e7 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -2,8 +2,8 @@ DevOps Info Service Main application module """ -from datetime import datetime, timezone from flask import Flask, jsonify, request +from datetime import datetime, timezone import logging import os import platform @@ -17,7 +17,7 @@ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) -app = Flask(__name__) +app: Flask = Flask(__name__) def get_system_info() -> dict[str, str | int | None]: @@ -48,7 +48,8 @@ def word_plural(number: int) -> str: tzinfo = str(tzinfo) return { 'uptime_seconds': seconds, - 'uptime_human': f"{hours} hour{word_plural(hours)}, {minutes} minute{word_plural(minutes)}", + 'uptime_human': f"{hours} hour{word_plural(hours)}, " + + f"{minutes} minute{word_plural(minutes)}", 'current_time': loctime.isoformat(timespec='milliseconds'), 'timezone': tzinfo, } @@ -78,7 +79,7 @@ def index(): 'runtime': get_uptime(), 'request': get_request_info(), 'endpoints': [ - {"path": "/" , "method": "GET", "description": "Service information"}, + {"path": "/", "method": "GET", "description": "Service information"}, {"path": "/health", "method": "GET", "description": "Health check"} ] }) diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..159fa6ee86 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,121 @@ +# Task 1 + +## Testing framework + +I chose `pytest` for its simplicity. + +## Test structure + +I created 3 files in the `tests` directory: (omitting `__init__.py`) + +```text +tests +├── test_404.py # for handling disallowed URLs +├── test_health.py # for the /health endpoint +└── test_index.py # for the / endpoint +``` + +Each of these files contains functions that use `assert`s to test the app with `app.test_client()`. + +## How to test locally + +1. Install and activate a virtual environment with all dependencies in `requirements-dev.txt` and `requirements.txt`: +```sh +python -m venv venv +. venv/bin/activate +pip install -r requirements-dev.txt -r requirements.txt +``` +2. Run `pytest` in the `app_python` directory while having the virtual environment active: +```sh +pytest +``` + +## Passing tests output + +```text +============================= test session starts ============================== +platform linux -- Python 3.14.2, pytest-9.0.0, pluggy-1.6.0 +rootdir: /home/timur/proj/DevOps-Core-Course/app_python +collected 3 items + +tests/test_404.py . [ 33%] +tests/test_health.py . [ 66%] +tests/test_index.py . [100%] + +============================== 3 passed in 0.16s =============================== +``` + +# Task 2 + +## Workflow trigger strategy and reasoning + +I run: +- the CI job on every push, so that we always know if the program is OK or not; +- the docker build also on every push (so that we know if it fails), but only if tests have passed; +- the docker push after every successful build, but only if this is a tagged commit (for semantic versioning). + +Note that this workflow remains correct if I decide to tag a commit that is not a tip of a branch. + +The downside is that if the tagged commit is the tip of the current branch, then tests and build run twice; I do not +know if there is a way to fix that. + +## Action choice justification + +All actions that I used (listed below) are mentioned in the lecture. + +- actions/checkout@v4 +- actions/setup-python@v5 +- docker/login-action@v3 +- docker/metadata-action@v5 +- docker/build-push-action@v6 + +## Docker tagging strategy + +I chose SemVer because we are developing a service that other applications are supposed to depend on. It will be helpful +to know when a breaking change occurs. + +## Link to workflow run + +This run pushed to Docker Hub: + +[github.com/Error10556/DevOps-Core-Course/actions/runs/21909811285](https://github.com/Error10556/DevOps-Core-Course/actions/runs/21909811285) + +## Green checkmark + +All the checkmarks: + +![Screenshot of a successful GitHub Actions view](/app_python/docs/screenshots/Lab3-green-checkmark.png) + +# Task 3 + +## Add badge + +See `app_python/README.md`. + +## Caching implementation + +We compare 2 jobs: + +- CI with caching: + [github.com/Error10556/DevOps-Core-Course/actions/runs/21910845044/job/63263997026](https://github.com/Error10556/DevOps-Core-Course/actions/runs/21910845044/job/63263997026) +- CI without caching: + [github.com/Error10556/DevOps-Core-Course/actions/runs/21910713353/job/63263515554](https://github.com/Error10556/DevOps-Core-Course/actions/runs/21910713353/job/63263515554) + +Results are surprising: without caching, the job took 10 seconds, but with caching, it took 11 seconds. + +This is a negative improvement (-10%). Still an improvement, though. Just negative. + +In all seriousness, the 1s difference could just be a measurement error, and these jobs took roughly the same time +because there are very few dependencies to install. + +## Snyk + +Snyk has difficulties with issuing tokens. + +## More best practices + +It was revealed to me in a dream that one of the most important security precautions to implement is not vibe-coding +your way through university. However, this is just not feasible with tasks like these. The AI-generated labs are poorly +reviewed for workload; as much as I wanted to learn the subject and take all that the course has to offer, I have to +prioritize actual learning above tasks like "Oh, by the way, do like these three other tasks on your own, independently, +because the course team can't be bothered to teach you. This is mandatory, by the way. OK? Thanks". diff --git a/app_python/docs/screenshots/Lab3-green-checkmark.png b/app_python/docs/screenshots/Lab3-green-checkmark.png new file mode 100644 index 0000000000..12dd2bb7d5 Binary files /dev/null and b/app_python/docs/screenshots/Lab3-green-checkmark.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..dd9710ab3a --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest==9.0.0 +flake8==7.3.0 diff --git a/app_python/tests/test_404.py b/app_python/tests/test_404.py new file mode 100644 index 0000000000..a46dbf9e9d --- /dev/null +++ b/app_python/tests/test_404.py @@ -0,0 +1,16 @@ +import flask +import app + + +def test_404(): + def assert_404(resp: flask.TestResponse, codes: set[int] | None = None): + if codes is None: + assert resp.status_code == 404 + else: + assert resp.status_code in codes + + app.app.testing = True + with app.app.test_client() as client: + assert_404(client.get('/health/')) + assert_404(client.get('/sdjc39042/sdcs/dcewcmscd/')) + assert_404(client.get('/../../../../../../../etc/shadow'), {403, 404}) diff --git a/app_python/tests/test_health.py b/app_python/tests/test_health.py new file mode 100644 index 0000000000..6d4ed4f91d --- /dev/null +++ b/app_python/tests/test_health.py @@ -0,0 +1,17 @@ +import app +import re + + +def test_health(): + app.app.testing = True + with app.app.test_client() as client: + for _ in range(3): + response = client.get('/health') + assert response.status_code == 200 + assert response.is_json + d = response.json + assert isinstance(d, dict) + assert set(d.keys()) == {"status", "timestamp", "uptime_seconds"} + assert d['status'] == 'healthy' + assert isinstance(d['timestamp'], str) + assert isinstance(d['uptime_seconds'], int) diff --git a/app_python/tests/test_index.py b/app_python/tests/test_index.py new file mode 100644 index 0000000000..2e57e022c1 --- /dev/null +++ b/app_python/tests/test_index.py @@ -0,0 +1,114 @@ +import app +import re + + +""" +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "127.0.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.18.0" + }, + "runtime": { + "current_time": "2026-01-26T15:56:22.043+03:00", + "timezone": "MSK", + "uptime_human": "0 hours, 2 minutes", + "uptime_seconds": 160 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 16, + "hostname": "timur-ficus", + "platform": "Linux", + "platform_version": "#1 SMP PREEMPT_DYNAMIC Sun, 18 Jan 2026 00:34:07 +0000", + "python_version": "3.14.2" + } +} +""" + + +def validate_endpoints(endpts): + assert isinstance(endpts, list) + assert len(endpts) >= 2 + assert all(isinstance(i, dict) for i in endpts) + assert all(set(i.keys()).issuperset({"description", "method", "path"}) for i in endpts) + locmethod = {i['path']: i['method'] for i in endpts} + assert '/' in locmethod + assert '/health' in locmethod + assert locmethod['/'] == 'GET' + assert locmethod['/health'] == 'GET' + + +def validate_request(req): + assert isinstance(req, dict) + assert set(req.keys()) == {"client_ip", "method", "path", "user_agent"} + assert all(isinstance(i, str) for i in req.values()) + assert req['method'] == 'GET' + assert req['path'] == '/' + + +def validate_runtime(rt): + assert isinstance(rt, dict) + assert set(rt.keys()) == {"current_time", "timezone", "uptime_human", "uptime_seconds"} + assert re.fullmatch(r'\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\d((\+|-)\d\d:\d\d)?', rt['current_time']) + assert isinstance(rt['timezone'], str) + assert isinstance(rt['uptime_human'], str) + assert isinstance(rt['uptime_seconds'], int) + assert rt['uptime_seconds'] >= 0 + + +def validate_service(desc): + assert isinstance(desc, dict) + assert set(desc.keys()) == {"description", "framework", "name", "version"} + assert all(isinstance(v, str) for v in desc.values()) + + +def validate_system(sysinfo): + assert isinstance(sysinfo, dict) + assert set(sysinfo.keys()) == { + "architecture", + "cpu_count", + "hostname", + "platform", + "platform_version", + "python_version" + } + assert all((isinstance(v, str)) ^ (k == 'cpu_count') for k, v in sysinfo.items()) + assert isinstance(sysinfo['cpu_count'], int) + assert sysinfo['cpu_count'] > 0 + + +def test_index(): + app.app.testing = True + with app.app.test_client() as client: + for _ in range(3): + response = client.get('/') + assert response.status_code == 200 + assert response.is_json + d = response.json + assert isinstance(d, dict) + assert set(d.keys()) == {"endpoints", "request", "runtime", "service", "system"} + validate_endpoints(d["endpoints"]) + validate_request(d["request"]) + validate_runtime(d["runtime"]) + validate_service(d["service"]) + validate_system(d["system"])