Prepare MariaDB restore tests and recover backup disk space safely

This commit is contained in:
admin committed 2026-09-12 20:36:18 +02:00
1 parent f0c761213d
commit b0416461bc
23 files changed
+918 -129

No files matched your search

+40 -14
View File
@@ -12,7 +12,7 @@ Stand des Reviews: 10. September 2026. Untersucht wurde der Gitea-Stand `099688e
| **Hoch:** Maildienste hatten keine zuverlässige Wiederanlaufbehandlung; Fehler und rsync-Teilergebnisse wurden ignoriert. | Unvollständige Mailsicherung oder unbeabsichtigte Dienstausfälle. | Zustandsabhängige Stopps, geprüfter Wiederanlauf, Exit-Code und Wiederanlaufdatei. | | **Hoch:** Maildienste hatten keine zuverlässige Wiederanlaufbehandlung; Fehler und rsync-Teilergebnisse wurden ignoriert. | Unvollständige Mailsicherung oder unbeabsichtigte Dienstausfälle. | Zustandsabhängige Stopps, geprüfter Wiederanlauf, Exit-Code und Wiederanlaufdatei. |
| **Hoch:** Crypt-Prüfung ließ u. a. `no_data_encryption=true` und reine Namensverschleierung zu. Restore verlangte dagegen explizite Optionen, obwohl sichere Defaults gültig sind. | Inhalt konnte trotz Remote-Typ `crypt` unverschlüsselt bleiben; gültige Remotes konnten abgelehnt werden. | Gemeinsame Prüfung von Typ, Inhalt, Dateinamen und Verzeichnisnamen; Defaults werden berücksichtigt. | | **Hoch:** Crypt-Prüfung ließ u. a. `no_data_encryption=true` und reine Namensverschleierung zu. Restore verlangte dagegen explizite Optionen, obwohl sichere Defaults gültig sind. | Inhalt konnte trotz Remote-Typ `crypt` unverschlüsselt bleiben; gültige Remotes konnten abgelehnt werden. | Gemeinsame Prüfung von Typ, Inhalt, Dateinamen und Verzeichnisnamen; Defaults werden berücksichtigt. |
| **Hoch:** `--checksum` und Remote-Listing ersetzten keine Prüfung des entschlüsselten Inhalts. | Transfererfolg war kein belastbarer Rücklesetest. | Vollständiger `rclone check --download` vor Veröffentlichung des Abschlussmarkers. | | **Hoch:** `--checksum` und Remote-Listing ersetzten keine Prüfung des entschlüsselten Inhalts. | Transfererfolg war kein belastbarer Rücklesetest. | Vollständiger `rclone check --download` vor Veröffentlichung des Abschlussmarkers. |
| **Hoch:** Retention lief vor dem neuen Backup und löschte remote einzelne alte Objekte rekursiv. | Alte Wiederherstellungspunkte konnten verloren gehen, bevor ein neuer verfügbar war; Sätze konnten unvollständig werden. | Retention ausschließlich nach einem neuen erfolgreichen Lauf, nur vollständige v2-Ordner, Mindestanzahl. | | **Hoch:** Retention lief vor dem neuen Backup und löschte remote einzelne alte Objekte rekursiv. | Alte Wiederherstellungspunkte konnten verloren gehen, bevor ein neuer verfügbar war; Sätze konnten unvollständig werden. | Reguläre Retention nach einem neuen erfolgreichen Lauf; zusätzliche lokale Platzbereinigung nur mit erneuter Offsite-Inhaltsprüfung und Mindestanzahl. |
| **Hoch:** Drei nicht als Vorlage gekennzeichnete DB-Dateien enthielten mögliche echte Zugangsdaten. | Geheimnisse im aktuellen Baum und in der Git-Historie. | Dateien entfernt, ausschließlich Vorlagen, `.gitignore`; Passwortrotation bleibt Betriebsaufgabe. Keine Historienumschreibung. | | **Hoch:** Drei nicht als Vorlage gekennzeichnete DB-Dateien enthielten mögliche echte Zugangsdaten. | Geheimnisse im aktuellen Baum und in der Git-Historie. | Dateien entfernt, ausschließlich Vorlagen, `.gitignore`; Passwortrotation bleibt Betriebsaufgabe. Keine Historienumschreibung. |
| **Hoch:** Gitea war im ausgelieferten Config-Beispiel nicht eingeschaltet; `APP_DATA_PATH` allein deckt externe Repositories/custom nicht ab. | Erwartete Gitea-Daten konnten fehlen. | Sichtbarer Aktivierungsschalter und zusätzliche Pfade; erforderliche Verzeichnisse fehlen nicht stillschweigend. | | **Hoch:** Gitea war im ausgelieferten Config-Beispiel nicht eingeschaltet; `APP_DATA_PATH` allein deckt externe Repositories/custom nicht ab. | Erwartete Gitea-Daten konnten fehlen. | Sichtbarer Aktivierungsschalter und zusätzliche Pfade; erforderliche Verzeichnisse fehlen nicht stillschweigend. |
| **Mittel:** Konfigurierbare WordPress-Excludes wurden nicht vollständig angewendet; Nextcloud `data/` war fest angenommen. | Falscher Umfang bei abweichender Verzeichnisstruktur. | Ableitung relativer Excludes aus den konfigurierten Pfaden, auch beim Restore. | | **Mittel:** Konfigurierbare WordPress-Excludes wurden nicht vollständig angewendet; Nextcloud `data/` war fest angenommen. | Falscher Umfang bei abweichender Verzeichnisstruktur. | Ableitung relativer Excludes aus den konfigurierten Pfaden, auch beim Restore. |
@@ -198,7 +198,7 @@ Quelle: [rclone 1.74.3 Inhaltsprüfung](https://github.com/rclone/rclone/blob/v1
| Ereignis | Verhalten und Konsequenz | | Ereignis | Verhalten und Konsequenz |
| --- | --- | | --- | --- |
| Vorübergehender Upload-Fehler | rclone wiederholt den Kopierauftrag bis zu `RCLONE_RETRIES=6` Versuche, soweit der Fehler wiederholbar ist; Wartezeit standardmäßig `RCLONE_RETRIES_SLEEP=30s`, zusätzlich gegebenenfalls Provider-Backoff. Kein erneuter DB-Dump oder Writer-Stopp innerhalb dieser Versuche. | | Vorübergehender Upload-Fehler | rclone wiederholt den Kopierauftrag bis zu `RCLONE_RETRIES=6` Versuche, soweit der Fehler wiederholbar ist; Wartezeit standardmäßig `RCLONE_RETRIES_SLEEP=30s`, zusätzlich gegebenenfalls Provider-Backoff. Kein erneuter DB-Dump oder Writer-Stopp innerhalb dieser Versuche. |
| Upload endgültig fehlgeschlagen | Fehlerexit; lokaler vollständiger Satz und Staging bleiben erhalten; kein neuer Remote-Abschlussmarker und keine Retention in diesem Lauf. | | Upload endgültig fehlgeschlagen | Fehlerexit; lokaler vollständiger Satz bleibt erhalten; Staging wird nach erfolgreichem Dienstwiederanlauf bereinigt. Kein neuer Remote-Abschlussmarker und keine nachgelagerte Altersretention. |
| Rückleseprüfung findet andere oder fehlende Inhalte | Fehlerexit; keine Veröffentlichung des Abschlussmarkers. Auch gleich große Dateien werden inhaltlich verglichen. | | Rückleseprüfung findet andere oder fehlende Inhalte | Fehlerexit; keine Veröffentlichung des Abschlussmarkers. Auch gleich große Dateien werden inhaltlich verglichen. |
| Abschlussmarker kann nicht geschrieben oder bestätigt werden | Fehlerexit; kein lokales `UPLOADED`, kein neuer Erfolgsstatus, keine Retention. Der Vergleich prüft die exakten Bytes einschließlich abschließender Zeilenumbrüche, passend zum strikten Restore-Parser. Ist der Marker schon geschrieben und nur seine Bestätigung fehlgeschlagen, kann der Remote-Satz vollständig vorliegen; der Lauf meldet konservativ trotzdem einen Fehler. | | Abschlussmarker kann nicht geschrieben oder bestätigt werden | Fehlerexit; kein lokales `UPLOADED`, kein neuer Erfolgsstatus, keine Retention. Der Vergleich prüft die exakten Bytes einschließlich abschließender Zeilenumbrüche, passend zum strikten Restore-Parser. Ist der Marker schon geschrieben und nur seine Bestätigung fehlgeschlagen, kann der Remote-Satz vollständig vorliegen; der Lauf meldet konservativ trotzdem einen Fehler. |
| Wiederholungen erfolgreich und Rücklesen bestanden | Remote-Marker wird zuletzt geschrieben und zurückgelesen; erst danach gilt der Upload als erfolgreich. Ein früheres `ERROR` im Log allein ist kein Endergebnis. | | Wiederholungen erfolgreich und Rücklesen bestanden | Remote-Marker wird zuletzt geschrieben und zurückgelesen; erst danach gilt der Upload als erfolgreich. Ein früheres `ERROR` im Log allein ist kein Endergebnis. |
@@ -318,19 +318,43 @@ Homes-Bestätigung; Details stehen in [UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md)
### Speicher, Retention und Protokolle ### Speicher, Retention und Protokolle
`MIN_FREE_GB` ist eine Reserveprüfung auf den Staging- und Archivdateisystemen, keine genaue Prognose. Die Kopie braucht Platz für unkomprimierte Quelldaten und Dumps; Archive benötigen zusätzlich komprimierten Platz. Beim Restore kommen die private Kopie des Sicherungssatzes und entpackte Daten hinzu. Eine Platte kann zwischen Prüfungen volllaufen; der Lauf muss dann scheitern. Ausreichend Reserve und ein externes Kapazitätsmonitoring einplanen. Der Ablauf und die Löschgrenzen sind in [SPEICHER.md](SPEICHER.md) beschrieben.
Vor echten Backups und der Update-Vorprüfung werden eindeutig erkannte alte
Arbeitsreste bereinigt und Quellen mit identischen rsync-Excludes sowie lesenden
DB-Größenabfragen inventarisiert. Die Platzschätzung enthält Reserven für Dumps,
Dateisystem-Metadaten und Wachstum. `MIN_FREE_GB` (Default 10 GiB) bleibt zusätzlich
frei. Erst nach erfolgreicher Platzbeschaffung werden Writer angehalten.
Neue lokale Sätze liegen zunächst unter `.partial-LAUFNAME`. Bei Fehlern bleiben Arbeitsdaten zur Analyse liegen. Sie sind **keine abgeschlossenen Backups** und werden nicht durch die Retention entfernt. Nach Ursachenklärung gezielt löschen; keine pauschale automatische Löschung gescheiterter Arbeitsstände. Reicht Scratch-Bereinigung nicht, werden ältere geeignete lokale Sätze unter
Wahrung von `RETENTION_KEEP_LAST` vollständig lokal und offsite zurückgelesen.
Nur danach darf ihre lokale Kopie entfernt werden. Offsite-Dateien bleiben
unverändert. Diese Platzbereinigung gilt auch bei `--no-retention` während einer
Update-Abnahme und kann das lokale Aufbewahrungsalter unterschreiten.
Retention: Jede erfolgreich komprimierte Komponente wird vor dem Entfernen ihrer
Arbeitskopie vollständig geprüft. Bei Fehlern werden Staging und unvollständige
Archive nach erfolgreicher Dienstwiederherstellung ebenfalls entfernt. Vollständige
lokale Sätze ohne Upload-Bestätigung, ungelöste Recovery-Zustände, manuelle Restores,
historische Formate und unbekannte Ordner bleiben geschützt. Ein erkanntes
altes Staging ohne Recovery-Journal kann auch aus vorherigen Paketständen
bereinigt werden. Die Bedarfsschätzung ist keine harte Quote; externe Schreiber
oder ungewöhnlich wachsende Dumps können weiterhin einen Abbruch verursachen.
Remote-`--verify-only` benötigt jeweils nur ein komprimiertes Archiv; die lokale
Variante prüft direkt ohne Kopie. Reguläre v2-Restores planen zusätzlichen Platz
vor Download/Kopie und Entpacken ein und nutzen dieselbe lokale Platzbereinigung.
Ein echter Restore benötigt weiterhin Platz für den vollständigen Arbeitsbestand;
Fehlerdaten werden dort für eine manuelle Prüfung erhalten.
Reguläre Altersretention:
- Läuft erst nach Abschluss des neuen Backups und gegebenenfalls der kompletten Remote-Prüfung. - Läuft erst nach Abschluss des neuen Backups und gegebenenfalls der kompletten Remote-Prüfung.
- Erkennt nur v2-Laufnamen und einen zum Manifest passenden Abschlussmarker. - Erkennt nur v2-Laufnamen und einen zum Manifest passenden Abschlussmarker.
- Löscht ganze Sätze, keine einzelnen Archivinhalte. - Löscht ganze Sätze, keine einzelnen Archivinhalte.
- Berücksichtigt das UTC-Datum im Laufnamen und behält die jüngsten `RETENTION_KEEP_LAST` geeigneten Sätze; mindestens zwei sind vorgeschrieben, Default drei. - Berücksichtigt das UTC-Datum im Laufnamen und behält die jüngsten `RETENTION_KEEP_LAST` geeigneten Sätze; mindestens zwei sind vorgeschrieben, Default drei.
- Lokale Sätze ohne `UPLOADED` werden bei aktiviertem Upload nicht gelöscht. Bei bewusst deaktiviertem Upload sind vollständige lokale Sätze löschbar. - Lokale Sätze ohne `UPLOADED` werden bei aktiviertem Upload nicht gelöscht. Bei bewusst deaktiviertem Upload sind vollständige lokale Sätze löschbar.
- Alte v1-Läufe, unbekannte Verzeichnisse, unvollständige Uploads und `.partial`-Verzeichnisse bleiben erhalten. - Alte v1-Läufe, unbekannte Verzeichnisse und unvollständige Uploads bleiben erhalten. Erkannte lokale Scratch-Reste werden durch die getrennte Platzbereinigung behandelt.
- Remote-Retention ist in neuen Konfigurationen aus. Ein vorhandenes `ENABLE_REMOTE_RETENTION=true` bleibt beim Update erhalten und muss bei Bedarf bewusst geändert werden. - Remote-Retention ist in neuen Konfigurationen aus. Das automatische Update setzt `ENABLE_REMOTE_RETENTION=false`; eine spätere Aktivierung erfolgt bewusst in der Konfiguration.
Retention prüft die Metadaten alter Sätze, nicht erneut deren gesamte Nutzdaten. Ein `COMPLETE`-Marker beweist den erfolgreichen Abschluss zum Erstellungszeitpunkt, keine ewige Verfügbarkeit. Regelmäßige unabhängige Rücklese-/Restore-Proben bleiben erforderlich. Remote-Metadaten, die sich nicht lesen/validieren lassen, werden konservativ ausgelassen; fehlgeschlagene eigentliche Löschungen führen zu einem fehlgeschlagenen Lauf. Retention prüft die Metadaten alter Sätze, nicht erneut deren gesamte Nutzdaten. Ein `COMPLETE`-Marker beweist den erfolgreichen Abschluss zum Erstellungszeitpunkt, keine ewige Verfügbarkeit. Regelmäßige unabhängige Rücklese-/Restore-Proben bleiben erforderlich. Remote-Metadaten, die sich nicht lesen/validieren lassen, werden konservativ ausgelassen; fehlgeschlagene eigentliche Löschungen führen zu einem fehlgeschlagenen Lauf.
@@ -512,10 +536,12 @@ Das behebt das gegenseitige Warten in den früheren Paketen: Ohne `--no-block`
wartete der Starter auf den oneshot-Abschluss, während der Worker auf seine wartete der Starter auf den oneshot-Abschluss, während der Worker auf seine
Sperre wartete. Ursache, Diagnose und Wiederanlauf beschreibt die Update-Anleitung. Sperre wartete. Ursache, Diagnose und Wiederanlauf beschreibt die Update-Anleitung.
Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keinerlei lokale oder entfernte Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keine reguläre Altersretention;
Löschung alter Sicherungssätze. Nach Upload/Rücklesevergleich prüft zusätzlich die sichere lokale Platzbereinigung bleibt erlaubt. Nach Upload/Rücklesevergleich
`app-restore.sh --remote-run ... --verify-only` den erneut heruntergeladenen Satz. prüft bei aktiviertem Monatstest `app-restore-test.sh --remote-run ...` den konkreten
Erst danach wird der vorhandene Backup-Timer aktiviert. Remote-Retention bleibt Satz vollständig mit Entpacken und isoliertem DB-Import. Andernfalls erfolgt eine
komponentenweise Archivprüfung mit `app-restore.sh --verify-only`. Erst nach Erfolg
werden die konfigurierten Timer aktiviert. Remote-Retention bleibt
nach dieser Migration bewusst aus, bis die fachliche Restore-Abnahme erfolgt ist. nach dieser Migration bewusst aus, bis die fachliche Restore-Abnahme erfolgt ist.
Direkt erkennbare Nextcloud-systemd-Writer werden übernommen. Bei klassischen Direkt erkennbare Nextcloud-systemd-Writer werden übernommen. Bei klassischen
@@ -562,11 +588,11 @@ geprüft und entpackt; komprimierte Eingaben werden vor DB-Imports entfernt.
Temporäre Dateien und DB-Container werden bei Erfolg, Fehler und Abbruch Temporäre Dateien und DB-Container werden bei Erfolg, Fehler und Abbruch
bereinigt; systemd und eine Boot-Einheit übernehmen die Nachbereinigung. bereinigt; systemd und eine Boot-Einheit übernehmen die Nachbereinigung.
Die Defaults von 4 GiB Arbeitsbudget und 2 GiB freier Reserve sind an den Standard sind ein adaptives Arbeitsbudget (`RESTORE_TEST_MAX_WORK_GB=0`) und
tatsächlichen Platz und die größte Komponente anzupassen. Die Überwachung ist 2 GiB freie Reserve. Eine positive Budgetgrenze bleibt optional möglich. Die Überwachung ist
keine harte Dateisystemquote. Ein nicht passender Sicherungssatz führt zu FAIL, keine harte Dateisystemquote. Ein nicht passender Sicherungssatz führt zu FAIL,
nicht zu einer unbemerkten Teilprüfung. Protokolle und Berichte bleiben klein; nicht zu einer unbemerkten Teilprüfung. Protokolle und Berichte bleiben klein;
ein vorab bereitgestelltes DB-Image bleibt als Abhängigkeit installiert. das vom Installer vorbereitete passende DB-Image bleibt als Abhängigkeit installiert.
Der Test ergänzt die bisherige technische Archivprüfung um tatsächliches Der Test ergänzt die bisherige technische Archivprüfung um tatsächliches
Entpacken und DB-Import mit Tabellenprüfung. Fachliche Anwendungswiederherstellung Entpacken und DB-Import mit Tabellenprüfung. Fachliche Anwendungswiederherstellung
+8 -5
View File
@@ -6,10 +6,13 @@ Die vollständige Analyse, Architektur, Konfiguration, Betriebsanleitung und ver
Neu: [Monatlicher Offsite-Restore-Test](RESTORE_TEST.md) mit vollständiger Prüfung Neu: [Monatlicher Offsite-Restore-Test](RESTORE_TEST.md) mit vollständiger Prüfung
aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern. aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern.
Für knappen Speicher wird jeweils nur eine Komponente verarbeitet. Das lokale Für knappen Speicher wird jeweils nur eine Komponente verarbeitet. Das Update
DB-Image und die Speichergrenzen einmal vorbereiten und `ENABLE_RESTORE_TEST=true` mit `--enable-restore-test` erkennt die Produktions-MariaDB, bereitet das passende
setzen; das Update-Paket aktiviert dann den monatlichen Timer und die lokale Docker-Image vor und prüft Initialisierung, Import und Bereinigung. Nach
Boot-Bereinigung. Ohne diese Einstellung bleibt der Test zunächst deaktiviert. Backup und vollständigem Offsite-Restore-Test aktiviert es den Monatstimer.
[Speicherbereinigung und Fehlerbehebung](SPEICHER.md) beschreibt die automatische
Entfernung erkannter Arbeitsreste und die geschützte lokale Notfallaufbewahrung.
Ohne Aktivierungsoption bleibt ein bisher deaktivierter Restore-Test deaktiviert.
## Produktionsstand und verbleibende Abnahme ## Produktionsstand und verbleibende Abnahme
@@ -88,7 +91,7 @@ Details und geprüfte Grenzen stehen in der Update-Anleitung.
- Vorübergehende systemd-Startsperren verhindern die erneute Aktivierung konfigurierter Writer während der Kopie. Zustands- und Zeitstempelprüfungen brechen bei erkannten Neustarts vor Veröffentlichung ab. Eigene Sperren werden beim Wiederanlauf entfernt; bestehende Maskierungen bleiben erhalten. - Vorübergehende systemd-Startsperren verhindern die erneute Aktivierung konfigurierter Writer während der Kopie. Zustands- und Zeitstempelprüfungen brechen bei erkannten Neustarts vor Veröffentlichung ab. Eigene Sperren werden beim Wiederanlauf entfernt; bestehende Maskierungen bleiben erhalten.
- Upload und Rückleseprüfung melden ihre Phase und standardmäßig jede Minute rclone-Statistiken im Journal (`RCLONE_STATS_INTERVAL`). Ein laufender Auftrag ist erst nach erfolgreicher Remote-Prüfung abgenommen. - Upload und Rückleseprüfung melden ihre Phase und standardmäßig jede Minute rclone-Statistiken im Journal (`RCLONE_STATS_INTERVAL`). Ein laufender Auftrag ist erst nach erfolgreicher Remote-Prüfung abgenommen.
- Vollständige Sicherungssätze mit SHA-256-Manifest, Archivprüfung und zuletzt hochgeladenem `COMPLETE`-Marker. - Vollständige Sicherungssätze mit SHA-256-Manifest, Archivprüfung und zuletzt hochgeladenem `COMPLETE`-Marker.
- Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Alte Sicherungen werden nicht automatisch gelöscht. - Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Historische v1-Sicherungen bleiben erhalten; bei Platzdruck gilt zusätzlich die abgesicherte lokale Bereinigung aus [SPEICHER.md](SPEICHER.md).
- Private Arbeitsverzeichnisse, gemeinsame Sperre für Backup/Restore/Update und Wiederanlaufhinweise nach Abbrüchen. - Private Arbeitsverzeichnisse, gemeinsame Sperre für Backup/Restore/Update und Wiederanlaufhinweise nach Abbrüchen.
- Installer erhält die Serverkonfiguration und den Zeitplan und erstellt ein Rollback-Paket. - Installer erhält die Serverkonfiguration und den Zeitplan und erstellt ein Rollback-Paket.
+64 -63
View File
@@ -25,11 +25,15 @@ Der Spitzenbedarf ist ungefähr das Maximum aus:
- SQL-Dump plus zugehöriger Testdatenbank einschließlich MariaDB-Systemtabellen, - SQL-Dump plus zugehöriger Testdatenbank einschließlich MariaDB-Systemtabellen,
Indexen und temporären DB-Dateien. Indexen und temporären DB-Dateien.
Standard: höchstens **4 GiB geplantes Test-Arbeitsvolumen**, zusätzlich mindestens Standard: **adaptives Arbeitsbudget** (`RESTORE_TEST_MAX_WORK_GB=0`) mit mindestens
**2 GiB freie Reserve**. Die Komponente muss vollständig in dieses Budget passen; **2 GiB freier Reserve**. Ein positiver Wert setzt zusätzlich eine feste Grenze.
sonst scheitert der Test mit einem Bericht und bereinigt seine Dateien. Es wird Die größte Komponente muss vollständig passen. Die frühere Vorgabe von 4 GiB
nicht still auf eine Zufallsdatei oder eine Teilprüfung ausgewichen. Das Budget reicht für Geminis rund 8,8 GB Nextcloud-Daten nicht; die Update-Option
lässt sich nach Prüfung mit `df -h /var/backups/app-backup` anpassen. `--enable-restore-test` setzt deshalb ausdrücklich das adaptive Budget.
Bei Platzdruck versucht der Test zwischen Arbeitsschritten die sichere lokale
Bereinigung aus [SPEICHER.md](SPEICHER.md). Schlägt sie fehl, wird der Test mit
Fehlerbericht beendet und sein Scratch bereinigt. Alle Komponenten werden geprüft;
ein fehlender Platz wird nicht durch eine Stichprobe kaschiert.
Vor Download und Entpacken wird der Bedarf geprüft. Während externer Prozesse Vor Download und Entpacken wird der Bedarf geprüft. Während externer Prozesse
wird freier Speicher laufend kontrolliert, die tatsächliche Belegung regelmäßig wird freier Speicher laufend kontrolliert, die tatsächliche Belegung regelmäßig
@@ -53,66 +57,60 @@ Die Bereinigung wird wiederholt; ein solcher Zustand darf nicht als Erfolg gelte
Bei einem ausgeschalteten Server erfolgt sie naturgemäß erst nach dem Neustart. Bei einem ausgeschalteten Server erfolgt sie naturgemäß erst nach dem Neustart.
Nur der eindeutig zu diesem Test gehörende Container und das markierte Nur der eindeutig zu diesem Test gehörende Container und das markierte
`$WORKDIR/restore-test/scratch` werden bereinigt. Vorhandene Backups, reguläre `$WORKDIR/restore-test/scratch` werden bereinigt. Reguläre manuelle Restore-Verzeichnisse und fremde Docker-Ressourcen bleiben
manuelle Restore-Verzeichnisse und fremde Docker-Ressourcen werden nicht gelöscht. erhalten. Die getrennte Platzbereinigung darf ältere lokale Sicherungskopien nur
nach erneuter vollständiger Offsite-Prüfung und unter Wahrung der Mindestanzahl entfernen.
Es gibt kein allgemeines `docker prune`. Das einmal vorbereitete DB-Image bleibt Es gibt kein allgemeines `docker prune`. Das einmal vorbereitete DB-Image bleibt
als wiederverwendbare Abhängigkeit erhalten; sein einmaliger Platzbedarf kommt als wiederverwendbare Abhängigkeit erhalten; sein einmaliger Platzbedarf kommt
zum Arbeitsbudget hinzu. Images werden im Test weder heruntergeladen noch gebaut. zum Arbeitsbudget hinzu. Images werden im Test weder heruntergeladen noch gebaut.
## Einmalige Vorbereitung auf Gemini ## Automatische Vorbereitung auf Gemini
Am 12.09.2026 lesend auf Gemini festgestellt: MariaDB **10.11.18**, Docker-CLI Docker Engine und CLI müssen lokal vorhanden sein. Gemini verwendet laut
vorhanden und rund **9,1 GiB frei auf `/var`**. Für diesen Stand passt die unten Bestandsaufnahme vom 12.09.2026 MariaDB 10.11.18 und besitzt Docker bereits.
genannte Serie `mariadb:10.11`. Die freie Kapazität ist eine Momentaufnahme; Der Installer liest `SELECT VERSION()` mit den vorhandenen Backup-Zugangsdaten.
vor Image-Download und Test nochmals prüfen. Alle zu prüfenden Datenbanken müssen dieselbe MariaDB-Major-/Minor-Serie verwenden.
Ohne gesetztes Image wählt er beispielsweise `mariadb:10.11`. Ein vorhandenes
Docker Engine und CLI müssen lokal vorhanden sein. Verwendet wird ausschließlich Image wird wiederverwendet, ein fehlendes einmalig mit Zeitlimit geladen. Ein
`unix:///var/run/docker.sock`. Zur produktiven MariaDB passende Major-/Minor-Version explizites Image wird erhalten, muss aber zur Produktionsserie passen.
ermitteln:
```bash ```bash
sudo mariadbd --no-defaults --version sha256sum -c app-backup-update-VERSION.run.sha256
sudo docker --host unix:///var/run/docker.sock info sudo bash app-backup-update-VERSION.run --enable-restore-test
``` ```
Beispiel **nur wenn die Produktion MariaDB 10.11.x verwendet**: Die Option aktiviert den Test und das adaptive Arbeitsbudget in der gesicherten
Kandidatenkonfiguration. Die freie Docker-Kapazität wird vor einem Download geprüft.
```bash Der Installer startet bei Bedarf den vorhandenen Docker-Dienst. Er initialisiert
sudo docker --host unix:///var/run/docker.sock pull mariadb:10.11 eine isolierte Test-MariaDB, importiert eine kleine Prüftabelle, führt
``` `mariadb-check` aus und entfernt Container und Daten wieder. Erst nach Erfolg
wird ein automatisch ermitteltes Image in die Konfiguration übernommen. Der
Bei einer anderen Produktionsversion den Tag entsprechend ersetzen. Platz für Probeimport verändert keine produktive Datenbank und setzt keinen erfolgreichen
Image und Testdaten vorab prüfen. Ein vorhandenes kompatibles Image kann monatlichen Sicherungsnachweis.
wiederverwendet werden; für reproduzierbare Tests kann statt des Tags ein Digest
konfiguriert werden. Bei einem Major-/Minor-Upgrade der Produktions-DB auch das Anschließend installiert der Update-Worker das Paket, erstellt ein Backup und
Testimage prüfen und aktualisieren. prüft **diesen konkreten Offsite-Satz vollständig inklusive DB-Import**. Erst nach
erfolgreicher Prüfung und Bereinigung aktiviert er Backup-Timer, Monatstimer und
Vor dem Start des neuen Update-Pakets `/etc/app-backup/app-backup.conf` bearbeiten: Boot-Bereinigung. Das Update-Ergebnis und der separate Restore-Bericht dokumentieren
die Abnahme. Die zeitaufwendige Worker-Phase läuft unabhängig von SSH; die
```bash vorgelagerte Vorbereitung sollte bis zur Übergabemeldung verbunden bleiben.
ENABLE_RESTORE_TEST="true"
RESTORE_TEST_DB_IMAGE="mariadb:10.11" # Beispiel: an Produktionsversion anpassen! Ohne `--enable-restore-test` erhält das Update den bisherigen Aktivierungszustand
RESTORE_TEST_MAX_WORK_GB=4 und ein ausdrücklich eingestelltes Arbeitsbudget. Ist der Test bereits aktiviert,
RESTORE_TEST_MIN_FREE_GB=2 erfolgen Image-Vorbereitung und vollständige Abnahme ebenfalls automatisch.
RESTORE_TEST_MAX_AGE_HOURS=72 Bei einem MariaDB-Serienwechsel ein bisher ausdrücklich eingetragenes Image
RESTORE_TEST_TIMEOUT=39600 # 11 Stunden, inklusive Remote-Arbeit anpassen oder leeren; ein inkompatibles Image wird vor Verwendung abgelehnt.
MAIL_HOSTNAME="gemini.jr-it-services.de"
``` Eine direkte Installation mit `install.sh` bereitet eine bereits aktivierte
Testkonfiguration ebenfalls vor. Für eine spätere Aktivierung ohne Paketupdate:
Danach das neue `.run`-Update wie in [UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md)
installieren. Der Starter prüft das lokale Image und die freie Reserve, bevor er
installiert. Er lädt kein Image nach. Nach der normalen Update-Abnahme aktiviert
er den Restore-Test-Timer und den Boot-Bereinigungsdienst, wenn
`ENABLE_RESTORE_TEST=true` gesetzt ist. Der Timer kann bei einem nachzuholenden
Termin sofort starten. Das Update-Ergebnis bestätigt weiterhin das Paket und sein
Abnahmebackup; der neue Restore-Test hat ein eigenes Ergebnis und eine eigene Mail.
Bestehende Konfigurationen ohne die neue Option bleiben kompatibel: Der monatliche
Test ist zunächst deaktiviert. Eine reine Installation mit `install.sh` installiert
die Units und erhält bestehende Zeitpläne; für die erstmalige Aktivierung dann:
```bash ```bash
sudoedit /etc/app-backup/app-backup.conf
# ENABLE_RESTORE_TEST=true; RESTORE_TEST_DB_IMAGE=""; RESTORE_TEST_MAX_WORK_GB=0
sudo bash /usr/local/sbin/app-restore-test.sh --cleanup
sudo bash /usr/local/lib/app-backup/prepare-restore-test.sh /etc/app-backup/app-backup.conf
sudo bash /usr/local/sbin/app-restore-test.sh --check sudo bash /usr/local/sbin/app-restore-test.sh --check
sudo systemctl start app-restore-test.service
sudo systemctl enable app-restore-test-cleanup.service sudo systemctl enable app-restore-test-cleanup.service
sudo systemctl enable --now app-restore-test.timer sudo systemctl enable --now app-restore-test.timer
``` ```
@@ -223,16 +221,19 @@ Technische Referenzen: [Docker run](https://docs.docker.com/reference/cli/docker
## Entwicklung und lokale Verifikation ## Entwicklung und lokale Verifikation
Der Standardlauf (`tests/run.sh` im bestehenden Testcontainer) prüfte am Am 12.09.2026 bestanden **100 unterschiedliche Tests**: 71 Backup-/Restore-/
12.09.2026 81 Fälle: 80 erfolgreich, der separate Docker-Test planmäßig Installer-Regressionstests, 23 Restore-Test-Fälle mit dem offiziellen lokalen
übersprungen. Zusätzlich bestanden alle 19 gezielten Restore-Tests mit lokalem MariaDB-10.11-Image und 6 gezielte Scratch-/Recovery-Schutzprüfungen. ShellCheck
Docker-Daemon und dem offiziellen `mariadb:10.11` (29,0 Sekunden). Vier gezielte und Python-Syntaxprüfung bestanden ebenfalls. Das Testverzeichnis war danach
Sperr-/Installer-Tests bestanden ebenfalls (23,7 Sekunden). Dazu gehören echte leer, und es blieben keine zugehörigen Testcontainer zurück.
Crypt-Downloads, vollständiges Entpacken, DB-Import/Tabellenprüfung, ungültiges SQL,
SIGTERM/SIGKILL, fehlender Platz, beschädigte Statusdateien und ein zeitweise nicht Die automatischen Tests decken echte Crypt-Downloads, Entpacken, DB-Import und
erreichbarer Docker-Daemon. Container-Isolation und vollständige Bereinigung Tabellenprüfung ab, außerdem ungültiges SQL, SIGTERM/SIGKILL, Platzmangel,
werden nachgeprüft. Die bereits vorhandene Entwicklungstest-DB mit MariaDB 11.8.6 beschädigte Statusdateien und einen zeitweise nicht erreichbaren Docker-Daemon.
wurde ebenfalls erfolgreich verwendet. Es werden keine echten Mails versendet. Die Installer-Vorbereitung wird zusätzlich mit dem offiziellen lokalen
`mariadb:10.11` geprüft, einschließlich automatischer Versionswahl und Ablehnung
einer falschen Produktionsserie. Scratch-, Container- und Konfigurationserhalt
werden nachgeprüft; Testmails bleiben lokal in der Testumgebung.
Auf einem separaten Entwicklungs-/Testrechner mit den Abhängigkeiten aus Auf einem separaten Entwicklungs-/Testrechner mit den Abhängigkeiten aus
`tests/Dockerfile`, Docker-CLI und lokal vorbereitetem Image lässt sich die `tests/Dockerfile`, Docker-CLI und lokal vorbereitetem Image lässt sich die
+131
View File
@@ -0,0 +1,131 @@
# Automatische Speicherbereinigung
Der auf Gemini gemeldete Abbruch vom 12.09.2026 erfolgte nach dem erfolgreichen
Wiederanlauf der Dienste, während der Kompression: Unter `/var` waren nur noch
9,2 GiB frei, die konfigurierte Reserve beträgt 10 GiB. Rund 29 GiB Staging und
91 GiB Archive belegten denselben Datenträger. Eine reine Reserveprüfung vor dem
Backup konnte den zusätzlichen Platzbedarf nicht erkennen.
## Neuer Ablauf
Backup, Restore und Update teilen sich dieselbe Sperre. Vor einem echten Backup
oder der Update-Vorbereitung werden folgende Schritte ausgeführt:
1. Einen unterbrochenen, eindeutig markierten monatlichen Restore-Test bereinigen.
Eine möglicherweise noch laufende Test-DB wird zuerst entfernt. Ist Docker
dafür nicht erreichbar, bleiben ihre Dateien geschützt; die Bereinigung meldet
einen Fehler und der Bereinigungsdienst versucht es erneut.
2. Erkannte Backup-Arbeitsreste früherer Läufe entfernen. Dazu müssen privates
Verzeichnis, Laufname, `meta/run-id.txt` und `meta/format.txt` zusammenpassen.
Das zugehörige unvollständige Archivverzeichnis wird ebenfalls entfernt.
Damit ist der im Gemini-Log gezeigte Fehlerfall auch aus älteren Paketständen
erkennbar. Unbekannte Ordner werden nicht anhand ihres Alters gelöscht.
3. Den Snapshot-Bedarf mit einem rsync-Trockenlauf unter denselben Excludes wie
beim Backup und einer lesenden DB-Größenabfrage schätzen. Die Planung enthält
Dateisystem-/Archiv-Metadaten, zusätzliche Dump-/Wachstumsreserven und bei
gemeinsamem Dateisystem Platz für Snapshot plus Archiv. Erst nach erfolgreicher
Platzprüfung werden produktive Writer angehalten.
4. Bei Platzmangel ältere geeignete lokale v2-Sätze prüfen. Nur Sätze mit gültigem
Manifest, `COMPLETE` und lokalem `UPLOADED` kommen infrage. Die neuesten
`RETENTION_KEEP_LAST` geeigneten Kopien bleiben geschützt (Default drei,
mindestens zwei). Ältere Kandidaten werden lokal vollständig validiert und mit
`rclone check --download` inklusive Manifest und `COMPLETE` erneut vollständig
gegen ihre Offsite-Kopie gelesen. Erst bei erfolgreicher Prüfung wird die lokale
Kopie entfernt. Ein nicht mehr lesbarer oder veränderter Remote-Satz bleibt
lokal erhalten. Es wird nur so lange bereinigt, bis der geplante Platz reicht.
Die Platzbereinigung benötigt keinen vorherigen neuen Sicherungslauf. Sie darf
das lokale Aufbewahrungsalter unterschreiten, **niemals die Mindestanzahl**.
Sie verändert keine Offsite-Daten. Die normale Altersretention nach einem neuen
erfolgreichen Backup bleibt davon getrennt. `--no-retention` im Update unterdrückt
die Altersretention, erlaubt aber weiterhin die hier beschriebene Platzbereinigung.
Nach dem Ende des Kopierfensters werden Dienste wie bisher wieder gestartet.
Während der Kompression wird jede Komponente vollständig als Archiv geprüft;
danach wird ihre unkomprimierte Arbeitskopie sofort entfernt. Die kleinen
Metadaten bleiben bis zum Abschluss für Veröffentlichung und Wiedererkennung.
Der Mailbericht erfasst die ursprünglichen Staging-Größen vor dieser Freigabe.
Bei einem fehlgeschlagenen Backup werden dessen Staging und unvollständige Archive
nach erfolgreicher Dienstwiederherstellung ebenfalls entfernt. Der Fehler bleibt
im Status und Bericht sichtbar. Ein vollständiger lokaler Satz, dessen Upload
fehlgeschlagen ist, bleibt erhalten.
## Grenzen der automatischen Löschung
Folgende Daten bleiben geschützt:
- vollständige lokale Sätze ohne bestätigten Upload;
- die neuesten `RETENTION_KEEP_LAST` geeigneten lokalen Sicherungen;
- ein `.partial`-Verzeichnis, das bereits einen `COMPLETE`-Marker enthält;
- sämtliche Arbeitsreste bei vorhandenem `recovery-*.json` oder angefangener
Recovery-Datei: zunächst muss der Dienst-/Restore-Zustand geklärt werden;
- manuell entpackte Restores, historische v1-Archive und unbekannte Verzeichnisse;
- Symlink-Ziele, eingehängte Dateisysteme, fremde Container, Images und Volumes.
Es gibt kein `docker system prune`, keine pauschale Löschung von `/var/backups`
und keine automatische Entfernung von Paketrollback-Sicherungen. Die entfernte
lokale Historie wird durch einen Paketrollback nicht wiederhergestellt; ihre
geprüften Offsite-Kopien stehen weiterhin zum Download bereit.
Scheitert die Platzbeschaffung an diesen Grenzen, beendet sich das Backup vor
dem Anhalten der Writer mit einer konkreten Platzmeldung. Bei Änderungen durch
andere Prozesse oder ungewöhnlich wachsenden Dumps können spätere Prüfungen
trotzdem scheitern. Die Schätzung ist keine Dateisystemquote. Fehler bei produktiver
Dienstwiederherstellung oder einem echten Restore erfordern weiterhin einen
Admin; sie werden nicht durch automatisches Löschen oder Neustarten verdeckt.
Ein bereits vollständig lokaler, aber noch nicht hochgeladener Satz wird nicht
automatisch nachträglich hochgeladen. Solche Sätze bleiben bei wiederholten
Remote-Ausfällen erhalten und können einen Eingriff nötig machen.
## Restore und MariaDB-Vorbereitung
Der monatliche Restore-Test verarbeitet alle Komponenten einzeln. Standardmäßig
begrenzt `RESTORE_TEST_MAX_WORK_GB=0` das Arbeitsvolumen anhand der tatsächlich
freien Kapazität bei mindestens `RESTORE_TEST_MIN_FREE_GB=2` GiB Reserve. Ein
positiver Wert setzt zusätzlich eine feste Obergrenze. Die Update-Option
`--enable-restore-test` aktiviert den Test und setzt ausdrücklich das adaptive
Budget; ohne diese Option bleibt eine bestehende feste Grenze erhalten.
Vor Download und Entpacken versucht der Test bei Platzmangel die oben beschriebene
lokale Bereinigung. Während des laufenden Imports werden Grenzen überwacht;
bei Unterschreitung wird abgebrochen und bereinigt. Dabei werden nicht nebenher
Sicherungen gelöscht. Vorbereitete MariaDB-Images bleiben zur Wiederverwendung
erhalten. Ein Image-Download prüft zusätzlich Platz auf dem Docker-Dateisystem.
`app-restore.sh --remote-run ... --verify-only` lädt jeweils nur ein komprimiertes
Archiv herunter, validiert es vollständig und entfernt es sofort. Lokale
`--verify-only`-Aufrufe prüfen den Satz direkt ohne Kopie. Bei Fehlern werden die
eigenen Prüftemporärdateien entfernt; neue abgebrochene Download-Prüfungen sind
durch einen Marker beim nächsten Lauf erkennbar. Manuelle `--extract-only`- und
produktive `--apply`-Restores benötigen weiterhin Platz für ihren vollständigen
Arbeitsbestand; zusätzliche Kopier- und Entpackgrößen werden vorab geprüft. Ihre Daten werden bei Fehlern zur Prüfung erhalten und nicht
automatisch als Testreste gelöscht. Eine ausgewählte lokale Restore-Quelle ist
von der Platzbereinigung ausgenommen.
## Vorgehen auf Gemini
Das neue Update-Paket wie in [UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md) mit
`--enable-restore-test` starten. Es bereinigt vor der Vorprüfung erkannte alte
Arbeitsreste, bereitet MariaDB vor und führt Backup und vollständigen Restore-Test
aus. Erst danach aktiviert es die Timer. Der alte fehlgeschlagene Update-Ordner
bleibt als Diagnose-/Rollback-Nachweis erhalten; ein pauschales `rm` ist unnötig.
Nach bereits erfolgter Installation kann die Platzvorbereitung separat gestartet
werden. Dieser Befehl **bereinigt tatsächlich** nach den obigen Regeln, erstellt
aber kein Backup und hält keine Anwendungen an:
```bash
sudo bash /usr/local/sbin/app-backup.sh --prepare-space
sudo bash /usr/local/sbin/app-backup.sh --check
df -h /var/backups/app-backup
```
`--check` allein bleibt eine Prüfung ohne Bereinigung. Vollständige
Offsite-Rückleseprüfungen während der Platzbeschaffung können dauern. Der Starter
nennt sein Verzeichnis mit `preflight.log`; der spätere Worker schreibt `update.log`
und `result.txt`. `SUCCESS` im aktuellen `result.txt` und `cleanup: OK` im
Restore-Teststatus sind die Abnahmenachweise. Scheitert ein Update trotz der
sicheren Bereinigung, bleiben die Timer nach begonnener Installation gestoppt;
der Fehler muss behoben und das Paket erneut ausgeführt werden.
+22 -14
View File
@@ -7,16 +7,21 @@ Wegen des knappen Speichers arbeitet er Komponenten einzeln ab und entfernt
jeweils Archiv, entpackte Daten und Test-DB. Auch Fehler- und Abbruchpfade besitzen jeweils Archiv, entpackte Daten und Test-DB. Auch Fehler- und Abbruchpfade besitzen
eine Bereinigung. Es bleiben nur kleine, begrenzte Betriebsnachweise. eine Bereinigung. Es bleiben nur kleine, begrenzte Betriebsnachweise.
Vor dem Paketstart nach [RESTORE_TEST.md](RESTORE_TEST.md) ein lokales, zur Mit `--enable-restore-test` übernimmt der Installer die MariaDB-Vorbereitung:
Produktions-MariaDB passendes Docker-Image vorbereiten, Speichergrenzen prüfen und Produktionsserie lesen, passendes lokales Image wiederverwenden oder laden und
`ENABLE_RESTORE_TEST=true` sowie `RESTORE_TEST_DB_IMAGE` in der aktiven Konfiguration einen isolierten Probeimport einschließlich Bereinigung ausführen. Manuelles
setzen. Die zusätzliche Vorprüfung kontrolliert lokale Voraussetzungen, ohne `docker pull` ist auf Gemini nicht mehr erforderlich. Die Option aktiviert
Images oder Backups herunterzuladen. Fehlende Voraussetzungen stoppen die zugleich das adaptive Arbeitsbudget; Details: [RESTORE_TEST.md](RESTORE_TEST.md).
Vorbereitung vor der Installation. Nach erfolgreicher Update-Abnahme aktiviert Danach prüft der Worker sein neues Offsite-Backup vollständig mit Entpacken und
der Installer den monatlichen Timer (1., 06:00 Uhr Serverzeit) und die DB-Import. Erst bei Erfolg aktiviert er Monatstimer und Boot-Bereinigung.
Boot-Bereinigung. Der erste echte Restore-Test hat eine eigene Mail und
`$WORKDIR/restore-test/state.json`; sein Erfolg ist separat zu kontrollieren.
Vor der Vorprüfung werden erkannte Überreste fehlgeschlagener Backups bereinigt
und der nächste Snapshot geplant. Reicht der Platz nicht, werden geeignete ältere
lokale Kopien nach vollständiger Offsite-Rückleseprüfung entfernt; die neuesten
`RETENTION_KEEP_LAST` bleiben geschützt. Diese Platzbereinigung gilt auch während
einer Update-Abnahme ohne reguläre Retention. Sie wird nicht durch Paketrollback
rückgängig gemacht; entfernte vollständige lokale Sätze bleiben offsite verfügbar.
[SPEICHER.md](SPEICHER.md) dokumentiert Grenzen und den Gemini-Fehlerfall.
Bestehende Konfigurationen ohne `ENABLE_RESTORE_TEST` bleiben deaktiviert. Bestehende Konfigurationen ohne `ENABLE_RESTORE_TEST` bleiben deaktiviert.
Bestehende Restore-Test-Zeitpläne werden erhalten; neue Vorlagen liegen als `.dist` Bestehende Restore-Test-Zeitpläne werden erhalten; neue Vorlagen liegen als `.dist`
daneben. Bei einem Paketrollback werden die Test-Timer/-Bereinigungs-Units vor dem daneben. Bei einem Paketrollback werden die Test-Timer/-Bereinigungs-Units vor dem
@@ -33,7 +38,7 @@ auf den Server übertragen. Auf dem Server im selben Verzeichnis prüfen/starten
```bash ```bash
sha256sum -c app-backup-update-VERSION.run.sha256 sha256sum -c app-backup-update-VERSION.run.sha256
sudo bash app-backup-update-VERSION.run sudo bash app-backup-update-VERSION.run --enable-restore-test
``` ```
VERSION durch den tatsächlichen Dateinamen ersetzen. Ein Git-Checkout auf dem VERSION durch den tatsächlichen Dateinamen ersetzen. Ein Git-Checkout auf dem
@@ -67,7 +72,7 @@ abschwächen und sind im Backup-Prozess nicht unterstützt.
5. Crypt-Konfiguration, DB-Dateien, globales DB-Recht `RELOAD` und bei MariaDB 5. Crypt-Konfiguration, DB-Dateien, globales DB-Recht `RELOAD` und bei MariaDB
Zugriff auf Routinen-Metadaten prüfen; Zugriff auf Routinen-Metadaten prüfen;
vorhandene Schlüssel/Passwörter erhalten. Keine automatischen DB-Grants. vorhandene Schlüssel/Passwörter erhalten. Keine automatischen DB-Grants.
6. Vorprüfung mit geschützten Kopien durchführen. rsync prüft außerdem den Zugriff 6. Sichere Platzbereinigung und Bedarfsplanung unter gemeinsamer Sperre ausführen, dann Vorprüfung mit geschützten Kopien durchführen. rsync prüft außerdem den Zugriff
auf Quellverzeichnisse und deren Attribute in einem privaten temporären auf Quellverzeichnisse und deren Attribute in einem privaten temporären
Prüfbereich, ohne Datei-Inhalte zu kopieren. Bei Fehlern bleibt das installierte Prüfbereich, ohne Datei-Inhalte zu kopieren. Bei Fehlern bleibt das installierte
Paket samt Anwendungskonfiguration und Timer unverändert. Paket samt Anwendungskonfiguration und Timer unverändert.
@@ -75,12 +80,15 @@ abschwächen und sind im Backup-Prozess nicht unterstützt.
`--no-block` einreihen und die Sperren an ihn übergeben. `--no-block` einreihen und die Sperren an ihn übergeben.
8. Backup-Timer stoppen, Paket mit Rollback-Inventar installieren, Konfiguration 8. Backup-Timer stoppen, Paket mit Rollback-Inventar installieren, Konfiguration
atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren. atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren.
9. Vollständiges Backup ohne lokale/Remote-Retention durchführen. Anschließend den 9. Vollständiges Backup ohne reguläre lokale/Remote-Altersretention durchführen.
abgeschlossenen Satz nochmals über Crypt herunterladen und mit dem Restore prüfen. Platzbereinigung bleibt erlaubt. Bei aktiviertem Restore-Test den Satz
komponentenweise herunterladen, entpacken und seine DB-Dumps isoliert importieren;
sonst alle Archive einzeln herunterladen und vollständig prüfen.
10. Erst bei Erfolg den Backup-Timer aktivieren und das Ergebnis protokollieren. 10. Erst bei Erfolg den Backup-Timer aktivieren und das Ergebnis protokollieren.
Es findet kein Import in produktive Anwendungen oder Datenbanken statt. Der Es findet kein Import in produktive Anwendungen oder Datenbanken statt. Der
Remote-Prüflauf belegt Archivlesbarkeit, ersetzt aber keinen fachlichen Test-Restore. Prüflauf belegt bei aktiviertem Restore-Test auch Entpacken und DB-Import, ersetzt
aber keinen fachlichen Anwendungstest.
## Verlauf und Ergebnis ## Verlauf und Ergebnis
+4 -3
View File
@@ -48,12 +48,13 @@ MAIL_INCLUDE_LOG_TAIL_LINES=200
MAIL_SENDMAIL_BIN="/usr/sbin/sendmail" MAIL_SENDMAIL_BIN="/usr/sbin/sendmail"
MAIL_SEND_TIMEOUT=30 MAIL_SEND_TIMEOUT=30
# Monthly offsite restore rehearsal. Prepare a LOCAL MariaDB image matching the # Monthly offsite restore rehearsal. The updater --enable-restore-test prepares
# production major/minor version before enabling. No automatic image downloads. # a matching LOCAL MariaDB image; monthly jobs never download images.
ENABLE_RESTORE_TEST="false" ENABLE_RESTORE_TEST="false"
RESTORE_TEST_DB_IMAGE="" RESTORE_TEST_DB_IMAGE=""
# Components are processed sequentially; fail/clean up if a component will not fit. # Components are processed sequentially; fail/clean up if a component will not fit.
RESTORE_TEST_MAX_WORK_GB=4 # 0: adaptive to free space; a positive value adds a fixed scratch limit.
RESTORE_TEST_MAX_WORK_GB=0
RESTORE_TEST_MIN_FREE_GB=2 RESTORE_TEST_MIN_FREE_GB=2
RESTORE_TEST_MAX_AGE_HOURS=72 RESTORE_TEST_MAX_AGE_HOURS=72
RESTORE_TEST_TIMEOUT=39600 RESTORE_TEST_TIMEOUT=39600
+10 -1
View File
@@ -18,7 +18,7 @@ done
[[ $NO_TIMER != true || $ENABLE_TIMER != true ]] || exit 1 [[ $NO_TIMER != true || $ENABLE_TIMER != true ]] || exit 1
# Syntax and packaging errors must be caught BEFORE changing the running installation. # Syntax and packaging errors must be caught BEFORE changing the running installation.
for file in "$ROOT_DIR"/usr-local-sbin/*.sh "$ROOT_DIR"/lib/*.sh; do bash -n "$file"; done for file in "$ROOT_DIR"/usr-local-sbin/*.sh "$ROOT_DIR"/lib/*.sh; do bash -n "$file"; done
python3 - "$ROOT_DIR/lib/backup_set.py" "$ROOT_DIR/lib/report.py" "$ROOT_DIR/lib/restore_test.py" <<'PY' python3 - "$ROOT_DIR"/lib/*.py <<'PY'
import ast,sys import ast,sys
for path in sys.argv[1:]: ast.parse(open(path).read()) for path in sys.argv[1:]: ast.parse(open(path).read())
PY PY
@@ -65,6 +65,9 @@ if [[ -e /etc/systemd/system/app-restore-test.timer ]]; then
else add systemd/app-restore-test.timer /etc/systemd/system/app-restore-test.timer 644; fi else add systemd/app-restore-test.timer /etc/systemd/system/app-restore-test.timer 644; fi
add lib/common.sh /usr/local/lib/app-backup/common.sh 640 add lib/common.sh /usr/local/lib/app-backup/common.sh 640
add lib/locking.sh /usr/local/lib/app-backup/locking.sh 640 add lib/locking.sh /usr/local/lib/app-backup/locking.sh 640
add lib/space.sh /usr/local/lib/app-backup/space.sh 640
add lib/space.py /usr/local/lib/app-backup/space.py 640
add lib/prepare-restore-test.sh /usr/local/lib/app-backup/prepare-restore-test.sh 640
add lib/backup_set.py /usr/local/lib/app-backup/backup_set.py 640 add lib/backup_set.py /usr/local/lib/app-backup/backup_set.py 640
add lib/report.py /usr/local/lib/app-backup/report.py 640 add lib/report.py /usr/local/lib/app-backup/report.py 640
add systemd/app-backup.service /etc/systemd/system/app-backup.service 644 add systemd/app-backup.service /etc/systemd/system/app-backup.service 644
@@ -81,6 +84,7 @@ add README.md /usr/local/share/doc/app-backup/README.md 644
add Dokumentation.md /usr/local/share/doc/app-backup/Dokumentation.md 644 add Dokumentation.md /usr/local/share/doc/app-backup/Dokumentation.md 644
add UPDATE_ANLEITUNG.md /usr/local/share/doc/app-backup/UPDATE_ANLEITUNG.md 644 add UPDATE_ANLEITUNG.md /usr/local/share/doc/app-backup/UPDATE_ANLEITUNG.md 644
add RESTORE_TEST.md /usr/local/share/doc/app-backup/RESTORE_TEST.md 644 add RESTORE_TEST.md /usr/local/share/doc/app-backup/RESTORE_TEST.md 644
add SPEICHER.md /usr/local/share/doc/app-backup/SPEICHER.md 644
# Save the exact replaced files plus a rollback inventory; do not dereference links. # Save the exact replaced files plus a rollback inventory; do not dereference links.
: > "$SNAPSHOT/present.txt"; : > "$SNAPSHOT/absent.txt" : > "$SNAPSHOT/present.txt"; : > "$SNAPSHOT/absent.txt"
for i in "${!TARGETS[@]}"; do for i in "${!TARGETS[@]}"; do
@@ -138,6 +142,11 @@ for i in "${!TARGETS[@]}"; do
done done
install -d -m 700 /var/log/app-backup /var/backups/app-backup install -d -m 700 /var/log/app-backup /var/backups/app-backup
systemctl daemon-reload systemctl daemon-reload
# The coordinator prepares its candidate before install; direct installs prepare
# an already configured feature under this same lock before restarting timers.
if [[ ${APP_BACKUP_INHERIT_LOCK:-0} != 1 && ${ENABLE_RESTORE_TEST:-false} == true ]]; then
APP_BACKUP_INHERIT_LOCK=1 bash /usr/local/lib/app-backup/prepare-restore-test.sh /etc/app-backup/app-backup.conf
fi
# A persistent timer can fire immediately. Release the package lock only after # A persistent timer can fire immediately. Release the package lock only after
# all files are installed, but before that timer can start the new backup. # all files are installed, but before that timer can start the new backup.
if [[ ${APP_BACKUP_INHERIT_LOCK:-0} != 1 ]]; then flock -u 9; fi if [[ ${APP_BACKUP_INHERIT_LOCK:-0} != 1 ]]; then flock -u 9; fi
+7 -2
View File
@@ -99,9 +99,11 @@ def check_tar(path, roots):
proc = subprocess.Popen(command, stdout=subprocess.PIPE) proc = subprocess.Popen(command, stdout=subprocess.PIPE)
entries = {} entries = {}
hardlinks = [] hardlinks = []
expanded = 0
try: try:
with tarfile.open(fileobj=proc.stdout, mode="r|") as archive: with tarfile.open(fileobj=proc.stdout, mode="r|") as archive:
for item in archive: for item in archive:
expanded += ((item.size + 4095) // 4096) * 4096 + 4096
name = member_name(item.name) name = member_name(item.name)
if not any(name == root or name.startswith(root + "/") for root in roots): if not any(name == root or name.startswith(root + "/") for root in roots):
raise ValueError(f"Member outside component root: {name}") raise ValueError(f"Member outside component root: {name}")
@@ -128,6 +130,7 @@ def check_tar(path, roots):
pass pass
if proc.wait() != 0: if proc.wait() != 0:
raise ValueError(f"Decompression failed: {path}") raise ValueError(f"Decompression failed: {path}")
return expanded
finally: finally:
proc.stdout.close() proc.stdout.close()
if proc.poll() is None: if proc.poll() is None:
@@ -291,9 +294,11 @@ def main():
crypt(*args) crypt(*args)
elif op == "create": elif op == "create":
create(Path(args[0]), *args[1:]) create(Path(args[0]), *args[1:])
elif op in ("verify", "metadata", "list"): elif op in ("verify", "metadata", "list", "expanded"):
data = load_set(Path(args[0]), metadata_only=op == "metadata") data = load_set(Path(args[0]), metadata_only=op == "metadata")
if op == "verify": if op == "expanded":
print(sum(check_tar(Path(args[0]) / item['file'], [item['root']]) for item in data['archives']))
elif op == "verify":
for item in data["archives"]: for item in data["archives"]:
check_tar(Path(args[0]) / item["file"], [item["root"]]) check_tar(Path(args[0]) / item["file"], [item["root"]])
print(f"Verified: {data['run_id']} ({len(data['archives'])} archives)") print(f"Verified: {data['run_id']} ({len(data['archives'])} archives)")
+16 -3
View File
@@ -40,7 +40,7 @@ load_config() {
: "${MAIL_HOSTNAME:=}" : "${MAIL_HOSTNAME:=}"
: "${MAIL_SENDMAIL_BIN:=/usr/sbin/sendmail}" "${MAIL_SEND_TIMEOUT:=30}" : "${MAIL_SENDMAIL_BIN:=/usr/sbin/sendmail}" "${MAIL_SEND_TIMEOUT:=30}"
: "${ENABLE_RESTORE_TEST:=false}" "${RESTORE_TEST_DB_IMAGE:=}" : "${ENABLE_RESTORE_TEST:=false}" "${RESTORE_TEST_DB_IMAGE:=}"
: "${RESTORE_TEST_MAX_WORK_GB:=4}" "${RESTORE_TEST_MIN_FREE_GB:=2}" : "${RESTORE_TEST_MAX_WORK_GB:=0}" "${RESTORE_TEST_MIN_FREE_GB:=2}"
: "${RESTORE_TEST_MAX_AGE_HOURS:=72}" "${RESTORE_TEST_TIMEOUT:=39600}" : "${RESTORE_TEST_MAX_AGE_HOURS:=72}" "${RESTORE_TEST_TIMEOUT:=39600}"
: "${ENABLE_WORDPRESS:=false}" "${ENABLE_NEXTCLOUD:=false}" "${ENABLE_NEXTCLOUD_DATA:=true}" : "${ENABLE_WORDPRESS:=false}" "${ENABLE_NEXTCLOUD:=false}" "${ENABLE_NEXTCLOUD_DATA:=true}"
: "${ENABLE_GITEA:=false}" "${ENABLE_MAIL:=false}" "${ENABLE_DB_DUMPS:=true}" : "${ENABLE_GITEA:=false}" "${ENABLE_MAIL:=false}" "${ENABLE_DB_DUMPS:=true}"
@@ -71,6 +71,7 @@ load_config() {
[[ ${!v} =~ ^(0|[1-9][0-9]*)$ ]] || die "$v must be a non-negative integer" [[ ${!v} =~ ^(0|[1-9][0-9]*)$ ]] || die "$v must be a non-negative integer"
done done
(( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2" (( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2"
(( MIN_FREE_GB <= 1000000 )) || die 'MIN_FREE_GB must not exceed 1000000'
(( ZSTD_LEVEL >= 1 && ZSTD_LEVEL <= 19 && ZSTD_THREADS >= 1 )) || die "Invalid zstd settings" (( ZSTD_LEVEL >= 1 && ZSTD_LEVEL <= 19 && ZSTD_THREADS >= 1 )) || die "Invalid zstd settings"
[[ $ARCHIVE_PREFIX =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || die "Unsafe ARCHIVE_PREFIX" [[ $ARCHIVE_PREFIX =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || die "Unsafe ARCHIVE_PREFIX"
[[ $COMPRESSOR == zstd || $COMPRESSOR == gzip ]] || die "COMPRESSOR must be zstd or gzip" [[ $COMPRESSOR == zstd || $COMPRESSOR == gzip ]] || die "COMPRESSOR must be zstd or gzip"
@@ -487,6 +488,7 @@ resume_services() {
} }
finish() { finish() {
local code=$? local code=$?
local recovery_ok=true
trap - EXIT INT TERM trap - EXIT INT TERM
set +e set +e
if [[ $OPERATION == restore && $MUTATION_STARTED == true && $code != 0 ]]; then if [[ $OPERATION == restore && $MUTATION_STARTED == true && $code != 0 ]]; then
@@ -495,7 +497,7 @@ finish() {
echo "ERROR: Partial restore. Service recovery requires manual review: ${STOPPED_SERVICES[*]}" echo "ERROR: Partial restore. Service recovery requires manual review: ${STOPPED_SERVICES[*]}"
echo "ERROR: Keep Nextcloud in maintenance; review $LOG_FILE before restarting." echo "ERROR: Keep Nextcloud in maintenance; review $LOG_FILE before restarting."
else else
resume_services || code=1 if ! resume_services; then code=1; recovery_ok=false; fi
fi fi
local report_sizes="$LOG_DIR/report_$RUN_ID.sizes.json" local report_sizes="$LOG_DIR/report_$RUN_ID.sizes.json"
if [[ ${CHECK:-false} != true && $ENABLE_MAIL_REPORT == true && $OPERATION == backup ]]; then if [[ ${CHECK:-false} != true && $ENABLE_MAIL_REPORT == true && $OPERATION == backup ]]; then
@@ -503,7 +505,18 @@ finish() {
python3 "$LIB_DIR/report.py" snapshot "$report_sizes" "${STAGING_DIR:-}" "${RUN_DIR:-${PARTIAL_DIR:-}}" || python3 "$LIB_DIR/report.py" snapshot "$report_sizes" "${STAGING_DIR:-}" "${RUN_DIR:-${PARTIAL_DIR:-}}" ||
echo 'WARNING: Report sizes unavailable' echo 'WARNING: Report sizes unavailable'
fi fi
if [[ $code == 0 && -n ${STAGING_DIR:-} ]]; then rm -rf -- "$STAGING_DIR" || code=1; fi if [[ $OPERATION == backup && $recovery_ok == true && -n ${STAGING_DIR:-} && ! -e $WORKDIR/recovery-$RUN_ID.json ]]; then
# A failed backup scratch copy is disposable once writer recovery succeeded.
# Never delete a locally complete set awaiting upload or a completion marker.
if [[ -n ${PARTIAL_DIR:-} && -d $PARTIAL_DIR && ! -e $PARTIAL_DIR/COMPLETE ]]; then
python3 "$LIB_DIR/space.py" remove "$PARTIAL_DIR" || code=1
fi
if [[ ! -d ${PARTIAL_DIR:-/nonexistent} ]]; then
python3 "$LIB_DIR/space.py" remove "$STAGING_DIR" && STAGING_DIR='' || code=1
fi
elif [[ ( $code == 0 || ( $OPERATION == restore && ${MODE:-} == verify ) ) && -n ${STAGING_DIR:-} ]]; then
rm -rf -- "$STAGING_DIR" && STAGING_DIR='' || code=1
fi
if [[ $code != 0 ]]; then echo "FAILED (exit $code). Retained work: ${STAGING_DIR:-none}"; fi if [[ $code != 0 ]]; then echo "FAILED (exit $code). Retained work: ${STAGING_DIR:-none}"; fi
# Close the logger's writers and wait for EOF so the mail tail includes the # Close the logger's writers and wait for EOF so the mail tail includes the
# final recovery/status messages. Reporting itself uses the original stdout. # final recovery/status messages. Reporting itself uses the original stdout.
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Root-only installer preparation. No production SQL writes or credentials in Docker.
set -Eeuo pipefail
LIB_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib/common.sh
source "$LIB_DIR/common.sh"
CONFIG_FILE=${1:?Configuration required}
load_config
[[ $ENABLE_RESTORE_TEST == true ]] || exit 0
guard_workspaces
# shellcheck source=lib/locking.sh
source "$LIB_DIR/locking.sh"
acquire_backup_lock 1
export APP_BACKUP_INHERIT_LOCK=1
build_plan
(( ${#DB_NAMES[@]} )) || exit 0
need docker; need timeout
docker_cmd=(docker --host=unix:///var/run/docker.sock)
if ! "${docker_cmd[@]}" info --format '{{.DockerRootDir}}' >/dev/null 2>&1; then
systemctl start docker.service
fi
docker_root=$("${docker_cmd[@]}" info --format '{{.DockerRootDir}}')
[[ $docker_root == /* ]] || die 'Cannot determine local Docker storage'
version=''
for i in "${!DB_NAMES[@]}"; do
secure_file "${DB_CNFS[i]}"
server=$("$DB_CLIENT_BIN" --defaults-extra-file="${DB_CNFS[i]}" --database="${DB_NAMES[i]}" --batch --skip-column-names --execute='SELECT VERSION()')
[[ $server =~ ^([0-9]+\.[0-9]+)\.[0-9]+.*MariaDB ]] || die 'Automatic restore-test preparation requires MariaDB; no production server changes made'
found=${BASH_REMATCH[1]}
[[ -z $version || $version == "$found" ]] || die 'Different production MariaDB series require separate restore-test configurations'
version=$found
done
image=${RESTORE_TEST_DB_IMAGE:-mariadb:$version}
[[ $image =~ ^[a-zA-Z0-9][a-zA-Z0-9._/:@-]*$ ]] || die 'Invalid restore-test image reference'
if ! "${docker_cmd[@]}" image inspect "$image" >/dev/null 2>&1; then
# Image layers are stored on Docker's filesystem, not necessarily WORKDIR.
[[ -d $docker_root ]] || die 'Docker storage is not accessible for the space check'
# shellcheck source=lib/space.sh
source "$LIB_DIR/space.sh"
ensure_space "$docker_root" 2147483648
echo "-- Preparing isolated MariaDB image: $image"
timeout --kill-after=30s 1800 "${docker_cmd[@]}" pull "$image"
fi
# Pin the probe to an immutable local image ID; never pull during monthly jobs.
image_id=$("${docker_cmd[@]}" image inspect --format '{{.Id}}' "$image")
"${docker_cmd[@]}" image inspect "$image_id" | python3 -c 'import json,sys; info=json.load(sys.stdin)[0]; assert not set(info.get("Config",{}).get("Volumes") or {}) - {"/var/lib/mysql"}, "Unexpected image volumes"'
probe=$(timeout --kill-after=10s 60 "${docker_cmd[@]}" run --rm --pull=never --network=none --read-only \
--cap-drop=ALL --security-opt=no-new-privileges --memory=128m --pids-limit=64 \
--entrypoint=mariadbd "$image_id" --no-defaults --version)
[[ $probe == *" $version."* && $probe == *MariaDB* ]] || die 'Restore-test image does not match production MariaDB series'
echo "-- MariaDB $version image verified; testing isolated initialization/import/cleanup"
# Use a private temporary config; publish the image selection only after success.
prepared=$(mktemp "${CONFIG_FILE}.prepare-XXXXXX")
trap 'rm -f -- "$prepared"' EXIT
cp -- "$CONFIG_FILE" "$prepared"
printf '\nRESTORE_TEST_DB_IMAGE=%q\n' "$image" >> "$prepared"
script="$LIB_DIR/../usr-local-sbin/app-restore-test.sh"
[[ -f $script ]] || script=/usr/local/sbin/app-restore-test.sh
bash "$script" --config "$prepared" --prepare-db
if [[ $RESTORE_TEST_DB_IMAGE != "$image" ]]; then
chmod 600 "$prepared"
mv -fT -- "$prepared" "$CONFIG_FILE"
fi
echo '-- MariaDB restore-test preparation passed; temporary database removed'
+4 -1
View File
@@ -27,7 +27,10 @@ def allocated(path):
def snapshot(output, staging, archives): def snapshot(output, staging, archives):
data = {'staged': {}, 'archives': {}} data = {'staged': {}, 'archives': {}}
if staging and Path(staging).is_dir(): if Path(output).is_file():
data.update(json.loads(Path(output).read_text()))
captured = 'staging_total' in data
if not captured and staging and Path(staging).is_dir():
root = Path(staging) root = Path(staging)
# Measure components independently; the total accounts for shared links. # Measure components independently; the total accounts for shared links.
paths = sorted((root/'files').glob('*')) + [root/'db', root/'meta'] paths = sorted((root/'files').glob('*')) + [root/'db', root/'meta']
+12
View File
@@ -7,6 +7,18 @@ source "$LIB_DIR/common.sh"
CONFIG_FILE=${1:?Missing config} CONFIG_FILE=${1:?Missing config}
shift shift
load_config load_config
if [[ ${1:-} == space ]]; then
# Local maintenance requested between components, while the parent holds FD 9.
[[ /proc/$PPID/fd/9 -ef $LOCKFILE ]] || die 'Space recovery requires the parent backup lock'
[[ ${2:-} =~ ^[0-9]+$ && $# == 2 ]] || die 'Invalid space request'
guard_workspaces
# shellcheck source=lib/space.sh
source "$LIB_DIR/space.sh"
clean_backup_scratch
MIN_FREE_GB=$(python3 -c 'import math,sys; print(math.ceil(float(sys.argv[1])))' "$RESTORE_TEST_MIN_FREE_GB")
ensure_space "$WORKDIR" "$2"
exit 0
fi
check_remote >&2 check_remote >&2
# rclone is restricted to read operations here, including metadata reads. # rclone is restricted to read operations here, including metadata reads.
case ${1:-} in lsf|cat) rc "$@" ;; *) die 'Unsupported restore-test remote operation' ;; esac case ${1:-} in lsf|cat) rc "$@" ;; *) die 'Unsupported restore-test remote operation' ;; esac
+39 -3
View File
@@ -137,6 +137,19 @@ class Rehearsal:
if time.monotonic() >= self.deadline: if time.monotonic() >= self.deadline:
raise TestError('Restore-test time limit exceeded') raise TestError('Restore-test time limit exceeded')
free = shutil.disk_usage(self.root).free free = shutil.disk_usage(self.root).free
if (force and self.args.mode == 'run' and self.args.remote_check and
free < self.args.min_free_gb * GIB + additional):
# Only between subprocesses: do not overwrite the active-child journal.
self.log.info('Attempting safe local space recovery before next component step')
self.monitor = False
try:
result = self.remote('space', str(int(additional)), capture=1024*1024)
self.log.info('%s', result.decode(errors='replace').strip())
except TestError as exc:
raise TestError('Free-space reserve unavailable after safe recovery; protected data retained') from exc
finally:
self.monitor = True
free = shutil.disk_usage(self.root).free
if free < self.args.min_free_gb * GIB + additional: if free < self.args.min_free_gb * GIB + additional:
raise TestError('Free-space reserve reached; test stopped and scratch will be cleaned') raise TestError('Free-space reserve reached; test stopped and scratch will be cleaned')
if force or time.monotonic() >= self.next_usage: if force or time.monotonic() >= self.next_usage:
@@ -145,7 +158,7 @@ class Rehearsal:
for name in directories + files: for name in directories + files:
self.used += (Path(path) / name).lstat().st_blocks * 512 self.used += (Path(path) / name).lstat().st_blocks * 512
self.next_usage = time.monotonic() + 2 self.next_usage = time.monotonic() + 2
if self.used + additional > self.args.max_work_gb * GIB: if self.args.max_work_gb and self.used + additional > self.args.max_work_gb * GIB:
raise TestError('Restore-test disk budget exceeded') raise TestError('Restore-test disk budget exceeded')
def command(self, command, *, capture=0, output=None, limit=None, stdin=None, timeout=120): def command(self, command, *, capture=0, output=None, limit=None, stdin=None, timeout=120):
@@ -459,7 +472,7 @@ def io_bytes(raw):
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=('run', 'check', 'cleanup')) parser.add_argument('mode', choices=('run', 'check', 'cleanup', 'prepare'))
for key in ('workdir', 'prefix', 'remote', 'run', 'image', 'hostname', 'to', 'sender', 'sendmail', 'mail'): for key in ('workdir', 'prefix', 'remote', 'run', 'image', 'hostname', 'to', 'sender', 'sendmail', 'mail'):
parser.add_argument('--' + key, required=True) parser.add_argument('--' + key, required=True)
for key in ('remote-check', 'config'): for key in ('remote-check', 'config'):
@@ -470,12 +483,35 @@ def main():
for key in ('max-work-gb', 'min-free-gb'): for key in ('max-work-gb', 'min-free-gb'):
parser.add_argument('--' + key, type=float, required=True) parser.add_argument('--' + key, type=float, required=True)
args = parser.parse_args() args = parser.parse_args()
if not (0 < args.max_work_gb <= 100000 and 0 <= args.min_free_gb <= 100000 and if not (0 <= args.max_work_gb <= 100000 and 0 <= args.min_free_gb <= 100000 and
1 <= args.max_age_hours <= 8760 and 1 <= args.timeout <= 86400 and 1 <= args.mail_timeout <= 300): 1 <= args.max_age_hours <= 8760 and 1 <= args.timeout <= 86400 and 1 <= args.mail_timeout <= 300):
parser.error('Invalid restore-test limits') parser.error('Invalid restore-test limits')
if os.geteuid() != 0: if os.geteuid() != 0:
parser.error('Run as root via app-restore-test.sh') parser.error('Run as root via app-restore-test.sh')
test = Rehearsal(args) test = Rehearsal(args)
if args.mode == 'prepare':
def stop_prepare(signum, _frame):
raise TestError('Installer probe interrupted: ' + str(signum))
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, stop_prepare)
# Installer smoke test, separate from monthly backup acceptance/status.
# Refuse an interrupted rehearsal until its regular cleanup reported it.
if any((test.root / name).exists() for name in ('scratch', 'db-created', 'child.json')):
raise TestError('Run restore-test --cleanup before DB preparation')
try:
test.preflight()
test.scratch.mkdir(mode=0o700)
test.monitor = True
sql = test.scratch / 'probe.sql'
sql.write_text('CREATE TABLE installer_probe (id INT PRIMARY KEY); INSERT INTO installer_probe VALUES (1);\n')
test.disk(additional=512*1024*1024, force=True)
if test.database(sql) != 1:
raise TestError('Installer database probe returned unexpected table count')
finally:
test.monitor = False
test.cleanup()
test.log.info('Installer DB initialization, import, check and cleanup passed')
return 0
if args.mode == 'cleanup' and not test.root.exists(): if args.mode == 'cleanup' and not test.root.exists():
return 0 return 0
if args.mode == 'check': if args.mode == 'check':
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Conservative scratch recovery. Caller MUST hold the shared backup lock.
Only private, recognized backup scratch is disposable. Recovery journals and
manual restore extractions always win over attempts to reclaim disk space.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import backup_set
def private(path):
return (path.is_dir() and path.resolve() == path and
path.stat().st_uid == 0 and not path.stat().st_mode & 0o077)
def remove_tree(path):
if not private(path):
raise ValueError('Refusing non-private/aliased scratch: ' + str(path))
device = path.stat().st_dev
for parent, dirs, _ in os.walk(path, followlinks=False):
for name in dirs:
child = Path(parent) / name
if not child.is_symlink() and (child.stat().st_dev != device or os.path.ismount(child)):
raise ValueError('Refusing mounted scratch: ' + str(child))
shutil.rmtree(path) # fd-based, symlink-safe implementation on Linux
def clean(work, staging, archives, prefix, restore=None):
for path in (work, staging, archives):
if not private(path):
raise ValueError('Cleanup requires private canonical directories: ' + str(path))
if list(work.glob('recovery-*.json')) or list(work.glob('recovery-*.json.tmp')):
raise ValueError('Unresolved service/restore recovery journal; preserve all scratch and review services')
pattern = re.compile(re.escape(prefix) + r'_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_[0-9a-f]{8}')
for path in sorted(staging.iterdir()):
run, dot, suffix = path.name.rpartition('.')
if not (dot and re.fullmatch(r'[a-zA-Z0-9]{6}', suffix) and pattern.fullmatch(run) and private(path)):
continue
identity, version = path/'meta/run-id.txt', path/'meta/format.txt'
if any(p.is_symlink() or not p.is_file() or p.stat().st_size > 256 for p in (identity, version)):
continue
if (path/'meta').is_symlink() or identity.read_text() != run+'\n' or not version.read_text().startswith('format=2\n'):
continue
partial = archives / ('.partial-' + run)
if partial.exists():
# A completion marker might be the only surviving complete snapshot.
if (partial/'COMPLETE').exists() or (partial/'COMPLETE').is_symlink():
continue
remove_tree(partial)
print('-- Removed abandoned partial archive: ' + partial.name, flush=True)
remove_tree(path)
print('-- Removed abandoned backup staging: ' + path.name, flush=True)
if restore is not None and private(restore):
for path in sorted(restore.iterdir()):
run, _, suffix = path.name.rpartition('.')
marker = path/'.verify-only'
if (private(path) and pattern.fullmatch(run) and re.fullmatch(r'[a-zA-Z0-9]{6}', suffix) and
marker.is_file() and not marker.is_symlink() and marker.stat().st_size < 256 and
marker.read_text() == run+'\n'):
remove_tree(path)
print('-- Removed abandoned verification download: ' + path.name, flush=True)
def eligible(archives, prefix, keep):
valid = []
for path in archives.iterdir():
if not private(path) or not path.name.startswith(prefix+'_'):
continue
marker = path/'UPLOADED'
if not marker.is_file() or marker.is_symlink():
continue
try:
manifest = backup_set.load_set(path, metadata_only=True)
if manifest['run_id'] != path.name:
continue
except (ValueError, OSError, KeyError, TypeError):
continue
valid.append(path.name)
# Emergency pruning may ignore age, NEVER the minimum newest-copy count.
for name in sorted(valid, reverse=True)[keep:][::-1]:
print(name)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=('clean', 'eligible', 'remove'))
parser.add_argument('paths', nargs='+')
args = parser.parse_args()
if args.mode == 'clean':
clean(*(Path(p) for p in args.paths[:3]), args.paths[3], Path(args.paths[4]) if len(args.paths) > 4 else None)
elif args.mode == 'eligible':
eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2]))
else:
remove_tree(Path(args.paths[0]))
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Called only under the shared lock; --check never invokes this maintenance.
clean_backup_scratch() {
python3 "$LIB_DIR/space.py" clean "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$RESTORE_ROOT"
}
ensure_space() {
local path=$1 additional=${2:-0} avail name dir candidates
[[ $additional =~ ^(0|[1-9][0-9]{0,15})$ ]] || die 'Invalid or excessive space request'
local required=$((MIN_FREE_GB * 1024 * 1024 * 1024 + additional))
avail=$(df -PB1 -- "$path" | awk 'END{print $4}')
(( avail < required )) || return 0
echo "-- Space recovery: need $required bytes, available $avail on $path"
if [[ $ENABLE_UPLOAD == true && $(stat -c %d "$path") == "$(stat -c %d "$ARCHIVE_DIR")" ]]; then
check_remote
candidates=$(python3 "$LIB_DIR/space.py" eligible "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$RETENTION_KEEP_LAST")
while IFS= read -r name; do
[[ -n $name ]] || continue
dir="$ARCHIVE_DIR/$name"
# A manually selected local restore source is never a cleanup candidate.
[[ -z ${LOCAL_RUN:-} || $(realpath -m "$LOCAL_RUN") != "$dir" ]] || continue
echo "-- Space recovery: verifying offsite copy before local removal: $name"
if helper verify "$dir" >/dev/null &&
rc check "$dir" "${RCLONE_REMOTE_BASE%/}/$name" --download --exclude /UPLOADED; then
python3 "$LIB_DIR/space.py" remove "$dir"
echo "-- Space recovery: removed local copy $name; verified offsite copy retained"
else
echo "WARNING: Keeping $name: local/offsite verification failed"
fi
avail=$(df -PB1 -- "$path" | awk 'END{print $4}')
(( avail < required )) || return 0
done <<< "$candidates"
fi
die "Insufficient space after safe cleanup on $path: need $required bytes, available $avail; protected backups/recovery data retained"
}
prepare_backup_space() {
if [[ -f $WORKDIR/restore-test/.owner ]]; then
local cleanup_script="$LIB_DIR/../usr-local-sbin/app-restore-test.sh"
[[ -f $cleanup_script ]] || cleanup_script=/usr/local/sbin/app-restore-test.sh
APP_BACKUP_INHERIT_LOCK=1 bash "$cleanup_script" --config "$CONFIG_FILE" --cleanup
fi
clean_backup_scratch
local i stats bytes count total=0 db_size
# Dry-run obeys the exact copy exclusions. No dump, copy or service stop.
for i in "${!LABELS[@]}"; do
check_source_path "${SOURCES[i]}"
exclusions "${LABELS[i]}"
stats=$(rsync -aHAX --dry-run --stats --numeric-ids "${EXCLUDES[@]}" -- "${SOURCES[i]}/" "$STAGING_ROOT/.space-estimate/" 8>&- 9>&-)
bytes=$(awk '/^Total file size:/ {gsub(/,/, "", $4); print $4}' <<< "$stats")
count=$(awk '/^Number of files:/ {gsub(/,/, "", $4); print $4}' <<< "$stats")
[[ $bytes =~ ^[0-9]+$ && $count =~ ^[0-9]+$ ]] || die 'Cannot estimate source allocation'
total=$((total + bytes + count * 8192))
done
for i in "${!DB_NAMES[@]}"; do
secure_file "${DB_CNFS[i]}"
db_size=$("$DB_CLIENT_BIN" --defaults-extra-file="${DB_CNFS[i]}" --database="${DB_NAMES[i]}" --batch --skip-column-names --execute='SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema=DATABASE()')
[[ $db_size =~ ^[0-9]+$ ]] || die 'Cannot estimate database size'
total=$((total + db_size * 3 + 67108864))
done
# Account for SQL expansion, tar metadata and growth between inventory/copy.
total=$((total * 12 / 10 + 67108864))
echo "-- Estimated snapshot budget: $total bytes (+ archive overlap and free-space reserve)"
if [[ $(stat -c %d "$STAGING_ROOT") == "$(stat -c %d "$ARCHIVE_DIR")" ]]; then
ensure_space "$STAGING_ROOT" "$((total * 2))"
else
ensure_space "$STAGING_ROOT" "$total"
ensure_space "$ARCHIVE_DIR" "$total"
fi
}
+72 -11
View File
@@ -272,6 +272,49 @@ QUIESCE_SERVICES=(nc-cron)
self.assertEqual((self.wp/'payload.txt').read_text(),'payload for web') self.assertEqual((self.wp/'payload.txt').read_text(),'payload for web')
self.assertEqual(json.loads(self.state.read_text()),self.initial) self.assertEqual(json.loads(self.state.read_text()),self.initial)
def test_failed_backup_scratch_is_removed_after_service_recovery(self):
result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_TOOL='zstd')
self.assertFalse(list((self.base/'work/staging').iterdir()))
self.assertFalse(list((self.base/'work/archives').iterdir()))
self.assertEqual(json.loads(self.state.read_text()),self.initial)
self.assertIn('Retained work: none',result.stdout)
def test_space_failure_happens_before_writer_shutdown(self):
with self.config.open('a') as f: f.write('MIN_FREE_GB=100000\n')
result=self.run_script('app-backup.sh',ok=False)
self.assertIn('Insufficient space after safe cleanup',result.stdout)
self.assertNotIn('systemctl stop',self.events.read_text())
self.assertEqual(json.loads(self.state.read_text()),self.initial)
def test_emergency_retention_reads_remote_and_preserves_minimum(self):
self.crypt_config()
current=self.backup()
archives=current.parent
old=[]
for day in range(1,5):
name=f'appbackup_2020-01-0{day}_00-00-00_aaaaaaaa'
path=archives/name
shutil.copytree(current,path)
manifest=json.loads((path/'manifest.json').read_text())
manifest['run_id']=name
(path/'manifest.json').write_text(json.dumps(manifest))
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n')
subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'copy',str(path),'crypt:sets/'+name],check=True,capture_output=True)
old.append(path)
# Full remote readback must fail for the oldest copy: retain it.
subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'deletefile','crypt:sets/'+old[0].name+'/wordpress.tar.zst'],check=True,capture_output=True)
fake=self.bin/'df'
fake.write_text('#!/bin/sh\nprintf "Filesystem 1B-blocks Used Available Use%% Mounted\\nroot 100 100 0 100%% /\\n"\n')
fake.chmod(0o700)
result=self.run_script('app-backup.sh',ok=False)
self.assertIn('verification failed',result.stdout)
self.assertTrue(old[0].exists())
self.assertFalse(old[1].exists())
self.assertTrue(all(p.exists() for p in old[2:]+[current]))
remote=subprocess.check_output(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'lsf','crypt:sets','--dirs-only'],text=True)
self.assertIn(old[1].name,remote)
self.assertNotIn('systemctl stop',result.stdout)
def test_dump_and_copy_failures_do_not_publish(self): def test_dump_and_copy_failures_do_not_publish(self):
for tool in ['mariadb-dump','rsync','zstd']: for tool in ['mariadb-dump','rsync','zstd']:
with self.subTest(tool=tool): with self.subTest(tool=tool):
@@ -345,7 +388,7 @@ if '--exclude=/*' in sys.argv:
self.state.write_text(json.dumps(state)) self.state.write_text(json.dumps(state))
wrapper=self.bin/'rsync' wrapper=self.bin/'rsync'
wrapper.write_text('''#!/usr/bin/env bash wrapper.write_text('''#!/usr/bin/env bash
if [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]; then if [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* && " $* " != *" --dry-run "* ]]; then
systemctl start httpd systemctl start httpd
systemctl start php-fpm systemctl start php-fpm
systemctl start gitea systemctl start gitea
@@ -366,7 +409,7 @@ import json,os,subprocess,sys
from pathlib import Path from pathlib import Path
result=subprocess.run(['/usr/bin/rsync',*sys.argv[1:]]) result=subprocess.run(['/usr/bin/rsync',*sys.argv[1:]])
if result.returncode: sys.exit(result.returncode) if result.returncode: sys.exit(result.returncode)
if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv and '--dry-run' not in sys.argv:
p=Path(os.environ['TEST_STATE']) p=Path(os.environ['TEST_STATE'])
state=json.loads(p.read_text()) state=json.loads(p.read_text())
state['services']['httpd']='''+repr(final_state)+''' state['services']['httpd']='''+repr(final_state)+'''
@@ -461,7 +504,7 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv:
wrapper=self.bin/'rsync' wrapper=self.bin/'rsync'
# Signal only during the real copy, after services have stopped. The # Signal only during the real copy, after services have stopped. The
# new access probes run earlier and must not trigger this recovery test. # new access probes run earlier and must not trigger this recovery test.
wrapper.write_text('#!/usr/bin/env bash\nif [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]; then\n echo waiting > "$TEST_EVENTS.waiting"\n sleep 2\nfi\nexec /usr/bin/rsync "$@"\n') wrapper.write_text('#!/usr/bin/env bash\nif [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* && " $* " != *" --dry-run "* ]]; then\n echo waiting > "$TEST_EVENTS.waiting"\n sleep 2\nfi\nexec /usr/bin/rsync "$@"\n')
proc=subprocess.Popen(['bash',str(REPO/'usr-local-sbin/app-backup.sh'),'--config',str(self.config)],env=self.env,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT) proc=subprocess.Popen(['bash',str(REPO/'usr-local-sbin/app-backup.sh'),'--config',str(self.config)],env=self.env,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
try: try:
for _ in range(200): for _ in range(200):
@@ -587,19 +630,31 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv:
self.enable_report() self.enable_report()
with self.config.open('a') as f: with self.config.open('a') as f:
f.write('MAIL_HOSTNAME="gemini.jr-it-services.de"\nENABLE_RESTORE_TEST=true\nRESTORE_TEST_DB_IMAGE="test-image"\nRESTORE_TEST_MIN_FREE_GB=0\n') f.write('MAIL_HOSTNAME="gemini.jr-it-services.de"\nENABLE_RESTORE_TEST=true\nRESTORE_TEST_DB_IMAGE="test-image"\nRESTORE_TEST_MIN_FREE_GB=0\n')
# The real isolated DB import is covered separately. Updater preflight # Real image preparation/import is also exercised in the opt-in Docker suite.
# only inspects the prepared local image; it must never pull one.
docker=self.bin/'docker' docker=self.bin/'docker'
docker.write_text('''#!/usr/bin/env python3 docker.write_text('''#!/usr/bin/env python3
import json,sys import json,sys,os
assert sys.argv[1:] == ['--host=unix:///var/run/docker.sock','image','inspect','test-image'] from pathlib import Path
print(json.dumps([{'Id':'sha256:test-image','Config':{'Volumes':{'/var/lib/mysql':{}}}}])) prepared=Path(os.environ["TEST_EVENTS"]+".image-prepared")
args=sys.argv[2:]
if args[0]=='info': print('/tmp')
elif args[0]=='pull':
prepared.write_text('image downloaded once'); print('prepared')
elif args[:2]==['image','inspect']:
if not prepared.exists(): raise SystemExit(1)
if '--format' in args: print('sha256:test-image')
else: print(json.dumps([{'Id':'sha256:test-image','Config':{'Volumes':{'/var/lib/mysql':{}}}}]))
elif args[0]=='run' and '--rm' in args: print('mariadbd Ver 11.8.6-MariaDB')
elif args[0]=='run': print('test-container')
elif args[0]=='exec': print('1')
elif args[:2]==['container','ls']: pass
else: raise SystemExit('Unexpected Docker command')
''') ''')
docker.chmod(0o700) docker.chmod(0o700)
canonical=self.configure_upgrade() canonical=self.configure_upgrade()
original=canonical.read_bytes() original=canonical.read_bytes()
package=self.build_package() package=self.build_package()
result=subprocess.run(['bash',str(package),'--foreground'],env=self.env,capture_output=True,text=True,timeout=120) result=subprocess.run(['bash',str(package),'--foreground','--enable-restore-test'],env=self.env,capture_output=True,text=True,timeout=120)
self.assertEqual(result.returncode,0,result.stdout+result.stderr) self.assertEqual(result.returncode,0,result.stdout+result.stderr)
state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0])
self.assertIn('SUCCESS',(state/'result.txt').read_text()) self.assertIn('SUCCESS',(state/'result.txt').read_text())
@@ -608,13 +663,19 @@ print(json.dumps([{'Id':'sha256:test-image','Config':{'Volumes':{'/var/lib/mysql
self.assertIn(' SUCCESS gemini.jr-it-services.de ',str(message['Subject'])) self.assertIn(' SUCCESS gemini.jr-it-services.de ',str(message['Subject']))
self.assertIn('Host: gemini.jr-it-services.de',body.splitlines()) self.assertIn('Host: gemini.jr-it-services.de',body.splitlines())
self.assertIn('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text()) self.assertIn('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text())
self.assertIn('OK (Inhalt und Abschlussmarker',body) self.assertIn('Bereinigung: OK',body)
self.assertIn('[app-restore-test] SUCCESS',message['Subject'])
saved=next((self.base/'logs').glob('report_*.eml'))
backup_message=BytesParser(policy=policy.default).parsebytes(saved.read_bytes())
self.assertIn('OK (Inhalt und Abschlussmarker',backup_message.get_content())
self.assertEqual(Path('/usr/local/lib/app-backup/report.py').read_bytes(),(REPO/'lib/report.py').read_bytes()) self.assertEqual(Path('/usr/local/lib/app-backup/report.py').read_bytes(),(REPO/'lib/report.py').read_bytes())
self.assertEqual(Path('/usr/local/lib/app-backup/restore_test.py').read_bytes(),(REPO/'lib/restore_test.py').read_bytes()) self.assertEqual(Path('/usr/local/lib/app-backup/restore_test.py').read_bytes(),(REPO/'lib/restore_test.py').read_bytes())
self.assertTrue(Path('/etc/systemd/system/app-restore-test.timer').is_file()) self.assertTrue(Path('/etc/systemd/system/app-restore-test.timer').is_file())
self.assertEqual(json.loads(self.state.read_text())['services']['app-restore-test.timer'],'active') self.assertEqual(json.loads(self.state.read_text())['services']['app-restore-test.timer'],'active')
self.assertTrue((state/'verified-backup-run').is_file()) self.assertTrue((state/'verified-backup-run').is_file())
self.assertIn('ENABLE_REMOTE_RETENTION=false',canonical.read_text()) self.assertIn('ENABLE_REMOTE_RETENTION=false',canonical.read_text())
self.assertIn('RESTORE_TEST_MAX_WORK_GB=0',canonical.read_text())
self.assertTrue(Path(str(self.events)+'.image-prepared').exists())
self.assertEqual(self.cnf.stat().st_mode & 0o777,0o600) self.assertEqual(self.cnf.stat().st_mode & 0o777,0o600)
self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active')
rollback=subprocess.run(['bash',str(state/'rollback-update.sh')],env=self.env,capture_output=True,text=True,timeout=30) rollback=subprocess.run(['bash',str(state/'rollback-update.sh')],env=self.env,capture_output=True,text=True,timeout=30)
@@ -827,7 +888,7 @@ if '--no-block' not in args:
self.systemd_launcher() self.systemd_launcher()
wrapper=self.bin/tool wrapper=self.bin/tool
if tool=='rsync': if tool=='rsync':
match='[[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]' match='[[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* && " $* " != *" --dry-run "* ]]'
else: else:
match='[[ " $* " == *" copy "* ]]' match='[[ " $* " == *" copy "* ]]'
wrapper.write_text('#!/usr/bin/env bash\nif '+match+'; then\n echo waiting > "$TEST_EVENTS.waiting"\n sleep 120\nfi\nexec /usr/bin/'+tool+' "$@"\n') wrapper.write_text('#!/usr/bin/env bash\nif '+match+'; then\n echo waiting > "$TEST_EVENTS.waiting"\n sleep 120\nfi\nexec /usr/bin/'+tool+' "$@"\n')
+77
View File
@@ -311,6 +311,83 @@ exec /usr/bin/rclone "$@"
with self.config.open('a') as f: with self.config.open('a') as f:
f.write(f'ENABLE_DB_DUMPS=true\nWP_DB_NAME=production\nWP_DB_CNF=/unused\nRESTORE_TEST_DB_IMAGE="{image}"\nRESTORE_TEST_TIMEOUT=120\n') f.write(f'ENABLE_DB_DUMPS=true\nWP_DB_NAME=production\nWP_DB_CNF=/unused\nRESTORE_TEST_DB_IMAGE="{image}"\nRESTORE_TEST_TIMEOUT=120\n')
@unittest.skipUnless(os.environ.get('RESTORE_TEST_REAL_DOCKER_IMAGE'), 'opt-in local Docker integration')
def test_installer_detects_mariadb_and_probes_real_image(self):
self.prepare_database(b'CREATE TABLE example (id INT);')
cnf=self.base/'backup.cnf'
cnf.write_text('[client]\n')
cnf.chmod(0o600)
client=self.base/'version-client'
client.write_text('#!/bin/sh\necho 10.11.18-MariaDB\n')
client.chmod(0o700)
with self.config.open('a') as f:
f.write(f'WP_DB_CNF="{cnf}"\nDB_CLIENT_BIN="{client}"\nRESTORE_TEST_DB_IMAGE=""\n')
result=subprocess.run(['bash',str(REPO/'lib/prepare-restore-test.sh'),str(self.config)],capture_output=True,text=True,timeout=120)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
self.assertIn('RESTORE_TEST_DB_IMAGE=mariadb:10.11',self.config.read_text())
self.assertIn('initialization, import, check and cleanup passed',result.stdout)
self.assertFalse((self.root/'last-success.json').exists())
self.no_container()
@unittest.skipUnless(os.environ.get('RESTORE_TEST_REAL_DOCKER_IMAGE'), 'opt-in local Docker integration')
def test_installer_rejects_wrong_mariadb_series_without_config_change(self):
self.prepare_database(b'CREATE TABLE example (id INT);')
cnf=self.base/'backup.cnf'
cnf.write_text('[client]\n')
cnf.chmod(0o600)
client=self.base/'version-client'
client.write_text('#!/bin/sh\necho 11.4.1-MariaDB\n')
client.chmod(0o700)
with self.config.open('a') as f: f.write(f'WP_DB_CNF="{cnf}"\nDB_CLIENT_BIN="{client}"\n')
original=self.config.read_bytes()
result=subprocess.run(['bash',str(REPO/'lib/prepare-restore-test.sh'),str(self.config)],capture_output=True,text=True,timeout=60)
self.assertNotEqual(result.returncode,0,result.stdout+result.stderr)
self.assertIn('does not match production MariaDB series',result.stdout+result.stderr)
self.assertEqual(self.config.read_bytes(),original)
self.assertFalse(self.root.exists())
def test_space_recovery_uses_parent_lock_and_preserves_three_local_sets(self):
self.work.chmod(0o700)
for name in ('archives','staging','restore'): (self.work/name).mkdir(mode=0o700)
old=[]
for day in range(1,5):
run=f'appbackup_2020-01-0{day}_00-00-00_aaaaaaaa'
path=self.work/'archives'/run
shutil.copytree(self.folder,path)
path.chmod(0o700)
data=json.loads((path/'manifest.json').read_text())
data['run_id']=run
(path/'manifest.json').write_text(json.dumps(data))
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+backup_set.digest(path/'manifest.json')+'\n')
(path/'UPLOADED').write_text('verified previously\n')
subprocess.run(['rclone','--config',str(self.crypt),'copy',str(path),'crypt:sets/'+run],check=True,capture_output=True)
old.append(path)
with self.config.open('a') as f: f.write('RESTORE_TEST_MIN_FREE_GB=0.001\n')
# Simulate pressure consistently for the Python supervisor and Bash df.
# Once one old local set is removed, the real free-space figure returns.
bindir=self.base/'bin'
bindir.mkdir()
script=self.base/'supervisor.py'
script.write_text('import os,runpy,shutil,sys\nfrom pathlib import Path\noriginal=shutil.disk_usage\npressure=Path('+repr(str(old[0]))+')\ndef usage(p):\n value=original(p)\n return value._replace(free=0) if pressure.exists() else value\nshutil.disk_usage=usage\npath=sys.argv.pop(1)\nsys.path.insert(0,str(Path(path).parent))\nrunpy.run_path(path,run_name="__main__")\n')
python=bindir/'python3'
python.write_text('#!/bin/sh\ncase "$1" in */restore_test.py) exec /usr/bin/python3 "'+str(script)+'" "$@";; esac\nexec /usr/bin/python3 "$@"\n')
python.chmod(0o700)
df=bindir/'df'
df.write_text('#!/bin/sh\nif test -d "'+str(old[0])+'"; then printf "Filesystem 1B-blocks Used Available Use%% Mounted\\nroot 100 100 0 100%% /\\n"; else exec /usr/bin/df "$@"; fi\n')
df.chmod(0o700)
result=subprocess.run(['bash',str(REPO/'usr-local-sbin/app-restore-test.sh'),'--config',str(self.config)],env=dict(os.environ,PATH=str(bindir)+':'+os.environ['PATH']),capture_output=True,text=True,timeout=60)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
self.assertFalse(old[0].exists())
self.assertTrue(all(p.exists() for p in old[1:]))
self.assertEqual(self.state()['status'],'SUCCESS')
self.clean()
def test_adaptive_budget_accepts_zero(self):
with self.config.open('a') as f: f.write('RESTORE_TEST_MAX_WORK_GB=0\n')
self.execute()
self.clean()
self.assertEqual(self.state()['status'],'SUCCESS')
def no_container(self): def no_container(self):
self.clean() self.clean()
names=subprocess.check_output(restore_test.DOCKER+['ps','-a','--filter',f'label={restore_test.OWNER_LABEL}', '--format','{{.Names}}'],text=True) names=subprocess.check_output(restore_test.DOCKER+['ps','-a','--filter',f'label={restore_test.OWNER_LABEL}', '--format','{{.Names}}'],text=True)
+81
View File
@@ -0,0 +1,81 @@
"""Recovery policy tests: reclaim only owned scratch, protect recovery/last copies."""
import importlib.util
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'lib'))
import space
@unittest.skipUnless(os.geteuid() == 0, 'root-only cleanup policy')
class SpaceRecovery(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.work = Path(self.temp.name)
self.staging = self.work/'staging'
self.archives = self.work/'archives'
self.restore = self.work/'restore'
for p in (self.staging, self.archives, self.restore): p.mkdir(mode=0o700)
self.run = 'appbackup_2026-09-12_17-07-56_bd1f6193'
self.stage = self.staging/(self.run+'.5ICERN')
self.stage.mkdir(mode=0o700)
(self.stage/'meta').mkdir()
(self.stage/'meta/run-id.txt').write_text(self.run+'\n')
(self.stage/'meta/format.txt').write_text('format=2\ncompressor=zstd\n')
self.partial = self.archives/('.partial-'+self.run)
self.partial.mkdir(mode=0o700)
(self.partial/'meta.tar.zst').write_text('incomplete scratch')
def clean(self):
space.clean(self.work, self.staging, self.archives, 'appbackup', self.restore)
def test_gemini_failed_compression_reclaims_both_trees(self):
unknown = self.staging/'keep-manual-files'
unknown.mkdir(mode=0o700)
self.clean()
self.assertFalse(self.stage.exists())
self.assertFalse(self.partial.exists())
self.assertTrue(unknown.exists())
def test_recovery_journal_blocks_all_cleanup(self):
(self.work/('recovery-'+self.run+'.json')).write_text('{}')
with self.assertRaisesRegex(ValueError, 'recovery journal'): self.clean()
self.assertTrue(self.stage.exists())
self.assertTrue(self.partial.exists())
def test_complete_partial_is_preserved(self):
(self.partial/'COMPLETE').write_text('possibly only complete snapshot')
self.clean()
self.assertTrue(self.stage.exists())
self.assertTrue(self.partial.exists())
def test_nested_symlink_does_not_delete_target(self):
target = self.work/'production'
target.mkdir()
(target/'data').write_text('live')
(self.stage/'link').symlink_to(target)
self.clean()
self.assertEqual((target/'data').read_text(), 'live')
def test_mount_is_never_removed(self):
mount = self.stage/'mounted'
mount.mkdir()
with patch('space.os.path.ismount', side_effect=lambda p: p == mount):
with self.assertRaisesRegex(ValueError, 'mounted'): self.clean()
self.assertTrue(mount.exists())
def test_verification_download_recovered_manual_restore_kept(self):
verify = self.restore/(self.run+'.abcdef')
verify.mkdir(mode=0o700)
(verify/'.verify-only').write_text(self.run+'\n')
manual = self.restore/(self.run+'.ghijkl')
manual.mkdir(mode=0o700)
self.clean()
self.assertFalse(verify.exists())
self.assertTrue(manual.exists())
+3 -2
View File
@@ -15,7 +15,8 @@ FILES = ['install.sh', 'update.sh', 'README.md', 'Dokumentation.md', 'UPDATE_ANL
'usr-local-sbin/app-backup.sh', 'usr-local-sbin/app-restore.sh', 'usr-local-sbin/app-backup.sh', 'usr-local-sbin/app-restore.sh',
'systemd/app-backup.service', 'systemd/app-backup.timer', 'logrotate/app-backup', 'systemd/app-backup.service', 'systemd/app-backup.timer', 'logrotate/app-backup',
'etc/app-backup/app-backup.conf'] 'etc/app-backup/app-backup.conf']
FILES += ['RESTORE_TEST.md', 'usr-local-sbin/app-restore-test.sh', 'lib/restore_test.py', 'lib/restore-test-remote.sh', FILES += ['RESTORE_TEST.md', 'SPEICHER.md', 'usr-local-sbin/app-restore-test.sh', 'lib/restore_test.py', 'lib/restore-test-remote.sh',
'lib/space.sh', 'lib/space.py', 'lib/prepare-restore-test.sh',
'systemd/app-restore-test.service', 'systemd/app-restore-test.timer', 'systemd/app-restore-test.service', 'systemd/app-restore-test.timer',
'systemd/app-restore-test-cleanup.service'] 'systemd/app-restore-test-cleanup.service']
@@ -49,7 +50,7 @@ def build(output, version):
set -Eeuo pipefail set -Eeuo pipefail
umask 077 umask 077
if [[ ${{1:-}} == --help ]]; then if [[ ${{1:-}} == --help ]]; then
echo 'Usage: sudo bash app-backup-update-{version}.run [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps]' echo 'Usage: sudo bash app-backup-update-{version}.run [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps] [--enable-restore-test]'
exit 0 exit 0
fi fi
[[ $EUID == 0 ]] || {{ echo 'Bitte mit sudo bash starten.' >&2; exit 1; }} [[ $EUID == 0 ]] || {{ echo 'Bitte mit sudo bash starten.' >&2; exit 1; }}
+23 -5
View File
@@ -19,11 +19,13 @@ UP_MAIL_DIR=''
UP_MAIL_HOMES=false UP_MAIL_HOMES=false
UP_STATE='' UP_STATE=''
UP_WORKER=false UP_WORKER=false
UP_RESTORE_TEST=false
while (( $# )); do while (( $# )); do
case $1 in case $1 in
--config) CONFIG_FILE=${2:?Missing configuration}; shift 2 ;; --config) CONFIG_FILE=${2:?Missing configuration}; shift 2 ;;
--foreground) UP_FOREGROUND=true; shift ;; --foreground) UP_FOREGROUND=true; shift ;;
--install-deps) UP_INSTALL_DEPS=true; shift ;; --install-deps) UP_INSTALL_DEPS=true; shift ;;
--enable-restore-test) UP_RESTORE_TEST=true; shift ;;
--enable-gitea) UP_GITEA=true; shift ;; --enable-gitea) UP_GITEA=true; shift ;;
--no-gitea) UP_GITEA=false; shift ;; --no-gitea) UP_GITEA=false; shift ;;
--rclone-config) UP_RCLONE=${2:?Missing rclone configuration}; shift 2 ;; --rclone-config) UP_RCLONE=${2:?Missing rclone configuration}; shift 2 ;;
@@ -31,7 +33,7 @@ while (( $# )); do
--mail-dir) UP_MAIL_DIR=${2:?Missing mailbox directory}; shift 2 ;; --mail-dir) UP_MAIL_DIR=${2:?Missing mailbox directory}; shift 2 ;;
--mail-homes-only) UP_MAIL_HOMES=true; shift ;; --mail-homes-only) UP_MAIL_HOMES=true; shift ;;
--worker) UP_WORKER=true; UP_STATE=${2:?Missing state}; shift 2 ;; --worker) UP_WORKER=true; UP_STATE=${2:?Missing state}; shift 2 ;;
--help) echo 'Usage: sudo bash update.sh [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps]'; exit 0 ;; --help) echo 'Usage: sudo bash update.sh [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps] [--enable-restore-test]'; exit 0 ;;
*) die "Unknown argument: $1" ;; *) die "Unknown argument: $1" ;;
esac esac
done done
@@ -134,10 +136,14 @@ if [[ $UP_WORKER == false ]]; then
chmod 600 "$UP_STATE/candidate.conf" chmod 600 "$UP_STATE/candidate.conf"
printf '\n# Automated package migration (original preserved in update snapshot)\n' >> "$UP_STATE/candidate.conf" printf '\n# Automated package migration (original preserved in update snapshot)\n' >> "$UP_STATE/candidate.conf"
append_setting ENABLE_REMOTE_RETENTION false append_setting ENABLE_REMOTE_RETENTION false
# Retention of existing local sets is also skipped for the acceptance backup. # Normal local age retention is skipped; safe space recovery remains enabled.
append_setting ENABLE_UPLOAD true append_setting ENABLE_UPLOAD true
append_setting ENABLE_NEXTCLOUD_MAINTENANCE true append_setting ENABLE_NEXTCLOUD_MAINTENANCE true
append_setting MAIL_BACKUP_MODE stop append_setting MAIL_BACKUP_MODE stop
if [[ $UP_RESTORE_TEST == true ]]; then
append_setting ENABLE_RESTORE_TEST true
append_setting RESTORE_TEST_MAX_WORK_GB 0
fi
prepare_mail prepare_mail
if [[ $ENABLE_WORDPRESS == true || $ENABLE_NEXTCLOUD == true ]]; then if [[ $ENABLE_WORDPRESS == true || $ENABLE_NEXTCLOUD == true ]]; then
for pair in "ENABLE_HTTPD_STOP:$HTTPD_SERVICE_NAME" "ENABLE_PHPFPM_STOP:$PHPFPM_SERVICE_NAME"; do for pair in "ENABLE_HTTPD_STOP:$HTTPD_SERVICE_NAME" "ENABLE_PHPFPM_STOP:$PHPFPM_SERVICE_NAME"; do
@@ -239,10 +245,18 @@ if [[ $UP_WORKER == false ]]; then
case ${DB_LABELS[i]} in db-wordpress) key=WP_DB_CNF ;; db-nextcloud) key=NC_DB_CNF ;; db-gitea) key=GITEA_DB_CNF ;; *) die 'Unknown database label' ;; esac case ${DB_LABELS[i]} in db-wordpress) key=WP_DB_CNF ;; db-nextcloud) key=NC_DB_CNF ;; db-gitea) key=GITEA_DB_CNF ;; *) die 'Unknown database label' ;; esac
printf '%s=%q\n' "$key" "$UP_STATE/db-$i.cnf" >> "$UP_STATE/check.conf" printf '%s=%q\n' "$key" "$UP_STATE/db-$i.cnf" >> "$UP_STATE/check.conf"
done done
APP_BACKUP_INHERIT_LOCK=1 bash "$UPDATE_ROOT/usr-local-sbin/app-backup.sh" --config "$UP_STATE/check.conf" --check > "$UP_STATE/preflight.log" 2>&1 || { APP_BACKUP_INHERIT_LOCK=1 bash "$UPDATE_ROOT/usr-local-sbin/app-backup.sh" --config "$UP_STATE/check.conf" --prepare-space > "$UP_STATE/preflight.log" 2>&1 || {
tail -n 40 "$UP_STATE/preflight.log"; die "Preflight failed; installed package/config/timer unchanged. Details: $UP_STATE"; tail -n 40 "$UP_STATE/preflight.log"; die "Preflight failed; installed package/config/timer unchanged. Details: $UP_STATE";
} }
if [[ $ENABLE_RESTORE_TEST == true ]]; then if [[ $ENABLE_RESTORE_TEST == true ]]; then
APP_BACKUP_INHERIT_LOCK=1 bash "$UPDATE_ROOT/usr-local-sbin/app-restore-test.sh" --config "$UP_STATE/check.conf" --cleanup >> "$UP_STATE/preflight.log" 2>&1
APP_BACKUP_INHERIT_LOCK=1 bash "$LIB_DIR/prepare-restore-test.sh" "$UP_STATE/check.conf" >> "$UP_STATE/preflight.log" 2>&1 || {
tail -n 30 "$UP_STATE/preflight.log"; die 'Automatic MariaDB preparation failed; see preflight.log';
}
# The preparation publishes a detected image only after the isolated probe.
CONFIG_FILE="$UP_STATE/check.conf"
load_config
append_setting RESTORE_TEST_DB_IMAGE "$RESTORE_TEST_DB_IMAGE"
APP_BACKUP_INHERIT_LOCK=1 bash "$UPDATE_ROOT/usr-local-sbin/app-restore-test.sh" --config "$UP_STATE/check.conf" --check >> "$UP_STATE/preflight.log" 2>&1 || { APP_BACKUP_INHERIT_LOCK=1 bash "$UPDATE_ROOT/usr-local-sbin/app-restore-test.sh" --config "$UP_STATE/check.conf" --check >> "$UP_STATE/preflight.log" 2>&1 || {
tail -n 20 "$UP_STATE/preflight.log"; die 'Restore-test prerequisites failed; prepare local DB image and disk budget first'; tail -n 20 "$UP_STATE/preflight.log"; die 'Restore-test prerequisites failed; prepare local DB image and disk budget first';
} }
@@ -367,7 +381,7 @@ build_plan
for file in "${DB_CNFS[@]}"; do secure_file "$file"; chmod 600 "$file"; done for file in "${DB_CNFS[@]}"; do secure_file "$file"; chmod 600 "$file"; done
# Hold the shared lock across every child. This also excludes manual restores. # Hold the shared lock across every child. This also excludes manual restores.
bash /usr/local/sbin/app-backup.sh --config "$CONFIG_FILE" --check bash /usr/local/sbin/app-backup.sh --config "$CONFIG_FILE" --check
# Disable all retention in this acceptance run, including local retention. # Skip normal age retention for acceptance; retain safe local space recovery.
bash /usr/local/sbin/app-backup.sh --config "$CONFIG_FILE" --no-retention bash /usr/local/sbin/app-backup.sh --config "$CONFIG_FILE" --no-retention
backup_run=$(python3 - "$WORKDIR/last-backup.json" <<'PY' backup_run=$(python3 - "$WORKDIR/last-backup.json" <<'PY'
import json,sys import json,sys
@@ -376,7 +390,11 @@ assert value['exit_code'] == 0
print(value['run']) print(value['run'])
PY PY
) )
bash /usr/local/sbin/app-restore.sh --config "$CONFIG_FILE" --remote-run "$backup_run" --verify-only if [[ $ENABLE_RESTORE_TEST == true ]]; then
bash /usr/local/sbin/app-restore-test.sh --config "$CONFIG_FILE" --remote-run "$backup_run"
else
bash /usr/local/sbin/app-restore.sh --config "$CONFIG_FILE" --remote-run "$backup_run" --verify-only
fi
printf '%s\n' "$backup_run" > "$UP_STATE/verified-backup-run" printf '%s\n' "$backup_run" > "$UP_STATE/verified-backup-run"
# A missed persistent timer can fire immediately, so release the shared lock first. # A missed persistent timer can fire immediately, so release the shared lock first.
unset APP_BACKUP_INHERIT_LOCK unset APP_BACKUP_INHERIT_LOCK
+12 -1
View File
@@ -9,12 +9,14 @@ source "$LIB_DIR/common.sh"
CONFIG_FILE=/etc/app-backup/app-backup.conf CONFIG_FILE=/etc/app-backup/app-backup.conf
CHECK=false CHECK=false
SKIP_RETENTION=false SKIP_RETENTION=false
PREPARE_SPACE=false
while (( $# )); do while (( $# )); do
case $1 in case $1 in
--config) CONFIG_FILE=${2:?Missing config path}; shift 2 ;; --config) CONFIG_FILE=${2:?Missing config path}; shift 2 ;;
--check) CHECK=true; shift ;; --check) CHECK=true; shift ;;
--prepare-space) PREPARE_SPACE=true; CHECK=true; shift ;;
--no-retention) SKIP_RETENTION=true; shift ;; --no-retention) SKIP_RETENTION=true; shift ;;
--help) echo 'Usage: app-backup.sh [--config FILE] [--check] [--no-retention]'; exit 0 ;; --help) echo 'Usage: app-backup.sh [--config FILE] [--check | --prepare-space] [--no-retention]'; exit 0 ;;
*) die "Unknown argument: $1" ;; *) die "Unknown argument: $1" ;;
esac esac
done done
@@ -22,6 +24,9 @@ OPERATION=backup
load_config load_config
init_runtime init_runtime
build_plan build_plan
# shellcheck source=lib/space.sh
source "$LIB_DIR/space.sh"
if [[ $CHECK == false || $PREPARE_SPACE == true ]]; then prepare_backup_space; fi
preflight preflight
if [[ $CHECK == true ]]; then if [[ $CHECK == true ]]; then
echo 'Preflight passed. Directory metadata probed; no application backup, service stop or upload performed.' echo 'Preflight passed. Directory metadata probed; no application backup, service stop or upload performed.'
@@ -75,6 +80,9 @@ assert_services_quiescent
echo '-- Copy window complete; writer guards verified' echo '-- Copy window complete; writer guards verified'
BACKUP_PHASE='Dienste wiederherstellen' BACKUP_PHASE='Dienste wiederherstellen'
resume_services || die 'Failed to resume original service state' resume_services || die 'Failed to resume original service state'
if [[ $ENABLE_MAIL_REPORT == true ]]; then
python3 "$LIB_DIR/report.py" snapshot "$LOG_DIR/report_$RUN_ID.sizes.json" "$STAGING_DIR" "$PARTIAL_DIR" || echo 'WARNING: Report sizes unavailable'
fi
while IFS=$'\t' read -r label _; do while IFS=$'\t' read -r label _; do
if [[ $label == meta ]]; then root=meta if [[ $label == meta ]]; then root=meta
@@ -93,6 +101,9 @@ while IFS=$'\t' read -r label _; do
gzip -6 -c > "$out.partial" gzip -6 -c > "$out.partial"
fi fi
mv -- "$out.partial" "$out" mv -- "$out.partial" "$out"
helper tar "$out" "$root"
# Retain small metadata/plan for publication and recognized crash recovery.
if [[ $label != meta ]]; then rm -rf -- "${STAGING_DIR:?}/$root"; fi
done < "$PLAN" done < "$PLAN"
BACKUP_PHASE='Lokale Archivprüfung' BACKUP_PHASE='Lokale Archivprüfung'
helper create "$PARTIAL_DIR" "$RUN_ID" "$PLAN" helper create "$PARTIAL_DIR" "$RUN_ID" "$PLAN"
+1
View File
@@ -14,6 +14,7 @@ while (( $# )); do
--config) CONFIG_FILE=${2:?Missing config}; shift 2 ;; --config) CONFIG_FILE=${2:?Missing config}; shift 2 ;;
--check) MODE=check; shift ;; --check) MODE=check; shift ;;
--cleanup) MODE=cleanup; shift ;; --cleanup) MODE=cleanup; shift ;;
--prepare-db) MODE=prepare; shift ;;
--remote-run) REMOTE_RUN=${2:?Missing run}; shift 2 ;; --remote-run) REMOTE_RUN=${2:?Missing run}; shift 2 ;;
--help) echo 'Usage: app-restore-test.sh [--config FILE] [--check | --cleanup] [--remote-run NAME]'; exit 0 ;; --help) echo 'Usage: app-restore-test.sh [--config FILE] [--check | --cleanup] [--remote-run NAME]'; exit 0 ;;
*) die "Unknown argument: $1" ;; *) die "Unknown argument: $1" ;;
+59 -1
View File
@@ -38,18 +38,71 @@ done
OPERATION=restore OPERATION=restore
load_config load_config
init_runtime init_runtime
check_space "$RESTORE_ROOT" # shellcheck source=lib/space.sh
source "$LIB_DIR/space.sh"
clean_backup_scratch
ensure_space "$RESTORE_ROOT"
STAGING_DIR=$(mktemp -d "$RESTORE_ROOT/${RUN_ID}.XXXXXX") STAGING_DIR=$(mktemp -d "$RESTORE_ROOT/${RUN_ID}.XXXXXX")
if [[ $MODE == verify ]]; then printf '%s\n' "$RUN_ID" > "$STAGING_DIR/.verify-only"; fi
INPUT="$STAGING_DIR/input" INPUT="$STAGING_DIR/input"
mkdir -m 700 "$INPUT" mkdir -m 700 "$INPUT"
if [[ -n $REMOTE_RUN ]]; then if [[ -n $REMOTE_RUN ]]; then
[[ $REMOTE_RUN =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]+$ && $REMOTE_RUN == "$ARCHIVE_PREFIX"_* ]] || die 'Unsafe remote run name' [[ $REMOTE_RUN =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]+$ && $REMOTE_RUN == "$ARCHIVE_PREFIX"_* ]] || die 'Unsafe remote run name'
check_remote check_remote
if [[ $LEGACY == false ]]; then
remote="${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN"
rc cat "$remote/manifest.json" --count 1048577 > "$INPUT/manifest.json"
rc cat "$remote/COMPLETE" --count 256 > "$INPUT/COMPLETE"
helper metadata "$INPUT" >/dev/null
python3 - "$INPUT" "$REMOTE_RUN" <<'PY' > "$STAGING_DIR/verify.tsv"
import json,sys
from pathlib import Path
data=json.loads((Path(sys.argv[1])/'manifest.json').read_text())
assert data['run_id'] == sys.argv[2], 'Remote run ID mismatch'
for item in data['archives']:
print('\t'.join(str(item[k]) for k in ('file','root','size','sha256')))
PY
rc lsf "$remote" --files-only --max-depth 1 > "$STAGING_DIR/remote-files.txt"
python3 - "$INPUT/manifest.json" "$STAGING_DIR/remote-files.txt" <<'PY'
import json,sys
from pathlib import Path
expected={x['file'] for x in json.loads(Path(sys.argv[1]).read_text())['archives']} | {'manifest.json','COMPLETE'}
actual=set(Path(sys.argv[2]).read_text().splitlines())
assert actual in (expected, expected | {'UPLOADED'}), 'Remote backup has missing or unexpected files'
PY
fi
if [[ $MODE == verify ]]; then
# Verify offsite archives sequentially, retaining at most one compressed file.
while IFS=$'\t' read -r file root size hash; do
ensure_space "$RESTORE_ROOT" "$size"
rc cat "$remote/$file" --count "$((size + 1))" > "$INPUT/$file"
[[ $(stat -c %s "$INPUT/$file") == "$size" && $(sha256sum "$INPUT/$file" | cut -d ' ' -f 1) == "$hash" ]] || die "Archive checksum/size mismatch: $file"
helper tar "$INPUT/$file" "$root"
rm -f -- "$INPUT/$file"
done < "$STAGING_DIR/verify.tsv"
echo 'Verification passed; remote archives checked sequentially; no application changes.'
exit 0
fi
if [[ $LEGACY == false ]]; then
size=$(python3 - "$INPUT/manifest.json" <<'PY'
import json,sys
print(sum(x['size'] for x in json.load(open(sys.argv[1]))['archives']))
PY
)
ensure_space "$RESTORE_ROOT" "$size"
fi
echo "-- Downloading remote backup for restore verification: $REMOTE_RUN" echo "-- Downloading remote backup for restore verification: $REMOTE_RUN"
rc copy "${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN" "$INPUT" rc copy "${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN" "$INPUT"
echo '-- Remote backup downloaded; validating manifest, hashes and archives' echo '-- Remote backup downloaded; validating manifest, hashes and archives'
else else
[[ -d $LOCAL_RUN && ! -L $LOCAL_RUN ]] || die 'Local run must be an existing directory' [[ -d $LOCAL_RUN && ! -L $LOCAL_RUN ]] || die 'Local run must be an existing directory'
if [[ $MODE == verify ]]; then
helper verify "$LOCAL_RUN"
echo 'Verification passed; local archives checked without copying; no application changes.'
exit 0
fi
size=$(du -s -B1 -- "$LOCAL_RUN" | awk '{print $1}')
ensure_space "$RESTORE_ROOT" "$size"
# Resolve overlap separately: archive sources are allowed under WORKDIR, but # Resolve overlap separately: archive sources are allowed under WORKDIR, but
# recursive copies into the freshly created restore workspace are forbidden. # recursive copies into the freshly created restore workspace are forbidden.
input_real=$(realpath -e "$LOCAL_RUN") input_real=$(realpath -e "$LOCAL_RUN")
@@ -85,6 +138,8 @@ helper verify "$INPUT"
if [[ $MODE == verify ]]; then echo 'Verification passed; no application changes.'; exit 0; fi if [[ $MODE == verify ]]; then echo 'Verification passed; no application changes.'; exit 0; fi
helper list "$INPUT" > "$STAGING_DIR/archive-list.tsv" helper list "$INPUT" > "$STAGING_DIR/archive-list.tsv"
if [[ $MODE == extract ]]; then if [[ $MODE == extract ]]; then
size=$(helper expanded "$INPUT")
ensure_space "$RESTORE_ROOT" "$size"
mkdir "$STAGING_DIR/extracted" mkdir "$STAGING_DIR/extracted"
while IFS=$'\t' read -r label file _; do while IFS=$'\t' read -r label file _; do
mkdir "$STAGING_DIR/extracted/$label" mkdir "$STAGING_DIR/extracted/$label"
@@ -137,6 +192,9 @@ if [[ $MODE == plan ]]; then
exit 0 exit 0
fi fi
mkdir "$STAGING_DIR/extracted" mkdir "$STAGING_DIR/extracted"
# Budget all extracted components before productive writer shutdown/mutation.
size=$(helper expanded "$INPUT")
ensure_space "$RESTORE_ROOT" "$size"
# Each component gets an independent extraction root, so links in one component # Each component gets an independent extraction root, so links in one component
# cannot redirect extraction of a later component into another tree. # cannot redirect extraction of a later component into another tree.
while IFS=$'\t' read -r label file _; do while IFS=$'\t' read -r label file _; do