# 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' # WAS BEIM ZURUECKSPIELEN WEG MUSS -- an EINER Stelle, weil die Uebung genau # denselben Befehl fahren soll wie der Ernstfall. Zwei Fassungen waeren zwei, # von denen die geuebte die harmlosere ist. # # media.db-wal UND media.db-shm STEHEN HIER MIT ABSICHT, und das ist teuer # gelernt: SQLite laeuft in diesem Dienst im WAL-Modus, neben der Datenbank # liegen also immer zwei Begleitdateien. Die Sicherung enthaelt sie NICHT -- # sie braucht sie auch nicht, `db.backup()` schreibt eine fertig # zusammengefuehrte Datei. # # Wer beim Zurueckspielen nur media.db ersetzt, laesst damit das WAL der ALTEN # Datenbank neben der NEUEN liegen. SQLite spielt es beim Oeffnen ein, und das # Ergebnis ist das Schlimmste, was hier passieren kann: # # media.db aus dem Archiv, mit altem WAL daneben : 0 Zeilen # dieselbe Datei ohne die beiden Begleiter : 4452 Zeilen # Dateien auf der Platte : 4452 # # Der Dienst kaeme HOCH, /health waere gruen, und die Mediathek waere leer -- # waehrend alle Dateien danebenliegen. Nachgemessen am 12.08.2026, gefunden # beim Ueben und nicht im Ernstfall. Genau dafuer gibt es die Uebung. # # .tmp ebenfalls: writeFileAtomic legt Zwischendateien an, und ein Absturz # mitten im Schreiben laesst eine liegen. $script:AUFRAEUMEN = 'rm -rf files media.db media.db-wal media.db-shm papierkorb thumbs webp *.tmp' 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' docker volume create $pVolume | Out-Null # ------------------------------------------------------------------ # ERST EINE BESTEHENDE INSTALLATION VORTAEUSCHEN, und das ist der # Kern der Uebung: im Ernstfall wird ueber etwas zurueckgespielt, das # schon da ist -- mit einer laufenden Datenbank, mit media.db-wal # daneben, mit Vorschauen. In ein LEERES Volume zu spielen probt genau # den Fall, der nie eintritt. # # Dass das ein Unterschied ist, ist nicht theoretisch: die erste # Fassung dieses Skripts liess media.db-wal stehen, und der Dienst kam # danach mit einer LEEREN Mediathek hoch, waehrend alle Dateien # danebenlagen. Aufgefallen ist es nur, weil ein Uebungslauf zufaellig # auf ein gebrauchtes Volume traf. Schritt 'Eine bestehende Installation vortaeuschen (damit die Uebung eine ist)' docker run -d --name "$pContainer-alt" ` -e PUBLIC_URL=http://probe.invalid -e ADMIN_PASSWORD=nur-fuer-die-uebung ` -v "${pVolume}:/data" $Abbild | Out-Null Start-Sleep -Seconds 4 docker stop "$pContainer-alt" 2>&1 | Out-Null docker rm "$pContainer-alt" 2>&1 | Out-Null $begleiter = docker run --rm -v "${pVolume}:/data" --entrypoint sh $Abbild ` -c "ls /data | tr '\n' ' '" Gut "im Volume liegt jetzt: $($begleiter.Trim())" Schritt 'Zurueckspielen -- mit DEMSELBEN Befehl wie der Ernstfall' docker run --rm -v "${pVolume}:/data" -v "${archivOrdner}:/in:ro" ` --entrypoint sh $Abbild -c "cd /data && $script:AUFRAEUMEN && tar xzf '/in/$archivDatei' && chown -R node:node /data" if ($LASTEXITCODE -ne 0) { throw 'Auspacken fehlgeschlagen' } Gut 'eingespielt' 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-alt" 2>&1 | Out-Null 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 && $script:AUFRAEUMEN && 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 ''