diff --git a/README.md b/README.md index e2debe1..dced6ac 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,33 @@ Wiki die Kopie. Wer dort tippt, verliert es beim nächsten Lauf. ### Zurückspielen +```powershell +pwsh -File tools\zurueckspielen.ps1 # ÜBUNG (Standard) +pwsh -File tools\zurueckspielen.ps1 -Echt # der Ernstfall +``` + +**Die Übung ist der eigentliche Gewinn.** Sie legt ein Wegwerf-Volume an, +spielt das Archiv hinein, startet einen *zweiten* Dienst davor, lässt ihn drei +zufällige Dateien ausliefern und vergleicht deren SHA-256 mit dem, was in der +Datenbank steht — dann räumt sie alles wieder ab. Der laufende Dienst wird +nicht angefasst. + +Das beantwortet die Frage, die `sichern.ps1` **nicht** beantwortet: nicht „ist +das Archiv heil", sondern „wird daraus wieder ein laufender Dienst". Ein +Backup, das man nie zurückgespielt hat, ist kein Backup, sondern eine Datei. + +Zuletzt gelaufen und bestanden: + +``` +4452 Medieneinträge, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb +4452 Dateien -- gleich viele wie Einträge +ok items/velum2.png 113567 B +ok items/flesh-dolphin-2star.png 3623 B +ok items/burgermeat.png 4269 B +``` + +Von Hand ginge es auch: + ```bash tar xzf d4rk_media-JJJJ-MM-TT-HHMM.tar.gz ``` diff --git a/ROADMAP.md b/ROADMAP.md index 7456492..899eaab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -803,6 +803,41 @@ nach. Eigene Art mit eigenem Zeichen; die Filterleiste kennt jetzt auch `/api/exists?pfad=…` statt über den Pfad, mit dem Grund als Kommentar daneben. Syntaktisch gegengeprüft (`luac -p`). +### ✅ Fertig — die Sicherung ist zurückgespielt worden + +Bis hierhin war sie **geprüft**, nicht **erprobt**. `sichern.ps1` packt das +Archiv aus, öffnet die Datenbank, zählt, lädt hoch, holt zurück und vergleicht +die Prüfsumme — das beantwortet „ist die Datei heil?". Es beantwortet nicht +„kommt daraus wieder ein Dienst?". + +Dazwischen liegen Handgriffe, die man sonst zum ersten Mal an dem Abend macht, +an dem man sie unter Druck macht: wie das Volume heißt, dass der Container +stehen muss, wem die Dateien gehören müssen, dass `thumbs/` und `webp/` +abgeleitet sind. + +`tools/zurueckspielen.ps1` macht beides — und die **Übung ist der Standard**: +Wegwerf-Volume, Archiv hinein, ein *zweiter* Container davor, drei zufällige +Dateien **durch den Dienst** geholt und ihr SHA-256 gegen die Datenbank +geprüft, danach alles abgeräumt. Der laufende Dienst wird nicht angefasst. + +Am 11.08.2026 gelaufen, mit dem Archiv von 23:07: + +``` +4452 Medieneinträge, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb +4452 Dateien -- gleich viele wie Einträge +ok items/velum2.png 113567 B +ok items/flesh-dolphin-2star.png 3623 B +ok items/burgermeat.png 4269 B + +Die Uebung ist bestanden. +``` + +Der Ernstfall (`-Echt`) verlangt ein getipptes `ZURUECKSPIELEN`, hält den +Container an, ersetzt `media.db`, `files/` und `papierkorb/`, **löscht +`thumbs/` und `webp/`** (Abgeleitetes stehenzulassen wäre schlimmer: die +Galerie zeigte die Vorschau einer Datei, die dort nicht mehr liegt), startet +wieder und zählt selbst nach. + ### ⬜ Als Nächstes **Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container diff --git a/tools/zurueckspielen.ps1 b/tools/zurueckspielen.ps1 new file mode 100644 index 0000000..6e0cd9c --- /dev/null +++ b/tools/zurueckspielen.ps1 @@ -0,0 +1,286 @@ +# Eine Sicherung zurueckspielen -- geuebt oder im Ernst. +# +# pwsh -File tools\zurueckspielen.ps1 <- UEBUNG (Standard) +# pwsh -File tools\zurueckspielen.ps1 -Echt <- der Ernstfall +# +# WOZU DAS UEBERHAUPT. sichern.ps1 prueft sein Archiv gruendlich: auspacken, +# Datenbank oeffnen, Zeilen zaehlen, hochladen, zurueckholen, Pruefsumme +# vergleichen. Das beantwortet die Frage "ist die Datei heil?". +# +# Es beantwortet NICHT die Frage, auf die es ankommt: "kommt daraus wieder ein +# laufender Dienst?" Dazwischen liegen Handgriffe, die man sonst zum ersten Mal +# an dem Abend macht, an dem man sie unter Druck macht -- wie das Volume +# heisst, dass der Container stehen muss, wem die Dateien gehoeren muessen, +# dass die Vorschauen abgeleitet sind und neu gerechnet werden wollen. +# +# EIN BACKUP, DAS MAN NIE ZURUECKGESPIELT HAT, IST KEIN BACKUP, SONDERN EINE +# DATEI. +# +# DIE UEBUNG ist deshalb der Standard und der eigentliche Gewinn: sie legt ein +# WEGWERF-VOLUME an, startet einen ZWEITEN Container davor, laesst ihn den +# Bestand ausliefern und raeumt hinterher auf. Der echte Dienst wird dabei +# nicht angefasst -- er laeuft die ganze Zeit weiter. + +[CmdletBinding()] +param( + # Ohne Angabe: das neueste Archiv im Sicherungsordner. + [string]$Archiv = '', + [string]$Ordner = 'C:\backup\d4rk_media', + [string]$Container = 'd4rk-media', + [string]$Volume = 'd4rk_media_media-data', + [string]$Abbild = 'd4rk-media:latest', + # Der Ernstfall. Ohne diesen Schalter passiert dem laufenden Dienst nichts. + [switch]$Echt +) + +$ErrorActionPreference = 'Stop' + +function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan } +function Gut($t) { Write-Host " $t" -ForegroundColor Green } +function Schlecht($t) { Write-Host " $t" -ForegroundColor Red } +function Grau($t) { Write-Host " $t" -ForegroundColor DarkGray } + +# --------------------------------------------------------------- Das Archiv + +if (-not $Archiv) { + $neuestes = Get-ChildItem "$Ordner\*.tar.gz" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (-not $neuestes) { + Schlecht "Kein Archiv in $Ordner. Erst tools\sichern.ps1 laufen lassen." + exit 1 + } + $Archiv = $neuestes.FullName +} +if (-not (Test-Path $Archiv)) { Schlecht "Nicht gefunden: $Archiv"; exit 1 } + +$archivDatei = Split-Path $Archiv -Leaf +$archivOrdner = Split-Path $Archiv -Parent +$mb = [math]::Round((Get-Item $Archiv).Length / 1MB, 1) + +Write-Host '' +Schritt "Archiv: $archivDatei ($mb MB, vom $((Get-Item $Archiv).LastWriteTime))" + +# Erst hineinsehen. Ein Archiv ohne media.db ist kein Archiv dieses Dienstes, +# und das soll auffallen, BEVOR irgendwo etwas geloescht wird. +$inhalt = docker run --rm -v "${archivOrdner}:/in:ro" --entrypoint sh $Abbild ` + -c "tar tzf '/in/$archivDatei' | cut -d/ -f1 | sort | uniq -c" +if ($LASTEXITCODE -ne 0) { Schlecht 'Das Archiv liess sich nicht lesen.'; exit 1 } +$inhalt | ForEach-Object { Grau $_.Trim() } + +if (($inhalt -join "`n") -notmatch 'media\.db') { + Schlecht 'In diesem Archiv steckt keine media.db. Falsche Datei?' + exit 1 +} + +# ------------------------------------------------------------------ Pruefen +# +# Egal ob Uebung oder Ernst: erst wird geprueft, was DRIN ist. Beides laeuft +# ueber denselben Weg -- ein zweiter waere einer, der beim naechsten Mal etwas +# anderes prueft als der erste. + +$pruefen = @" +set -e +cd /app +node -e " +const db = require('better-sqlite3')('/data/media.db', { readonly: true, fileMustExist: true }); +const n = db.prepare('SELECT COUNT(*) AS n FROM media').get().n; +const b = db.prepare('SELECT COALESCE(SUM(size),0) AS b FROM media').get().b; +const t = db.prepare('SELECT COUNT(*) AS n FROM tokens').get().n; +const u = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; +const k = db.prepare('SELECT COUNT(*) AS n FROM papierkorb').get().n; +console.log(JSON.stringify({ medien: n, bytes: b, tokens: t, benutzer: u, papierkorb: k })); +" +"@ -replace "`r`n", "`n" + +# ======================================================================= UEBUNG +if (-not $Echt) { + $pVolume = 'd4rk-media-probe' + $pContainer = 'd4rk-media-probe' + + # Aufraeumen, falls ein frueherer Lauf abgebrochen ist. Sonst scheitert das + # Anlegen an einem Rest, den niemand mehr erwartet. + docker rm -f $pContainer 2>&1 | Out-Null + docker volume rm $pVolume 2>&1 | Out-Null + + try { + Schritt 'Wegwerf-Volume anlegen und Archiv hineinspielen' + docker volume create $pVolume | Out-Null + docker run --rm -v "${pVolume}:/data" -v "${archivOrdner}:/in:ro" ` + --entrypoint sh $Abbild -c "cd /data && tar xzf '/in/$archivDatei' && chown -R node:node /data" + if ($LASTEXITCODE -ne 0) { throw 'Auspacken fehlgeschlagen' } + Gut 'ausgepackt' + + Schritt 'Einen zweiten Dienst davorhaengen (ohne Port nach aussen)' + # KEIN veroeffentlichter Port: der Uebungslauf soll sich mit nichts + # anlegen, was schon laeuft. Geredet wird ueber docker exec. + # NODE_ENV NICHT auf production: sonst waere das Sitzungs-Cookie + # "Secure", und ueber http://127.0.0.1 im Container faende keine + # Anmeldung statt. Hier wird ohnehin nur ausgeliefert. + docker run -d --name $pContainer ` + -e PUBLIC_URL=http://probe.invalid ` + -e ADMIN_PASSWORD=nur-fuer-die-uebung ` + -v "${pVolume}:/data" $Abbild | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Der Probe-Container startete nicht' } + + # Warten, bis er antwortet. Nicht blind schlafen -- fragen. + $lebt = $false + foreach ($i in 1..30) { + Start-Sleep -Milliseconds 700 + $a = docker exec $pContainer node -e "fetch('http://127.0.0.1:8080/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))" 2>&1 + if ($LASTEXITCODE -eq 0) { $lebt = $true; break } + } + if (-not $lebt) { + Schlecht 'Der Dienst kam aus dieser Sicherung NICHT hoch.' + Grau 'Protokoll:' + docker logs $pContainer --tail 25 2>&1 | ForEach-Object { Grau $_ } + throw 'Uebung fehlgeschlagen' + } + Gut 'Der Dienst ist hochgekommen und antwortet auf /health' + + Schritt 'Nachzaehlen, was drin ist' + $roh = docker exec $pContainer sh -c $pruefen + if ($LASTEXITCODE -ne 0) { throw 'Die Datenbank liess sich nicht lesen' } + $z = $roh | ConvertFrom-Json + Gut "$($z.medien) Medieneintraege, $([math]::Round($z.bytes/1MB,1)) MB, $($z.tokens) Token, $($z.benutzer) Benutzer, $($z.papierkorb) im Papierkorb" + + Schritt 'Zaehlen, was WIRKLICH auf der Platte liegt' + $aufPlatte = [int](docker exec $pContainer sh -c "find /data/files -type f | wc -l").Trim() + $passt = ($aufPlatte -eq $z.medien) + if ($passt) { Gut "$aufPlatte Dateien -- gleich viele wie Eintraege" } + else { Schlecht "$aufPlatte Dateien gegen $($z.medien) Eintraege -- das passt NICHT zusammen" } + + # --------------------------------------------------------------------- + # DIE EIGENTLICHE PRUEFUNG: nicht "liegen Dateien da", sondern "liefert + # der Dienst die richtigen Bytes aus". Dazu wird eine zufaellige Datei + # durch den wiederhergestellten Dienst geholt und ihr SHA-256 gegen den + # verglichen, der in der Datenbank steht. + # + # Das ist der Unterschied zwischen "das Archiv ist heil" und "daraus + # wird wieder ein Dienst". + Schritt 'Drei zufaellige Dateien DURCH den Dienst holen und vergleichen' + $holen = @" +cd /app +node -e " +const db = require('better-sqlite3')('/data/media.db', { readonly: true }); +const { createHash } = require('node:crypto'); +const zeilen = db.prepare('SELECT path, sha256, size FROM media ORDER BY RANDOM() LIMIT 3').all(); +(async () => { + let schlecht = 0; + for (const z of zeilen) { + const r = await fetch('http://127.0.0.1:8080/f/' + z.path.split('/').map(encodeURIComponent).join('/')); + if (!r.ok) { console.log('FEHLT ' + z.path + ' HTTP ' + r.status); schlecht++; continue; } + const buf = Buffer.from(await r.arrayBuffer()); + const hash = createHash('sha256').update(buf).digest('hex'); + if (hash === z.sha256) console.log('ok ' + z.path + ' ' + buf.length + ' B'); + else { console.log('ANDERS ' + z.path + ' erwartet ' + z.sha256.slice(0,12) + ', bekommen ' + hash.slice(0,12)); schlecht++; } + } + process.exit(schlecht === 0 ? 0 : 1); +})(); +" +"@ -replace "`r`n", "`n" + $bytesOk = $true + docker exec $pContainer sh -c $holen | ForEach-Object { + if ($_ -match '^ok') { Gut $_ } else { Schlecht $_; $script:bytesOk = $false } + } + if ($LASTEXITCODE -ne 0) { $bytesOk = $false } + + Write-Host '' + if ($passt -and $bytesOk) { + Write-Host ' Die Uebung ist bestanden.' -ForegroundColor Green + Write-Host ' Aus dieser Sicherung wird wieder ein laufender Dienst,' -ForegroundColor Green + Write-Host ' und er liefert dieselben Bytes aus.' -ForegroundColor Green + Write-Host '' + Grau 'Der echte Dienst wurde dabei nicht angefasst.' + Grau 'Fuer den Ernstfall: pwsh -File tools\zurueckspielen.ps1 -Echt' + } else { + Write-Host ' DIE UEBUNG IST NICHT BESTANDEN.' -ForegroundColor Red + Write-Host ' Diese Sicherung wuerde im Ernstfall nicht reichen.' -ForegroundColor Red + exit 1 + } + } finally { + # Aufraeumen IMMER -- auch nach einem Abbruch. Ein liegengebliebenes + # Uebungs-Volume mit 300 MB findet sonst niemand wieder. + Schritt 'Aufraeumen' + docker rm -f $pContainer 2>&1 | Out-Null + docker volume rm $pVolume 2>&1 | Out-Null + Gut 'Wegwerf-Volume und Probe-Container entfernt' + } + Write-Host '' + exit 0 +} + +# ======================================================================= ERNST + +Write-Host '' +Write-Host ' ACHTUNG: das ist der Ernstfall.' -ForegroundColor Yellow +Write-Host '' +Write-Host " Der Container $Container wird ANGEHALTEN." +Write-Host " Im Volume $Volume werden media.db, files/ und papierkorb/" +Write-Host ' durch den Inhalt des Archivs ERSETZT. Alles, was seit' +Write-Host " $((Get-Item $Archiv).LastWriteTime) dazugekommen ist, ist danach weg." +Write-Host '' +Write-Host ' thumbs/ und webp/ werden geloescht. Beides ist abgeleitet und' +Write-Host ' wird mit zwei Knoepfen unter Speicher -> Wartung neu gerechnet --' +Write-Host ' stehenzulassen waere schlimmer: dann zeigte die Galerie die' +Write-Host ' Vorschau einer Datei, die dort nicht mehr liegt.' +Write-Host '' +Write-Host ' Hast du die Uebung laufen lassen? (ohne -Echt)' -ForegroundColor Yellow +Write-Host '' +$antwort = Read-Host ' Zum Fortfahren "ZURUECKSPIELEN" eintippen' +if ($antwort -ne 'ZURUECKSPIELEN') { + Write-Host ' Abgebrochen. Es wurde nichts angefasst.' -ForegroundColor Green + exit 0 +} + +Schritt "Container $Container anhalten" +docker stop $Container | Out-Null +Gut 'steht' + +try { + Schritt 'Alten Bestand entfernen und Archiv einspielen' + # In EINEM Container-Aufruf: zwischen Loeschen und Einspielen darf nichts + # liegen, in dem jemand den Dienst wieder startet. + docker run --rm -v "${Volume}:/data" -v "${archivOrdner}:/in:ro" --entrypoint sh $Abbild ` + -c "cd /data && rm -rf files media.db papierkorb thumbs webp && tar xzf '/in/$archivDatei' && chown -R node:node /data" + if ($LASTEXITCODE -ne 0) { throw 'Einspielen fehlgeschlagen' } + Gut 'eingespielt' +} catch { + Schlecht $_.Exception.Message + Schlecht "Der Container ist ANGEHALTEN. Starte ihn mit: docker start $Container" + exit 1 +} + +Schritt "Container $Container starten" +docker start $Container | Out-Null + +$lebt = $false +foreach ($i in 1..40) { + Start-Sleep -Milliseconds 700 + $a = docker exec $Container node -e "fetch('http://127.0.0.1:8080/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))" 2>&1 + if ($LASTEXITCODE -eq 0) { $lebt = $true; break } +} +if (-not $lebt) { + Schlecht 'Der Dienst kam nicht hoch. Protokoll:' + docker logs $Container --tail 30 2>&1 | ForEach-Object { Grau $_ } + exit 1 +} +Gut 'laeuft und antwortet auf /health' + +Schritt 'Nachzaehlen' +$roh = docker exec $Container sh -c $pruefen +$z = $roh | ConvertFrom-Json +$aufPlatte = [int](docker exec $Container sh -c "find /data/files -type f | wc -l").Trim() +Gut "$($z.medien) Medieneintraege, $aufPlatte Dateien, $($z.tokens) Token, $($z.benutzer) Benutzer" +if ($aufPlatte -ne $z.medien) { + Schlecht "Eintraege und Dateien passen nicht zusammen ($($z.medien) gegen $aufPlatte)." + Schlecht 'Dashboard -> Speicher -> "Verwaiste suchen" sagt, welche.' +} + +Write-Host '' +Write-Host ' Zurueckgespielt.' -ForegroundColor Green +Write-Host '' +Write-Host ' NOCH ZU TUN -- Abgeleitetes ist nicht im Archiv:' -ForegroundColor Yellow +Write-Host ' Dashboard -> Speicher -> Wartung' +Write-Host ' "Fehlende Vorschaubilder erzeugen"' +Write-Host ' "Sparsame Fassungen erzeugen"' +Write-Host ''