{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "# Lesson 7 — Containers, Configuration & Secrets\n",
    "\n",
    "**Companion notebook** for the Lesson 7 slides. Building images needs Docker on your\n",
    "machine (see the slides); this notebook covers the parts you can check anywhere:\n",
    "configuration from the environment, secrets that never touch the image, a linter for the\n",
    "Dockerfile mistakes the slides warn about, and how Compose orders services."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-001",
   "metadata": {},
   "outputs": [],
   "source": [
    "def check(label, got, expected, tol=1e-6):\n",
    "    \"\"\"Compare your answer with the expected value; prints ✅ or ❌ (never raises).\"\"\"\n",
    "    import math\n",
    "    try:\n",
    "        if isinstance(expected, float) or isinstance(got, float):\n",
    "            ok = math.isclose(float(got), float(expected), rel_tol=tol, abs_tol=tol)\n",
    "        elif isinstance(expected, (list, tuple)) and expected and isinstance(expected[0], str):\n",
    "            ok = list(got) == list(expected)\n",
    "        elif hasattr(expected, \"__len__\") and not isinstance(expected, str):\n",
    "            import numpy as np\n",
    "            ok = np.allclose(np.asarray(got, dtype=float), np.asarray(expected, dtype=float), rtol=tol, atol=tol)\n",
    "        else:\n",
    "            ok = got == expected\n",
    "    except Exception:\n",
    "        ok = False\n",
    "    print((\"✅ \" if ok else \"❌ \") + f\"{label}: got {got!r}, expected {expected!r}\")\n",
    "import os\n",
    "import re\n",
    "import shutil\n",
    "import tempfile\n",
    "from pathlib import Path\n",
    "\n",
    "import yaml\n",
    "from pydantic import SecretStr\n",
    "from pydantic_settings import BaseSettings, SettingsConfigDict\n",
    "\n",
    "print(\"Docker on this machine:\", shutil.which(\"docker\") or \"not found (fine for this notebook)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-002",
   "metadata": {},
   "source": [
    "---\n",
    "## Your turn 1 — settings from the environment\n",
    "\n",
    "Write `Settings`: fields `database_url` (default `\"sqlite:///rating.db\"`), `api_key`\n",
    "(`SecretStr`, default empty) and `liability_basis` (default `\"model\"`), read from\n",
    "environment variables with the prefix `RP_` and from files in `SECRETS` (a stand-in for\n",
    "`/run/secrets`). Then run the four scenarios below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "SECRETS = Path(tempfile.mkdtemp())\n",
    "Settings = None\n",
    "...  # your code here\n",
    "\n",
    "\n",
    "def scenario(env=None, secret_file=None):\n",
    "    for k in list(os.environ):\n",
    "        if k.startswith(\"RP_\"):\n",
    "            del os.environ[k]\n",
    "    os.environ.update(env or {})\n",
    "    (SECRETS / \"rp_api_key\").unlink(missing_ok=True)\n",
    "    if secret_file is not None:\n",
    "        (SECRETS / \"rp_api_key\").write_text(secret_file)\n",
    "    return Settings()\n",
    "\n",
    "\n",
    "results = None\n",
    "if Settings is not None:\n",
    "    defaults = scenario()\n",
    "    from_env = scenario(env={\"RP_DATABASE_URL\": \"postgresql+psycopg://rating@db/rating\"})\n",
    "    from_secret = scenario(secret_file=\"k-from-file\")\n",
    "    both = scenario(env={\"RP_API_KEY\": \"k-from-env\"}, secret_file=\"k-from-file\")\n",
    "    results = [defaults.database_url, from_env.database_url, from_secret.api_key.get_secret_value(),\n",
    "               both.api_key.get_secret_value()]\n",
    "    print(results)\n",
    "    print(\"repr hides the secret:\", repr(from_secret.api_key))\n",
    "    scenario()\n",
    "check(\"defaults, env, secret file, env beats file\", results,\n",
    "      [\"sqlite:///rating.db\", \"postgresql+psycopg://rating@db/rating\", \"k-from-file\", \"k-from-env\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-004",
   "metadata": {},
   "source": [
    "## Your turn 2 — a Dockerfile linter\n",
    "\n",
    "Write `lint(dockerfile) -> set[str]` returning the codes of the problems found:\n",
    "\n",
    "| code | problem |\n",
    "|---|---|\n",
    "| `UNPINNED` | a `FROM` image with no tag, or the tag `latest` (a stage named earlier with `AS` is not an image) |\n",
    "| `ROOT` | no `USER` instruction: the container runs as root |\n",
    "| `SECRET` | an `ENV` or `ARG` whose name contains KEY, SECRET, PASSWORD or TOKEN |\n",
    "| `CACHE` | `COPY . .` (or `ADD . .`) before the first `RUN`: every code change reinstalls everything |\n",
    "| `NOHEALTH` | no `HEALTHCHECK` in the final stage |\n",
    "\n",
    "Treat instructions case-insensitively, ignore comments, and join lines ending in `\\`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-005",
   "metadata": {},
   "outputs": [],
   "source": [
    "NAIVE = \"\"\"\n",
    "FROM python:3.12-slim-bookworm\n",
    "WORKDIR /app\n",
    "COPY . .\n",
    "RUN pip install uv && uv pip install --system \".[postgres,portal]\"\n",
    "ENV RP_API_KEY=prod-key-4f9a\n",
    "CMD [\"uvicorn\", \"rating_platform.api:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n",
    "\"\"\"\n",
    "REFERENCE = \"\"\"# syntax=docker/dockerfile:1\n",
    "# Multi-stage, multi-target build:\n",
    "#   docker build --target api    -t rating-api:1.0.0 .\n",
    "#   docker build --target portal -t rating-portal:1.0.0 .\n",
    "# Build stages have uv; runtime stages have only Python and a virtualenv.\n",
    "\n",
    "# ---- build stages ------------------------------------------------------------------------------\n",
    "FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS build-base\n",
    "ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=0\n",
    "WORKDIR /app\n",
    "\n",
    "FROM build-base AS build-api\n",
    "# Dependencies first, from the lockfile only: this layer is reused until uv.lock changes.\n",
    "RUN --mount=type=cache,target=/root/.cache/uv \\\\\n",
    "    --mount=type=bind,source=uv.lock,target=uv.lock \\\\\n",
    "    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \\\\\n",
    "    uv sync --locked --no-dev --no-install-project --extra postgres\n",
    "# Then the project itself: a change in src/ rebuilds only this small layer.\n",
    "# --reinstall-package: uv's cache keys a local project on pyproject.toml, not on src/ files.\n",
    "COPY pyproject.toml uv.lock README.md ./\n",
    "COPY src ./src\n",
    "RUN --mount=type=cache,target=/root/.cache/uv \\\\\n",
    "    uv sync --locked --no-dev --no-editable --reinstall-package rating-platform --extra postgres\n",
    "\n",
    "FROM build-base AS build-portal\n",
    "RUN --mount=type=cache,target=/root/.cache/uv \\\\\n",
    "    --mount=type=bind,source=uv.lock,target=uv.lock \\\\\n",
    "    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \\\\\n",
    "    uv sync --locked --no-dev --no-install-project --extra postgres --extra portal\n",
    "COPY pyproject.toml uv.lock README.md ./\n",
    "COPY src ./src\n",
    "RUN --mount=type=cache,target=/root/.cache/uv \\\\\n",
    "    uv sync --locked --no-dev --no-editable --reinstall-package rating-platform \\\\\n",
    "    --extra postgres --extra portal\n",
    "\n",
    "# ---- runtime stages ----------------------------------------------------------------------------\n",
    "FROM python:3.12-slim-bookworm AS runtime-base\n",
    "RUN useradd --create-home --uid 10001 app\n",
    "WORKDIR /app\n",
    "ENV PATH=\"/app/.venv/bin:$PATH\" PYTHONUNBUFFERED=1 RP_MODEL_DIR=/app/models/champion\n",
    "COPY --chown=app:app models ./models\n",
    "\n",
    "FROM runtime-base AS portal\n",
    "COPY --from=build-portal --chown=app:app /app/.venv /app/.venv\n",
    "USER app\n",
    "EXPOSE 8501\n",
    "HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \\\\\n",
    "    CMD [\"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health')\"]\n",
    "CMD [\"rating-platform\", \"portal\", \"--host\", \"0.0.0.0\"]\n",
    "\n",
    "FROM runtime-base AS api\n",
    "COPY --from=build-api --chown=app:app /app/.venv /app/.venv\n",
    "USER app\n",
    "EXPOSE 8000\n",
    "HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \\\\\n",
    "    CMD [\"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')\"]\n",
    "CMD [\"uvicorn\", \"rating_platform.api:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n",
    "\"\"\"\n",
    "lint = None\n",
    "...  # your code here\n",
    "if lint is not None:\n",
    "    print(\"naive:\", sorted(lint(NAIVE)))\n",
    "    print(\"reference:\", sorted(lint(REFERENCE)))\n",
    "check(\"naive Dockerfile\", None if lint is None else sorted(lint(NAIVE)), [\"CACHE\", \"NOHEALTH\", \"ROOT\", \"SECRET\"])\n",
    "check(\"reference Dockerfile is clean\", None if lint is None else lint(REFERENCE) == set(), True)\n",
    "check(\"an unpinned base image\", None if lint is None else \"UNPINNED\" in lint(\"FROM python\\nUSER app\\nHEALTHCHECK CMD true\"), True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-006",
   "metadata": {},
   "source": [
    "## Your turn 3 — what never goes into the build context\n",
    "\n",
    "Everything in the build folder is sent to Docker and can end up in an image. Write\n",
    "`missing(dockerignore)` returning, in this order, which of `.git`, `.venv`, `.env`,\n",
    "`secrets`, `*.db` the file does **not** list."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-007",
   "metadata": {},
   "outputs": [],
   "source": [
    "REQUIRED = [\".git\", \".venv\", \".env\", \"secrets\", \"*.db\"]\n",
    "REFERENCE_IGNORE = \"\"\".git\n",
    ".venv\n",
    "**/__pycache__\n",
    ".pytest_cache\n",
    ".mypy_cache\n",
    ".ruff_cache\n",
    ".coverage\n",
    "*.db\n",
    ".env\n",
    "secrets\n",
    "tests\n",
    ".github\n",
    "\"\"\"\n",
    "missing = None\n",
    "...  # your code here\n",
    "check(\"reference .dockerignore is complete\", None if missing is None else missing(REFERENCE_IGNORE) == [], True)\n",
    "check(\"a .dockerignore with only .venv\", None if missing is None else missing(\".venv\\n\"),\n",
    "      [\".git\", \".env\", \"secrets\", \"*.db\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-008",
   "metadata": {},
   "source": [
    "## Your turn 4 — the order Compose starts things\n",
    "\n",
    "Parse the platform's `compose.yaml` and return the services in an order that respects\n",
    "every `depends_on` (a topological sort; break ties alphabetically). Also list the\n",
    "services that publish a port to your machine, and the services that receive the\n",
    "`api_key` secret."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "COMPOSE = \"\"\"# The whole platform on one machine:  docker compose up --build\n",
    "#   API     http://localhost:8000/docs\n",
    "#   portal  http://localhost:8501\n",
    "# Secrets are files mounted at /run/secrets, never environment variables baked into images.\n",
    "\n",
    "services:\n",
    "  db:\n",
    "    image: postgres:17-alpine\n",
    "    environment:\n",
    "      POSTGRES_USER: rating\n",
    "      POSTGRES_DB: rating\n",
    "      POSTGRES_PASSWORD_FILE: /run/secrets/db_password\n",
    "    secrets: [db_password]\n",
    "    volumes: [pgdata:/var/lib/postgresql/data]\n",
    "    healthcheck:\n",
    "      test: [\"CMD-SHELL\", \"pg_isready -U rating -d rating\"]\n",
    "      interval: 5s\n",
    "      retries: 10\n",
    "\n",
    "  init:  # one-off job: create tables and load the in-force book, then exit\n",
    "    image: rating-api:1.0.0\n",
    "    build: {context: ., target: api}\n",
    "    command: [\"rating-platform\", \"init-db\", \"--book\", \"/data/book_2025.csv\"]\n",
    "    environment: &db-env\n",
    "      RP_DATABASE_URL: postgresql+psycopg://rating@db:5432/rating\n",
    "    secrets: &db-secret\n",
    "      - source: db_password\n",
    "        target: rp_database_password\n",
    "    volumes: [\"../../data/motor:/data:ro\"]\n",
    "    depends_on:\n",
    "      db: {condition: service_healthy}\n",
    "\n",
    "  api:\n",
    "    image: rating-api:1.0.0\n",
    "    build: {context: ., target: api}\n",
    "    environment: *db-env\n",
    "    secrets:\n",
    "      - source: db_password\n",
    "        target: rp_database_password\n",
    "      - source: api_key\n",
    "        target: rp_api_key\n",
    "    ports: [\"8000:8000\"]\n",
    "    depends_on:\n",
    "      init: {condition: service_completed_successfully}\n",
    "\n",
    "  portal:\n",
    "    image: rating-portal:1.0.0\n",
    "    build: {context: ., target: portal}\n",
    "    environment:\n",
    "      <<: *db-env\n",
    "      RP_API_URL: http://api:8000\n",
    "    secrets:\n",
    "      - source: db_password\n",
    "        target: rp_database_password\n",
    "      - source: api_key\n",
    "        target: rp_api_key\n",
    "    ports: [\"8501:8501\"]\n",
    "    depends_on:\n",
    "      api: {condition: service_healthy}\n",
    "\n",
    "secrets:\n",
    "  db_password:\n",
    "    file: ./secrets/db_password.txt\n",
    "  api_key:\n",
    "    file: ./secrets/api_key.txt\n",
    "\n",
    "volumes:\n",
    "  pgdata:\n",
    "\"\"\"\n",
    "order = published = gets_key = None\n",
    "...  # your code here\n",
    "check(\"start order\", order, [\"db\", \"init\", \"api\", \"portal\"])\n",
    "check(\"published ports\", published, [\"api\", \"portal\"])\n",
    "check(\"services holding the API key\", gets_key, [\"api\", \"portal\"])"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
