feat(betrieb): Docker Compose und LXC-Installer

Ein Laufzeit-Paket (scripts/paket-bauen.sh) für beide Wege: Next standalone, worker als Bundle mit nur den nötigen Paketen, Migrationen ohne Prisma-CLI. Compose lokal komplett hochgefahren; der Installer auf pve-demo in einem echten Debian-13-LXC durchgelaufen.

Closes #019

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:35:41 +02:00
co-authored by Claude Opus 5.5
parent 7f07efe05d
commit 533a821087
17 changed files with 767 additions and 7 deletions
+94
View File
@@ -0,0 +1,94 @@
import { createHash, randomUUID } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import pg from "pg";
/**
* Spielt die Migrationen aus `prisma/migrations/` ein – im Betrieb statt `prisma migrate deploy`,
* damit weder Docker-Image noch LXC das Prisma-CLI samt Schema-Engine brauchen.
*
* Kompatibel zu Prisma: dieselbe Tabelle `_prisma_migrations`, Prüfsumme = SHA-256 der
* migration.sql. In der Entwicklung bleibt `prisma migrate dev` also benutzbar, und beide
* erkennen, was schon eingespielt ist.
*
* Jede Migration läuft in einer eigenen Transaktion; eine Sperre (`pg_advisory_lock`) verhindert,
* dass app und worker gleichzeitig migrieren. Eine eingespielte Migration, deren Datei sich
* geändert hat, ist ein Fehler – dann stimmt der Codestand nicht zur Datenbank.
*/
const SPERRE = 724_196_331; // beliebig, aber fest: „proxdeploy-migration“
export type MigrationsErgebnis = { eingespielt: string[]; vorhanden: number };
export async function migrieren(
datenbankUrl: string,
ordner: string,
log: (text: string) => void = () => {},
): Promise<MigrationsErgebnis> {
const migrationen = readdirSync(ordner, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name))
.map((e) => e.name)
.sort();
const client = new pg.Client({ connectionString: datenbankUrl });
await client.connect();
try {
await client.query("SELECT pg_advisory_lock($1)", [SPERRE]);
await client.query(`CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
"id" VARCHAR(36) PRIMARY KEY NOT NULL,
"checksum" VARCHAR(64) NOT NULL,
"finished_at" TIMESTAMPTZ,
"migration_name" VARCHAR(255) NOT NULL,
"logs" TEXT,
"rolled_back_at" TIMESTAMPTZ,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
)`);
const { rows } = await client.query<{
migration_name: string;
checksum: string;
finished_at: Date | null;
}>(`SELECT migration_name, checksum, finished_at FROM "_prisma_migrations" WHERE rolled_back_at IS NULL`);
const bekannt = new Map(rows.map((r) => [r.migration_name, r]));
const unfertig = rows.filter((r) => !r.finished_at);
if (unfertig.length > 0) {
throw new Error(
`Migration ${unfertig.map((r) => r.migration_name).join(", ")} ist unvollständig eingespielt – bitte prüfen.`,
);
}
const eingespielt: string[] = [];
for (const name of migrationen) {
const sql = readFileSync(join(ordner, name, "migration.sql"));
const pruefsumme = createHash("sha256").update(sql).digest("hex");
const vorhanden = bekannt.get(name);
if (vorhanden) {
if (vorhanden.checksum !== pruefsumme) {
throw new Error(`Migration ${name} wurde nach dem Einspielen verändert (Prüfsumme weicht ab).`);
}
continue;
}
log(`Migration ${name} …`);
await client.query("BEGIN");
try {
const id = randomUUID();
await client.query(
`INSERT INTO "_prisma_migrations" (id, checksum, migration_name, started_at) VALUES ($1, $2, $3, now())`,
[id, pruefsumme, name],
);
await client.query(sql.toString("utf8"));
await client.query(
`UPDATE "_prisma_migrations" SET finished_at = now(), applied_steps_count = 1 WHERE id = $1`,
[id],
);
await client.query("COMMIT");
} catch (fehler) {
await client.query("ROLLBACK");
throw new Error(`Migration ${name} fehlgeschlagen: ${(fehler as Error).message}`);
}
eingespielt.push(name);
}
return { eingespielt, vorhanden: migrationen.length - eingespielt.length };
} finally {
await client.query("SELECT pg_advisory_unlock($1)", [SPERRE]).catch(() => {});
await client.end();
}
}