Phase 11: Freigabereife (#16)
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s

This commit was merged in pull request #16.
This commit is contained in:
2026-09-06 20:05:49 +00:00
parent 91742a0dd7
commit 14cc4de9b8
21 changed files with 2495 additions and 8 deletions
+188
View File
@@ -0,0 +1,188 @@
/**
* 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<void> {
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);
});