diff --git a/Dokumentation.md b/Dokumentation.md index 971dcef..4d94f65 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -553,9 +553,31 @@ Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie ## Prüfung und Abnahme +### Monatliche automatisierte Restore-Probe + +Die neue [Restore-Test-Betriebsanleitung](RESTORE_TEST.md) beschreibt den +vollständigen Offsite-Test mit isoliertem MariaDB-Import. Ein neuer Scheduler +verwendet die gemeinsame Backup-/Restore-/Update-Sperre. Archive werden einzeln +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 +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. + +Der Test ergänzt die bisherige technische Archivprüfung um tatsächliches +Entpacken und DB-Import mit Tabellenprüfung. Fachliche Anwendungswiederherstellung +auf einer isolierten VM und unabhängige Überwachung des letzten Erfolgs bleiben +separate Abnahmen. Aktivierung und Prüfung auf Gemini erfolgen nach Installation. + +### Entwicklungstests + Reproduzierbar mit `tests/Dockerfile` und `tests/run.sh`. Der Container bekommt das Repository nur lesbar und benötigt während der Tests kein Netzwerk. Die MariaDB läuft ohne TCP-Port in einem temporären Verzeichnis. systemd/occ werden simuliert; alle eigentlichen Archive, Dateikopien, Dumps/Imports und Crypt-Operationen sind real. -Verifikation am 12.09.2026: **68 Testfälle abgedeckt**. Im Gesamtlauf (741,0 Sekunden) bestanden 63 Fälle direkt. Fünf Mail-Testprüfungen erwarteten LF statt der korrekten SMTP-Zeilenumbrüche CRLF; nach Korrektur dieser Prüfungen bestanden alle 13 gezielt wiederholten Mail-/Installertests (155,9 Sekunden), einschließlich der fünf betroffenen Fälle. Geprüft sind Hostname-Override bei Erfolg und Fehler, leerer/fehlender Wert, unveränderter Absender, Header-Schutz und Paketupdate mit erhaltener Einstellung. Shell-Syntax und ShellCheck ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. Der MTA wird durch eine lokale Nachrichten-Attrappe ersetzt; es wurden keine echten E-Mails versendet. Die Zustellung auf Gemini ist nach Installation zu prüfen. +Verifikation am 12.09.2026: **Standardlauf erfolgreich** (81 Fälle, davon 80 erfolgreich und ein separater Docker-Test planmäßig übersprungen; 704,8 Sekunden). Zusätzlich bestanden alle **19 gezielten Restore-Tests** mit echten DB-Containern und der Paketupdate-/Rollback-Test mit aktiviertem Restore-Timer. Die gezielten Tests prüfen insbesondere Platzgrenzen, ungültiges SQL, SIGTERM, SIGKILL, defekte Statusdateien, einen unerreichbaren Docker-Daemon und anschließende vollständige Bereinigung. Shell-Syntax, ShellCheck und systemd-Unit-Prüfung ohne Befund. Basis-Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV und ShellCheck 0.10.0. Test-Mails werden nur lokal erfasst. Die tatsächliche Rocky-/OneDrive-/MTA-Abnahme des neuen Monatslaufs auf Gemini steht nach Installation noch aus. Die Regressionstests für `RCLONE_SIZE_ONLY` und zusätzliche Marker-Zeilenumbrüche scheiterten vor den jeweiligen Korrekturen mit einem fälschlich erfolgreichen Backup und bestanden danach. Upload-/Markerfehler werden an den Befehlsgrenzen injiziert, während Crypt-Übertragung und Inhaltsvergleich real bleiben. Die Tests bilden keine OneDrive-API nach; das tatsächliche HTTP-409-Wiederholungsverhalten ist durch den Betreiberlauf und den rclone-1.74.3-Quellcode separat belegt. diff --git a/README.md b/README.md index bd52d91..d07406a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ Sichert WordPress, Nextcloud, native Gitea-Installationen mit MariaDB, Maildaten Die vollständige Analyse, Architektur, Konfiguration, Betriebsanleitung und verbleibenden Grenzen stehen in [Dokumentation.md](Dokumentation.md). +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. + ## Produktionsstand und verbleibende Abnahme Auf Gemini bestand am 10.09.2026 ein vollständiger Paketlauf mit geschützter @@ -273,9 +280,12 @@ Die Mail bestätigt den Backup-Lauf; beim Update ist weiterhin dessen abschließ ## Tests +Der Restore-Test ist separat unter [RESTORE_TEST.md](RESTORE_TEST.md) dokumentiert, +einschließlich Aktivierung, Platzbedarf, Statusdateien, Fehlern und Bereinigung. + ```bash docker build -t app-backup-test -f tests/Dockerfile . docker run --rm --network none -v "$PWD:/src:ro" app-backup-test ``` -ShellCheck, echte MariaDB-Dumps/Imports, gzip/zstd, rsync, lokale Crypt-Uploads und Remote-Restore sowie Fehlerfälle laufen im Container. systemd, Nextcloud-occ und der Mailversand sind simuliert; Nachrichten werden nur lokal erfasst. Am 12.09.2026 wurden 68 Testfälle abgedeckt: 63 bestanden im Gesamtlauf; nach Korrektur von fünf Testprüfungen für SMTP-Zeilenumbrüche bestanden alle 13 gezielt wiederholten Mail-/Installertests, einschließlich dieser fünf Fälle. ShellCheck und Shell-Syntaxprüfung bestanden. Eine echte Rocky-/Nextcloud-/Gitea-/OneDrive-Wiederherstellungsprobe und die tatsächliche Mailzustellung bleiben Teil der Serverabnahme. +ShellCheck, echte MariaDB-Dumps/Imports, gzip/zstd, rsync, lokale Crypt-Uploads und Remote-Restore sowie Fehlerfälle laufen im Container. systemd, Nextcloud-occ und der Mailversand sind simuliert; Nachrichten werden nur lokal erfasst. Am 12.09.2026 bestand der Standardlauf mit 81 Fällen (80 erfolgreich, ein separater Docker-Test planmäßig übersprungen). Zusätzlich bestanden alle 19 gezielten Restore-Tests einschließlich echter DB-Imports und Abbruchbereinigung sowie der Installer-Test mit aktiviertem Restore-Timer. ShellCheck, Shell-Syntax und systemd-Unit-Prüfung bestanden. Eine echte Rocky-/Nextcloud-/Gitea-/OneDrive-Wiederherstellungsprobe und die tatsächliche Mailzustellung bleiben Teil der Serverabnahme. diff --git a/RESTORE_TEST.md b/RESTORE_TEST.md new file mode 100644 index 0000000..1e95f85 --- /dev/null +++ b/RESTORE_TEST.md @@ -0,0 +1,251 @@ +# Monatlicher Offsite-Restore-Test + +Der Restore-Test prüft den neuesten vollständigen Sicherungssatz direkt aus dem +konfigurierten rclone-Crypt-Remote. Alle Komponenten werden heruntergeladen, +entschlüsselt, validiert und in ein privates Testverzeichnis entpackt. SQL-Dumps +werden zusätzlich in eine isolierte MariaDB importiert und mit `mariadb-check` +geprüft. Produktive Anwendungen werden dabei nicht angehalten oder überschrieben. + +## Speicherbedarf und Bereinigung + +Wegen des knappen Speichers auf Gemini läuft immer nur **eine Komponente**: + +1. Archiv herunterladen; Größe und SHA-256 gegen das Manifest prüfen. +2. Archivstruktur und Kompression vollständig validieren. +3. Platz für die entpackte Komponente abschätzen und diese mit GNU tar entpacken. +4. Das komprimierte Archiv sofort löschen. +5. Bei einem DB-Dump eine frische Test-DB starten, importieren und Tabellen prüfen. +6. Den eigenen DB-Container samt eventuellen anonymen Volumes entfernen, danach + alle temporären Dateien dieser Komponente löschen; erst dann die nächste laden. + +Es liegt somit nie der gesamte Sicherungssatz gleichzeitig auf der Platte. +Der Spitzenbedarf ist ungefähr das Maximum aus: + +- komprimiertem Archiv plus entpackter **größter Komponente** und Dateisystem-Overhead; +- 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. + +Vor Download und Entpacken wird der Bedarf geprüft. Während externer Prozesse +wird freier Speicher laufend kontrolliert, die tatsächliche Belegung regelmäßig +nachgemessen und bei Grenzverletzung abgebrochen. Dies ist **keine harte +Dateisystemquote**: schnelle DB-Schreibvorgänge und fremde Prozesse können zwischen +Messungen weiteren Platz belegen. Daher die Reserve nicht knapp auf null setzen. +Für den DB-Start werden zusätzlich mindestens 512 MiB eingeplant; das ist eine +Mindestreserve, keine obere Grenze für die Größe der importierten Datenbank. + +Normale Fehler, Speicher-/Zeitlimits und SIGTERM/SIGINT führen zur Bereinigung. +Nach einem harten Abbruch führt systemd `ExecStopPost` aus. Bei einem Fehler wird +außerdem der Bereinigungsdienst gestartet; er versucht eine fehlgeschlagene +Bereinigung alle 15 Minuten erneut. Ein aktivierter Boot-Bereinigungsdienst und +der nächste Teststart räumen unterbrochene Tests ebenfalls auf. Beide verwenden +dieselbe Sperre wie Backup und Update. + +Kann beispielsweise der Docker-Daemon nicht erreicht und der Testcontainer nicht +sicher entfernt werden, lautet das Ergebnis **FAIL / Bereinigung FAILED**. Die +Dateien werden dann nicht unter einer möglicherweise noch laufenden DB gelöscht. +Die Bereinigung wird wiederholt; ein solcher Zustand darf nicht als Erfolg gelten. +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. +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 + +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: + +```bash +sudo mariadbd --no-defaults --version +sudo docker --host unix:///var/run/docker.sock info +``` + +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: + +```bash +sudo bash /usr/local/sbin/app-restore-test.sh --check +sudo systemctl enable app-restore-test-cleanup.service +sudo systemctl enable --now app-restore-test.timer +``` + +`--check` prüft Konfiguration, lokale Werkzeuge, Image-Metadaten und freie Reserve. +Es lädt kein Backup herunter und führt keinen DB-Import aus. Den ersten echten +Test nach Installation anstoßen und sein Ergebnis kontrollieren: + +```bash +sudo systemctl start --no-block app-restore-test.service +sudo journalctl -fu app-restore-test.service --no-pager +sudo cat /var/backups/app-backup/restore-test/state.json +sudo systemctl list-timers app-restore-test.timer --no-pager +``` + +Bei angepasstem `WORKDIR` die Pfade entsprechend ersetzen. Einen bestimmten Satz +kann man manuell prüfen; Altersgrenze und alle übrigen Prüfungen bleiben aktiv: + +```bash +sudo bash /usr/local/sbin/app-restore-test.sh \ + --remote-run appbackup_DATUM_UHRZEIT_ID +``` + +## Zeitplan, Auswahl und Ergebnisse + +Der Standardtermin ist der **1. jedes Monats um 06:00 Uhr Serverzeit**, mit bis zu +15 Minuten zufälliger Verzögerung. `Persistent=true` holt verpasste Termine nach. +Ein belegter Backup-/Restore-/Update-Lock liefert Exit-Code 75; systemd versucht +es eine Stunde später erneut. Ein echter Prüffehler löst eine Fehlermail aus und +wird nicht durch automatische Neuversuche als Erfolg verdeckt. + +Unvollständige neuere Sätze ohne `COMPLETE` werden mit Protokolleintrag übersprungen. +Für den neuesten abgeschlossenen Satz müssen Marker und Manifest zusammenpassen, +die Lauf-ID muss dem Remote-Verzeichnis entsprechen, alle konfigurierten +Komponenten müssen enthalten sein und die Remote-Dateiliste muss vollständig sein. +Ein beschädigter abgeschlossener Satz wird nicht durch einen älteren ersetzt. +Standardmäßig sind maximal 72 Stunden alte Backups zulässig; ein ungültiger +Zeitstempel oder ein Zeitstempel mehr als fünf Minuten in der Zukunft scheitert. + +Der Bericht verwendet die bisherigen Mail-Einstellungen (`ENABLE_MAIL_REPORT`, +`MAIL_TO`, `MAIL_FROM`, `MAIL_HOSTNAME`, `MAIL_SENDMAIL_BIN`, `MAIL_SEND_TIMEOUT`). +Der Betreff lautet beispielsweise: + +```text +[app-restore-test] SUCCESS gemini.jr-it-services.de appbackup_2026-… +``` + +Enthalten sind Alter und ID des Sicherungssatzes, Laufzeit, Status je Komponente, +Archivgröße, Anzahl der Archiveinträge, Anzahl der importierten Tabellen sowie +**Bereinigung: OK/FAILED**. Es werden weder SQL-Inhalte noch Dateiinhalte in die +Mail oder das Testprotokoll übernommen. Der Status je Komponente wird erst nach +seiner Bereinigung als erfolgreich gespeichert. + +Unter `$WORKDIR/restore-test/` bleiben nur kleine Betriebsnachweise erhalten: + +| Datei | Bedeutung | +| --- | --- | +| `state.json` | Letzter/aktueller Lauf; bei Start sofort `RUNNING`, abschließend `SUCCESS` oder `FAIL`, Bereinigungs- und Mailstatus | +| `last-success.json` | Letzter vollständig geprüfter **und bereinigter** Satz; bleibt bei einem späteren Fehler erhalten | +| `report.eml` | Jeweils letzter Mailbericht, auch bei Zustellungsfehler | +| `restore-test.log` und `.1` | Rotierendes Testprotokoll, jeweils ungefähr 512 KiB | +| `.owner` | Kennzeichnung des Testverzeichnisses für sichere Bereinigung | + +Temporäre Prozess-/Container-Marker verschwinden nach erfolgreicher Bereinigung. +Die Nachweise sind root-privat. Für die unabhängige Überwachung sollte ein +`RUNNING`, `cleanup != OK`, `mail == FAILED` oder ein mehr als etwa 40 Tage alter +`last-success.json` auffallen. Ein erfolgreicher Restore bei fehlgeschlagener +Mail bleibt inhaltlich `SUCCESS`, liefert aber Exit-Code 1 und `mail: FAILED`. +Der SMTP-Versand ist wie bisher eine Übergabe an den lokalen MTA, kein Nachweis +der tatsächlichen Zustellung. + +Manuelle Wiederholung der Bereinigung, auch bei deaktivierter Testfunktion: + +```bash +sudo bash /usr/local/sbin/app-restore-test.sh --cleanup +sudo journalctl -u app-restore-test-cleanup.service -n 50 --no-pager +``` + +Zum Aussetzen des Zeitplans `app-restore-test.timer` deaktivieren. Den +Boot-Bereinigungsdienst aktiviert lassen, bis eventuell unterbrochene Testdaten +nachweislich entfernt wurden. + +## Isolation und Aussagekraft + +Jeder DB-Dump erhält eine neue Datenbankinstanz. Der Container hat ausschließlich +ein Bind-Mount auf sein privates DB-Testverzeichnis, kein Netzwerk, keine +veröffentlichten Ports, keine zusätzlichen Linux-Capabilities, ein schreibgeschütztes +Root-Dateisystem und keinen persistenten Docker-Logtreiber. Er läuft mit maximal +512 MiB RAM, 128 Prozessen und einer CPU. MariaDB liest keine Hostkonfiguration, +verwendet nur ihren eigenen Unix-Socket und hat Event-Scheduler und Binlog aus. +Der SQL-Client verwendet Batch-/Binary-Modus, damit Dumps keine lokalen +Client-Kommandos ausführen. Docker-API-Ausgaben und SQL-Fehlertexte gelangen nicht +ungefiltert in Logs. Hintergrunddatenbanken können keine produktiven DB-Sockets +oder Konfigurationen erreichen. + +Der Test belegt Download, Entschlüsselung, Integrität, tatsächliches Entpacken und +DB-Import mit Tabellenprüfung. Er belegt weder eine vollständige Datenabdeckung, +die schon im Backup fehlte, noch fachliche Konsistenz zwischen Anwendungen. Ein +Start-/Funktionstest von Nextcloud, WordPress und Gitea auf einer getrennten VM +bleibt eine zusätzliche Abnahme. Dateibesitz/ACLs werden beim Entpacken angefordert; +die korrekte Anwendung aller Sicherheitskontexte auf einem neuen Produktivsystem +bedarf dort einer eigenen Prüfung. + +Technische Referenzen: [Docker run](https://docs.docker.com/reference/cli/docker/container/run), +[Docker ohne Netzwerk](https://docs.docker.com/engine/network/drivers/none/), +[MariaDB-Systemtabellen initialisieren](https://mariadb.com/docs/server/clients-and-utilities/deployment-tools/mariadb-install-db), +[MariaDB-Serveroptionen](https://mariadb.com/docs/server/server-management/starting-and-stopping-mariadb/mariadbd-options). + +## 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. + +Auf einem separaten Entwicklungs-/Testrechner mit den Abhängigkeiten aus +`tests/Dockerfile`, Docker-CLI und lokal vorbereitetem Image lässt sich die +gezielte Prüfung aus dem Quell-Checkout starten: + +```bash +sudo env RESTORE_TEST_REAL_DOCKER_IMAGE=mariadb:10.11 \ + python3 -m unittest discover -s tests -p test_restore_test.py -v +``` + +Diese Tests verwenden ausschließlich selbst angelegte temporäre Konfigurationen, +Daten und Container. Die vollständige Basissuite enthält zusätzlich simulierte +systemd-/Installertests und gehört in den bestehenden isolierten Testcontainer. +Die neuen Units wurden mit `systemd-analyze verify` geprüft. Ihre tatsächliche +Ausführung unter Rocky/SELinux und der produktive OneDrive-/MTA-Pfad sind nach +Installation auf Gemini noch abzunehmen. diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index 38dd786..2d89d49 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -1,5 +1,30 @@ # Automatisches Update-Paket +## Neu: monatlicher Restore-Test + +Das Paket enthält jetzt den automatischen vollständigen Offsite-Restore-Test. +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. + +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 +Zurückspielen deaktiviert und die vorherigen Dateien wiederhergestellt. Den +gewünschten Zeitplan danach anhand des zurückgespielten Paketstands prüfen. + +## Paket starten + Für Gemini zuerst `MAIL_HOSTNAME="gemini.jr-it-services.de"` in der aktiven Konfiguration setzen: [Mailberichte nach dem Update](#mailberichte-nach-dem-update). @@ -475,7 +500,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -Verifikation am 12.09.2026: Gesamtlauf mit 68 Testfällen (741,0 Sekunden), davon 63 direkt erfolgreich. Fünf Mail-Testprüfungen wurden für korrekte SMTP-Zeilenumbrüche (CRLF) angepasst; danach bestanden alle 13 gezielt wiederholten Mail-/Installertests (155,9 Sekunden), darunter sämtliche fünf betroffenen Fälle. Dies umfasst den konfigurierbaren Mail-Hostnamen, automatische FQDN-Ermittlung, erhaltenen Absender, Versandfehler/-Timeout, Header-Schutz und Paketupdate mit erhaltenen Mail-Einstellungen. ShellCheck und Shell-Syntaxprüfung erfolgreich. Der MTA wird durch eine lokale Attrappe ersetzt; die Tests versenden keine echten E-Mails. Die Tests für eine über Umgebungsvariablen abgeschwächte Inhaltsprüfung und den nicht bytegenauen Marker-Vergleich reproduzierten die Fehler vor der Korrektur und bestanden danach. +Verifikation am 12.09.2026: Standardlauf mit 81 Fällen erfolgreich (80 bestanden, ein separater Docker-Test planmäßig übersprungen; 704,8 Sekunden). Zusätzlich bestanden alle 19 gezielten Restore-Tests mit echten DB-Containern und der Installer-/Rollback-Test mit aktiviertem Restore-Timer. Geprüft sind vollständiges Entpacken, isolierter SQL-Import, fehlerhafte Dumps, Platzgrenzen und Bereinigung nach normalen und harten Abbrüchen. ShellCheck, Shell-Syntax und systemd-Unit-Prüfung erfolgreich. Echte E-Mails werden in Entwicklungstests nicht versendet; die Serverabnahme des Monatslaufs erfolgt nach Installation. Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips; systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell diff --git a/etc/app-backup/app-backup.conf b/etc/app-backup/app-backup.conf index 981f10c..b67412a 100644 --- a/etc/app-backup/app-backup.conf +++ b/etc/app-backup/app-backup.conf @@ -48,6 +48,16 @@ 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. +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 +RESTORE_TEST_MIN_FREE_GB=2 +RESTORE_TEST_MAX_AGE_HOURS=72 +RESTORE_TEST_TIMEOUT=39600 + ENABLE_WORDPRESS="true" WP_DIR="/var/www/html" # Nextcloud paths below WP_DIR are automatically excluded (including on restore). diff --git a/install.sh b/install.sh index 0a44355..668b1a7 100755 --- a/install.sh +++ b/install.sh @@ -17,8 +17,8 @@ while (( $# )); do 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/common.sh"; do bash -n "$file"; done -python3 - "$ROOT_DIR/lib/backup_set.py" "$ROOT_DIR/lib/report.py" <<'PY' +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' import ast,sys for path in sys.argv[1:]: ast.parse(open(path).read()) PY @@ -32,13 +32,19 @@ if [[ -f /etc/app-backup/app-backup.conf ]]; then fi # shellcheck source=lib/locking.sh source "$ROOT_DIR/lib/locking.sh" -acquire_backup_lock +acquire_backup_lock 1 if systemctl is-active --quiet app-backup.service; then echo 'app-backup.service is active. Wait for completion before updating.' >&2 exit 1 fi +if systemctl is-active --quiet app-restore-test.service; then + echo 'Restore test is active. Wait for completion before updating.' >&2 + exit 1 +fi TIMER_ACTIVE=false if systemctl is-active --quiet app-backup.timer; then TIMER_ACTIVE=true; fi +RESTORE_TIMER_ACTIVE=false +if systemctl is-active --quiet app-restore-test.timer; then RESTORE_TIMER_ACTIVE=true; fi if [[ -n $SNAPSHOT_DIR ]]; then [[ $SNAPSHOT_DIR == /* && ! -e $SNAPSHOT_DIR ]] || { echo 'Snapshot directory must be new and absolute'; exit 1; } mkdir -m 700 -- "$SNAPSHOT_DIR" @@ -49,6 +55,14 @@ declare -a SOURCES TARGETS MODES add() { SOURCES+=("$ROOT_DIR/$1"); TARGETS+=("$2"); MODES+=("$3"); } add usr-local-sbin/app-backup.sh /usr/local/sbin/app-backup.sh 750 add usr-local-sbin/app-restore.sh /usr/local/sbin/app-restore.sh 750 +add usr-local-sbin/app-restore-test.sh /usr/local/sbin/app-restore-test.sh 750 +add lib/restore_test.py /usr/local/lib/app-backup/restore_test.py 640 +add lib/restore-test-remote.sh /usr/local/lib/app-backup/restore-test-remote.sh 640 +add systemd/app-restore-test.service /etc/systemd/system/app-restore-test.service 644 +add systemd/app-restore-test-cleanup.service /etc/systemd/system/app-restore-test-cleanup.service 644 +if [[ -e /etc/systemd/system/app-restore-test.timer ]]; then + add systemd/app-restore-test.timer /etc/systemd/system/app-restore-test.timer.dist 644 +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/backup_set.py /usr/local/lib/app-backup/backup_set.py 640 @@ -66,6 +80,7 @@ for f in "$ROOT_DIR"/etc/app-backup/*.example; do add "etc/app-backup/$(basename 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 # Save the exact replaced files plus a rollback inventory; do not dereference links. : > "$SNAPSHOT/present.txt"; : > "$SNAPSHOT/absent.txt" for i in "${!TARGETS[@]}"; do @@ -87,9 +102,10 @@ LOCKFILE=/run/app-backup.lock if [[ -f /etc/app-backup/app-backup.conf ]]; then source /etc/app-backup/app-backup.conf; fi # shellcheck disable=SC1091 source "$here/locking.sh" -acquire_backup_lock +acquire_backup_lock 1 if systemctl is-active --quiet app-backup.service; then echo 'Backup service is active'; exit 1; fi systemctl stop app-backup.timer +systemctl disable --now app-restore-test.timer app-restore-test-cleanup.service || true while IFS= read -r target; do cp -a --remove-destination -- "$here$target" "$target"; done < "$here/present.txt" while IFS= read -r target; do rm -f -- "$target"; done < "$here/absent.txt" systemctl daemon-reload @@ -109,6 +125,7 @@ trap on_exit EXIT trap 'exit 130' INT trap 'exit 143' TERM systemctl stop app-backup.timer +if [[ $RESTORE_TIMER_ACTIVE == true ]]; then systemctl stop app-restore-test.timer; fi CHANGING=true for i in "${!TARGETS[@]}"; do target=${TARGETS[i]} @@ -127,6 +144,7 @@ if [[ ${APP_BACKUP_INHERIT_LOCK:-0} != 1 ]]; then flock -u 9; fi if [[ $NO_TIMER == true ]]; then : elif [[ $ENABLE_TIMER == true ]]; then systemctl enable --now app-backup.timer elif [[ $TIMER_ACTIVE == true ]]; then systemctl start app-backup.timer; fi +if [[ $NO_TIMER != true && $RESTORE_TIMER_ACTIVE == true ]]; then systemctl start app-restore-test.timer; fi CHANGING=false echo "Installed. Rollback package: $SNAPSHOT" echo 'Existing config and timer schedule were preserved. Compare *.dist files and review migration documentation.' diff --git a/lib/common.sh b/lib/common.sh index 22b9ca7..658fa5b 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -39,6 +39,9 @@ load_config() { : "${MAIL_FROM:=}" "${MAIL_SUBJECT_PREFIX:=[app-backup]}" "${MAIL_INCLUDE_LOG_TAIL_LINES:=200}" : "${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_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}" : "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}" "${NC_OCC_USER:=apache}" @@ -61,7 +64,7 @@ load_config() { fi for v in ENABLE_UPLOAD ENABLE_REMOTE_RETENTION ENABLE_MAIL_REPORT ENABLE_WORDPRESS ENABLE_NEXTCLOUD \ ENABLE_NEXTCLOUD_DATA ENABLE_GITEA ENABLE_MAIL ENABLE_MAIL_STORE ENABLE_DB_DUMPS ENABLE_NEXTCLOUD_MAINTENANCE \ - ENABLE_GITEA_SERVICE_STOP ENABLE_HTTPD_STOP ENABLE_PHPFPM_STOP ENABLE_MAIL_HOME_DIRS RESTORE_STRICT_DELETE; do + ENABLE_GITEA_SERVICE_STOP ENABLE_HTTPD_STOP ENABLE_PHPFPM_STOP ENABLE_MAIL_HOME_DIRS RESTORE_STRICT_DELETE ENABLE_RESTORE_TEST; do [[ ${!v} == true || ${!v} == false ]] || die "$v must be true or false" done for v in LOG_RETENTION_DAYS MIN_FREE_GB LOCAL_RETENTION_DAYS REMOTE_RETENTION_DAYS RETENTION_KEEP_LAST ZSTD_LEVEL ZSTD_THREADS; do @@ -110,7 +113,7 @@ init_runtime() { guard_workspaces # shellcheck source=lib/locking.sh source "$LIB_DIR/locking.sh" - acquire_backup_lock + acquire_backup_lock 1 for p in "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$RESTORE_ROOT" "$LOG_DIR"; do [[ $p == /* && $p != / && $p != *$'\n'* && $p != *$'\t'* ]] || die "Unsafe working path: $p" [[ ! -L $p ]] || die "Symlink working directory refused: $p" diff --git a/lib/locking.sh b/lib/locking.sh index 1060c90..d858449 100644 --- a/lib/locking.sh +++ b/lib/locking.sh @@ -11,7 +11,12 @@ acquire_backup_lock() { echo 'Inherited lock descriptor does not match configured lock' >&2; return 1; } else - exec 9>"$LOCKFILE" + exec 9>"$LOCKFILE" || return 1 fi - flock -n 9 || { echo "Another backup/restore/update holds $LOCKFILE" >&2; return 1; } + local lock_code + flock -E "${1:-1}" -n 9 || { + lock_code=$? + echo "Another backup/restore/update holds $LOCKFILE, or the lock is unavailable" >&2 + return "$lock_code" + } } diff --git a/lib/restore-test-remote.sh b/lib/restore-test-remote.sh new file mode 100644 index 0000000..f3c1b34 --- /dev/null +++ b/lib/restore-test-remote.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Private bridge to the existing crypt/environment validation and rclone flags. +set -Eeuo pipefail +LIB_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib/common.sh +source "$LIB_DIR/common.sh" +CONFIG_FILE=${1:?Missing config} +shift +load_config +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 new file mode 100644 index 0000000..03b55af --- /dev/null +++ b/lib/restore_test.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +"""Read-only offsite restore rehearsal. Only marked scratch data may be deleted. + +The shell launcher holds the shared backup lock through this process. Docker has +one bind mount, no network/ports, no restart policy and no persistent log driver. +""" +import argparse +import datetime as dt +from email.message import EmailMessage +from email.policy import SMTP +import hashlib +import json +import logging +from logging.handlers import RotatingFileHandler +import os +from pathlib import Path +import re +import selectors +import shutil +import signal +import socket +import subprocess +import sys +import tarfile +import time + +import backup_set + +GIB = 1024 ** 3 +MARKER = 'app-backup-restore-test-v1\n' +DOCKER = ['docker', '--host=unix:///var/run/docker.sock'] +OWNER_LABEL = 'de.jr-it-services.app-backup.restore-test' + + +class TestError(Exception): + pass + + +def atomic_json(path, data): + temp = path.with_suffix('.tmp') + temp.write_text(json.dumps(data, ensure_ascii=True, indent=2) + '\n') + temp.replace(path) + + +def private_root(workdir, create=True): + work = Path(workdir) + # Do not follow aliases to source paths or accept broad filesystem roots. + if (not work.is_absolute() or work.resolve() != work or len(work.parts) < 3 or + any(c in str(work) for c in ':\n\r')): + raise TestError('Unsafe WORKDIR for restore test') + if work.exists() and (work.stat().st_uid != 0 or work.stat().st_mode & 0o022): + raise TestError('WORKDIR must be root-owned and not writable by group/others') + root = work / 'restore-test' + if root.is_symlink(): + raise TestError('Restore-test root must not be a symlink') + if not root.exists(): + if not create: + return root + root.mkdir(mode=0o700, parents=True) + (root / '.owner').write_text(MARKER) + marker = root / '.owner' + if (root.stat().st_uid != 0 or root.stat().st_mode & 0o077 or + not marker.is_file() or marker.is_symlink() or marker.read_text() != MARKER): + raise TestError('Unrecognized/non-private restore-test directory; refusing cleanup') + # These paths are written by us only. Never follow replacements into live data. + for name in ('.owner', 'scratch', 'state.json', 'state.tmp', 'last-success.json', + 'last-success.tmp', 'report.eml', 'restore-test.log', 'restore-test.log.1', 'db-created', + 'child.json', 'child.tmp'): + if (root / name).is_symlink(): + raise TestError('Symlink inside restore-test control directory') + return root + + +class Rehearsal: + def __init__(self, args): + self.args = args + self.root = private_root(args.workdir, create=args.mode != 'cleanup') + self.scratch = self.root / 'scratch' + self.owner = hashlib.sha256(str(self.root).encode()).hexdigest()[:16] + self.container = 'app-backup-restore-test-' + self.owner + self.started = time.time() + self.deadline = time.monotonic() + args.timeout + self.state = dict(status='RUNNING', started_epoch=int(self.started), run='', + components=[], cleanup='PENDING', error='') + self.log = logging.getLogger('restore-test') + self.log.setLevel(logging.INFO) + for handler in self.log.handlers: + handler.close() + self.log.handlers.clear() + if self.root.exists(): + handler = RotatingFileHandler(self.root / 'restore-test.log', maxBytes=512*1024, backupCount=1) + handler.setFormatter(logging.Formatter('%(asctime)s %(message)s')) + self.log.addHandler(handler) + self.log.addHandler(logging.StreamHandler(sys.stdout)) + self.next_usage = 0 + self.used = 0 + self.monitor = False + + def remember_child(self, proc): + stat = Path(f'/proc/{proc.pid}/stat').read_text().rsplit(')', 1)[1].split() + atomic_json(self.root / 'child.json', dict(pid=proc.pid, start=stat[19])) + + def stop_orphan(self): + marker = self.root / 'child.json' + if not marker.exists(): + return + child = json.loads(marker.read_text()) + pid = child['pid'] + if not isinstance(pid, int) or pid <= 1: + raise TestError('Invalid child-process marker') + def alive(): + try: + stat = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split() + return stat[19] == child['start'] and stat[0] != 'Z' and os.getpgid(pid) == pid + except (FileNotFoundError, ProcessLookupError): + return False + if alive(): + os.killpg(pid, signal.SIGTERM) + end = time.monotonic() + 5 + while alive() and time.monotonic() < end: + time.sleep(0.1) + if alive(): + os.killpg(pid, signal.SIGKILL) + end = time.monotonic() + 5 + while alive() and time.monotonic() < end: + time.sleep(0.1) + if alive(): + raise TestError('Child process could not be stopped; cleanup deferred') + marker.unlink() + + def save(self): + atomic_json(self.root / 'state.json', self.state) + + def disk(self, additional=0, force=False): + if not self.monitor: + return + if time.monotonic() >= self.deadline: + raise TestError('Restore-test time limit exceeded') + 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: + self.used = 0 + for path, directories, files in os.walk(self.scratch, followlinks=False): + 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: + raise TestError('Restore-test disk budget exceeded') + + def command(self, command, *, capture=0, output=None, limit=None, stdin=None, timeout=120): + """Bound stdout and runtime, monitor disk, reap children even on interruption. + + stderr is intentionally not copied: DB errors can contain restored data. + Errors identify the failed operation without logging SQL or credentials. + """ + result = bytearray() + total = 0 + end = time.monotonic() + timeout + proc = subprocess.Popen(command, stdin=stdin or subprocess.DEVNULL, + stdout=subprocess.PIPE if capture or output is not None else subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + selector = selectors.DefaultSelector() + if proc.stdout: + selector.register(proc.stdout, selectors.EVENT_READ) + try: + self.remember_child(proc) + while selector.get_map() or proc.poll() is None: + self.disk() + if time.monotonic() >= end: + raise TestError('Timed out: ' + Path(command[0]).name) + if not selector.get_map(): + time.sleep(0.1) + continue + for key, _ in selector.select(0.2): + chunk = os.read(key.fd, 64*1024) + if not chunk: + selector.unregister(key.fileobj) + continue + total += len(chunk) + if total > (limit if limit is not None else capture): + raise TestError('Remote output exceeds expected size') + if output is not None: + self.disk(additional=len(chunk)) + output.write(chunk) + else: + result.extend(chunk) + if proc.wait() != 0: + raise TestError('Command failed: ' + Path(command[0]).name) + return bytes(result) + finally: + selector.close() + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + if proc.stdout: + proc.stdout.close() + (self.root / 'child.json').unlink(missing_ok=True) + + def remote(self, *args, **kwargs): + return self.command(['bash', self.args.remote_check, self.args.config, *args], + timeout=self.args.timeout, **kwargs) + + def cleanup(self): + self.stop_orphan() + # db-created is written BEFORE docker run. A lost CLI reply cannot orphan a DB. + if (self.root / 'db-created').exists(): + names = self.command(DOCKER + ['container', 'ls', '-a', '--filter', + f'label={OWNER_LABEL}={self.owner}', '--format', '{{.Names}}'], + capture=4096).decode().splitlines() + if any(name != self.container for name in names): + raise TestError('Unexpected container ownership; refusing cleanup') + if names: + self.command(DOCKER + ['container', 'rm', '-f', '-v', self.container]) + (self.root / 'db-created').unlink() + if self.scratch.exists(): + # shutil.rmtree uses fd-relative traversal on Linux; nested symlinks + # are unlinked, never traversed. No broad cleanup of WORKDIR or Docker. + if self.scratch.is_symlink() or self.scratch.is_mount(): + raise TestError('Unsafe scratch directory; refusing cleanup') + shutil.rmtree(self.scratch) + self.state['cleanup'] = 'OK' + + def preflight(self): + self.monitor = True + for executable in ('tar', 'gzip', 'zstd'): + if not shutil.which(executable): + raise TestError('Missing tool: ' + executable) + if any(label.startswith('db-') for label in self.args.expected): + self.check_image() + self.disk(force=True) + + def check_image(self): + if hasattr(self, 'image_id'): + return + if not self.args.image or self.args.image.startswith('-') or any(c.isspace() for c in self.args.image): + raise TestError('Set RESTORE_TEST_DB_IMAGE to a locally prepared MariaDB image matching production') + raw = self.command(DOCKER + ['image', 'inspect', self.args.image], capture=128*1024) + info = json.loads(raw)[0] + volumes = info.get('Config', {}).get('Volumes') or {} + if set(volumes) - {'/var/lib/mysql'}: + raise TestError('Test image declares unexpected volumes') + # Store immutable local image ID: a tag change during a test cannot switch binaries. + self.image_id = info['Id'] + + def select(self): + base = self.args.remote.rstrip('/') + if self.args.run: + runs = [self.args.run] + else: + listing = self.remote('lsf', base, '--dirs-only', '--max-depth', '1', capture=1024*1024).decode() + runs = sorted((line.rstrip('/') for line in listing.splitlines() + if backup_set.RUN.fullmatch(line.rstrip('/')) and + line.startswith(self.args.prefix + '_')), reverse=True) + for run in runs: + if not backup_set.RUN.fullmatch(run) or not run.startswith(self.args.prefix + '_'): + raise TestError('Invalid remote run name') + files = self.remote('lsf', base + '/' + run, '--files-only', '--max-depth', '1', capture=1024*1024).decode().splitlines() + if 'COMPLETE' not in files: + if self.args.run: + raise TestError('Selected backup is incomplete') + self.log.info('Incomplete backup skipped: %s', run) + continue + self.state['run'] = run + self.save() + for name, maximum in [('manifest.json', 1024*1024), ('COMPLETE', 256)]: + data = self.remote('cat', base + '/' + run + '/' + name, capture=maximum) + (self.scratch / name).write_bytes(data) + manifest = backup_set.load_set(self.scratch, metadata_only=True) + if manifest['run_id'] != run: + raise TestError('Remote directory and manifest run differ') + age = time.time() - manifest['created_epoch'] + self.state['backup_age_hours'] = round(age / 3600, 2) + if age < -300 or age > self.args.max_age_hours * 3600: + raise TestError('Backup is stale or dated in the future') + labels = {item['label'] for item in manifest['archives']} + if set(self.args.expected) - labels: + raise TestError('Backup is missing configured components') + allowed = {item['file'] for item in manifest['archives']} | {'manifest.json', 'COMPLETE', 'UPLOADED'} + if set(files) != allowed and set(files) != allowed - {'UPLOADED'}: + raise TestError('Remote backup has missing or unexpected files') + return manifest + raise TestError('No completed remote backup found') + + def expand_size(self, archive): + # Full tar validation is performed first by the shared validator. A second + # streaming pass estimates logical size, including sparse files and metadata. + unpacked = 0 + count = 0 + proc = subprocess.Popen(['zstd' if archive.suffix == '.zst' else 'gzip', '-dc', '--', str(archive)], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True) + try: + self.remember_child(proc) + with tarfile.open(fileobj=proc.stdout, mode='r|') as tar: + for item in tar: + self.disk() + unpacked += ((item.size + 4095) // 4096) * 4096 + 4096 + count += 1 + while proc.stdout.read(1024*1024): + self.disk() + if proc.wait() != 0: + raise TestError('Archive decompression failed') + return unpacked, count + finally: + proc.stdout.close() + if proc.poll() is None: + proc.terminate() + proc.wait() + (self.root / 'child.json').unlink(missing_ok=True) + + def database(self, sql): + self.check_image() + # No imported code can reach a host DB socket, production path or network. + data = self.scratch / 'database' + data.mkdir(mode=0o700) + (self.root / 'db-created').write_text(self.container + '\n') + startup = '''set -eu +mkdir -p /var/lib/mysql/tmp +mariadb-install-db --no-defaults --datadir=/var/lib/mysql/db --auth-root-authentication-method=normal --skip-test-db --user=root --tmpdir=/var/lib/mysql/tmp >/dev/null 2>&1 +exec mariadbd --no-defaults --user=root --datadir=/var/lib/mysql/db --socket=/var/lib/mysql/mysql.sock --pid-file=/var/lib/mysql/mysql.pid --tmpdir=/var/lib/mysql/tmp --skip-networking --skip-log-bin --event-scheduler=OFF --local-infile=0 --innodb-buffer-pool-size=64M --innodb-log-file-size=16M --max-allowed-packet=64M +''' + self.command(DOCKER + ['run', '-d', '--pull=never', '--name', self.container, + '--label', f'{OWNER_LABEL}={self.owner}', '--network=none', '--read-only', + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--user=0:0', + '--memory=512m', '--memory-swap=512m', '--cpus=1', '--pids-limit=128', + '--log-driver=none', '--no-healthcheck', '--volume', str(data) + ':/var/lib/mysql:Z', + '--entrypoint=/bin/sh', self.image_id, '-c', startup]) + client = DOCKER + ['exec', '-i', self.container, 'mariadb', '--no-defaults', + '--socket=/var/lib/mysql/mysql.sock', '-uroot', '--batch', '--skip-column-names'] + ready = time.monotonic() + 90 + while True: + try: + self.command(client + ['--execute=SELECT 1'], capture=64, timeout=10) + break + except TestError: + self.disk() + if time.monotonic() > ready: + raise TestError('Isolated MariaDB did not become ready') from None + time.sleep(0.5) + self.command(client + ['--execute=CREATE DATABASE restore_test']) + with sql.open('rb') as stream: + # --binary-mode disables client commands in batch SQL (e.g. system/source). + self.command(client + ['--binary-mode', 'restore_test'], stdin=stream, timeout=self.args.timeout) + self.command(DOCKER + ['exec', self.container, 'mariadb-check', '--no-defaults', + '--socket=/var/lib/mysql/mysql.sock', '-uroot', '--all-databases'], timeout=self.args.timeout) + tables = self.command(client + ["--execute=SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')"], capture=128).decode().strip() + return int(tables) + + def execute(self): + self.preflight() + manifest = self.select() + self.log.info('Testing %s, %s components, sequentially', self.state['run'], len(manifest['archives'])) + for item in manifest['archives']: + component = dict(label=item['label'], status='RUNNING', archive_bytes=item['size']) + self.state['components'].append(component) + self.save() + self.log.info('Component %s: download, verify, extract', item['label']) + self.disk(additional=item['size'], force=True) + archive = self.scratch / item['file'] + with archive.open('wb') as output: + self.remote('cat', self.args.remote.rstrip('/') + '/' + self.state['run'] + '/' + item['file'], + output=output, limit=item['size']) + if archive.stat().st_size != item['size'] or backup_set.digest(archive) != item['sha256']: + raise TestError('Archive checksum/size mismatch: ' + item['label']) + # Run shared validation as a monitored child, so even corrupt streams + # and metadata-heavy archives cannot bypass the total timeout. + self.command([sys.executable, str(Path(__file__).with_name('backup_set.py')), + 'tar', str(archive), item['root']], timeout=self.args.timeout) + unpacked, entries = self.expand_size(archive) + self.disk(additional=unpacked, force=True) + extracted = self.scratch / 'extracted' + extracted.mkdir(mode=0o700) + self.command(['tar', '--extract', '--file', str(archive), '--acls', '--xattrs', '--selinux', + '--numeric-owner', '--same-owner', '-C', str(extracted)], timeout=self.args.timeout) + # The compressed input is no longer needed; free it BEFORE DB import. + archive.unlink() + component.update(extracted_bytes=unpacked, entries=entries) + if item['label'].startswith('db-'): + sql = extracted / item['root'] + if not sql.is_file() or sql.is_symlink() or not sql.stat().st_size: + raise TestError('SQL dump missing or empty') + self.disk(additional=512*1024*1024, force=True) + component['tables'] = self.database(sql) + self.monitor = False + self.cleanup() + self.scratch.mkdir(mode=0o700) + self.monitor = True + component['status'] = 'SUCCESS' + self.save() + self.log.info('Component %s: SUCCESS; files and test DB removed', item['label']) + self.state['status'] = 'SUCCESS' + + def report(self): + fqdn = socket.getfqdn() + host = self.args.hostname or fqdn + state = self.state + message = EmailMessage(policy=SMTP) + message['From'] = self.args.sender or 'app-backup@' + fqdn + message['To'] = self.args.to + message['Subject'] = f"[app-restore-test] {state['status']} {host} {state['run']}" + message['Date'] = dt.datetime.now().astimezone() + body = ['Restore-Test', f'Host: {host}', f"Status: {state['status']}", f"Sicherung: {state['run']}", + f"Alter: {state.get('backup_age_hours', '?')} Stunden", f"Dauer: {state['duration_seconds']}s", + f"Bereinigung: {state['cleanup']}", f"Fehler: {state['error']}", '', 'Komponenten:'] + for c in state['components']: + body.append(f"{c['label']}: {c['status']}; Archiv {c['archive_bytes']} Bytes; " + f"Einträge {c.get('entries', '?')}; Tabellen {c.get('tables', '-')}") + body.extend(['', 'Dateien entpackt; enthaltene DB-Dumps isoliert importiert und Tabellen geprüft.', + 'Dies ist kein Start-/Funktionstest der produktiven Anwendungen.']) + message.set_content('\n'.join(body)) + raw = message.as_bytes() + (self.root / 'report.eml').write_bytes(raw) + if self.args.mail == 'true': + if not self.args.to.strip() or not str(message['From']).strip(): + raise TestError('Empty mail recipient or sender') + with io_bytes(raw) as stream: + self.command([self.args.sendmail, '-oi', '-t'], stdin=stream, timeout=self.args.mail_timeout) + + def finish(self): + self.monitor = False + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, signal.SIG_IGN) + try: + self.cleanup() + except Exception as exc: + self.state.update(status='FAIL', cleanup='FAILED', error='Cleanup failed: ' + str(exc)) + self.log.error('%s', self.state['error']) + for c in self.state['components']: + if c['status'] == 'RUNNING': + c['status'] = 'FAIL' + self.state['duration_seconds'] = max(0, int(time.time() - self.state['started_epoch'])) + self.state['finished_epoch'] = int(time.time()) + self.save() + if self.state['status'] == 'SUCCESS': + atomic_json(self.root / 'last-success.json', self.state) + try: + self.report() + self.state['mail'] = 'SENT' if self.args.mail == 'true' else 'DISABLED' + except Exception as exc: + self.state['mail'] = 'FAILED' + self.log.error('Restore-test report failed (%s); inspect state.json', type(exc).__name__) + self.save() + self.log.info('Restore-test %s; cleanup %s', self.state['status'], self.state['cleanup']) + return 0 if self.state['status'] == 'SUCCESS' and self.state['mail'] != 'FAILED' else 1 + + +def io_bytes(raw): + # A real temporary FD for Popen stdin, unlinked immediately on creation. + import tempfile + stream = tempfile.TemporaryFile() + stream.write(raw) + stream.seek(0) + return stream + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('mode', choices=('run', 'check', 'cleanup')) + 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'): + parser.add_argument('--' + key) + parser.add_argument('--expected', action='append', default=[]) + for key in ('max-age-hours', 'timeout', 'mail-timeout'): + parser.add_argument('--' + key, type=int, required=True) + 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 + 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 == 'cleanup' and not test.root.exists(): + return 0 + if args.mode == 'check': + if any((test.root / name).exists() for name in ('scratch', 'db-created', 'child.json')): + raise TestError('Interrupted test data found; run --cleanup before --check') + test.preflight() + test.log.info('Restore-test prerequisites and disk reserve OK; no image pulled or backup downloaded') + return 0 + previous = test.root / 'state.json' + if previous.exists(): + try: + old = json.loads(previous.read_text()) + if not isinstance(old, dict) or not {'status', 'cleanup', 'started_epoch', 'components', 'run'} <= old.keys(): + raise ValueError('Invalid state') + except (ValueError, OSError): + old = dict(test.state) + if old.get('status') == 'RUNNING' or old.get('cleanup') != 'OK': + test.state = old + test.state.update(status='FAIL', error='Previous restore test interrupted; recovery cleanup') + result = test.finish() + if args.mode == 'cleanup' or test.state['cleanup'] != 'OK': + return result + test = Rehearsal(args) + if args.mode == 'cleanup': + test.cleanup() + return 0 + def interrupted(sig, _): + raise TestError('Interrupted by signal ' + str(sig)) + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, interrupted) + try: + test.cleanup() + test.state['cleanup'] = 'PENDING' + test.save() + test.scratch.mkdir(mode=0o700) + test.execute() + except Exception as exc: + test.state.update(status='FAIL', error=str(exc)) + test.log.error('Restore-test failed: %s', exc) + return test.finish() + + +if __name__ == '__main__': + os.umask(0o077) + try: + sys.exit(main()) + except Exception as exc: + print(f'ERROR: restore-test {type(exc).__name__}: {exc}', file=sys.stderr) + sys.exit(1) diff --git a/systemd/app-restore-test-cleanup.service b/systemd/app-restore-test-cleanup.service new file mode 100644 index 0000000..c4894e3 --- /dev/null +++ b/systemd/app-restore-test-cleanup.service @@ -0,0 +1,16 @@ +[Unit] +Description=Clean interrupted app-backup restore tests after boot +After=local-fs.target docker.service +Wants=docker.service + +[Service] +Type=oneshot +ExecStart=/bin/bash /usr/local/sbin/app-restore-test.sh --cleanup +SELinuxContext=system_u:system_r:unconfined_service_t:s0 +TimeoutStartSec=5min +UMask=0077 +Restart=on-failure +RestartSec=15min + +[Install] +WantedBy=multi-user.target diff --git a/systemd/app-restore-test.service b/systemd/app-restore-test.service new file mode 100644 index 0000000..700689a --- /dev/null +++ b/systemd/app-restore-test.service @@ -0,0 +1,26 @@ +[Unit] +Description=Monthly offsite restore test with automatic scratch cleanup +Wants=network-online.target docker.service +After=network-online.target docker.service +OnFailure=app-restore-test-cleanup.service + +[Service] +Type=oneshot +ExecStart=/bin/bash /usr/local/sbin/app-restore-test.sh +ExecStopPost=/bin/bash /usr/local/sbin/app-restore-test.sh --cleanup +SELinuxContext=system_u:system_r:unconfined_service_t:s0 +TimeoutStartSec=12h +TimeoutStopSec=5min +KillMode=control-group +# A busy shared lock is retried later; a failed restore remains a visible failure. +Restart=no +RestartForceExitStatus=75 +RestartSec=1h +UMask=0077 +Nice=15 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 +Environment=LANG=C +Environment=LC_ALL=C +StandardOutput=journal +StandardError=journal diff --git a/systemd/app-restore-test.timer b/systemd/app-restore-test.timer new file mode 100644 index 0000000..7e46191 --- /dev/null +++ b/systemd/app-restore-test.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Test the latest offsite backup on the first day of each month + +[Timer] +OnCalendar=*-*-01 06:00:00 +RandomizedDelaySec=15m +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/tests/test_backup.py b/tests/test_backup.py index a63d023..cc01fb3 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -585,7 +585,17 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: def test_automatic_package_upgrade_and_combined_rollback(self): self.enable_report() - with self.config.open('a') as f: f.write('MAIL_HOSTNAME="gemini.jr-it-services.de"\n') + 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. + 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':{}}}}])) +''') + docker.chmod(0o700) canonical=self.configure_upgrade() original=canonical.read_bytes() package=self.build_package() @@ -600,6 +610,9 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: self.assertIn('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text()) self.assertIn('OK (Inhalt und Abschlussmarker',body) 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.assertEqual(self.cnf.stat().st_mode & 0o777,0o600) diff --git a/tests/test_restore_test.py b/tests/test_restore_test.py new file mode 100644 index 0000000..b302253 --- /dev/null +++ b/tests/test_restore_test.py @@ -0,0 +1,366 @@ +"""Real local crypt/archive rehearsal tests; Docker DB isolation has a separate opt-in test.""" +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tarfile +import tempfile +import time +import unittest +from email.parser import BytesParser +from email import policy + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / 'lib')) +import backup_set +import restore_test + + +@unittest.skipUnless(os.geteuid() == 0 and shutil.which('rclone'), 'run in test container') +class RestoreTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='restore-rehearsal-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.work = self.base/'work' + self.work.mkdir() + self.root = self.work/'restore-test' + self.sentinel = self.work/'keep-backup.txt' + self.sentinel.write_text('real backup must survive') + self.config = self.base/'config' + self.crypt = self.base/'crypt.conf' + password = subprocess.check_output(['rclone','obscure','restore-test-only'],text=True).strip() + self.crypt.write_text(f'[crypt]\ntype=crypt\nremote={self.base}/encrypted\npassword={password}\n') + self.crypt.chmod(0o600) + self.sendmail = self.base/'sendmail' + self.sendmail.write_text(f'#!/bin/sh\ncat > "{self.base}/mail.eml"\n') + self.sendmail.chmod(0o700) + self.config.write_text(f'''WORKDIR="{self.work}" +LOG_DIR="{self.base}/logs" +LOCKFILE="{self.base}/lock" +ENABLE_RESTORE_TEST=true +ENABLE_WORDPRESS=true +WP_DIR="{self.base}/source" +ENABLE_DB_DUMPS=false +ENABLE_MAIL=false +ENABLE_NEXTCLOUD=false +RCLONE_CONFIG="{self.crypt}" +RCLONE_REMOTE_BASE="crypt:sets" +RCLONE_RETRIES=1 +RCLONE_LOW_LEVEL_RETRIES=1 +RESTORE_TEST_MIN_FREE_GB=0 +RESTORE_TEST_MAX_WORK_GB=1 +RESTORE_TEST_TIMEOUT=30 +MAIL_HOSTNAME="gemini.jr-it-services.de" +MAIL_TO="backup@example.invalid" +MAIL_FROM="app-backup@example.invalid" +MAIL_SENDMAIL_BIN="{self.sendmail}" +''') + self.config.chmod(0o600) + self.run = 'appbackup_'+time.strftime('%Y-%m-%d_%H-%M-%S',time.gmtime())+'_12345678' + self.folder = self.base/self.run + self.folder.mkdir() + self.make_archive('meta', 'meta', b'{}') + self.make_archive('wordpress', 'files/wordpress', b'expected data\n') + self.manifest() + self.upload() + + def make_archive(self, label, root, content): + with tarfile.open(self.folder/(label+'.tar.gz'),'w:gz') as tar: + if not root.endswith('.sql'): + entry = tarfile.TarInfo(root) + entry.type = tarfile.DIRTYPE + entry.mode = 0o700 + tar.addfile(entry) + root += '/payload.txt' + entry = tarfile.TarInfo(root) + entry.mode = 0o600 + entry.size = len(content) + tar.addfile(entry,io.BytesIO(content)) + + def manifest(self): + entries = [] + for archive in sorted(self.folder.glob('*.tar.gz')): + label = archive.name.removesuffix('.tar.gz') + entries.append(dict(label=label, source='/source', root=backup_set.expected_root(label), + file=archive.name,size=archive.stat().st_size,sha256=backup_set.digest(archive))) + data = dict(format=2,run_id=self.run,created_epoch=int(time.time()),archives=entries) + self.write_manifest(data) + + def write_manifest(self, data): + (self.folder/'manifest.json').write_text(json.dumps(data)) + (self.folder/'COMPLETE').write_text('APP_BACKUP_V2 '+backup_set.digest(self.folder/'manifest.json')+'\n') + + def rc(self,*args): + return subprocess.run(['rclone','--config',str(self.crypt),*args],check=True,capture_output=True) + + def upload(self): + self.rc('copy',str(self.folder),'crypt:sets/'+self.run,'--ignore-times') + + def launch(self,*args,**kw): + return subprocess.Popen(['bash',str(REPO/'usr-local-sbin/app-restore-test.sh'),'--config',str(self.config),*args], + stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,**kw) + + def execute(self,*args,ok=True): + p = self.launch(*args) + output = p.communicate(timeout=60)[0] + self.assertEqual(p.returncode,0 if ok else 1,output) + return output + + def clean(self): + self.assertFalse((self.root/'scratch').exists()) + self.assertFalse((self.root/'db-created').exists()) + self.assertFalse((self.root/'child.json').exists()) + self.assertEqual(self.sentinel.read_text(),'real backup must survive') + + def state(self): + return json.loads((self.root/'state.json').read_text()) + + def test_full_crypt_download_extract_report_and_cleanup(self): + self.execute() + state = self.state() + self.assertEqual(state['status'],'SUCCESS') + self.assertEqual(state['cleanup'],'OK') + self.assertEqual([c['label'] for c in state['components']],['meta','wordpress']) + self.assertTrue(all(c['status']=='SUCCESS' for c in state['components'])) + self.clean() + mail = BytesParser(policy=policy.default).parsebytes((self.base/'mail.eml').read_bytes()) + self.assertIn('[app-restore-test] SUCCESS gemini.jr-it-services.de',str(mail['Subject'])) + self.assertIn('Bereinigung: OK',mail.get_content()) + self.assertNotIn('expected data',mail.get_content()) + self.assertEqual((self.root/'last-success.json').stat().st_mode & 0o777,0o600) + self.execute('--cleanup') + self.clean() + + def test_stale_completed_backup_fails_and_cleans(self): + data=json.loads((self.folder/'manifest.json').read_text()) + data['created_epoch']-=4*86400 + self.write_manifest(data) + self.upload() + self.execute(ok=False) + self.assertIn('stale',self.state()['error']) + self.clean() + + def test_corrupt_archive_is_not_success_and_cleans(self): + (self.folder/'wordpress.tar.gz').write_bytes(b'corrupt') + self.upload() + self.execute(ok=False) + self.assertEqual(self.state()['status'],'FAIL') + self.clean() + + def test_disk_budget_refuses_work_and_cleans(self): + with self.config.open('a') as f: f.write('RESTORE_TEST_MAX_WORK_GB=0.000001\n') + self.execute(ok=False) + self.assertIn('budget',self.state()['error']) + self.clean() + + def test_reserve_refuses_work_and_cleans(self): + with self.config.open('a') as f: f.write('RESTORE_TEST_MIN_FREE_GB=100000\n') + self.execute(ok=False) + self.assertIn('reserve',self.state()['error']) + self.clean() + + def test_missing_configured_component_fails(self): + data=json.loads((self.folder/'manifest.json').read_text()) + data['archives']=[a for a in data['archives'] if a['label']=='meta'] + self.write_manifest(data) + self.upload() + self.execute(ok=False) + self.assertIn('missing configured',self.state()['error']) + self.clean() + + def test_latest_incomplete_set_is_skipped(self): + self.rc('mkdir','crypt:sets/appbackup_2099-01-01_00-00-00_abcdef12') + self.execute() + self.assertEqual(self.state()['run'],self.run) + self.clean() + + def test_scratch_symlink_is_never_followed(self): + self.root.mkdir(mode=0o700) + (self.root/'.owner').write_text(restore_test.MARKER) + (self.root/'scratch').symlink_to(self.work,target_is_directory=True) + self.execute('--cleanup',ok=False) + self.assertEqual(self.sentinel.read_text(),'real backup must survive') + self.assertTrue((self.root/'scratch').is_symlink()) + + def test_unknown_directory_is_never_deleted(self): + self.root.mkdir(mode=0o700) + (self.root/'precious').write_text('keep') + self.execute('--cleanup',ok=False) + self.assertEqual((self.root/'precious').read_text(),'keep') + + def test_check_preserves_interrupted_cleanup_markers(self): + self.root.mkdir(mode=0o700) + (self.root/'.owner').write_text(restore_test.MARKER) + (self.root/'scratch').mkdir() + (self.root/'child.json').write_text('{"pid": 99999999, "start": "old"}') + self.execute('--check',ok=False) + self.assertTrue((self.root/'child.json').exists()) + self.assertTrue((self.root/'scratch').exists()) + + def test_corrupt_state_does_not_block_cleanup(self): + self.root.mkdir(mode=0o700) + (self.root/'.owner').write_text(restore_test.MARKER) + (self.root/'scratch').mkdir() + (self.root/'scratch/old-data').write_text('remove') + (self.root/'state.json').write_text('incomplete json') + self.execute('--cleanup',ok=False) + self.clean() + self.assertEqual(self.state()['cleanup'],'OK') + + def test_unreachable_docker_does_not_claim_cleanup_success(self): + self.root.mkdir(mode=0o700) + (self.root/'.owner').write_text(restore_test.MARKER) + (self.root/'scratch').mkdir() + (self.root/'scratch/database-file').write_text('potentially still in use') + (self.root/'db-created').write_text('pending') + (self.root/'state.json').write_text(json.dumps(dict(status='RUNNING',cleanup='PENDING',started_epoch=int(time.time()),components=[],run=self.run))) + binpath=self.base/'bin' + binpath.mkdir() + docker=binpath/'docker' + docker.write_text('#!/bin/sh\nexit 42\n') + docker.chmod(0o700) + env=dict(os.environ,PATH=str(binpath)+':'+os.environ['PATH']) + p=self.launch('--cleanup',env=env) + output=p.communicate(timeout=20)[0] + self.assertEqual(p.returncode,1,output) + self.assertEqual(self.state()['cleanup'],'FAILED') + self.assertTrue((self.root/'scratch/database-file').exists()) + self.assertTrue((self.root/'db-created').exists()) + docker.write_text('#!/bin/sh\nexit 0\n') + p=self.launch('--cleanup',env=env) + output=p.communicate(timeout=20)[0] + self.assertEqual(p.returncode,1,output) # Previous run remains failed. + self.clean() + self.assertEqual(self.state()['cleanup'],'OK') + + def slow_download(self): + wrapper = self.base/'slow-rclone' + wrapper.write_text('''#!/bin/bash +for arg in "$@"; do + if [[ $arg == *wordpress.tar.gz ]]; then sleep 20; fi +done +exec /usr/bin/rclone "$@" +''') + wrapper.chmod(0o700) + with self.config.open('a') as f: f.write(f'RCLONE_BIN="{wrapper}"\n') + + def wait_download(self,p): + end=time.monotonic()+20 + while time.monotonic() "$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" --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'; + } + fi sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256" echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")" echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.' @@ -294,7 +299,7 @@ worker_finish() { if [[ $UP_OK == true && $code == 0 ]]; then printf 'SUCCESS\nPaket aktualisiert, Backup und Remote-Restore-Pruefung erfolgreich.\nBackup-Timer aktiv.\n' > "$UP_STATE/result.txt" else - if [[ $UP_CHANGED == true ]]; then systemctl stop app-backup.timer; fi + if [[ $UP_CHANGED == true ]]; then systemctl stop app-backup.timer app-restore-test.timer; fi printf 'FAILED (exit %s)\nDetails: %s/update.log\n' "$code" "$UP_STATE" > "$UP_STATE/result.txt" if [[ -f $UP_STATE/rollback-update.sh ]]; then printf 'Rollback: sudo bash %s/rollback-update.sh\n' "$UP_STATE" >> "$UP_STATE/result.txt" @@ -311,11 +316,12 @@ trap 'exit 143' TERM flock -w 60 8 || die 'Timed out waiting for update coordinator lock (60s); installation not started' [[ $(cat "$UP_STATE/result.txt") == QUEUED ]] || die 'Upgrade is not queued or submission was cancelled; rerun updater' printf 'RUNNING\n' > "$UP_STATE/result.txt" -acquire_backup_lock +acquire_backup_lock 1 export APP_BACKUP_INHERIT_LOCK=1 [[ $(sha256sum "$CONFIG_FILE" | cut -d ' ' -f 1) == "$(cat "$UP_STATE/original.sha256")" ]] || die 'Configuration changed after preparation; rerun updater' [[ $(sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1) == "$(cat "$UP_STATE/candidate.sha256")" ]] || die 'Prepared configuration changed unexpectedly' systemctl stop app-backup.timer +if systemctl is-active --quiet app-restore-test.timer; then systemctl stop app-restore-test.timer; fi UP_CHANGED=true # The ordinary installer records all replaced package files. Its timer remains # disabled here until this coordinator has verified the complete new backup. @@ -334,7 +340,7 @@ flock -n 8 || { echo 'Update still active'; exit 1; } systemctl stop app-backup.timer LOCKFILE=$lock source "$here/locking.sh" -acquire_backup_lock +acquire_backup_lock 1 export APP_BACKUP_INHERIT_LOCK=1 if [[ -f $here/package-rollback/rollback.sh ]]; then bash "$here/package-rollback/rollback.sh" @@ -376,4 +382,10 @@ printf '%s\n' "$backup_run" > "$UP_STATE/verified-backup-run" unset APP_BACKUP_INHERIT_LOCK flock -u 9 systemctl enable --now app-backup.timer +if [[ $ENABLE_RESTORE_TEST == true ]]; then + systemctl enable app-restore-test-cleanup.service + systemctl enable --now app-restore-test.timer +else + systemctl disable --now app-restore-test.timer || true +fi UP_OK=true diff --git a/usr-local-sbin/app-restore-test.sh b/usr-local-sbin/app-restore-test.sh new file mode 100755 index 0000000..5a8c3fc --- /dev/null +++ b/usr-local-sbin/app-restore-test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Read trusted config and hold the same lock as backup/restore/update. +set -Eeuo pipefail +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +LIB_DIR="$SCRIPT_DIR/../lib/app-backup" +[[ -f $LIB_DIR/common.sh ]] || LIB_DIR="$SCRIPT_DIR/../lib" +# shellcheck source=lib/common.sh +source "$LIB_DIR/common.sh" +CONFIG_FILE=/etc/app-backup/app-backup.conf +MODE=run +REMOTE_RUN='' +while (( $# )); do + case $1 in + --config) CONFIG_FILE=${2:?Missing config}; shift 2 ;; + --check) MODE=check; shift ;; + --cleanup) MODE=cleanup; 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" ;; + esac +done +load_config +# Cleanup must still work if the feature was disabled after an interrupted test. +[[ $MODE != run || $ENABLE_RESTORE_TEST == true ]] || die 'Set ENABLE_RESTORE_TEST=true after preparing the test image and disk budget' +guard_workspaces +# shellcheck source=lib/locking.sh +source "$LIB_DIR/locking.sh" +acquire_backup_lock 75 || exit $? +args=("$MODE" --workdir "$WORKDIR" --prefix "$ARCHIVE_PREFIX" --remote "$RCLONE_REMOTE_BASE" + --run "$REMOTE_RUN" --max-age-hours "$RESTORE_TEST_MAX_AGE_HOURS" + --max-work-gb "$RESTORE_TEST_MAX_WORK_GB" --min-free-gb "$RESTORE_TEST_MIN_FREE_GB" + --timeout "$RESTORE_TEST_TIMEOUT" --image "$RESTORE_TEST_DB_IMAGE" + --hostname "$MAIL_HOSTNAME" --to "$MAIL_TO" --sender "$MAIL_FROM" + --sendmail "$MAIL_SENDMAIL_BIN" --mail-timeout "$MAIL_SEND_TIMEOUT" --mail "$ENABLE_MAIL_REPORT") +if [[ $MODE != cleanup ]]; then + build_plan + for label in meta "${LABELS[@]}" "${DB_LABELS[@]}"; do args+=(--expected "$label"); done + # For run failures, let the Python supervisor report preflight errors as well. + args+=(--remote-check "$LIB_DIR/restore-test-remote.sh" --config "$CONFIG_FILE") +fi +exec python3 "$LIB_DIR/restore_test.py" "${args[@]}"