fix(deploy): vollständige pg-Closure + funktionierender RUN_SEED im Runner-Image
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>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,3 +8,7 @@ node_modules/
|
|||||||
tests/e2e/.auth/
|
tests/e2e/.auth/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Generiertes Artefakt: wird im Docker-builder aus src/db/seed gebündelt
|
||||||
|
# (scripts/build-seed-bundle.mjs), nicht eingecheckt.
|
||||||
|
docker/seed.mjs
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -21,6 +21,9 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
# next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js.
|
# next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js.
|
||||||
RUN npm run build
|
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 ----------------------------------------
|
# --- runner: minimales Laufzeit-Image ----------------------------------------
|
||||||
FROM node:${NODE_VERSION}-alpine AS runner
|
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-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/postgres-interval ./node_modules/postgres-interval
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/split2 ./node_modules/split2
|
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 --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
|
COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||||
RUN chmod +x ./docker/entrypoint.sh
|
RUN chmod +x ./docker/entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,17 @@ echo "[entrypoint] Wende Migrationen an ..."
|
|||||||
node docker/migrate.mjs
|
node docker/migrate.mjs
|
||||||
|
|
||||||
# --- 3) Optionaler Seed -------------------------------------------------------
|
# --- 3) Optionaler Seed -------------------------------------------------------
|
||||||
# RUN_SEED=true füllt den NÖ-Katalog (idempotente Upserts). Setzt das gebündelte
|
# RUN_SEED=true füllt den NÖ-Katalog (idempotente Upserts) über das beim Image-
|
||||||
# Seed-Skript voraus (docker/seed.mjs); fehlt es, wird der Schritt übersprungen.
|
# 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 [ "${RUN_SEED:-false}" = "true" ]; then
|
||||||
if [ -f docker/seed.mjs ]; then
|
if [ ! -f docker/seed.mjs ]; then
|
||||||
echo "[entrypoint] Führe Katalog-Seed aus ..."
|
echo "[entrypoint] FEHLER: RUN_SEED=true, aber docker/seed.mjs fehlt im Image." >&2
|
||||||
node docker/seed.mjs
|
exit 1
|
||||||
else
|
|
||||||
echo "[entrypoint] RUN_SEED=true, aber docker/seed.mjs fehlt — Seed übersprungen." >&2
|
|
||||||
fi
|
fi
|
||||||
|
echo "[entrypoint] Führe Katalog-Seed aus ..."
|
||||||
|
node docker/seed.mjs
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 4) App-Server starten ----------------------------------------------------
|
# --- 4) App-Server starten ----------------------------------------------------
|
||||||
|
|||||||
37
scripts/build-seed-bundle.mjs
Normal file
37
scripts/build-seed-bundle.mjs
Normal file
@@ -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.");
|
||||||
@@ -30,6 +30,70 @@ describe("Deployment-Artefakte", () => {
|
|||||||
expect(df).toMatch(/entrypoint\.sh/);
|
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", () => {
|
it(".dockerignore schließt node_modules, .next und Secrets aus", () => {
|
||||||
const di = read(".dockerignore");
|
const di = read(".dockerignore");
|
||||||
expect(di).toMatch(/node_modules/);
|
expect(di).toMatch(/node_modules/);
|
||||||
|
|||||||
Reference in New Issue
Block a user