backup: die Sicherung verlaesst das Volume -- tools/abholen.ps1 plus Wachhund
Befund 1 aus docs/befunde-2026-08-12.md, der wichtigste: Datenbank und alle
vierzehn Sicherungen lagen im selben Volume. Geht das Volume verloren, geht
beides zusammen verloren -- das ist keine Sicherung, das ist eine Kopie.
tools/abholen.ps1 SICHERT NICHT. Das Archiv baut der Bot selbst; hier wird nur
abgeholt, und zwar so, dass am Ende feststeht, dass die Kopie heil ist und
nicht nur, dass ein Kopierbefehl keinen Fehler geworfen hat:
1. neuestes Archiv im Container finden (und meckern, wenn es aelter als
24 Stunden ist -- ein Skript, das treu das Archiv von vorletzter Woche
abholt und "fertig" sagt, ist schlimmer als eines, das gar nicht laeuft)
2. docker cp heraus
3. Pruefsumme IM CONTAINER gegen die Kopie hier -- nicht die Dateigroesse:
eine abgebrochene Kopie auf eine volle Platte hat oft genau die richtige
Laenge und trotzdem Nullen am Ende
4. auspacken und die Datenbank darin oeffnen, mit dem Bot-Abbild, weil dort
better-sqlite3 schon liegt; integrity_check plus Tabellen zaehlen
5. Bericht als last_zweitziel zurueck in die Einstellungen
6. ausduennen
Und es warnt, wenn es DIESELBE PLATTE ist. Verglichen wird die physische Platte
und nicht der Laufwerksbuchstabe: zwei Partitionen derselben NVMe sterben
zusammen.
Echter Lauf, kein Trockentest:
d4rkbot-2026-08-12.db.gz 993.332 Bytes
andere Platte: Nr. 1 statt Nr. 0
identisch (ff08516d293d...)
in der Sicherung: 55 Tabellen, 86 Einstellungen
DAZU EIN ZWEITER WACHHUND, pruefeZweitziel(). Ein Skript auf dem Wirt, das
still aufhoert zu laufen -- Aufgabe deaktiviert, Laufwerk weg, Pfad geaendert --
waere derselbe Schaden noch einmal, nur eine Ebene weiter aussen. Er meldet nur,
wenn die Abholung schon einmal lief: fehlt der Eintrag ganz, ist sie nicht
eingerichtet, und daraus taeglich eine Meldung zu machen waere Naergelei.
Gegen eine Kopie der Datenbank durchgespielt:
40 h alt -> "alt:17863965"
nochmal -> unveraendert (keine Wiederholung)
Fehlschlag -> "fehler:1786540570974"
wieder gut -> "" (Entwarnung)
Nebenbei gelernt, zum zweiten Mal in diesem Projekt: die Kopie der Datenbank
ohne -wal war leer an der Stelle, die zaehlte -- last_zweitziel stand noch im
Write-Ahead-Log. Dieselbe Falle wie beim Zurueckspielen von d4rk_media.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# Die Sicherung des Bots aus dem Volume HOLEN und auf eine zweite Platte legen.
|
||||
#
|
||||
# Aufruf:
|
||||
# pwsh -File tools\abholen.ps1
|
||||
# pwsh -File tools\abholen.ps1 -Ziel E:\woanders -Behalten 30
|
||||
#
|
||||
# WOZU, wo der Bot doch schon sichert. Er sichert in DASSELBE VOLUME, in dem
|
||||
# auch die Datenbank liegt (`/app/data/ecobot.db` und `/app/data/backups/`).
|
||||
# Geht das Volume verloren, geht beides zusammen verloren -- das ist keine
|
||||
# Sicherung, das ist eine Kopie. Dieses Skript ist der Weg hinaus.
|
||||
#
|
||||
# WAS ES NICHT TUT: sichern. Das Archiv baut der Bot selbst, mit SQLites
|
||||
# Online-Schnittstelle und seit dem 12.08.2026 mit eigener Gegenpruefung. Hier
|
||||
# wird nur ABGEHOLT -- und zwar so, dass am Ende feststeht, dass die Kopie heil
|
||||
# ist und nicht nur, dass ein Kopierbefehl keinen Fehler geworfen hat.
|
||||
#
|
||||
# Die Reihenfolge und warum sie so ist:
|
||||
#
|
||||
# 1. Das neueste Archiv im Container finden.
|
||||
# 2. Herausholen. `docker cp` und kein zweiter Container: der Bot laeuft
|
||||
# ohnehin, und ein Volume-Mount waere ein Umweg um eine offene Tuer.
|
||||
# 3. Pruefsumme vergleichen -- IM CONTAINER gerechnet gegen die Kopie hier.
|
||||
# Nicht die Dateigroesse: eine abgebrochene Kopie auf eine volle Platte hat
|
||||
# oft genau die richtige Laenge und trotzdem Nullen am Ende.
|
||||
# 4. Das Archiv AUSPACKEN und die Datenbank darin oeffnen -- mit dem
|
||||
# Bot-Abbild, weil dort better-sqlite3 schon liegt. Ein Archiv, das
|
||||
# niemand geoeffnet hat, ist eine Vermutung.
|
||||
# 5. Melden: `last_zweitziel` in die Einstellungen des Bots. Sein Wachhund
|
||||
# liest das und beschwert sich, wenn die Abholung ausbleibt -- sonst waere
|
||||
# dieses Skript das naechste, das still aufhoert zu laufen.
|
||||
# 6. Alte Kopien ausduennen.
|
||||
#
|
||||
# UND ES WARNT, WENN ES DIESELBE PLATTE IST. Ein zweiter Ordner auf C: sieht
|
||||
# genauso gruen aus wie eine echte zweite Platte und hilft gegen gar nichts;
|
||||
# zwei Partitionen derselben NVMe sterben zusammen. Verglichen wird deshalb die
|
||||
# PHYSISCHE Platte und nicht der Laufwerksbuchstabe.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Ziel = 'D:\backup\d4rkbot',
|
||||
[string]$Container = 'd4rkbot',
|
||||
[string]$Abbild = 'd4rkbot:latest',
|
||||
[int]$Behalten = 14
|
||||
)
|
||||
|
||||
$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 }
|
||||
|
||||
# Das Ergebnis zurueck in die Datenbank des Bots. Schlaegt DAS fehl, ist es kein
|
||||
# Grund, die Abholung als gescheitert zu behandeln -- die Kopie liegt dann
|
||||
# trotzdem richtig.
|
||||
function Melde($ok, $name, $bytes, $fehler) {
|
||||
$eintrag = @{
|
||||
at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
|
||||
ok = [bool]$ok
|
||||
datei = "$name"
|
||||
bytes = [long]$bytes
|
||||
ziel = "$Ziel"
|
||||
}
|
||||
if ($fehler) { $eintrag['fehler'] = "$fehler" }
|
||||
|
||||
# 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('/app/node_modules/better-sqlite3')('/app/data/ecobot.db');
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.run('last_zweitziel', roh.trim());
|
||||
});
|
||||
"@ -replace "`r", ''
|
||||
($eintrag | ConvertTo-Json -Compress) | 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."
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
|
||||
|
||||
# -- 1. Das neueste Archiv finden ----------------------------------------------
|
||||
|
||||
Schritt 'Das neueste Archiv im Container suchen'
|
||||
|
||||
# `ls -1` und danach das letzte: die Namen tragen das Datum in ISO-Form, sind
|
||||
# also alphabetisch schon in der richtigen Reihenfolge.
|
||||
$name = (docker exec $Container sh -c 'ls -1 /app/data/backups/*.db.gz 2>/dev/null | tail -1') -join ''
|
||||
$name = $name.Trim()
|
||||
if (-not $name) {
|
||||
Schlecht 'Im Container liegt kein Archiv. Hat der Bot schon einmal gesichert?'
|
||||
Melde $false '' 0 'kein Archiv im Container'
|
||||
exit 1
|
||||
}
|
||||
$kurz = Split-Path $name -Leaf
|
||||
Gut $kurz
|
||||
|
||||
# Ist es ueberhaupt von heute? Ein Skript, das treu das Archiv von vorletzter
|
||||
# Woche abholt und "fertig" sagt, ist schlimmer als eines, das gar nicht laeuft.
|
||||
$alter = (docker exec $Container sh -c "find '$name' -mtime +1 | wc -l").Trim()
|
||||
if ($alter -ne '0') {
|
||||
Write-Host ' ACHTUNG: das neueste Archiv ist aelter als 24 Stunden.' -ForegroundColor Yellow
|
||||
Write-Host ' Die Abholung laeuft trotzdem -- aber die Sicherung des Bots stockt.' -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# -- 2. Herausholen ------------------------------------------------------------
|
||||
|
||||
Schritt "Herausholen nach $Ziel"
|
||||
|
||||
$dortHin = Join-Path $Ziel $kurz
|
||||
docker cp "${Container}:$name" $dortHin
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Schlecht 'docker cp fehlgeschlagen.'
|
||||
Melde $false $kurz 0 'docker cp fehlgeschlagen'
|
||||
exit 1
|
||||
}
|
||||
$bytes = (Get-Item $dortHin).Length
|
||||
Gut ("{0:N0} Bytes" -f $bytes)
|
||||
|
||||
# Dieselbe Platte? Dann ist es keine Sicherung, sondern eine Kopie.
|
||||
function PlattenNummer($pfad) {
|
||||
# $null heisst "kein Urteil moeglich" (Netzfreigabe, ungewoehnliche
|
||||
# Einhaengung) und ist ausdruecklich nicht dasselbe wie "andere Platte".
|
||||
try {
|
||||
$wurzel = [System.IO.Path]::GetPathRoot((Resolve-Path $pfad).Path)
|
||||
$b = $wurzel.TrimEnd('\', ':')
|
||||
if ($b.Length -ne 1) { return $null }
|
||||
return (Get-Partition -DriveLetter $b -ErrorAction Stop).DiskNumber
|
||||
} catch { return $null }
|
||||
}
|
||||
# Die Daten des Bots liegen im Docker-Volume, und das liegt auf der Platte, auf
|
||||
# der Docker seine Volumes haelt -- unter Windows also im Systemlaufwerk.
|
||||
$hier = PlattenNummer $env:SystemDrive
|
||||
$dort = PlattenNummer $Ziel
|
||||
if ($null -ne $hier -and $hier -eq $dort) {
|
||||
Write-Host " ACHTUNG: $Ziel liegt auf DERSELBEN Platte (Nr. $hier) wie das Docker-Volume." -ForegroundColor Yellow
|
||||
Write-Host ' Gegen einen Plattenausfall hilft das nicht -- ein anderes Laufwerk waehlen.' -ForegroundColor Yellow
|
||||
} elseif ($null -ne $hier -and $null -ne $dort) {
|
||||
Gut "andere Platte: Nr. $dort statt Nr. $hier"
|
||||
}
|
||||
|
||||
# -- 3. Pruefsumme -------------------------------------------------------------
|
||||
|
||||
Schritt 'Pruefsumme vergleichen'
|
||||
|
||||
$drinnen = (docker exec $Container sh -c "sha256sum '$name' | cut -d' ' -f1").Trim()
|
||||
$draussen = (Get-FileHash $dortHin -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
if (-not $drinnen) {
|
||||
Schlecht 'Im Container liess sich keine Pruefsumme bilden.'
|
||||
Melde $false $kurz $bytes 'sha256sum im Container fehlgeschlagen'
|
||||
exit 1
|
||||
}
|
||||
if ($drinnen -ne $draussen) {
|
||||
Schlecht 'Die Kopie ist NICHT identisch.'
|
||||
Schlecht " im Container: $drinnen"
|
||||
Schlecht " hier: $draussen"
|
||||
Remove-Item $dortHin -Force -ErrorAction SilentlyContinue
|
||||
Melde $false $kurz $bytes 'Pruefsumme weicht ab'
|
||||
exit 1
|
||||
}
|
||||
Gut "identisch ($($drinnen.Substring(0,12))...)"
|
||||
|
||||
# -- 4. Auspacken und hineinsehen ----------------------------------------------
|
||||
#
|
||||
# Mit dem Bot-Abbild, weil dort better-sqlite3 schon liegt. Der Ordner haengt
|
||||
# NUR LESEND -- eine Pruefung, die schreiben kann, kann auch kaputtmachen.
|
||||
|
||||
Schritt 'Auspacken und die Datenbank darin oeffnen'
|
||||
|
||||
# Das Pruefskript geht als DATEI hinein und nicht als `node -e`-Argument. Ein
|
||||
# mehrzeiliges Skript durch PowerShell, docker und die Shell des Containers zu
|
||||
# reichen, ist eine Anfuehrungszeichen-Frage mit drei Beteiligten -- und die
|
||||
# verliert man.
|
||||
$werkbank = Join-Path $env:TEMP "d4rkbot-pruefer-$PID"
|
||||
New-Item -ItemType Directory -Force -Path $werkbank | Out-Null
|
||||
$pruefer = @'
|
||||
const { createReadStream, createWriteStream } = require('node:fs');
|
||||
const { pipeline } = require('node:stream/promises');
|
||||
const { createGunzip } = require('node:zlib');
|
||||
const Database = require('/app/node_modules/better-sqlite3');
|
||||
(async () => {
|
||||
await pipeline(createReadStream(process.argv[2]), createGunzip(), createWriteStream('/tmp/p.db'));
|
||||
const db = new Database('/tmp/p.db', { readonly: true });
|
||||
const urteil = db.pragma('integrity_check', { simple: true });
|
||||
if (urteil !== 'ok') { console.error('integrity_check: ' + urteil); process.exit(1); }
|
||||
const t = db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table'").get().n;
|
||||
const s = db.prepare('SELECT COUNT(*) AS n FROM settings').get().n;
|
||||
console.log(t + ' ' + s);
|
||||
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
'@ -replace "`r", ''
|
||||
# .cjs, weil das Bot-Abbild "type": "module" traegt -- eine .js waere dort ein
|
||||
# ES-Modul, und require() gaebe es darin nicht.
|
||||
Set-Content -Path (Join-Path $werkbank 'pruefer.cjs') -Value $pruefer -NoNewline -Encoding utf8
|
||||
|
||||
try {
|
||||
$ausgabe = docker run --rm `
|
||||
-v "${Ziel}:/x:ro" -v "${werkbank}:/s:ro" `
|
||||
--entrypoint node $Abbild /s/pruefer.cjs "/x/$kurz" 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
Remove-Item $werkbank -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($code -ne 0) {
|
||||
Schlecht "Das Archiv liess sich nicht oeffnen: $ausgabe"
|
||||
Remove-Item $dortHin -Force -ErrorAction SilentlyContinue
|
||||
Melde $false $kurz $bytes "Archiv unlesbar: $ausgabe"
|
||||
exit 1
|
||||
}
|
||||
$zahlen = ($ausgabe -join ' ').Trim().Split(' ')
|
||||
Gut "in der Sicherung: $($zahlen[0]) Tabellen, $($zahlen[1]) Einstellungen"
|
||||
|
||||
# -- 5. Melden -----------------------------------------------------------------
|
||||
|
||||
Melde $true $kurz $bytes $null
|
||||
|
||||
# -- 6. Ausduennen -------------------------------------------------------------
|
||||
|
||||
$alle = Get-ChildItem $Ziel -Filter 'd4rkbot-*.db.gz' | Sort-Object Name -Descending
|
||||
if ($alle.Count -gt $Behalten) {
|
||||
Schritt "Alte Kopien entfernen (mehr als $Behalten)"
|
||||
$alle | Select-Object -Skip $Behalten | ForEach-Object {
|
||||
Remove-Item $_.FullName -Force
|
||||
Gut "entfernt: $($_.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Gut "Fertig: $dortHin"
|
||||
# NACH dem Ausduennen neu zaehlen -- $alle ist der Stand von davor, und eine
|
||||
# Zahl, die das Ausduennen nicht mitbekommt, ist eine falsche.
|
||||
$jetzt = @(Get-ChildItem $Ziel -Filter 'd4rkbot-*.db.gz')
|
||||
Write-Host " $($jetzt.Count) Kopien in $Ziel" -ForegroundColor DarkGray
|
||||
Reference in New Issue
Block a user