diff --git a/default.nix b/default.nix new file mode 100644 index 0000000000..3821151856 --- /dev/null +++ b/default.nix @@ -0,0 +1,30 @@ +{ pkgs ? import {} }: + +let + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + fastapi + uvicorn + pydantic + starlette + python-dotenv + prometheus-client + annotated-doc + ]); +in +pkgs.stdenv.mkDerivation rec { + pname = "devops-info-service"; + version = "1.0.0"; + src = ./.; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/bin $out/app + cp app.py $out/app/app.py + + makeWrapper ${pythonEnv}/bin/python $out/bin/devops-info-service \ + --add-flags "$out/app/app.py" + runHook postInstall + ''; +} diff --git a/labs/lab18/app_python/.dockerignore b/labs/lab18/app_python/.dockerignore new file mode 100644 index 0000000000..c1ae79e6f1 --- /dev/null +++ b/labs/lab18/app_python/.dockerignore @@ -0,0 +1,64 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +pip-wheel-metadata/ + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ +virtualenv/ + +# IDEs and editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ + +# Version control +.git/ +.gitignore +.gitattributes + +# Documentation (keep only what's needed) +docs/ +*.md +!README.md + +# Logs +*.log +app.log + +# Tests +tests/ +test_*.py +*_test.py +pytest.ini +.pytest_cache/ +.coverage +htmlcov/ + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# Environment files +.env +.env.local +.env.*.local + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/labs/lab18/app_python/.gitignore b/labs/lab18/app_python/.gitignore new file mode 100644 index 0000000000..27c453dcfa --- /dev/null +++ b/labs/lab18/app_python/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ +virtualenv/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local + +# Logs +*.log +app.log \ No newline at end of file diff --git a/labs/lab18/app_python/Dockerfile b/labs/lab18/app_python/Dockerfile new file mode 100644 index 0000000000..8b776d5593 --- /dev/null +++ b/labs/lab18/app_python/Dockerfile @@ -0,0 +1,32 @@ +# Using Python slim image +FROM python:3.13-slim + +# Working directory +WORKDIR /app + +# Non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Copying requirements first for better layer caching +COPY requirements.txt . + +# Installing dependencies without cache to reduce image size +RUN pip install --no-cache-dir -r requirements.txt + +# Copying application code +COPY app.py . + +# Changing ownership to non-root user +RUN chown -R appuser:appuser /app + +RUN mkdir -p /data && chown -R appuser:appuser /data + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8000 + +# Runing the application +CMD ["python", "app.py"] + diff --git a/labs/lab18/app_python/README.md b/labs/lab18/app_python/README.md new file mode 100644 index 0000000000..f4e1b5ab71 --- /dev/null +++ b/labs/lab18/app_python/README.md @@ -0,0 +1,225 @@ +[![Python CI](https://github.com/3llimi/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/3llimi/DevOps-Core-Course/actions/workflows/python-ci.yml) +[![Coverage Status](https://coveralls.io/repos/github/3llimi/DevOps-Core-Course/badge.svg?branch=lab03)](https://coveralls.io/github/3llimi/DevOps-Core-Course?branch=lab03) +# DevOps Info Service + +A Python web service that provides system and runtime information. Built with FastAPI for the DevOps Core Course. + +## Overview + +This service exposes REST API endpoints that return: +- Service metadata (name, version, framework) +- System information (hostname, platform, CPU, Python version) +- Runtime information (uptime, current time) +- Request details (client IP, user agent) + +## Prerequisites + +- Python 3.11 or higher +- pip (Python package manager) + +## Installation + +```bash +# Navigate to app folder +cd app_python + +# Create virtual environment +python -m venv venv + +# Activate virtual environment (Windows PowerShell) +.\venv\Scripts\Activate + +# Activate virtual environment (Linux/Mac) +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt +``` + +## Running the Application + +**Default (port 8000):** +```bash +python app.py +``` + +**Custom port:** +```bash +# Windows PowerShell +$env:PORT=3000 +python app.py + +# Linux/Mac +PORT=3000 python app.py +``` + +**Custom host and port:** +```bash +# Windows PowerShell +$env:HOST="127.0.0.1" +$env:PORT=5000 +python app.py +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/` | GET | Service and system information | +| `/health` | GET | Health check for monitoring | +| `/docs` | GET | Swagger UI documentation | + +### GET `/` — Main Endpoint + +Returns comprehensive service and system information. + +**Request:** +```bash +curl http://localhost:8000/ +``` + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "3llimi", + "platform": "Windows", + "platform_version": "Windows-11-10.0.26200-SP0", + "architecture": "AMD64", + "cpu_count": 12, + "python_version": "3.14.2" + }, + "runtime": { + "uptime_seconds": 58, + "uptime_human": "0 hours, 0 minutes", + "current_time": "2026-01-26T18:54:58.321970+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/7.81.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] +} +``` + +### GET `/health` — Health Check + +Returns service health status for monitoring and Kubernetes probes. + +**Request:** +```bash +curl http://localhost:8000/health +``` + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-26T18:55:51.887474+00:00", + "uptime_seconds": 51 +} +``` + +## Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `HOST` | `0.0.0.0` | Server bind address | +| `PORT` | `8000` | Server port | + +## Project Structure + +``` +app_python/ +├── app.py # Main application +├── requirements.txt # Dependencies +├── .gitignore # Git ignore rules +├── .dockerignore # Dockerignore rules +├── Dockerfile # Dockerfile +├── README.md # This file +├── tests/ # Unit tests +│ └── __init__.py +└── docs/ + ├── LAB01.md + ├── LAB02.md # Lab submission + └── screenshots/ +``` + +## Docker + +### Building the Image Locally + +```bash +# Build the image +docker build -t 3llimi/devops-info-service:latest . + +# Check image size +docker images 3llimi/devops-info-service +``` + +### Running with Docker + +```bash +# Run with default settings (port 8000) +docker run -p 8000:8000 3llimi/devops-info-service:latest + +# Run with custom port mapping +docker run -p 3000:8000 3llimi/devops-info-service:latest + +# Run with environment variables +docker run -p 5000:5000 -e PORT=5000 3llimi/devops-info-service:latest + +# Run in detached mode +docker run -d -p 8000:8000 --name devops-service 3llimi/devops-info-service:latest +``` + +### Pulling from Docker Hub + +```bash +# Pull the image +docker pull 3llimi/devops-info-service:latest + +# Run the pulled image +docker run -p 8000:8000 3llimi/devops-info-service:latest +``` + +### Testing the Containerized Application + +```bash +# Health check +curl http://localhost:8000/health + +# Main endpoint +curl http://localhost:8000/ + +# View logs (if running in detached mode) +docker logs devops-service + +# Stop container +docker stop devops-service +docker rm devops-service +``` + +### Docker Hub Repository + +**Image:** `3llimi/devops-info-service:latest` +**Registry:** https://hub.docker.com/r/3llimi/devops-info-service + +## Tech Stack + +- **Language:** Python 3.14 +- **Framework:** FastAPI 0.115.0 +- **Server:** Uvicorn 0.32.0 +- **Containerization:** Docker 29.2.0 \ No newline at end of file diff --git a/labs/lab18/app_python/app.py b/labs/lab18/app_python/app.py new file mode 100644 index 0000000000..203af6781e --- /dev/null +++ b/labs/lab18/app_python/app.py @@ -0,0 +1,313 @@ +from fastapi import FastAPI, Request +from datetime import datetime, timezone +from fastapi.responses import JSONResponse, Response +from starlette.exceptions import HTTPException as StarletteHTTPException +from prometheus_client import ( + Counter, + Histogram, + Gauge, + generate_latest, + CONTENT_TYPE_LATEST, +) +import platform +import socket +import os +import logging +import sys +import json +import threading + + +class JSONFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + log_entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + for key, value in record.__dict__.items(): + if key not in ( + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "message", + "taskName", + ): + log_entry[key] = value + if record.exc_info: + log_entry["exception"] = self.formatException(record.exc_info) + return json.dumps(log_entry) + + +handler = logging.StreamHandler(sys.stdout) +handler.setFormatter(JSONFormatter()) +logging.basicConfig(level=logging.INFO, handlers=[handler]) +logger = logging.getLogger(__name__) + +app = FastAPI() +START_TIME = datetime.now(timezone.utc) + +# ── Prometheus Metrics ─────────────────────────────────────────────── +http_requests_total = Counter( + "http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status_code"], +) + +http_request_duration_seconds = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], + buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5], +) + +http_requests_in_progress = Gauge( + "http_requests_in_progress", "HTTP requests currently being processed" +) + +devops_info_endpoint_calls = Counter( + "devops_info_endpoint_calls_total", "Calls per endpoint", ["endpoint"] +) + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 8000)) + +logger.info(f"Application starting - Host: {HOST}, Port: {PORT}") + +# ── Visits Counter ─────────────────────────────────────────────────── +VISITS_FILE = os.getenv("VISITS_FILE", "/data/visits") +_visits_lock = threading.Lock() + + +def get_visits() -> int: + try: + with open(VISITS_FILE, "r") as f: + return int(f.read().strip()) + except (FileNotFoundError, ValueError): + return 0 + + +def increment_visits() -> int: + with _visits_lock: + count = get_visits() + 1 + os.makedirs(os.path.dirname(VISITS_FILE), exist_ok=True) + with open(VISITS_FILE, "w") as f: + f.write(str(count)) + return count + + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + secs = int(delta.total_seconds()) + hrs = secs // 3600 + mins = (secs % 3600) // 60 + return {"seconds": secs, "human": f"{hrs} hours, {mins} minutes"} + + +@app.on_event("startup") +async def startup_event(): + logger.info("FastAPI application startup complete") + logger.info(f"Python version: {platform.python_version()}") + logger.info(f"Platform: {platform.system()} {platform.platform()}") + logger.info(f"Hostname: {socket.gethostname()}") + + +@app.on_event("shutdown") +async def shutdown_event(): + uptime = get_uptime() + logger.info(f"Application shutting down. Total uptime: {uptime['human']}") + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + start_time = datetime.now(timezone.utc) + client_ip = request.client.host if request.client else "unknown" + endpoint = request.url.path + http_requests_in_progress.inc() + logger.info( + f"Request started: {request.method} {endpoint} from {client_ip}" + ) + try: + response = await call_next(request) + process_time = ( + datetime.now(timezone.utc) - start_time + ).total_seconds() + http_requests_total.labels( + method=request.method, + endpoint=endpoint, + status_code=str(response.status_code), + ).inc() + http_request_duration_seconds.labels( + method=request.method, endpoint=endpoint + ).observe(process_time) + devops_info_endpoint_calls.labels(endpoint=endpoint).inc() + logger.info( + "Request completed", + extra={ + "method": request.method, + "path": endpoint, + "status_code": response.status_code, + "client_ip": client_ip, + "duration_seconds": round(process_time, 3), + }, + ) + response.headers["X-Process-Time"] = str(process_time) + return response + except Exception as e: + process_time = ( + datetime.now(timezone.utc) - start_time + ).total_seconds() + http_requests_total.labels( + method=request.method, endpoint=endpoint, status_code="500" + ).inc() + logger.error( + "Request failed", + extra={ + "method": request.method, + "path": endpoint, + "client_ip": client_ip, + "duration_seconds": round(process_time, 3), + "error": str(e), + }, + ) + raise + finally: + http_requests_in_progress.dec() + + +@app.get("/metrics") +def metrics(): + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) + + +@app.get("/") +def home(request: Request): + logger.debug("Home endpoint called") + uptime = get_uptime() + visits = increment_visits() + return { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI", + }, + "system": { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.platform(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + }, + "runtime": { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": { + "client_ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent", "unknown"), + "method": request.method, + "path": request.url.path, + }, + "visits": visits, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + {"path": "/visits", "method": "GET", "description": "Visit counter"}, + ], + } + + +@app.get("/visits") +def visits_endpoint(): + logger.debug("Visits endpoint called") + count = get_visits() + return { + "visits": count, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + +@app.get("/health") +def health(): + logger.debug("Health check endpoint called") + uptime = get_uptime() + return { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime["seconds"], + } + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler( + request: Request, exc: StarletteHTTPException +): + client = request.client.host if request.client else "unknown" + logger.warning( + "HTTP exception", + extra={ + "status_code": exc.status_code, + "detail": exc.detail, + "path": request.url.path, + "client_ip": client, + }, + ) + return JSONResponse( + status_code=exc.status_code, + content={ + "error": exc.detail, + "status_code": exc.status_code, + "path": request.url.path, + }, + ) + + +@app.exception_handler(Exception) +async def general_exception_handler(request: Request, exc: Exception): + client = request.client.host if request.client else "unknown" + logger.error( + "Unhandled exception", + extra={ + "exception_type": type(exc).__name__, + "path": request.url.path, + "client_ip": client, + }, + exc_info=True, + ) + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + "path": request.url.path, + }, + ) + + +if __name__ == "__main__": + import uvicorn + + logger.info(f"Starting Uvicorn server on {HOST}:{PORT}") + uvicorn.run(app, host=HOST, port=PORT) \ No newline at end of file diff --git a/labs/lab18/app_python/default.nix b/labs/lab18/app_python/default.nix new file mode 100644 index 0000000000..22f0011eb7 --- /dev/null +++ b/labs/lab18/app_python/default.nix @@ -0,0 +1,47 @@ +{ pkgs ? import {} }: + +let + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + fastapi + uvicorn + pydantic + starlette + python-dotenv + prometheus-client + ]); + + cleanSrc = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + base = builtins.baseNameOf path; + in + !( + base == "venv" || + base == "__pycache__" || + base == ".pytest_cache" || + base == ".coverage" || + base == "app.log" || + base == "freeze1.txt" || + base == "freeze2.txt" || + base == "requirements-unpinned.txt" || + pkgs.lib.hasSuffix ".pyc" base + ); + }; +in +pkgs.stdenv.mkDerivation rec { + pname = "devops-info-service"; + version = "1.0.0"; + src = cleanSrc; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/bin $out/app + cp app.py $out/app/app.py + makeWrapper ${pythonEnv}/bin/python $out/bin/devops-info-service \ + --add-flags "$out/app/app.py" + runHook postInstall + ''; +} diff --git a/labs/lab18/app_python/docker.nix b/labs/lab18/app_python/docker.nix new file mode 100644 index 0000000000..33548fb1e5 --- /dev/null +++ b/labs/lab18/app_python/docker.nix @@ -0,0 +1,17 @@ +{ pkgs ? import {} }: + +let + app = import ./default.nix { inherit pkgs; }; +in +pkgs.dockerTools.buildLayeredImage { + name = "devops-info-service-nix"; + tag = "1.0.0"; + contents = [ app ]; + + config = { + Cmd = [ "${app}/bin/devops-info-service" ]; + ExposedPorts = { "8000/tcp" = {}; }; + }; + + created = "1970-01-01T00:00:01Z"; +} diff --git a/labs/lab18/app_python/docs/LAB01.md b/labs/lab18/app_python/docs/LAB01.md new file mode 100644 index 0000000000..a5b62361ea --- /dev/null +++ b/labs/lab18/app_python/docs/LAB01.md @@ -0,0 +1,274 @@ +# Lab 1 — DevOps Info Service: Submission + +## Framework Selection + +### My Choice: FastAPI + +I chose **FastAPI** for building this DevOps info service. + +### Comparison with Alternatives + +FastAPI is a good choice for APIs because it’s fast, supports async, and automatically generates API documentation, and it’s becoming more popular in the tech industry with growing demand in job listings. Even though Flask is easier and good for small projects, but it’s slower, synchronous, and needs manual documentation. Django is better for full web applications, widely used in companies with larger projects, but it has a steeper learning curve and can feel heavy for simple use cases. + +### Why I Chose FastAPI + +1. **Automatic API Documentation** — Swagger UI is generated automatically at `/docs`, which makes testing and sharing the API easy. + +2. **Modern Python** — FastAPI uses type hints and async/await, which are modern Python features that are good to learn. + +3. **Great for Microservices** — FastAPI is lightweight and fast, perfect for the DevOps info service we're building. + +4. **Performance** — Built on Starlette and Pydantic, FastAPI is one of the fastest Python frameworks. + +### Why Not Flask + +Flask is simpler but doesn't have built-in documentation or type validation. Would need extra libraries. + +### Why Not Django + +Django is too heavy for a simple API service. It includes ORM, admin panel, and templates that we don't need. + +--- + +## Best Practices Applied + +### 1. Clean Code Organization + +Imports are grouped properly: +```python +# Standard library +from datetime import datetime, timezone +import platform +import socket +import os + +# Third-party +from fastapi import FastAPI, Request +``` + +### 2. Configuration via Environment Variables + +```python +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 8000)) +``` + +**Why it matters:** Allows changing configuration without modifying code. Essential for Docker and Kubernetes deployments. + +### 3. Helper Functions + +```python +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + secs = int(delta.total_seconds()) + hrs = secs // 3600 + mins = (secs % 3600) // 60 + return { + "seconds": secs, + "human": f"{hrs} hours, {mins} minutes" + } +``` + +**Why it matters:** Reusable code — used in both `/` and `/health` endpoints. + +### 4. Consistent JSON Responses + +All endpoints return structured JSON with consistent formatting. + +### 5. Safe Defaults + +```python +"client_ip": request.client.host if request.client else "unknown" +``` + +**Why it matters:** Prevents crashes if a value is missing. + +--- + +### 6. Comprehensive Logging +```python +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +logger.info(f"Application starting - Host: {HOST}, Port: {PORT}") +``` + +**Why it matters:** Essential for debugging production issues and monitoring application behavior. + +### 7. Error Handling +```python +@app.exception_handler(Exception) +async def general_exception_handler(request: Request, exc: Exception): + logger.error(f"Unhandled exception: {type(exc).__name__}", exc_info=True) + return JSONResponse( + status_code=500, + content={"error": "Internal Server Error"} + ) +``` + +**Why it matters:** Prevents application crashes and provides meaningful error messages to clients. + +## API Documentation + +### Endpoint: GET `/` + +**Description:** Returns service and system information. + +**Request:** +```bash +curl http://localhost:8000/ +``` + +**Response:** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "3llimi", + "platform": "Windows", + "architecture": "AMD64", + "cpu_count": 12, + "python_version": "3.14.2" + }, + "runtime": { + "uptime_seconds": 58, + "uptime_human": "0 hours, 0 minutes", + "current_time": "2026-01-26T18:54:58+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "Mozilla/5.0...", + "method": "GET", + "path": "/" + }, + "endpoints": [...] +} +``` + +### Endpoint: GET `/health` + +**Description:** Health check for monitoring and Kubernetes probes. + +**Request:** +```bash +curl http://localhost:8000/health +``` + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-26T18:55:51+00:00", + "uptime_seconds": 51 +} +``` + +--- + +## Testing Evidence + +### Testing Commands Used + +```bash +# Start the application +python app.py + +# Test main endpoint +curl http://localhost:8000/ + +# Test health endpoint +curl http://localhost:8000/health + +# Test with custom port +$env:PORT=3000 +python app.py +curl http://localhost:3000/ + +# View Swagger documentation +# Open http://localhost:8000/docs in browser +``` + +### Screenshots + +1. **01-main-endpoint.png** — Main endpoint showing complete JSON response +2. **02-health-check.png** — Health check endpoint response +3. **03-formatted-output.png** — Swagger UI documentation + +--- + +## Challenges & Solutions + +### Challenge 1: Understanding Request Object + +**Problem:** Wasn't sure how to get client IP and user agent in FastAPI. + +**Solution:** Import `Request` from FastAPI and add it as a parameter: +```python +from fastapi import FastAPI, Request + +@app.get("/") +def home(request: Request): + client_ip = request.client.host + user_agent = request.headers.get("user-agent") +``` + +### Challenge 2: Timezone-Aware Timestamps + +**Problem:** Needed UTC timestamps for consistency across different servers. + +**Solution:** Used `timezone.utc` from datetime module: +```python +from datetime import datetime, timezone + +current_time = datetime.now(timezone.utc).isoformat() +``` + +### Challenge 3: Running with Custom Port + +**Problem:** Needed to make the port configurable. + +**Solution:** Used environment variables with a default value: +```python +import os +PORT = int(os.getenv('PORT', 8000)) +``` + +--- + +## GitHub Community + +### Why Starring Repositories Matters + +Starring repositories is important in open source because it: +- Bookmarks useful projects for later reference +- Shows appreciation to maintainers +- Helps projects gain visibility and attract contributors +- Indicates project quality to other developers + +### How Following Developers Helps + +Following developers on GitHub helps in team projects and professional growth by: +- Keeping you updated on teammates' and mentors' activities +- Discovering new projects through their activity +- Learning from experienced developers' code and commits +- Building professional connections in the developer community + +### Completed Actions + +- [x] Starred course repository +- [x] Starred [simple-container-com/api](https://github.com/simple-container-com/api) +- [x] Followed [@Cre-eD](https://github.com/Cre-eD) +- [x] Followed [@marat-biriushev](https://github.com/marat-biriushev) +- [x] Followed [@pierrepicaud](https://github.com/pierrepicaud) +- [x] Followed 3 classmates [@abdughafforzoda](https://github.com/abdughafforzoda),[@Boogyy](https://github.com/Boogyy), [@mpasgat](https://github.com/mpasgat) \ No newline at end of file diff --git a/labs/lab18/app_python/docs/LAB02.md b/labs/lab18/app_python/docs/LAB02.md new file mode 100644 index 0000000000..803628ca3e --- /dev/null +++ b/labs/lab18/app_python/docs/LAB02.md @@ -0,0 +1,806 @@ +# Lab 2 — Docker Containerization Documentation + +## 1. Docker Best Practices Applied + +### 1.1 Non-Root User ✅ + +**Implementation:** +```dockerfile +RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN chown -R appuser:appuser /app +USER appuser +``` + +**Why it matters:** +Running containers as root is a critical security vulnerability. If an attacker exploits the application and gains access, they would have root privileges inside the container and potentially on the host system. By creating and switching to a non-root user (`appuser`), we implement the **principle of least privilege**. This limits the damage an attacker can do if they compromise the application. Even if they gain code execution, they won't have root permissions to install malware, modify system files, or escalate privileges. + +**Real-world impact:** Many Kubernetes clusters enforce non-root container policies. Without this, your container won't run in production environments. + +--- + +### 1.2 Layer Caching Optimization ✅ + +**Implementation:** +```dockerfile +# Dependencies copied first (changes rarely) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Application code copied second (changes frequently) +COPY app.py . +``` + +**Why it matters:** +Docker builds images in **layers**, and each layer is cached. When you rebuild an image, Docker reuses cached layers if the input hasn't changed. By copying `requirements.txt` before `app.py`, we ensure that: +- **Dependency layer is cached** when only code changes +- **Rebuilds are fast** (seconds instead of minutes) +- **Development workflow is efficient** (no waiting for pip install on every code change) + +**Without this optimization:** +```dockerfile +COPY . . # Everything copied at once +RUN pip install -r requirements.txt +``` +Every code change would invalidate the pip install layer, forcing Docker to reinstall all dependencies. + +**Real-world impact:** In CI/CD pipelines, this can save hours of build time per day across a team. + +--- + +### 1.3 Specific Base Image Version ✅ + +**Implementation:** +```dockerfile +FROM python:3.13-slim +``` + +**Why it matters:** +Using `python:latest` is dangerous because: +- **Unpredictable updates:** The image changes without warning, breaking your builds +- **No reproducibility:** Different developers get different images +- **Security risks:** You don't control when updates happen + +Using `python:3.13-slim` provides: +- **Reproducible builds:** Same image every time +- **Predictable behavior:** You control when to upgrade +- **Smaller size:** `slim` variant is ~120MB vs ~900MB for full Python image +- **Security:** Debian-based with regular security patches + +**Alternatives considered:** +- `python:3.13-alpine`: Even smaller (~50MB) but has compatibility issues with some Python packages (especially those with C extensions) +- `python:3.13`: Full image includes unnecessary development tools, increasing attack surface + +--- + +### 1.4 .dockerignore File ✅ + +**Implementation:** +Excludes: +- `__pycache__/`, `*.pyc` (Python bytecode) +- `venv/`, `.venv/` (virtual environments) +- `.git/` (version control) +- `tests/` (not needed at runtime) +- `.env` files (prevents leaking secrets) + +**Why it matters:** +The `.dockerignore` file prevents unnecessary files from being sent to the Docker daemon during build. Without it: +- **Slower builds:** Docker has to transfer megabytes of unnecessary files +- **Larger build context:** `venv/` alone can be 100MB+ +- **Security risk:** Could accidentally copy `.env` files with secrets into the image +- **Bloated images:** Tests and documentation increase image size + +**Real-world impact:** Build context reduced from ~150MB to ~5KB for this simple app. + +--- + +### 1.5 --no-cache-dir for pip ✅ + +**Implementation:** +```dockerfile +RUN pip install --no-cache-dir -r requirements.txt +``` + +**Why it matters:** +By default, pip caches downloaded packages to speed up future installs. In a Docker image: +- **No benefit:** The container is immutable; we'll never reinstall in the same container +- **Wastes space:** The cache can add 50-100MB to the image +- **Unnecessary layer bloat:** Makes images harder to distribute + +Using `--no-cache-dir` ensures the pip cache isn't stored in the image. + +--- + +### 1.6 Proper File Ownership ✅ + +**Implementation:** +```dockerfile +RUN chown -R appuser:appuser /app +``` + +**Why it matters:** +Files copied into the container are owned by root by default. If we switch to `appuser` without changing ownership, the application can't write logs or temporary files, causing runtime errors. Changing ownership before switching users ensures the application has proper permissions. + +--- + +## 2. Image Information & Decisions + +### 2.1 Base Image Choice + +**Image:** `python:3.13-slim` + +**Justification:** +1. **Python 3.13:** Latest stable version with performance improvements +2. **Slim variant:** Balance between size and functionality + - Based on Debian (better package compatibility than Alpine) + - Contains only essential packages + - ~120MB vs ~900MB for full Python image +3. **Official image:** Maintained by Docker and Python teams, receives security updates + +**Why not Alpine?** +Alpine uses musl libc instead of glibc, which can cause issues with Python packages that have C extensions (like some data science libraries). For a production service, the slim variant offers better compatibility with minimal size increase. + +--- + +### 2.2 Final Image Size + +```bash +REPOSITORY TAG SIZE +3llimi/devops-info-service latest 234 MB +``` + +**Assessment:** + +**Size breakdown:** +- Base image: ~125MB +- FastAPI + dependencies: ~15-20MB +- Application code: <1MB + +This is acceptable for a production FastAPI service. Further optimization would require Alpine (complexity trade-off) or multi-stage builds (unnecessary for interpreted Python). + +--- + +### 2.3 Layer Structure + +```bash +$ docker history 3llimi/devops-info-service:latest + +IMAGE CREATED CREATED BY SIZE COMMENT +a4af5e6e1e17 11 hours ago CMD ["python" "app.py"] 0B buildkit.dockerfile.v0 + 11 hours ago EXPOSE [8000/tcp] 0B buildkit.dockerfile.v0 + 11 hours ago USER appuser 0B buildkit.dockerfile.v0 + 11 hours ago RUN /bin/sh -c chown -R appuser:appuser /app… 20.5kB buildkit.dockerfile.v0 + 11 hours ago COPY app.py . # buildkit 16.4kB buildkit.dockerfile.v0 + 11 hours ago RUN /bin/sh -c pip install --no-cache-dir -r… 45.2MB buildkit.dockerfile.v0 + 11 hours ago COPY requirements.txt . # buildkit 12.3kB buildkit.dockerfile.v0 + 11 hours ago RUN /bin/sh -c groupadd -r appuser && userad… 41kB buildkit.dockerfile.v0 + 11 hours ago WORKDIR /app 8.19kB buildkit.dockerfile.v0 + 29 hours ago CMD ["python3"] 0B buildkit.dockerfile.v0 + 29 hours ago RUN /bin/sh -c set -eux; for src in idle3 p… 16.4kB buildkit.dockerfile.v0 + 29 hours ago RUN /bin/sh -c set -eux; savedAptMark="$(a… 39.9MB buildkit.dockerfile.v0 + 29 hours ago ENV PYTHON_SHA256=16ede7bb7cdbfa895d11b0642f… 0B buildkit.dockerfile.v0 + 29 hours ago ENV PYTHON_VERSION=3.13.11 0B buildkit.dockerfile.v0 + 29 hours ago ENV GPG_KEY=7169605F62C751356D054A26A821E680… 0B buildkit.dockerfile.v0 + 29 hours ago RUN /bin/sh -c set -eux; apt-get update; a… 4.94MB buildkit.dockerfile.v0 + 29 hours ago ENV PATH=/usr/local/bin:/usr/local/sbin:/usr… 0B buildkit.dockerfile.v0 + 2 days ago # debian.sh --arch 'amd64' out/ 'trixie' '@1… 87.4MB debuerreotype 0.17 +``` + +**Layer-by-Layer Explanation:** + +**Your Application Layers (Top 9 layers):** + +| Layer | Dockerfile Instruction | Size | Purpose | +|-------|------------------------|------|---------| +| 1 | `CMD ["python" "app.py"]` | 0 B | Metadata: defines how to start container | +| 2 | `EXPOSE 8000` | 0 B | Metadata: documents the port | +| 3 | `USER appuser` | 0 B | Metadata: switches to non-root user | +| 4 | `RUN chown -R appuser:appuser /app` | 20.5 kB | Changes file ownership for non-root user | +| 5 | `COPY app.py .` | 16.4 kB | **Your application code** | +| 6 | `RUN pip install --no-cache-dir -r requirements.txt` | **45.2 MB** | **FastAPI + uvicorn dependencies** | +| 7 | `COPY requirements.txt .` | 12.3 kB | Python dependencies list | +| 8 | `RUN groupadd -r appuser && useradd -r -g appuser appuser` | 41 kB | Creates non-root user for security | +| 9 | `WORKDIR /app` | 8.19 kB | Creates working directory | + +**Base Image Layers (python:3.13-slim):** + +| Layer | What It Contains | Size | Purpose | +|-------|------------------|------|---------| +| Python 3.13.11 installation | Python interpreter & stdlib | 39.9 MB | Core Python runtime | +| Python dependencies | SSL, compression, system libs | 44.9 MB (combined with apt layer) | Python support libraries | +| Debian Trixie base | Minimal Debian OS | 87.4 MB | Operating system foundation | +| Apt packages | Essential system tools | 4.94 MB | Package management & utilities | + +**Key Insights:** + +1. **Efficient layer caching:** + - `requirements.txt` copied BEFORE `app.py` + - When you change code, only layer 5 rebuilds (16.4 kB) + - Dependencies (45.2 MB) are cached unless requirements.txt changes + - Saves 30-40 seconds per rebuild during development + +2. **Security layers:** + - User created early (layer 8) + - Files owned by appuser (layer 4) + - User switched before CMD (layer 3) + - Proper order prevents permission errors + +3. **Largest layer:** + - Layer 6 (`pip install`) is 45.2 MB + - Contains FastAPI, Pydantic, uvicorn, and all dependencies + - This is normal and expected for a FastAPI application + +4. **Metadata layers (0 B):** + - CMD, EXPOSE, USER, ENV don't increase image size + - They only add configuration metadata + - No disk space impact + +**Why This Layer Order Matters:** + +If we had done this (BAD): +```dockerfile +COPY app.py . # Changes frequently +COPY requirements.txt . +RUN pip install ... +``` + +**Result:** Every code change would force pip to reinstall all dependencies (45.2 MB download + install time). + +**Our approach (GOOD):** +```dockerfile +COPY requirements.txt . # Changes rarely +RUN pip install ... +COPY app.py . # Changes frequently +``` + +**Result:** Code changes only rebuild the 16.4 kB layer. Dependencies stay cached. + +--- + +### 2.4 Optimization Choices Made + +1. **Minimal file copying:** Only `requirements.txt` and `app.py` (no tests, docs, venv) +2. **Layer order optimized:** Dependencies before code for cache efficiency +3. **Single RUN for user creation:** Reduces layer count +4. **No cache pip install:** Reduces image size +5. **Slim base image:** Smaller attack surface and faster downloads + +**What I didn't do (and why):** +- **Multi-stage build:** Unnecessary for Python (interpreted language, no compilation step) +- **Alpine base:** Potential compatibility issues outweigh 70MB savings +- **Combining RUN commands:** Kept separate for readability; minimal size impact + +--- + +## 3. Build & Run Process + +### 3.1 Build Output + +**First Build (with downloads):** +```bash +$ docker build -t 3llimi/devops-info-service:latest . + +[+] Building 45-60s (estimated for first build) + => [internal] load build definition from Dockerfile + => [internal] load metadata for docker.io/library/python:3.13-slim + => [1/7] FROM docker.io/library/python:3.13-slim@sha256:2b9c9803... + => [2/7] WORKDIR /app + => [3/7] RUN groupadd -r appuser && useradd -r -g appuser appuser + => [4/7] COPY requirements.txt . + => [5/7] RUN pip install --no-cache-dir -r requirements.txt ← Takes ~30s + => [6/7] COPY app.py . + => [7/7] RUN chown -R appuser:appuser /app + => exporting to image + => => naming to docker.io/3llimi/devops-info-service:latest +``` + +**Rebuild (demonstrating layer caching):** +```bash +$ docker build -t 3llimi/devops-info-service:latest . + +[+] Building 2.3s (13/13) FINISHED docker:desktop-linux + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 664B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.5s + => [auth] library/python:pull token for registry-1.docker.io 0.0s + => [internal] load .dockerignore 0.1s + => => transferring context: 694B 0.0s + => [1/7] FROM docker.io/library/python:3.13-slim@sha256:2b9c9803c6a287cafa... 0.1s + => => resolve docker.io/library/python:3.13-slim@sha256:2b9c9803c6a287cafa... 0.1s + => [internal] load build context 0.0s + => => transferring context: 64B 0.0s + => CACHED [2/7] WORKDIR /app 0.0s + => CACHED [3/7] RUN groupadd -r appuser && useradd -r -g appuser appuser 0.0s + => CACHED [4/7] COPY requirements.txt . 0.0s + => CACHED [5/7] RUN pip install --no-cache-dir -r requirements.txt 0.0s + => CACHED [6/7] COPY app.py . 0.0s + => CACHED [7/7] RUN chown -R appuser:appuser /app 0.0s + => exporting to image 0.3s + => => exporting layers 0.0s + => => exporting manifest sha256:528daa8b95a1dac8ef2e570d12a882fd422ef1db... 0.0s + => => exporting config sha256:1852b4b7945ec0417ffc2ee516fe379a562ff0da... 0.0s + => => exporting attestation manifest sha256:93bafd7d5460bd10e910df1880e7... 0.1s + => => exporting manifest list sha256:b8cd349da61a65698c334ae6e0bba54081c6... 0.1s + => => naming to docker.io/3llimi/devops-info-service:latest 0.0s + => => unpacking to docker.io/3llimi/devops-info-service:latest 0.0s +``` + +**Build Performance Analysis:** + +| Metric | First Build | Cached Rebuild | Improvement | +|--------|-------------|----------------|-------------| +| **Total Time** | ~45-60 seconds | **2.3 seconds** | **95% faster** ✅ | +| **Base Image** | Downloaded (~125 MB) | Cached | No download | +| **pip install** | ~30 seconds | **0.0s (CACHED)** | Instant | +| **Copy app.py** | Executed | **CACHED** | Instant | +| **Build Context** | 64B (only necessary files) | 64B | ✅ .dockerignore working | + +**Key Observations:** + +1. **✅ Layer Caching Works Perfectly:** + - All 7 layers show `CACHED` + - Build time reduced from ~45s to 2.3s (95% faster) + - Only metadata operations and exports take time + +2. **✅ .dockerignore is Effective:** + - Build context: Only **64 bytes** transferred + - Without .dockerignore: Would be ~150 MB (venv/, .git/, __pycache__) + - Transferring context took 0.0s (instant) + +3. **✅ Optimal Layer Order:** + - `requirements.txt` copied before `app.py` + - When code changes, only layer 6 rebuilds (16.4 kB) + - Dependencies (45.2 MB) stay cached unless requirements.txt changes + +4. **✅ Security Best Practices:** + - Non-root user created (layer 3) + - Files owned by appuser (layer 7) + - No warnings or security issues + +**What Triggers Cache Invalidation:** + +| Change | Layers Rebuilt | Time Impact | +|--------|----------------|-------------| +| Modify `app.py` | Layer 6-7 only (~0.5s) | Minimal ✅ | +| Modify `requirements.txt` | Layer 5-7 (~35s) | Moderate ⚠️ | +| Change Dockerfile | All layers (~50s) | Full rebuild 🔄 | +| No changes | None (all cached) | 2-3s ✅ | + +**Real-World Impact:** + +During development, you'll be changing `app.py` frequently: +- **Without optimization:** Every change = 45s rebuild (pip reinstall) +- **With our approach:** Every change = 2-5s rebuild (only app.py layer) +- **Time saved per day:** ~20-30 minutes for 50 rebuilds + +**Conclusion:** + +The 2.3-second cached rebuild proves that our Dockerfile layer ordering is **optimal**. In CI/CD pipelines and development workflows, this caching strategy will save significant time and compute resources. + +### 3.2 Container Running + +```bash +$ docker run -p 8000:8000 3llimi/devops-info-service:latest + +2026-02-04 14:15:06,474 - __main__ - INFO - Application starting - Host: 0.0.0.0, Port: 8000 +2026-02-04 14:15:06,552 - __main__ - INFO - Starting Uvicorn server on 0.0.0.0:8000 +INFO: Started server process [1] +INFO: Waiting for application startup. +2026-02-04 14:15:06,580 - __main__ - INFO - FastAPI application startup complete +2026-02-04 14:15:06,581 - __main__ - INFO - Python version: 3.13.11 +2026-02-04 14:15:06,582 - __main__ - INFO - Platform: Linux Linux-5.15.167.4-microsoft-standard-WSL2-x86_64-with-glibc2.41 +2026-02-04 14:15:06,583 - __main__ - INFO - Hostname: c787d0c53472 +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +``` + + +**Verification:** +```bash +$ docker ps + +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +c787d0c53472 3llimi/devops-info-service:latest "python app.py" 30 seconds ago Up 29 seconds 0.0.0.0:8000->8000/tcp nice_lalande +``` + +**Key Observations:** + +✅ **Container Startup Successful:** +- Server process started as PID 1 (best practice for containers) +- Running on all interfaces (0.0.0.0:8000) +- Port 8000 exposed and accessible from host +- Container ID: `c787d0c53472` (also the hostname) + +✅ **Security Verified:** +- Running as non-root user `appuser` (no permission errors) +- Files owned correctly (chown worked) +- Application has necessary permissions to run + +✅ **Platform Detection:** +- **Platform:** Linux (container OS) +- **Kernel:** 5.15.167.4-microsoft-standard-WSL2 (WSL2 on Windows host) +- **Architecture:** x86_64 +- **Python:** 3.13.11 +- **glibc:** 2.41 (Debian Trixie) + +✅ **Application Lifecycle:** +- Custom logging initialized +- Startup event handler executed +- System information logged +- Uvicorn ASGI server running + +### 3.3 Testing Endpoints + +```bash +# Health check endpoint +$ curl http://localhost:8000/health + +{ + "status": "healthy", + "timestamp": "2026-02-04T14:20:07.530342+00:00", + "uptime_seconds": 301 +} + +# Main endpoint +$ curl http://localhost:8000/ + +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "c787d0c53472", + "platform": "Linux", + "platform_version": "Linux-5.15.167.4-microsoft-standard-WSL2-x86_64-with-glibc2.41", + "architecture": "x86_64", + "cpu_count": 12, + "python_version": "3.13.11" + }, + "runtime": { + "uptime_seconds": 280, + "uptime_human": "0 hours, 4 minutes", + "current_time": "2026-02-04T14:19:47.376710+00:00", + "timezone": "UTC" + }, + "request": { + "client_ip": "172.17.0.1", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 OPR/126.0.0.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information" + }, + { + "path": "/health", + "method": "GET", + "description": "Health check" + } + ] +} +``` + +**Note:** The hostname will be the container ID, and the platform will show Linux even if you're on Windows/Mac (because the container runs Linux). + +--- + +### 3.4 Docker Hub Repository + +**Repository URL:** https://hub.docker.com/r/3llimi/devops-info-service + +**Push Process:** +```bash +# Login to Docker Hub +$ docker login +Username: 3llimi +Password: [hidden] +Login Succeeded + +# Tag the image +$ docker tag devops-info-service:latest 3llimi/devops-info-service:latest + +# Push to Docker Hub +$ docker push 3llimi/devops-info-service:latest + +The push refers to repository [docker.io/3llimi/devops-info-service] +74bb1edc7d55: Pushed +0da4a108bcf2: Pushed +0c8d55a45c0d: Pushed +3acbcd2044b6: Pushed +eb096c0aadf7: Pushed +8a3ca8cbd12d: Pushed +0e1c5ff6738e: Pushed +084c4f2cfc58: Pushed +a686eac92bec: Pushed +b3639af23419: Pushed +14c3434fa95e: Pushed +latest: digest: sha256:a4af5e6e1e17b5c1f3ce418098f4dff5fbb941abf5f473c6f2358c3fa8587db3 size: 856 + + +``` + +**Verification:** +```bash +# Pull from Docker Hub on another machine +$ docker pull 3llimi/devops-info-service:latest +$ docker run -p 8000:8000 3llimi/devops-info-service:latest +``` + +--- + +## 4. Technical Analysis + +### 4.1 Why This Dockerfile Works + +**The layer ordering is critical:** + +1. **FROM python:3.13-slim** → Provides Python runtime environment +2. **WORKDIR /app** → Sets working directory for all subsequent commands +3. **RUN groupadd/useradd** → Creates non-root user early (needed before chown) +4. **COPY requirements.txt** → Brings in dependencies list FIRST (for caching) +5. **RUN pip install** → Installs packages (cached if requirements.txt unchanged) +6. **COPY app.py** → Brings in application code LAST (changes frequently) +7. **RUN chown** → Gives ownership to appuser BEFORE switching +8. **USER appuser** → Switches to non-root (must be after chown) +9. **EXPOSE 8000** → Documents port (metadata only, doesn't actually open port) +10. **CMD ["python", "app.py"]** → Defines how to start the container + +**Key insight:** Each instruction creates a new layer. Docker caches layers and reuses them if the input hasn't changed. By putting frequently-changing files (app.py) AFTER rarely-changing files (requirements.txt), we maximize cache efficiency. + +--- + +### 4.2 What Happens If Layer Order Changes? + +#### **Scenario 1: Copy code before requirements** + +**Bad Dockerfile:** +```dockerfile +COPY app.py . # Code changes frequently +COPY requirements.txt . +RUN pip install -r requirements.txt +``` + +**Impact:** +- Every code change invalidates the cache for `COPY requirements.txt` and `RUN pip install` +- Docker reinstalls ALL dependencies on every build (even if requirements.txt didn't change) +- Build time increases from ~5 seconds to ~30+ seconds for simple code changes +- In CI/CD, this wastes compute resources and slows down deployments + +**Why it happens:** Docker invalidates all subsequent layers when a layer changes. Since app.py changes frequently, it invalidates the pip install layer. + +--- + +#### **Scenario 2: Create user after copying files** + +**Bad Dockerfile:** +```dockerfile +COPY app.py . +RUN groupadd -r appuser && useradd -r -g appuser appuser +USER appuser +``` + +**Impact:** +- Files are owned by root (copied before user exists) +- When container runs as appuser, it can't write logs (`app.log`) +- Application crashes with "Permission denied" errors +- Security vulnerability: Files owned by root can't be modified by non-root user + +**Fix:** Always change ownership (`chown`) before switching users. + +--- + +#### **Scenario 3: USER directive before COPY** + +**Bad Dockerfile:** +```dockerfile +USER appuser +COPY app.py . +``` + +**Impact:** +- COPY fails because appuser doesn't have permission to write to /app +- Build fails with "permission denied" error + +**Why:** The USER directive affects all subsequent commands, including COPY. + +--- + +### 4.3 Security Considerations Implemented + +1. **Non-root user:** Limits privilege escalation attacks + - Even if attacker exploits the app, they don't have root access + - Cannot modify system files or install malware + - Kubernetes enforces this with PodSecurityPolicy + +2. **Specific base image version:** Prevents supply chain attacks + - `latest` tag can change without warning + - Could introduce vulnerabilities or breaking changes + - Version pinning gives you control over updates + +3. **Minimal image (slim):** Reduces attack surface + - Fewer packages = fewer potential vulnerabilities + - Smaller image = faster security scans + - Less code to audit and patch + +4. **No secrets in image:** .dockerignore prevents leaking credentials + - Prevents `.env` files from being copied + - Blocks accidentally committed API keys + - Secrets should be injected at runtime (environment variables, Kubernetes secrets) + +5. **Immutable infrastructure:** Container can't be modified after build + - No SSH daemon (common attack vector) + - No package manager in runtime (can't install malware) + - Must rebuild to change (auditable) + +6. **Proper file permissions:** chown prevents unauthorized modifications + - Application files owned by appuser + - Root can't accidentally overwrite code + - Clear separation of privileges + +--- + +### 4.4 How .dockerignore Improves Build + +**Without .dockerignore:** + +```bash +# Everything is sent to Docker daemon +$ docker build . +Sending build context to Docker daemon 156.3MB +Step 1/10 : FROM python:3.13-slim +``` + +**What gets sent:** +- `venv/` (50-100MB of installed packages) +- `.git/` (entire repository history, 20-50MB) +- `__pycache__/` (compiled bytecode, 5-10MB) +- `tests/` (test files, 1-5MB) +- `.env` files (SECURITY RISK!) +- IDE configs, logs, temporary files + +**Problems:** +- ❌ Slow builds (uploading 150MB+ every time) +- ❌ Security risk (secrets in .env could end up in image) +- ❌ Larger images (if you use `COPY . .`) +- ❌ Cache invalidation (changing .git history invalidates layers) + +--- + +**With .dockerignore:** + +```bash +$ docker build . +Sending build context to Docker daemon 5.12kB # Only app.py and requirements.txt +Step 1/10 : FROM python:3.13-slim +``` + +**Benefits:** +- ✅ **Fast builds:** Only 5KB sent to daemon (30x faster transfer) +- ✅ **No accidental secrets:** .env files are excluded +- ✅ **Clean images:** Only necessary files included +- ✅ **Better caching:** Git history changes don't invalidate layers + +**Real-world impact:** +- Local builds: Saves seconds per build (adds up during development) +- CI/CD: Saves minutes per pipeline run +- Security: Prevents credential leaks in public images + +--- + +## 5. Challenges & Solutions + +### Challenge 1: Permission Denied Errors + +**Problem:** +Container failed to start with: +``` +PermissionError: [Errno 13] Permission denied: 'app.log' +``` + +The application couldn't write log files because files were owned by root, but the container was running as `appuser`. + +**Solution:** +Added `RUN chown -R appuser:appuser /app` BEFORE the `USER appuser` directive. This ensures all files are owned by the non-root user before switching to it. + +**Learning:** +Order matters for security directives. You must: +1. Create the user +2. Copy/create files +3. Change ownership (`chown`) +4. Switch to the user (`USER`) + +Doing it in any other order causes permission errors. + +**How I debugged:** +Ran `docker run -it --entrypoint /bin/bash ` to get a shell in the container and checked file permissions with `ls -la /app`. Saw that files were owned by root, which explained why appuser couldn't write to them. + +--- + +## 6. Additional Commands Reference + +### Build and Run + +```bash +# Build image +docker build -t 3llimi/devops-info-service:latest . + +# Run container +docker run -p 8000:8000 3llimi/devops-info-service:latest + +# Run in detached mode +docker run -d -p 8000:8000 --name devops-svc 3llimi/devops-info-service:latest + +# View logs +docker logs devops-svc +docker logs -f devops-svc # Follow logs + +# Stop and remove +docker stop devops-svc +docker rm devops-svc +``` + +### Debugging + +```bash +# Get a shell in the container +docker run -it --entrypoint /bin/bash 3llimi/devops-info-service:latest + +# Inspect running container +docker exec -it devops-svc /bin/bash + +# Check file permissions +docker run -it --entrypoint /bin/bash 3llimi/devops-info-service:latest +> ls -la /app +> whoami # Should show 'appuser' +``` + +### Image Analysis + +```bash +# View image layers +docker history 3llimi/devops-info-service:latest + +# Check image size +docker images 3llimi/devops-info-service + +# Inspect image details +docker inspect 3llimi/devops-info-service:latest +``` + +### Docker Hub + +```bash +# Login +docker login + +# Tag image +docker tag devops-info-service:latest 3llimi/devops-info-service:latest + +# Push to registry +docker push 3llimi/devops-info-service:latest + +# Pull from registry +docker pull 3llimi/devops-info-service:latest +``` + +--- + +## Summary + +This lab taught me: +1. **Security first:** Non-root containers are mandatory, not optional +2. **Layer caching:** Order matters for build efficiency +3. **Minimal images:** Only include what you need +4. **Reproducibility:** Pin versions, use .dockerignore +5. **Testing:** Always test the containerized app, not just the build + +**Key metrics:** +- Image size: 234 MB +- Build time (first): ~30-45s +- Build time (cached): ~3-5s +- Security: Non-root user, minimal attack surface \ No newline at end of file diff --git a/labs/lab18/app_python/docs/LAB03.md b/labs/lab18/app_python/docs/LAB03.md new file mode 100644 index 0000000000..5b41705882 --- /dev/null +++ b/labs/lab18/app_python/docs/LAB03.md @@ -0,0 +1,389 @@ +# Lab 3 — Continuous Integration (CI/CD) + +## 1. Overview + +### Testing Framework +**Framework:** pytest +**Why pytest?** +- Industry standard for Python testing +- Clean, simple syntax with native `assert` statements +- Excellent plugin ecosystem (pytest-cov for coverage) +- Built-in test discovery and fixtures +- Better error messages than unittest + +### Test Coverage +**Endpoints Tested:** +- `GET /` — 6 test cases covering: + - HTTP 200 status code + - Valid JSON response structure + - Service information fields (name, version, framework) + - System information fields (hostname, platform, python_version) + - Runtime information fields (uptime_seconds, current_time) + - Request information fields (method) + +- `GET /health` — 5 test cases covering: + - HTTP 200 status code + - Valid JSON response structure + - Status field ("healthy") + - Timestamp field + - Uptime field (with type validation) + +**Total:** 11 test methods organized into 2 test classes + +### CI Workflow Configuration +**Trigger Strategy:** +```yaml +on: + push: + branches: [ master, lab03 ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [ master ] + paths: + - 'app_python/**' +``` + +**Rationale:** +- **Path filters** ensure workflow only runs when Python app changes (not for Go changes or docs) +- **Push to master and lab03** for continuous testing during development +- **Pull requests to master** to enforce quality before merging +- **Include workflow file itself** so changes to CI trigger a test run + +### Versioning Strategy +**Strategy:** Calendar Versioning (CalVer) with SHA suffix +**Format:** `YYYY.MM.DD-` + +**Example Tags:** +- `3llimi/devops-info-service:latest` +- `3llimi/devops-info-service:2026.02.11-89e5033` + +**Rationale:** +- **Time-based releases:** Perfect for continuous deployment workflows +- **SHA suffix:** Provides exact traceability to commit +- **No breaking change tracking needed:** This is a service, not a library +- **Easier to understand:** "I deployed the version from Feb 11" vs "What changed in v1.2.3?" +- **Automated generation:** `{{date 'YYYY.MM.DD'}}` in metadata-action handles it + +--- + +## 2. Workflow Evidence + +### ✅ Successful Workflow Run +**Link:** [Python CI #7 - Success](https://github.com/3llimi/DevOps-Core-Course/actions/runs/21924734953) +- **Commit:** `89e5033` (Version Issue) +- **Status:** ✅ All jobs passed +- **Jobs:** test → docker → security +- **Duration:** ~3 minutes + +### ✅ Tests Passing Locally +```bash +$ cd app_python +$ pytest -v +================================ test session starts ================================= +platform win32 -- Python 3.14.2, pytest-8.3.4, pluggy-1.6.1 +collected 11 items + +tests/test_app.py::TestHomeEndpoint::test_home_returns_200 PASSED [ 9%] +tests/test_app.py::TestHomeEndpoint::test_home_returns_json PASSED [ 18%] +tests/test_app.py::TestHomeEndpoint::test_home_has_service_info PASSED [ 27%] +tests/test_app.py::TestHomeEndpoint::test_home_has_system_info PASSED [ 36%] +tests/test_app.py::TestHomeEndpoint::test_home_has_runtime_info PASSED [ 45%] +tests/test_app.py::TestHomeEndpoint::test_home_has_request_info PASSED [ 54%] +tests/test_app.py::TestHealthEndpoint::test_health_returns_200 PASSED [ 63%] +tests/test_app.py::TestHealthEndpoint::test_health_returns_json PASSED [ 72%] +tests/test_app.py::TestHealthEndpoint::test_health_has_status PASSED [ 81%] +tests/test_app.py::TestHealthEndpoint::test_health_has_timestamp PASSED [ 90%] +tests/test_app.py::TestHealthEndpoint::test_health_has_uptime PASSED [100%] + +================================= 11 passed in 1.34s ================================= +``` + +### ✅ Docker Image on Docker Hub +**Link:** [3llimi/devops-info-service](https://hub.docker.com/r/3llimi/devops-info-service) +- **Latest tag:** `2026.02.11-89e5033` +- **Size:** ~86 MB compressed +- **Platform:** linux/amd64 + +### ✅ Status Badge Working +![Python CI](https://github.com/3llimi/DevOps-Core-Course/workflows/Python%20CI/badge.svg) + +**Badge added to:** `app_python/README.md` + +--- + +## 3. Best Practices Implemented + +### 1. **Dependency Caching (Built-in)** +**Implementation:** +```yaml +- name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + cache: 'pip' + cache-dependency-path: 'app_python/requirements-dev.txt' +``` +**Why it helps:** Caches pip packages between runs, reducing install time from ~45s to ~8s (83% faster) + +### 2. **Docker Layer Caching (GitHub Actions Cache)** +**Implementation:** +```yaml +- name: Build and push + uses: docker/build-push-action@v6 + with: + cache-from: type=gha + cache-to: type=gha,mode=max +``` +**Why it helps:** Reuses Docker layers between builds, reducing build time from ~2m to ~30s (75% faster) + +### 3. **Job Dependencies (needs)** +**Implementation:** +```yaml +docker: + runs-on: ubuntu-latest + needs: test # Only runs if test job succeeds +``` +**Why it helps:** Prevents pushing broken Docker images to registry, saves time and resources + +### 4. **Security Scanning (Snyk)** +**Implementation:** +```yaml +security: + name: Security Scan with Snyk + steps: + - name: Run Snyk to check for vulnerabilities + run: snyk test --severity-threshold=high +``` +**Why it helps:** Catches known vulnerabilities in dependencies before production deployment + +### 5. **Path-Based Triggers** +**Implementation:** +```yaml +on: + push: + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' +``` +**Why it helps:** Saves CI minutes, prevents unnecessary runs when only Go code or docs change + +### 6. **Linting Before Testing** +**Implementation:** +```yaml +- name: Lint with ruff + run: ruff check . --output-format=github || true +``` +**Why it helps:** Catches style issues and potential bugs early, provides inline annotations in PR + +--- + +## 4. Caching Performance + +**Before Caching (First Run):** +``` +Install dependencies: 47s +Build Docker image: 2m 15s +Total: 3m 02s +``` + +**After Caching (Subsequent Runs):** +``` +Install dependencies: 8s (83% improvement) +Build Docker image: 32s (76% improvement) +Total: 1m 12s (60% improvement) +``` + +**Cache Hit Rate:** ~95% for dependencies, ~80% for Docker layers + +--- + +## 5. Snyk Security Scanning + +**Severity Threshold:** High (only fails on high/critical vulnerabilities) + +**Scan Results:** +``` +Testing /home/runner/work/DevOps-Core-Course/DevOps-Core-Course/app_python... + +✓ Tested 6 dependencies for known issues, no vulnerable paths found. +``` + +**Action Taken:** +- Set `continue-on-error: true` to warn but not block builds +- Configured `--severity-threshold=high` to only alert on serious issues +- No vulnerabilities found in current dependencies + +**Rationale:** +- **Don't break builds on low/medium issues:** Allows flexibility for acceptable risk +- **High severity only:** Focus on critical security flaws +- **Regular monitoring:** Snyk runs on every push to catch new CVEs + +--- + +## 6. Key Decisions + +### **Versioning Strategy: CalVer** +**Why CalVer over SemVer?** +- This is a **service**, not a library (no external API consumers) +- **Time-based releases** make more sense for continuous deployment +- **Traceability:** Date + SHA provides clear deployment history +- **Simplicity:** No need to manually bump major/minor/patch versions +- **GitOps friendly:** Easy to see "what was deployed on Feb 11" + +### **Docker Tags** +**Tags created by CI:** +``` +3llimi/devops-info-service:latest +3llimi/devops-info-service:2026.02.11-89e5033 +``` + +**Rationale:** +- `latest` — Always points to most recent build +- `YYYY.MM.DD-SHA` — Immutable, reproducible, traceable + +### **Workflow Triggers** +**Why these triggers?** +- **Push to master/lab03:** Continuous testing during development +- **PR to master:** Quality gate before merging +- **Path filters:** Efficiency (don't test Python when only Go changes) + +**Why include workflow file in path filter?** +- If I change the CI pipeline itself, it should test those changes +- Prevents "forgot to test the new CI step" scenarios + +### **Test Coverage** +**What's Tested:** +- All endpoint responses return 200 OK +- JSON structure validation +- Required fields present in response +- Correct data types (integers, strings) +- Framework-specific values (FastAPI, devops-info-service) + +**What's NOT Tested:** +- Exact hostname values (varies by environment) +- Exact uptime values (time-dependent) +- Network failures (out of scope for unit tests) +- Database connections (no database in this app) + +**Coverage:** 87% (target was 70%, exceeded!) + +--- + +## 7. Challenges & Solutions + +### Challenge 1: Python 3.14 Not Available in setup-python@v4 +**Problem:** Initial workflow used `setup-python@v4` which didn't support Python 3.14 +**Solution:** Upgraded to `setup-python@v5` which has bleeding-edge Python support + +### Challenge 2: Snyk Action Failing with Authentication +**Problem:** `snyk/actions/python@master` kept failing with auth errors +**Solution:** Switched to Snyk CLI approach: +```yaml +- name: Install Snyk CLI + run: curl --compressed https://static.snyk.io/cli/latest/snyk-linux -o snyk +- name: Authenticate Snyk + run: snyk auth ${{ secrets.SNYK_TOKEN }} +``` + +### Challenge 3: Coverage Report Format +**Problem:** Coveralls expected `lcov` format, pytest-cov defaults to `xml` +**Solution:** Added `--cov-report=lcov` flag to pytest command + +--- + +## 8. CI Workflow Structure + +``` +Python CI Workflow +│ +├── Job 1: Test (runs on all triggers) +│ ├── Checkout code +│ ├── Set up Python 3.14 (with cache) +│ ├── Install dependencies +│ ├── Lint with ruff +│ ├── Run tests with coverage +│ └── Upload coverage to Coveralls +│ +├── Job 2: Docker (needs: test, only on push) +│ ├── Checkout code +│ ├── Set up Docker Buildx +│ ├── Log in to Docker Hub +│ ├── Extract metadata (tags, labels) +│ └── Build and push (with caching) +│ +└── Job 3: Security (runs in parallel with docker) + ├── Checkout code + ├── Set up Python + ├── Install dependencies + ├── Install Snyk CLI + ├── Authenticate Snyk + └── Run security scan +``` + +--- + +## 9. Workflow Artifacts + +**Test Coverage Badge:** +[![Coverage Status](https://coveralls.io/repos/github/3llimi/DevOps-Core-Course/badge.svg?branch=lab03)](https://coveralls.io/github/3llimi/DevOps-Core-Course?branch=lab03) + +**Workflow Status Badge:** +![Python CI](https://github.com/3llimi/DevOps-Core-Course/workflows/Python%20CI/badge.svg?branch=lab03) + +**Docker Hub:** +- Image: `3llimi/devops-info-service` +- Tags: `latest`, `2026.02.11-89e5033` +- Pull command: `docker pull 3llimi/devops-info-service:latest` + +--- + +## 10. How to Run Tests Locally + +```bash +# Navigate to Python app +cd app_python + +# Install dev dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest -v + +# Run tests with coverage +pytest -v --cov=. --cov-report=term + +# Run tests with coverage and HTML report +pytest -v --cov=. --cov-report=html +# Open htmlcov/index.html in browser + +# Run linter +ruff check . + +# Run linter with auto-fix +ruff check . --fix +``` + +--- + +## Summary + +✅ **All requirements met:** +- Unit tests written with pytest (9 tests, 87% coverage) +- CI workflow with linting, testing, Docker build/push +- CalVer versioning implemented +- Dependency caching (60% speed improvement) +- Snyk security scanning (no vulnerabilities found) +- Status badge in README +- Path filters for monorepo efficiency + +✅ **Best Practices Applied:** +1. Dependency caching +2. Docker layer caching +3. Job dependencies +4. Security scanning +5. Path-based triggers +6. Linting before testing + +🎯 **Bonus Task Completed:** Multi-app CI with path filters (Go workflow in separate doc) \ No newline at end of file diff --git a/labs/lab18/app_python/docs/screenshots/01-main-endpoint.png b/labs/lab18/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..f3040444cd Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/labs/lab18/app_python/docs/screenshots/02-health-check.png b/labs/lab18/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..cfc6ac2a65 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/02-health-check.png differ diff --git a/labs/lab18/app_python/docs/screenshots/03-formatted-output.png b/labs/lab18/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..d38fb2c628 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/labs/lab18/app_python/docs/screenshots/03-formatted-outputV2.png b/labs/lab18/app_python/docs/screenshots/03-formatted-outputV2.png new file mode 100644 index 0000000000..5179f4cbbe Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/03-formatted-outputV2.png differ diff --git a/labs/lab18/app_python/docs/screenshots/Error Handling.png b/labs/lab18/app_python/docs/screenshots/Error Handling.png new file mode 100644 index 0000000000..6331c8450a Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/Error Handling.png differ diff --git a/labs/lab18/app_python/docs/screenshots/S18-01-nix-install-verify.png b/labs/lab18/app_python/docs/screenshots/S18-01-nix-install-verify.png new file mode 100644 index 0000000000..5a4031deb8 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/S18-01-nix-install-verify.png differ diff --git a/labs/lab18/app_python/docs/screenshots/S18-02-task1-nix-run.png b/labs/lab18/app_python/docs/screenshots/S18-02-task1-nix-run.png new file mode 100644 index 0000000000..6fe3f0cc85 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/S18-02-task1-nix-run.png differ diff --git a/labs/lab18/app_python/docs/screenshots/S18-03-task1-reproducible-storepath.png b/labs/lab18/app_python/docs/screenshots/S18-03-task1-reproducible-storepath.png new file mode 100644 index 0000000000..295510b9a7 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/S18-03-task1-reproducible-storepath.png differ diff --git a/labs/lab18/app_python/docs/screenshots/S18-04-task2-nix-docker-build-load.png b/labs/lab18/app_python/docs/screenshots/S18-04-task2-nix-docker-build-load.png new file mode 100644 index 0000000000..e71e5bf233 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/S18-04-task2-nix-docker-build-load.png differ diff --git a/labs/lab18/app_python/docs/screenshots/S18-05-task2-both-containers-health.png b/labs/lab18/app_python/docs/screenshots/S18-05-task2-both-containers-health.png new file mode 100644 index 0000000000..c63f5e44c0 Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/S18-05-task2-both-containers-health.png differ diff --git a/labs/lab18/app_python/docs/screenshots/Screenshot 2026-03-26 092923.png b/labs/lab18/app_python/docs/screenshots/Screenshot 2026-03-26 092923.png new file mode 100644 index 0000000000..7f410ed04d Binary files /dev/null and b/labs/lab18/app_python/docs/screenshots/Screenshot 2026-03-26 092923.png differ diff --git a/labs/lab18/app_python/flake.lock b/labs/lab18/app_python/flake.lock new file mode 100644 index 0000000000..fe08f5660f --- /dev/null +++ b/labs/lab18/app_python/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/labs/lab18/app_python/flake.nix b/labs/lab18/app_python/flake.nix new file mode 100644 index 0000000000..950b0fbd53 --- /dev/null +++ b/labs/lab18/app_python/flake.nix @@ -0,0 +1,23 @@ +{ + description = "DevOps Info Service - Reproducible Build with Flakes"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + }; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.${system} = { + default = import ./default.nix { inherit pkgs; }; + dockerImage = import ./docker.nix { inherit pkgs; }; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ pkgs.python313 ]; + }; + }; +} diff --git a/labs/lab18/app_python/requirements-dev.txt b/labs/lab18/app_python/requirements-dev.txt new file mode 100644 index 0000000000..e3248a3b86 --- /dev/null +++ b/labs/lab18/app_python/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest==8.3.4 +pytest-cov==6.0.0 +httpx==0.28.1 +ruff==0.8.4 +coveralls==4.0.2 \ No newline at end of file diff --git a/labs/lab18/app_python/requirements.txt b/labs/lab18/app_python/requirements.txt new file mode 100644 index 0000000000..8ed1b51a07 Binary files /dev/null and b/labs/lab18/app_python/requirements.txt differ diff --git a/labs/lab18/app_python/tests/__init__.py b/labs/lab18/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/labs/lab18/app_python/tests/test_app.py b/labs/lab18/app_python/tests/test_app.py new file mode 100644 index 0000000000..ed28b1886a --- /dev/null +++ b/labs/lab18/app_python/tests/test_app.py @@ -0,0 +1,102 @@ +import os +import tempfile + +_tmp = tempfile.NamedTemporaryFile(delete=False) +_tmp.close() +os.environ["VISITS_FILE"] = _tmp.name + +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + + +class TestHomeEndpoint: + """Tests for the main / endpoint""" + + def test_home_returns_200(self): + """Test that home endpoint returns HTTP 200 OK""" + response = client.get("/") + assert response.status_code == 200 + + def test_home_returns_json(self): + """Test that response is valid JSON""" + response = client.get("/") + data = response.json() + assert isinstance(data, dict) + + def test_home_has_service_info(self): + """Test that service section exists and has required fields""" + response = client.get("/") + data = response.json() + + assert "service" in data + assert data["service"]["name"] == "devops-info-service" + assert data["service"]["version"] == "1.0.0" + assert data["service"]["framework"] == "FastAPI" + + def test_home_has_system_info(self): + """Test that system section exists and has required fields""" + response = client.get("/") + data = response.json() + + assert "system" in data + assert "hostname" in data["system"] + assert "platform" in data["system"] + assert "python_version" in data["system"] + + def test_home_has_runtime_info(self): + """Test that runtime section exists""" + response = client.get("/") + data = response.json() + + assert "runtime" in data + assert "uptime_seconds" in data["runtime"] + assert "current_time" in data["runtime"] + + def test_home_has_request_info(self): + """Test that request section exists""" + response = client.get("/") + data = response.json() + + assert "request" in data + assert "method" in data["request"] + assert data["request"]["method"] == "GET" + + +class TestHealthEndpoint: + """Tests for the /health endpoint""" + + def test_health_returns_200(self): + """Test that health endpoint returns HTTP 200 OK""" + response = client.get("/health") + assert response.status_code == 200 + + def test_health_returns_json(self): + """Test that response is valid JSON""" + response = client.get("/health") + data = response.json() + assert isinstance(data, dict) + + def test_health_has_status(self): + """Test that health response has status field""" + response = client.get("/health") + data = response.json() + + assert "status" in data + assert data["status"] == "healthy" + + def test_health_has_timestamp(self): + """Test that health response has timestamp""" + response = client.get("/health") + data = response.json() + + assert "timestamp" in data + + def test_health_has_uptime(self): + """Test that health response has uptime""" + response = client.get("/health") + data = response.json() + + assert "uptime_seconds" in data + assert isinstance(data["uptime_seconds"], int) \ No newline at end of file diff --git a/labs/submission18.md b/labs/submission18.md new file mode 100644 index 0000000000..6ca9ff369e --- /dev/null +++ b/labs/submission18.md @@ -0,0 +1,769 @@ +# Lab 18 — Reproducible Builds with Nix + +- **Environment:** Windows + WSL2 (Ubuntu), Docker Desktop +- **Repository:** `DevOps-Core-Course` +- **Branch:** `lab18` + +--- + +## Task 1 — Build Reproducible Python App (6 pts) + +### 1.1 Nix Installation and Verification + +Installed Nix using the Determinate Systems installer (recommended for WSL2 — enables flakes by default): + +```bash +curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install +``` + +Verification: + +```bash +nix --version +# nix (Determinate Nix 3.17.1) 2.33.3 + +nix run nixpkgs#hello +# Hello, world! +``` + +![Nix install and verification](../labs/lab18/app_python/docs/screenshots/S18-01-nix-install-verify.png) + +--- + +### 1.2 Application Preparation + +The Lab 1/2 FastAPI-based DevOps Info Service was copied into `labs/lab18/app_python/`. The app exposes `/health` returning JSON with status, timestamp, and uptime. + +Key files: +- `app.py` — FastAPI application +- `requirements.txt` — Python dependencies + +--- + +### 1.3 Nix Derivation (`default.nix`) + +Created `labs/lab18/app_python/default.nix`: + +```nix +{ pkgs ? import {} }: + +let + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + fastapi + uvicorn + pydantic + starlette + python-dotenv + prometheus-client + ]); + + cleanSrc = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + base = builtins.baseNameOf path; + in + !( + base == "venv" || + base == "__pycache__" || + base == ".pytest_cache" || + base == ".coverage" || + base == "app.log" || + base == "freeze1.txt" || + base == "freeze2.txt" || + base == "requirements-unpinned.txt" || + pkgs.lib.hasSuffix ".pyc" base + ); + }; +in +pkgs.stdenv.mkDerivation rec { + pname = "devops-info-service"; + version = "1.0.0"; + src = cleanSrc; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/bin $out/app + cp app.py $out/app/app.py + makeWrapper ${pythonEnv}/bin/python $out/bin/devops-info-service \ + --add-flags "$out/app/app.py" + runHook postInstall + ''; +} +``` + +**Field explanations:** + +- `pythonEnv` — a Nix-managed Python environment with all required packages; + versions come from the pinned nixpkgs, not from PyPI at runtime +- `cleanSrc` / `cleanSourceWith` — filters out mutable files (venvs, + caches, logs, pip freeze outputs) from the build input; without this, + any incidental file change would alter the input hash and produce a + different store path, breaking reproducibility +- `pname` / `version` — used to name the output in the Nix store: + `/nix/store/-devops-info-service-1.0.0` +- `src = cleanSrc` — the filtered source tree; Nix hashes this to + determine whether a rebuild is needed +- `nativeBuildInputs = [ pkgs.makeWrapper ]` — tools available only + at build time, not included in the runtime closure +- `makeWrapper` — wraps the `app.py` script with the exact Python + interpreter path from the Nix store, so the binary works in + complete isolation from the system Python +- `runHook preInstall` / `runHook postInstall` — hooks for any + pre/post install steps defined elsewhere in the build chain + +--- + +### 1.4 Build and Run + +```bash +cd labs/lab18/app_python +nix-build +readlink result +# /nix/store/fvznf4v44sp4k1v2q1wva5r096az1s10-devops-info-service-1.0.0 + +./result/bin/devops-info-service +``` + +Health check: + +```bash +curl -s http://localhost:8000/health +# {"status":"healthy","timestamp":"2026-03-26T05:21:29.528356+00:00","uptime_seconds":20} +``` + +The app runs identically to the Lab 1 version — same code, same +behaviour — but now built entirely through Nix with no system Python +or pip involvement. + +![Task 1 app running from Nix build](../labs/lab18/app_python/docs/screenshots/S18-02-task1-nix-run.png) + +--- + +### 1.5 Nix Store Path Anatomy + +Every output in the Nix store follows this format: + +``` +/nix/store/-- + │ │ │ + │ │ └── version field from the derivation + │ └────────── pname field from the derivation + └────────────────── SHA256 hash of ALL build inputs: + · source code (after cleanSrc filter) + · all dependencies (transitively) + · build instructions (installPhase) + · compiler and flags + · Nix itself +``` + +Example from this lab: + +``` +/nix/store/fvznf4v44sp4k1v2q1wva5r096az1s10-devops-info-service-1.0.0 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + This hash uniquely identifies the exact build. + Any change to any input produces a completely different hash. +``` + +This is called **content-addressable storage**. The hash is not a +build ID or timestamp — it is a cryptographic fingerprint of +everything that went into producing the output. Two machines with the +same `default.nix` and the same nixpkgs revision will always produce +the same hash, making binary sharing across machines safe and +verifiable. + +--- + +### 1.6 Reproducibility Proof — Force Rebuild + +To prove Nix rebuilds identically from scratch (not just reuses cache): + +```bash +# Step 1: Build and record store path +nix-build default.nix +STORE_PATH=$(readlink result) +echo "Store path before delete: $STORE_PATH" +# Store path before delete: /nix/store/w3w9lcwxlbs695mspgjpgajm6n2ywp59-devops-info-service-1.0.0 + +# Step 2: Remove symlink (so Nix no longer treats it as a GC root) +rm -f result + +# Step 3: Delete from the Nix store +nix-store --delete $STORE_PATH +# removing stale link from '/nix/var/nix/gcroots/auto/...' to '.../result' +# deleting '/nix/store/w3w9lcwxlbs695mspgjpgajm6n2ywp59-devops-info-service-1.0.0' +# 1 store paths deleted, 9.8 KiB freed + +# Step 4: Rebuild from scratch +nix-build default.nix +echo "Store path after rebuild: $(readlink result)" +# Store path after rebuild: /nix/store/fvznf4v44sp4k1v2q1wva5r096az1s10-devops-info-service-1.0.0 +``` + +**Observation:** After deleting the store path and forcing a full +rebuild, Nix produced `fvznf4v4...` — identical to all prior stable +builds. The rebuild in this session initially produced a different hash +(`w3w9lcw...`) because `nixpkgs-weekly` fetched a newer nixpkgs +revision mid-session. This actually demonstrates an important nuance: + +- `import {}` in `default.nix` uses a **floating** nixpkgs + reference — reproducibility holds only while nixpkgs is stable on + a given machine +- `flake.nix` with `flake.lock` **pins** the exact nixpkgs commit, + giving true cross-machine, cross-time reproducibility (see Bonus) + +Same inputs → same hash → Nix reuses or identically rebuilds the output. + +Hash of the final stable output: + +```bash +nix-hash --type sha256 result +# d4ad3501ab1afad0104576d6e84704971daac215df5e643d7e86927e44235658 +``` + +![Task 1 reproducibility proof](../labs/lab18/app_python/docs/screenshots/S18-03-task1-reproducible-storepath.png) + +--- + +### 1.7 Pip Reproducibility Demo — Demonstrating the Gap + +To illustrate why `requirements.txt + pip` provides weaker guarantees: + +```bash +echo "flask" > requirements-unpinned.txt + +# venv1 — pip install fails silently (externally-managed-environment) +python3 -m venv venv1 +source venv1/bin/activate +pip install -r requirements-unpinned.txt --quiet +# error: externally-managed-environment +# (install failed silently — no error at pip freeze time) +pip freeze | grep -i flask > freeze1.txt +deactivate + +pip cache purge # simulate different cache/machine state + +# venv2 — pip install succeeds +python3 -m venv venv2 +source venv2/bin/activate +pip install -r requirements-unpinned.txt --quiet +pip freeze | grep -i flask > freeze2.txt +deactivate + +echo "=== freeze1 ===" && cat freeze1.txt +# (empty — install failed silently) + +echo "=== freeze2 ===" && cat freeze2.txt +# Flask==3.1.3 + +diff freeze1.txt freeze2.txt +# 0a1 +# > Flask==3.1.3 +# result: DIFFERENT +``` + +**Two failure modes demonstrated simultaneously:** + +1. **Silent failure:** `venv1`'s pip install failed due to + `externally-managed-environment`, but `pip freeze` produced no + error — only an empty file. The broken environment would only be + discovered at runtime when imports fail. + +2. **No version pinning:** `requirements-unpinned.txt` specified only + `flask` with no version constraint. `venv2` resolved `Flask==3.1.3` + today; next month it might resolve a different version. Even with + pinned versions, transitive dependencies (Werkzeug, click, + itsdangerous) remain unpinned and can drift. + +**Nix eliminates both:** the build either succeeds with exact pinned +versions for every package in the closure, or fails loudly at build +time — never silently at runtime. + +--- + +### 1.8 Lab 1 vs Lab 18 Comparison + +| Aspect | Lab 1 (pip + venv) | Lab 18 (Nix) | +|---|---|---| +| Python version | System-dependent | Pinned in derivation | +| Dependency resolution | Runtime (`pip install`) | Build-time (pure, sandboxed) | +| Transitive deps pinned | ❌ Only direct deps | ✅ Full closure | +| Silent failure possible | ✅ Yes | ❌ Fails loudly at build | +| Reproducibility | Approximate | Bit-for-bit identical | +| Portability | Requires same OS + Python | Works anywhere Nix runs | +| Binary cache | ❌ No | ✅ Yes (content-addressed) | +| Store path / audit trail | ❌ N/A | ✅ `/nix/store/-...` | + +**Reflection:** Had Nix been used from Lab 1, the development +environment, CI pipeline, and production build would all share a +single `default.nix`. Every teammate would get byte-for-byte identical +Python environments with zero setup friction. Dependency updates would +be explicit and reviewable in git (a change to `default.nix`), not +silent side effects of `pip install` running against a live PyPI index. + +--- + +## Task 2 — Reproducible Docker Images with Nix (4 pts) + +### 2.1 Nix Docker Expression (`docker.nix`) + +Created `labs/lab18/app_python/docker.nix`: + +```nix +{ pkgs ? import {} }: + +let + app = import ./default.nix { inherit pkgs; }; +in +pkgs.dockerTools.buildLayeredImage { + name = "devops-info-service-nix"; + tag = "1.0.0"; + contents = [ app ]; + + config = { + Cmd = [ "${app}/bin/devops-info-service" ]; + ExposedPorts = { "8000/tcp" = {}; }; + }; + + created = "1970-01-01T00:00:01Z"; +} +``` + +**Field explanations:** + +- `app = import ./default.nix` — reuses the Task 1 derivation; + the image is built from the same reproducible artifact +- `buildLayeredImage` — creates one Docker layer per Nix store path, + enabling perfect layer-level caching: if a dependency hasn't changed, + its layer hash is identical and Docker reuses it +- `contents = [ app ]` — only the explicit closure of `app` is + included; no base OS, no shell, no package manager +- `config.Cmd` — uses the absolute Nix store path for the binary, + not a PATH lookup, so the correct version is always invoked +- `created = "1970-01-01T00:00:01Z"` — **critical for reproducibility**; + setting a fixed epoch timestamp prevents Docker from embedding the + current build time into the image manifest, which would cause the + tarball hash to differ on every rebuild even with identical content + +--- + +### 2.2 Build Image Tarball + +```bash +cd labs/lab18/app_python +nix-build docker.nix +readlink result +# /nix/store/35yig2qrsrq7xjmsrrj9wmdxbml1g1rk-devops-info-service-nix.tar.gz +``` + +--- + +### 2.3 Load into Docker and Run Side-by-Side + +Loaded the Nix image tarball from PowerShell via the WSL filesystem path: + +```powershell +docker load -i "\\wsl$\Ubuntu\nix\store\35yig2qrsrq7xjmsrrj9wmdxbml1g1rk-devops-info-service-nix.tar.gz" +# Loaded image: devops-info-service-nix:1.0.0 +``` + +Run both containers simultaneously: + +```powershell +docker rm -f lab2-container nix-container 2>$null + +# Lab 2 traditional image on port 5000 +docker run -d -p 5000:8000 --name lab2-container lab2-app:v1 + +# Nix image on port 5001 +docker run -d -p 5001:8000 --name nix-container devops-info-service-nix:1.0.0 + +curl.exe -s http://localhost:5000/health +# {"status":"healthy",...} + +curl.exe -s http://localhost:5001/health +# {"status":"healthy",...} +``` + +Both containers return identical responses — same application code, +same behaviour, different build mechanisms. + +![Task 2 both containers healthy](../labs/lab18/app_python/docs/screenshots/S18-05-task2-both-containers-health.png) + +--- + +### 2.4 Reproducibility Proof: Nix vs Traditional Dockerfile + +#### Nix image — two builds, identical SHA256 + +```bash +# Build 1 +rm -f result && nix-build docker.nix +sha256sum result +# 5aedc01bd28e7e27963ae7fec685e511dec5a146e8aaf178de3eda019bc652b9 result + +# Build 2 +rm -f result && nix-build docker.nix +sha256sum result +# 5aedc01bd28e7e27963ae7fec685e511dec5a146e8aaf178de3eda019bc652b9 result +``` + +Both builds produce the **identical SHA256 hash** and resolve to the +same store path: +`/nix/store/35yig2qrsrq7xjmsrrj9wmdxbml1g1rk-devops-info-service-nix.tar.gz` + +#### Traditional Dockerfile — two builds, different SHA256 + +```powershell +docker build -t lab2-app:test1 ./app_python/ +docker save lab2-app:test1 -o lab2-test1.tar +Get-FileHash lab2-test1.tar -Algorithm SHA256 +# SHA256: E6ACA7072A53A206D404B7E20AE2D1437F95B9C0E034471E2E275F9E6D696CFD + +Start-Sleep -Seconds 3 + +docker build -t lab2-app:test2 ./app_python/ +docker save lab2-app:test2 -o lab2-test2.tar +Get-FileHash lab2-test2.tar -Algorithm SHA256 +# SHA256: E8557EC819B99810F946A7E708C315344B773A914D78CAAA6CA5A8CFE73B9892 +``` + +Same Dockerfile, same source, same machine — **different hashes**. +Docker embeds attestation manifests and metadata that vary per build, +making bit-for-bit reproducibility structurally impossible with +traditional Dockerfiles. + +--- + +### 2.5 Layer Analysis: docker history + +#### Lab 2 Dockerfile layers + +``` +IMAGE CREATED CREATED BY SIZE +babb9c242385 15 hours ago CMD ["python" "app.py"] 0B + 15 hours ago EXPOSE [8000/tcp] 0B + 15 hours ago USER appuser 0B + 15 hours ago RUN mkdir -p /data && chown -R appuser... 8.19kB + 15 hours ago RUN chown -R appuser:appuser /app 24.6kB + 15 hours ago COPY app.py . 20.5kB + 15 hours ago RUN pip install --no-cache-dir -r req... 45.9MB + 15 hours ago COPY requirements.txt . 12.3kB + 15 hours ago RUN groupadd -r appuser && useradd... 41kB + 15 hours ago WORKDIR /app 8.19kB + 9 days ago CMD ["python3"] 0B + 9 days ago RUN set -eux; savedAptMark=... 39.9MB + 9 days ago ENV PYTHON_VERSION=3.13.12 0B + 10 days ago # debian.sh --arch 'amd64' ... 87.4MB +``` + +Every layer shows a human-readable `CREATED` timestamp. These +timestamps are embedded in the image manifest and change on every +rebuild — this alone ensures the tarball hash differs between builds +even when content is identical. + +#### Nix dockerTools layers + +``` +IMAGE CREATED CREATED BY SIZE COMMENT +cb5db5223a36 N/A 20.5kB store paths: [...customisation-layer] + N/A 41kB store paths: [...devops-info-service-1.0.0] + N/A 1.26MB store paths: [...python3-3.13.12-env] + N/A 2.15MB store paths: [...python3.13-fastapi-0.128.0] + N/A 6.42MB store paths: [...python3.13-pydantic-2.12.5] + N/A 5.66MB store paths: [...python3.13-pydantic-core-2.41.5] + N/A 1.25MB store paths: [...python3.13-starlette-0.52.1] + N/A 119MB store paths: [...python3-3.13.12] + N/A 10.4MB store paths: [...gcc-15.2.0-lib] + N/A 9.36MB store paths: [...openssl-3.6.1] +... (41 layers total) +``` + +Every layer shows `N/A` for CREATED — the fixed epoch timestamp set in +`docker.nix`. Each layer is named by its Nix store path (a content +hash), not by build time. Same content = same layer hash = perfect +cache reuse with no timestamp interference. + +--- + +### 2.6 Image Size and Full Comparison + +```powershell +docker images | findstr "lab2-app" +# lab2-app:v1 3edcea3aa3f6 235MB + +docker images | findstr "devops-info-service-nix" +# devops-info-service-nix:1.0.0 d902ddd6cc1a 452MB +``` + +| Aspect | Lab 2 Traditional Dockerfile | Lab 18 Nix dockerTools | +|---|---|---| +| Image size | 235 MB | 452 MB | +| Base image | `python:3.13-slim` (moving tag) | No base image — full Nix closure | +| Layer timestamps | Build-time (vary per rebuild) | `N/A` (fixed epoch) | +| SHA256 across rebuilds | ❌ Different | ✅ Identical | +| Dependency traceability | Opaque (pip inside layer) | Full — every store path visible | +| Layer cache validity | Timestamp-dependent | Content-addressed | +| Reproducibility | ❌ | ✅ | + +**Size tradeoff explained:** The Nix image is larger (452 MB vs 235 MB) +because it includes the full explicit closure — every transitive +dependency as a separate named layer (glibc, openssl, readline, +sqlite, gcc-lib, etc.). The `python:3.13-slim` base image is smaller +because it uses pre-optimised shared layers from Docker Hub, but at +the cost of reproducibility: `slim` is a mutable tag that can point +to different content over time without notice. Nix trades image size +for complete transparency and guaranteed reproducibility. + +--- + +### 2.7 Analysis and Reflection + +**Why can't traditional Dockerfiles achieve bit-for-bit reproducibility?** + +Three structural reasons: + +1. **Mutable tags:** `FROM python:3.13-slim` is a pointer, not a + content hash. The same tag can resolve to a different image digest + next month without any change to the Dockerfile. + +2. **Embedded metadata:** Docker injects build timestamps and + attestation manifests into every image, ensuring the saved tarball + hash differs between builds even when all layers are identical. + +3. **Runtime package installation:** `pip install` inside a `RUN` + layer resolves versions at build time against a live PyPI index. + Results can vary across time and network conditions, and transitive + dependencies are not pinned. + +**Practical scenarios where Nix reproducibility matters:** + +- **CI/CD:** Two pipeline runs of the same commit produce identical + artifacts — no "flaky" builds caused by upstream package updates + between runs +- **Security audits:** Every dependency in the image closure is named + and content-addressed — trivial to generate a full SBOM or scan + the complete dependency tree +- **Rollbacks:** Rolling back to a previous Nix derivation guarantees + the exact same binary, not an approximation rebuilt from a tag that + may have moved + +**If redoing Lab 2 with Nix from the start:** I would define +`docker.nix` alongside the application from day one, commit +`flake.lock` to git, and use the Nix store path hash as the image +tag in Helm `values.yaml` — giving end-to-end cryptographic +traceability from source code to running container. + +--- + +## Bonus Task — Modern Nix with Flakes (2 pts) + +### 5.1 flake.nix + +Created `labs/lab18/app_python/flake.nix`: + +```nix +{ + description = "DevOps Info Service - Reproducible Build with Flakes"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; # Pinned channel + }; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; # Target: WSL2 / Linux x86_64 + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.${system} = { + default = import ./default.nix { inherit pkgs; }; # App package + dockerImage = import ./docker.nix { inherit pkgs; }; # Docker image + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ pkgs.python313 ]; # Reproducible dev shell + }; + }; +} +``` + +**Field explanations:** + +- `description` — human-readable label shown in `nix flake info` +- `inputs.nixpkgs.url` — pins the nixpkgs channel to `nixos-24.11`; + without this, `import {}` uses a floating reference that + silently changes between builds +- `system = "x86_64-linux"` — targets WSL2/Linux; change to + `aarch64-darwin` for Apple Silicon or `x86_64-darwin` for Intel Mac +- `packages.${system}.default` — built by `nix build` (no argument) +- `packages.${system}.dockerImage` — built by `nix build .#dockerImage` +- `devShells.${system}.default` — entered by `nix develop`; provides + an isolated shell with the pinned Python version + +--- + +### 5.2 flake.lock — Pinned Dependency Evidence + +Generated with `nix flake update`: + +```json +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} +``` + +**What each field locks:** + +- `rev` — the exact git commit of nixpkgs (`50ab793...`); this single + commit determines the version of every one of the 80,000+ packages + in nixpkgs, including Python, all libraries, and build tools +- `narHash` — cryptographic hash of the entire nixpkgs source tree at + that revision; Nix verifies this on download, making tampering or + corruption detectable +- `lastModified` — Unix timestamp of the commit (informational only, + not used for hash verification) + +Any machine running `nix build` with this `flake.lock` present will +fetch the exact same nixpkgs revision and produce the exact same +output store paths — regardless of when or where the build runs. + +--- + +### 5.3 Build Outputs Using Flakes + +```bash +# App package +nix build +readlink result +# /nix/store/zrxwmif48w8hccc60fmclv7vr1hfgnlx-devops-info-service-1.0.0 + +# Docker image +nix build .#dockerImage +readlink result +# /nix/store/3pqfdzi91x4ns4br6cyvc8bw99ic8sb6-devops-info-service-nix.tar.gz + +# Dev shell Python version +nix develop -c python --version +# Python 3.13.1 + +# Flake validation +nix flake check +# checks passed: default package, dockerImage, devShell +``` + +--- + +### 5.4 Comparison: flake.lock vs Lab 10 Helm values.yaml + +In Lab 10, Helm pinned the container image in `values.yaml`: + +```yaml +image: + repository: yourusername/devops-info-service + tag: "1.0.0" + pullPolicy: IfNotPresent +``` + +**Limitations of this approach:** +- Pins only the image **tag** — a mutable pointer that can be retagged + to different content without warning +- Does not lock any dependency inside the image (Python version, pip + packages, transitive libraries) +- Does not lock Helm chart dependencies +- No cryptographic verification of content + +| What is locked | Helm values.yaml | Nix flake.lock | +|---|---|---| +| Container image reference | ✅ (mutable tag) | ✅ (content hash) | +| Python version | ❌ | ✅ | +| All Python dependencies | ❌ | ✅ | +| Transitive dependencies | ❌ | ✅ | +| Build tools / compilers | ❌ | ✅ | +| Cryptographic verification | ❌ | ✅ (`narHash`) | +| Entire nixpkgs (80k+ pkgs) | ❌ | ✅ (single `rev`) | + +The two approaches are complementary rather than competing. Nix builds +and cryptographically verifies the image; Helm deploys it +declaratively to Kubernetes. Combined workflow: build with +`nix build .#dockerImage`, tag the resulting artifact with its store +path hash, and reference that immutable hash in `values.yaml` — giving +end-to-end traceability from source commit to running pod. + +--- + +### 5.5 Dev Shell Comparison: nix develop vs Lab 1 venv + +```bash +# Lab 1 approach +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +# Python version: whatever the system provides +# Dependencies: resolved live against PyPI + +# Lab 18 Nix approach +nix develop +python --version +# Python 3.13.1 (exact, pinned, same on every machine) +python -c "import fastapi; print(fastapi.__version__)" +# 0.128.0 (locked via flake.lock) +``` + +| Aspect | Lab 1 (python -m venv) | Lab 18 (nix develop) | +|---|---|---| +| Python version | System-dependent | Pinned (`3.13.1`) | +| Activation | `source venv/bin/activate` | `nix develop` | +| Reproducible across machines | ❌ | ✅ | +| Committed to version control | ❌ (venv not committed) | ✅ (`flake.lock` committed) | +| Dependencies drift over time | ✅ (pip resolves live) | ❌ (locked forever) | +| Setup on new machine | `pip install -r requirements.txt` | `nix develop` (one command) | + +--- + +### 5.6 Reflection + +Flakes solve the main weakness of plain `default.nix`: the +`import {}` channel reference is a floating pointer that +silently changes between builds on different machines or different +days — as observed in section 1.6 where `nixpkgs-weekly` fetched a +new revision mid-session and produced a different hash. By committing +`flake.lock` to git, the entire dependency graph is frozen at a single +nixpkgs commit (`50ab793...`). Any contributor who clones the +repository and runs `nix build` gets byte-for-byte identical outputs +regardless of when or where they build — eliminating "works on my +machine" drift across both space (different machines) and time +(different dates). + +--- + +## Challenges and Fixes + +| Challenge | Cause | Fix | +|---|---|---| +| Store paths differing across builds | Mutable files (logs, freezes, venvs) included in source hash | Added `cleanSourceWith` filter to `default.nix` | +| `nix-store --delete` blocked | `result` symlink held as GC root | Remove `result` symlink before deleting store path | +| `docker save \| Get-FileHash` pipeline error | PowerShell doesn't support piping binary streams to `Get-FileHash` | Save to file first: `docker save -o file.tar`, then `Get-FileHash file.tar` | +| Docker CLI unavailable in WSL | Docker Desktop integration | Loaded Nix tar from PowerShell via `\\wsl$\Ubuntu\nix\store\...` path | \ No newline at end of file