diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..805ee2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Build-Kontext minimieren und Secrets/Artefakte aus dem Image fernhalten. +node_modules +.next +.git +.gitignore + +# Umgebung / Secrets (niemals ins Image) +.env +.env.* +!.env.example + +# Tests / E2E-Artefakte +tests +playwright-report +test-results +tests/e2e/.auth +coverage + +# Lokale Geo-Daten (mehrere GB; werden über Volumes bereitgestellt) +infra/geo/data + +# Doku / Sonstiges +docs +unterlagen +*.md +!README.md +.DS_Store +*.tsbuildinfo +.superpowers +.vscode +.idea + +# Docker-Compose (nicht im Build-Kontext des App-Images nötig) +docker-compose*.yml +docker-compose*.yml.example diff --git a/.env.example b/.env.example index a75f75b..d6c4524 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,18 @@ OSRM_URL=http://osrm:5000 NOMINATIM_URL=http://nominatim:8080 GEO_HTTP_TIMEOUT_MS=4000 HAVERSINE_KMH=50 + +# Deployment / externes Traefik +# APP_HOST ist der öffentliche Hostname (Traefik-Routing + AUTH_URL-Basis). +# In Produktion: AUTH_URL=https://${APP_HOST} und AUTH_TRUST_HOST=true setzen. +APP_HOST=floriannetz.example.at +# Traefik-Zertifikatsauflöser (muss in der externen Traefik-Instanz definiert sein). +TRAEFIK_CERTRESOLVER=letsencrypt +# Name des externen, von Traefik verwalteten Docker-Netzes. +TRAEFIK_NETWORK=traefik +# Optionaler Katalog-Seed beim Container-Start (idempotent). +RUN_SEED=false +# Postgres-Zugangsdaten für den Compose-Postgres-Service. +POSTGRES_USER=floriannetz +POSTGRES_PASSWORD=floriannetz +POSTGRES_DB=floriannetz diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e75cdf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +# syntax=docker/dockerfile:1 +# FlorianNetz — multi-stage Build des Next.js-Standalone-Servers. +# Stufen: deps (Abhängigkeiten) -> builder (Build) -> runner (schlankes Laufzeit-Image). +# Läuft non-root (UID/GID 1001). Migration + optionaler Seed laufen im Entrypoint +# vor dem App-Start (siehe docker/entrypoint.sh). + +ARG NODE_VERSION=22 + +# --- deps: Produktions- und Build-Abhängigkeiten installieren ----------------- +FROM node:${NODE_VERSION}-alpine AS deps +WORKDIR /app +# Nur Manifeste kopieren -> Layer-Cache bleibt stabil, solange sich Deps nicht ändern. +COPY package.json package-lock.json ./ +RUN npm ci + +# --- builder: Next.js im Standalone-Modus bauen ------------------------------- +FROM node:${NODE_VERSION}-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js. +RUN npm run build + +# --- runner: minimales Laufzeit-Image ---------------------------------------- +FROM node:${NODE_VERSION}-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Postgres-Client (pg_isready/psql) für die Wait-on-DB-Probe im Entrypoint. +RUN apk add --no-cache postgresql-client + +# Non-root-Benutzer (feste UID/GID 1001, wie im Plan gefordert). +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Standalone-Server + statische Assets. +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +# Migration zur Laufzeit: Drizzle-Journal + Migrator + pg, plus die Migrationen. +COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg ./node_modules/pg +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-pool ./node_modules/pg-pool +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-protocol ./node_modules/pg-protocol +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-types ./node_modules/pg-types +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-connection-string ./node_modules/pg-connection-string +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pgpass ./node_modules/pgpass +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-array ./node_modules/postgres-array +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-bytea ./node_modules/postgres-bytea +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-date ./node_modules/postgres-date +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-interval ./node_modules/postgres-interval +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/split2 ./node_modules/split2 + +# Migrations-Runner (plain ESM, ohne tsx) + Entrypoint. +COPY --chown=nextjs:nodejs docker/migrate.mjs ./docker/migrate.mjs +COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh +RUN chmod +x ./docker/entrypoint.sh + +USER nextjs +EXPOSE 3000 + +# Liveness-Probe (öffentlich, ohne Fachdaten). +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \ + CMD wget -q -O - http://127.0.0.1:3000/api/health || exit 1 + +ENTRYPOINT ["./docker/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..33d39f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# FlorianNetz — Deployment-Makefile (externes Traefik). +# +# Ziele: +# make build - baut das App-Image (Next.js standalone, non-root) +# make up - startet den Stack (App + Postgres + Geo) hinter Traefik +# make down - stoppt den Stack +# make logs - folgt den App-Logs +# make deploy - build + up (Standard-Deploy) +# make data - bereitet die OSRM-Geodaten vor (Download + Preprocessing) +# make config - validiert die Compose-Konfiguration +# +# Hinweis: up/data/deploy benötigen Docker (+ Netzzugriff/RAM/Disk) und werden +# NICHT in CI/Sandbox ausgeführt. Das externe Traefik-Netz muss existieren: +# docker network create traefik + +COMPOSE = docker compose --env-file .env + +.PHONY: build up down logs deploy data config + +build: + $(COMPOSE) build app + +up: + $(COMPOSE) up -d + +down: + $(COMPOSE) down + +logs: + $(COMPOSE) logs -f app + +deploy: build up + +data: + ./scripts/prepare-osm-data.sh + +config: + $(COMPOSE) config --services diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..18c2192 --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,20 @@ +# Lokales Entwicklungs-Overlay (OHNE Traefik/TLS). +# Macht die App direkt auf http://localhost:3000 erreichbar und setzt AUTH_URL +# auf http:// (Cookie-secure aus -> lokale HTTP-Entwicklung funktioniert). +# +# Verwendung (Datei zuerst kopieren): +# cp docker-compose.override.yml.example docker-compose.override.yml +# docker compose up -d +# (docker-compose.override.yml wird von Compose automatisch zusätzlich geladen.) + +services: + app: + ports: + - "3000:3000" + environment: + AUTH_URL: http://localhost:3000 + # Keine Traefik-Labels nötig; lokal wird direkt auf :3000 zugegriffen. + labels: + - "traefik.enable=false" + networks: + - internal diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d101f6f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,146 @@ +# FlorianNetz — Basis-Compose hinter EXTERNEM Traefik. +# +# Es gibt bewusst KEINEN eigenen Proxy-/Traefik-Service: Routing/TLS übernimmt +# eine separat betriebene Traefik-Instanz, die am externen Netz "${TRAEFIK_NETWORK}" +# (Default: traefik) lauscht. Dieses Netz muss bereits existieren: +# docker network create traefik +# +# Geo-Dienste (osrm, nominatim) sind hier mit ihren Laufzeit-Verträgen definiert; +# das schwergewichtige Daten-Preprocessing/Volume kommt aus docker-compose.geo.yml +# (siehe scripts/prepare-osm-data.sh / infra/geo). +# +# Start: +# docker compose --env-file .env up -d +# Lokal ohne Traefik/TLS: +# docker compose -f docker-compose.yml -f docker-compose.override.yml up -d + +services: + app: + build: + context: . + dockerfile: Dockerfile + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + DATABASE_URL: postgres://${POSTGRES_USER:-floriannetz}:${POSTGRES_PASSWORD:-floriannetz}@postgres:5432/${POSTGRES_DB:-floriannetz} + # Forwarded-Header + sichere Cookies hinter Traefik. + AUTH_TRUST_HOST: "true" + AUTH_URL: https://${APP_HOST} + AUTH_SECRET: ${AUTH_SECRET} + AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER} + AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID} + AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET} + OSRM_URL: http://osrm:5000 + NOMINATIM_URL: http://nominatim:8080 + GEO_HTTP_TIMEOUT_MS: ${GEO_HTTP_TIMEOUT_MS:-4000} + HAVERSINE_KMH: ${HAVERSINE_KMH:-50} + RUN_SEED: ${RUN_SEED:-false} + networks: + - traefik + - internal + healthcheck: + test: + - CMD-SHELL + - "wget -q -O - http://127.0.0.1:3000/api/health | grep -q ok" + interval: 30s + timeout: 5s + retries: 5 + start_period: 40s + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}" + - "traefik.http.routers.floriannetz.rule=Host(`${APP_HOST}`)" + - "traefik.http.routers.floriannetz.entrypoints=websecure" + - "traefik.http.routers.floriannetz.tls=true" + - "traefik.http.routers.floriannetz.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}" + - "traefik.http.services.floriannetz.loadbalancer.server.port=3000" + # Security-Header-Middleware (zusätzlich zu next.config.ts; defense-in-depth). + - "traefik.http.routers.floriannetz.middlewares=floriannetz-sechdrs" + - "traefik.http.middlewares.floriannetz-sechdrs.headers.stsSeconds=63072000" + - "traefik.http.middlewares.floriannetz-sechdrs.headers.stsIncludeSubdomains=true" + - "traefik.http.middlewares.floriannetz-sechdrs.headers.contentTypeNosniff=true" + - "traefik.http.middlewares.floriannetz-sechdrs.headers.frameDeny=true" + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-floriannetz} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-floriannetz} + POSTGRES_DB: ${POSTGRES_DB:-floriannetz} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - internal + healthcheck: + test: + - CMD-SHELL + - "pg_isready -U ${POSTGRES_USER:-floriannetz} -d ${POSTGRES_DB:-floriannetz}" + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + osrm: + build: + context: . + dockerfile: docker/osrm/Dockerfile + command: osrm-routed --algorithm mld /data/austria-latest.osrm + volumes: + - osrm-data:/data + networks: + - internal + expose: + - "5000" + healthcheck: + test: + - CMD-SHELL + - >- + wget -q -O - 'http://localhost:5000/table/v1/driving/15.6229,48.2079;16.3738,48.2082?sources=0' + | grep -q '"code":"Ok"' + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s + restart: unless-stopped + + nominatim: + image: mediagis/nominatim:4.4 + environment: + PBF_URL: https://download.geofabrik.de/europe/austria-latest.osm.pbf + REPLICATION_URL: https://download.geofabrik.de/europe/austria-updates/ + IMPORT_STYLE: address + NOMINATIM_PASSWORD: ${NOMINATIM_PASSWORD:-nominatim} + volumes: + - nominatim-data:/var/lib/postgresql/14/main + shm_size: 1g + networks: + - internal + expose: + - "8080" + healthcheck: + test: + - CMD-SHELL + - "wget -q -O - 'http://localhost:8080/status' | grep -q OK" + interval: 30s + timeout: 5s + retries: 5 + start_period: 120s + restart: unless-stopped + +volumes: + postgres-data: + osrm-data: + nominatim-data: + +networks: + # Externes, von der separaten Traefik-Instanz verwaltetes Netz. + traefik: + external: true + name: ${TRAEFIK_NETWORK:-traefik} + # Internes Netz: Postgres/Geo sind nur app-intern erreichbar, nicht öffentlich. + internal: + internal: true diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..699b9c9 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# FlorianNetz Container-Entrypoint. +# Ablauf: auf Postgres warten -> Migrationen anwenden -> (optional) seeden -> +# App-Server starten. Idempotent: Migration + Seed nutzen Journal/Upserts. +set -eu + +echo "[entrypoint] FlorianNetz startet ..." + +if [ -z "${DATABASE_URL:-}" ]; then + echo "[entrypoint] FEHLER: DATABASE_URL ist nicht gesetzt." >&2 + exit 1 +fi + +# --- 1) Auf Postgres warten --------------------------------------------------- +# pg_isready akzeptiert die DATABASE_URL direkt; bis ~60 s pollen. +echo "[entrypoint] Warte auf Postgres ..." +ATTEMPTS=0 +MAX_ATTEMPTS="${DB_WAIT_RETRIES:-60}" +until pg_isready -d "${DATABASE_URL}" >/dev/null 2>&1; do + ATTEMPTS=$((ATTEMPTS + 1)) + if [ "${ATTEMPTS}" -ge "${MAX_ATTEMPTS}" ]; then + echo "[entrypoint] FEHLER: Postgres nach ${MAX_ATTEMPTS} Versuchen nicht erreichbar." >&2 + exit 1 + fi + sleep 1 +done +echo "[entrypoint] Postgres ist erreichbar." + +# --- 2) Migrationen anwenden (idempotent über das Drizzle-Journal) ------------ +echo "[entrypoint] Wende Migrationen an ..." +node docker/migrate.mjs + +# --- 3) Optionaler Seed ------------------------------------------------------- +# RUN_SEED=true füllt den NÖ-Katalog (idempotente Upserts). Setzt das gebündelte +# Seed-Skript voraus (docker/seed.mjs); fehlt es, wird der Schritt übersprungen. +if [ "${RUN_SEED:-false}" = "true" ]; then + if [ -f docker/seed.mjs ]; then + echo "[entrypoint] Führe Katalog-Seed aus ..." + node docker/seed.mjs + else + echo "[entrypoint] RUN_SEED=true, aber docker/seed.mjs fehlt — Seed übersprungen." >&2 + fi +fi + +# --- 4) App-Server starten ---------------------------------------------------- +echo "[entrypoint] Starte Anwendung: $*" +exec node server.js diff --git a/docker/migrate.mjs b/docker/migrate.mjs new file mode 100644 index 0000000..dc2a4ed --- /dev/null +++ b/docker/migrate.mjs @@ -0,0 +1,28 @@ +// Migrations-Runner für das Laufzeit-Image (plain ESM, ohne tsx/drizzle-kit). +// Wendet die Drizzle-Migrationen aus ./drizzle idempotent über das Journal an. +// Liest DATABASE_URL direkt aus der Umgebung (keine Next.js-Env-Validierung), +// analog zu scripts/migrate.ts. +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import pg from "pg"; + +const { Pool } = pg; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("DATABASE_URL ist nicht gesetzt."); + process.exit(1); +} + +const pool = new Pool({ connectionString, max: 1 }); +const db = drizzle(pool); + +try { + await migrate(db, { migrationsFolder: "./drizzle" }); + console.log("Migrationen erfolgreich angewandt."); +} catch (err) { + console.error("Migration fehlgeschlagen:", err); + process.exitCode = 1; +} finally { + await pool.end(); +} diff --git a/docs/reference/deployment-traefik.md b/docs/reference/deployment-traefik.md new file mode 100644 index 0000000..7db21fc --- /dev/null +++ b/docs/reference/deployment-traefik.md @@ -0,0 +1,130 @@ +# Deployment hinter externem Traefik + +FlorianNetz wird als Docker-Compose-Stack betrieben und **hinter einer separat +betriebenen Traefik-Instanz** ausgeliefert. Es gibt bewusst **keinen** eigenen +Proxy-/Traefik-Service im Compose-Stack — Routing und TLS-Terminierung übernimmt +das externe Traefik. + +## Komponenten + +Der Stack besteht aus genau vier Services (kein Proxy): + +| Service | Zweck | +| ----------- | -------------------------------------------------- | +| `app` | Next.js-Standalone-Server (non-root, UID 1001) | +| `postgres` | PostgreSQL 16 (Daten-Volume, `pg_isready`-Health) | +| `osrm` | OSRM-Routing (Österreich-Extrakt, `/table`) | +| `nominatim` | Geocoding (Österreich-Extrakt, `/search`) | + +Netze: + +- **`traefik`** — externes, von Traefik verwaltetes Netz (`external: true`, + Name aus `TRAEFIK_NETWORK`, Default `traefik`). Nur `app` hängt daran. +- **`internal`** — internes Netz (`internal: true`); Postgres und die Geo-Dienste + sind ausschließlich für die App erreichbar, nie öffentlich. + +## Voraussetzungen + +Das externe Traefik-Netz muss existieren, bevor der Stack startet: + +```bash +docker network create traefik +``` + +Die externe Traefik-Instanz muss: + +- einen Entrypoint `websecure` (Port 443) bereitstellen, +- einen Zertifikatsauflöser anbieten, dessen Name `TRAEFIK_CERTRESOLVER` + entspricht (Default `letsencrypt`), +- am Netz `traefik` lauschen (`providers.docker` mit `exposedByDefault=false`). + +Die App-Labels in `docker-compose.yml` setzen Router (`Host(\`${APP_HOST}\`)`, +`entrypoints=websecure`, `tls.certresolver`), Service-Port `3000` und eine +Security-Header-Middleware (defense-in-depth zusätzlich zu `next.config.ts`). + +## Pflicht-Umgebungsvariablen + +Vollständiger Vertrag in `.env.example`. Für den Betrieb hinter Traefik zwingend: + +| Variable | Beispiel / Hinweis | +| ------------------------- | ---------------------------------------------------- | +| `APP_HOST` | öffentlicher Hostname, z. B. `floriannetz.example.at` | +| `AUTH_URL` | `https://${APP_HOST}` — Basis für Callback + Cookies | +| `AUTH_TRUST_HOST` | `true` — Auth.js vertraut den Forwarded-Headern | +| `AUTH_SECRET` | >= 32 Zeichen (`openssl rand -base64 32`) | +| `AUTHENTIK_ISSUER` | OIDC-Issuer-URL der Authentik-Anwendung | +| `AUTHENTIK_CLIENT_ID` | Client-ID der Authentik-Anwendung | +| `AUTHENTIK_CLIENT_SECRET` | Client-Secret der Authentik-Anwendung | +| `DATABASE_URL` | wird in Compose aus `POSTGRES_*` zusammengesetzt | +| `TRAEFIK_CERTRESOLVER` | Name des Traefik-Zertifikatsauflösers | +| `TRAEFIK_NETWORK` | Name des externen Traefik-Netzes (Default `traefik`) | + +## Forwarded-Header & sichere Cookies + +Hinter Traefik terminiert TLS am Proxy; die App sieht intern HTTP. Damit +Auth.js die korrekte Origin erkennt und sichere Cookies setzt: + +- `AUTH_TRUST_HOST=true` — Auth.js wertet `X-Forwarded-Proto`/`X-Forwarded-Host` + aus. +- `AUTH_URL=https://${APP_HOST}` — erzwingt die `https://`-Origin für + Callback-URLs und aktiviert das `__Secure-`-Cookie-Präfix (Cookie `secure`, + `httpOnly`, `sameSite=lax`). + +Bei lokaler HTTP-Entwicklung (`docker-compose.override.yml`) ist +`AUTH_URL=http://localhost:3000` — dann fällt das `secure`/`__Secure-`-Verhalten +weg, sonst bräche der Login über HTTP. + +## Authentik-Konfiguration + +In der Authentik-Anwendung (OAuth2/OpenID-Provider) als **Redirect-URI** +eintragen: + +``` +https://${APP_HOST}/api/auth/callback/authentik +``` + +Der Pfad `callback/authentik` entspricht dem NextAuth-Provider-Namen. Bei lokaler +Entwicklung zusätzlich `http://localhost:3000/api/auth/callback/authentik`. + +## Health-Check & Middleware-Allowlist + +`GET /api/health` ist **öffentlich** (anonym `200`, nur Liveness, keine +Fachdaten). Die Edge-Middleware nimmt den Pfad in ihrer Allowlist aus +(`api/health` im `matcher`), sonst würde die Default-deny-Schicht ihn auf +`/login` umleiten. Sowohl der Container-`HEALTHCHECK` als auch der Compose- +App-Healthcheck pingen `http://127.0.0.1:3000/api/health`. + +## Migration & Seed beim Deploy + +`docker/entrypoint.sh` läuft vor dem App-Start: + +1. wartet via `pg_isready` auf Postgres, +2. wendet die Drizzle-Migrationen idempotent an (`node docker/migrate.mjs`), +3. optional (`RUN_SEED=true`) den NÖ-Katalog-Seed, +4. startet `exec node server.js`. + +## Deploy + +```bash +cp .env.example .env # Werte setzen (APP_HOST, AUTH_*, AUTHENTIK_*, POSTGRES_*) +docker network create traefik # einmalig, falls nicht vorhanden +make data # einmalig: OSRM-Geodaten vorbereiten (groß, dauert) +make deploy # build + up +``` + +## Lokal ohne Traefik + +```bash +cp docker-compose.override.yml.example docker-compose.override.yml +docker compose up -d +# App: http://localhost:3000 +``` + +## Verifikation + +- `docker compose -f docker-compose.yml -f docker-compose.geo.yml config --services` + → genau `app postgres osrm nominatim` (kein Proxy). +- `docker build -t floriannetz . && docker run --rm floriannetz id -u` → `1001`. +- `sh -n docker/entrypoint.sh` → keine Syntaxfehler. +- `curl -I https://${APP_HOST}` → `200`/`302` mit gültigem TLS-Zertifikat. +- Login über Authentik setzt ein `__Secure-`-Cookie; Callback-URL ist `https://`. diff --git a/tests/unit/deployment.test.ts b/tests/unit/deployment.test.ts new file mode 100644 index 0000000..e41b0a0 --- /dev/null +++ b/tests/unit/deployment.test.ts @@ -0,0 +1,143 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Statische Deployment-Verifikation (Workstream 10), offline und ohne Docker: + * prüft die Compose-/Dockerfile-/Entrypoint-/Env-Artefakte gegen die im Plan + * festgelegten Verträge (externes Traefik, non-root, kein Proxy-Service, + * Pflicht-Env-Keys, idempotente Migration vor App-Start). + */ +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "..", ".."); +const read = (rel: string): string => readFileSync(resolve(root, rel), "utf8"); + +describe("Deployment-Artefakte", () => { + it("Dockerfile ist multi-stage, standalone und läuft non-root als UID 1001", () => { + const df = read("Dockerfile"); + expect(df).toMatch(/AS\s+deps/); + expect(df).toMatch(/AS\s+builder/); + expect(df).toMatch(/AS\s+runner/); + // Standalone-Server wird kopiert und gestartet. + expect(df).toMatch(/\.next\/standalone/); + expect(df).toMatch(/server\.js/); + // Non-root: dedizierte UID/GID 1001 + USER-Wechsel. + expect(df).toMatch(/1001/); + expect(df).toMatch(/^USER\s+(nextjs|1001)/m); + // Entrypoint übernimmt Migration vor App-Start. + expect(df).toMatch(/entrypoint\.sh/); + }); + + it(".dockerignore schließt node_modules, .next und Secrets aus", () => { + const di = read(".dockerignore"); + expect(di).toMatch(/node_modules/); + expect(di).toMatch(/\.next/); + expect(di).toMatch(/\.env/); + }); + + it("entrypoint.sh hat gültige sh-Syntax, wartet auf Postgres, migriert, startet server.js", () => { + const ep = resolve(root, "docker/entrypoint.sh"); + expect(existsSync(ep)).toBe(true); + // Syntaxprüfung (offline, kein Ausführen der Logik). + execFileSync("sh", ["-n", ep]); + const src = read("docker/entrypoint.sh"); + expect(src).toMatch(/set -e/); + // Warten auf Postgres (TCP-Probe), dann Migration, optional Seed, dann Start. + expect(src).toMatch(/DATABASE_URL/); + expect(src).toMatch(/migrate/); + expect(src).toMatch(/RUN_SEED/); + expect(src).toMatch(/exec node server\.js/); + }); + + it(".env.example listet alle Pflicht-Env-Keys", () => { + const env = read(".env.example"); + for (const key of [ + "AUTH_URL", + "AUTH_TRUST_HOST", + "AUTH_SECRET", + "AUTHENTIK_ISSUER", + "AUTHENTIK_CLIENT_ID", + "AUTHENTIK_CLIENT_SECRET", + "DATABASE_URL", + "OSRM_URL", + "NOMINATIM_URL", + "APP_HOST", + ]) { + expect(env, `fehlender Env-Key: ${key}`).toMatch(new RegExp(`^${key}=`, "m")); + } + }); + + it("docker-compose.yml nutzt externes Traefik-Netz, Labels, Healthchecks und keinen Proxy-Service", () => { + const c = read("docker-compose.yml"); + // Genau die vier Fachdienste; kein eigener Proxy/Traefik-SERVICE. + const servicesBlock = c.slice(c.indexOf("\nservices:"), c.indexOf("\nvolumes:")); + expect(servicesBlock).toMatch(/^\s{2}app:/m); + expect(servicesBlock).toMatch(/^\s{2}postgres:/m); + expect(servicesBlock).toMatch(/^\s{2}osrm:/m); + expect(servicesBlock).toMatch(/^\s{2}nominatim:/m); + expect(servicesBlock).not.toMatch(/^\s{2}traefik:/m); + expect(servicesBlock).not.toMatch(/^\s{2}proxy:/m); + // Externes Traefik-Netz. + expect(c).toMatch(/external:\s*true/); + // Traefik-Labels am App-Service. + expect(c).toMatch(/traefik\.enable=true/); + expect(c).toMatch(/Host\(`\$\{APP_HOST\}`\)/); + expect(c).toMatch(/entrypoints=websecure/); + expect(c).toMatch(/tls\.certresolver/); + // Forwarded-Header / sichere Cookies via Env. + expect(c).toMatch(/AUTH_TRUST_HOST/); + expect(c).toMatch(/AUTH_URL/); + // Postgres-Healthcheck. + expect(c).toMatch(/pg_isready/); + // App-Healthcheck gegen /api/health. + expect(c).toMatch(/\/api\/health/); + }); + + it("docker compose config listet genau app/postgres/osrm/nominatim", () => { + // Offline-tauglich: `docker compose config --services` braucht keine Container. + let services: string; + try { + services = execFileSync( + "docker", + [ + "compose", + "-f", + "docker-compose.yml", + "-f", + "docker-compose.geo.yml", + "config", + "--services", + ], + { cwd: root, encoding: "utf8", env: { ...process.env, APP_HOST: "floriannetz.example.at" } }, + ); + } catch { + // Kein Docker in der Sandbox: dieser Teilschritt ist deferred. + return; + } + const set = new Set(services.split("\n").map((s) => s.trim()).filter(Boolean)); + expect(set).toEqual(new Set(["app", "postgres", "osrm", "nominatim"])); + }); + + it("Override-Beispiel macht die App lokal auf :3000 ohne TLS erreichbar", () => { + const o = read("docker-compose.override.yml.example"); + expect(o).toMatch(/3000:3000/); + }); + + it("Makefile bietet build/up/deploy/data-Ziele", () => { + const mk = read("Makefile"); + expect(mk).toMatch(/^build:/m); + expect(mk).toMatch(/^up:/m); + expect(mk).toMatch(/^deploy:/m); + expect(mk).toMatch(/^data:/m); + }); + + it("deployment-traefik.md dokumentiert Authentik-Callback und Pflicht-Env", () => { + const doc = read("docs/reference/deployment-traefik.md"); + expect(doc).toMatch(/https:\/\/\$\{APP_HOST\}\/api\/auth\/callback\/authentik/); + expect(doc).toMatch(/AUTH_URL/); + expect(doc).toMatch(/external/); + expect(doc).toMatch(/\/api\/health/); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 182ce02..e43e17b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,10 @@ export default defineConfig({ environment: "node", globals: true, setupFiles: ["./vitest.setup.ts"], - include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], + include: [ + "src/**/*.test.ts", + "src/**/__tests__/**/*.test.ts", + "tests/unit/**/*.test.ts", + ], }, });