From 9927711192ed8c1e87ad5e5f25229a8b6dcd854e Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 9 Jun 2026 12:54:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20vollst=C3=A4ndige=20pg-Closure?= =?UTF-8?q?=20+=20funktionierender=20RUN=5FSEED=20im=20Runner-Image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 4 +++ Dockerfile | 12 ++++++- docker/entrypoint.sh | 16 +++++---- scripts/build-seed-bundle.mjs | 37 ++++++++++++++++++++ tests/unit/deployment.test.ts | 64 +++++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 scripts/build-seed-bundle.mjs diff --git a/.gitignore b/.gitignore index 79235d9..ef65e41 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ node_modules/ tests/e2e/.auth/ next-env.d.ts *.tsbuildinfo + +# Generiertes Artefakt: wird im Docker-builder aus src/db/seed gebündelt +# (scripts/build-seed-bundle.mjs), nicht eingecheckt. +docker/seed.mjs diff --git a/Dockerfile b/Dockerfile index 1e75cdf..6de8f8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,9 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js. RUN npm run build +# Katalog-Seed zu einer selbstständigen Plain-ESM-Datei (docker/seed.mjs) +# bündeln, damit RUN_SEED=true im Runner (ohne tsx/src) funktioniert. +RUN node scripts/build-seed-bundle.mjs # --- runner: minimales Laufzeit-Image ---------------------------------------- FROM node:${NODE_VERSION}-alpine AS runner @@ -56,9 +59,16 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-bytea ./nod 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 +# Tiefere Transitiv-Abhängigkeiten der pg-Kette mit eagerem require (sonst +# ERR_MODULE_NOT_FOUND beim Container-Start in docker/migrate.mjs/seed.mjs): +# pg-types/lib/binaryParsers.js -> require('pg-int8') +# postgres-interval/index.js -> require('xtend/mutable') +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-int8 ./node_modules/pg-int8 +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/xtend ./node_modules/xtend -# Migrations-Runner (plain ESM, ohne tsx) + Entrypoint. +# Migrations-Runner (plain ESM, ohne tsx) + gebündelter Seed + Entrypoint. COPY --chown=nextjs:nodejs docker/migrate.mjs ./docker/migrate.mjs +COPY --from=builder --chown=nextjs:nodejs /app/docker/seed.mjs ./docker/seed.mjs COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh RUN chmod +x ./docker/entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 699b9c9..339b680 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -31,15 +31,17 @@ 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. +# RUN_SEED=true füllt den NÖ-Katalog (idempotente Upserts) über das beim Image- +# Build gebündelte Seed-Skript (docker/seed.mjs, siehe scripts/build-seed-bundle.mjs). +# Das Skript MUSS im Image vorhanden sein; fehlt es, ist das Image kaputt gebaut +# und wir brechen laut ab, statt einen leeren Katalog vorzutäuschen. 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 + if [ ! -f docker/seed.mjs ]; then + echo "[entrypoint] FEHLER: RUN_SEED=true, aber docker/seed.mjs fehlt im Image." >&2 + exit 1 fi + echo "[entrypoint] Führe Katalog-Seed aus ..." + node docker/seed.mjs fi # --- 4) App-Server starten ---------------------------------------------------- diff --git a/scripts/build-seed-bundle.mjs b/scripts/build-seed-bundle.mjs new file mode 100644 index 0000000..1059d57 --- /dev/null +++ b/scripts/build-seed-bundle.mjs @@ -0,0 +1,37 @@ +// Bündelt den Katalog-Seed (src/db/seed/index.ts inkl. Seed-Daten + Drizzle- +// Schema) zu einer einzigen, selbstständigen ESM-Datei docker/seed.mjs für das +// Laufzeit-Image — analog zu docker/migrate.mjs, aber generiert statt handgepflegt. +// +// Hintergrund: Der Standalone-Runner enthält weder `tsx` noch `src/`. Damit +// RUN_SEED=true im Entrypoint tatsächlich funktioniert (statt still no-op zu +// werden), bündeln wir die Seed-Logik beim Build zu Plain-ESM. `pg` und +// `drizzle-orm` bleiben extern (sind im Runner als node_modules vorhanden); +// alles andere (Schema, Seed-Daten, Upserts) wird inline gebündelt, sodass +// keine `src/`-Dateien ins Runner-Image müssen. +// +// Aufruf: node scripts/build-seed-bundle.mjs (läuft im builder-Stage). + +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); + +await build({ + entryPoints: [resolve(root, "src/db/seed/index.ts")], + outfile: resolve(root, "docker/seed.mjs"), + bundle: true, + platform: "node", + format: "esm", + target: "node22", + // Im Runner vorhandene (und im Dockerfile kopierte) Laufzeit-Pakete bleiben + // extern, damit wir keine zwei Kopien bündeln und die native pg-Kette nutzen. + external: ["pg", "drizzle-orm", "drizzle-orm/*"], + // src/db/seed/index.ts importiert ../../lib/audit.js NUR als `import type` + // (Tx); damit zieht esbuild die @/db-Kette (next-auth etc.) nicht in den + // Bundle. Die folgende alias-freie Auflösung reicht deshalb aus. + logLevel: "info", +}); + +console.log("docker/seed.mjs gebündelt."); diff --git a/tests/unit/deployment.test.ts b/tests/unit/deployment.test.ts index e41b0a0..56ebf2b 100644 --- a/tests/unit/deployment.test.ts +++ b/tests/unit/deployment.test.ts @@ -30,6 +30,70 @@ describe("Deployment-Artefakte", () => { 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(); + 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 }; + 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([...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/);