diff --git a/labs/lab18/.gitignore b/labs/lab18/.gitignore new file mode 100644 index 0000000000..3100aca37e --- /dev/null +++ b/labs/lab18/.gitignore @@ -0,0 +1,7 @@ +app_python/result +app_python/result-* +app_python/venv*/ +app_python/freeze*.txt +app_python/requirements-unpinned.txt +*.tar +*.tar.gz diff --git a/labs/lab18/app_python/Dockerfile b/labs/lab18/app_python/Dockerfile new file mode 100644 index 0000000000..7eae8081f6 --- /dev/null +++ b/labs/lab18/app_python/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim +WORKDIR /app_python +COPY requirements.txt . + +# Installs newest packet versions(or reloads indices) without additional recomendations, +# clears cache from installation, +# creates non-root user, +# and installs python requirements +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --shell /bin/bash appuser \ + && mkdir -p /data /config \ + && chown -R appuser:appuser /data /config \ + && pip install --no-cache-dir -r requirements.txt + +COPY app.py . +#COPY templates/ templates/ +# There are no /templates now... + +ENV VISITS_FILE=/data/visits +ENV CONFIG_FILE=/config/config.json + +EXPOSE 5000 + +USER appuser + +CMD ["python", "app.py"] diff --git a/labs/lab18/app_python/app.py b/labs/lab18/app_python/app.py new file mode 100644 index 0000000000..e66ebcce5c --- /dev/null +++ b/labs/lab18/app_python/app.py @@ -0,0 +1,393 @@ +import json +import logging +import os +import platform +import socket +import sys +import tempfile +import time +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock + +from fastapi import FastAPI, Request, Response +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest + + +class JSONFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": datetime.fromtimestamp( + record.created, + tz=timezone.utc, + ).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "service": "devops-python", + } + + for field in ( + "event", + "method", + "path", + "status_code", + "client_ip", + "duration_ms", + "host", + "port", + "debug", + ): + value = getattr(record, field, None) + if value is not None: + payload[field] = value + + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + + return json.dumps(payload, ensure_ascii=True) + + +def configure_logging() -> logging.Logger: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JSONFormatter()) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.setLevel(logging.INFO) + root_logger.addHandler(handler) + + app_logger = logging.getLogger("devops.python") + app_logger.setLevel(logging.INFO) + app_logger.propagate = True + return app_logger + + +logger = configure_logging() +REQUEST_COUNTER = Counter( + "http_requests_total", + "Total HTTP requests.", + ["method", "endpoint", "status_code"], +) +REQUEST_DURATION = 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, 5.0, 10.0), +) +REQUESTS_IN_PROGRESS = Gauge( + "http_requests_in_progress", + "HTTP requests currently being processed.", + ["method", "endpoint"], +) +ENDPOINT_CALLS = Counter( + "devops_info_endpoint_calls_total", + "Total calls to DevOps info service endpoints.", + ["endpoint"], +) +SYSTEM_INFO_DURATION = Histogram( + "devops_info_system_collection_seconds", + "Time spent collecting system information.", + buckets=(0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05), +) + +# ======== Parameters ======== +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "false").lower() == "true" +VISITS_FILE = Path( + os.getenv( + "VISITS_FILE", + str(Path(tempfile.gettempdir()) / "devops-info-service" / "visits"), + ) +) +CONFIG_FILE = Path(os.getenv("CONFIG_FILE", "/config/config.json")) + +# ======== Setup ======== +START_TIME = datetime.now(timezone.utc) + + +class VisitsCounter: + def __init__(self, path: Path): + self.path = path + self.lock = Lock() + self.value = self._read_from_disk() + + def _read_from_disk(self) -> int: + try: + raw_value = self.path.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return 0 + except OSError as exc: + logger.warning( + "could not read visits file", + extra={"event": "visits_read_failed", "path": str(self.path), "error": str(exc)}, + ) + return self.value if hasattr(self, "value") else 0 + + try: + return max(int(raw_value), 0) + except ValueError: + logger.warning( + "invalid visits file content", + extra={"event": "visits_invalid_value", "path": str(self.path)}, + ) + return 0 + + def _write_to_disk(self, value: int) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(f".{self.path.name}.tmp") + tmp_path.write_text(f"{value}\n", encoding="utf-8") + os.replace(tmp_path, self.path) + + def increment(self) -> int: + with self.lock: + self.value = self._read_from_disk() + 1 + self._write_to_disk(self.value) + return self.value + + def current(self) -> int: + with self.lock: + self.value = self._read_from_disk() + return self.value + + +visits_counter = VisitsCounter(VISITS_FILE) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + logger.info( + "application startup", + extra={ + "event": "startup", + "host": HOST, + "port": PORT, + "debug": DEBUG, + "visits_file": str(VISITS_FILE), + "config_file": str(CONFIG_FILE), + }, + ) + yield + logger.info("application shutdown", extra={"event": "shutdown"}) + + +app = FastAPI(lifespan=lifespan) + + +def normalize_endpoint(path: str) -> str: + if path in {"/", "/health", "/metrics", "/visits"}: + return path + return "other" + + +@app.middleware("http") +async def log_requests(request: Request, call_next): + started = time.perf_counter() + client_ip = request.client.host if request.client else "unknown" + endpoint = normalize_endpoint(request.url.path) + in_progress = REQUESTS_IN_PROGRESS.labels( + method=request.method, + endpoint=endpoint, + ) + in_progress.inc() + + try: + response = await call_next(request) + except Exception: + duration_seconds = max(time.perf_counter() - started, 0) + REQUEST_COUNTER.labels( + method=request.method, + endpoint=endpoint, + status_code="500", + ).inc() + REQUEST_DURATION.labels( + method=request.method, + endpoint=endpoint, + ).observe(duration_seconds) + logger.exception( + "request failed", + extra={ + "event": "request_error", + "method": request.method, + "path": request.url.path, + "client_ip": client_ip, + }, + ) + raise + finally: + in_progress.dec() + + duration_ms = round((time.perf_counter() - started) * 1000, 2) + duration_seconds = duration_ms / 1000 + REQUEST_COUNTER.labels( + method=request.method, + endpoint=endpoint, + status_code=str(response.status_code), + ).inc() + REQUEST_DURATION.labels( + method=request.method, + endpoint=endpoint, + ).observe(duration_seconds) + log_level = ( + logging.ERROR if response.status_code >= 500 + else logging.WARNING if response.status_code >= 400 + else logging.INFO + ) + logger.log( + log_level, + "request completed", + extra={ + "event": "http_request", + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "client_ip": client_ip, + "duration_ms": duration_ms, + }, + ) + return response + + +# ======== Endpoints ======== +@app.get("/") +def main_endpoint(request: Request): + ENDPOINT_CALLS.labels(endpoint="/").inc() + visits = visits_counter.increment() + return { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI", + }, + "system": get_system_info(), + "configuration": get_app_config(), + "runtime": { + "uptime_seconds": get_uptime()["seconds"], + "uptime_human": get_uptime()["human"], + "current_time": get_current_time(), + "timezone": "UTC", # Static for simplicity + }, + "visits": { + "count": visits, + "file": str(VISITS_FILE), + }, + "request": { + "client_ip": request.client.host, + "user_agent": request.headers.get("user-agent"), + "method": request.method, + "path": request.url.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", + "description": "Service information"}, + {"path": "/health", "method": "GET", + "description": "Health check"}, + {"path": "/visits", "method": "GET", + "description": "Current persisted visits count"}, + ], + } + + +@app.get("/health") +def health(): + ENDPOINT_CALLS.labels(endpoint="/health").inc() + return { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": get_uptime()["seconds"], + } + + +@app.get("/metrics") +def metrics(): + ENDPOINT_CALLS.labels(endpoint="/metrics").inc() + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) + + +@app.get("/visits") +def visits(): + ENDPOINT_CALLS.labels(endpoint="/visits").inc() + return { + "visits": visits_counter.current(), + "file": str(VISITS_FILE), + } + + +# ======== Functions ======== +def get_app_config(): + try: + return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + return { + "application": { + "name": "devops-info-service", + "environment": os.getenv("APP_ENV", "local"), + "configSource": "default", + }, + "features": { + "visitsCounter": True, + }, + } + except (OSError, json.JSONDecodeError) as exc: + logger.warning( + "could not load app config", + extra={"event": "config_load_failed", "path": str(CONFIG_FILE), "error": str(exc)}, + ) + return { + "application": { + "name": "devops-info-service", + "environment": os.getenv("APP_ENV", "local"), + "configSource": "fallback", + }, + "features": { + "visitsCounter": True, + }, + } + + +def get_system_info(): + started = time.perf_counter() + hostname = socket.gethostname() + platform_name = platform.system() + architecture = platform.machine() + cpu_count = os.cpu_count() + python_version = platform.python_version() + SYSTEM_INFO_DURATION.observe(max(time.perf_counter() - started, 0)) + return { + "hostname": hostname, + "platform": platform_name, + "architecture": architecture, + "cpu_count": cpu_count, + "python_version": python_version + } + + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + + +def get_current_time(): + return datetime.now(timezone.utc).isoformat() + + +# ======== Launch ======== +if __name__ == "__main__": + import uvicorn + uvicorn.run( + app, + host=HOST, + port=PORT, + reload=False, + access_log=False, + log_config=None, + ) diff --git a/labs/lab18/app_python/default.nix b/labs/lab18/app_python/default.nix new file mode 100644 index 0000000000..8e42ae0b6e --- /dev/null +++ b/labs/lab18/app_python/default.nix @@ -0,0 +1,36 @@ +{ pkgs ? import {} }: + +let + python = pkgs.python312.withPackages (ps: with ps; [ + fastapi + prometheus-client + uvicorn + ]); +in +pkgs.stdenv.mkDerivation { + pname = "devops-info-service"; + version = "1.0.0"; + src = ./.; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/devops-info-service $out/bin + cp app.py requirements.txt $out/share/devops-info-service/ + + makeWrapper ${python}/bin/python $out/bin/devops-info-service \ + --add-flags "$out/share/devops-info-service/app.py" \ + --set-default HOST "0.0.0.0" \ + --set-default PORT "5000" \ + --set-default APP_ENV "nix" + + runHook postInstall + ''; + + meta = { + description = "FastAPI DevOps Info Service built reproducibly with Nix"; + mainProgram = "devops-info-service"; + }; +} diff --git a/labs/lab18/app_python/docker.nix b/labs/lab18/app_python/docker.nix new file mode 100644 index 0000000000..b377e6de5a --- /dev/null +++ b/labs/lab18/app_python/docker.nix @@ -0,0 +1,31 @@ +{ pkgs ? import {} }: + +let + app = import ./default.nix { inherit pkgs; }; +in +pkgs.dockerTools.buildLayeredImage { + name = "devops-info-service-nix"; + tag = "1.0.0"; + + contents = [ + app + pkgs.cacert + ]; + + config = { + Cmd = [ "${app}/bin/devops-info-service" ]; + Env = [ + "HOST=0.0.0.0" + "PORT=5000" + "APP_ENV=nix-docker" + "VISITS_FILE=/tmp/devops-info-service/visits" + "CONFIG_FILE=/config/config.json" + ]; + ExposedPorts = { + "5000/tcp" = {}; + }; + WorkingDir = "/"; + }; + + created = "1970-01-01T00:00:01Z"; +} diff --git a/labs/lab18/app_python/flake.lock b/labs/lab18/app_python/flake.lock new file mode 100644 index 0000000000..9203379e6f --- /dev/null +++ b/labs/lab18/app_python/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1778580735, + "narHash": "sha256-t+8AVV8ExvOmslz2sLIgw/hJBKlyl65rJvxjvvjHgpE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "48d91f2c0ce7b9e589f967d4f685153dd765dcdd", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "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..4b76c827a7 --- /dev/null +++ b/labs/lab18/app_python/flake.nix @@ -0,0 +1,41 @@ +{ + description = "DevOps Info Service reproducible builds with Nix"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + default = import ./default.nix { inherit pkgs; }; + dockerImage = import ./docker.nix { inherit pkgs; }; + }); + + devShells = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + python = pkgs.python312.withPackages (ps: with ps; [ + fastapi + prometheus-client + uvicorn + ]); + in + { + default = pkgs.mkShell { + packages = [ + python + pkgs.curl + ]; + }; + }); + }; +} diff --git a/labs/lab18/app_python/requirements.txt b/labs/lab18/app_python/requirements.txt new file mode 100644 index 0000000000..3a0cc213f3 --- /dev/null +++ b/labs/lab18/app_python/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +prometheus-client==0.23.1 +pytest +httpx diff --git a/labs/lab18/lab18_runtime.txt b/labs/lab18/lab18_runtime.txt new file mode 100644 index 0000000000..6827fb4240 --- /dev/null +++ b/labs/lab18/lab18_runtime.txt @@ -0,0 +1,118 @@ +COMMAND: nix --version +nix (Determinate Nix 3.20.0) 2.34.6 + +COMMAND: nix-build default.nix +/nix/store/9frsq2d4cp52vkjpmw4kiflwrc8gk0y8-devops-info-service-1.0.0 + +COMMAND: readlink result +/nix/store/9frsq2d4cp52vkjpmw4kiflwrc8gk0y8-devops-info-service-1.0.0 + +COMMAND: nix-hash --type sha256 result +94600cef2e83fb943ace43326e1732984f263c265218f9da4b996a992dee2af9 + +COMMAND: rebuild and compare store path +first=/nix/store/9frsq2d4cp52vkjpmw4kiflwrc8gk0y8-devops-info-service-1.0.0 +second=/nix/store/9frsq2d4cp52vkjpmw4kiflwrc8gk0y8-devops-info-service-1.0.0 +store_paths_match=yes + +COMMAND: run Nix-built app and curl /health +{"status":"healthy","timestamp":"2026-05-13T19:07:19.928853+00:00","uptime_seconds":2} + +COMMAND: first app log lines +{"timestamp": "2026-05-13T19:07:17.216431+00:00", "level": "INFO", "logger": "uvicorn.error", "message": "Started server process [3126]", "service": "devops-python"} +{"timestamp": "2026-05-13T19:07:17.216650+00:00", "level": "INFO", "logger": "uvicorn.error", "message": "Waiting for application startup.", "service": "devops-python"} +{"timestamp": "2026-05-13T19:07:17.216817+00:00", "level": "INFO", "logger": "devops.python", "message": "application startup", "service": "devops-python", "event": "startup", "host": "127.0.0.1", "port": 15000, "debug": false} +{"timestamp": "2026-05-13T19:07:17.216868+00:00", "level": "INFO", "logger": "uvicorn.error", "message": "Application startup complete.", "service": "devops-python"} + +COMMAND: force rebuild after deleting app store path +delete_target=/nix/store/9frsq2d4cp52vkjpmw4kiflwrc8gk0y8-devops-info-service-1.0.0 +1 store paths deleted, 11.4 KiB freed +rebuilt=/nix/store/9frsq2d4cp52vkjpmw4kiflwrc8gk0y8-devops-info-service-1.0.0 + +COMMAND: nix-build docker.nix +/nix/store/6dxzl05d424w4sbs3jml0f64hvwa25pw-devops-info-service-nix.tar.gz + +COMMAND: file result +result: symbolic link to /nix/store/6dxzl05d424w4sbs3jml0f64hvwa25pw-devops-info-service-nix.tar.gz + +COMMAND: sha256sum result (first image build) +828135cc23d56f24c6328b742ed4052d21b094bb38c879689a0fec911552323a result + +COMMAND: dockerTools image reproducibility +first_result=/nix/store/6dxzl05d424w4sbs3jml0f64hvwa25pw-devops-info-service-nix.tar.gz +first_sha256=828135cc23d56f24c6328b742ed4052d21b094bb38c879689a0fec911552323a +second_result=/nix/store/6dxzl05d424w4sbs3jml0f64hvwa25pw-devops-info-service-nix.tar.gz +second_sha256=828135cc23d56f24c6328b742ed4052d21b094bb38c879689a0fec911552323a +docker_tar_hashes_match=yes +copied_tar=../devops-info-service-nix-1.0.0.tar.gz +-r-xr-xr-x 1 sfedbro sfedbro 91M May 13 22:10 ../devops-info-service-nix-1.0.0.tar.gz + +COMMAND: docker load Nix image +Loaded image: devops-info-service-nix:1.0.0 + +COMMAND: build traditional Docker image twice with --no-cache and compare docker save hashes +lab2_test1_sha256=6691df76978715e7809e5d53911e62783b54c08f32939f76caff7ea48a42fd9a +lab2_test2_sha256=64ac3320849e02f6c5bd664f70276d7b12cf21a98c6f5d90e664839497be1cc7 +traditional_docker_hashes_match=no + +COMMAND: run Lab 2 and Nix containers side by side +lab2_container=7f244def2dbd1b60b111c83b54f4927044e917137d64ebbe85faefdcb1133a3e. +nix_container=404c42c46edb640013d96fdb083b771d79730732deaaa0a8235aff856670d4b7. + +COMMAND: curl Lab 2 container /health +{"status":"healthy","timestamp":"2026-05-13T19:13:51.119954+00:00","uptime_seconds":2} + +COMMAND: curl Nix container /health +{"status":"healthy","timestamp":"2026-05-13T19:13:51.267399+00:00","uptime_seconds":2} + +COMMAND: docker images sizes +REPOSITORY TAG SIZE +lab2-app test2 180MB +lab2-app test1 180MB +devops-info-service-nix 1.0.0 229MB + +COMMAND: docker history summary +Lab 2 Dockerfile image: +CREATED BY SIZE +CMD ["python" "app.py"] 0B +USER appuser 0B +EXPOSE [5000/tcp] 0B +ENV CONFIG_FILE=/config/config.json 0B +ENV VISITS_FILE=/data/visits 0B +COPY app.py . # buildkit 11.2kB +RUN /bin/sh -c apt-get update && apt-get install -y --no-install-recommends && rm -rf /var/lib/apt/lists/* && useradd --create-home --shell /bin/bash appuser && mkdir -p /data /config && chown -R appuser:appuser /data /config && pip install --no-cache-dir -r requirements.txt # buildkit 60.8MB +Nix dockerTools image: +CREATED BY SIZE + 581B + 11.7kB + 248kB + 1.65MB + 5.59MB + 5.68MB + 958kB + +COMMAND: nix flake lock + +COMMAND: nix flake lock in native WSL path + +COMMAND: nix build .#default +/nix/store/6h7vs0p1204jnvir5f8l5nz4h7c4nd66-devops-info-service-1.0.0 + +COMMAND: nix develop checks +Python 3.12.13 +0.128.0 0.40.0 + +COMMAND: flake.lock nixpkgs revision + "locked": { + "lastModified": 1778580735, + "narHash": "sha256-t+8AVV8ExvOmslz2sLIgw/hJBKlyl65rJvxjvvjHgpE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "48d91f2c0ce7b9e589f967d4f685153dd765dcdd", + "type": "github" + }, + "original": { + +COMMAND: nix build .#dockerImage +/nix/store/3j6kfg8c4ncn33dxnj91qnbvcr27l292-devops-info-service-nix.tar.gz +9950b440bd03c9b997224e664ffcedc609b022c6fbc30d62f7715da9476e38c8 result diff --git a/labs/lab18/screenshots/lab18_1_docker_container_health.png b/labs/lab18/screenshots/lab18_1_docker_container_health.png new file mode 100644 index 0000000000..df93de04df Binary files /dev/null and b/labs/lab18/screenshots/lab18_1_docker_container_health.png differ diff --git a/labs/lab18/screenshots/lab18_2_nix_build_hash.png b/labs/lab18/screenshots/lab18_2_nix_build_hash.png new file mode 100644 index 0000000000..35401e9887 Binary files /dev/null and b/labs/lab18/screenshots/lab18_2_nix_build_hash.png differ diff --git a/labs/submission18.md b/labs/submission18.md new file mode 100644 index 0000000000..0e68de935d --- /dev/null +++ b/labs/submission18.md @@ -0,0 +1,440 @@ +# Lab 18 - Reproducible Builds with Nix + +## Environment + +Repository path in WSL: + +```text +/mnt/c/Users/Admin/Desktop/DevOps-Core-Course +``` + +Nix installation: + +```text +COMMAND: nix --version +nix (Determinate Nix 3.20.0) 2.34.6 +``` + +Basic Nix verification was completed before the lab: + +```text +COMMAND: nix run nixpkgs#hello +Hello, world! +``` + +Application used for the lab: + +- Source: `app_python/` +- Lab 18 copy: `labs/lab18/app_python/` +- Runtime: FastAPI on port `5000` +- Health endpoint: `/health` + +## Task 1 - Reproducible Python App + +### Files Created + +- `labs/lab18/app_python/app.py` +- `labs/lab18/app_python/requirements.txt` +- `labs/lab18/app_python/default.nix` + +The original Python application from Lab 1-2 was copied into `labs/lab18/app_python/`. + +### Nix Derivation + +`default.nix` builds a reproducible wrapper for the FastAPI service: + +```nix +{ pkgs ? import {} }: + +let + python = pkgs.python312.withPackages (ps: with ps; [ + fastapi + prometheus-client + uvicorn + ]); +in +pkgs.stdenv.mkDerivation { + pname = "devops-info-service"; + version = "1.0.0"; + src = ./.; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/devops-info-service $out/bin + cp app.py requirements.txt $out/share/devops-info-service/ + + makeWrapper ${python}/bin/python $out/bin/devops-info-service \ + --add-flags "$out/share/devops-info-service/app.py" \ + --set-default HOST "0.0.0.0" \ + --set-default PORT "5000" \ + --set-default APP_ENV "nix" + + runHook postInstall + ''; +} +``` + +Why this is reproducible: + +- Python version is selected from Nix, not from the host OS. +- Python packages are selected from the pinned Nix package set, not resolved dynamically by `pip`. +- The output path is content-addressed by Nix inputs. +- Build inputs are declared explicitly. + +### Build Evidence + +```text +COMMAND: nix-build default.nix +/nix/store/r7slan31szsf2ry3px8f9c7v24pmz0lc-devops-info-service-1.0.0 + +COMMAND: readlink result +/nix/store/r7slan31szsf2ry3px8f9c7v24pmz0lc-devops-info-service-1.0.0 + +COMMAND: nix-hash --type sha256 result +5c48f0e722ca6ddc3ce516e0be0830e40a3142276ea03934b3bde41912934f24 +``` + +Screenshot: + +![Nix build hash](lab18/screenshots/lab18_2_nix_build_hash.png) + +### Rebuild Evidence + +The same derivation was built twice. The store path stayed identical: + +```text +COMMAND: rebuild and compare store path +first=/nix/store/r7slan31szsf2ry3px8f9c7v24pmz0lc-devops-info-service-1.0.0 +second=/nix/store/r7slan31szsf2ry3px8f9c7v24pmz0lc-devops-info-service-1.0.0 +store_paths_match=yes +``` + +The output was then deleted from the Nix store and rebuilt from scratch: + +```text +COMMAND: force rebuild after deleting app store path +delete_target=/nix/store/r7slan31szsf2ry3px8f9c7v24pmz0lc-devops-info-service-1.0.0 +1 store paths deleted, 11.4 KiB freed +rebuilt=/nix/store/r7slan31szsf2ry3px8f9c7v24pmz0lc-devops-info-service-1.0.0 +``` + +The rebuilt output path is identical, proving that the same inputs produced the same output. + +### Runtime Evidence + +The Nix-built application was started with a custom port and queried with `curl`: + +```text +COMMAND: run Nix-built app and curl /health +{"status":"healthy","timestamp":"2026-05-13T19:07:19.928853+00:00","uptime_seconds":2} +``` + +Application logs: + +```text +{"timestamp": "2026-05-13T19:07:17.216431+00:00", "level": "INFO", "logger": "uvicorn.error", "message": "Started server process [3126]", "service": "devops-python"} +{"timestamp": "2026-05-13T19:07:17.216650+00:00", "level": "INFO", "logger": "uvicorn.error", "message": "Waiting for application startup.", "service": "devops-python"} +{"timestamp": "2026-05-13T19:07:17.216817+00:00", "level": "INFO", "logger": "devops.python", "message": "application startup", "service": "devops-python", "event": "startup", "host": "127.0.0.1", "port": 15000, "debug": false} +{"timestamp": "2026-05-13T19:07:17.216868+00:00", "level": "INFO", "logger": "uvicorn.error", "message": "Application startup complete.", "service": "devops-python"} +``` + +### Lab 1 vs Lab 18 Comparison + +| Aspect | Lab 1: `pip` + venv | Lab 18: Nix | +|--------|----------------------|-------------| +| Python version | Depends on local system Python | Comes from Nix package set | +| Dependency resolution | Happens at install time with `pip` | Declared in Nix expression | +| Transitive dependencies | Can drift unless fully locked with hashes | Locked by Nix closure | +| Build isolation | Virtual environment, still host-dependent | Nix sandbox and store | +| Rebuild output | No content-addressed output path | Same inputs produce same store path | +| Binary cache | No native content-addressed cache | Nix store and binary caches | + +`requirements.txt` is weaker than Nix because it usually pins only direct Python dependencies. Even this project has transitive dependencies such as `starlette`, `pydantic`, `click`, `h11`, and `anyio`; in the traditional workflow those are resolved by `pip` at install time. Nix captures the whole dependency closure and produces a store path from all build inputs. + +### Nix Store Path Format + +Example: + +```text +/nix/store/r7slan31szsf2ry3px8f9c7v24pmz0lc-devops-info-service-1.0.0 +``` + +Meaning: + +- `/nix/store`: immutable Nix store root +- `r7slan31szsf2ry3px8f9c7v24pmz0lc`: hash derived from build inputs +- `devops-info-service`: package name +- `1.0.0`: package version + +If source code, dependencies, or build instructions change, the hash changes and a new store path is created. + +## Task 2 - Reproducible Docker Images + +### Files Created + +- `labs/lab18/app_python/docker.nix` +- `labs/lab18/app_python/Dockerfile` + +The Dockerfile copy represents the Lab 2 traditional container build. The Nix Docker image is built with `dockerTools`. + +### Nix Docker Image + +`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 + pkgs.cacert + ]; + + config = { + Cmd = [ "${app}/bin/devops-info-service" ]; + Env = [ + "HOST=0.0.0.0" + "PORT=5000" + "APP_ENV=nix-docker" + "VISITS_FILE=/tmp/devops-info-service/visits" + "CONFIG_FILE=/config/config.json" + ]; + ExposedPorts = { + "5000/tcp" = {}; + }; + WorkingDir = "/"; + }; + + created = "1970-01-01T00:00:01Z"; +} +``` + +The fixed `created` timestamp avoids timestamp drift in image metadata. + +### Nix Docker Build Evidence + +```text +COMMAND: nix-build docker.nix +/nix/store/6dxzl05d424w4sbs3jml0f64hvwa25pw-devops-info-service-nix.tar.gz + +COMMAND: sha256sum result +828135cc23d56f24c6328b742ed4052d21b094bb38c879689a0fec911552323a result +``` + +Rebuild comparison: + +```text +COMMAND: dockerTools image reproducibility +first_result=/nix/store/6dxzl05d424w4sbs3jml0f64hvwa25pw-devops-info-service-nix.tar.gz +first_sha256=828135cc23d56f24c6328b742ed4052d21b094bb38c879689a0fec911552323a +second_result=/nix/store/6dxzl05d424w4sbs3jml0f64hvwa25pw-devops-info-service-nix.tar.gz +second_sha256=828135cc23d56f24c6328b742ed4052d21b094bb38c879689a0fec911552323a +docker_tar_hashes_match=yes +``` + +### Traditional Dockerfile Comparison + +The traditional Dockerfile was built twice with `--no-cache`, then each image was saved and hashed: + +```text +COMMAND: build traditional Docker image twice with --no-cache and compare docker save hashes +lab2_test1_sha256=6691df76978715e7809e5d53911e62783b54c08f32939f76caff7ea48a42fd9a +lab2_test2_sha256=64ac3320849e02f6c5bd664f70276d7b12cf21a98c6f5d90e664839497be1cc7 +traditional_docker_hashes_match=no +``` + +The two traditional Docker image tarballs differ even though the Dockerfile and source were the same. Reasons: + +- Docker layer metadata includes creation time. +- `apt-get update` reads mutable package indexes. +- `pip install` resolves dependencies from PyPI at build time. +- Docker tags such as `python:3.12-slim` can point to newer image digests over time unless pinned by digest. + +### Container Runtime Evidence + +The Nix-built image was loaded into Docker: + +```text +COMMAND: docker load Nix image +Loaded image: devops-info-service-nix:1.0.0 +``` + +Both containers ran side by side: + +```text +COMMAND: run Lab 2 and Nix containers side by side +lab2_container=7f244def2dbd1b60b111c83b54f4927044e917137d64ebbe85faefdcb1133a3e. +nix_container=404c42c46edb640013d96fdb083b771d79730732deaaa0a8235aff856670d4b7. + +COMMAND: curl Lab 2 container /health +{"status":"healthy","timestamp":"2026-05-13T19:13:51.119954+00:00","uptime_seconds":2} + +COMMAND: curl Nix container /health +{"status":"healthy","timestamp":"2026-05-13T19:13:51.267399+00:00","uptime_seconds":2} +``` + +Screenshot: + +![Docker containers health](lab18/screenshots/lab18_1_docker_container_health.png) + +Image size comparison: + +```text +COMMAND: docker images sizes +REPOSITORY TAG SIZE +lab2-app test2 180MB +lab2-app test1 180MB +devops-info-service-nix 1.0.0 229MB +``` + +In this implementation the Nix image is larger because it includes the Nix store closure for Python and dependencies as image layers. The main benefit demonstrated here is reproducibility, not image minimization. + +### Lab 2 vs Lab 18 Docker Comparison + +| Aspect | Lab 2 Dockerfile | Lab 18 Nix dockerTools | +|--------|------------------|------------------------| +| Base image | `python:3.12-slim` Docker tag | No mutable Docker base image | +| Dependency install | `pip install -r requirements.txt` during Docker build | Nix dependency closure | +| Timestamps | Vary between builds | Fixed image timestamp | +| Hash result | Different `docker save` SHA256 values | Identical tarball SHA256 values | +| Runtime result | Works | Works | +| Size in this run | 180 MB | 229 MB | +| Reproducibility | Not bit-for-bit reproducible | Bit-for-bit reproducible | + +## Bonus - Modern Nix With Flakes + +### Files Created + +- `labs/lab18/app_python/flake.nix` +- `labs/lab18/app_python/flake.lock` + +### Flake Configuration + +`flake.nix` exposes: + +- `packages.x86_64-linux.default` +- `packages.x86_64-linux.dockerImage` +- `devShells.x86_64-linux.default` + +The flake pins `nixpkgs` through `flake.lock`. + +### flake.lock Evidence + +```text +COMMAND: flake.lock nixpkgs revision +"locked": { + "lastModified": 1778580735, + "narHash": "sha256-t+8AVV8ExvOmslz2sLIgw/hJBKlyl65rJvxjvvjHgpE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "48d91f2c0ce7b9e589f967d4f685153dd765dcdd", + "type": "github" +} +``` + +### Flake Build Evidence + +The flake was built from a native WSL path because `nix flake lock` was very slow on the Windows-mounted `/mnt/c` Git tree. + +```text +COMMAND: nix build .#default +/nix/store/6h7vs0p1204jnvir5f8l5nz4h7c4nd66-devops-info-service-1.0.0 + +COMMAND: nix build .#dockerImage +/nix/store/3j6kfg8c4ncn33dxnj91qnbvcr27l292-devops-info-service-nix.tar.gz +9950b440bd03c9b997224e664ffcedc609b022c6fbc30d62f7715da9476e38c8 result +``` + +### Dev Shell Evidence + +```text +COMMAND: nix develop checks +Python 3.12.13 +0.128.0 0.40.0 +``` + +The printed Python package versions are: + +- FastAPI `0.128.0` +- Uvicorn `0.40.0` + +These versions come from the locked `nixpkgs` revision, not from the host Python environment. + +### Lab 10 Helm Values vs Nix Flakes + +| Aspect | Lab 10 Helm values | Lab 18 Nix Flakes | +|--------|--------------------|-------------------| +| Locks app deployment config | Yes | Not the focus | +| Locks image tag | Yes, if tag is explicit | Can build and lock the image content | +| Locks Python version | No | Yes | +| Locks Python dependencies | No | Yes through Nix closure | +| Locks build tools | No | Yes | +| Lock format | `values.yaml`, `Chart.lock` for chart deps | `flake.lock` with exact revision and hash | +| Reproducibility guarantee | Deployment-level, tag-based | Build-level, content-addressed | + +Helm is still useful for deploying to Kubernetes, but it does not make the application image reproducible by itself. Nix Flakes solve the build reproducibility problem before the image is deployed. + +## Challenges And Solutions + +### Repository Recovery Experience + +Earlier in the course, around Lab 5, I had a repository recovery issue. That experience made this lab feel more serious because losing or accidentally changing project state can make later evidence difficult to trust. I did not treat that as proof that previous labs were invalid, but it did make me more careful in Lab 18: I kept the Nix expressions, command outputs, screenshots, and raw runtime log in the repository so the work can be rebuilt and checked again. + +This is also one of the practical reasons reproducible builds matter. If a repository or environment has to be restored, the build should not depend on memory, local machine state, or whatever package versions happen to be current on that day. + +### Slow Flake Lock On `/mnt/c` + +`nix flake lock` was too slow when executed directly inside the Windows-mounted repository path. The workaround was: + +1. Copy `labs/lab18/app_python/` into native WSL path `/tmp/lab18-app-python`. +2. Run `nix flake lock`, `nix build`, and `nix develop` there. +3. Copy the generated `flake.lock` back into the repository. + +This does not change the Nix expressions. It only avoids slow Git/filesystem operations on `/mnt/c`. + +### Docker CLI In WSL + +Docker CLI was not available inside WSL: + +```text +The command 'docker' could not be found in this WSL 2 distro. +``` + +Solution: + +- Build Nix image tarball in WSL. +- Copy the tarball to the repository path. +- Use Docker Desktop from Windows PowerShell for `docker load`, `docker build`, `docker run`, and `curl` verification. + +## Final Results + +| Requirement | Status | +|-------------|--------| +| Nix installed and verified | Done | +| Python app built with Nix | Done | +| Store path reproducibility shown | Done | +| Forced rebuild after deleting store path | Done | +| Nix-built app runtime verified | Done | +| Docker image built with `dockerTools` | Done | +| Nix Docker tarball hash reproducibility shown | Done | +| Traditional Dockerfile non-reproducibility shown | Done | +| Lab 2 and Nix containers tested side by side | Done | +| Flake bonus | Done | +| Dev shell bonus | Done | + +## Reflection + +This lab made the idea of reproducibility more concrete for me. Earlier in the course I already had a stressful repository recovery situation around Lab 5, and that made me think about how fragile coursework or production work can become when the exact state of files, dependencies, and build steps is not easy to reproduce. Nix addresses that problem by making the build inputs explicit and by producing content-addressed outputs. + +Nix would have helped in Lab 1 by removing the dependency on the local Python installation and making the Python dependency tree explicit. It would have helped in Lab 2 by removing mutable Docker build inputs and timestamp drift from the container image. The store path and hash comparisons in this lab are useful because they give evidence that can be checked again later, not just a statement that the build worked once on my machine. + +The tradeoff is complexity. Docker and `requirements.txt` are easier to understand at first, while Nix requires learning a new workflow and debugging unfamiliar problems such as flakes on `/mnt/c`. Still, for CI/CD, audits, long-term maintenance, and recovery after mistakes, Nix provides stronger guarantees than `pip`, virtual environments, Dockerfiles, or Helm values alone.