Files
betreuungs-cockpit/tests/skripte.test.ts
T
Florian Wilde 4d826ef55f Dokumente mitsichern - eine Akte ohne ihre Dateien ist wertlos
Der Sicherungsdienst machte taeglich ein pg_dump und sonst nichts. Nach
einem Plattenschaden waere die Akte vollstaendig wiederhergestellt worden
- mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
der schlechteste denkbare Zustand.

Je Lauf entstehen jetzt zwei verschluesselte Dateien mit demselben
Zeitstempel: der Datenbankstand und ein Archiv des Ablageverzeichnisses.
Beide mit Pruefsumme, beide in der Aufbewahrung, beide im Monatsstand,
beide in der Selbstpruefung.

Schlaegt der Dokumententeil fehl, bricht der Lauf ab und raeumt den
angefangenen Datenbankstand wieder weg. Eine Sicherung, die nur die halbe
Wahrheit enthaelt, aber vollstaendig aussieht, ist schlimmer als gar
keine: Man verlaesst sich darauf und merkt es erst im Ernstfall.

## Abweichung von der urspruenglichen Idee

Im Issue stand "mc mirror statt rohem Verzeichnis". Beim Umsetzen zeigte
sich, dass der MinIO-Client in diesem Behaelter fehlt und bei jedem Lauf
aus dem Netz geholt werden muesste. Eine Internetabhaengigkeit im
Sicherungspfad ist genau das, was man dort nicht will.

Stattdessen wird das Verzeichnis direkt eingepackt. Das ist vertretbar,
weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp schreibt und
dann an seinen Platz umbenennt: Ein laufender Upload liegt nie halbfertig
im Archiv, er fehlt hoechstens. Derselbe Weg deckt den Dateitreiber mit
ab.

## Nur lesend

Der Sicherungsdienst bekommt das Ablageverzeichnis mit ":ro" eingehaengt.
Er laeuft unbeaufsichtigt per cron; ein Fehler im Skript oder ein
kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
treffen - genau der Fall, gegen den eine Sicherung da ist.

Deshalb schreibt die Wiederherstellung die Dateien auch nicht selbst an
ihren Platz, sondern packt sie nach ./backups aus und gibt den letzten
Handgriff als Einzeiler aus. In ein laufendes MinIO hineinzuschreiben
wuerde dessen Datenbestand ohnehin beschaedigen.

## Geprueft

Die Kernlogik einmal von Hand durchgespielt: packen, verschluesseln,
Selbstpruefung, zurueckholen - der Inhalt kam bitgleich zurueck, der
Klartext war im Archiv nicht auffindbar, und die Selbstpruefung schlug
sowohl bei einem beschaedigten Archiv als auch bei falschem Passwort an.

Dazu elf Pruefungen in tests/skripte.test.ts gegen vier Mutationen:
Dokumententeil entfernt, Abbruch entfernt, Aufraeumen halbiert,
Schreibrecht erteilt - jede wird rot.

Refs #41
2026-09-01 16:03:26 +02:00

