From c01192c82c8ba633d33f1f7a1662383d707b3a30 Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Tue, 27 Jan 2026 13:49:03 +0300 Subject: [PATCH 1/9] feat: implement lab01 devops info service --- app_python/.gitignore | 156 ++++++++++++++++++++++++++ app_python/README.md | 44 ++++++++ app_python/app.py | 21 ++++ app_python/config.py | 5 + app_python/docs/LAB01.md | 138 +++++++++++++++++++++++ app_python/docs/screenshots/img.png | Bin 0 -> 41706 bytes app_python/docs/screenshots/img_1.png | Bin 0 -> 7030 bytes app_python/docs/screenshots/img_2.png | Bin 0 -> 20698 bytes app_python/health_check/__init__.py | 1 + app_python/health_check/router.py | 18 +++ app_python/health_check/schemas.py | 52 +++++++++ app_python/health_check/service.py | 104 +++++++++++++++++ app_python/logger_config.py | 22 ++++ app_python/requirements.txt | 3 + app_python/tests/__init__.py | 0 app_python/utils.py | 3 + 16 files changed, 567 insertions(+) create mode 100644 app_python/.gitignore create mode 100644 app_python/README.md create mode 100644 app_python/app.py create mode 100644 app_python/config.py create mode 100644 app_python/docs/LAB01.md create mode 100644 app_python/docs/screenshots/img.png create mode 100644 app_python/docs/screenshots/img_1.png create mode 100644 app_python/docs/screenshots/img_2.png create mode 100644 app_python/health_check/__init__.py create mode 100644 app_python/health_check/router.py create mode 100644 app_python/health_check/schemas.py create mode 100644 app_python/health_check/service.py create mode 100644 app_python/logger_config.py create mode 100644 app_python/requirements.txt create mode 100644 app_python/tests/__init__.py create mode 100644 app_python/utils.py diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..bcd5ee2a42 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,156 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +.svelte-kit +/build + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# OS +.DS_Store +Thumbs.db + +# Env +.env.* +!.env.example +!.env.test + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +coverage + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# JetBrains IDEs +.idea/ diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..d3809d245d --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,44 @@ +## Overview +This API contains two endpoints: +1. Getting information about the system +2. Getting the health status of the API itself + +## Prerequisites +``` +python==3.13.5 +uvicorn==0.40.0 +pydantic==2.12.5 +fastapi==0.128.0 +``` + +## Installation + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Running + +```bash +python app.py +# Or with custom config +PORT=8080 python app.py +``` + +## API Endpoints + +``` +GET / - Service and system information +GET /health - Health check +``` + +## Configuration + +| Variable | Description | Type | Default | Example | +| -------- | -------------------------------------- | ------- | --------- |-------------| +| `HOST` | Host address the application binds to | string | `0.0.0.0` | `127.0.0.1` | +| `PORT` | Port number the application listens on | integer | `5000` | `8000` | +| `DEBUG` | Enables debug mode | boolean | `False` | `True` | + diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..9207e80411 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,21 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from config import DEBUG, PORT, HOST +from health_check.router import router +from logger_config import setup_logger + +setup_logger() +app = FastAPI(debug=DEBUG) +app.include_router(router=router) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +if __name__ == "__main__": + uvicorn.run(app=app, port=PORT, host=HOST) diff --git a/app_python/config.py b/app_python/config.py new file mode 100644 index 0000000000..142a3e1fbf --- /dev/null +++ b/app_python/config.py @@ -0,0 +1,5 @@ +import os + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..9710dad499 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,138 @@ +## Framework Selection + +I chose FastApi because it's simple, easy to create endpoints, and has automatic documentation. + +| Framework | Pros | Cons | Reason Not Chosen | +|-------------| ----------------------------------------------------- | ------------------------------------------- | --------------------------------- | +| **FastAPI** | Async support, type safety, OpenAPI, high performance | Slight learning curve | **Chosen** | +| Flask | Simple, minimal | No async by default, no built-in validation | Less suitable for structured APIs | +| Django | Full-featured, mature | Heavy, overkill for small service | Too complex for this task | + +## Best Practices Applied + +1. Environment-based Configuration + + ```python + HOST = os.getenv("HOST", "0.0.0.0") + PORT = int(os.getenv("PORT", 5000)) + DEBUG = os.getenv("DEBUG", "False").lower() == "true" + ``` + + it important because it enables configuration without code changes. + +2. Separation of Concerns + + ```python + class HealthCheckService: + async def get_info(self, request: Request) -> InfoResponse: + ... + + ``` + + it important because it easier testing, cleaner routing layer + +3. Typed Responses with Pydantic + + ```python + class InfoResponse(BaseModel): + service: ServiceInfo + system: SystemInfo + runtime: RuntimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] + ``` + + it important because guarantees response structure and improves readability + +4. Logging + + ```python + logger = logging.getLogger(__name__) + logger.info("Handling info request") + ``` + + it important because it centralized observability and works seamlessly with Uvicorn + +## API Documentation + +1. GET `/` - get system information + + Response example: + ```json + { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Fastapi" + }, + "system": { + "hostname": "Th1ef", + "platform": "Windows", + "platform_version": "10.0.26200", + "architecture": "AMD64", + "cpu_count": 8, + "python_version": "3.13.5" + }, + "runtime": { + "uptime_seconds": 18, + "uptime_human": "0 hours, 0 minutes", + "current_time": "2026-01-26T12:41:50.413788Z", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information" + }, + { + "path": "/health", + "method": "GET", + "description": "Health check" + } + ] + } + ``` + +2. GET `/health` - get service status + + Response example: + ```json + { + "status": "healthy", + "timestamp": "2026-01-27T10:32:15.552053Z", + "uptime_seconds": 7390 + } + ``` + +3. Testing Commands + + Using curl: + ```bash + curl http://localhost:5000/ + curl http://localhost:5000/health + ``` + + or auto generated documentation: + + ```bash + http://localhost:5000/docs + ``` + +## Testing Evidence + +- Successful responses from `/` and `/health` +- Correct JSON structure returned +- Terminal output from uvicorn confirming requests +- Screenshots Swagger UI + +## Challenges & Solutions + +There were no difficulties diff --git a/app_python/docs/screenshots/img.png b/app_python/docs/screenshots/img.png new file mode 100644 index 0000000000000000000000000000000000000000..ec290c9123dbbd4d4a20633a73b87d8821261390 GIT binary patch literal 41706 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 literal 0 HcmV?d00001 diff --git a/app_python/health_check/__init__.py b/app_python/health_check/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app_python/health_check/__init__.py @@ -0,0 +1 @@ + diff --git a/app_python/health_check/router.py b/app_python/health_check/router.py new file mode 100644 index 0000000000..84fa2ee4e6 --- /dev/null +++ b/app_python/health_check/router.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Request +from health_check.schemas import InfoResponse, HealthResponse +from health_check.service import HealthCheckServiceDep + +router = APIRouter() + + +@router.get("/", description="Service information") +async def get_info( + service: HealthCheckServiceDep, + request: Request, +) -> InfoResponse: + return await service.get_info(request=request) + + +@router.get("/health", description="Health check") +async def health_check(service: HealthCheckServiceDep) -> HealthResponse: + return await service.health_check() diff --git a/app_python/health_check/schemas.py b/app_python/health_check/schemas.py new file mode 100644 index 0000000000..b6cfd4dc97 --- /dev/null +++ b/app_python/health_check/schemas.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel +from datetime import datetime + + +class ServiceInfo(BaseModel): + name: str + version: str + description: str + framework: str + + +class SystemInfo(BaseModel): + hostname: str + platform: str + platform_version: str + architecture: str + cpu_count: int + python_version: str + + +class RuntimeInfo(BaseModel): + uptime_seconds: int + uptime_human: str + current_time: datetime + timezone: str + + +class RequestInfo(BaseModel): + client_ip: str + user_agent: str + method: str + path: str + + +class EndpointInfo(BaseModel): + path: str + method: str + description: str + + +class InfoResponse(BaseModel): + service: ServiceInfo + system: SystemInfo + runtime: RuntimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] + + +class HealthResponse(BaseModel): + status: str + timestamp: datetime + uptime_seconds: int diff --git a/app_python/health_check/service.py b/app_python/health_check/service.py new file mode 100644 index 0000000000..ab2939d887 --- /dev/null +++ b/app_python/health_check/service.py @@ -0,0 +1,104 @@ +import logging +import socket +import platform +from datetime import datetime, timezone +import os +from typing import Annotated + +from fastapi import Request, Depends +from fastapi.routing import APIRoute + +from utils import APP_START_TIME +from health_check.schemas import ( + InfoResponse, + EndpointInfo, + ServiceInfo, + SystemInfo, + RuntimeInfo, + RequestInfo, + HealthResponse, +) + +logger = logging.getLogger(__name__) + + +class HealthCheckService: + @staticmethod + def get_uptime(start_time) -> tuple[int, str]: + delta = datetime.now(tz=timezone.utc) - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return seconds, f"{hours} hours, {minutes} minutes" + + async def get_info(self, request: Request) -> InfoResponse: + try: + logger.info("Starting to find info") + + hostname = socket.gethostname() + platform_name = platform.system() + architecture = platform.machine() + python_version = platform.python_version() + cpu_count = os.cpu_count() + platform_version = platform.version() + + current_time = datetime.now(tz=timezone.utc) + uptime_seconds, uptime_human = self.get_uptime(APP_START_TIME) + + client_ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent") + method = request.method + path = request.url.path + + endpoints = [] + for route in request.app.routes: + if isinstance(route, APIRoute): + for method in route.methods: + endpoints.append( + EndpointInfo( + path=route.path, + method=method, + description=route.description, + ) + ) + + return InfoResponse( + service=ServiceInfo( + name="devops-info-service", + version="1.0.0", + description="DevOps course info service", + framework="Fastapi", + ), + system=SystemInfo( + hostname=hostname, + platform=platform_name, + platform_version=platform_version, + architecture=architecture, + cpu_count=cpu_count, + python_version=python_version, + ), + runtime=RuntimeInfo( + uptime_seconds=uptime_seconds, + uptime_human=uptime_human, + current_time=current_time, + timezone="UTC", + ), + request=RequestInfo( + client_ip=client_ip, user_agent=user_agent, method=method, path=path + ), + endpoints=endpoints, + ) + except Exception as e: + logger.exception(e) + raise + + async def health_check(self) -> HealthResponse: + logger.info("Health check called") + return HealthResponse( + status="healthy", + timestamp=datetime.now(tz=timezone.utc), + uptime_seconds=self.get_uptime(APP_START_TIME)[0], + ) + + +HealthCheckServiceDep = Annotated[HealthCheckService, Depends(HealthCheckService)] diff --git a/app_python/logger_config.py b/app_python/logger_config.py new file mode 100644 index 0000000000..87f89a287d --- /dev/null +++ b/app_python/logger_config.py @@ -0,0 +1,22 @@ +import logging +import logging.config + +LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": {"format": "[%(asctime)s] [%(levelname)s] %(name)s: %(message)s"} + }, + "handlers": { + "console": { + "class": logging.StreamHandler, + "formatter": "default", + "level": "INFO", + } + }, + "root": {"level": "INFO", "handlers": ["console"]}, +} + + +def setup_logger() -> None: + logging.config.dictConfig(LOGGING_CONFIG) diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..6818010aec --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,3 @@ +uvicorn==0.40.0 +pydantic==2.12.5 +fastapi==0.128.0 \ No newline at end of file diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/utils.py b/app_python/utils.py new file mode 100644 index 0000000000..d10d7e8a2a --- /dev/null +++ b/app_python/utils.py @@ -0,0 +1,3 @@ +from datetime import datetime, timezone + +APP_START_TIME = datetime.now(tz=timezone.utc) From 7d1abfe23e0935c10bdb1f1abdccb38190179740 Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Wed, 4 Feb 2026 20:56:58 +0300 Subject: [PATCH 2/9] feat: add Docker support with Dockerfile and .dockerignore --- app_python/.dockerignore | 8 +++ app_python/Dockerfile | 16 +++++ app_python/README.md | 32 +++++++++ app_python/docs/LAB02.md | 145 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 app_python/.dockerignore create mode 100644 app_python/Dockerfile create mode 100644 app_python/docs/LAB02.md diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..5bd6a39c7b --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,8 @@ +.venv +venv +__pycache__ +.git +.gitignore +.env +*.pyc +.idea diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..24daabb9f5 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.13-slim + +WORKDIR /app + +RUN adduser --disabled-password --gecos "" appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chown -R appuser:appuser /app + +USER appuser + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md index d3809d245d..022c11336f 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -42,3 +42,35 @@ GET /health - Health check | `PORT` | Port number the application listens on | integer | `5000` | `8000` | | `DEBUG` | Enables debug mode | boolean | `False` | `True` | +## Docker + +1. Building the image + example: + ```bash + docker build -t : + ``` + + to build our service used: + ```bash + docker duild -t devops-info-service:latest . + ``` +2. Running a container + example: + ```bash + docker run + ``` + + to run our service used: + ```bash + docker run -d -p 5000:5000 devops-info-service + ``` + +3. Pulling from Docker Hub example: + ```bash + docker pull + ``` + + to pull our repo used: + ```bash + docker pull th1ef/devops-info-service:latest + ``` diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..c846e43364 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,145 @@ +## Docker Best Practices Applied +1. Minimal Base Image + ```dockerfile + FROM python:3.13-slim + ``` + it important because `slim` is significantly smaller than `python:3.13` -> faster download and deployment + +2. Proper Layer Ordering + ```dockerfile + WORKDIR /app + + COPY requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt + + COPY . . + ``` + it important because dependencies are installed once and when code changes, `pip install` is not rerun. + +3. .dockerignore + ```dockerignore + .venv + __pycache__ + .git + .gitignore + .idea + *.pyc + ``` + + it important because it reduces the size of the build context and speeds up `docker build` + +4. Non-root User + ```dockerfile + RUN useradd -m appuser + USER appuser + ``` + + it important because container doesn't run as root, reduces the risk of vulnerabilities + +5. No Cache in pip + +```dockerfile +RUN pip install --no-cache-dir -r requirements.txt +``` + +it important because it reduces the final image size and pip cache is not needed at runtime + + +## Image Information & Decisions + +#### Base image chosen: +| Image | Reason for failure | +| ---------------- |-------------------| +| python:3.13 | too big | +| alpine | dependency issues | +| python:3.13-slim | optimal balance | + +#### Final image size: +```text +140MB +``` + +#### Layer structure +```dockerfile +FROM python:3.13-slim +WORKDIR /app +RUN adduser --disabled-password --gecos "" appuser +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN chown -R appuser:appuser /app +USER appuser +CMD ["python", "app.py"] +``` + +## Build & Run Process +1. Complete terminal output from build process + ```text + (.venv) C:\Users\kve10\PycharmProjects\DevOps-Core-Course\app_python>docker build -t devops-info-service:latest . + [+] Building 15.0s (12/12) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 289B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.0s + => [internal] load .dockerignore 0.0s + => => transferring context: 104B 0.0s + => [internal] load build context 0.0s + => => transferring context: 1.32kB 0.0s + => [1/7] FROM docker.io/library/python:3.13-slim@sha256:51e1a0a317fdb6e170dc791bbeae63fac5272c82f43958ef74a34e170c6f8b18 0.0s + => CACHED [2/7] WORKDIR /app 0.0s + => CACHED [3/7] RUN adduser --disabled-password --gecos "" appuser 0.0s + => CACHED [4/7] COPY requirements.txt . 0.0s + => [5/7] RUN pip install -r requirements.txt 12.8s + => [6/7] COPY . . 0.0s + => [7/7] RUN chown -R appuser:appuser /app 0.6s + => exporting to image 0.3s + => => exporting layers 0.3s + => => writing image sha256:4951433b4ff82147cbd1bf45597c98fb56f13ffa619ec10098559796ac8f6210 0.0s + => => naming to docker.io/library/devops-info-service:latest + ``` +2. Terminal output showing container running + ```text + (.venv) C:\Users\kve10\PycharmProjects\DevOps-Core-Course\app_python>docker run -d -p 5000:5000 devops-info-service + 8a9df27c507cb56b6999fababd27de98bd87ba96ed0fcdeec0cd3ed10fb6a208 + ``` + +3. Terminal output from testing endpoints + #### root endpoint + ```text + (.venv) C:\Users\kve10\PycharmProjects\DevOps-Core-Course\app_python>curl http://localhost:5000/ + {"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"Fastapi"},"system":{"hostname":"69f1f9d7f438","platform":"Linux","platform_version":"#1 SMP Tue Nov 5 00:21:55 UTC + 2024","architecture":"x86_64","cpu_count":8,"python_version":"3.13.11"},"runtime":{"uptime_seconds":63481,"uptime_human":"17 hours, 38 minutes","current_time":"2026-01-28T13:48:16.715852Z","timezone":"UTC"},"request":{"client_ip":"172.17.0.1","user_agent":"curl/8.16.0","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]} + ``` + #### health endpoint + ```text + (.venv) C:\Users\kve10\PycharmProjects\DevOps-Core-Course\app_python>curl http://localhost:5000/health + {"status":"healthy","timestamp":"2026-01-28T13:49:10.566548Z","uptime_seconds":63535} + ``` + +4. Docker Hub repository URL + +```text +https://hub.docker.com/r/th1ef/devops-info-service +``` + +## Technical Analysis +1. Why does your Dockerfile work the way it does? + - Layers are built for the cache + - Runtime and build are logically separated + - No extra files + - The environment is managed via `ENV` +2. What would happen if you changed the layer order? + - The cache breaks + - Every build rebuilds dependencies + - CI/CD time increases +3. What security considerations did you implement? + - Non-root user + - Minimal base image + - No dev files + - Environment variables are set during run +4. How does `.dockerignore` improve your build? + - Less data → faster build + - No .git leaks + - Smaller image size + +## Challenges & Solutions +There were no difficulties \ No newline at end of file From 23a24277703bb1f1f77d1c498379a1104703e45e Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Thu, 12 Feb 2026 23:07:57 +0300 Subject: [PATCH 3/9] feat: add Docker support with Dockerfile and .dockerignore --- .github/workflows/python-ci.yml | 80 ++++++++++++ app_python/app.py | 5 +- app_python/docs/LAB01.md | 71 ++++++----- app_python/health_check/router.py | 18 --- app_python/health_check/service.py | 104 ---------------- app_python/requirements.txt | 3 +- app_python/routes/__init__.py | 4 + .../{ => routes}/health_check/__init__.py | 0 app_python/routes/health_check/router.py | 9 ++ app_python/routes/health_check/schemas.py | 7 ++ app_python/routes/health_check/service.py | 31 +++++ app_python/routes/root/__init__.py | 0 app_python/routes/root/router.py | 12 ++ .../{health_check => routes/root}/schemas.py | 8 +- app_python/routes/root/service.py | 115 ++++++++++++++++++ app_python/tests/health_check/__init__.py | 0 app_python/tests/health_check/test_router.py | 37 ++++++ app_python/tests/health_check/test_service.py | 34 ++++++ app_python/tests/root/__init__.py | 0 app_python/tests/root/test_router.py | 70 +++++++++++ app_python/tests/root/test_service.py | 0 21 files changed, 440 insertions(+), 168 deletions(-) create mode 100644 .github/workflows/python-ci.yml delete mode 100644 app_python/health_check/router.py delete mode 100644 app_python/health_check/service.py create mode 100644 app_python/routes/__init__.py rename app_python/{ => routes}/health_check/__init__.py (100%) create mode 100644 app_python/routes/health_check/router.py create mode 100644 app_python/routes/health_check/schemas.py create mode 100644 app_python/routes/health_check/service.py create mode 100644 app_python/routes/root/__init__.py create mode 100644 app_python/routes/root/router.py rename app_python/{health_check => routes/root}/schemas.py (85%) create mode 100644 app_python/routes/root/service.py create mode 100644 app_python/tests/health_check/__init__.py create mode 100644 app_python/tests/health_check/test_router.py create mode 100644 app_python/tests/health_check/test_service.py create mode 100644 app_python/tests/root/__init__.py create mode 100644 app_python/tests/root/test_router.py create mode 100644 app_python/tests/root/test_service.py diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..89e48ef44c --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,80 @@ +name: Python CI + +on: + [push, pull_request] + +permissions: + contents: read + +jobs: + + test: + name: Lint & Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + fail-fast: true + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest ruff + + - name: Lint + run: ruff check . + + - name: Run tests + run: pytest + + - name: Run Snyk + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + command: test + + + docker: + name: Build & Push Docker + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set version (CalVer) + id: version + run: | + echo "VERSION=$(date +'%Y.%m')" >> $GITHUB_OUTPUT + + - 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@v5 + with: + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/app_python:${{ steps.version.outputs.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/app_python:latest diff --git a/app_python/app.py b/app_python/app.py index 9207e80411..0ca8bf8edc 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -2,12 +2,13 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from config import DEBUG, PORT, HOST -from health_check.router import router +from routes import health_router, root_router from logger_config import setup_logger setup_logger() app = FastAPI(debug=DEBUG) -app.include_router(router=router) +for router in [health_router, root_router]: + app.include_router(router=router) app.add_middleware( CORSMiddleware, diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md index 9710dad499..332983fd8e 100644 --- a/app_python/docs/LAB01.md +++ b/app_python/docs/LAB01.md @@ -3,7 +3,7 @@ I chose FastApi because it's simple, easy to create endpoints, and has automatic documentation. | Framework | Pros | Cons | Reason Not Chosen | -|-------------| ----------------------------------------------------- | ------------------------------------------- | --------------------------------- | +|-------------|-------------------------------------------------------|---------------------------------------------|-----------------------------------| | **FastAPI** | Async support, type safety, OpenAPI, high performance | Slight learning curve | **Chosen** | | Flask | Simple, minimal | No async by default, no built-in validation | Less suitable for structured APIs | | Django | Full-featured, mature | Heavy, overkill for small service | Too complex for this task | @@ -12,46 +12,45 @@ I chose FastApi because it's simple, easy to create endpoints, and has automatic 1. Environment-based Configuration - ```python - HOST = os.getenv("HOST", "0.0.0.0") - PORT = int(os.getenv("PORT", 5000)) - DEBUG = os.getenv("DEBUG", "False").lower() == "true" - ``` - - it important because it enables configuration without code changes. + ```text + HOST = os.getenv("HOST", "0.0.0.0") + PORT = int(os.getenv("PORT", 5000)) + DEBUG = os.getenv("DEBUG", "False").lower() == "true" + ``` + +it important because it enables configuration without code changes. 2. Separation of Concerns - ```python - class HealthCheckService: - async def get_info(self, request: Request) -> InfoResponse: - ... - - ``` - - it important because it easier testing, cleaner routing layer +```text + class HealthCheckService: + async def get_info(self, request: Request) -> InfoResponse: + pass +``` + +it important because it easier testing, cleaner routing layer 3. Typed Responses with Pydantic - ```python - class InfoResponse(BaseModel): - service: ServiceInfo - system: SystemInfo - runtime: RuntimeInfo - request: RequestInfo - endpoints: list[EndpointInfo] - ``` - - it important because guarantees response structure and improves readability +```text +class InfoResponse(BaseModel): + service: ServiceInfo + system: SystemInfo + runtime: RuntimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] +``` + +it important because guarantees response structure and improves readability 4. Logging - ```python - logger = logging.getLogger(__name__) - logger.info("Handling info request") - ``` - - it important because it centralized observability and works seamlessly with Uvicorn +```text +logger = logging.getLogger(__name__) +logger.info("Handling info request") +``` + +it important because it centralized observability and works seamlessly with Uvicorn ## API Documentation @@ -111,7 +110,7 @@ I chose FastApi because it's simple, easy to create endpoints, and has automatic "uptime_seconds": 7390 } ``` - + 3. Testing Commands Using curl: @@ -119,13 +118,13 @@ I chose FastApi because it's simple, easy to create endpoints, and has automatic curl http://localhost:5000/ curl http://localhost:5000/health ``` - + or auto generated documentation: - + ```bash http://localhost:5000/docs ``` - + ## Testing Evidence - Successful responses from `/` and `/health` diff --git a/app_python/health_check/router.py b/app_python/health_check/router.py deleted file mode 100644 index 84fa2ee4e6..0000000000 --- a/app_python/health_check/router.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import APIRouter, Request -from health_check.schemas import InfoResponse, HealthResponse -from health_check.service import HealthCheckServiceDep - -router = APIRouter() - - -@router.get("/", description="Service information") -async def get_info( - service: HealthCheckServiceDep, - request: Request, -) -> InfoResponse: - return await service.get_info(request=request) - - -@router.get("/health", description="Health check") -async def health_check(service: HealthCheckServiceDep) -> HealthResponse: - return await service.health_check() diff --git a/app_python/health_check/service.py b/app_python/health_check/service.py deleted file mode 100644 index ab2939d887..0000000000 --- a/app_python/health_check/service.py +++ /dev/null @@ -1,104 +0,0 @@ -import logging -import socket -import platform -from datetime import datetime, timezone -import os -from typing import Annotated - -from fastapi import Request, Depends -from fastapi.routing import APIRoute - -from utils import APP_START_TIME -from health_check.schemas import ( - InfoResponse, - EndpointInfo, - ServiceInfo, - SystemInfo, - RuntimeInfo, - RequestInfo, - HealthResponse, -) - -logger = logging.getLogger(__name__) - - -class HealthCheckService: - @staticmethod - def get_uptime(start_time) -> tuple[int, str]: - delta = datetime.now(tz=timezone.utc) - start_time - seconds = int(delta.total_seconds()) - hours = seconds // 3600 - minutes = (seconds % 3600) // 60 - return seconds, f"{hours} hours, {minutes} minutes" - - async def get_info(self, request: Request) -> InfoResponse: - try: - logger.info("Starting to find info") - - hostname = socket.gethostname() - platform_name = platform.system() - architecture = platform.machine() - python_version = platform.python_version() - cpu_count = os.cpu_count() - platform_version = platform.version() - - current_time = datetime.now(tz=timezone.utc) - uptime_seconds, uptime_human = self.get_uptime(APP_START_TIME) - - client_ip = request.client.host if request.client else "unknown" - user_agent = request.headers.get("user-agent") - method = request.method - path = request.url.path - - endpoints = [] - for route in request.app.routes: - if isinstance(route, APIRoute): - for method in route.methods: - endpoints.append( - EndpointInfo( - path=route.path, - method=method, - description=route.description, - ) - ) - - return InfoResponse( - service=ServiceInfo( - name="devops-info-service", - version="1.0.0", - description="DevOps course info service", - framework="Fastapi", - ), - system=SystemInfo( - hostname=hostname, - platform=platform_name, - platform_version=platform_version, - architecture=architecture, - cpu_count=cpu_count, - python_version=python_version, - ), - runtime=RuntimeInfo( - uptime_seconds=uptime_seconds, - uptime_human=uptime_human, - current_time=current_time, - timezone="UTC", - ), - request=RequestInfo( - client_ip=client_ip, user_agent=user_agent, method=method, path=path - ), - endpoints=endpoints, - ) - except Exception as e: - logger.exception(e) - raise - - async def health_check(self) -> HealthResponse: - logger.info("Health check called") - return HealthResponse( - status="healthy", - timestamp=datetime.now(tz=timezone.utc), - uptime_seconds=self.get_uptime(APP_START_TIME)[0], - ) - - -HealthCheckServiceDep = Annotated[HealthCheckService, Depends(HealthCheckService)] diff --git a/app_python/requirements.txt b/app_python/requirements.txt index 6818010aec..9d9c76ccf1 100644 --- a/app_python/requirements.txt +++ b/app_python/requirements.txt @@ -1,3 +1,4 @@ uvicorn==0.40.0 pydantic==2.12.5 -fastapi==0.128.0 \ No newline at end of file +fastapi==0.128.0 +pytest==9.0.2 \ No newline at end of file diff --git a/app_python/routes/__init__.py b/app_python/routes/__init__.py new file mode 100644 index 0000000000..1ac227689e --- /dev/null +++ b/app_python/routes/__init__.py @@ -0,0 +1,4 @@ +from .health_check.router import router as health_router +from .root.router import router as root_router + +__all__ = ["root_router", "health_router"] \ No newline at end of file diff --git a/app_python/health_check/__init__.py b/app_python/routes/health_check/__init__.py similarity index 100% rename from app_python/health_check/__init__.py rename to app_python/routes/health_check/__init__.py diff --git a/app_python/routes/health_check/router.py b/app_python/routes/health_check/router.py new file mode 100644 index 0000000000..52e8175d36 --- /dev/null +++ b/app_python/routes/health_check/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from routes.health_check.schemas import HealthResponse +from routes.health_check.service import HealthCheckServiceDep + +router = APIRouter() + +@router.get("/health", description="Health check") +async def health_check(service: HealthCheckServiceDep) -> HealthResponse: + return await service.health_check() diff --git a/app_python/routes/health_check/schemas.py b/app_python/routes/health_check/schemas.py new file mode 100644 index 0000000000..0c9dc93020 --- /dev/null +++ b/app_python/routes/health_check/schemas.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from datetime import datetime + +class HealthResponse(BaseModel): + status: str + timestamp: datetime + uptime_seconds: int diff --git a/app_python/routes/health_check/service.py b/app_python/routes/health_check/service.py new file mode 100644 index 0000000000..a7d62a9d5f --- /dev/null +++ b/app_python/routes/health_check/service.py @@ -0,0 +1,31 @@ +import logging +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import Depends + +from utils import APP_START_TIME +from routes.health_check.schemas import HealthResponse + +logger = logging.getLogger(__name__) + + +class HealthCheckService: + @staticmethod + def get_uptime(start_time) -> tuple[int, str]: + delta = datetime.now(tz=timezone.utc) - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return seconds, f"{hours} hours, {minutes} minutes" + + async def health_check(self) -> HealthResponse: + logger.info("Health check called") + return HealthResponse( + status="healthy", + timestamp=datetime.now(tz=timezone.utc), + uptime_seconds=self.get_uptime(APP_START_TIME)[0], + ) + + +HealthCheckServiceDep = Annotated[HealthCheckService, Depends(HealthCheckService)] diff --git a/app_python/routes/root/__init__.py b/app_python/routes/root/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/routes/root/router.py b/app_python/routes/root/router.py new file mode 100644 index 0000000000..672187eda2 --- /dev/null +++ b/app_python/routes/root/router.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from routes.root.schemas import InfoResponse +from routes.root.service import SysInfoServiceDep + +router = APIRouter() + + +@router.get("/", description="Service information") +async def get_info( + service: SysInfoServiceDep, +) -> InfoResponse: + return await service.get_info() \ No newline at end of file diff --git a/app_python/health_check/schemas.py b/app_python/routes/root/schemas.py similarity index 85% rename from app_python/health_check/schemas.py rename to app_python/routes/root/schemas.py index b6cfd4dc97..5f65cf20c5 100644 --- a/app_python/health_check/schemas.py +++ b/app_python/routes/root/schemas.py @@ -43,10 +43,4 @@ class InfoResponse(BaseModel): system: SystemInfo runtime: RuntimeInfo request: RequestInfo - endpoints: list[EndpointInfo] - - -class HealthResponse(BaseModel): - status: str - timestamp: datetime - uptime_seconds: int + endpoints: list[EndpointInfo] \ No newline at end of file diff --git a/app_python/routes/root/service.py b/app_python/routes/root/service.py new file mode 100644 index 0000000000..6b47a6a637 --- /dev/null +++ b/app_python/routes/root/service.py @@ -0,0 +1,115 @@ +import logging +import socket +import platform +from datetime import datetime, timezone +import os +from typing import Annotated + +from fastapi import Request, Depends +from fastapi.routing import APIRoute + +from utils import APP_START_TIME +from routes.root.schemas import ( + InfoResponse, + EndpointInfo, + ServiceInfo, + SystemInfo, + RuntimeInfo, + RequestInfo, +) + +logger = logging.getLogger(__name__) + + +class SysInfoService: + def __init__(self, request: Request): + self.request = request + + @staticmethod + def _get_uptime(start_time) -> tuple[int, str]: + delta = datetime.now(tz=timezone.utc) - start_time + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return seconds, f"{hours} hours, {minutes} minutes" + + @staticmethod + def _get_service_info() -> ServiceInfo: + logger.info("Starting to find service info") + + return ServiceInfo( + name="devops-info-service", + version="1.0.0", + description="DevOps course info service", + framework="Fastapi", + ) + + def _get_system_info(self) -> SystemInfo: + hostname = socket.gethostname() + platform_name = platform.system() + architecture = platform.machine() + python_version = platform.python_version() + cpu_count = os.cpu_count() + platform_version = platform.version() + + return SystemInfo( + hostname=hostname, + platform=platform_name, + platform_version=platform_version, + architecture=architecture, + cpu_count=cpu_count, + python_version=python_version, + ) + + def _get_runtime_info(self) -> RuntimeInfo: + current_time = datetime.now(tz=timezone.utc) + uptime_seconds, uptime_human = self._get_uptime(APP_START_TIME) + + return RuntimeInfo( + uptime_seconds=uptime_seconds, + uptime_human=uptime_human, + current_time=current_time, + timezone="UTC", + ) + + def _get_request_info(self) -> RequestInfo: + client_ip = self.request.client.host if self.request.client else "unknown" + user_agent = self.request.headers.get("user-agent") + method = self.request.method + path = self.request.url.path + + return RequestInfo( + client_ip=client_ip, user_agent=user_agent, method=method, path=path + ) + + def _get_endpoints(self) -> list[EndpointInfo]: + endpoints = [] + for route in self.request.app.routes: + if isinstance(route, APIRoute): + for method in route.methods: + endpoints.append( + EndpointInfo( + path=route.path, + method=method, + description=route.description, + ) + ) + return endpoints + + async def get_info(self) -> InfoResponse: + try: + logger.info("Starting run main func") + + return InfoResponse( + service=self._get_service_info(), + system=self._get_system_info(), + runtime=self._get_runtime_info(), + request=self._get_request_info(), + endpoints=self._get_endpoints(), + ) + except Exception as e: + logger.exception(e) + raise + + +SysInfoServiceDep = Annotated[SysInfoService, Depends(SysInfoService)] diff --git a/app_python/tests/health_check/__init__.py b/app_python/tests/health_check/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/health_check/test_router.py b/app_python/tests/health_check/test_router.py new file mode 100644 index 0000000000..c3be217cef --- /dev/null +++ b/app_python/tests/health_check/test_router.py @@ -0,0 +1,37 @@ +from datetime import datetime, timezone + +import pytest +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture + +from app import app +from routes.health_check.schemas import HealthResponse +from routes.health_check.service import HealthCheckService + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def test_get_health_success(client: TestClient, mocker: MockerFixture): + mock_service = mocker.AsyncMock() + mock_service.health_check.return_value = HealthResponse( + status="healthy", + timestamp=datetime(2026, 2, 12, 11, 37, 1, 912380, tzinfo=timezone.utc), + uptime_seconds=1020 + ) + + # Override зависимости + app.dependency_overrides[HealthCheckService] = lambda: mock_service + + r = client.get("/health") + print(r.json()) + + assert r.status_code == 200 + assert r.json()["uptime_seconds"] == 1020 + assert r.json()["status"] == "healthy" + + mock_service.health_check.assert_awaited_once() + app.dependency_overrides.clear() + diff --git a/app_python/tests/health_check/test_service.py b/app_python/tests/health_check/test_service.py new file mode 100644 index 0000000000..1d97880067 --- /dev/null +++ b/app_python/tests/health_check/test_service.py @@ -0,0 +1,34 @@ +import pytest +from datetime import datetime, timezone, timedelta + +from pytest_mock import MockerFixture +from routes.health_check.service import HealthCheckService +from utils import APP_START_TIME +from routes.health_check.schemas import HealthResponse + + +@pytest.mark.asyncio +async def test_health_check_returns_healthy(): + service = HealthCheckService() + result: HealthResponse = await service.health_check() + + assert result.status == "healthy" + + assert isinstance(result.timestamp, datetime) + assert result.timestamp <= datetime.now(tz=timezone.utc) + + uptime_seconds, _ = service.get_uptime(APP_START_TIME) + assert result.uptime_seconds == uptime_seconds + + +@pytest.mark.asyncio +async def test_get_uptime_returns_correct_tuple(mocker: MockerFixture): + fixed_start = datetime(2026, 2, 12, 12, 0, tzinfo=timezone.utc) + mocker.patch("routes.health_check.service.APP_START_TIME", fixed_start) + + service_instance = HealthCheckService() + result: HealthResponse = await service_instance.health_check() + + assert result.status == "healthy" + expected_seconds, _ = service_instance.get_uptime(fixed_start) + assert result.uptime_seconds == expected_seconds diff --git a/app_python/tests/root/__init__.py b/app_python/tests/root/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/root/test_router.py b/app_python/tests/root/test_router.py new file mode 100644 index 0000000000..277fe4b4ba --- /dev/null +++ b/app_python/tests/root/test_router.py @@ -0,0 +1,70 @@ +import pytest +from fastapi.testclient import TestClient +from pytest_mock import MockerFixture +from datetime import datetime, timezone + +from app import app +from routes.root.service import SysInfoService +from routes.root.schemas import ( + InfoResponse, + ServiceInfo, + SystemInfo, + RuntimeInfo, + RequestInfo, + EndpointInfo, +) + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def test_get_info_router(client: TestClient, mocker: MockerFixture) -> None: + mock_service = mocker.AsyncMock() + mock_service.get_info.return_value = InfoResponse( + service=ServiceInfo( + name="test-service", + version="1.0", + description="desc", + framework="FastAPI" + ), + system=SystemInfo( + hostname="localhost", + platform="Linux", + platform_version="5.0", + architecture="x86_64", + cpu_count=4, + python_version="3.11" + ), + runtime=RuntimeInfo( + uptime_seconds=1000, + uptime_human="0 hours, 16 minutes", + current_time=datetime.now(tz=timezone.utc), + timezone="UTC" + ), + request=RequestInfo( + client_ip="127.0.0.1", + user_agent="pytest", + method="GET", + path="/" + ), + endpoints=[ + EndpointInfo(path="/", method="GET", description="Service information") + ] + ) + + app.dependency_overrides[SysInfoService] = lambda: mock_service + + response = client.get("/") + + assert response.status_code == 200 + json_data = response.json() + print(json_data) + assert json_data["service"]["name"] == "test-service" + assert json_data["system"]["hostname"] == "localhost" + assert json_data["runtime"]["uptime_seconds"] == 1000 + assert json_data["request"]["method"] == "GET" + assert len(json_data["endpoints"]) == 1 + + mock_service.get_info.assert_awaited_once() + app.dependency_overrides.clear() diff --git a/app_python/tests/root/test_service.py b/app_python/tests/root/test_service.py new file mode 100644 index 0000000000..e69de29bb2 From 3498d7116d1d527cd147eb5d91ffba247bce97d8 Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Thu, 12 Feb 2026 23:31:17 +0300 Subject: [PATCH 4/9] feat: add Docker support with Dockerfile and .dockerignore --- .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 89e48ef44c..059cc21953 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -35,7 +35,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r app_python/requirements.txt pip install pytest ruff - name: Lint From 477bc29219881c782bee90ed42d0bc43b164cf82 Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Thu, 12 Feb 2026 23:35:22 +0300 Subject: [PATCH 5/9] feat: add Docker support with Dockerfile and .dockerignore --- app_python/requirements.txt | 5 ++++- app_python/tests/health_check/test_service.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app_python/requirements.txt b/app_python/requirements.txt index 9d9c76ccf1..53cff444fe 100644 --- a/app_python/requirements.txt +++ b/app_python/requirements.txt @@ -1,4 +1,7 @@ uvicorn==0.40.0 pydantic==2.12.5 fastapi==0.128.0 -pytest==9.0.2 \ No newline at end of file +pytest==9.0.2 +ruff==0.15.0 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 diff --git a/app_python/tests/health_check/test_service.py b/app_python/tests/health_check/test_service.py index 1d97880067..a5dbcdb7f9 100644 --- a/app_python/tests/health_check/test_service.py +++ b/app_python/tests/health_check/test_service.py @@ -1,5 +1,5 @@ import pytest -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone from pytest_mock import MockerFixture from routes.health_check.service import HealthCheckService From 2b1fcc7928cd3d1e63b128f3aa9e53383ce47054 Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Thu, 12 Feb 2026 23:44:07 +0300 Subject: [PATCH 6/9] add LAB3.md --- app_python/docs/LAB3.md | 61 +++++++++++++++++++++++++++++++++++++ app_python/requirements.txt | 1 + 2 files changed, 62 insertions(+) create mode 100644 app_python/docs/LAB3.md diff --git a/app_python/docs/LAB3.md b/app_python/docs/LAB3.md new file mode 100644 index 0000000000..e346c0a4f1 --- /dev/null +++ b/app_python/docs/LAB3.md @@ -0,0 +1,61 @@ +## GitHub Actions Status Badge + +![CI](https://github.com///actions/workflows/python-ci.yml/badge.svg) + + +## Dependency Caching & Performance Improvement + +### Python dependencies are cached using GitHub Actions cache: +```yaml +- uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} +``` + +### Result: +Run Duration +Without cache ~2m 10s +With cache ~1m 05s + +## CI Best Practices Applied +### Dependency Caching +Speeds up pipelines by reusing installed packages. + +### Separate CI stages + +Workflow is logically split: +- Lint +- Tests +- Docker build & push +- Security scan + +### Secrets Management +Sensitive data (DOCKERHUB_TOKEN, SNYK_TOKEN) stored in GitHub Secrets. +Never committed to repository. + +### Versioned Docker Images +```text +YYYY.MM +latest +``` + +## Snyk Security Scanning + +Snyk is integrated using: + +```yaml +- uses: snyk/actions/python@master +``` +It scans Python dependencies for known vulnerabilities. + +## Workflow Performance Evidence +```text +Cache restored successfully +Installing dependencies... +Finished in 12 seconds + +pytest passed +Docker build completed +Snyk scan completed +``` \ No newline at end of file diff --git a/app_python/requirements.txt b/app_python/requirements.txt index 53cff444fe..ed12187c1c 100644 --- a/app_python/requirements.txt +++ b/app_python/requirements.txt @@ -5,3 +5,4 @@ pytest==9.0.2 ruff==0.15.0 pytest-asyncio==1.3.0 pytest-mock==3.15.1 +httpx==0.28.1 From c4a8322243d01f6bf5a3f951e5960bee56ccb0cb Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Thu, 12 Feb 2026 23:47:40 +0300 Subject: [PATCH 7/9] fix workflow --- .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 059cc21953..796b27faee 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -46,10 +46,10 @@ jobs: - name: Run Snyk uses: snyk/actions/python@master + with: + args: --file=app_python/requirements.txt env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} - with: - command: test docker: From 6660dedca5426ad35efb30b23a9ef0ea4417ac4c Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Thu, 12 Feb 2026 23:50:23 +0300 Subject: [PATCH 8/9] fix workflow --- .github/workflows/python-ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 796b27faee..3a349448c5 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -44,10 +44,11 @@ jobs: - name: Run tests run: pytest + - name: Setup Snyk + uses: snyk/actions/setup@master + - name: Run Snyk - uses: snyk/actions/python@master - with: - args: --file=app_python/requirements.txt + run: snyk test --file=app_python/requirements.txt env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} From 9ec9f4d6193abbb5677c968aa1350a76ddf2abfd Mon Sep 17 00:00:00 2001 From: Thi1ef Date: Thu, 12 Feb 2026 23:52:43 +0300 Subject: [PATCH 9/9] fix workflow --- .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 3a349448c5..d61e727044 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -72,10 +72,12 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push + - name: Build and push Docker image uses: docker/build-push-action@v5 with: + context: ./app_python + file: ./app_python/Dockerfile push: true tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/app_python:${{ steps.version.outputs.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/app_python:2026.02 ${{ secrets.DOCKERHUB_USERNAME }}/app_python:latest