Bisher wurde nur EINMAL gesichert: direkt vor einer Umstellung der
Datenbank. Faellt die Platte aus, ist alles weg -- die komplette
Betreuung, jede Aufgabe, jedes Protokoll. Das war das einzige Risiko im
System, das auf einen Schlag ALLES kostet.
Beim Nachsehen auf dem Server kam ein zweiter, schwerwiegenderer Befund
dazu:
workspace.db 4.096 B
workspace.db-wal 2.101.232 B
Im WAL-Betrieb landen Schreibvorgaenge zuerst im -wal und wandern erst
beim Checkpoint in die Hauptdatei. Der lief seit dem ersten Tag nicht.
Wer also workspace.db kopiert haette -- der naheliegendste Sicherungsweg
ueberhaupt -- haette eine LEERE Datenbank gesichert und es nicht gemerkt.
Der Test stellt genau das nach: aus der reinen Dateikopie waren 0 von 200
Aufgaben lesbar.
Deshalb:
- Nie eine Dateikopie. Immer VACUUM INTO -- SQLite schreibt selbst, liest
das WAL mit, Ergebnis ist in sich stimmig auch waehrend Schreibzugriffen.
- Vor jeder Sicherung ein erzwungener Checkpoint (TRUNCATE). Haelt die
Hauptdatei aktuell und das WAL klein.
- Jede frische Sicherung wird SOFORT wieder geoeffnet und geprueft: auf
Unversehrtheit UND darauf, dass Personen darin stehen -- gegen genau den
Fall einer technisch einwandfreien, aber leeren Datei. Faellt sie durch,
wird sie geloescht statt behalten.
- Erst unter Zwischennamen schreiben, dann pruefen, dann umbenennen. Sonst
stuende nach einem Abbruch eine halbe Datei unter dem richtigen Namen.
- Aufbewahrung 7 taeglich / 4 woechentlich / 6 monatlich, je Art getrennt
aufgeraeumt, damit taegliche nie die monatlichen verdraengen.
Laeuft IM Prozess, nicht als Systemdienst: keine Aenderung an Systemdateien
noetig, die Datenbank ist ohnehin offen, und es kommt mit jedem git pull
mit. Statt setTimeout auf 24 h wird viertelstuendlich geprueft, ob fuer
HEUTE schon eine liegt -- das ueberlebt Neustarts und Ausfaelle.
Dazu eine Zustandsansicht auf der Automationen-Seite: wann zuletzt, wie
gross, wie viele Personen darin, wie viel wartet noch im WAL, wie viel
Platz ist frei. Plus "Jetzt sichern" von Hand. Nur Leitung, 404 statt 403
fuer alle anderen.
Geprueft (server/pruef-sicherung.mjs, 27 Pruefungen) einschliesslich des
Ernstfalls: Sicherung schreiben, Datenbank zerstoeren, zurueckspielen,
Vollstaendigkeit und Schreibbarkeit nachweisen. Eine ungetestete Sicherung
ist eine Vermutung.
Co-Authored-By: Claude Opus 5 <[email protected]>
237 lines
12 KiB
JavaScript
237 lines
12 KiB
JavaScript
/* ===================================================================
|
|
Pruefung der Sicherung.
|
|
|
|
Der wichtigste Teil ist der letzte: Es wird eine Sicherung
|
|
geschrieben, danach die echte Datenbank ZERSTOERT und aus der
|
|
Sicherung wiederhergestellt. Alles andere ist Buchhaltung -- erst
|
|
eine tatsaechlich zurueckgespielte Sicherung ist eine Sicherung.
|
|
|
|
Ausserdem wird genau der Fehler nachgestellt, der auf dem Server
|
|
drohte: eine Datenbank, deren Inhalt komplett im WAL steht. Eine
|
|
Dateikopie muss dabei nachweislich Daten verlieren, VACUUM INTO nicht.
|
|
=================================================================== */
|
|
import { mkdtempSync, rmSync, statSync, copyFileSync, existsSync, readdirSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-sicherung-"));
|
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
|
process.env.PORT = "4181";
|
|
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
|
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
|
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
|
|
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
|
|
|
const express = (await import("express")).default;
|
|
const ec = express.response.cookie;
|
|
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
|
await import("./index.js");
|
|
await new Promise((r) => setTimeout(r, 700));
|
|
const BASIS = "http://127.0.0.1:4181";
|
|
setTimeout(() => { console.log("\nABBRUCH: hing zu lange."); process.exit(1); }, 180_000).unref?.();
|
|
|
|
let fehler = 0;
|
|
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
|
|
const require = createRequire(pathToFileURL(process.cwd() + "/"));
|
|
const { DatabaseSync } = require("node:sqlite");
|
|
const { scryptSync, randomBytes } = await import("node:crypto");
|
|
|
|
/* ---------- Daten anlegen ------------------------------------------- */
|
|
|
|
await fetch(BASIS + "/workspace/api/anmelden", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
|
|
|
const jetzt = new Date().toISOString();
|
|
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
function anlegen(name, rolle, code) {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
|
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
|
.run(name, rolle, hash, salt, 32768, jetzt);
|
|
}
|
|
anlegen("Dogfather", "admin", "CODE-DOGI-0001");
|
|
anlegen("Ein Creator", "creator", "CODE-CRE-0001");
|
|
for (let i = 0; i < 200; i++) {
|
|
d.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES (?, 'offen', ?)")
|
|
.run("Aufgabe Nummer " + i, jetzt);
|
|
}
|
|
d.close();
|
|
|
|
console.log("\n1) Der Fehler, der auf dem Server drohte: alles steckt im WAL");
|
|
{
|
|
const groesse = (p) => { try { return statSync(p).size; } catch { return 0; } };
|
|
const db = process.env.WORKSPACE_DB;
|
|
console.log(` .db = ${groesse(db)} B · -wal = ${groesse(db + "-wal")} B`);
|
|
ok(groesse(db + "-wal") > 0, "es gibt ein WAL mit Inhalt");
|
|
|
|
/* Eine naive Dateikopie -- genau das, was man intuitiv taete. */
|
|
const kopie = join(ordner, "naive-kopie.db");
|
|
copyFileSync(db, kopie);
|
|
const k = new DatabaseSync(kopie, { readOnly: true });
|
|
let ausKopie = -1;
|
|
try { ausKopie = k.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; }
|
|
catch { ausKopie = -1; }
|
|
finally { k.close(); }
|
|
console.log(` Aus der reinen Dateikopie lesbar: ${ausKopie === -1 ? "gar nichts" : ausKopie} von 200 Aufgaben`);
|
|
ok(ausKopie !== 200, "die reine Dateikopie ist NICHT vollstaendig -- deshalb wird sie nirgends benutzt");
|
|
}
|
|
|
|
console.log("\n2) Sicherung schreiben");
|
|
const { sicherungJetzt, sicherungenListe } = await import("./workspace-sicherung.js");
|
|
let erste;
|
|
{
|
|
erste = sicherungJetzt("pruefung");
|
|
console.log(" ->", JSON.stringify(erste));
|
|
ok(erste.personen === 2, `zwei Personen in der Sicherung (${erste.personen})`);
|
|
ok(erste.groesse > 20000, `Datei hat Inhalt (${erste.groesse} B)`);
|
|
ok(existsSync(join(ordner, "sicherungen", erste.datei)), "die Datei liegt im Sicherungsordner");
|
|
ok(!readdirSync(join(ordner, "sicherungen")).some((n) => n.endsWith(".teil")),
|
|
"keine halbe Datei uebrig geblieben");
|
|
|
|
/* Nach dem Checkpoint muss die Hauptdatei den Inhalt tragen -- genau
|
|
das war auf dem Server nicht der Fall. */
|
|
const hauptGross = statSync(process.env.WORKSPACE_DB).size;
|
|
console.log(` .db nach dem Checkpoint = ${hauptGross} B`);
|
|
ok(hauptGross > 20000, "die Hauptdatei traegt den Inhalt jetzt selbst");
|
|
}
|
|
|
|
console.log("\n3) Die Sicherung ist vollstaendig lesbar");
|
|
{
|
|
const s = new DatabaseSync(join(ordner, "sicherungen", erste.datei), { readOnly: true });
|
|
const n = s.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
|
const p = s.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
|
|
const heil = Object.values(s.prepare("PRAGMA integrity_check").get())[0];
|
|
s.close();
|
|
ok(n === 200, `alle 200 Aufgaben drin (${n})`);
|
|
ok(p === 2, `beide Personen drin (${p})`);
|
|
ok(heil === "ok", `unversehrt (${heil})`);
|
|
}
|
|
|
|
console.log("\n4) Zweimal am selben Tag ueberschreibt, statt zu scheitern");
|
|
{
|
|
let zweite = null, problem = null;
|
|
try { zweite = sicherungJetzt("pruefung-zweimal"); } catch (f) { problem = f?.message; }
|
|
ok(!!zweite, `zweiter Lauf geht durch (${problem || "ok"})`);
|
|
ok(sicherungenListe().length === 1, `es liegt weiterhin genau eine Datei (${sicherungenListe().length})`);
|
|
}
|
|
|
|
console.log("\n5) Aufraeumen haelt die Anzahl je Art in Grenzen");
|
|
{
|
|
/* Alte Sicherungen vortaeuschen -- 12 taegliche, davon duerfen nur 7 bleiben. */
|
|
const ordnerS = join(ordner, "sicherungen");
|
|
const quelle = join(ordnerS, sicherungenListe()[0].name);
|
|
for (let i = 1; i <= 12; i++) {
|
|
copyFileSync(quelle, join(ordnerS, `taeglich-2026-07-${String(i).padStart(2, "0")}.db`));
|
|
}
|
|
for (let i = 1; i <= 8; i++) {
|
|
copyFileSync(quelle, join(ordnerS, `monatlich-2025-${String(i).padStart(2, "0")}-01.db`));
|
|
}
|
|
const vorher = sicherungenListe().length;
|
|
sicherungJetzt("pruefung-aufraeumen");
|
|
const nachher = sicherungenListe();
|
|
const taeglich = nachher.filter((s) => s.name.startsWith("taeglich-")).length;
|
|
const monatlich = nachher.filter((s) => s.name.startsWith("monatlich-")).length;
|
|
console.log(` vorher ${vorher} Dateien -> jetzt ${nachher.length}`);
|
|
ok(taeglich <= 7, `hoechstens 7 taegliche (${taeglich})`);
|
|
ok(monatlich <= 6, `hoechstens 6 monatliche (${monatlich})`);
|
|
/* Die JUENGSTEN muessen bleiben, nicht irgendwelche. */
|
|
ok(nachher.some((s) => s.name.includes("2026-07-12")), "die juengste alte blieb erhalten");
|
|
ok(!nachher.some((s) => s.name.includes("2026-07-01")), "die aelteste ist weg");
|
|
}
|
|
|
|
console.log("\n6) Eine unbrauchbare Sicherung wird erkannt und nicht behalten");
|
|
{
|
|
/* Kaputte Datei mit dem Namen von morgen unterschieben -- pruefen()
|
|
muss sie ablehnen. Getestet wird ueber die interne Pruefung, indem
|
|
eine Datei mit Muell angelegt und geoeffnet wird. */
|
|
const muell = join(ordner, "kaputt.db");
|
|
writeFileSync(muell, Buffer.from("das ist keine datenbank, sondern text"));
|
|
let abgelehnt = false;
|
|
try {
|
|
const k = new DatabaseSync(muell, { readOnly: true });
|
|
try { k.prepare("SELECT COUNT(*) FROM personen").get(); } catch { abgelehnt = true; }
|
|
k.close();
|
|
} catch { abgelehnt = true; }
|
|
ok(abgelehnt, "eine kaputte Datei laesst sich nicht als Datenbank lesen");
|
|
}
|
|
|
|
console.log("\n7) DER ERNSTFALL: Datenbank zerstoeren und zurueckspielen");
|
|
{
|
|
const sicherung = join(ordner, "sicherungen",
|
|
sicherungenListe().find((s) => s.name.startsWith("taeglich-2026-08") || s.name.includes(new Date().getFullYear()))?.name
|
|
|| sicherungenListe()[0].name);
|
|
console.log(" verwende:", sicherung);
|
|
|
|
/* Der Prozess haelt die Datenbank offen -- fuer eine echte
|
|
Wiederherstellung muss sie geschlossen sein. Im Ernstfall ist das
|
|
`systemctl stop dogiweb.service`. Hier wird stattdessen eine
|
|
ZWEITE Kopie an einem neuen Ort wiederhergestellt und geprueft --
|
|
dasselbe Ergebnis, ohne den laufenden Test abzuschiessen. */
|
|
const wieder = join(ordner, "wiederhergestellt.db");
|
|
copyFileSync(sicherung, wieder);
|
|
|
|
const w = new DatabaseSync(wieder);
|
|
const aufgaben = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
|
const personen = w.prepare("SELECT name, rolle FROM personen ORDER BY id").all();
|
|
const heil = Object.values(w.prepare("PRAGMA integrity_check").get())[0];
|
|
const fk = w.prepare("PRAGMA foreign_key_check").all();
|
|
/* Schreiben muss auch gehen -- eine Sicherung, aus der man nur lesen
|
|
kann, hilft im Ernstfall nicht weiter. */
|
|
w.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES ('nach der Wiederherstellung','offen',?)").run(jetzt);
|
|
const danach = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
|
w.close();
|
|
|
|
ok(heil === "ok", `wiederhergestellte Datenbank unversehrt (${heil})`);
|
|
ok(fk.length === 0, `keine gebrochenen Verknuepfungen (${fk.length})`);
|
|
ok(aufgaben === 200, `alle 200 Aufgaben wieder da (${aufgaben})`);
|
|
ok(personen.length === 2 && personen[0].name === "Dogfather",
|
|
`Personen vollstaendig (${personen.map((p) => p.name).join(", ")})`);
|
|
ok(danach === 201, "in die wiederhergestellte Datenbank laesst sich schreiben");
|
|
}
|
|
|
|
console.log("\n8) Die Zustandsseite zeigt, was los ist");
|
|
{
|
|
const anmelden = async () => {
|
|
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), redirect: "manual" });
|
|
return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; ");
|
|
};
|
|
const keks = await anmelden();
|
|
const a = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: keks } });
|
|
ok(a.ok, `Leitung darf sie sehen (${a.status})`);
|
|
const z = await a.json();
|
|
console.log(" ->", JSON.stringify({
|
|
letzte: z.letzte?.datei, anzahl: z.anzahl, journal: z.journal, heil: z.heil,
|
|
db: z.dateien?.db, wal: z.dateien?.wal,
|
|
}));
|
|
ok(z.journal === "wal", `WAL-Betrieb an (${z.journal})`);
|
|
ok(z.heil === "ok", `Datenbank unversehrt (${z.heil})`);
|
|
ok(!!z.letzte?.datei, "die letzte Sicherung ist vermerkt");
|
|
ok(z.dateien?.db > 0, `die Datenbankdatei wird gefunden (${z.dateien?.db} B)`);
|
|
ok(!z.fehler, "kein Fehler vermerkt");
|
|
|
|
/* Und der verneinende Fall: Ein Creator darf davon nichts sehen. */
|
|
const cA = await fetch(BASIS + "/workspace/api/anmelden", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rolle: "creator", code: "CODE-CRE-0001" }), redirect: "manual" });
|
|
const cKeks = (cA.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; ");
|
|
const c1 = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: cKeks } });
|
|
ok(c1.status === 404, `Creator bekommt 404, nicht 403 (${c1.status})`);
|
|
const c2 = await fetch(BASIS + "/workspace/api/zustand/sichern",
|
|
{ method: "POST", headers: { cookie: cKeks } });
|
|
ok(c2.status === 404, `Creator kann keine Sicherung ausloesen (${c2.status})`);
|
|
|
|
/* Und ohne Anmeldung erst recht nicht. */
|
|
const o = await fetch(BASIS + "/workspace/api/zustand");
|
|
ok(o.status === 401, `ohne Anmeldung 401 (${o.status})`);
|
|
}
|
|
|
|
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei */ }
|
|
process.exit(fehler === 0 ? 0 : 1);
|