222 lines
9.0 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
/**
* Die Betriebsskripte müssen im Repository so liegen, dass sie auf dem
* Server auch laufen.
*
* Anlass sind zwei Fehler, die beide erst im Betrieb aufgefallen sind und
* beide nichts mit dem Programm zu tun hatten, sondern mit der Verpackung:
*
* 1. `scripts/` fehlte im Docker-Abbild — `node scripts/totp-geheimnis.mjs`
* lief ins Leere (behoben in 1.0.1).
* 2. Die `.sh`-Dateien lagen mit Modus 644 im Repository. Die Anleitung
* sagt `./scripts/update.sh`, auf Linux kam „Permission denied".
*
* Beide Male hat kein Test angeschlagen, weil beide Male der Code richtig
* war. Diese Datei prüft deshalb nicht Verhalten, sondern Verpackung.
*
* Der Ausführbarkeitsbit wird aus dem Git-Index gelesen, nicht vom
* Dateisystem: Unter Windows steht `core.filemode=false`, dort sagt das
* Dateisystem nichts aus. Maßgeblich ist, was ausgecheckt wird.
*/
function gitVerfuegbar(): boolean {
try {
execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'pipe' })
return true
} catch {
return false
}
}
/** Liefert [Modus, Pfad] je Datei, so wie sie im Index steht. */
function eintraegeImIndex(muster: string): [string, string][] {
const ausgabe = execFileSync('git', ['ls-files', '-s', muster], { encoding: 'utf8' })
return ausgabe
.split('\n')
.filter((zeile) => zeile.trim() !== '')
.map((zeile) => {
const [angaben, pfad] = zeile.split('\t')
return [angaben!.split(' ')[0]!, pfad!]
})
}
const mitGit = gitVerfuegbar()
describe.skipIf(!mitGit)('Betriebsskripte', () => {
it('legt jedes Shell-Skript ausführbar ab', () => {
const skripte = eintraegeImIndex('scripts/*.sh')
// Wenn hier nichts steht, stimmt das Muster nicht mehr - das waere
// ein stiller Ausfall der Pruefung.
expect(skripte.length).toBeGreaterThan(0)
for (const [modus, pfad] of skripte) {
expect(modus, `${pfad} muss ausführbar sein (100755, ist ${modus})`).toBe('100755')
}
})
it('gibt jedem Shell-Skript eine Shebang-Zeile', () => {
for (const [, pfad] of eintraegeImIndex('scripts/*.sh')) {
const ersteZeile = readFileSync(pfad, 'utf8').split('\n')[0] ?? ''
expect(ersteZeile, `${pfad} braucht eine Shebang-Zeile`).toMatch(/^#!/)
}
})
it('nimmt scripts/ ins Docker-Abbild auf', () => {
// Der Fehler aus 1.0.1: Die Wartungsskripte lagen im Repository, aber
// nicht im Behaelter. Aufgefallen ist es erst, als sie gebraucht wurden.
const dockerfile = readFileSync('Dockerfile', 'utf8')
// Die Zeile traegt zusaetzliche Schalter (--from, --chown) - deshalb
// nicht auf eine feste Reihenfolge festlegen, sondern nur darauf, dass
// scripts/ ins Abbild kopiert wird.
expect(dockerfile).toMatch(/^COPY.*\/scripts\s+\.\/scripts\s*$/m)
})
it('bindet die Sicherungsskripte aus scripts/ in die Compose-Datei ein', () => {
// Sie werden als Volume in den Sicherungsdienst gehaengt. Ein
// umbenanntes Skript faellt sonst erst um 03:15 Uhr auf - im Log,
// das niemand liest.
const compose = readFileSync('docker-compose.yml', 'utf8')
for (const name of ['sicherung.sh', 'wiederherstellung.sh']) {
expect(compose, `${name} wird nicht eingebunden`).toContain(`./scripts/${name}:`)
}
})
})
/**
* Abschneidende Leitungen in Skripten mit "pipefail".
*
* Anlass ist ein Abbruch im Betrieb: Beim Sprung von v1.5.0 auf v1.10.1
* endete update.sh mitten in der Ausgabe, ohne jede Meldung. Ursache war
*
* git log --oneline "HEAD..$ZIEL" | head -15 | sed ...
*
* "head" schliesst die Leitung nach 15 Zeilen, "git log" schreibt weiter
* und bekommt SIGPIPE (Exitcode 141). Mit "set -o pipefail" gilt die
* ganze Kette als gescheitert, "set -e" beendet das Skript - stumm.
*
* Es ist zeitabhaengig: Solange der Erzeuger fertig wird, bevor "head"
* zumacht, faellt nichts auf. Deshalb lief es monatelang gut und riss
* genau dann, als zum ersten Mal mehr als 15 Commits dazwischenlagen.
*
* Dieselbe Falle steckte in der Selbstpruefung der Sicherung, wo sie
* eine intakte Sicherung als kaputt gemeldet haette, sobald die
* Datenbank gross genug ist.
*
* Der Test verbietet das Muster deshalb ganz. Wer eine Ausgabe kuerzen
* will, nimmt "git log -15", "sed -n '1,15p'" oder einen Zaehler in awk -
* alles drei liest bis zum Ende und schliesst nichts zu frueh.
*/
describe('Skripte mit pipefail', () => {
const skripte = ['scripts/update.sh', 'scripts/sicherung.sh', 'scripts/wiederherstellung.sh']
it.each(skripte)('%s schneidet keine Leitung mit head ab', (pfad) => {
const inhalt = readFileSync(pfad, 'utf8')
if (!/set -[a-z]*o pipefail|set -o pipefail/.test(inhalt)) return
const treffer = inhalt
.split('\n')
.map((zeile, nummer) => ({ zeile, nummer: nummer + 1 }))
// Kommentarzeilen erklaeren das Muster gerade - sie sind erlaubt.
.filter(({ zeile }) => !zeile.trim().startsWith('#'))
.filter(({ zeile }) => !zeile.includes('printf'))
.filter(({ zeile }) => /\|\s*head\b/.test(zeile))
expect(
treffer.map((t) => `Zeile ${t.nummer}: ${t.zeile.trim()}`),
'Abschneidende Leitung: der Erzeuger bekommt SIGPIPE, pipefail reicht ' +
'es weiter, set -e beendet das Skript stumm.',
).toEqual([])
})
})
/**
* Die Sicherung muss die Dokumente enthalten - nicht nur die Datenbank.
*
* Bis v1.11.0 sicherte scripts/sicherung.sh ausschliesslich die Datenbank.
* Nach einem Plattenschaden waere die Akte vollstaendig wiederhergestellt
* worden - mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine
* einzige Datei dahinter. Die Pruefsumme in der Akte haette dann ein
* Dokument belegt, das es nicht mehr gibt.
*
* Aufgefallen ist das nicht durch einen Test, sondern beim Nachsehen, wo
* die Daten eigentlich liegen. Die folgenden Pruefungen lesen deshalb den
* Skripttext: Sie koennen die Sicherung nicht ausfuehren - dafuer braeuchte
* es Postgres, MinIO und einen Behaelter -, aber sie schlagen an, sobald
* einer der Bausteine wieder herausfaellt.
*/
describe('Sicherung umfasst die Dokumente', () => {
const sicherung = readFileSync('scripts/sicherung.sh', 'utf8')
const wiederherstellung = readFileSync('scripts/wiederherstellung.sh', 'utf8')
it('packt das Ablageverzeichnis ein', () => {
expect(sicherung).toMatch(/tar -czf - -C "\$DOKUMENTE_QUELLE"/)
})
it('verschlüsselt das Archiv mit demselben Verfahren wie den Datenbankstand', () => {
// Zwei Aufrufe: einer fuer den Dump, einer fuer die Dokumente.
const verschluesselt = sicherung.match(/openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt/g)
expect(verschluesselt).toHaveLength(2)
})
it('legt zu beiden Teilen eine Prüfsumme', () => {
expect(sicherung).toMatch(/sha256sum "\$DATEI"/)
expect(sicherung).toMatch(/sha256sum "\$DOKUMENTE_DATEI"/)
})
it('bricht ab, wenn das Ablageverzeichnis nicht eingehängt ist', () => {
// Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
// vollstaendig aussieht, ist schlimmer als gar keine.
expect(sicherung).toMatch(/if \[ ! -d "\$DOKUMENTE_QUELLE" \]/)
expect(sicherung).toMatch(/rm -f "\$DATEI" "\$DATEI\.sha256"/)
})
it('prüft am Ende beide Teile, nicht nur den Datenbankstand', () => {
expect(sicherung).toMatch(/tar -tzf -/)
expect(sicherung).toMatch(/Datenbank und Dokumente/)
})
it('räumt alte Dokumentenarchive genauso auf wie alte Datenbankstände', () => {
expect(sicherung).toMatch(/dokumente_\*\.tar\.gz\.enc/)
})
it('verschont beim Monatsstand beide Teile', () => {
expect(sicherung).toMatch(/touch "\$DATEI\.monatsstand" "\$DOKUMENTE_DATEI\.monatsstand"/)
})
it('holt die Dokumente beim Zurückspielen mit zurück', () => {
expect(wiederherstellung).toMatch(/DOKUMENTE_DATEI=/)
expect(wiederherstellung).toMatch(/tar -xzf - -C "\$AUSPACKZIEL"/)
})
it('sagt beim Zurückspielen deutlich, wenn kein Dokumentenarchiv dabei ist', () => {
// Sonst haelt man eine halbe Wiederherstellung fuer eine ganze.
expect(wiederherstellung).toMatch(/kein Dokumentenarchiv/)
})
it('schreibt die Dokumente nicht selbst an ihren Platz', () => {
// Der Sicherungsdienst hat auf das Ablageverzeichnis nur Lesezugriff.
// In ein laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
// beschaedigen - der letzte Handgriff bleibt beim Menschen.
expect(wiederherstellung).toMatch(/docker compose stop minio app/)
})
})
/**
* Der Sicherungsdienst darf die Daten, die er sichert, nicht zerstoeren
* koennen.
*
* Er laeuft unbeaufsichtigt per cron. Ein Fehler im Skript oder ein
* kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
* treffen - genau der Fall, gegen den eine Sicherung da ist.
*/
describe('Sicherungsdienst greift nur lesend auf die Dokumente zu', () => {
it('hängt das Ablageverzeichnis mit :ro ein', () => {
const compose = readFileSync('docker-compose.yml', 'utf8')
expect(compose).toMatch(/minio-daten:\/dokumente:ro/)
})
})