Add monthly offsite restore tests and automatic cleanup

This commit is contained in:
admin committed 2026-09-12 19:47:32 +02:00
1 parent 64eab1d3c2
commit f0c761213d
18 files changed
+1386 -16

No files matched your search

+23 -1
View File
@@ -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.
+11 -1
View File
@@ -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.
+251
View File
@@ -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.
+26 -1
View File
@@ -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
+10
View File
@@ -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).
+22 -4
View File
@@ -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.'
+5 -2
View File
@@ -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"
+7 -2
View File
@@ -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"
}
}
+12
View File
@@ -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
+527
View File
@@ -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)
+16
View File
@@ -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
+26
View File
@@ -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
+10
View File
@@ -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
+14 -1
View File
@@ -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)
+366
View File
@@ -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()<end:
if p.poll() is not None:
self.fail(p.communicate()[0])
if (self.root/'state.json').exists():
state=self.state()
if len(state['components'])==2 and (self.root/'child.json').exists():
return
time.sleep(0.05)
self.fail('test did not reach download')
def test_term_cleans_partial_download(self):
self.slow_download()
p=self.launch()
self.wait_download(p)
p.send_signal(signal.SIGTERM)
output=p.communicate(timeout=20)[0]
self.assertEqual(p.returncode,1,output)
self.clean()
self.assertEqual(self.state()['status'],'FAIL')
def test_sigkill_recovery_cleans_orphan_and_reports_failure(self):
self.slow_download()
p=self.launch()
self.wait_download(p)
p.kill()
p.communicate(timeout=30)
self.execute('--cleanup',ok=False)
self.clean()
self.assertEqual(self.state()['status'],'FAIL')
self.assertIn('interrupted',self.state()['error'])
def test_shared_lock_excludes_restore_test(self):
lock=self.base/'lock'
p=subprocess.Popen(['flock',str(lock),'sleep','20'])
try:
time.sleep(.2)
test=self.launch()
output=test.communicate(timeout=10)[0]
self.assertEqual(test.returncode,75,output)
self.assertFalse(self.root.exists())
finally:
p.terminate()
p.wait()
def test_unsafe_lock_is_not_treated_as_busy(self):
(self.base/'lock').symlink_to(self.sentinel)
self.execute(ok=False)
self.assertFalse(self.root.exists())
self.assertEqual(self.sentinel.read_text(),'real backup must survive')
def prepare_database(self, sql):
image=os.environ['RESTORE_TEST_REAL_DOCKER_IMAGE']
self.make_archive('db-wordpress','db/wordpress.sql',sql)
self.manifest()
self.upload()
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')
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)
self.assertNotIn('app-backup-restore-test-'+hashlib.sha256(str(self.root).encode()).hexdigest()[:16],names)
@unittest.skipUnless(os.environ.get('RESTORE_TEST_REAL_DOCKER_IMAGE'), 'opt-in local Docker integration')
def test_real_isolated_db_import_and_container_cleanup(self):
self.prepare_database(b'CREATE TABLE example (id INT PRIMARY KEY); INSERT INTO example VALUES (1);')
self.execute()
state=self.state()
db=next(c for c in state['components'] if c['label']=='db-wordpress')
self.assertEqual(db['tables'],1)
self.no_container()
@unittest.skipUnless(os.environ.get('RESTORE_TEST_REAL_DOCKER_IMAGE'), 'opt-in local Docker integration')
def test_real_db_sql_failure_cleans_container_and_data(self):
self.prepare_database(b'CREATE TABLE example (id INT); invalid SQL contains-secret-value;')
self.execute(ok=False)
self.no_container()
self.assertEqual(self.state()['cleanup'],'OK')
self.assertNotIn('contains-secret-value',(self.root/'report.eml').read_text())
self.assertNotIn('contains-secret-value',(self.root/'restore-test.log').read_text())
@unittest.skipUnless(os.environ.get('RESTORE_TEST_REAL_DOCKER_IMAGE'), 'opt-in local Docker integration')
def test_real_db_sigkill_recovery_removes_container_and_data(self):
self.prepare_database(b'CREATE TABLE example (id INT); SELECT SLEEP(40);')
p=self.launch()
container='app-backup-restore-test-'+hashlib.sha256(str(self.root).encode()).hexdigest()[:16]
end=time.monotonic()+20
while time.monotonic()<end:
result=subprocess.run(restore_test.DOCKER+['exec',container,'mariadb','--no-defaults','--socket=/var/lib/mysql/mysql.sock','-uroot','--execute=SELECT COUNT(*) FROM restore_test.example'],capture_output=True)
if result.returncode==0:
break
time.sleep(.2)
else:
p.terminate()
self.fail(p.communicate(timeout=20)[0])
info=json.loads(subprocess.check_output(restore_test.DOCKER+['inspect',container]))[0]
self.assertEqual(info['HostConfig']['NetworkMode'],'none')
self.assertTrue(info['HostConfig']['ReadonlyRootfs'])
self.assertEqual(info['HostConfig']['CapDrop'],['ALL'])
self.assertEqual(info['HostConfig']['LogConfig']['Type'],'none')
self.assertEqual(len(info['Mounts']),1)
self.assertEqual(info['Mounts'][0]['Source'],str(self.root/'scratch/database'))
p.kill()
p.communicate(timeout=20)
self.execute('--cleanup',ok=False)
self.no_container()
self.assertIn('interrupted',self.state()['error'])
if __name__=='__main__':
unittest.main()
+3
View File
@@ -15,6 +15,9 @@ 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',
'systemd/app-restore-test.service', 'systemd/app-restore-test.timer',
'systemd/app-restore-test-cleanup.service']
def build(output, version):
+16 -4
View File
@@ -127,7 +127,7 @@ if [[ $UP_WORKER == false ]]; then
UP_HAD_GITEA=${ENABLE_GITEA+x}
UP_ORIGINAL_RCLONE=${RCLONE_CONFIG:-}
load_config
acquire_backup_lock
acquire_backup_lock 1
# Preserve original text and append effective safety settings. Repeated upgrades
# may append another small block; the last explicit values remain authoritative.
cp -- "$CONFIG_FILE" "$UP_STATE/candidate.conf"
@@ -242,6 +242,11 @@ if [[ $UP_WORKER == false ]]; then
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 || {
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
+41
View File
@@ -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[@]}"