Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 2 additions & 0 deletions app_python/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
9 changes: 5 additions & 4 deletions app_python/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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"}
]
})
Expand Down
121 changes: 121 additions & 0 deletions app_python/docs/LAB03.md
Original file line number Diff line number Diff line change
@@ -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".
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions app_python/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pytest==9.0.0
flake8==7.3.0
16 changes: 16 additions & 0 deletions app_python/tests/test_404.py
Original file line number Diff line number Diff line change
@@ -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})
17 changes: 17 additions & 0 deletions app_python/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -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)
114 changes: 114 additions & 0 deletions app_python/tests/test_index.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading