Files
FireAdministration/scripts/sicherung.ts
T
Florian WildeandClaude Opus 5 9156dcc144
Pruefung / pruefung (pull_request) Canceled after 0s
Pruefung / oberflaeche (pull_request) Canceled after 0s
Phase 11: Freigabereife
Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0:
Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine
Pipeline.

Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export`
stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen,
implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht
abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person,
mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern
auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht.
Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20.

Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person
gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie
getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum
Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber
eine Selbstauskunft an die Geraeteakten.

Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch
lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien
der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt
jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT
kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log
aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts
haette darauf hingewiesen. Gefunden haben es vier Datenbanktests
unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie
verlangt.

Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein
act_runner registriert. Das steht so im README und im Devlog, damit sich
niemand auf eine Pruefung verlaesst, die nicht stattfindet.

601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig
durchgefuehrt und die Tests danach erneut gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 17:16:08 +02:00

189 lines
6.1 KiB
TypeScript

/**
* 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);
});