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