Zwei BLOCKING-Befunde für "Deployment (Docker + externes Traefik)":
1) pg-Laufzeit-Closure unvollständig: pg-types/lib/binaryParsers.js macht
eager require('pg-int8'), postgres-interval/index.js require('xtend/mutable').
Beide lagen NICHT im Runner-Image -> node docker/migrate.mjs crasht beim
Container-Start mit ERR_MODULE_NOT_FOUND, Deploy kaputt. Fix: COPY für
pg-int8 + xtend ergänzt. Neuer Test berechnet die reale Closure aus
node_modules und schlägt fehl, sobald ein Paket nicht ins Image kopiert
wird (schützt vor erneutem Brechen bei pg/drizzle-Updates).
2) RUN_SEED toter Pfad: entrypoint.sh rief docker/seed.mjs auf, das nie
existierte -> RUN_SEED=true no-opte still zu leerem Katalog. Fix:
scripts/build-seed-bundle.mjs bündelt src/db/seed (inkl. Schema + Daten,
pg/drizzle-orm extern) per esbuild zu selbstständigem docker/seed.mjs;
im builder erzeugt und ins Runner-Image kopiert. entrypoint.sh bricht
jetzt laut ab, wenn RUN_SEED=true und das Bundle fehlt, statt still zu
überspringen. docker/seed.mjs ist generiert -> gitignored.
Verifiziert offline: tsc --noEmit, vitest (deployment + seed-Daten, 36 grün),
Bundle baut + lädt sauber (externe Imports nur pg/drizzle-orm, exit 1 ohne
DATABASE_URL). docker build/run sind im Sandbox deferred (kein Docker/Postgres).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
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("Dockerfile kopiert die VOLLSTÄNDIGE pg/drizzle-orm-Laufzeit-Closure ins Runner-Image", () => {
|
|
// Reale Laufzeit-Closure aus node_modules berechnen (nur dependencies,
|
|
// wie sie zur Laufzeit von docker/migrate.mjs + docker/seed.mjs benötigt
|
|
// wird). Jedes Paket MUSS eine COPY-Zeile im Runner-Stage haben, sonst
|
|
// crasht der Container-Start mit ERR_MODULE_NOT_FOUND (z. B. pg-int8,
|
|
// xtend). Schützt vor erneutem Brechen bei pg/drizzle-Updates.
|
|
const df = read("Dockerfile");
|
|
const nodeModules = resolve(root, "node_modules");
|
|
|
|
const seen = new Set<string>();
|
|
const resolvePkg = (name: string, fromDir: string): string | null => {
|
|
let cur = fromDir;
|
|
for (;;) {
|
|
const candidate = resolve(cur, "node_modules", name);
|
|
if (existsSync(resolve(candidate, "package.json"))) return candidate;
|
|
const parent = dirname(cur);
|
|
if (parent === cur) break;
|
|
cur = parent;
|
|
}
|
|
const top = resolve(nodeModules, name);
|
|
return existsSync(resolve(top, "package.json")) ? top : null;
|
|
};
|
|
const walk = (dir: string): void => {
|
|
const pkg = JSON.parse(
|
|
readFileSync(resolve(dir, "package.json"), "utf8"),
|
|
) as { dependencies?: Record<string, string> };
|
|
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
|
if (seen.has(dep)) continue;
|
|
seen.add(dep);
|
|
const resolved = resolvePkg(dep, dir);
|
|
// Nur skalierbar prüfbar, wenn auflösbar; sonst meldet npm ci es ohnehin.
|
|
if (resolved) walk(resolved);
|
|
}
|
|
};
|
|
|
|
const pgRoot = resolve(nodeModules, "pg");
|
|
const drizzleRoot = resolve(nodeModules, "drizzle-orm");
|
|
walk(pgRoot);
|
|
walk(drizzleRoot);
|
|
|
|
// pg + drizzle-orm selbst sind ebenfalls Teil der Closure.
|
|
const closure = new Set<string>([...seen, "pg", "drizzle-orm"]);
|
|
|
|
const missing = [...closure].filter(
|
|
(name) =>
|
|
!df.includes(`/app/node_modules/${name} ./node_modules/${name}`),
|
|
);
|
|
expect(missing, `nicht ins Runner-Image kopiert: ${missing.join(", ")}`).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it("Dockerfile bündelt den Seed (build-seed-bundle) und kopiert docker/seed.mjs ins Runner-Image", () => {
|
|
const df = read("Dockerfile");
|
|
// Im builder wird der Seed zu Plain-ESM gebündelt ...
|
|
expect(df).toMatch(/build-seed-bundle\.mjs/);
|
|
// ... und ins Runner-Image kopiert, damit RUN_SEED=true nicht still no-opt.
|
|
expect(df).toMatch(/docker\/seed\.mjs/);
|
|
// Der Bundler-Entrypoint existiert.
|
|
expect(existsSync(resolve(root, "scripts/build-seed-bundle.mjs"))).toBe(true);
|
|
// Das Seed-Quellmodul, das gebündelt wird, existiert.
|
|
expect(existsSync(resolve(root, "src/db/seed/index.ts"))).toBe(true);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|