Bis hierhin war sie GEPRUEFT, nicht ERPROBT. sichern.ps1 packt sein Archiv aus,
oeffnet die Datenbank, zaehlt, laedt hoch, holt zurueck und vergleicht die
Pruefsumme. Das beantwortet "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 thumbs/
und webp/ abgeleitet sind und neu gerechnet werden wollen.
Ein Backup, das man nie zurueckgespielt hat, ist kein Backup, sondern eine
Datei.
DIE UEBUNG IST DER STANDARD, und sie ist der eigentliche Gewinn: Wegwerf-
Volume anlegen, Archiv hineinspielen, einen ZWEITEN Container davorhaengen
(ohne veroeffentlichten Port -- der Uebungslauf soll sich mit nichts anlegen,
was schon laeuft), auf /health warten, nachzaehlen, und dann der Teil, auf den
es ankommt: drei zufaellige Dateien DURCH den Dienst holen und ihr SHA-256
gegen den vergleichen, der in der Datenbank steht. Danach alles abraeumen, im
finally -- ein liegengebliebenes Uebungs-Volume mit 300 MB findet sonst niemand
wieder.
Gelaufen, nicht behauptet:
4452 Medieneintraege, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
4452 Dateien -- gleich viele wie Eintraege
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 echte Dienst lief die ganze Zeit weiter und war danach unveraendert
(health 200, dasselbe Volume, 4542 Dateien).
DER ERNSTFALL verlangt ein getipptes ZURUECKSPIELEN und sagt vorher, was
passiert. Er loescht thumbs/ und webp/ mit: Abgeleitetes stehenzulassen waere
schlimmer als es neu zu rechnen -- die Galerie zeigte sonst die Vorschau einer
Datei, die dort nicht mehr liegt. Loeschen und Einspielen laufen in EINEM
Container-Aufruf, damit dazwischen niemand den Dienst wieder startet. Geht
etwas schief, sagt das Skript, dass der Container steht, und wie man ihn
startet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
287 lines
13 KiB
PowerShell
287 lines
13 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.
|
|
|
|
[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 ''
|