diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..d61e727044 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,83 @@ +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 app_python/requirements.txt + pip install pytest ruff + + - name: Lint + run: ruff check . + + - name: Run tests + run: pytest + + - name: Setup Snyk + uses: snyk/actions/setup@master + + - name: Run Snyk + run: snyk test --file=app_python/requirements.txt + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + + + 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 Docker image + uses: docker/build-push-action@v5 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/app_python:2026.02 + ${{ secrets.DOCKERHUB_USERNAME }}/app_python:latest 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/.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/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 new file mode 100644 index 0000000000..022c11336f --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,76 @@ +## 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` | + +## 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/app.py b/app_python/app.py new file mode 100644 index 0000000000..0ca8bf8edc --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,22 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from config import DEBUG, PORT, HOST +from routes import health_router, root_router +from logger_config import setup_logger + +setup_logger() +app = FastAPI(debug=DEBUG) +for router in [health_router, root_router]: + 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..332983fd8e --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,137 @@ +## 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 + + ```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 + +```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 + +```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 + +```text +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/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 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/docs/screenshots/img.png b/app_python/docs/screenshots/img.png new file mode 100644 index 0000000000..ec290c9123 Binary files /dev/null and b/app_python/docs/screenshots/img.png differ diff --git a/app_python/docs/screenshots/img_1.png b/app_python/docs/screenshots/img_1.png new file mode 100644 index 0000000000..520b53752a Binary files /dev/null and b/app_python/docs/screenshots/img_1.png differ diff --git a/app_python/docs/screenshots/img_2.png b/app_python/docs/screenshots/img_2.png new file mode 100644 index 0000000000..9646c3cfd9 Binary files /dev/null and b/app_python/docs/screenshots/img_2.png differ 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..ed12187c1c --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,8 @@ +uvicorn==0.40.0 +pydantic==2.12.5 +fastapi==0.128.0 +pytest==9.0.2 +ruff==0.15.0 +pytest-asyncio==1.3.0 +pytest-mock==3.15.1 +httpx==0.28.1 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/routes/health_check/__init__.py b/app_python/routes/health_check/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/app_python/routes/health_check/__init__.py @@ -0,0 +1 @@ + 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/routes/root/schemas.py b/app_python/routes/root/schemas.py new file mode 100644 index 0000000000..5f65cf20c5 --- /dev/null +++ b/app_python/routes/root/schemas.py @@ -0,0 +1,46 @@ +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] \ 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/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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..a5dbcdb7f9 --- /dev/null +++ b/app_python/tests/health_check/test_service.py @@ -0,0 +1,34 @@ +import pytest +from datetime import datetime, timezone + +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 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)