Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
12 KiB
Backup Restore Orchestrator
Automatisiertes tägliches Backup-Restore-Testsystem auf Basis von Windmill.
Jeden Tag um 00:11 Uhr werden alle PBS-Backups auf mehreren Restore-Servern wiederhergestellt, auf Bootfähigkeit geprüft, als verschlüsselte 7z-Archive gespeichert und auf einen zentralen Backup-Server übertragen. Ergebnisse werden in einer MySQL-Datenbank gespeichert und per Nextcloud Talk gemeldet.
Repository-Struktur
├── f/Backup/ ← Windmill Workspace (sync-fähig)
│ ├── backup_restore_orchestrator__flow/ ← Hauptflow (Orchestrator)
│ │ ├── flow.yaml
│ │ ├── aktive_datastores_aus_db_holen.my.sql ─ Step F
│ │ ├── job_initialisieren_&_backup-queue_...py ─ Step A
│ │ ├── alle_freien_restore-server_holen.py ─ Step B
│ │ ├── ssh_key_versuch.py ─ Step I ← SSH-Key Auth
│ │ ├── ssh-credentials_fuer_alle_...py ─ Step G ← Bitwarden Fallback
│ │ ├── script_deployen_&_pbs-datastores_...py ─ Step C
│ │ ├── alte_restore-ordner_...py ─ Step H
│ │ ├── ersten_restore_pro_server_starten.py ─ Step D
│ │ ├── webhook_verarbeiten_&_...py ─ Step E
│ │ └── flow_fehler_handler.py ─ failure_module
│ ├── backup_restore_report___nextcloud_talk__flow/ ← Täglicher Report (08:00 Uhr)
│ ├── folder.meta.yaml
│ ├── nextcloud_talk_room.variable.yaml
│ ├── nextcloud_talk_url.variable.yaml
│ └── restore_version.variable.yaml
└── restore-worker/
└── restore.sh ← Restore-Script (auf Restore-Servern)
Windmill Sync
# Einmalig: Windmill CLI installieren
npm install -g windmill-cli
# Workspace konfigurieren
wmill workspace add <workspace-id> https://windmill.stines.de
# Aus Repo in Windmill einspielen
wmill sync push --workspace <workspace-id>
# Aus Windmill ins Repo ziehen
wmill sync pull --workspace <workspace-id>
Hinweis: Secrets (
skipSecrets: true) werden nicht synchronisiert. Variablen mit sensiblen Werten müssen nach dem Push manuell in Windmill gesetzt werden.
Systemarchitektur
Windmill (Schedule 00:11 Uhr)
│
├─► PBS backup.stines.de:8007 ← Backup-Quelle
│
├─► STI-PROX01 (max 200 GB) ── restore.sh ──► 7z ──► Rsync ──► Backup-Server
├─► ITD-PROX01 (max 100 GB) ── restore.sh ──► 7z ──► Rsync ──► Backup-Server
└─► STI-BAC01 (min 250 GB) ── restore.sh ──► 7z (lokal, kein Rsync)
│
▼
Webhook → Windmill Step E
→ nächstes Backup starten
Restore-Server
| Hostname | max_backup_size_gb | min_backup_size_gb | Rsync |
|---|---|---|---|
| STI-PROX01 | 200 | NULL | ja |
| ITD-PROX01 | 100 | NULL | ja |
| STI-BAC01 | NULL | 250 | nein (lokal gemountet) |
Flow-Ablauf: F → A → B → I → G → C → H → D → E
| Step | ID | Sprache | Funktion |
|---|---|---|---|
| F | f |
MySQL | Aktive Datastores aus DB holen |
| A | a |
Python | Job anlegen, PBS-Snapshots holen, Queue aufbauen (größte zuerst) |
| B | b |
Python | Freie Restore-Server holen |
| I | i |
Python | SSH-Key aus DB testen (primäre Auth-Methode) |
| G | g |
Python | SSH-Credentials aus Bitwarden (Fallback für Server ohne Key) |
| C | c |
Python | Script deployen, PBS-Storages registrieren, Session speichern |
| H | h |
Python | Alte Backup-Ordner auf Backup-Server löschen |
| D | d |
Python | Ersten Restore pro Server starten |
| E | e |
Python | Webhook verarbeiten, nächsten Restore starten |
| — | failure |
Python | Error Handler: Talk-Nachricht + DB-Cleanup bei jedem Flow-Fehler |
SSH-Auth Logik (Step I → G)
Step I liest SSH-Keys aus bronze.restore.server.ssh_private_key und testet die Verbindung:
- Key OK → Server in
server_credseingetragen, Bitwarden wird übersprungen - Key fehlt oder schlägt fehl → Server in
needs_bitwardenListe → Step G holt Credentials aus Bitwarden
Step G ist ein No-Op wenn alle Server per Key authentifiziert wurden.
SSH-Key für Server eintragen
UPDATE Kunden.`bronze.restore.server`
SET ssh_private_key = '-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----',
ssh_key_user = 'root'
WHERE hostname = 'STI-PROX01';
Zwei Modi
Schedule-Pfad (täglich 00:11):
Steps F → A → B → I → G → C → H → D laufen sequenziell. Step D startet den ersten Restore pro Server per SSH non-blocking.
Webhook-Pfad (nach jedem restore.sh):
Flow-Input enthält job_uuid → Step A erkennt Webhook-Aufruf. Steps B–H werden übersprungen. Step E verarbeitet das Ergebnis und startet sofort das nächste Backup auf demselben Server.
Error Handler (failure_module)
Bei jedem Flow-Fehler wird automatisch:
- Eine Nextcloud Talk Nachricht gesendet:
🚨 Backup Restore Flow Fehler Step: `b` Fehler: Kein freier Restore-Server verfuegbar! - Der laufende DB-Job auf
failedgesetzt - Alle blockierten Restore-Server freigegeben (
current_job_uuid = NULL) - Session-Einträge des Jobs gelöscht
restore.sh — Ablauf
Das Script läuft auf den Restore-Servern unter /opt/windmill-restore/restore.sh.
Gestartet von Step D und Step E via SSH + nohup (non-blocking).
[0] 7z-Passwort vom PBS holen (password_7z.txt via Rsync)
[1] Space-Check: free_space >= backup_size * 1.5
[2] IDs ermitteln: Original aus Backup-Pfad, Restore-ID ab 1000
[2.5] ZIP-bereits-vorhanden-Check → bei Treffer: success Webhook + exit
[3] qmrestore (VM) / pct restore (CT)
[4] IMAGE_DIR dynamisch aus PVE-Storage-Pfad ermitteln
[5] Images prüfen (leer → failed)
[6] Vorbereiten: Netzwerkkarten entfernen, qemu-Agent aktivieren
[7] VM/CT starten, Bootfähigkeit prüfen (Agent ping 120s / pct exec)
[8] VM: qm shutdown --timeout 120, CT: pct stop
[9] Config sichern (qemu-server.conf / lxc.conf)
[10] Verschlüsseltes 7z-Archiv erstellen (mx=0, mhe=on)
[11] Rsync zum Backup-Server (3 Versuche + Größenprüfung)
ODER lokal speichern (STI-BAC01: SKIP_RSYNC=1)
[12] VM/CT destroy, ZIP löschen (außer STI-BAC01)
[13] Webhook → Windmill Step E
Script-Deployment
Das Script wird von Step C automatisch auf alle Restore-Server deployed:
restore.shin diesem Repo (Ordnerrestore-worker/) aktualisieren- In Gitea pushen:
http://172.17.1.251:8080/sebastian.serfling/BackupScript.git - Windmill-Variable
f/Backup/restore_versionerhöhen (z.B.1.0.27) - Nächster Flow-Lauf: Step C erkennt Versionsunterschied → deployed automatisch
Windmill-Variablen
| Variable | Inhalt |
|---|---|
f/Backup/pbs_variable |
JSON: host, port, user, password, fingerprint |
f/Backup/mysql_config |
JSON: MySQL-Verbindungsdaten |
f/Backup/bitwarden_api_login |
JSON: bw_clientid, bw_clientsecret, bw_masterpassword |
f/Backup/gitea_token |
Gitea Access Token |
f/Backup/restore_version |
Aktuelle Script-Version, z.B. 1.0.26 |
f/Backup/backup_server_host |
Hostname/IP Backup-Server |
f/Backup/backup_server_ssh_password |
SSH-Passwort Backup-Server |
f/Backup/windmill_webhook_url |
Webhook-URL für restore.sh Callbacks |
f/Backup/windmill_webhook_token |
Bearer Token |
f/Backup/nextcloud_talk_url |
https://nextcloud.stines.de |
f/Backup/nextcloud_talk_room |
Room-Token |
f/Backup/nextcloud_talk_user |
Benutzername |
f/Backup/nextcloud_talk_password |
App-Passwort |
Datenbank-Schema (MySQL: Kunden)
bronze.restore.jobs
| Spalte | Typ | Beschreibung |
|---|---|---|
| job_uuid | VARCHAR(64) PK | Eindeutige Job-ID |
| started_at | DATETIME | Startzeitpunkt |
| finished_at | DATETIME | Endzeitpunkt |
| status | VARCHAR(20) | running / completed / failed |
| total_backups | INT | Anzahl Backups in Queue |
| restored_count | INT | Erfolgreich abgeschlossen |
| failed_count | INT | Fehlgeschlagen |
bronze.restore.result
| Spalte | Typ | Beschreibung |
|---|---|---|
| job_uuid | VARCHAR(64) | Referenz auf Job |
| client_name | VARCHAR(128) | z.B. tnp-Invest-GmbH:vm/100 |
| backup_path | VARCHAR(256) | Vollpfad mit Timestamp |
| vm_name | VARCHAR(128) | Hostname der VM/CT |
| restore_server | VARCHAR(128) | Hostname des Restore-Servers |
| status | VARCHAR(20) | restoring / done / failed |
| restore_duration_sec | INT | Dauer Restore in Sekunden |
| zip_size_bytes | BIGINT | Größe des 7z-Archivs |
| rsync_ok | TINYINT | Rsync erfolgreich |
| qm_agent_ok | TINYINT | Boot-Check erfolgreich |
| error_message | TEXT | Fehlermeldung falls failed |
bronze.backup.queue
| Spalte | Typ | Beschreibung |
|---|---|---|
| job_uuid | VARCHAR(64) | Referenz auf Job |
| client_name | VARCHAR(128) | Backup-Bezeichnung |
| backup_path | VARCHAR(256) | Vollpfad mit Timestamp |
| backup_size_bytes | BIGINT | Komprimierte PBS-Größe |
| priority | INT | 0 = größtes (höchste Prio) |
| rsync_target | VARCHAR(256) | Zielpfad auf Backup-Server |
| pbs_storage_id | VARCHAR(128) | z.B. pbs-firma-gmbh |
| status | VARCHAR(20) | queued / assigned / done / failed / obsolete |
bronze.restore.server
| Spalte | Typ | Beschreibung |
|---|---|---|
| hostname | VARCHAR(128) PK | Server-Hostname |
| ip | VARCHAR(45) | IP-Adresse |
| is_active | TINYINT | 1 = aktiv |
| free_space_gb | INT | Freier Speicher (wird aktualisiert) |
| restore_mount | VARCHAR(128) | z.B. /mnt/BTRFS |
| restore_path | VARCHAR(128) | PVE-Storage-Name |
| current_job_uuid | VARCHAR(64) | NULL = frei |
| max_backup_size_gb | INT | NULL = kein Limit |
| min_backup_size_gb | INT | NULL = kein Limit |
| script_deployed | TINYINT | Script vorhanden |
| script_version | VARCHAR(20) | Aktuelle Script-Version |
| ssh_private_key | TEXT | SSH Private Key (RSA/Ed25519/ECDSA) |
| ssh_key_user | VARCHAR(64) | SSH-User für Key-Auth (default: root) |
bronze.restore.session
| Spalte | Typ | Beschreibung |
|---|---|---|
| job_uuid | VARCHAR(64) | Referenz auf Job |
| hostname | VARCHAR(128) | Server-Hostname |
| ip | VARCHAR(45) | IP-Adresse |
| ssh_user | VARCHAR(64) | SSH-Benutzername |
| ssh_password | VARCHAR(256) | SSH-Passwort (leer bei Key-Auth) |
| ssh_private_key | TEXT | SSH Private Key (leer bei Passwort-Auth) |
Temporäre Tabelle — wird am Job-Ende oder bei Fehler automatisch bereinigt.
Neue Spalten anlegen
ALTER TABLE Kunden.`bronze.restore.server`
ADD COLUMN ssh_private_key TEXT,
ADD COLUMN ssh_key_user VARCHAR(64) DEFAULT 'root';
ALTER TABLE Kunden.`bronze.restore.session`
ADD COLUMN ssh_private_key TEXT;
SQL-Reset bei Problemen
-- Kompletter Reset für neuen Testlauf
UPDATE Kunden.`bronze.restore.jobs` SET status='failed', finished_at=NOW() WHERE status='running';
UPDATE Kunden.`bronze.restore.server` SET current_job_uuid=NULL;
DELETE FROM Kunden.`bronze.restore.session`;
UPDATE Kunden.`bronze.backup.queue` SET status='obsolete' WHERE status IN ('queued','assigned');
-- Einzelnen Server freigeben
UPDATE Kunden.`bronze.restore.server` SET current_job_uuid=NULL WHERE hostname='ITD-PROX01';
Bekannte Probleme
| Problem | Ursache | Fix |
|---|---|---|
| Falsches Datum in Logs | Server auf UTC statt CET | timedatectl set-timezone Europe/Berlin |
/var/tmp voll (ITD-PROX01) |
Proxmox schreibt tmp auf Root-Partition | mount --bind /mnt/BTRFS/tmp /var/tmp |
| Server bleibt nach letztem Backup belegt | current_job_uuid nicht zurückgesetzt |
UPDATE bronze.restore.server SET current_job_uuid=NULL WHERE hostname=... |