diff --git a/backend/.containerignore b/backend/.containerignore new file mode 100644 index 0000000..592ea54 --- /dev/null +++ b/backend/.containerignore @@ -0,0 +1,48 @@ +# Build context = backend/. Keep it to source + manifests. +# podman reads .containerignore; docker reads .dockerignore (a symlink of this +# file lives next to it). + +# Virtualenvs / caches +.venv/ +venv/ +env/ +__pycache__/ +**/__pycache__/ +*.py[cod] +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ + +# Tests, coverage, dev artifacts +tests/ +htmlcov/ +.coverage +.coverage.* +coverage.xml + +# Local databases — never bake user data into an image +*.db +*.db-shm +*.db-wal +*.db-journal + +# Secrets / local config +.env +.env.* +!.env.example + +# Scraper page dumps and verification screenshots: REAL PERSONAL DATA from +# Försäkringskassan. These must never enter an image layer. +captures/ +debug/ + +# Repo-level noise that is not part of this context anyway +.git/ +.gitignore +graphify-out/ +scripts/ +*.md +!README.md +Dockerfile +.containerignore +.dockerignore diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 120000 index 0000000..092a75d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1 @@ +.containerignore \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e35be0c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,203 @@ +# syntax=docker/dockerfile:1.7 +# +# Föräldraledigplannerare — backend (FastAPI + uvicorn, Python 3.14, uv). +# +# Build context: backend/ +# +# podman build -t foraldraledig-backend backend/ # slim default +# podman build --target scraper -t foraldraledig-backend:scraper backend/ # + Chromium +# +# --------------------------------------------------------------------------- +# WHY TWO RUNTIMES +# +# `playwright` sits in [project].dependencies (not an extra), so the Python +# package is always installed — but the *browser* it drives is a separate +# ~170 MB download plus its system libraries, and Playwright publishes no +# musl/Alpine build at all. Shipping Chromium in the default image would +# several-fold inflate it for a feature most deployments never turn on. +# +# So: +# * target `runtime` (default, and deliberately the LAST stage so a plain +# `podman build` lands here) — Alpine, NO Chromium. Runs with +# APP_SCRAPER_ENABLED=false baked in; the FK-scraper endpoints are the +# only thing unavailable. +# * target `scraper` (opt-in) — Debian slim + Chromium + its libs, built +# from its own Debian dependency stage because musl wheels cannot be +# reused on glibc. +# --------------------------------------------------------------------------- + + +# =========================================================================== +# Stage 1 — dependency build (glibc / Debian), only for the scraper variant +# =========================================================================== +FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS deps-glibc + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + # Build the venv at its FINAL path. A uv/virtualenv venv is not + # relocatable: console-script shebangs are absolute, so a venv built at + # /build/.venv and copied to /app/.venv yields "uvicorn: not found" + # (exit 127 — a missing interpreter, not a missing script). + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /build +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-dev --no-install-project + + +# =========================================================================== +# Stage 2 — opt-in scraper runtime: Debian slim + Chromium +# =========================================================================== +FROM python:3.14-slim-trixie AS scraper + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" \ + APP_SQLITE_PATH=/data/foraldraledig.db \ + APP_SCRAPER_ENABLED=true \ + APP_SCRAPER_HEADLESS=true \ + # Browsers outside $HOME so they survive a read-only /app and any UID. + PLAYWRIGHT_BROWSERS_PATH=/opt/playwright + +WORKDIR /app + +# --chown here, not a `chown -R /app` below: see the note in the default +# runtime stage — a recursive chown duplicates the whole venv into a new layer. +COPY --from=deps-glibc --chown=10001:10001 /app/.venv /app/.venv +COPY --chown=10001:10001 alembic.ini ./ +COPY --chown=10001:10001 migrations ./migrations +COPY --chown=10001:10001 app ./app + +# `playwright install --with-deps` pulls Chromium *and* the system libraries +# it needs. --only-shell installs the headless shell only (no headed browser, +# no ffmpeg), which is all the FK scraper uses. This single line is why this +# variant is big, and the whole reason it is opt-in. +RUN python -m compileall -q /app/app /app/migrations \ + && playwright install --with-deps --only-shell chromium \ + && rm -rf /var/lib/apt/lists/* /root/.cache \ + && groupadd -g 10001 app \ + && useradd -u 10001 -g app -M -d /app app \ + && mkdir -p /data \ + && chown app:app /data \ + && chmod -R a+rX /opt/playwright + +VOLUME ["/data"] +USER 10001:10001 +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ + CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"] + +# Chromium needs writable scratch space; with --read-only add +# --tmpfs /tmp --tmpfs /dev/shm:size=256m +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] + + +# =========================================================================== +# Stage 3 — dependency build (musl / Alpine), for the default slim runtime +# =========================================================================== +FROM ghcr.io/astral-sh/uv:python3.14-alpine AS deps-musl + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +# No compiler needed: every dependency in uv.lock except playwright publishes +# a cp314 musllinux aarch64/x86_64 wheel today (argon2-cffi, greenlet, uvloop, +# httptools, pydantic-core all do). If a future dependency bump breaks that, +# add `RUN apk add --no-cache build-base libffi-dev` here — it is builder-only +# and never reaches the final image. + +WORKDIR /build + +# Manifests first: this layer stays cached until the dependency set actually +# changes, so source edits never re-resolve or re-compile the tree. +COPY pyproject.toml uv.lock ./ + +# --no-install-project: the app is plain source we COPY in below, so the venv +# holds third-party dependencies only. --no-dev drops pytest/ruff/httpx. +# +# --no-install-package playwright: playwright ships NO musllinux wheel at all +# (manylinux/macOS/win only), so on Alpine it is not merely undesirable, it is +# uninstallable. Dropping it is safe *only* because the import is lazy — +# app/scrapers/fk_scraper.py does `from playwright.async_api import +# async_playwright` inside the function that drives the browser, not at module +# scope, so `app.main` imports cleanly without it. The scraper is disabled via +# APP_SCRAPER_ENABLED=false in the runtime stage; use the `scraper` target if +# you need it. Keep that import lazy, or this stage breaks at boot. +RUN uv sync --locked --no-dev --no-install-project --no-install-package playwright + + +# =========================================================================== +# Stage 4 — DEFAULT runtime: Alpine, no build toolchain, no Chromium +# =========================================================================== +FROM python:3.14-alpine AS runtime + +# Pinned by tag, not digest, on purpose: a tag pin keeps rebuilds picking up +# Alpine/CPython security patches, which matters more here than bit-for-bit +# reproducibility — and the *Python* dependency set is already byte-pinned by +# uv.lock (`uv sync --locked`). Pin the digest in the deployment pipeline if +# full supply-chain immutability outranks patching. + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" \ + # SQLite lives on the mounted volume, never in an image layer. + APP_SQLITE_PATH=/data/foraldraledig.db \ + # No Chromium in this image — see "WHY TWO RUNTIMES" above. + APP_SCRAPER_ENABLED=false + +WORKDIR /app + +# --chown on the COPY itself: a later `chown -R /app` would rewrite every +# file's metadata and so duplicate the entire 89 MB venv into a second layer +# (it cost ~86 MB before this was fixed). +COPY --from=deps-musl --chown=10001:10001 /app/.venv /app/.venv + +# Source last — it changes most often, so it is the only layer that rebuilds. +# alembic.ini and migrations/ must sit in WORKDIR: app/db/connection.py loads +# `Config("alembic.ini")` by a path relative to the process CWD. +COPY --chown=10001:10001 alembic.ini ./ +COPY --chown=10001:10001 migrations ./migrations +COPY --chown=10001:10001 app ./app + +# Pre-compile so a read-only root filesystem never needs to write .pyc files. +RUN python -m compileall -q /app/app /app/migrations \ + && addgroup -g 10001 -S app \ + && adduser -u 10001 -S -G app -h /app app \ + && mkdir -p /data \ + && chown app:app /data + +# SQLite database + its WAL/SHM sidecars. Mount a named volume or host path +# here; without one the data dies with the container. +# +# Read-only root filesystem (verified working) — run with: +# --read-only --tmpfs /tmp -v :/data +# Nothing outside /data is written at runtime: bytecode is pre-compiled above +# and PYTHONDONTWRITEBYTECODE is set. +VOLUME ["/data"] + +USER 10001:10001 +EXPOSE 8000 + +# /health is defined in app/main.py. urllib keeps this dependency-free — no +# curl or wget needs to exist in the image. +HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ + CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"] + +# Exec form => uvicorn is PID 1 and receives SIGTERM/SIGINT directly, so it +# runs its own graceful shutdown (lifespan closes the DB connection) rather +# than being SIGKILLed after the stop timeout. No init shim needed: a single +# uvicorn process spawns no children and has nothing to reap. +# +# MIGRATIONS: the app lifespan already calls `alembic upgrade head` itself on +# SQLite (app/main.py -> app/db/connection.py:run_migrations), so the schema is +# applied on boot. That is forward-only `upgrade`, never `downgrade`, so no +# destructive DDL runs implicitly. On Postgres (APP_DATABASE_TYPE=postgres) the +# lifespan does NOT migrate — run it as a one-shot before rollout: +# podman run --rm -e APP_DATABASE_TYPE=postgres ... alembic upgrade head +# To take the boot migration off the hot path on SQLite too, run that same +# one-shot and treat the auto-migrate as a no-op safety net. +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] diff --git a/frontend/.containerignore b/frontend/.containerignore new file mode 100644 index 0000000..fd29a0a --- /dev/null +++ b/frontend/.containerignore @@ -0,0 +1,32 @@ +# Build context = frontend/. Everything the Vite build does not read stays out. + +node_modules/ +dist/ +coverage/ +test-results/ +playwright-report/ +.vite/ +.turbo/ + +# Tests and E2E are gated in CI, not in the image build. +tests/ +e2e/ +playwright.config.ts +vitest.config.ts +**/*.test.ts +**/*.test.tsx + +# Local env / secrets +.env +.env.* +!.env.example + +# Repo noise +.git/ +.gitignore +.DS_Store +*.md +out.js +Dockerfile +.containerignore +.dockerignore diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 120000 index 0000000..092a75d --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1 @@ +.containerignore \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b70511f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,90 @@ +# syntax=docker/dockerfile:1.7 +# +# Föräldraledigplannerare — frontend (Vite build, served by nginx-unprivileged). +# +# Build context: frontend/ +# +# podman build -t foraldraledig-frontend frontend/ +# podman run -p 8080:8080 -e API_UPSTREAM=http://backend:8000 foraldraledig-frontend +# +# --------------------------------------------------------------------------- +# NO BUILD-TIME API URL — AND NONE IS NEEDED +# +# src/services/api.ts uses `const BASE = '/api'`: every call is same-origin and +# relative. Nothing about the backend location is baked into the bundle by +# Vite, so the usual `import.meta.env.VITE_API_URL` placeholder-substitution +# dance does not apply here. Instead this image *is* the origin: nginx serves +# the static bundle and reverse-proxies /api to the backend, with the upstream +# taken from the ${API_UPSTREAM} env var at container start (nginx's own +# envsubst templating, /etc/nginx/templates). One knob, resolved at runtime, +# and the default points at a compose/pod service name rather than localhost. +# +# Caveat: nginx resolves the proxy_pass host once, at startup. If the backend +# name is not resolvable yet, nginx exits — start the backend first, or give +# the frontend a restart policy. +# --------------------------------------------------------------------------- + + +# =========================================================================== +# Stage 1 — build the static bundle +# =========================================================================== +FROM node:22-alpine AS build + +WORKDIR /src + +# Lockfile first: dependency installs stay cached across source edits. +COPY package.json package-lock.json ./ +# `npm ci` = exact, reproducible install from package-lock.json. +RUN npm ci + +COPY . . + +# `npm run build` is `tsc && vite build` — the type check is part of the +# build, so a type error fails the image instead of shipping. +RUN npm run build + + +# =========================================================================== +# Stage 2 — runtime +# =========================================================================== +# +# Why nginxinc/nginx-unprivileged: +# * Already non-root (uid 101) and listens on 8080, so no capability juggling +# and no USER override needed to satisfy a restricted PodSecurity policy. +# * Alpine variant is ~20 MB, i.e. in the same class as Caddy/distroless +# while being the most boring, most audited static server in existence. +# * Ships the envsubst template entrypoint, which is exactly what the +# runtime ${API_UPSTREAM} substitution above needs — a distroless static +# binary would have meant hand-rolling that. +FROM nginxinc/nginx-unprivileged:1.29-alpine AS runtime + +# Tag-pinned, not digest-pinned: rebuilds should pick up nginx/Alpine CVE +# fixes. The *content* served is already pinned by package-lock.json. Pin the +# digest in the deployment pipeline if immutability outranks patching. + +ENV API_UPSTREAM=http://backend:8000 + +# Template, not a plain conf: the stock entrypoint runs envsubst over +# /etc/nginx/templates/*.template into /etc/nginx/conf.d/ on start. +COPY nginx.conf.template /etc/nginx/templates/default.conf.template + +COPY --from=build --chown=101:101 /src/dist /usr/share/nginx/html + +USER 101 +EXPOSE 8080 + +# busybox wget — already present, no extra package. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"] + +# The base image's ENTRYPOINT (docker-entrypoint.sh) execs nginx, so nginx is +# PID 1 and gets SIGTERM/SIGQUIT directly. Inherited CMD ["nginx", "-g", +# "daemon off;"] is correct as-is. +# +# Read-only root filesystem (verified working) — run with: +# --read-only --tmpfs /tmp --tmpfs /var/cache/nginx \ +# --tmpfs /etc/nginx/conf.d:rw,mode=1777 +# /etc/nginx/conf.d is where envsubst renders the template, so it must be +# writable BY UID 101 — podman's default tmpfs mode is not, and the entrypoint +# then silently skips the substitution and nginx serves no server block at all. +# mode=1777 is what makes it work. /tmp holds the pid file and proxy buffers. diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template new file mode 100644 index 0000000..0901bed --- /dev/null +++ b/frontend/nginx.conf.template @@ -0,0 +1,77 @@ +# Rendered by the nginx-unprivileged entrypoint: envsubst expands ${API_UPSTREAM} +# (and only names that exist in the environment — nginx's own $uri/$host/etc. +# are left alone) into /etc/nginx/conf.d/default.conf at container start. + +server { + # 8080, not 80: unprivileged nginx runs as uid 101 and cannot bind <1024. + listen 8080; + listen [::]:8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # The bundle is already gzipped over the wire by this; Brotli would need a + # module the stock image does not carry, so gzip it is. + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css application/javascript application/json + image/svg+xml application/manifest+json; + + server_tokens off; + + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy no-referrer always; + + # Container-level liveness. Served from memory, so a broken bundle or a + # read-only mount cannot make this lie. + location = /healthz { + access_log off; + # default_type, not `add_header Content-Type`: add_header appends, so + # the response would carry two Content-Type headers. + default_type text/plain; + return 200 "ok\n"; + } + + # Same-origin API. Because the browser only ever talks to this origin, the + # backend's CORS allow-list is not exercised at all in this deployment. + location /api/ { + proxy_pass ${API_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + # The FK-scraper poll endpoints are long-ish; don't cut them at 60s. + proxy_read_timeout 120s; + proxy_buffering off; + } + + # Vite emits content-hashed filenames under /assets, so they are immutable. + location /assets/ { + access_log off; + # `expires 1y` would emit its own Cache-Control alongside this one; + # one explicit header is clearer than two contradicting-looking ones. + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + + # index.html is the only mutable entry point — never cache it, or a deploy + # leaves clients pinned to a stale asset graph. + location = /index.html { + add_header Cache-Control "no-cache" always; + } + + # SPA fallback. react-router-dom uses BrowserRouter, so a hard refresh or a + # shared link on a client-only route (/calendar, /settings, ...) arrives + # here as a real HTTP request for a path with no file behind it. Without + # this line that is a 404 and client-side routing breaks on reload. + location / { + try_files $uri $uri/ /index.html; + } +}