Files
d4rk_media/tools/zurueckspielen.ps1
D4rkst3randClaude Opus 5 74e2128afb feat: die Sicherung liegt an drei Orten statt an zweien
Der letzte offene Punkt aus docs/ideen.md. Ein ZWEITES LAUFWERK, einstellbar im
Panel unter Einstellungen -> Sicherung; sichern.ps1 legt das Archiv dort ab,
vergleicht die Pruefsumme und duennt auch dort aus.

WARUM DAS NICHT DASSELBE IST WIE DIE NEXTCLOUD. Die hilft, wenn der ganze
Rechner weg ist -- aber wer 335 MB, spaeter 100 GB, zurueckholen muss, laedt sie
ueber die Leitung. Das zweite Laufwerk ist in Minuten zurueckgespielt. Zwei
Fragen, zwei Antworten, deshalb zwei Ziele.

UND ES WARNT, WENN ES DIESELBE PLATTE IST -- das ist der eigentliche Inhalt.
Ein zweiter Ordner auf C: sieht im Panel genauso gruen aus wie eine echte zweite
Platte und hilft gegen gar nichts. Verglichen wird die PHYSISCHE Platte und
nicht der Laufwerksbuchstabe: zwei Partitionen derselben NVMe sterben zusammen.
Nachgemessen in beide Richtungen:

    C:\backup gegen D:\backup  ->  Nr. 0 gegen Nr. 1  ->  "andere Platte"
    C:\backup gegen C:\Users   ->  Nr. 0 gegen Nr. 0  ->  Warnung

Auf diesem Rechner sind das zwei getrennte NVMe zu je 954 GB.

GEMESSEN AN EINEM ECHTEN LAUF, keinem Trockentest: 4630 Eintraege, 335,9 MB,
auf beiden lokalen Zielen und in der Nextcloud dieselbe Pruefsumme
FFCE4D0A7310... -- identisch.

Geprueft wird mit der Pruefsumme und nicht mit der Dateigroesse: eine
abgebrochene Kopie auf eine volle Platte hat oft genau die richtige Laenge und
trotzdem Nullen am Ende. Schlaegt das Kopieren oder der Vergleich fehl, ist der
ganze Lauf gescheitert (exit 1, ok:false) -- ein Ziel, das still ausfaellt, ist
genau das, wogegen das hier gebaut ist.

Der Bericht traegt jetzt zweit:true. Fehlt das Feld, ist KEIN zweites Ziel
eingerichtet -- es steht absichtlich nicht als false da, denn das laese sich wie
"hat nicht geklappt".

Die Statusseite zaehlt die Orte mit: "sicherung ok an 3 Orten" statt nur "ok".
Eine Sicherung, die es nur einmal gibt, ist gruen und trotzdem eine, die ein
Plattenausfall mitnimmt -- das gehoert dorthin, wo jemand hinsieht.

Der Pfad wird beim Speichern auf seine FORM geprueft (Laufwerksbuchstabe oder
UNC-Freigabe): der Dienst laeuft im Container und kann ihn nicht nachschlagen,
aber ein relativer Pfad ist mit Sicherheit ein Tippfehler -- und ein Tippfehler
in einem Sicherungsziel faellt sonst erst auf, wenn man die Sicherung braucht.
Nachgemessen: "backup/woauchimmer" -> 400 mit Text.

Und zurueckspielen.ps1 sagt im Kopf, was zu tippen ist, wenn genau diese Platte
das Problem ist. Das ist der Fall, fuer den das Ganze da ist, und niemand soll
ihn um vier Uhr nachts erst herleiten muessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:50:44 +02:00

351 lines
16 KiB
PowerShell

# 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.
# WENN GENAU DIESE PLATTE DAS PROBLEM IST: die Sicherung liegt seit dem zweiten
# Ziel auch woanders. Das ist der Fall, fuer den sie da ist, und niemand soll
# ihn um vier Uhr nachts erst herleiten muessen --
#
# pwsh -File tools\zurueckspielen.ps1 -Ordner D:\backup\d4rk_media -Echt
#
# und wenn auch die zweite Platte weg ist, holt man das Archiv aus der Nextcloud
# (Sicherungen/d4rk_media) und zeigt mit -Archiv direkt darauf. Welche Ziele
# eingerichtet sind, steht im Panel unter Einstellungen -> Sicherung.
[CmdletBinding()]
param(
# Ohne Angabe: das neueste Archiv im Sicherungsordner.
[string]$Archiv = '',
# Die ZWEITE Platte steht in den Einstellungen; ohne Angabe wird hier
# gesucht, weil das der Ordner ist, in den sichern.ps1 zuerst schreibt.
[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 ''