Phase 11: Freigabereife (#16)
This commit was merged in pull request #16.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user