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([]) }) })