Add monthly offsite restore tests and automatic cleanup
This commit is contained in:
1 parent
64eab1d3c2
commit
f0c761213d
18 files changed
+1386
-16
No files matched your search
+23
-1
@@ -553,9 +553,31 @@ Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie
|
|||||||
|
|
||||||
## Prüfung und Abnahme
|
## 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
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
|
## Produktionsstand und verbleibende Abnahme
|
||||||
|
|
||||||
Auf Gemini bestand am 10.09.2026 ein vollständiger Paketlauf mit geschützter
|
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
|
## Tests
|
||||||
|
|
||||||
|
Der Restore-Test ist separat unter [RESTORE_TEST.md](RESTORE_TEST.md) dokumentiert,
|
||||||
|
einschließlich Aktivierung, Platzbedarf, Statusdateien, Fehlern und Bereinigung.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t app-backup-test -f tests/Dockerfile .
|
docker build -t app-backup-test -f tests/Dockerfile .
|
||||||
docker run --rm --network none -v "$PWD:/src:ro" app-backup-test
|
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
@@ -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
@@ -1,5 +1,30 @@
|
|||||||
# Automatisches Update-Paket
|
# 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
|
Für Gemini zuerst `MAIL_HOSTNAME="gemini.jr-it-services.de"` in der aktiven
|
||||||
Konfiguration setzen: [Mailberichte nach dem Update](#mailberichte-nach-dem-update).
|
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 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;
|
Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips;
|
||||||
systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell
|
systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ MAIL_INCLUDE_LOG_TAIL_LINES=200
|
|||||||
MAIL_SENDMAIL_BIN="/usr/sbin/sendmail"
|
MAIL_SENDMAIL_BIN="/usr/sbin/sendmail"
|
||||||
MAIL_SEND_TIMEOUT=30
|
MAIL_SEND_TIMEOUT=30
|
||||||
|
|
||||||
|
# Monthly offsite restore rehearsal. Prepare a LOCAL MariaDB image matching the
|
||||||
|
# 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"
|
ENABLE_WORDPRESS="true"
|
||||||
WP_DIR="/var/www/html"
|
WP_DIR="/var/www/html"
|
||||||
# Nextcloud paths below WP_DIR are automatically excluded (including on restore).
|
# Nextcloud paths below WP_DIR are automatically excluded (including on restore).
|
||||||
|
|||||||
+22
-4
@@ -17,8 +17,8 @@ while (( $# )); do
|
|||||||
done
|
done
|
||||||
[[ $NO_TIMER != true || $ENABLE_TIMER != true ]] || exit 1
|
[[ $NO_TIMER != true || $ENABLE_TIMER != true ]] || exit 1
|
||||||
# Syntax and packaging errors must be caught BEFORE changing the running installation.
|
# Syntax and packaging errors must be caught BEFORE changing the running installation.
|
||||||
for file in "$ROOT_DIR"/usr-local-sbin/*.sh "$ROOT_DIR/lib/common.sh"; do bash -n "$file"; done
|
for file in "$ROOT_DIR"/usr-local-sbin/*.sh "$ROOT_DIR"/lib/*.sh; do bash -n "$file"; done
|
||||||
python3 - "$ROOT_DIR/lib/backup_set.py" "$ROOT_DIR/lib/report.py" <<'PY'
|
python3 - "$ROOT_DIR/lib/backup_set.py" "$ROOT_DIR/lib/report.py" "$ROOT_DIR/lib/restore_test.py" <<'PY'
|
||||||
import ast,sys
|
import ast,sys
|
||||||
for path in sys.argv[1:]: ast.parse(open(path).read())
|
for path in sys.argv[1:]: ast.parse(open(path).read())
|
||||||
PY
|
PY
|
||||||
@@ -32,13 +32,19 @@ if [[ -f /etc/app-backup/app-backup.conf ]]; then
|
|||||||
fi
|
fi
|
||||||
# shellcheck source=lib/locking.sh
|
# shellcheck source=lib/locking.sh
|
||||||
source "$ROOT_DIR/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
|
if systemctl is-active --quiet app-backup.service; then
|
||||||
echo 'app-backup.service is active. Wait for completion before updating.' >&2
|
echo 'app-backup.service is active. Wait for completion before updating.' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
TIMER_ACTIVE=false
|
||||||
if systemctl is-active --quiet app-backup.timer; then TIMER_ACTIVE=true; fi
|
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
|
if [[ -n $SNAPSHOT_DIR ]]; then
|
||||||
[[ $SNAPSHOT_DIR == /* && ! -e $SNAPSHOT_DIR ]] || { echo 'Snapshot directory must be new and absolute'; exit 1; }
|
[[ $SNAPSHOT_DIR == /* && ! -e $SNAPSHOT_DIR ]] || { echo 'Snapshot directory must be new and absolute'; exit 1; }
|
||||||
mkdir -m 700 -- "$SNAPSHOT_DIR"
|
mkdir -m 700 -- "$SNAPSHOT_DIR"
|
||||||
@@ -49,6 +55,14 @@ declare -a SOURCES TARGETS MODES
|
|||||||
add() { SOURCES+=("$ROOT_DIR/$1"); TARGETS+=("$2"); MODES+=("$3"); }
|
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-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.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/common.sh /usr/local/lib/app-backup/common.sh 640
|
||||||
add lib/locking.sh /usr/local/lib/app-backup/locking.sh 640
|
add lib/locking.sh /usr/local/lib/app-backup/locking.sh 640
|
||||||
add lib/backup_set.py /usr/local/lib/app-backup/backup_set.py 640
|
add lib/backup_set.py /usr/local/lib/app-backup/backup_set.py 640
|
||||||
@@ -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 README.md /usr/local/share/doc/app-backup/README.md 644
|
||||||
add Dokumentation.md /usr/local/share/doc/app-backup/Dokumentation.md 644
|
add Dokumentation.md /usr/local/share/doc/app-backup/Dokumentation.md 644
|
||||||
add UPDATE_ANLEITUNG.md /usr/local/share/doc/app-backup/UPDATE_ANLEITUNG.md 644
|
add UPDATE_ANLEITUNG.md /usr/local/share/doc/app-backup/UPDATE_ANLEITUNG.md 644
|
||||||
|
add RESTORE_TEST.md /usr/local/share/doc/app-backup/RESTORE_TEST.md 644
|
||||||
# Save the exact replaced files plus a rollback inventory; do not dereference links.
|
# Save the exact replaced files plus a rollback inventory; do not dereference links.
|
||||||
: > "$SNAPSHOT/present.txt"; : > "$SNAPSHOT/absent.txt"
|
: > "$SNAPSHOT/present.txt"; : > "$SNAPSHOT/absent.txt"
|
||||||
for i in "${!TARGETS[@]}"; do
|
for i in "${!TARGETS[@]}"; do
|
||||||
@@ -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
|
if [[ -f /etc/app-backup/app-backup.conf ]]; then source /etc/app-backup/app-backup.conf; fi
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$here/locking.sh"
|
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
|
if systemctl is-active --quiet app-backup.service; then echo 'Backup service is active'; exit 1; fi
|
||||||
systemctl stop app-backup.timer
|
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 cp -a --remove-destination -- "$here$target" "$target"; done < "$here/present.txt"
|
||||||
while IFS= read -r target; do rm -f -- "$target"; done < "$here/absent.txt"
|
while IFS= read -r target; do rm -f -- "$target"; done < "$here/absent.txt"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
@@ -109,6 +125,7 @@ trap on_exit EXIT
|
|||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
systemctl stop app-backup.timer
|
systemctl stop app-backup.timer
|
||||||
|
if [[ $RESTORE_TIMER_ACTIVE == true ]]; then systemctl stop app-restore-test.timer; fi
|
||||||
CHANGING=true
|
CHANGING=true
|
||||||
for i in "${!TARGETS[@]}"; do
|
for i in "${!TARGETS[@]}"; do
|
||||||
target=${TARGETS[i]}
|
target=${TARGETS[i]}
|
||||||
@@ -127,6 +144,7 @@ if [[ ${APP_BACKUP_INHERIT_LOCK:-0} != 1 ]]; then flock -u 9; fi
|
|||||||
if [[ $NO_TIMER == true ]]; then :
|
if [[ $NO_TIMER == true ]]; then :
|
||||||
elif [[ $ENABLE_TIMER == true ]]; then systemctl enable --now app-backup.timer
|
elif [[ $ENABLE_TIMER == true ]]; then systemctl enable --now app-backup.timer
|
||||||
elif [[ $TIMER_ACTIVE == true ]]; then systemctl start app-backup.timer; fi
|
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
|
CHANGING=false
|
||||||
echo "Installed. Rollback package: $SNAPSHOT"
|
echo "Installed. Rollback package: $SNAPSHOT"
|
||||||
echo 'Existing config and timer schedule were preserved. Compare *.dist files and review migration documentation.'
|
echo 'Existing config and timer schedule were preserved. Compare *.dist files and review migration documentation.'
|
||||||
|
|||||||
+5
-2
@@ -39,6 +39,9 @@ load_config() {
|
|||||||
: "${MAIL_FROM:=}" "${MAIL_SUBJECT_PREFIX:=[app-backup]}" "${MAIL_INCLUDE_LOG_TAIL_LINES:=200}"
|
: "${MAIL_FROM:=}" "${MAIL_SUBJECT_PREFIX:=[app-backup]}" "${MAIL_INCLUDE_LOG_TAIL_LINES:=200}"
|
||||||
: "${MAIL_HOSTNAME:=}"
|
: "${MAIL_HOSTNAME:=}"
|
||||||
: "${MAIL_SENDMAIL_BIN:=/usr/sbin/sendmail}" "${MAIL_SEND_TIMEOUT:=30}"
|
: "${MAIL_SENDMAIL_BIN:=/usr/sbin/sendmail}" "${MAIL_SEND_TIMEOUT:=30}"
|
||||||
|
: "${ENABLE_RESTORE_TEST:=false}" "${RESTORE_TEST_DB_IMAGE:=}"
|
||||||
|
: "${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_WORDPRESS:=false}" "${ENABLE_NEXTCLOUD:=false}" "${ENABLE_NEXTCLOUD_DATA:=true}"
|
||||||
: "${ENABLE_GITEA:=false}" "${ENABLE_MAIL:=false}" "${ENABLE_DB_DUMPS:=true}"
|
: "${ENABLE_GITEA:=false}" "${ENABLE_MAIL:=false}" "${ENABLE_DB_DUMPS:=true}"
|
||||||
: "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}" "${NC_OCC_USER:=apache}"
|
: "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}" "${NC_OCC_USER:=apache}"
|
||||||
@@ -61,7 +64,7 @@ load_config() {
|
|||||||
fi
|
fi
|
||||||
for v in ENABLE_UPLOAD ENABLE_REMOTE_RETENTION ENABLE_MAIL_REPORT ENABLE_WORDPRESS ENABLE_NEXTCLOUD \
|
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_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"
|
[[ ${!v} == true || ${!v} == false ]] || die "$v must be true or false"
|
||||||
done
|
done
|
||||||
for v in LOG_RETENTION_DAYS MIN_FREE_GB LOCAL_RETENTION_DAYS REMOTE_RETENTION_DAYS RETENTION_KEEP_LAST ZSTD_LEVEL ZSTD_THREADS; do
|
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
|
guard_workspaces
|
||||||
# shellcheck source=lib/locking.sh
|
# shellcheck source=lib/locking.sh
|
||||||
source "$LIB_DIR/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
|
for p in "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$RESTORE_ROOT" "$LOG_DIR"; do
|
||||||
[[ $p == /* && $p != / && $p != *$'\n'* && $p != *$'\t'* ]] || die "Unsafe working path: $p"
|
[[ $p == /* && $p != / && $p != *$'\n'* && $p != *$'\t'* ]] || die "Unsafe working path: $p"
|
||||||
[[ ! -L $p ]] || die "Symlink working directory refused: $p"
|
[[ ! -L $p ]] || die "Symlink working directory refused: $p"
|
||||||
|
|||||||
+7
-2
@@ -11,7 +11,12 @@ acquire_backup_lock() {
|
|||||||
echo 'Inherited lock descriptor does not match configured lock' >&2; return 1;
|
echo 'Inherited lock descriptor does not match configured lock' >&2; return 1;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
exec 9>"$LOCKFILE"
|
exec 9>"$LOCKFILE" || return 1
|
||||||
fi
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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):
|
def test_automatic_package_upgrade_and_combined_rollback(self):
|
||||||
self.enable_report()
|
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()
|
canonical=self.configure_upgrade()
|
||||||
original=canonical.read_bytes()
|
original=canonical.read_bytes()
|
||||||
package=self.build_package()
|
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('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text())
|
||||||
self.assertIn('OK (Inhalt und Abschlussmarker',body)
|
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/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.assertTrue((state/'verified-backup-run').is_file())
|
||||||
self.assertIn('ENABLE_REMOTE_RETENTION=false',canonical.read_text())
|
self.assertIn('ENABLE_REMOTE_RETENTION=false',canonical.read_text())
|
||||||
self.assertEqual(self.cnf.stat().st_mode & 0o777,0o600)
|
self.assertEqual(self.cnf.stat().st_mode & 0o777,0o600)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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',
|
'usr-local-sbin/app-backup.sh', 'usr-local-sbin/app-restore.sh',
|
||||||
'systemd/app-backup.service', 'systemd/app-backup.timer', 'logrotate/app-backup',
|
'systemd/app-backup.service', 'systemd/app-backup.timer', 'logrotate/app-backup',
|
||||||
'etc/app-backup/app-backup.conf']
|
'etc/app-backup/app-backup.conf']
|
||||||
|
FILES += ['RESTORE_TEST.md', 'usr-local-sbin/app-restore-test.sh', 'lib/restore_test.py', 'lib/restore-test-remote.sh',
|
||||||
|
'systemd/app-restore-test.service', 'systemd/app-restore-test.timer',
|
||||||
|
'systemd/app-restore-test-cleanup.service']
|
||||||
|
|
||||||
|
|
||||||
def build(output, version):
|
def build(output, version):
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ if [[ $UP_WORKER == false ]]; then
|
|||||||
UP_HAD_GITEA=${ENABLE_GITEA+x}
|
UP_HAD_GITEA=${ENABLE_GITEA+x}
|
||||||
UP_ORIGINAL_RCLONE=${RCLONE_CONFIG:-}
|
UP_ORIGINAL_RCLONE=${RCLONE_CONFIG:-}
|
||||||
load_config
|
load_config
|
||||||
acquire_backup_lock
|
acquire_backup_lock 1
|
||||||
# Preserve original text and append effective safety settings. Repeated upgrades
|
# Preserve original text and append effective safety settings. Repeated upgrades
|
||||||
# may append another small block; the last explicit values remain authoritative.
|
# may append another small block; the last explicit values remain authoritative.
|
||||||
cp -- "$CONFIG_FILE" "$UP_STATE/candidate.conf"
|
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 || {
|
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";
|
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"
|
sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256"
|
||||||
echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")"
|
echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")"
|
||||||
echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.'
|
echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.'
|
||||||
@@ -294,7 +299,7 @@ worker_finish() {
|
|||||||
if [[ $UP_OK == true && $code == 0 ]]; then
|
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"
|
printf 'SUCCESS\nPaket aktualisiert, Backup und Remote-Restore-Pruefung erfolgreich.\nBackup-Timer aktiv.\n' > "$UP_STATE/result.txt"
|
||||||
else
|
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"
|
printf 'FAILED (exit %s)\nDetails: %s/update.log\n' "$code" "$UP_STATE" > "$UP_STATE/result.txt"
|
||||||
if [[ -f $UP_STATE/rollback-update.sh ]]; then
|
if [[ -f $UP_STATE/rollback-update.sh ]]; then
|
||||||
printf 'Rollback: sudo bash %s/rollback-update.sh\n' "$UP_STATE" >> "$UP_STATE/result.txt"
|
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'
|
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'
|
[[ $(cat "$UP_STATE/result.txt") == QUEUED ]] || die 'Upgrade is not queued or submission was cancelled; rerun updater'
|
||||||
printf 'RUNNING\n' > "$UP_STATE/result.txt"
|
printf 'RUNNING\n' > "$UP_STATE/result.txt"
|
||||||
acquire_backup_lock
|
acquire_backup_lock 1
|
||||||
export APP_BACKUP_INHERIT_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 "$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'
|
[[ $(sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1) == "$(cat "$UP_STATE/candidate.sha256")" ]] || die 'Prepared configuration changed unexpectedly'
|
||||||
systemctl stop app-backup.timer
|
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
|
UP_CHANGED=true
|
||||||
# The ordinary installer records all replaced package files. Its timer remains
|
# The ordinary installer records all replaced package files. Its timer remains
|
||||||
# disabled here until this coordinator has verified the complete new backup.
|
# 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
|
systemctl stop app-backup.timer
|
||||||
LOCKFILE=$lock
|
LOCKFILE=$lock
|
||||||
source "$here/locking.sh"
|
source "$here/locking.sh"
|
||||||
acquire_backup_lock
|
acquire_backup_lock 1
|
||||||
export APP_BACKUP_INHERIT_LOCK=1
|
export APP_BACKUP_INHERIT_LOCK=1
|
||||||
if [[ -f $here/package-rollback/rollback.sh ]]; then
|
if [[ -f $here/package-rollback/rollback.sh ]]; then
|
||||||
bash "$here/package-rollback/rollback.sh"
|
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
|
unset APP_BACKUP_INHERIT_LOCK
|
||||||
flock -u 9
|
flock -u 9
|
||||||
systemctl enable --now app-backup.timer
|
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
|
UP_OK=true
|
||||||
Executable
+41
@@ -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[@]}"
|
||||||
Reference in new issue
Block a user