diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..3122095879 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,122 @@ +name: Python CI/CD + +on: + - push + - pull_request + +env: + DOCKER_IMAGE: ${{ secrets.DOCKER_USERNAME }}/devops-info-service + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Cache Python dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('app_python/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + cd app_python + pip install -r requirements.txt + pip install ruff + + - name: Run linter (ruff) + run: | + cd app_python + ruff check app.py tests/ + + - name: Run tests + run: | + cd app_python + pytest tests/ -v --cov=. --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: app_python/coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + security: + name: Security Scan (Snyk) + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + cd app_python + pip install -r requirements.txt + + - name: Run Snyk to check for vulnerabilities + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high --file=app_python/requirements.txt --skip-unresolved + command: test + + build-and-push: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: [test, security] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_IMAGE }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix= + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: app_python + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..09d76ff1ac --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,43 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +.venv/ +venv/ +ENV/ +env/ + +.git/ +.gitignore +.gitattributes + +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +docs/ +README.md +*.md + +tests/ +.pytest_cache/ +.coverage +htmlcov/ + +.env +.env.local +.env.*.local + +*.log + +*.bak +*.tmp diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..d1bea01ab6 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.log +*.pot + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..e31abf4032 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + HOST=0.0.0.0 \ + PORT=5000 \ + DEBUG=False + +RUN groupadd -r appuser && useradd -r -g appuser -s /sbin/nologin -d /app appuser + +RUN mkdir -p /app && chown -R appuser:appuser /app + +WORKDIR /app + +COPY --chown=appuser:appuser requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY --chown=appuser:appuser app.py . + +USER appuser + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..ddbe92eeb4 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,166 @@ +# devops info service + +[![.github/workflows/python-ci.yml](https://github.com/agonychaser/devops-s26/actions/workflows/python-ci.yml/badge.svg)](https://github.com/agonychaser/devops-s26/actions/workflows/python-ci.yml) + +a web application providing detailed information about itself and its runtime environment. built with FastAPI as part of the devops course labs. + +## overview + +the devops info service is a monitoring foundation that reports system information and health status. this service will evolve throughout the course into a comprehensive monitoring tool with containerization, CI/CD, monitoring, and persistence capabilities. + +## prerequisites + +- python 3.11 or higher +- pip (python package manager) + +## installation + +1. create a virtual environment: + ```bash + python3 -m venv .venv + ``` + +2. activate the virtual environment: + + ```bash + source venv/bin/activate + ``` + +3. install dependencies: + ```bash + pip install -r requirements.txt + ``` + +## running the application + +start the application with default configuration: + +```bash +python app.py +``` + +or with custom configuration: + +```bash +HOST=127.0.0.1 PORT=3000 DEBUG=True python app.py +``` + +the service will be available at `http://localhost:5000` (or the configured port). + +## API endpoints + +### `GET /` + +returns comprehensive service and system information. + +**example response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "s-razmakhov", + "platform": "Darwin", + "platform_version": "macOS-26.2-arm64-arm-64bit", + "architecture": "arm64", + "cpu_count": 12, + "python_version": "3.9.6" + }, + "runtime": { + "uptime_seconds": 2, + "uptime_human": "2 seconds", + "current_time": "2026-01-28T20:07:01.956014+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) 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" + } + ] +} +``` + +### `GET /health` + +simple health check endpoint for monitoring and Kubernetes probes. + +**example response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T20:08:16.012061+00:00", + "uptime_seconds": 76 +} +``` + +## configuration + +the application can be configured via environment variables: + +| variable | default | description | +|----------|---------|-------------| +| `HOST` | `0.0.0.0` | host to bind the server to | +| `PORT` | `5000` | port to listen on | +| `DEBUG` | `False` | enable debug mode with auto-reload | + +## docker + +### building the image + +build the docker image from the `app_python` directory: + +```bash +docker build -t devops-info-service . +``` + +### running the container + +run the container with port mapping: + +```bash +docker run -d -p 5000:5000 devops-info-service +``` + +### pulling from docker hub + +pull the image from docker hub: + +```bash +docker pull onemoreslacker/devops-info-service:v0 +``` + +run the pulled image: + +```bash +docker run -d -p 5000:5000 onemoreslacker/devops-info-service:v0 +``` + +## testing + +```bash +# get main endpoint +curl http://localhost:5000/ + +# get health check +curl http://localhost:5000/health + +# 404 not found +curl http://localhost:5000/devops +``` diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..0b73d7acbc --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,229 @@ +""" +DevOps Info Service +Main application module - FastAPI implementation +""" +import logging +import os +import platform +import socket +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Any + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Configuration +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + +START_TIME = datetime.now(timezone.utc) + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage application lifespan events.""" + # Startup + logger.info(f'DevOps Info Service starting on {HOST}:{PORT}') + logger.info(f'Python version: {platform.python_version()}') + yield + # Shutdown + uptime = get_uptime() + logger.info(f'DevOps Info Service shutting down (uptime: {uptime["human"]})') + + +app = FastAPI( + title="DevOps Info Service", + description="DevOps course info service providing system information", + version="1.0.0", + lifespan=lifespan +) + + +# Pydantic models for response structure +class ServiceMetadata(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: str + 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 ServiceResponse(BaseModel): + service: ServiceMetadata + system: SystemInfo + runtime: RuntimeInfo + request: RequestInfo + endpoints: list[EndpointInfo] + + +class HealthResponse(BaseModel): + status: str + timestamp: str + uptime_seconds: int + + +def get_uptime() -> dict[str, Any]: + """Calculate application uptime in seconds and human-readable format.""" + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + + human_parts = [] + if hours: + human_parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes: + human_parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + if secs and not human_parts: + human_parts.append(f"{secs} second{'s' if secs != 1 else ''}") + + return { + 'seconds': seconds, + 'human': ', '.join(human_parts) if human_parts else '0 seconds' + } + + +def get_system_info() -> dict[str, Any]: + """Collect system information using platform module.""" + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.platform(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count() or 0, + 'python_version': platform.python_version() + } + + +@app.get('/', response_model=ServiceResponse, include_in_schema=False) +async def main(request: Request) -> ServiceResponse: + """ + Main endpoint - returns comprehensive service and system information. + """ + uptime = get_uptime() + system = get_system_info() + + # Get client IP - handle proxies + client_ip = request.client.host if request.client else 'unknown' + forwarded_for = request.headers.get('X-Forwarded-For') + if forwarded_for: + client_ip = forwarded_for.split(',')[0].strip() + + return ServiceResponse( + service=ServiceMetadata( + name='devops-info-service', + version='1.0.0', + description='DevOps course info service', + framework='FastAPI' + ), + system=SystemInfo(**system), + runtime=RuntimeInfo( + uptime_seconds=uptime['seconds'], + uptime_human=uptime['human'], + current_time=datetime.now(timezone.utc).isoformat(), + timezone=str(timezone.utc) + ), + request=RequestInfo( + client_ip=client_ip, + user_agent=request.headers.get('user-agent', 'unknown'), + method=request.method, + path=request.url.path + ), + endpoints=[ + EndpointInfo( + path='/', + method='GET', + description='Service information' + ), + EndpointInfo( + path='/health', + method='GET', + description='Health check' + ), + ] + ) + + +@app.get('/health', response_model=HealthResponse, include_in_schema=False) +async def health() -> HealthResponse: + """ + Health check endpoint - returns service health status. + Used for Kubernetes liveness/readiness probes. + """ + uptime = get_uptime() + + return HealthResponse( + status='healthy', + timestamp=datetime.now(timezone.utc).isoformat(), + uptime_seconds=uptime['seconds'] + ) + + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc: Exception) -> JSONResponse: + """Handle 404 Not Found errors.""" + logger.warning(f'Not found: {request.method} {request.url.path}') + return JSONResponse( + status_code=404, + content={'error': 'Not Found', 'message': 'Endpoint does not exist'} + ) + + +@app.exception_handler(500) +async def internal_error_handler(request: Request, exc: Exception) -> JSONResponse: + """Handle 500 Internal Server errors.""" + logger.error(f'Internal error: {exc}', exc_info=True) + return JSONResponse( + status_code=500, + content={'error': 'Internal Server Error', 'message': 'An unexpected error occurred'} + ) + + +if __name__ == '__main__': + uvicorn.run( + 'app:app', + host=HOST, + port=PORT, + reload=DEBUG, + log_level='info' + ) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..f1a312cead --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,239 @@ +# lab 01 submission: devops info service + +## framework selection (why FastAPI) + + +1. modern & async-first: built on Starlette with native async support, making it ideal for modern devops tools that may need concurrent operations. + +2. automatic documentation: interactive API documentation at `/docs` (Swagger UI) and `/redoc` without additional configuration. + +3. type safety: pydantic integration provides automatic data validation and clear API contracts. + +4. performance: one of the fastest python web frameworks, which is important for monitoring tools that need to respond quickly. + + +## best practices applied + +### 1. pydantic models for type safety + +```python +class ServiceMetadata(BaseModel): + name: str + version: str + description: str + framework: str +``` + +**importance**: provides automatic validation, documentation generation, and IDE autocompletion. reduces runtime errors from malformed data. + +### 2. structured logging + +```python +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc: Exception): + logger.warning(f'not found: {request.method} {request.url.path}') + # ... +``` + +**importance**: essential for production debugging and monitoring. structured logs help track issues in containerized environments where logs are the primary debugging tool. + +### 3. custom error handlers + +```python +@app.exception_handler(404) +async def not_found_handler(request: Request, exc: Exception): + return { + 'error': 'not found', + 'message': 'endpoint does not exist', + 'path': request.url.path + } +``` + +**importance**: consistent error responses make the API predictable and easier to integrate with monitoring systems. + +### 4. startup/shutdown hooks + +```python +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + logger.info(f'DevOps Info Service starting on {HOST}:{PORT}') + logger.info(f'Python version: {platform.python_version()}') + yield + # Shutdown + uptime = get_uptime() + logger.info(f'DevOps Info Service shutting down (uptime: {uptime["human"]})') +``` + +**importance**: critical for graceful shutdown in containerized environments (Docker/Kubernetes) and proper resource cleanup. + +## API documentation + +### endpoints overview + +| endpoint | method | description | +|----------|--------|-------------| +| `/` | GET | service and system information | +| `/health` | GET | health check | + +### testing commands + +**main endpoint:** +```bash +# basic request +curl http://localhost:5000/ + +# pretty-printed output +curl http://localhost:5000/ | jq +``` + +**health check:** +```bash +curl http://localhost:5000/health + +# check HTTP status code +curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/health +``` + +**custom port:** +```bash +PORT=8080 python app.py +curl http://localhost:8080/ +``` + +### example response - main endpoint (`GET /`) + +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "s-razmakhov", + "platform": "Darwin", + "platform_version": "macOS-26.2-arm64-arm-64bit", + "architecture": "arm64", + "cpu_count": 12, + "python_version": "3.9.6" + }, + "runtime": { + "uptime_seconds": 2, + "uptime_human": "2 seconds", + "current_time": "2026-01-28T20:07:01.956014+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) 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" + } + ] +} +``` + +### example response - health check (`GET /health`) + +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T20:08:16.012061+00:00", + "uptime_seconds": 76 +} +``` + +## testing evidence + +### screenshots + +*see screenshots in `docs/screenshots/`* + +1. **`01-main-endpoint.png`** - main endpoint showing complete JSON response +2. **`02-health-check.png`** - health check response +3. **`03-formatted-output.png`** - pretty-printed JSON output using `jq` + +### terminal output example + +```bash +(venv) λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* python3 app.py +INFO: Started server process [83518] +INFO: Waiting for application startup. +2026-01-28 23:26:40,735 - app - INFO - DevOps Info Service starting on 0.0.0.0:5000 +2026-01-28 23:26:40,735 - app - INFO - Python version: 3.9.6 +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit) +INFO: 127.0.0.1:55234 - "GET /health HTTP/1.1" 200 OK +INFO: 127.0.0.1:55234 - "GET / HTTP/1.1" 200 OK +^CINFO: Shutting down +INFO: Waiting for application shutdown. +2026-01-28 23:26:49,635 - app - INFO - DevOps Info Service shutting down (uptime: 8 seconds) +INFO: Application shutdown complete. +INFO: Finished server process [83518] +``` + +## challenges & solutions + + +### challenge i: uptime human-readable format + +**problem**: converting seconds to a readable format needed to handle various durations (seconds, minutes, hours) gracefully. + +**solution**: dynamic formatting based on elapsed time: +```python +hours = seconds // 3600 +minutes = (seconds % 3600) // 60 +secs = seconds % 60 + +human_parts = [] +if hours: + human_parts.append(f"{hours} hour{'s' if hours != 1 else ''}") +# ... similar for minutes and seconds +``` + +### challenge ii: platform version string + +**problem**: getting just the OS version information without the full platform string. + +**solution**: used `platform.platform()` which provides comprehensive platform information including OS, version, and architecture in a single string. + +## github community + +### why starring repositories matters + +starring repositories on github serves multiple important purposes in the open-source ecosystem: + +1. discovery & bookmarking: stars help bookmark interesting projects for future reference. starred repositories appear on your github profile, making it easy to find them later and showing others what technologies you're interested in. + +2. community signal: the star count indicates project popularity and community trust. high star counts help other developers discover quality tools and encourage maintainers by showing appreciation for their work. + +3. professional development: a thoughtful collection of starred repositories demonstrates awareness of industry tools, best practices, and emerging technologies to potential employers. + +### why following developers helps + +following developers on github is valuable for several reasons: + +1. learning & inspiration: seeing what others are working on, how they solve problems, and their code quality provides continuous learning opportunities. + +2. networking & collaboration: building connections with classmates, professors, and industry professionals creates a supportive community for future projects and career opportunities. + +3. staying current: following thought leaders and active contributors keeps you updated on trending projects, new tools, and best practices in your technology stack. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..3916be5dfe --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,334 @@ +# lab 02: containerizing the devops info service + +## docker best practices applied + +### 1. non-root user (security) + +```dockerfile +RUN groupadd -r appuser && useradd -r -g appuser -s /sbin/nologin -d /app appuser + +RUN mkdir -p /app && chown -R appuser:appuser /app + +USER appuser +``` + +**why it matters**: running as root is a significant security risk. if a vulnerability is exploited in the application or its dependencies, an attacker gains full control of the container with root privileges. by running as a non-root user, we limit the potential damage scope. + +### 2. specific base image version (reproducibility) + +```dockerfile +FROM python:3.13-slim +``` + +**why it matters**: using a specific version (`3.13-slim` instead of just `3` or `latest`) ensures reproducibility. without pinning the version, rebuilding the image in the future might pull a newer base image with breaking changes, causing unexpected failures. + +### 3. layer caching optimization (build performance) + +```dockerfile +COPY --chown=appuser:appuser requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY --chown=appuser:appuser app.py . +``` + +**why it matters**: docker builds images in layers, and each layer is cached if its contents haven't changed. by copying `requirements.txt` and installing dependencies before copying the application code, we ensure that code changes don't trigger a full reinstall of dependencies. + +### 4. .dockerignore (build performance & security) + +``` +__pycache__/ +*.py[cod] +*$py.class +*.so + +.venv/ +venv/ + +.git/ +.gitignore + +.vscode/ +.idea/ +.DS_Store + +tests/ +.pytest_cache/ + +docs/ +*.md +``` + +**why it matters**: `.dockerignore` prevents unnecessary files from being sent to the docker daemon during build. this has several benefits: + +1. **build speed**: sending the build context over the docker daemon takes time. excluding `.venv/` (hundreds of MB), `__pycache__/`, and other unnecessary files significantly speeds up the build process. + +2. **image size**: files that aren't copied into the image can't accidentally end up in it, keeping the final image smaller. + +3. **security**: sensitive files like `.env`, secrets, and git history shouldn't be in the image, even if not directly referenced. `.dockerignore` prevents accidental inclusion. + + +### 5. pip --no-cache-dir (image size) + +```dockerfile +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt +``` + +**why it matters**: pip caches downloaded packages in `~/.cache/pip` by default. in a docker image, this cache is unnecessary since the packages are already installed in site-packages. using `--no-cache-dir` prevents storing these cached wheel files, reducing the final image size. + +## image information & decisions + +### base image selection + +**justification**: + +| option | pros | cons | +|--------|------|------| +| `python:3.13` | includes all standard libraries, complete compatibility | unnecessarily large, slower pulls, more attack surface | +| `python:3.13-slim` | good balance of size and compatibility, includes common libraries | some less common packages may not work | +| `python:3.13-alpine` | smallest size, minimal attack surface | uses musl libc instead of glibc - some python wheels don't work, requires compilation | + +the `slim` variant was chosen because: +1. it's significantly smaller than the full image +2. it uses standard glibc, so all wheels work without compilation +3. it includes common libraries needed by our dependencies +4. it's the recommended choice for most production workloads according to docker's official python image documentation + +### final image size + +``` +REPOSITORY TAG IMAGE ID CREATED SIZE +devops-info-service latest f517ff3170ec 55 seconds ago 273MB +``` + +**assessment**: 273MB is an excellent size for a python web application. it's small enough for: +- fast pulls from registry (seconds, not minutes) +- efficient storage in private registries +- quick deployment to kubernetes clusters +- manageable disk usage even with multiple versions + +### layer structure explanation + +``` +L1: base image (python:3.13-slim) +L2: create user & directories +L3: set working directory +L4: copy requirements.txt +L5: pip install dependencies +L6: copy app.py +L7: expose port & health check +``` + +each layer builds on the previous one. layers are cached independently, so changing `app.py` only invalidates layers 6 and 7, not the dependency installation (layer 5). this is why layer ordering matters so much for build performance. + +### optimization choices + +1. **single-stage build**: for this simple app with no compilation steps, a multi-stage build would add complexity without significant benefits. the build artifacts are removed via `--no-cache-dir`, keeping the image small. + +2. **uvicorn in cmd instead of python app.py**: using `uvicorn app:app` directly is the production-recommended way to run fastapi. it ensures the asgi server is used directly without going through the python interpreter's module loading overhead. + +## build & run process + +### build process + +```bash +λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* docker build -t devops-info-service . +[+] Building 1.8s (12/12) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 784B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.5s + => [internal] load .dockerignore 0.0s + => => transferring context: 333B 0.0s + => [1/7] FROM docker.io/library/python:3.13-slim@sha256:49b618b8afc2742b94fa8419d8f4d3b337f111a0527d417a1db97d4683cb71a6 0.0s + => => resolve docker.io/library/python:3.13-slim@sha256:49b618b8afc2742b94fa8419d8f4d3b337f111a0527d417a1db97d4683cb71a6 0.0s + => [internal] load build context 0.0s + => => transferring context: 63B 0.0s + => CACHED [2/7] RUN groupadd -r appuser && useradd -r -g appuser -s /sbin/nologin -d /app appuser 0.0s + => CACHED [3/7] RUN mkdir -p /app && chown -R appuser:appuser /app 0.0s + => CACHED [4/7] WORKDIR /app 0.0s + => CACHED [5/7] COPY --chown=appuser:appuser requirements.txt . 0.0s + => CACHED [6/7] RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt 0.0s + => CACHED [7/7] COPY --chown=appuser:appuser app.py . 0.0s + => exporting to image 0.2s + => => exporting layers 0.0s + => => exporting manifest sha256:c14c025125461b2d0b426ec1d28e424f57672543ff09161024f6016247a34775 0.0s + => => exporting config sha256:934f921706517c37050b927c3173bd0a16e3660f597ac7517a65aca466fdbec2 0.0s + => => exporting attestation manifest sha256:fe7f451aefa0b7100ea11bd662fd5771915a8980a3dd4d0cebf4ddd532698b31 0.0s + => => exporting manifest list sha256:c6421ebc0664e665831b82e3e4342f4aacff40578f6d475d83a8a9401e936853 0.0s + => => naming to docker.io/library/devops-info-service:latest 0.0s + => => unpacking to docker.io/library/devops-info-service:latest 0.2s +``` + +### run process + +```bash +λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* docker run -p 5000:5000 -d devops-info-service +aade498c5fabd56df37b71672e7c500bf15ced6c8517d7ad166882830ee2e1db +λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* docker ps -a +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +aade498c5fab devops-info-service "uvicorn app:app --h…" 39 seconds ago Up 38 seconds (healthy) 0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp elated_heisenberg +``` + +### testing endpoints + +```bash +λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* curl localhost:5000 +{"service":{"name":"devops-info-service","version":"1.0.0","description":"DevOps course info service","framework":"FastAPI"},"system":{"hostname":"aade498c5fab","platform":"Linux","platform_version":"Linux-6.10.14-linuxkit-aarch64-with-glibc2.41","architecture":"aarch64","cpu_count":12,"python_version":"3.13.12"},"runtime":{"uptime_seconds":94,"uptime_human":"1 minute","current_time":"2026-02-05T10:22:23.770311+00:00","timezone":"UTC"},"request":{"client_ip":"192.168.65.1","user_agent":"curl/8.7.1","method":"GET","path":"/"},"endpoints":[{"path":"/","method":"GET","description":"Service information"},{"path":"/health","method":"GET","description":"Health check"}]}% +λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* curl localhost:5000/health +{"status":"healthy","timestamp":"2026-02-05T10:22:29.978475+00:00","uptime_seconds":101}% +``` + +### image size verification + +```bash +docker images devops-info-service +``` + +``` +REPOSITORY TAG IMAGE ID CREATED SIZE +devops-info-service latest abc123def456 5 minutes ago 143MB +``` + +## docker hub + +### successful push + +```bash +λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* docker tag devops-info-service:latest onemoreslacker/devops-info-service:v0 +λ ~/bucket/courses/uni/devops-s26/app_python/ lab01* docker push onemoreslacker/devops-info-service:v0 +The push refers to repository [docker.io/onemoreslacker/devops-info-service] +4267f74b21c9: Pushed +26e6cfdcdd79: Pushed +14c37da83ac4: Pushed +2e7c982ef2d0: Pushed +d00bf8b69cc9: Pushed +af94c6242df3: Pushed +4c4a8dac9336: Pushed +4f4fb700ef54: Mounted from wazuh/wazuh-manager +90e3d2267298: Pushed +23058a1975cc: Pushed +3ea009573b47: Pushed +v0: digest: sha256:c6421ebc0664e665831b82e3e4342f4aacff40578f6d475d83a8a9401e936853 size: 856 +``` + +### docker hub repository url +https://hub.docker.com/r/onemoreslacker/devops-info-service + +### tagging strategy +my tagging strategy follows semantic versioning: + +| tag | when to update | example | +|-----|----------------|---------| +| `latest` | every push (default) | `docker push user/repo:latest` | +| `v1.0.0` | stable release | `docker push user/repo:v1.0.0` | +| `v1.0.1`, `v1.1.0` | patch/minor updates | `docker push user/repo:v1.0.1` | +| `dev`, `staging` | environment-specific | `docker push user/repo:dev` | + +### public accessibility + +```bash +λ ~/ docker pull onemoreslacker/devops-info-service:v0 +v0: Pulling from onemoreslacker/devops-info-service +Digest: sha256:c6421ebc0664e665831b82e3e4342f4aacff40578f6d475d83a8a9401e936853 +Status: Image is up to date for onemoreslacker/devops-info-service:v0 +docker.io/onemoreslacker/devops-info-service:v0 +``` + +## technical analysis + +### how the dockerfile works + +1. **base layer**: we start with `python:3.13-slim`, which provides: + - a minimal debian operating system + - python 3.13 interpreter + - pip package manager + - common system libraries + +2. **user creation**: we create a dedicated user `appuser` with no login shell. this is more secure than using the default `python` user. + +3. **working directory**: `/app` is created and owned by `appuser`. this is where our application will live. + +4. **dependency installation**: we copy only `requirements.txt` first, then install dependencies. the `--chown=appuser:appuser` flag ensures the installed packages are owned by the non-root user, which is required because we switch users before running the app. + +5. **application code**: we copy `app.py` into the working directory. + +6. **user switch**: we switch to the `appuser` user. all subsequent commands run without root privileges. + +7. **health check**: the health check runs inside the container as the `appuser`, checking the `/health` endpoint. + +8. **cmd**: the final command starts uvicorn, which serves the fastapi application. + +### what happens if layer order is changed? + +if we reversed the order and copied `app.py` before `requirements.txt`: + +```dockerfile +COPY app.py . +COPY requirements.txt . +RUN pip install -r requirements.txt +``` + +**consequences**: +- **no caching benefit**: any change to `app.py` (typo, comment, code change) invalidates all subsequent layers. pip would reinstall all dependencies on every build, even though they haven't changed. + +- **slower builds**: what should be a 2-second rebuild becomes a 30-second rebuild because pip downloads and installs fastapi, uvicorn, pydantic every time. + + + +### security considerations implemented + +1. **non-root user**: limits the attack surface. even if the application is compromised, the attacker cannot: + - modify system files + - install new packages + - read other containers' data (if they share the same user namespace) + - escalate to host root (due to user namespaces) + +2. **minimal base image**: the `slim` variant has fewer packages installed, reducing the attack surface. fewer packages mean fewer potential vulnerabilities. + +3. **no secrets in image**: `.dockerignore` prevents `.env` files from being included. secrets should be injected at runtime via environment variables or docker secrets, not baked into the image. + +### how .dockerignore improves the build + +it eliminates the following files: +- `.venv/` can be hundreds of MB with all the site-packages +- `__pycache__/` contains compiled python files +- `.git/` contains the entire repository history +- IDE files (`.idea/`, `.vscode/`) can be large + +additionally, `.dockerignore` prevents accidental inclusion: +- without it, a `docker build` from the wrong directory might include unrelated files +- sensitive files (like `.env`) could end up in the image layers +- test files and documentation files might be copied, bloating the image + +## challenges & solutions + +### challenge i: health check command + +**problem**: the initial health check using `curl` failed because `curl` is not installed in the `python:3.13-slim` image. + +**attempted solution 1**: install curl +```dockerfile +RUN apt-get update && apt-get install -y curl +``` +**downside**: increases image size by ~5MB and adds attack surface. + +**final solution**: use python's built-in `urllib`: +```dockerfile +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1 +``` +**benefit**: no additional packages needed, works out-of-the-box, keeps image small. + +### challenge ii: understanding layer caching behavior + +**problem**: initially confused about when layers are cached vs invalidated. + +**learned**: +- layers are cached if the instruction hasn't changed +- `COPY` invalidates cache if the file content has changed (based on checksum) +- `RUN` invalidates cache if the command string has changed +- `ADD` behaves similarly to `COPY` but has additional features (url, extraction) + +**example**: changing a comment in the dockerfile doesn't invalidate cache because the layer content (not the file) is what's cached. but changing a command argument (like `pip install --upgrade pip`) creates a new layer. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..af75d16f89 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,174 @@ +# lab 03: continuous integration (ci/cd) + +## overview + +### testing framework: pytest + +i chose **pytest** for unit testing due to: + +1. simple syntax: tests are written using plain python functions with assert statements - no need for test classes or special methods like in unittest. + +2. powerful fixtures: pytest's fixture system provides flexible test setup/teardown, allowing shared test context and dependency injection. + +3. excellent plugin ecosystem: wide range of plugins available including `pytest-cov` for coverage reporting. + +4. industry standard: pytest is the de facto choice for modern python projects, making it easier for others to contribute. + +### test coverage + +tests cover both endpoints: +- `GET /` - validates complete JSON structure, all required fields, data types, and error cases +- `GET /health` - validates health status, timestamp, and uptime fields + +### ci workflow configuration + +the workflow triggers on **any push and pull request**, ensuring code quality is checked continuously across all branches. + +### versioning strategy: semantic versioning (semver) + +i chose semver for docker image tagging: +- format: `major.minor.patch` (e.g., `1.0.0`) +- tags: full version, minor version, `latest`, and commit sha +- when to use: traditional software releases where breaking changes need explicit communication + +semver provides clear signals about api compatibility changes, which is important for services consumed by other applications. + +## workflow evidence + +### github actions workflow link + +[![.github/workflows/python-ci.yml](https://github.com/agonychaser/devops-s26/actions/workflows/python-ci.yml/badge.svg)](https://github.com/agonychaser/devops-s26/actions/workflows/python-ci.yml) + +### docker hub image + +images are published to: `https://hub.docker.com/r/razmakhovs/devops-info-service` + +tags include: +- `latest` - most recent successful build +- commit sha - exact version reference for reproducibility +- branch name - for tracking per-branch builds + +## best practices implemented + +### 1. dependency caching + +```yaml +- name: Cache Python dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('app_python/requirements.txt') }} +``` + +**why it helps**: caches pip packages between workflow runs, reducing install time from ~1 minute to ~10 seconds on cache hit. the key is based on `requirements.txt` hash, ensuring cache invalidates when dependencies change. + +### 2. job dependencies + +```yaml +build-and-push: + needs: [test, security] +``` + +**why it helps**: ensures docker images are only built and pushed when tests pass and security scans complete. prevents publishing potentially broken or vulnerable code to docker hub. + +### 3. conditional docker push + +```yaml +if: github.event_name == 'push' +``` + +**why it helps**: only pushes images to docker hub on actual pushes (not on pull requests), preventing unnecessary builds and registry bloat. + +### 4. built-in pip caching + +```yaml +- uses: actions/setup-python@v5 + with: + cache: 'pip' +``` + +**why it helps**: leverages github actions' native pip caching for faster dependency installation with minimal configuration. + +### 5. ruff linting + +```yaml +- name: Run linter (ruff) + run: | + cd app_python + ruff check app.py tests/ +``` + +**why it helps**: fast python linter written in rust. catches code style issues, potential bugs, and import problems before tests run. much faster than alternatives like pylint or flake8. + +### 6. snyk security scanning + +```yaml +- name: Run Snyk to check for vulnerabilities + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high +``` + +**why it helps**: automatically scans dependencies for known vulnerabilities. `--severity-threshold=high` means builds only fail on high/critical issues, allowing medium/low issues to be tracked without blocking development. + +### 7. docker build caching + +```yaml +- uses: docker/build-push-action@v6 + with: + cache-from: type=gha + cache-to: type=gha,mode=max +``` + +**why it helps**: caches docker build layers in github actions cache, significantly reducing build time for subsequent runs. + +## key decisions + +### versioning strategy: semver + +chose semantic versioning because: +- clear communication about api changes (major = breaking, minor = features, patch = fixes) +- industry standard for libraries and services +- allows consumers to pin to specific versions while getting automatic patch updates + +### docker tags + +ci creates these tags: +- `latest` - always points to most recent successful build +- `{branch-name}` - for tracking branch-specific builds +- `{sha}` - exact commit reference for reproducibility +- `{major}.{minor}` - rolling release branch for minor version +- `{major}.{minor}.{patch}` - exact version (requires git tag) + +### workflow triggers + +all pushes and pull requests trigger the full workflow: +- push: validates code before/after merging +- pull request: ensures incoming changes pass all checks + +this provides fast feedback on all changes while protecting the main branch. + +### test coverage + +**tested:** +- json structure validation for both endpoints +- required fields presence +- data type verification +- successful responses (200 status) +- error cases (404, 405) +- http method routing + +**not tested:** +- external service integration (none exists) +- database operations (not applicable yet) +- complex error scenarios beyond basic 404 + +## challenges + +### challenge: python 3.14 compatibility + +**issue**: local development environment uses python 3.14, but pydantic-core (pyo3) doesn't support python 3.14 yet. + +**solution**: configured ci to use python 3.12, which is compatible with all dependencies. this demonstrates a key devops principle: ci environment doesn't need to match local dev environment. diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..a253ec076f Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..7ea8c9cdd4 Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png.png b/app_python/docs/screenshots/03-formatted-output.png.png new file mode 100644 index 0000000000..8f3f2b1788 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..3b887b5703 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,12 @@ +# Web Framework +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +pydantic==2.10.3 + +# Testing +pytest==8.3.3 +pytest-cov==6.0.0 +httpx==0.28.1 + +# Linting +ruff==0.8.4 diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..beb8407539 --- /dev/null +++ b/app_python/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for DevOps Info Service.""" diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..9653d627a7 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,152 @@ +""" +Unit tests for DevOps Info Service FastAPI application. +""" +import pytest +from fastapi.testclient import TestClient +from app import app + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI application.""" + return TestClient(app) + + +class TestMainEndpoint: + """Tests for the main GET / endpoint.""" + + def test_main_endpoint_success(self, client): + """Test that GET / returns correct JSON structure with all required fields.""" + response = client.get('/') + + assert response.status_code == 200 + data = response.json() + + # Verify service metadata + assert 'service' in data + assert data['service']['name'] == 'devops-info-service' + assert 'version' in data['service'] + assert 'description' in data['service'] + assert data['service']['framework'] == 'FastAPI' + + # Verify system information + assert 'system' in data + assert 'hostname' in data['system'] + assert 'platform' in data['system'] + assert 'architecture' in data['system'] + assert 'cpu_count' in data['system'] + assert isinstance(data['system']['cpu_count'], int) + assert 'python_version' in data['system'] + + # Verify runtime information + assert 'runtime' in data + assert 'uptime_seconds' in data['runtime'] + assert isinstance(data['runtime']['uptime_seconds'], int) + assert 'uptime_human' in data['runtime'] + assert 'current_time' in data['runtime'] + assert 'timezone' in data['runtime'] + + # Verify request information + assert 'request' in data + assert 'client_ip' in data['request'] + assert 'user_agent' in data['request'] + assert data['request']['method'] == 'GET' + assert data['request']['path'] == '/' + + # Verify endpoints list + assert 'endpoints' in data + assert isinstance(data['endpoints'], list) + assert len(data['endpoints']) >= 2 + + def test_main_endpoint_data_types(self, client): + """Test that response data has correct types.""" + response = client.get('/') + data = response.json() + + assert isinstance(data['service'], dict) + assert isinstance(data['system'], dict) + assert isinstance(data['runtime'], dict) + assert isinstance(data['request'], dict) + assert isinstance(data['endpoints'], list) + + def test_main_endpoint_required_fields_present(self, client): + """Test that all fields from the spec are present.""" + response = client.get('/') + data = response.json() + + # Top-level fields + required_fields = ['service', 'system', 'runtime', 'request', 'endpoints'] + for field in required_fields: + assert field in data, f"Missing required field: {field}" + + # Service fields + service_fields = ['name', 'version', 'description', 'framework'] + for field in service_fields: + assert field in data['service'], f"Missing service field: {field}" + + # System fields + system_fields = ['hostname', 'platform', 'platform_version', 'architecture', 'cpu_count', 'python_version'] + for field in system_fields: + assert field in data['system'], f"Missing system field: {field}" + + # Runtime fields + runtime_fields = ['uptime_seconds', 'uptime_human', 'current_time', 'timezone'] + for field in runtime_fields: + assert field in data['runtime'], f"Missing runtime field: {field}" + + # Request fields + request_fields = ['client_ip', 'user_agent', 'method', 'path'] + for field in request_fields: + assert field in data['request'], f"Missing request field: {field}" + + +class TestHealthEndpoint: + """Tests for the GET /health endpoint.""" + + def test_health_endpoint_success(self, client): + """Test that GET /health returns correct health status.""" + response = client.get('/health') + + assert response.status_code == 200 + data = response.json() + + assert data['status'] == 'healthy' + assert 'timestamp' in data + assert 'uptime_seconds' in data + assert isinstance(data['uptime_seconds'], int) + + def test_health_endpoint_json_structure(self, client): + """Test that health endpoint returns correct JSON structure.""" + response = client.get('/health') + data = response.json() + + required_fields = ['status', 'timestamp', 'uptime_seconds'] + for field in required_fields: + assert field in data, f"Missing health field: {field}" + + def test_health_endpoint_data_types(self, client): + """Test that health endpoint data has correct types.""" + response = client.get('/health') + data = response.json() + + assert isinstance(data['status'], str) + assert isinstance(data['timestamp'], str) + assert isinstance(data['uptime_seconds'], int) + assert data['uptime_seconds'] >= 0 + + +class TestErrorHandling: + """Tests for error handling.""" + + def test_404_not_found(self, client): + """Test that non-existent endpoints return 404.""" + response = client.get('/nonexistent') + assert response.status_code == 404 + data = response.json() + assert 'error' in data or 'detail' in data + + def test_post_not_allowed(self, client): + """Test that POST to unsupported endpoints returns appropriate error.""" + # POST to main endpoint (should fail as it's GET only) + response = client.post('/') + assert response.status_code == 405 # Method Not Allowed