/** * Sicherung: Datenbank und Dateien in ein Verzeichnis. * * npm run sicherung * npm run sicherung -- --ziel D:\backups * * Eine Feuerwehrverwaltung ohne geprüfte Sicherung ist ein Aktenschrank ohne * Schloss — bis zum ersten Festplattenfehler sieht man den Unterschied nicht. * Deshalb gehört zu diesem Skript zwingend `npm run wiederherstellen`: eine * Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. * * Gesichert wird beides, und zwar zusammen: * * * **Die Datenbank** über `pg_dump` im Postgres-Container. Im eigenen * Format (`--format=custom`), weil sich daraus einzelne Tabellen * zurückholen lassen und es kleiner ist. * * **Die Dateien** aus MinIO — Prüfprotokolle, Einsatzberichte, Fotos, * Rechnungen. Ohne sie wäre die Datenbank voller Verweise ins Leere. * * Beides muss aus demselben Moment stammen. Eine Datenbank von heute mit * Dateien von gestern ergibt Datensätze, die auf nichts zeigen. */ import "dotenv/config"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { mkdirSync, writeFileSync, createWriteStream } from "node:fs"; import { join, dirname } from "node:path"; import { pipeline } from "node:stream/promises"; import { Client as MinioClient } from "minio"; import { zugangAus } from "./sicherung-zugang"; const ZIEL_VORGABE = "sicherungen"; const CONTAINER = process.env.SICHERUNG_POSTGRES_CONTAINER ?? "florian-postgres"; function log(nachricht: string): void { console.info(`[sicherung] ${nachricht}`); } /** Ein Zeitstempel, der sich als Verzeichnisname eignet. */ function stempel(): string { return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); } function argument(name: string): string | undefined { const stelle = process.argv.indexOf(`--${name}`); return stelle >= 0 ? process.argv[stelle + 1] : undefined; } /** * Die Datenbank sichern. * * Über `docker compose exec` statt über einen lokalen `pg_dump`: die Version * des Clients muss zur Serverversion passen, und im Container ist sie das * garantiert. Auf einem Rechner ohne passendes `pg_dump` schlüge es sonst mit * einer Meldung fehl, die niemand mit der Ursache in Verbindung bringt. */ function datenbankSichern(ziel: string): { datei: string; bytes: number } { const { benutzer, passwort, datenbank } = zugangAus( process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL, ); const datei = join(ziel, "datenbank.dump"); log(`Datenbank aus Container "${CONTAINER}" ...`); const lauf = spawnSync( "docker", [ "compose", "exec", "-T", "postgres", "pg_dump", "--format=custom", // Der Eigentuemer wird nicht mitgesichert (er heisst auf jedem Server // anders), die RECHTE dagegen schon. Ohne sie waeren nach dem // Zurueckspielen alle REVOKEs der Migrationen weg: die Anwendungsrolle // duerfte das Audit-Log aendern, Wiki-Fassungen loeschen, // Kiosk-Vorgaenge umschreiben. Nichts wuerde darauf hinweisen. "--no-owner", "--username", benutzer, "--dbname", datenbank, ], { encoding: "buffer", maxBuffer: 2_000_000_000, shell: true, env: { ...process.env, PGPASSWORD: passwort }, }, ); if (lauf.status !== 0) { const fehler = lauf.stderr?.toString() ?? "unbekannt"; throw new Error(`pg_dump ist fehlgeschlagen:\n${fehler}`); } writeFileSync(datei, lauf.stdout); return { datei: "datenbank.dump", bytes: lauf.stdout.byteLength }; } /** Alle Objekte aus MinIO holen. */ async function dateienSichern(ziel: string): Promise<{ anzahl: number; bytes: number }> { const bucket = process.env.S3_BUCKET ?? "florian"; const client = new MinioClient({ endPoint: process.env.S3_ENDPOINT ?? "localhost", port: Number(process.env.S3_PORT ?? 9000), useSSL: (process.env.S3_USE_SSL ?? "false") === "true", accessKey: process.env.S3_ACCESS_KEY ?? "", secretKey: process.env.S3_SECRET_KEY ?? "", }); if (!(await client.bucketExists(bucket))) { log(`Bucket "${bucket}" gibt es nicht — keine Dateien zu sichern.`); return { anzahl: 0, bytes: 0 }; } const ordner = join(ziel, "dateien"); mkdirSync(ordner, { recursive: true }); let anzahl = 0; let bytes = 0; const strom = client.listObjectsV2(bucket, "", true); for await (const objekt of strom) { if (!objekt.name) continue; const pfad = join(ordner, objekt.name); mkdirSync(dirname(pfad), { recursive: true }); const quelle = await client.getObject(bucket, objekt.name); await pipeline(quelle, createWriteStream(pfad)); anzahl += 1; bytes += objekt.size ?? 0; if (anzahl % 50 === 0) log(` ${anzahl} Dateien ...`); } return { anzahl, bytes }; } async function main(): Promise { const wurzel = argument("ziel") ?? ZIEL_VORGABE; const ziel = join(wurzel, stempel()); mkdirSync(ziel, { recursive: true }); log(`Ziel: ${ziel}`); const datenbank = datenbankSichern(ziel); log(`Datenbank: ${(datenbank.bytes / 1024 / 1024).toFixed(1)} MB`); const dateien = await dateienSichern(ziel); log(`Dateien: ${dateien.anzahl} Stück, ${(dateien.bytes / 1024 / 1024).toFixed(1)} MB`); /** * Das Manifest. * * Es hält fest, woraus die Sicherung stammt und wie groß sie ist. Beim * Zurückspielen wird es gelesen und angezeigt — damit niemand versehentlich * eine Sicherung von vor drei Monaten einspielt, weil das Verzeichnis * ähnlich hieß. */ const manifest = { erstelltAm: new Date().toISOString(), version: process.env.npm_package_version ?? "unbekannt", datenbank, dateien, pruefsumme: createHash("sha256") .update(`${datenbank.bytes}:${dateien.anzahl}:${dateien.bytes}`) .digest("hex"), }; writeFileSync(join(ziel, "manifest.json"), JSON.stringify(manifest, null, 2)); log(""); log("Fertig. Zum Prüfen der Sicherung:"); log(` npm run wiederherstellen -- --quelle ${ziel} --wirklich`); log(""); log("Eine Sicherung, die nie zurueckgespielt wurde, ist eine Vermutung."); } main().catch((fehler) => { console.error("[sicherung] Abbruch:", fehler); process.exit(1); });