diff --git a/Dokumentation.md b/Dokumentation.md index 4d94f65..708c614 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -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:** 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:** 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:** 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. | @@ -198,7 +198,7 @@ Quelle: [rclone 1.74.3 Inhaltsprüfung](https://github.com/rclone/rclone/blob/v1 | 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. | -| 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. | | 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. | @@ -318,19 +318,43 @@ Homes-Bestätigung; Details stehen in [UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md) ### 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. - Erkennt nur v2-Laufnamen und einen zum Manifest passenden Abschlussmarker. - 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. - 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. -- 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. +- 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. 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. @@ -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 Sperre wartete. Ursache, Diagnose und Wiederanlauf beschreibt die Update-Anleitung. -Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keinerlei lokale oder entfernte -Löschung alter Sicherungssätze. Nach Upload/Rücklesevergleich prüft zusätzlich -`app-restore.sh --remote-run ... --verify-only` den erneut heruntergeladenen Satz. -Erst danach wird der vorhandene Backup-Timer aktiviert. Remote-Retention bleibt +Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keine reguläre Altersretention; +die sichere lokale Platzbereinigung bleibt erlaubt. Nach Upload/Rücklesevergleich +prüft bei aktiviertem Monatstest `app-restore-test.sh --remote-run ...` den konkreten +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. 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 bereinigt; systemd und eine Boot-Einheit übernehmen die Nachbereinigung. -Die Defaults von 4 GiB Arbeitsbudget und 2 GiB freier Reserve sind an den -tatsächlichen Platz und die größte Komponente anzupassen. Die Überwachung ist +Standard sind ein adaptives Arbeitsbudget (`RESTORE_TEST_MAX_WORK_GB=0`) und +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, 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 Entpacken und DB-Import mit Tabellenprüfung. Fachliche Anwendungswiederherstellung diff --git a/README.md b/README.md index d07406a..e175cdf 100644 --- a/README.md +++ b/README.md @@ -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 aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern. -Für knappen Speicher wird jeweils nur eine Komponente verarbeitet. Das lokale -DB-Image und die Speichergrenzen einmal vorbereiten und `ENABLE_RESTORE_TEST=true` -setzen; das Update-Paket aktiviert dann den monatlichen Timer und die -Boot-Bereinigung. Ohne diese Einstellung bleibt der Test zunächst deaktiviert. +Für knappen Speicher wird jeweils nur eine Komponente verarbeitet. Das Update +mit `--enable-restore-test` erkennt die Produktions-MariaDB, bereitet das passende +lokale Docker-Image vor und prüft Initialisierung, Import und Bereinigung. Nach +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 @@ -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. - 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. -- 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. - Installer erhält die Serverkonfiguration und den Zeitplan und erstellt ein Rollback-Paket. diff --git a/RESTORE_TEST.md b/RESTORE_TEST.md index 1e95f85..4e898bb 100644 --- a/RESTORE_TEST.md +++ b/RESTORE_TEST.md @@ -25,11 +25,15 @@ Der Spitzenbedarf ist ungefähr das Maximum aus: - SQL-Dump plus zugehöriger Testdatenbank einschließlich MariaDB-Systemtabellen, Indexen und temporären DB-Dateien. -Standard: höchstens **4 GiB geplantes Test-Arbeitsvolumen**, zusätzlich mindestens -**2 GiB freie Reserve**. Die Komponente muss vollständig in dieses Budget passen; -sonst scheitert der Test mit einem Bericht und bereinigt seine Dateien. Es wird -nicht still auf eine Zufallsdatei oder eine Teilprüfung ausgewichen. Das Budget -lässt sich nach Prüfung mit `df -h /var/backups/app-backup` anpassen. +Standard: **adaptives Arbeitsbudget** (`RESTORE_TEST_MAX_WORK_GB=0`) mit mindestens +**2 GiB freier Reserve**. Ein positiver Wert setzt zusätzlich eine feste Grenze. +Die größte Komponente muss vollständig passen. Die frühere Vorgabe von 4 GiB +reicht für Geminis rund 8,8 GB Nextcloud-Daten nicht; die Update-Option +`--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 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. Nur der eindeutig zu diesem Test gehörende Container und das markierte -`$WORKDIR/restore-test/scratch` werden bereinigt. Vorhandene Backups, reguläre -manuelle Restore-Verzeichnisse und fremde Docker-Ressourcen werden nicht gelöscht. +`$WORKDIR/restore-test/scratch` werden bereinigt. Reguläre manuelle Restore-Verzeichnisse und fremde Docker-Ressourcen bleiben +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 als wiederverwendbare Abhängigkeit erhalten; sein einmaliger Platzbedarf kommt 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 -vorhanden und rund **9,1 GiB frei auf `/var`**. Für diesen Stand passt die unten -genannte Serie `mariadb:10.11`. Die freie Kapazität ist eine Momentaufnahme; -vor Image-Download und Test nochmals prüfen. - -Docker Engine und CLI müssen lokal vorhanden sein. Verwendet wird ausschließlich -`unix:///var/run/docker.sock`. Zur produktiven MariaDB passende Major-/Minor-Version -ermitteln: +Docker Engine und CLI müssen lokal vorhanden sein. Gemini verwendet laut +Bestandsaufnahme vom 12.09.2026 MariaDB 10.11.18 und besitzt Docker bereits. +Der Installer liest `SELECT VERSION()` mit den vorhandenen Backup-Zugangsdaten. +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 +Image wird wiederverwendet, ein fehlendes einmalig mit Zeitlimit geladen. Ein +explizites Image wird erhalten, muss aber zur Produktionsserie passen. ```bash -sudo mariadbd --no-defaults --version -sudo docker --host unix:///var/run/docker.sock info +sha256sum -c app-backup-update-VERSION.run.sha256 +sudo bash app-backup-update-VERSION.run --enable-restore-test ``` -Beispiel **nur wenn die Produktion MariaDB 10.11.x verwendet**: - -```bash -sudo docker --host unix:///var/run/docker.sock pull mariadb:10.11 -``` - -Bei einer anderen Produktionsversion den Tag entsprechend ersetzen. Platz für -Image und Testdaten vorab prüfen. Ein vorhandenes kompatibles Image kann -wiederverwendet werden; für reproduzierbare Tests kann statt des Tags ein Digest -konfiguriert werden. Bei einem Major-/Minor-Upgrade der Produktions-DB auch das -Testimage prüfen und aktualisieren. - -Vor dem Start des neuen Update-Pakets `/etc/app-backup/app-backup.conf` bearbeiten: - -```bash -ENABLE_RESTORE_TEST="true" -RESTORE_TEST_DB_IMAGE="mariadb:10.11" # Beispiel: an Produktionsversion anpassen! -RESTORE_TEST_MAX_WORK_GB=4 -RESTORE_TEST_MIN_FREE_GB=2 -RESTORE_TEST_MAX_AGE_HOURS=72 -RESTORE_TEST_TIMEOUT=39600 # 11 Stunden, inklusive Remote-Arbeit -MAIL_HOSTNAME="gemini.jr-it-services.de" -``` - -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: +Die Option aktiviert den Test und das adaptive Arbeitsbudget in der gesicherten +Kandidatenkonfiguration. Die freie Docker-Kapazität wird vor einem Download geprüft. +Der Installer startet bei Bedarf den vorhandenen Docker-Dienst. Er initialisiert +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 +Probeimport verändert keine produktive Datenbank und setzt keinen erfolgreichen +monatlichen Sicherungsnachweis. + +Anschließend installiert der Update-Worker das Paket, erstellt ein Backup und +prüft **diesen konkreten Offsite-Satz vollständig inklusive DB-Import**. Erst nach +erfolgreicher Prüfung und Bereinigung aktiviert er Backup-Timer, Monatstimer und +Boot-Bereinigung. Das Update-Ergebnis und der separate Restore-Bericht dokumentieren +die Abnahme. Die zeitaufwendige Worker-Phase läuft unabhängig von SSH; die +vorgelagerte Vorbereitung sollte bis zur Übergabemeldung verbunden bleiben. + +Ohne `--enable-restore-test` erhält das Update den bisherigen Aktivierungszustand +und ein ausdrücklich eingestelltes Arbeitsbudget. Ist der Test bereits aktiviert, +erfolgen Image-Vorbereitung und vollständige Abnahme ebenfalls automatisch. +Bei einem MariaDB-Serienwechsel ein bisher ausdrücklich eingetragenes Image +anpassen oder leeren; ein inkompatibles Image wird vor Verwendung abgelehnt. + +Eine direkte Installation mit `install.sh` bereitet eine bereits aktivierte +Testkonfiguration ebenfalls vor. Für eine spätere Aktivierung ohne Paketupdate: ```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 systemctl start app-restore-test.service sudo systemctl enable app-restore-test-cleanup.service 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 -Der Standardlauf (`tests/run.sh` im bestehenden Testcontainer) prüfte am -12.09.2026 81 Fälle: 80 erfolgreich, der separate Docker-Test planmäßig -übersprungen. Zusätzlich bestanden alle 19 gezielten Restore-Tests mit lokalem -Docker-Daemon und dem offiziellen `mariadb:10.11` (29,0 Sekunden). Vier gezielte -Sperr-/Installer-Tests bestanden ebenfalls (23,7 Sekunden). Dazu gehören echte -Crypt-Downloads, vollständiges Entpacken, DB-Import/Tabellenprüfung, ungültiges SQL, -SIGTERM/SIGKILL, fehlender Platz, beschädigte Statusdateien und ein zeitweise nicht -erreichbarer Docker-Daemon. Container-Isolation und vollständige Bereinigung -werden nachgeprüft. Die bereits vorhandene Entwicklungstest-DB mit MariaDB 11.8.6 -wurde ebenfalls erfolgreich verwendet. Es werden keine echten Mails versendet. +Am 12.09.2026 bestanden **100 unterschiedliche Tests**: 71 Backup-/Restore-/ +Installer-Regressionstests, 23 Restore-Test-Fälle mit dem offiziellen lokalen +MariaDB-10.11-Image und 6 gezielte Scratch-/Recovery-Schutzprüfungen. ShellCheck +und Python-Syntaxprüfung bestanden ebenfalls. Das Testverzeichnis war danach +leer, und es blieben keine zugehörigen Testcontainer zurück. + +Die automatischen Tests decken echte Crypt-Downloads, Entpacken, DB-Import und +Tabellenprüfung ab, außerdem ungültiges SQL, SIGTERM/SIGKILL, Platzmangel, +beschädigte Statusdateien und einen zeitweise nicht erreichbaren Docker-Daemon. +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 `tests/Dockerfile`, Docker-CLI und lokal vorbereitetem Image lässt sich die diff --git a/SPEICHER.md b/SPEICHER.md new file mode 100644 index 0000000..6f4391e --- /dev/null +++ b/SPEICHER.md @@ -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. diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index 2d89d49..9b0729a 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -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 eine Bereinigung. Es bleiben nur kleine, begrenzte Betriebsnachweise. -Vor dem Paketstart nach [RESTORE_TEST.md](RESTORE_TEST.md) ein lokales, zur -Produktions-MariaDB passendes Docker-Image vorbereiten, Speichergrenzen prüfen und -`ENABLE_RESTORE_TEST=true` sowie `RESTORE_TEST_DB_IMAGE` in der aktiven Konfiguration -setzen. Die zusätzliche Vorprüfung kontrolliert lokale Voraussetzungen, ohne -Images oder Backups herunterzuladen. Fehlende Voraussetzungen stoppen die -Vorbereitung vor der Installation. Nach erfolgreicher Update-Abnahme aktiviert -der Installer den monatlichen Timer (1., 06:00 Uhr Serverzeit) und die -Boot-Bereinigung. Der erste echte Restore-Test hat eine eigene Mail und -`$WORKDIR/restore-test/state.json`; sein Erfolg ist separat zu kontrollieren. +Mit `--enable-restore-test` übernimmt der Installer die MariaDB-Vorbereitung: +Produktionsserie lesen, passendes lokales Image wiederverwenden oder laden und +einen isolierten Probeimport einschließlich Bereinigung ausführen. Manuelles +`docker pull` ist auf Gemini nicht mehr erforderlich. Die Option aktiviert +zugleich das adaptive Arbeitsbudget; Details: [RESTORE_TEST.md](RESTORE_TEST.md). +Danach prüft der Worker sein neues Offsite-Backup vollständig mit Entpacken und +DB-Import. Erst bei Erfolg aktiviert er Monatstimer und Boot-Bereinigung. +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 Restore-Test-Zeitpläne werden erhalten; neue Vorlagen liegen als `.dist` 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 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 @@ -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 Zugriff auf Routinen-Metadaten prüfen; 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 Prüfbereich, ohne Datei-Inhalte zu kopieren. Bei Fehlern bleibt das installierte 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. 8. Backup-Timer stoppen, Paket mit Rollback-Inventar installieren, Konfiguration atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren. -9. Vollständiges Backup ohne lokale/Remote-Retention durchführen. Anschließend den - abgeschlossenen Satz nochmals über Crypt herunterladen und mit dem Restore prüfen. +9. Vollständiges Backup ohne reguläre lokale/Remote-Altersretention durchführen. + 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. 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 diff --git a/etc/app-backup/app-backup.conf b/etc/app-backup/app-backup.conf index b67412a..4abe83f 100644 --- a/etc/app-backup/app-backup.conf +++ b/etc/app-backup/app-backup.conf @@ -48,12 +48,13 @@ MAIL_INCLUDE_LOG_TAIL_LINES=200 MAIL_SENDMAIL_BIN="/usr/sbin/sendmail" MAIL_SEND_TIMEOUT=30 -# Monthly offsite restore rehearsal. Prepare a LOCAL MariaDB image matching the -# production major/minor version before enabling. No automatic image downloads. +# Monthly offsite restore rehearsal. The updater --enable-restore-test prepares +# a matching LOCAL MariaDB image; monthly jobs never download images. ENABLE_RESTORE_TEST="false" RESTORE_TEST_DB_IMAGE="" # 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_MAX_AGE_HOURS=72 RESTORE_TEST_TIMEOUT=39600 diff --git a/install.sh b/install.sh index 668b1a7..ef9a11f 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ done [[ $NO_TIMER != true || $ENABLE_TIMER != true ]] || exit 1 # 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 -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 for path in sys.argv[1:]: ast.parse(open(path).read()) 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 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/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/report.py /usr/local/lib/app-backup/report.py 640 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 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 SPEICHER.md /usr/local/share/doc/app-backup/SPEICHER.md 644 # Save the exact replaced files plus a rollback inventory; do not dereference links. : > "$SNAPSHOT/present.txt"; : > "$SNAPSHOT/absent.txt" for i in "${!TARGETS[@]}"; do @@ -138,6 +142,11 @@ for i in "${!TARGETS[@]}"; do done install -d -m 700 /var/log/app-backup /var/backups/app-backup 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 # 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 diff --git a/lib/backup_set.py b/lib/backup_set.py index 8943b74..4030c53 100644 --- a/lib/backup_set.py +++ b/lib/backup_set.py @@ -99,9 +99,11 @@ def check_tar(path, roots): proc = subprocess.Popen(command, stdout=subprocess.PIPE) entries = {} hardlinks = [] + expanded = 0 try: with tarfile.open(fileobj=proc.stdout, mode="r|") as archive: for item in archive: + expanded += ((item.size + 4095) // 4096) * 4096 + 4096 name = member_name(item.name) if not any(name == root or name.startswith(root + "/") for root in roots): raise ValueError(f"Member outside component root: {name}") @@ -128,6 +130,7 @@ def check_tar(path, roots): pass if proc.wait() != 0: raise ValueError(f"Decompression failed: {path}") + return expanded finally: proc.stdout.close() if proc.poll() is None: @@ -291,9 +294,11 @@ def main(): crypt(*args) elif op == "create": 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") - 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"]: check_tar(Path(args[0]) / item["file"], [item["root"]]) print(f"Verified: {data['run_id']} ({len(data['archives'])} archives)") diff --git a/lib/common.sh b/lib/common.sh index 658fa5b..cb3f167 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -40,7 +40,7 @@ load_config() { : "${MAIL_HOSTNAME:=}" : "${MAIL_SENDMAIL_BIN:=/usr/sbin/sendmail}" "${MAIL_SEND_TIMEOUT:=30}" : "${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}" : "${ENABLE_WORDPRESS:=false}" "${ENABLE_NEXTCLOUD:=false}" "${ENABLE_NEXTCLOUD_DATA:=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" done (( 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" [[ $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" @@ -487,6 +488,7 @@ resume_services() { } finish() { local code=$? + local recovery_ok=true trap - EXIT INT TERM set +e 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: Keep Nextcloud in maintenance; review $LOG_FILE before restarting." else - resume_services || code=1 + if ! resume_services; then code=1; recovery_ok=false; fi fi local report_sizes="$LOG_DIR/report_$RUN_ID.sizes.json" 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:-}}" || echo 'WARNING: Report sizes unavailable' 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 # 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. diff --git a/lib/prepare-restore-test.sh b/lib/prepare-restore-test.sh new file mode 100644 index 0000000..1db6d16 --- /dev/null +++ b/lib/prepare-restore-test.sh @@ -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' diff --git a/lib/report.py b/lib/report.py index 0e3d9aa..444f63a 100644 --- a/lib/report.py +++ b/lib/report.py @@ -27,7 +27,10 @@ def allocated(path): def snapshot(output, staging, 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) # Measure components independently; the total accounts for shared links. paths = sorted((root/'files').glob('*')) + [root/'db', root/'meta'] diff --git a/lib/restore-test-remote.sh b/lib/restore-test-remote.sh index f3c1b34..3f07b32 100644 --- a/lib/restore-test-remote.sh +++ b/lib/restore-test-remote.sh @@ -7,6 +7,18 @@ source "$LIB_DIR/common.sh" CONFIG_FILE=${1:?Missing config} shift 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 # rclone is restricted to read operations here, including metadata reads. case ${1:-} in lsf|cat) rc "$@" ;; *) die 'Unsupported restore-test remote operation' ;; esac diff --git a/lib/restore_test.py b/lib/restore_test.py index 03b55af..2d07f24 100644 --- a/lib/restore_test.py +++ b/lib/restore_test.py @@ -137,6 +137,19 @@ class Rehearsal: if time.monotonic() >= self.deadline: raise TestError('Restore-test time limit exceeded') 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: raise TestError('Free-space reserve reached; test stopped and scratch will be cleaned') if force or time.monotonic() >= self.next_usage: @@ -145,7 +158,7 @@ class Rehearsal: for name in directories + files: self.used += (Path(path) / name).lstat().st_blocks * 512 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') def command(self, command, *, capture=0, output=None, limit=None, stdin=None, timeout=120): @@ -459,7 +472,7 @@ def io_bytes(raw): def main(): 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'): parser.add_argument('--' + key, required=True) for key in ('remote-check', 'config'): @@ -470,12 +483,35 @@ def main(): for key in ('max-work-gb', 'min-free-gb'): parser.add_argument('--' + key, type=float, required=True) 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): parser.error('Invalid restore-test limits') if os.geteuid() != 0: parser.error('Run as root via app-restore-test.sh') 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(): return 0 if args.mode == 'check': diff --git a/lib/space.py b/lib/space.py new file mode 100644 index 0000000..ebc1b70 --- /dev/null +++ b/lib/space.py @@ -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])) diff --git a/lib/space.sh b/lib/space.sh new file mode 100644 index 0000000..0cf9fa8 --- /dev/null +++ b/lib/space.sh @@ -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 +} diff --git a/tests/test_backup.py b/tests/test_backup.py index cc01fb3..75e991b 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -272,6 +272,49 @@ QUIESCE_SERVICES=(nc-cron) self.assertEqual((self.wp/'payload.txt').read_text(),'payload for web') 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): for tool in ['mariadb-dump','rsync','zstd']: with self.subTest(tool=tool): @@ -345,7 +388,7 @@ if '--exclude=/*' in sys.argv: self.state.write_text(json.dumps(state)) wrapper=self.bin/'rsync' wrapper.write_text('''#!/usr/bin/env bash -if [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]; then +if [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* && " $* " != *" --dry-run "* ]]; then systemctl start httpd systemctl start php-fpm systemctl start gitea @@ -366,7 +409,7 @@ import json,os,subprocess,sys from pathlib import Path result=subprocess.run(['/usr/bin/rsync',*sys.argv[1:]]) 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']) state=json.loads(p.read_text()) 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' # Signal only during the real copy, after services have stopped. The # 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) try: for _ in range(200): @@ -587,19 +630,31 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: self.enable_report() 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') - # The real isolated DB import is covered separately. Updater preflight - # only inspects the prepared local image; it must never pull one. + # Real image preparation/import is also exercised in the opt-in Docker suite. docker=self.bin/'docker' docker.write_text('''#!/usr/bin/env python3 -import json,sys -assert sys.argv[1:] == ['--host=unix:///var/run/docker.sock','image','inspect','test-image'] -print(json.dumps([{'Id':'sha256:test-image','Config':{'Volumes':{'/var/lib/mysql':{}}}}])) +import json,sys,os +from pathlib import Path +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) canonical=self.configure_upgrade() original=canonical.read_bytes() 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) state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) 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('Host: gemini.jr-it-services.de',body.splitlines()) 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/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.assertEqual(json.loads(self.state.read_text())['services']['app-restore-test.timer'],'active') self.assertTrue((state/'verified-backup-run').is_file()) 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(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) @@ -827,7 +888,7 @@ if '--no-block' not in args: self.systemd_launcher() wrapper=self.bin/tool if tool=='rsync': - match='[[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]' + match='[[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* && " $* " != *" --dry-run "* ]]' else: 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') diff --git a/tests/test_restore_test.py b/tests/test_restore_test.py index b302253..b9fa3f6 100644 --- a/tests/test_restore_test.py +++ b/tests/test_restore_test.py @@ -311,6 +311,83 @@ exec /usr/bin/rclone "$@" 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') + @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): self.clean() names=subprocess.check_output(restore_test.DOCKER+['ps','-a','--filter',f'label={restore_test.OWNER_LABEL}', '--format','{{.Names}}'],text=True) diff --git a/tests/test_space.py b/tests/test_space.py new file mode 100644 index 0000000..18c7a0a --- /dev/null +++ b/tests/test_space.py @@ -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()) diff --git a/tools/build-update-package.py b/tools/build-update-package.py index 30af2aa..f9eb321 100644 --- a/tools/build-update-package.py +++ b/tools/build-update-package.py @@ -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', 'systemd/app-backup.service', 'systemd/app-backup.timer', 'logrotate/app-backup', '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-cleanup.service'] @@ -49,7 +50,7 @@ def build(output, version): set -Eeuo pipefail umask 077 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 fi [[ $EUID == 0 ]] || {{ echo 'Bitte mit sudo bash starten.' >&2; exit 1; }} diff --git a/update.sh b/update.sh index fcb1bd2..1036046 100755 --- a/update.sh +++ b/update.sh @@ -19,11 +19,13 @@ UP_MAIL_DIR='' UP_MAIL_HOMES=false UP_STATE='' UP_WORKER=false +UP_RESTORE_TEST=false while (( $# )); do case $1 in --config) CONFIG_FILE=${2:?Missing configuration}; shift 2 ;; --foreground) UP_FOREGROUND=true; shift ;; --install-deps) UP_INSTALL_DEPS=true; shift ;; + --enable-restore-test) UP_RESTORE_TEST=true; shift ;; --enable-gitea) UP_GITEA=true; shift ;; --no-gitea) UP_GITEA=false; shift ;; --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-homes-only) UP_MAIL_HOMES=true; shift ;; --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" ;; esac done @@ -134,10 +136,14 @@ if [[ $UP_WORKER == false ]]; then chmod 600 "$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 - # 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_NEXTCLOUD_MAINTENANCE true 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 if [[ $ENABLE_WORDPRESS == true || $ENABLE_NEXTCLOUD == true ]]; then 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 printf '%s=%q\n' "$key" "$UP_STATE/db-$i.cnf" >> "$UP_STATE/check.conf" 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"; } 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 || { 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 # Hold the shared lock across every child. This also excludes manual restores. 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 backup_run=$(python3 - "$WORKDIR/last-backup.json" <<'PY' import json,sys @@ -376,7 +390,11 @@ assert value['exit_code'] == 0 print(value['run']) 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" # A missed persistent timer can fire immediately, so release the shared lock first. unset APP_BACKUP_INHERIT_LOCK diff --git a/usr-local-sbin/app-backup.sh b/usr-local-sbin/app-backup.sh index b189e22..282cc2c 100755 --- a/usr-local-sbin/app-backup.sh +++ b/usr-local-sbin/app-backup.sh @@ -9,12 +9,14 @@ source "$LIB_DIR/common.sh" CONFIG_FILE=/etc/app-backup/app-backup.conf CHECK=false SKIP_RETENTION=false +PREPARE_SPACE=false while (( $# )); do case $1 in --config) CONFIG_FILE=${2:?Missing config path}; shift 2 ;; --check) CHECK=true; shift ;; + --prepare-space) PREPARE_SPACE=true; CHECK=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" ;; esac done @@ -22,6 +24,9 @@ OPERATION=backup load_config init_runtime build_plan +# shellcheck source=lib/space.sh +source "$LIB_DIR/space.sh" +if [[ $CHECK == false || $PREPARE_SPACE == true ]]; then prepare_backup_space; fi preflight if [[ $CHECK == true ]]; then 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' BACKUP_PHASE='Dienste wiederherstellen' 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 if [[ $label == meta ]]; then root=meta @@ -93,6 +101,9 @@ while IFS=$'\t' read -r label _; do gzip -6 -c > "$out.partial" fi 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" BACKUP_PHASE='Lokale Archivprüfung' helper create "$PARTIAL_DIR" "$RUN_ID" "$PLAN" diff --git a/usr-local-sbin/app-restore-test.sh b/usr-local-sbin/app-restore-test.sh index 5a8c3fc..8bc86fd 100755 --- a/usr-local-sbin/app-restore-test.sh +++ b/usr-local-sbin/app-restore-test.sh @@ -14,6 +14,7 @@ while (( $# )); do --config) CONFIG_FILE=${2:?Missing config}; shift 2 ;; --check) MODE=check; shift ;; --cleanup) MODE=cleanup; shift ;; + --prepare-db) MODE=prepare; shift ;; --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 ;; *) die "Unknown argument: $1" ;; diff --git a/usr-local-sbin/app-restore.sh b/usr-local-sbin/app-restore.sh index 46e51e6..6c4e767 100755 --- a/usr-local-sbin/app-restore.sh +++ b/usr-local-sbin/app-restore.sh @@ -38,18 +38,71 @@ done OPERATION=restore load_config 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") +if [[ $MODE == verify ]]; then printf '%s\n' "$RUN_ID" > "$STAGING_DIR/.verify-only"; fi INPUT="$STAGING_DIR/input" mkdir -m 700 "$INPUT" if [[ -n $REMOTE_RUN ]]; then [[ $REMOTE_RUN =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]+$ && $REMOTE_RUN == "$ARCHIVE_PREFIX"_* ]] || die 'Unsafe remote run name' 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" rc copy "${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN" "$INPUT" echo '-- Remote backup downloaded; validating manifest, hashes and archives' else [[ -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 # recursive copies into the freshly created restore workspace are forbidden. 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 helper list "$INPUT" > "$STAGING_DIR/archive-list.tsv" if [[ $MODE == extract ]]; then + size=$(helper expanded "$INPUT") + ensure_space "$RESTORE_ROOT" "$size" mkdir "$STAGING_DIR/extracted" while IFS=$'\t' read -r label file _; do mkdir "$STAGING_DIR/extracted/$label" @@ -137,6 +192,9 @@ if [[ $MODE == plan ]]; then exit 0 fi 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 # cannot redirect extraction of a later component into another tree. while IFS=$'\t' read -r label file _; do