Workstream 10: Deployment (Docker + externes Traefik) (Phase 7)

Liefert das reproduzierbare Compose-Setup hinter EXTERNEM Traefik:

- Dockerfile (multi-stage deps/builder/runner, Next.js standalone, non-root
  UID/GID 1001, HEALTHCHECK gegen /api/health).
- docker/entrypoint.sh: wartet via pg_isready auf Postgres, wendet Migrationen
  idempotent an (docker/migrate.mjs, plain ESM ohne tsx/drizzle-kit), optionaler
  Seed (RUN_SEED), dann exec node server.js.
- docker-compose.yml: genau vier Services (app, postgres, osrm, nominatim),
  KEIN Proxy-Service; externes traefik-Netz + internes Netz; Traefik-Labels
  (Host, websecure, tls.certresolver, Security-Header-Middleware);
  Postgres-/App-Healthchecks; AUTH_URL/AUTH_TRUST_HOST/Forwarded-Header.
- docker-compose.override.yml.example: lokal :3000 ohne TLS (http AUTH_URL).
- .dockerignore, Makefile (build/up/down/logs/deploy/data/config).
- .env.example: voller Vertrag inkl. APP_HOST, TRAEFIK_*, POSTGRES_*, RUN_SEED.
- docs/reference/deployment-traefik.md: externes Netz, Authentik-Redirect-URI
  https://${APP_HOST}/api/auth/callback/authentik, Forwarded-Header/Cookies,
  /api/health-Allowlist.
- tests/unit/deployment.test.ts (TDD): statische Offline-Verifikation der
  Artefakte; vitest.config.ts nimmt tests/unit/** auf.

Offline verifiziert: tsc --noEmit sauber; vitest run grün (200 passed,
7 db-roundtrip skipped); next build erzeugt .next/standalone/server.js;
sh -n docker/entrypoint.sh ok; make -n deploy zeigt build->up.
Deferred (kein Docker/Postgres in der Sandbox): docker build/run id -u=1001,
docker compose config --services, /api/health anonym 200, End-to-End Traefik.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 12:35:45 +02:00
parent f99c1f1abd
commit d50ec765ab
11 changed files with 680 additions and 1 deletions

View File

@@ -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/);
});
});