# Backup Restore Orchestrator Automatisiertes tägliches Backup-Restore-Testsystem auf Basis von [Windmill](https://windmill.dev). 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 ```bash # Einmalig: Windmill CLI installieren npm install -g windmill-cli # Workspace konfigurieren wmill workspace add https://windmill.stines.de # Aus Repo in Windmill einspielen wmill sync push --workspace # Aus Windmill ins Repo ziehen wmill sync pull --workspace ``` > **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_creds` eingetragen, Bitwarden wird übersprungen - **Key fehlt oder schlägt fehl** → Server in `needs_bitwarden` Liste → 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 ```sql 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: 1. Eine Nextcloud Talk Nachricht gesendet: ``` 🚨 Backup Restore Flow Fehler Step: `b` Fehler: Kein freier Restore-Server verfuegbar! ``` 2. Der laufende DB-Job auf `failed` gesetzt 3. Alle blockierten Restore-Server freigegeben (`current_job_uuid = NULL`) 4. 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: 1. `restore.sh` in diesem Repo (Ordner `restore-worker/`) aktualisieren 2. In Gitea pushen: `http://172.17.1.251:8080/sebastian.serfling/BackupScript.git` 3. Windmill-Variable `f/Backup/restore_version` erhöhen (z.B. `1.0.27`) 4. 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 ```sql 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 ```sql -- 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=...` |