# Sicherung von d4rk_media: Datenbank und Bilder in EIN Archiv. # # Aufruf: # pwsh -File tools\sichern.ps1 # pwsh -File tools\sichern.ps1 -Ziel D:\woanders -Behalten 30 # # Was passiert, und warum in dieser Reihenfolge: # # 1. Die Datenbank wird IM laufenden Container ueber SQLites eigene # Sicherungsschnittstelle als eine in sich stimmige Datei abgelegt # (dist/backup.js). Ein blosses Kopieren waere wegen des WAL still # unvollstaendig. # 2. Ein zweiter, kurzlebiger Container packt Bilder und diese Aufnahme in # ein tar.gz. Er haengt das Volume NUR LESEND ein — eine Sicherung, die # schreiben kann, kann auch kaputtmachen. # 3. Das Archiv wird GEPRUEFT: ausgepackt, die Datenbank geoeffnet, die # Eintraege gezaehlt und mit dem laufenden Dienst verglichen. Eine # Sicherung, die niemand nachzaehlt, ist eine Vermutung. # 4. Das Archiv geht in die NEXTCLOUD, wenn im Panel Zugangsdaten stehen # (Einstellungen → Sicherung). Bis dahin lag die Sicherung auf derselben # Platte wie die Daten — gegen einen Plattenausfall half sie gar nicht. # 4b. Und der Lauf MELDET SICH ZURUECK: das Ergebnis landet in der Datenbank # und steht danach im Panel. Eine Sicherung, die still aufhoert zu laufen, # ist der eigentliche Schaden — nicht die eine Nacht, in der sie fehlt. # 5. Alte Archive fliegen raus, hier wie dort, sobald mehr als -Behalten da # sind. # # WARUM CURL UND NICHT RCLONE. Nextcloud spricht WebDAV, curl spricht WebDAV, # und curl ist ohnehin da. rclone waere ein weiteres Programm, das installiert, # konfiguriert und aktuell gehalten werden will — fuer PUT, PROPFIND und DELETE # lohnt das nicht. # # NICHT MIT DRIN sind die Vorschaubilder unter /data/thumbs, und das ist # Absicht: sie lassen sich aus den Bildern jederzeit neu rechnen (Dashboard → # Speicher → "Fehlende Vorschaubilder erzeugen"). Sie mitzusichern hiesse, jede # Nacht Abgeleitetes mitzuschleppen. Wer zurueckspielt, drueckt den Knopf. # # Der Dienst laeuft die ganze Zeit weiter. Nichts hier haelt ihn an. [CmdletBinding()] param( [string]$Ziel = 'C:\backup\d4rk_media', [string]$Container = 'd4rk-media', [string]$Volume = 'd4rk_media_media-data', [string]$Abbild = 'd4rk-media:latest', [int]$Behalten = 14 ) $ErrorActionPreference = 'Stop' function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan } function Gut($text) { Write-Host " $text" -ForegroundColor Green } function Schlecht($text) { Write-Host " $text" -ForegroundColor Red } # Das Ergebnis in die Datenbank des Dienstes schreiben, damit es im Panel # steht. Schlaegt DAS fehl, ist es kein Grund, die Sicherung als gescheitert zu # behandeln — das Archiv liegt dann trotzdem richtig. $inDerWolke = $false function Melde($ok, $dateien, $groesse, $hoch, $fehler) { $eintrag = @{ at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) ok = [bool]$ok dateien = $dateien groesse = $groesse hoch = [bool]$hoch } if ($fehler) { $eintrag['fehler'] = "$fehler" } $json = ($eintrag | ConvertTo-Json -Compress) # Ueber die Standardeingabe und nicht als Argument: der JSON-Text enthaelt # Anfuehrungszeichen, und die ueberleben den Weg durch zwei Shells nicht. $skript = @" let roh = ''; process.stdin.on('data', (d) => (roh += d)).on('end', () => { const db = require('better-sqlite3')('/data/media.db'); db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at') .run('sicherung.letzte', roh.trim(), Date.now()); }); "@ -replace "`r", '' $json | docker exec -i $Container node -e $skript 2>&1 | Out-Null } # -- Vorbedingungen ------------------------------------------------------------ $laeuft = docker ps --filter "name=^/$Container$" --format '{{.Names}}' if ($laeuft -ne $Container) { Schlecht "Der Container '$Container' laeuft nicht. Ohne ihn gibt es keine stimmige Aufnahme der Datenbank." exit 1 } New-Item -ItemType Directory -Force -Path $Ziel | Out-Null $stempel = Get-Date -Format 'yyyy-MM-dd-HHmm' $name = "d4rk_media-$stempel.tar.gz" $archiv = Join-Path $Ziel $name # -- 1. Datenbank stimmig herausholen ------------------------------------------ Schritt 'Datenbank sichern (SQLite-Sicherungsschnittstelle im Container)' $ausgabe = docker exec $Container node dist/backup.js /data/.sicherung/media.db 2>&1 if ($LASTEXITCODE -ne 0) { Schlecht "Fehlgeschlagen: $ausgabe" exit 1 } Gut $ausgabe # Die erwartete Zeilenzahl steht in der Meldung -- sie ist der Massstab fuer # Schritt 3. $erwartet = if ($ausgabe -match '(\d+) Medieneintraege') { [int]$Matches[1] } else { -1 } # -- 2. Alles in ein Archiv ---------------------------------------------------- Schritt 'Bilder und Datenbank packen' # Zwei -C: die Bilder kommen als files/... in das Archiv, die Aufnahme als # media.db daneben. Bewusst das eigene Abbild statt alpine -- dort ist GNU tar # drin, und das kann mehrere -C. docker run --rm ` -v "${Volume}:/data:ro" ` -v "${Ziel}:/out" ` --entrypoint sh $Abbild -c "tar czf '/out/$name' -C /data files -C /data/.sicherung media.db" if ($LASTEXITCODE -ne 0) { Schlecht 'Packen fehlgeschlagen.'; exit 1 } # Die Zwischenaufnahme im Volume wieder weg: sie gehoert nicht zum Bestand und # wuerde bei der naechsten Sicherung mitgezaehlt. docker exec $Container rm -rf /data/.sicherung | Out-Null $groesse = (Get-Item $archiv).Length Gut "$name — $([math]::Round($groesse/1MB, 1)) MB" # -- 3. Nachzaehlen ------------------------------------------------------------ Schritt 'Sicherung pruefen (auspacken, Datenbank oeffnen, zaehlen)' # Die Wagenruecklaeufe muessen raus. Diese Datei liegt unter Windows und wird # je nach Werkzeug mit CRLF gespeichert; die Shell im Container liest dann # "set -e\r" und antwortet mit "Illegal option -". Der Fehler sieht aus wie ein # Tippfehler im Skript und ist keiner — genau einmal reingefallen. $skript = (@" set -e mkdir -p /tmp/p && cd /tmp/p tar xzf '/in/$name' # Aus /app heraus starten und nicht aus /tmp/p: Node sucht node_modules vom # Arbeitsverzeichnis aufwaerts, und ueber /tmp liegt kein better-sqlite3. cd /app node -e " const db = require('better-sqlite3')('/tmp/p/media.db', { readonly: true, fileMustExist: true }); const m = db.prepare('SELECT COUNT(*) AS n FROM media').get().n; const t = db.prepare('SELECT COUNT(*) AS n FROM tokens').get().n; const u = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; console.log(JSON.stringify({ media: m, tokens: t, users: u })); " find /tmp/p/files -type f | wc -l "@) -replace "`r", '' $pruefung = docker run --rm -v "${Ziel}:/in:ro" --entrypoint sh $Abbild -c $skript if ($LASTEXITCODE -ne 0) { Schlecht "Pruefung fehlgeschlagen: $pruefung"; exit 1 } $zeilen = @($pruefung -split "`n" | Where-Object { $_.Trim() }) $zahlen = $zeilen[0] | ConvertFrom-Json $dateien = [int]$zeilen[1].Trim() Gut "in der Sicherung: $($zahlen.media) Medieneintraege, $dateien Dateien, $($zahlen.tokens) Token, $($zahlen.users) Benutzer" if ($erwartet -ge 0 -and $zahlen.media -ne $erwartet) { Schlecht "PASST NICHT: erwartet waren $erwartet Eintraege." exit 1 } # Eine Datei mehr oder weniger als Eintraege ist ein echter Befund und kein # Rundungsfehler: dann liegt eine Datei ohne Datensatz herum oder umgekehrt. if ($dateien -ne $zahlen.media) { Write-Host " Hinweis: $dateien Dateien zu $($zahlen.media) Eintraegen — das sollte gleich sein." -ForegroundColor Yellow } Gut 'Datenbank in der Sicherung ist lesbar und vollstaendig.' # -- 4. In die Nextcloud ------------------------------------------------------- # # Die Zugangsdaten liegen NEBEN den Sicherungen und nicht im Repo: sie gehoeren # zu dieser Maschine, nicht zum Code. Fehlt die Datei, wird das einmal gesagt # und lokal weitergesichert — eine Sicherung, die wegen eines fehlenden # Passworts GAR NICHT stattfindet, waere der schlechtere Tausch. # # Aufbau der Datei (eine Zuweisung je Zeile, # ist ein Kommentar): # # basis = https://cdn.d4rkst3r.de # benutzer = darkster # passwort = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx <- APP-PASSWORT, nicht das echte # ordner = Sicherungen/d4rk_media # # Das App-Passwort steht in Nextcloud unter Einstellungen → Sicherheit → # "Neues App-Passwort erstellen". Es laesst sich einzeln zurueckziehen, ohne # dass das eigene Passwort geaendert werden muss — genau dafuer gibt es sie. # Die Werte kommen aus der Datenbank des Dienstes, eingetragen im Panel unter # Einstellungen → Sicherung. Gelesen wird DIREKT ueber `docker exec` und nicht # ueber die API: so verlaesst das Passwort den Rechner nie. # # nextcloud.txt neben den Sicherungen gilt weiterhin und hat VORRANG — wer sie # angelegt hat, soll nicht suchen muessen, warum sie ploetzlich ignoriert wird. $z = @{} $zugangDatei = Join-Path $Ziel 'nextcloud.txt' if (Test-Path $zugangDatei) { foreach ($zeile in Get-Content $zugangDatei) { if ($zeile -match '^\s*#') { continue } if ($zeile -match '^\s*([a-z]+)\s*=\s*(.+?)\s*$') { $z[$Matches[1]] = $Matches[2] } } Gut "Zugang aus $zugangDatei" } else { $roh = docker exec $Container node -e @" const db = require('better-sqlite3')('/data/media.db', { readonly: true }); const hol = (k) => { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(k); return r ? r.value : ''; }; console.log(JSON.stringify({ basis: hol('sicherung.basis'), benutzer: hol('sicherung.benutzer'), passwort: hol('sicherung.passwort'), ordner: hol('sicherung.ordner'), behalten: hol('sicherung.behalten'), })); "@ if ($LASTEXITCODE -eq 0 -and $roh) { $j = $roh | ConvertFrom-Json foreach ($k in 'basis', 'benutzer', 'passwort', 'ordner', 'behalten') { if ($j.$k) { $z[$k] = $j.$k } } } } if ($z['behalten']) { $Behalten = [int]$z['behalten'] } if (-not ($z['basis'] -and $z['benutzer'] -and $z['passwort'])) { Write-Host ' Keine Nextcloud eingerichtet (Panel → Einstellungen → Sicherung) — die Sicherung bleibt lokal.' -ForegroundColor Yellow } else { if (-not $z['ordner']) { $z['ordner'] = 'Sicherungen/d4rk_media' } $basis = $z['basis'].TrimEnd('/') $wurzel = "$basis/remote.php/dav/files/$($z['benutzer'])" $auth = "$($z['benutzer']):$($z['passwort'])" Schritt "In die Nextcloud: $($z['ordner'])" # Die Ordner Ebene fuer Ebene anlegen. MKCOL legt NUR EINE an; auf einen # fehlenden Zwischenschritt antwortet es mit 409, und dann sucht man den # Fehler beim Passwort. $pfadBisher = '' foreach ($teil in $z['ordner'].Split('/')) { if (-not $teil) { continue } $pfadBisher = if ($pfadBisher) { "$pfadBisher/$teil" } else { $teil } # 201 = angelegt, 405 = gab es schon. Beides ist in Ordnung. $code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X MKCOL "$wurzel/$pfadBisher" if ($code -notin @('201', '405')) { $fehlertext = "MKCOL $pfadBisher -> HTTP $code" if ($code -eq '401') { $fehlertext += ' (Benutzer oder App-Passwort stimmt nicht)' } Schlecht $fehlertext Melde $false $zahlen.media $groesse $false $fehlertext exit 1 } } $fern = "$wurzel/$($z['ordner'])/$name" $code = curl.exe -s -o NUL -w '%{http_code}' -u $auth -T $archiv $fern if ($code -notin @('201', '204')) { Schlecht "Hochladen fehlgeschlagen -> HTTP $code" Melde $false $zahlen.media $groesse $false "Hochladen -> HTTP $code" exit 1 } Gut "hochgeladen ($code)" # NACHZAEHLEN, auch hier: zurueckholen und die Pruefsumme vergleichen. Ein # PUT, der 201 sagt, hat noch nichts bewiesen — abgeschnittene # Uebertragungen sehen genauso aus. Bei ein paar Megabyte ist das billig. $zurueck = Join-Path $env:TEMP "pruefe-$name" curl.exe -s -u $auth -o $zurueck $fern $hierHash = (Get-FileHash $archiv -Algorithm SHA256).Hash $dortHash = if (Test-Path $zurueck) { (Get-FileHash $zurueck -Algorithm SHA256).Hash } else { '' } Remove-Item $zurueck -Force -ErrorAction SilentlyContinue if ($hierHash -ne $dortHash) { Schlecht 'Die Kopie in der Nextcloud ist NICHT identisch.' Melde $false $zahlen.media $groesse $false 'Pruefsumme der Kopie weicht ab' exit 1 } Gut "zurueckgeholt und verglichen: identisch ($($hierHash.Substring(0,12))…)" $inDerWolke = $true # Auch draussen ausduennen. Sonst waechst die Nextcloud still voll, und das # faellt erst auf, wenn sie voll ist. $liste = curl.exe -s -u $auth -X PROPFIND -H 'Depth: 1' "$wurzel/$($z['ordner'])" $fernListe = @([regex]::Matches($liste, 'd4rk_media-[\d-]+\.tar\.gz') | ForEach-Object { $_.Value } | Sort-Object -Unique -Descending) if ($fernListe.Count -gt $Behalten) { foreach ($alt in $fernListe | Select-Object -Skip $Behalten) { $c = curl.exe -s -o NUL -w '%{http_code}' -u $auth -X DELETE "$wurzel/$($z['ordner'])/$alt" if ($c -in @('204', '200')) { Gut "in der Nextcloud entfernt: $alt" } else { Write-Host " konnte $alt nicht entfernen (HTTP $c)" -ForegroundColor Yellow } } } } # -- 5. Alte Sicherungen ausduennen (lokal) ------------------------------------ $alle = Get-ChildItem $Ziel -Filter 'd4rk_media-*.tar.gz' | Sort-Object Name -Descending if ($alle.Count -gt $Behalten) { Schritt "Alte Sicherungen entfernen (mehr als $Behalten)" $alle | Select-Object -Skip $Behalten | ForEach-Object { Remove-Item $_.FullName -Force Gut "entfernt: $($_.Name)" } } Melde $true $zahlen.media $groesse $inDerWolke $null Write-Host '' Gut "Fertig: $archiv" Write-Host " Zurueckspielen: tar xzf , dann media.db und files/ ins Volume legen," -ForegroundColor DarkGray Write-Host " waehrend der Container steht. Siehe ROADMAP." -ForegroundColor DarkGray