Compare commits

..
2 Commits
Author SHA1 Message Date
admin 6737405b35 Recover backup capacity with verified adaptive retention
Resume pending publications under local pressure, allow verified offsite-only history as a final local recovery stage, and prune old complete remote sets against actual upload quota while preserving the newest verified copies. Document defaults, recovery limits and validation; add pressure and quota regression coverage.
2026-10-22 15:08:35 +02:00
admin a79f48bc2a Record successful Gemini backup acceptance on 19 October 2026-10-19 10:57:42 +02:00
10 changed files with 648 additions and 35 deletions

No files matched your search

+12 -8
View File
@@ -1,10 +1,11 @@
# Dokumentation app-backup v2 # Dokumentation app-backup v2
**Aktualisiert am 21. September 2026:** Die Platzbereinigung schützt mit **Aktualisiert am 22. Oktober 2026:** Adaptive lokale und Remote-Bereinigung löst
`SPACE_KEEP_LOCAL=1` mindestens eine vollständig geprüfte, als hochgeladen markierte auch die Blockade durch die letzte lokale Kopie. Standardmäßig darf vorübergehend
lokale Sicherung; reguläre Retention und Speicherreserve bleiben separat bestehen. nur die erneut geprüfte Offsite-Kopie verbleiben (`SPACE_ALLOW_REMOTE_ONLY=true`).
Manifest und Abschlussmarker werden vor lokaler Entfernung bytegenau remote verglichen. Unbestätigte vollständige lokale Sätze werden bei lokalem Platzdruck erneut
Siehe [Speicherstrategie und Fehlerbehebung](SPEICHER.md). veröffentlicht. Die Quotenbereinigung schützt mindestens `SPACE_KEEP_REMOTE=1`
Remote-Satz. [Verbindlicher Ablauf, Einstellungen und Grenzen](SPEICHER.md).
Seit 16. September 2026: Backup, Platzbereinigung und Update-Abnahme Seit 16. September 2026: Backup, Platzbereinigung und Update-Abnahme
prüfen remote nur Dateiexistenz und Größen; Archive werden erst beim monatlichen prüfen remote nur Dateiexistenz und Größen; Archive werden erst beim monatlichen
@@ -355,15 +356,18 @@ DB-Größenabfragen inventarisiert. Die Platzschätzung enthält Reserven für D
Dateisystem-Metadaten und Wachstum. `MIN_FREE_GB` (Default 10 GiB) bleibt zusätzlich Dateisystem-Metadaten und Wachstum. `MIN_FREE_GB` (Default 10 GiB) bleibt zusätzlich
frei. Erst nach erfolgreicher Platzbeschaffung werden Writer angehalten. frei. Erst nach erfolgreicher Platzbeschaffung werden Writer angehalten.
Reicht Scratch-Bereinigung nicht, werden ältere geeignete lokale Sätze unter Reicht Scratch-Bereinigung nicht, werden ältere geeignete lokale Sätze
Wahrung von `SPACE_KEEP_LOCAL` (Default und Minimum eins, seit 21.09.2026) lokal zunächst unter Wahrung von `SPACE_KEEP_LOCAL` (Default eins) lokal
vollständig validiert und remote auf Dateiexistenz und passende Größen geprüft. vollständig validiert und remote auf Dateiexistenz und passende Größen geprüft.
Remote-Manifest und Abschlussmarker müssen zusätzlich bytegenau übereinstimmen. Remote-Manifest und Abschlussmarker müssen zusätzlich bytegenau übereinstimmen.
Dabei werden keine Archive geladen. Die normale Retention bleibt separat bei Dabei werden keine Archive geladen. Die normale Retention bleibt separat bei
`RETENTION_KEEP_LAST` (Default drei). Bei weiterem Platzmangel nennt das Log das `RETENTION_KEEP_LAST` (Default drei). Bei weiterem Platzmangel nennt das Log das
Defizit und die geschützten Archivbestände mit belegten Bytes und Schutzgrund. Defizit und die geschützten Archivbestände mit belegten Bytes und Schutzgrund.
Nur danach darf ihre lokale Kopie entfernt werden. Offsite-Dateien bleiben Nur danach darf ihre lokale Kopie entfernt werden. Offsite-Dateien bleiben
unverändert. Diese Platzbereinigung gilt auch bei `--no-retention` während einer erhalten, außer bei der separaten Quotenbereinigung. Wenn die lokale Mindestkopie
den nächsten Lauf blockiert, darf `SPACE_ALLOW_REMOTE_ONLY=true` sie nach erneuter
Offsite-Prüfung freigeben; Details und Risiken stehen in `SPEICHER.md`.
Diese Platzbereinigung gilt auch bei `--no-retention` während einer
Update-Abnahme und kann das lokale Aufbewahrungsalter unterschreiten. Update-Abnahme und kann das lokale Aufbewahrungsalter unterschreiten.
Jede erfolgreich komprimierte Komponente wird vor dem Entfernen ihrer Jede erfolgreich komprimierte Komponente wird vor dem Entfernen ihrer
+19 -11
View File
@@ -1,11 +1,14 @@
# app-backup – geprüfte Sicherungen für den JR IT Server # app-backup – geprüfte Sicherungen für den JR IT Server
**21.09.2026 – Speicherblockade:** Bei Platznot bleiben mit `SPACE_KEEP_LOCAL=1` **22.10.2026 – Adaptive Speicherbereinigung:** Reicht eine lokale Mindestkopie
mindestens eine bestätigte lokale Sicherung und alle unbestätigten Sätze erhalten. bei Platznot nicht mehr aus, darf sie nach vollständiger lokaler und erneuter
Ältere Kopien werden erst nach lokalen Inhalts- und erneuten Offsite-Prüfungen Offsite-Prüfung freigegeben werden (`SPACE_ALLOW_REMOTE_ONLY=true`). Bis zum neuen
entfernt, bis der geplante Platzbedarf gedeckt ist. Die normale Retention bleibt Backup kann vorübergehend nur die Offsite-Kopie existieren. Fehlgeschlagene Uploads
bei drei Kopien. [Ablauf und Grenzen](SPEICHER.md), vollständiger lokaler Sätze werden unter lokalem Platzdruck erneut versucht.
[Deployment](UPDATE_ANLEITUNG.md). Die Installation auf Gemini steht aus. Bei knapper Remote-Quote werden ältere vollständige Sätze bereinigt; mindestens
die neueste geprüfte Offsite-Sicherung bleibt erhalten. Normale Altersretention
und Speicherreserve bleiben separat bestehen. [Ablauf und Grenzen](SPEICHER.md),
[Installation und Abnahme](UPDATE_ANLEITUNG.md).
Sichert WordPress, Nextcloud, native Gitea-Installationen mit MariaDB, Maildaten und zusätzliche Verzeichnisse. Jede Komponente erhält ein eigenes Archiv. Uploads gehen ausschließlich über ein **rclone-Crypt-Remote**; anschließend werden Existenz und Größe der Remote-Dateien geprüft. Vollständige Downloads mit Prüfsummen-, Entpack- und DB-Importprüfung erfolgen beim monatlichen Restore-Test. Sichert WordPress, Nextcloud, native Gitea-Installationen mit MariaDB, Maildaten und zusätzliche Verzeichnisse. Jede Komponente erhält ein eigenes Archiv. Uploads gehen ausschließlich über ein **rclone-Crypt-Remote**; anschließend werden Existenz und Größe der Remote-Dateien geprüft. Vollständige Downloads mit Prüfsummen-, Entpack- und DB-Importprüfung erfolgen beim monatlichen Restore-Test.
@@ -34,10 +37,12 @@ Ohne Aktivierungsoption bleibt ein bisher deaktivierter Restore-Test deaktiviert
## Produktionsstand und verbleibende Abnahme ## Produktionsstand und verbleibende Abnahme
**Stand 16.09.2026:** Nach erneutem Rücklesefehler bei `nextcloud-data.tar.zst` **Stand 19.10.2026:** Nach geprüfter Archivverlagerung und Trennung von Staging
wird der tägliche Inhaltsvergleich durch eine Remote-Datei-/Größenprüfung ersetzt. und aktivem Archiv bestand `appbackup_2026-10-18_15-22-34_fe0edb31` um 03:53:28 MESZ
Die Installation dieses neuen Stands auf Gemini steht aus; konkrete Befehle und mit `SUCCESS`. Der Upload von 32,053 GiB, alle 17 Remote-Dateien samt Größen und
Erfolgskriterien stehen in der [Update-Anleitung](UPDATE_ANLEITUNG.md). der Abschlussmarker wurden bestätigt. Anwendungen und Timer laufen wieder.
Der vollständige monatliche Offsite-Restore bleibt eine separate offene Abnahme.
Nachweise und Grenzen stehen in [SPEICHER.md](SPEICHER.md#abschlussprüfung-am-19102026).
Auf Gemini bestand am 10.09.2026 ein vollständiger Paketlauf mit geschützter Auf Gemini bestand am 10.09.2026 ein vollständiger Paketlauf mit geschützter
Dateikopie, DB-Dumps, OneDrive-Upload, vollständigem Rücklesen und separater Dateikopie, DB-Dumps, OneDrive-Upload, vollständigem Rücklesen und separater
@@ -182,7 +187,10 @@ In der Konfiguration insbesondere ergänzen/prüfen:
RCLONE_CONFIG="/root/.config/rclone/rclone.conf" RCLONE_CONFIG="/root/.config/rclone/rclone.conf"
RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" # vorhandenes Crypt-Ziel beibehalten! RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" # vorhandenes Crypt-Ziel beibehalten!
RETENTION_KEEP_LAST=3 RETENTION_KEEP_LAST=3
SPACE_KEEP_LOCAL=1 # nur bei Platznot, nach erneuter Prüfung SPACE_KEEP_LOCAL=1 # erste Bereinigungsstufe bei Platznot
SPACE_ALLOW_REMOTE_ONLY=true # letzte lokale Kopie nach Offsite-Prüfung freigeben
SPACE_REMOTE_CLEANUP=true # ältere Remote-Sätze bei knapper Quote entfernen
SPACE_KEEP_REMOTE=1 # neueste geprüfte Offsite-Kopie immer behalten
ENABLE_REMOTE_RETENTION="false" # während der Migration zunächst aus ENABLE_REMOTE_RETENTION="false" # während der Migration zunächst aus
MAIL_BACKUP_MODE="stop" MAIL_BACKUP_MODE="stop"
# Zentrale Mailablage muss existieren; bei ausschließlich in Homes liegenden # Zentrale Mailablage muss existieren; bei ausschließlich in Homes liegenden
+131 -3
View File
@@ -1,5 +1,106 @@
# Automatische Speicherbereinigung # Automatische Speicherbereinigung
## Adaptive Bereinigung bei Platznot – 22.10.2026
Der Bericht vom 22.10. nennt 74.630.297.390 benötigte und 59.122.741.248 freie
Bytes auf `/var`: Es fehlen 15.507.556.142 Bytes (14,44 GiB). Die geschützte
hochgeladene lokale Sicherung belegt 34.408.038.400 Bytes (32,05 GiB).
Ihre Freigabe kann diesen Engpass lösen. Eine Löschung in OneDrive schafft dagegen
keinen Platz auf Gemini. Der historische/unbestätigte Satz wird nicht blind entfernt.
Die folgende Strategie ersetzt die frühere unbedingte lokale Mindestkopie.
Neue Standardwerte gelten auch bei bestehenden Konfigurationen ohne diese Einträge:
```bash
SPACE_KEEP_LOCAL=1
SPACE_ALLOW_REMOTE_ONLY=true
SPACE_REMOTE_CLEANUP=true
SPACE_KEEP_REMOTE=1
```
1. Wie bisher: erkannte Arbeitsreste bereinigen, Snapshotbedarf plus Reserve
schätzen, Writer erst nach erfolgreichem Preflight anhalten.
2. Vor einem Upload die Remote-Quote über `rclone about --json` abfragen.
Unter Platzdruck darf `SPACE_REMOTE_CLEANUP=true` ältere vollständige v2-Sätze
unabhängig von Altersretention und `ENABLE_REMOTE_RETENTION` entfernen.
Die neuesten `SPACE_KEEP_REMOTE` geprüften vollständigen Sätze bleiben erhalten
(Default und Minimum eins). Nur Verzeichnisse mit passendem Laufnamen,
gültigem Manifest und Abschlussmarker sowie vollständigem Dateibestand in
Manifestgrößen zählen als gültig. Unvollständige, unbekannte oder abweichende
Bestände werden weder gelöscht noch als Rückfallkopie gezählt. Vor jeder
Entfernung werden Kandidat und geschützte Rückfallkopien erneut geprüft.
Der aktuelle Upload und eine ausdrücklich ausgewählte Remote-Restore-Quelle
werden ausgenommen. Gelöscht wird ältester Satz zuerst, nur bis ausreichend
Quote verfügbar ist. Der Quotenbedarf enthält zusätzlich 5 % plus 1 MiB für
Verschlüsselungs-/Datei-Overhead. Nach jeder Entfernung wird die Quote neu
gelesen; ein erfolgreicher Purge gilt nicht als Beleg für freien Speicher.
3. Bei lokalem Archivplatzmangel vollständige unbestätigte lokale v2-Sätze prüfen.
Ist ihre Remote-Veröffentlichung schon vollständig und identisch, wird der
lokale `UPLOADED`-Marker nach erneuter Prüfung nachgetragen. Andernfalls wird
der Upload mit `--immutable` wiederholt: Archive und Manifest zuerst,
Dateiprüfung, Abschlussmarker zuletzt, Identitätsprüfung, dann `UPLOADED`.
Fehlerhafte, unvollständige oder weiterhin nicht veröffentlichte Sätze bleiben
geschützt. Eine beschädigte Remote-Datei wird nicht überschrieben.
4. Zunächst wie bisher ältere bestätigte lokale Kopien bis `SPACE_KEEP_LOCAL`
bereinigen. Reicht das nicht, erlaubt `SPACE_ALLOW_REMOTE_ONLY=true` auch die
Freigabe dieser lokalen Mindestkopien, wenn ihre belegten Blöcke das Defizit
voraussichtlich decken können. Auch hier: vollständige lokale Inhaltsprüfung,
Remote-Dateien/-Größen sowie bytegleiche Remote-Manifeste und Abschlussmarker;
erst danach lokale Entfernung, ältester Satz zuerst, erneute Platzmessung und
Stopp bei ausreichender Kapazität. Eine ausgewählte lokale Restore-Quelle
bleibt ausgeschlossen. Mit `SPACE_ALLOW_REMOTE_ONLY=false` bleibt die bisherige
feste lokale Untergrenze erhalten.
5. Vor der Veröffentlichung des neuen lokalen Satzes Remote-Quote anhand seiner
tatsächlichen Archivgröße prüfen. Das unkomprimierte Snapshotbudget ist keine
Remote-Platzanforderung und blockiert keinen kleineren komprimierten Upload.
Nach erfolgreichem Lauf bleibt der neue lokale Satz erhalten; die normale Altersretention gilt weiterhin separat.
**Abwägung:** In der letzten lokalen Notstufe kann bis zur Fertigstellung des
neuen Backups ausschließlich die geprüfte Offsite-Sicherung existieren. Schlägt
der neue Lauf fehl, steht diese weiter bereit; ein lokaler Restore erfordert dann
zunächst einen Download. Die Prüfungen lesen keine Archive aus OneDrive zurück.
Gleich große Inhaltsbeschädigungen erkennt erst der vollständige Restore-Test.
Die Änderungen ersetzen dessen weiterhin offene produktive Abnahme nicht.
`SPACE_REMOTE_CLEANUP=false` deaktiviert Quotenbereinigung, `SPACE_KEEP_REMOTE`
erhöht die geschützte Remote-Anzahl. Die normale Altersretention behält ihre
separaten Werte `RETENTION_KEEP_LAST`, `LOCAL_RETENTION_DAYS` und
`REMOTE_RETENTION_DAYS`; Quotenbereinigung ändert diese Konfiguration nicht.
`--no-retention` unterdrückt weiterhin nur die normale Altersretention, nicht
die notwendige lokale oder Remote-Platzbeschaffung. `--check` bleibt ohne
Bereinigung, Upload oder Remote-Löschung. `--prepare-space` kann diese Schritte
bereits ausführen und stoppt dabei keine produktiven Writer.
Kann das Backend keine brauchbare freie Quote melden, wird dies protokolliert;
es erfolgt keine Remote-Notbereinigung, der normale Upload wird weiter versucht.
Signalisierte Abbrüche werden weitergereicht. Bei nicht freigegebener Provider-
Quote (z. B. verzögerte Abrechnung), ausschließlich geschützten Beständen,
Remote-Prüffehlern oder einem selbst für einen neuen Satz zu kleinen Dateisystem
bleibt ein gezielter Abbruch erforderlich. Keine Automatik kann diese Fälle
sicher durch Löschen der letzten Offsite-Sicherung lösen. Speicherreserve,
Recovery-Journale, historische Formate, Mounts und unbekannte Daten bleiben geschützt.
Installation erfolgt als vollständiges Update-Paket nach
[UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md). Der dortige alte Abnahmenachweis vom
19.10. gilt für den damaligen Stand. Der Fix vom 22.10. muss nach Installation
auf Gemini anhand des nächsten erfolgreichen Backup-Berichts abgenommen werden.
### Lokale Prüfung am 22.10.2026
Shell-Syntax und ShellCheck bestanden. Von 157 Testfällen bestanden alle 149
aktivierten Fälle; acht optionale Tests für echten User-systemd beziehungsweise
Docker-Restore bleiben übersprungen. Die Suite verwendet echte Archive, eine
isolierte MariaDB und lokale rclone-Crypt-Ziele. Geprüft sind insbesondere ein
neuer erfolgreicher Backup-/Upload-Lauf nach Freigabe der letzten lokalen Kopie,
fehlende beziehungsweise beschädigte Offsite-Metadaten, nachgeholte Uploads,
Quotenbereinigung mit erhaltener neuester Offsite-Sicherung, unveränderte Quote
nach Purge, unbekannte Quote, Abbruchsignale und die Verwendung komprimierter
Archivgrößen statt des Snapshotbudgets für die Remote-Platzprüfung.
Die produktive Installation und Abnahme auf Gemini sind damit nicht ersetzt.
Die folgenden datierten Abschnitte dokumentieren frühere Stände; bei abweichenden
Schutzgrenzen und Upload-Regeln gilt die oben beschriebene Strategie vom 22.10.2026.
## Platznot vor dem Backup: Korrektur vom 21.09.2026 ## Platznot vor dem Backup: Korrektur vom 21.09.2026
Der Mailbericht vom 20.09. nennt 55.823.183.174 benötigte und Der Mailbericht vom 20.09. nennt 55.823.183.174 benötigte und
@@ -194,8 +295,11 @@ der Fehler muss behoben und das Paket erneut ausgeführt werden.
## Gemini: historische Archive erhalten, Staging trennen – 18.10.2026 ## Gemini: historische Archive erhalten, Staging trennen – 18.10.2026
**Reparatur vorbereitet; produktive Ausführung und vollständiger neuer Lauf noch **Am 19.10.2026 produktiv abgenommen:** Revision 2 beendete die Verlagerung und
offen.** Die durch Johannes ausgeführte Root-Diagnose bestätigt rund 48 GB beide Preflights am 18.10. um 17:22:34 MESZ erfolgreich. Der anschließend gestartete
Lauf `appbackup_2026-10-18_15-22-34_fe0edb31` endete am 19.10. um 03:53:28 MESZ
mit `SUCCESS`, `Result=success` und Status 0. Die durch Johannes ausgeführte
ursprüngliche Root-Diagnose bestätigte rund 48 GB
historische flache Archive sowie drei vollständige lokale Sätze ohne `UPLOADED`. historische flache Archive sowie drei vollständige lokale Sätze ohne `UPLOADED`.
Die Platzprüfung des Nachtlaufs bricht vor dem Snapshot ab. Diese Daten werden Die Platzprüfung des Nachtlaufs bricht vor dem Snapshot ab. Diese Daten werden
nicht als automatische Löschkandidaten freigegeben. nicht als automatische Löschkandidaten freigegeben.
@@ -251,7 +355,31 @@ Wiederherstellung beziehungsweise Auswahl historischer Restore-Quellen nutzen.
Acht lokale Tests prüfen Datei- und Verzeichnisverlagerung, Kopierkorruption Acht lokale Tests prüfen Datei- und Verzeichnisverlagerung, Kopierkorruption
mit erhaltenem Original, Schutz vor Quellenüberschneidung/Links und Platzreserve. mit erhaltenem Original, Schutz vor Quellenüberschneidung/Links und Platzreserve.
Die produktive Abnahme und ein vollständiger Restore-Test stehen weiterhin aus. Die produktive Backup-Abnahme ist bestätigt; ein vollständiger Restore-Test
steht weiterhin aus.
### Abschlussprüfung am 19.10.2026
Das Serverjournal bestätigt SHA-256-/Metadatenprüfung der verlagerten Objekte,
die neue Staging-Konfiguration und erfolgreiche echte Preflights. Das neue Backup
enthält 16 Komponentenarchive und das Manifest (32,053 GiB Upload). Alle 17 Dateien
bestanden die Remote-Datei-/Größenprüfung mit null Unterschieden; der zuletzt
veröffentlichte Abschlussmarker wurde bestätigt. Es erfolgte kein vollständiges
Rücklesen der Archive. Die Erfolgsmail wurde dem lokalen MTA übergeben; eine
Zustellung beim Empfänger ist damit nicht nachgewiesen.
Die Writer-Prüfung bestand; Nextcloud verließ den Wartungsmodus. Die angehaltenen
Anwendungen und ai-rem-Timer wurden bis 17:29:00 MESZ wieder gestartet. Bei der
Abschlussprüfung laufen Web, Datenbank, Mail, Gitea, Cron und ai-rem. Backup- und
Restore-Test-Timer sind aktiviert und aktiv. Nächste Termine laut systemd:
Backup 20.10.2026 gegen 02:34 MESZ, Restore-Test 01.11.2026 gegen 06:02 MEZ.
Freier Platz: `/` rund 14 GiB, `/home` 77 GiB, `/var` 51 GiB.
Der erste reguläre ai-rem-Update-Lauf bestand am 19.10.2026 von 05:23:43 bis
05:23:54 MESZ einschließlich eigener Sicherung mit Gzip-/SHA-256-Prüfung,
Cache-Prüfung und Versionsvergleich (1.10.3 aktuell, keine Probleme).
Nur die alte fehlgeschlagene Reparatur-Unit der ersten Revision bleibt in
`systemctl --failed` sichtbar; Revision 2 und der Backup-Lauf sind erfolgreich.
### Fortsetzung nach Certbot-Abbruch ### Fortsetzung nach Certbot-Abbruch
+42
View File
@@ -1,5 +1,47 @@
# Automatisches Update-Paket # Automatisches Update-Paket
## Update vom 22.10.2026: lokale Mindestkopie und knappe OneDrive-Quote
Die neue [Speicherstrategie](SPEICHER.md#adaptive-bereinigung-bei-platznot--22102026)
löst den gemeldeten Engpass durch die bisher geschützte letzte lokale Kopie.
Bei lokalem Platzdruck werden vollständige ausstehende Uploads erneut versucht.
Erst nach erfolgreicher Prüfung darf die lokale Historie vorübergehend auf null
Kopien sinken; die Offsite-Sicherung bleibt erhalten. Bei knapper gemeldeter
Remote-Quote werden ältere vollständige Sätze bis auf mindestens die neueste
geprüfte Sicherung entfernt. Normale Retention und `MIN_FREE_GB` bleiben bestehen.
Neue Defaults gelten auch ohne Ergänzung bestehender Konfigurationen:
`SPACE_ALLOW_REMOTE_ONLY=true`, `SPACE_REMOTE_CLEANUP=true`, `SPACE_KEEP_REMOTE=1`.
Mit `SPACE_ALLOW_REMOTE_ONLY=false` bleibt die bisherige lokale Mindestanzahl
verbindlich. Die neue Paket-Vorbereitung kann lokale Kopien freigeben, Uploads
wiederholen und unter Quotenmangel Remote-Sätze entfernen; `--no-retention`
unterdrückt diese Notmaßnahmen nicht. Ein reines `--check` bleibt ohne Änderungen.
Paket auf dem Entwicklungsrechner aus dem sauberen Commit bauen:
```bash
python3 tools/build-update-package.py
```
Das ausgegebene `.run` samt `.sha256` wie unten beschrieben per SCP übertragen
und als vollständiges Paket installieren. Nach Installation `result.txt` und den
neuen Backup-Bericht prüfen: erfolgreicher Lauf, Upload bestätigt, Anwendungen
wieder aktiv, Reserve erhalten. Die Abnahme vom 19.10. unten ist historisch;
für den neuen Fix ist noch kein produktiver Gemini-Lauf nachgewiesen.
## Produktive Abschlussprüfung am 19.10.2026
Die Speicherreparatur auf Gemini und der danach gestartete normale Backup-Lauf
sind erfolgreich abgenommen: `appbackup_2026-10-18_15-22-34_fe0edb31` endete
am 19.10.2026 um 03:53:28 MESZ mit `SUCCESS` und Status 0. Upload, alle 17
Remote-Dateien/-Größen und Abschlussmarker wurden bestätigt. Anwendungen und
Timer laufen wieder; der erste reguläre ai-rem-Update-Lauf bestand um 05:23:54
MESZ. [Nachweise und Grenzen](SPEICHER.md#abschlussprüfung-am-19102026).
Die folgenden älteren Abschnitte beschreiben den jeweiligen damaligen Stand.
Ein vollständiger Offsite-Restore mit isoliertem DB-Import bleibt weiterhin
offen; sein Timer ist aktiv und für den 01.11.2026 geplant.
## Update vom 25.09.2026: Laufzeit und Speicher im Mailbericht ## Update vom 25.09.2026: Laufzeit und Speicher im Mailbericht
Backup- und monatlicher Restore-Test-Mailbericht enden jetzt mit einer Übersicht Backup- und monatlicher Restore-Test-Mailbericht enden jetzt mit einer Übersicht
+10 -3
View File
@@ -17,10 +17,17 @@ LOG_RETENTION_DAYS=30
LOCAL_RETENTION_DAYS=7 LOCAL_RETENTION_DAYS=7
REMOTE_RETENTION_DAYS=30 REMOTE_RETENTION_DAYS=30
RETENTION_KEEP_LAST=3 RETENTION_KEEP_LAST=3
# Under disk pressure only: keep at least this many uploaded local sets. # First pressure stage: retain this many uploaded local sets (minimum one).
# Other local copies require a fresh offsite check before removal; never zero. # SPACE_ALLOW_REMOTE_ONLY can release this floor as a last resort.
# Set equal to RETENTION_KEEP_LAST to retain the former emergency floor. # Set SPACE_ALLOW_REMOTE_ONLY=false to enforce it unconditionally.
SPACE_KEEP_LOCAL=1 SPACE_KEEP_LOCAL=1
# If the floor prevents a new backup: permit offsite-only history after full
# local validation + remote sizes/identity checks. false enforces the floor.
SPACE_ALLOW_REMOTE_ONLY=true
# Quota-driven offsite cleanup is separate from normal age-based retention.
# Keeps at least the newest verified complete remote set, including before upload.
SPACE_REMOTE_CLEANUP=true
SPACE_KEEP_REMOTE=1
# Enable deliberately after a successful restore rehearsal. v1 runs are retained. # Enable deliberately after a successful restore rehearsal. v1 runs are retained.
ENABLE_REMOTE_RETENTION="false" ENABLE_REMOTE_RETENTION="false"
+4 -2
View File
@@ -28,7 +28,8 @@ load_config() {
: "${LOG_RETENTION_DAYS:=30}" : "${LOG_RETENTION_DAYS:=30}"
: "${MIN_FREE_GB:=10}" "${LOCAL_RETENTION_DAYS:=7}" "${REMOTE_RETENTION_DAYS:=30}" : "${MIN_FREE_GB:=10}" "${LOCAL_RETENTION_DAYS:=7}" "${REMOTE_RETENTION_DAYS:=30}"
: "${RETENTION_KEEP_LAST:=3}" "${ENABLE_REMOTE_RETENTION:=false}" : "${RETENTION_KEEP_LAST:=3}" "${ENABLE_REMOTE_RETENTION:=false}"
: "${SPACE_KEEP_LOCAL:=1}" : "${SPACE_KEEP_LOCAL:=1}" "${SPACE_ALLOW_REMOTE_ONLY:=true}"
: "${SPACE_REMOTE_CLEANUP:=true}" "${SPACE_KEEP_REMOTE:=1}"
: "${ENABLE_UPLOAD:=true}" "${RCLONE_BIN:=rclone}" : "${ENABLE_UPLOAD:=true}" "${RCLONE_BIN:=rclone}"
: "${RCLONE_REMOTE_BASE:=}" "${RCLONE_CONFIG:=/root/.config/rclone/rclone.conf}" : "${RCLONE_REMOTE_BASE:=}" "${RCLONE_CONFIG:=/root/.config/rclone/rclone.conf}"
: "${RCLONE_RETRIES:=6}" "${RCLONE_LOW_LEVEL_RETRIES:=20}" : "${RCLONE_RETRIES:=6}" "${RCLONE_LOW_LEVEL_RETRIES:=20}"
@@ -67,7 +68,7 @@ load_config() {
fi fi
for v in ENABLE_UPLOAD ENABLE_REMOTE_RETENTION ENABLE_MAIL_REPORT ENABLE_WORDPRESS ENABLE_NEXTCLOUD \ for v in ENABLE_UPLOAD ENABLE_REMOTE_RETENTION ENABLE_MAIL_REPORT ENABLE_WORDPRESS ENABLE_NEXTCLOUD \
ENABLE_NEXTCLOUD_DATA ENABLE_GITEA ENABLE_MAIL ENABLE_MAIL_STORE ENABLE_DB_DUMPS ENABLE_NEXTCLOUD_MAINTENANCE \ ENABLE_NEXTCLOUD_DATA ENABLE_GITEA ENABLE_MAIL ENABLE_MAIL_STORE ENABLE_DB_DUMPS ENABLE_NEXTCLOUD_MAINTENANCE \
ENABLE_GITEA_SERVICE_STOP ENABLE_HTTPD_STOP ENABLE_PHPFPM_STOP ENABLE_MAIL_HOME_DIRS RESTORE_STRICT_DELETE ENABLE_RESTORE_TEST; do ENABLE_GITEA_SERVICE_STOP ENABLE_HTTPD_STOP ENABLE_PHPFPM_STOP ENABLE_MAIL_HOME_DIRS RESTORE_STRICT_DELETE ENABLE_RESTORE_TEST SPACE_ALLOW_REMOTE_ONLY SPACE_REMOTE_CLEANUP; do
[[ ${!v} == true || ${!v} == false ]] || die "$v must be true or false" [[ ${!v} == true || ${!v} == false ]] || die "$v must be true or false"
done done
for v in LOG_RETENTION_DAYS MIN_FREE_GB LOCAL_RETENTION_DAYS REMOTE_RETENTION_DAYS RETENTION_KEEP_LAST ZSTD_LEVEL ZSTD_THREADS; do for v in LOG_RETENTION_DAYS MIN_FREE_GB LOCAL_RETENTION_DAYS REMOTE_RETENTION_DAYS RETENTION_KEEP_LAST ZSTD_LEVEL ZSTD_THREADS; do
@@ -75,6 +76,7 @@ load_config() {
done done
(( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2" (( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2"
[[ $SPACE_KEEP_LOCAL =~ ^[1-9][0-9]{0,5}$ ]] || die 'SPACE_KEEP_LOCAL must be an integer between 1 and 999999' [[ $SPACE_KEEP_LOCAL =~ ^[1-9][0-9]{0,5}$ ]] || die 'SPACE_KEEP_LOCAL must be an integer between 1 and 999999'
[[ $SPACE_KEEP_REMOTE =~ ^[1-9][0-9]{0,5}$ ]] || die 'SPACE_KEEP_REMOTE must be an integer between 1 and 999999'
# Seconds/counts only: never permit disabled deadlines or unbounded retries. # Seconds/counts only: never permit disabled deadlines or unbounded retries.
for v in RCLONE_CHECK_ATTEMPTS RCLONE_CHECK_LOW_LEVEL_RETRIES RCLONE_CHECK_TIMEOUT RCLONE_CHECK_DEADLINE RCLONE_CHECK_RETRY_DELAY; do for v in RCLONE_CHECK_ATTEMPTS RCLONE_CHECK_LOW_LEVEL_RETRIES RCLONE_CHECK_TIMEOUT RCLONE_CHECK_DEADLINE RCLONE_CHECK_RETRY_DELAY; do
[[ ${!v} =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "$v must be a bounded integer" [[ ${!v} =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "$v must be a bounded integer"
+53 -3
View File
@@ -5,7 +5,9 @@ Only private, recognized backup scratch is disposable. Recovery journals and
manual restore extractions always win over attempts to reclaim disk space. manual restore extractions always win over attempts to reclaim disk space.
""" """
import argparse import argparse
import json
import os import os
import sys
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
@@ -105,6 +107,48 @@ def eligible(archives, prefix, keep, protected=False):
print(name) print(name)
def pending(archives, prefix):
if not private(archives):
raise ValueError('Pending uploads require private canonical archives')
for path in sorted(archives.iterdir()):
if not private(path) or not path.name.startswith(prefix+'_') or (path/'UPLOADED').exists() or (path/'UPLOADED').is_symlink():
continue
try:
data = backup_set.load_set(path, metadata_only=True)
if data['run_id'] == path.name:
print(path.name)
except (ValueError, OSError, KeyError, TypeError):
continue
def remote_valid(folder, name):
data = backup_set.load_set(folder, metadata_only=True)
if data['run_id'] != name:
raise ValueError('Remote directory/manifest identity mismatch')
entries = json.loads((folder/'listing.json').read_text())
expected = {a['file']: a['size'] for a in data['archives']}
expected.update({p: (folder/p).stat().st_size for p in ('manifest.json', 'COMPLETE')})
actual = {}
for entry in entries:
path = entry['Path']
if path in actual or entry['IsDir'] or type(entry['Size']) is not int:
raise ValueError('Invalid remote file inventory')
actual[path] = entry['Size']
if actual != expected:
raise ValueError('Remote files missing, unexpected or wrong sizes')
def quota():
try:
data = json.load(sys.stdin)
except (ValueError, TypeError):
sys.exit(1)
free = data.get('free') if isinstance(data, dict) else None
if type(free) is not int or not 0 <= free <= 10**18:
sys.exit(1)
print(free)
def inventory(archives, prefix, keep): def inventory(archives, prefix, keep):
valid = uploaded_sets(archives, prefix) valid = uploaded_sets(archives, prefix)
protected = set(valid[:keep]) protected = set(valid[:keep])
@@ -127,10 +171,16 @@ def inventory(archives, prefix, keep):
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=('clean', 'eligible', 'protected', 'inventory', 'remove')) parser.add_argument('mode', choices=('clean', 'eligible', 'protected', 'inventory', 'remove', 'pending', 'remote-valid', 'quota'))
parser.add_argument('paths', nargs='+') parser.add_argument('paths', nargs='*')
args = parser.parse_args() args = parser.parse_args()
if args.mode == 'clean': if args.mode == 'quota':
quota()
elif args.mode == 'remote-valid':
remote_valid(Path(args.paths[0]), args.paths[1])
elif args.mode == 'pending':
pending(Path(args.paths[0]), args.paths[1])
elif 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) 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 in ('eligible', 'protected'): elif args.mode in ('eligible', 'protected'):
eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2]), args.mode == 'protected') eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2]), args.mode == 'protected')
+181 -4
View File
@@ -12,7 +12,7 @@ available_space() {
} }
ensure_space() { ensure_space() {
local path=$1 additional=${2:-0} avail name dir candidates code protected local path=$1 additional=${2:-0} avail name dir candidates code protected=''
[[ $additional =~ ^(0|[1-9][0-9]{0,15})$ ]] || die 'Invalid or excessive space request' [[ $additional =~ ^(0|[1-9][0-9]{0,15})$ ]] || die 'Invalid or excessive space request'
local required=$((MIN_FREE_GB * 1024 * 1024 * 1024 + additional)) local required=$((MIN_FREE_GB * 1024 * 1024 * 1024 + additional))
avail=$(available_space "$path") avail=$(available_space "$path")
@@ -21,16 +21,17 @@ ensure_space() {
echo "-- Space policy: keep $SPACE_KEEP_LOCAL uploaded local set(s) under pressure; normal retention keeps $RETENTION_KEEP_LAST" echo "-- Space policy: keep $SPACE_KEEP_LOCAL uploaded local set(s) under pressure; normal retention keeps $RETENTION_KEEP_LAST"
if [[ $ENABLE_UPLOAD == true && $(stat -c %d "$path") == "$(stat -c %d "$ARCHIVE_DIR")" ]]; then if [[ $ENABLE_UPLOAD == true && $(stat -c %d "$path") == "$(stat -c %d "$ARCHIVE_DIR")" ]]; then
check_remote check_remote
resume_pending_uploads
candidates=$(python3 "$LIB_DIR/space.py" eligible "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$SPACE_KEEP_LOCAL") candidates=$(python3 "$LIB_DIR/space.py" eligible "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$SPACE_KEEP_LOCAL")
if [[ -n $candidates ]]; then
protected=$(python3 "$LIB_DIR/space.py" protected "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$SPACE_KEEP_LOCAL") protected=$(python3 "$LIB_DIR/space.py" protected "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$SPACE_KEEP_LOCAL")
[[ -n $protected ]] || die 'No protected local backup; refusing space recovery' if [[ -n $protected ]]; then
while IFS= read -r name; do while IFS= read -r name; do
echo "-- Space recovery: validating protected local copy: $name" echo "-- Space recovery: validating protected local copy: $name"
if helper verify "$ARCHIVE_DIR/$name" >/dev/null; then continue; else code=$?; fi if helper verify "$ARCHIVE_DIR/$name" >/dev/null; then continue; else code=$?; fi
(( code < 128 )) || return "$code" (( code < 128 )) || return "$code"
echo "WARNING: Protected local copy is damaged; keeping all candidates: $name" echo "WARNING: Protected local copy is damaged; keeping all candidates: $name"
candidates='' candidates=''
protected=''
break break
done <<< "$protected" done <<< "$protected"
fi fi
@@ -58,12 +59,13 @@ ensure_space() {
return 0 return 0
fi fi
done <<< "$candidates" done <<< "$candidates"
release_local_floor "$path" "$required" "$protected"
fi fi
avail=$(available_space "$path") avail=$(available_space "$path")
(( avail < required )) || return 0 (( avail < required )) || return 0
python3 "$LIB_DIR/space.py" inventory "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$SPACE_KEEP_LOCAL" || python3 "$LIB_DIR/space.py" inventory "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$SPACE_KEEP_LOCAL" ||
echo 'WARNING: Local archive inventory incomplete; no further cleanup attempted' echo 'WARNING: Local archive inventory incomplete; no further cleanup attempted'
die "Insufficient space after safe cleanup on $path: need $required bytes, available $avail, deficit $((required - avail)) bytes; protected backups/recovery data retained. See SPEICHER.md: resolve pending uploads or move backup workspace to a larger filesystem." die "Insufficient space after safe cleanup on $path: need $required bytes, available $avail, deficit $((required - avail)) bytes; protected backups/recovery data retained. See SPEICHER.md: remote verification/quota failed, protected data or workspace capacity prevents further safe cleanup."
} }
prepare_backup_space() { prepare_backup_space() {
@@ -100,3 +102,178 @@ prepare_backup_space() {
ensure_space "$ARCHIVE_DIR" "$total" ensure_space "$ARCHIVE_DIR" "$total"
fi fi
} }
# Bound listing/quota/control-file operations just like remote verification.
space_rc() {
local attempt code=1
for ((attempt=1; attempt<=RCLONE_CHECK_ATTEMPTS; attempt++)); do
if timeout --kill-after=10s "${RCLONE_CHECK_DEADLINE}s" \
"$RCLONE_BIN" "${RCLONE_ARGS[@]}" "$@" \
--timeout "${RCLONE_CHECK_TIMEOUT}s" --low-level-retries "$RCLONE_CHECK_LOW_LEVEL_RETRIES"; then
return 0
else code=$?; fi
(( code < 128 )) || return "$code"
if (( attempt < RCLONE_CHECK_ATTEMPTS )); then sleep "$RCLONE_CHECK_RETRY_DELAY" || return $?; fi
done
return "$code"
}
remote_free_space() {
local -a statuses
if space_rc about "$RCLONE_REMOTE_BASE" --json | python3 "$LIB_DIR/space.py" quota; then return 0; else
statuses=("${PIPESTATUS[@]}")
if (( statuses[0] != 0 )); then return "${statuses[0]}"; fi
return "${statuses[1]}"
fi
}
# Read only small control files and the decrypted size inventory. Identity must
# match the directory, and every archive must exist with its manifest size.
space_remote_valid() {
local name=$1 folder=$2 remote="${RCLONE_REMOTE_BASE%/}/$1"
space_rc cat "$remote/COMPLETE" --head 256 > "$folder/COMPLETE" &&
space_rc cat "$remote/manifest.json" --head 1048577 > "$folder/manifest.json" &&
space_rc lsjson "$remote" --max-depth 1 > "$folder/listing.json" &&
python3 "$LIB_DIR/space.py" remote-valid "$folder" "$name"
}
ensure_remote_space() (
local additional=$1 exclude=${2:-} free scratch name anchor code kept=0
additional=$((additional + additional / 20 + 1048576))
[[ $ENABLE_UPLOAD == true && $SPACE_REMOTE_CLEANUP == true ]] || return 0
if free=$(remote_free_space); then :; else
code=$?
(( code < 128 )) || return "$code"
echo 'WARNING: Remote quota unavailable; no automatic remote pressure cleanup (normal upload will still be attempted)'
return 0
fi
(( free < additional )) || return 0
echo "-- Remote space recovery: need $additional bytes, available $free; keep $SPACE_KEEP_REMOTE complete offsite set(s)"
scratch=$(mktemp -d "$STAGING_ROOT/.remote-space.XXXXXX") || return 1
trap 'rm -rf -- "$scratch"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if space_rc lsf "$RCLONE_REMOTE_BASE" --dirs-only --max-depth 1 > "$scratch/dirs"; then :; else
code=$?
# No deletion on an incomplete listing. Keep cancellation semantics.
rm -rf -- "$scratch"
return "$code"
fi
: > "$scratch/valid" || return 1
# Newest first: quota cleanup always protects the newest verified offsite sets.
sort -r "$scratch/dirs" > "$scratch/sorted" || return 1
while IFS= read -r name; do
name=${name%/}
[[ $name =~ ^${ARCHIVE_PREFIX}_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_[0-9a-f]{8}$ ]] || continue
[[ $name != "$exclude" && $name != "${REMOTE_RUN:-}" ]] || continue
if space_remote_valid "$name" "$scratch"; then
printf '%s\n' "$name" >> "$scratch/valid" || return 1
else
code=$?
if (( code >= 128 )); then rm -rf -- "$scratch"; return "$code"; fi
echo "WARNING: Remote pressure cleanup preserves unverified/incomplete set: $name"
fi
done < "$scratch/sorted"
: > "$scratch/keep" || return 1
: > "$scratch/candidates" || return 1
while IFS= read -r name; do
if (( kept < SPACE_KEEP_REMOTE )); then
echo "-- Remote pressure cleanup protects: $name"
printf '%s\n' "$name" >> "$scratch/keep" || return 1
kept=$((kept + 1))
else printf '%s\n' "$name" >> "$scratch/candidates" || return 1; fi
done < "$scratch/valid"
sort "$scratch/candidates" > "$scratch/delete" || return 1
while IFS= read -r name; do
while IFS= read -r anchor; do
if space_remote_valid "$anchor" "$scratch"; then :; else
code=$?; rm -rf -- "$scratch"; return "$code"
fi
done < "$scratch/keep"
if space_remote_valid "$name" "$scratch"; then :; else
code=$?; rm -rf -- "$scratch"; return "$code"
fi
echo "-- Remote pressure cleanup: removing old complete set $name"
if space_rc purge "${RCLONE_REMOTE_BASE%/}/$name"; then :; else
code=$?; rm -rf -- "$scratch"; return "$code"
fi
# Provider quota/recycle-bin accounting can lag: never assume purge freed it.
if free=$(remote_free_space); then :; else
code=$?; rm -rf -- "$scratch"; return "$code"
fi
if (( free >= additional )); then rm -rf -- "$scratch"; return 0; fi
done < "$scratch/delete"
rm -rf -- "$scratch"
echo "ERROR: Insufficient remote quota after safe cleanup: need $additional bytes, available $free; newest offsite backups retained"
return 1
)
resume_pending_uploads() {
local name dir code size
local pending
pending=$(python3 "$LIB_DIR/space.py" pending "$ARCHIVE_DIR" "$ARCHIVE_PREFIX") || return 1
while IFS= read -r name; do
[[ -n $name ]] || continue
dir="$ARCHIVE_DIR/$name"
[[ -z ${LOCAL_RUN:-} || $(realpath -m "$LOCAL_RUN") != "$dir" ]] || continue
echo "-- Space recovery: retrying pending publication: $name"
if helper verify "$dir" >/dev/null; then :; else
code=$?; (( code < 128 )) || return "$code"
echo "WARNING: Keeping invalid pending local set: $name"; continue
fi
if remote_metadata_check "$dir" "${RCLONE_REMOTE_BASE%/}/$name" --exclude /UPLOADED &&
remote_identity_check "$dir" "${RCLONE_REMOTE_BASE%/}/$name"; then
date -u -Is > "$dir/UPLOADED" || return 1
sync -f "$dir" || return 1
echo "-- Pending publication already complete offsite: $name"
continue
else
code=$?; (( code < 128 )) || return "$code"
fi
size=$(du -sb -- "$dir" | awk '{print $1}') || return 1
if ensure_remote_space "$size" "$name" &&
rc copy "$dir" "${RCLONE_REMOTE_BASE%/}/$name" --exclude /COMPLETE --exclude /UPLOADED --immutable &&
remote_metadata_check "$dir" "${RCLONE_REMOTE_BASE%/}/$name" --exclude /COMPLETE --exclude /UPLOADED &&
rc copyto "$dir/COMPLETE" "${RCLONE_REMOTE_BASE%/}/$name/COMPLETE" --immutable &&
remote_identity_check "$dir" "${RCLONE_REMOTE_BASE%/}/$name"; then
date -u -Is > "$dir/UPLOADED" || return 1
sync -f "$dir" || return 1
echo "-- Pending publication confirmed: $name"
else
code=$?; (( code < 128 )) || return "$code"
echo "WARNING: Pending upload remains protected: $name"
fi
done <<< "$pending"
}
release_local_floor() {
local path=$1 required=$2 protected=$3 name dir code bytes available reclaimable=0
[[ $SPACE_ALLOW_REMOTE_ONLY == true && -n $protected ]] || return 0
# Do not sacrifice the local floor if its allocated blocks cannot solve the
# deficit. This also avoids deleting the last local set on undersized disks.
available=$(available_space "$path") || return 1
while IFS= read -r name; do
dir="$ARCHIVE_DIR/$name"
[[ -z ${LOCAL_RUN:-} || $(realpath -m "$LOCAL_RUN") != "$dir" ]] || continue
bytes=$(du -sx -B1 -- "$dir" | awk '{print $1}') || return 1
reclaimable=$((reclaimable + bytes))
done <<< "$protected"
(( available + reclaimable >= required )) || return 0
echo '-- Emergency space recovery: allowing offsite-only history until the new backup completes'
# protected is newest first. Release oldest first, stopping at the target.
while IFS= read -r name; do
dir="$ARCHIVE_DIR/$name"
[[ -z ${LOCAL_RUN:-} || $(realpath -m "$LOCAL_RUN") != "$dir" ]] || continue
# ensure_space already validated all protected local contents under the lock.
if remote_metadata_check "$dir" "${RCLONE_REMOTE_BASE%/}/$name" --exclude /UPLOADED &&
remote_identity_check "$dir" "${RCLONE_REMOTE_BASE%/}/$name"; then
python3 "$LIB_DIR/space.py" remove "$dir" || return 1
echo "-- Emergency space recovery: removed local copy $name; verified offsite set retained"
else
code=$?; (( code < 128 )) || return "$code"
echo "WARNING: Emergency cleanup keeps $name: local/offsite verification failed"
fi
available=$(available_space "$path") || return 1
(( available < required )) || return 0
done < <(sort <<< "$protected")
}
+190 -1
View File
@@ -340,7 +340,7 @@ QUIESCE_SERVICES=(nc-cron)
(path/'manifest.json').write_text(json.dumps(manifest)) (path/'manifest.json').write_text(json.dumps(manifest))
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n') (path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n')
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'), subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'copy', str(path), 'crypt:sets/'+path.name], check=True, capture_output=True) 'copy', str(path), 'crypt:sets/'+path.name, '--exclude', '/UPLOADED'], check=True, capture_output=True)
copies.append(path) copies.append(path)
self.env['TEST_METADATA_ONLY'] = 'true' self.env['TEST_METADATA_ONLY'] = 'true'
self.events.write_text('') self.events.write_text('')
@@ -371,6 +371,7 @@ QUIESCE_SERVICES=(nc-cron)
def test_pressure_keeps_last_uploaded_and_unconfirmed_copies(self): def test_pressure_keeps_last_uploaded_and_unconfirmed_copies(self):
current, copies = self.pressure_copies() current, copies = self.pressure_copies()
(copies[0]/'UPLOADED').unlink() (copies[0]/'UPLOADED').unlink()
(copies[0]/'wordpress.tar.zst').write_bytes(b'invalid unconfirmed set')
self.fake_pressure(copies[0], always=True) self.fake_pressure(copies[0], always=True)
result = self.run_script('app-backup.sh', '--prepare-space', ok=False) result = self.run_script('app-backup.sh', '--prepare-space', ok=False)
self.assertTrue(current.exists()) self.assertTrue(current.exists())
@@ -428,6 +429,194 @@ QUIESCE_SERVICES=(nc-cron)
result = self.run_script('app-backup.sh', '--check', ok=False) result = self.run_script('app-backup.sh', '--check', ok=False)
self.assertIn('SPACE_KEEP_LOCAL must', result.stdout) self.assertIn('SPACE_KEEP_LOCAL must', result.stdout)
def space_call(self, command, ok=True):
self.events.write_text('') # Observe maintenance only, excluding setup backup.
result = subprocess.run(['bash', '-c', '''
LIB_DIR=$1/lib
source "$LIB_DIR/common.sh"
CONFIG_FILE=$2
OPERATION=backup
CHECK=true
load_config
init_runtime
build_plan
source "$1/lib/space.sh"
clean_backup_scratch
check_remote
eval "$3"
''', 'space-test', str(REPO), str(self.config), command], env=self.env,
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=90)
if ok: self.assertEqual(result.returncode, 0, result.stdout)
else: self.assertNotEqual(result.returncode, 0, result.stdout)
return result
def remote_names(self):
return subprocess.check_output(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'lsf', 'crypt:sets', '--dirs-only'], text=True)
def quota_wrapper(self, mode='recover'):
wrapper = self.bin/'rclone'
wrapper.write_text('''#!/bin/bash
if [[ " $* " == *' about '* ]]; then
if [[ $TEST_QUOTA_MODE == unavailable ]]; then exit 9; fi
if [[ $TEST_QUOTA_MODE == cancel ]]; then exit 143; fi
if [[ $TEST_QUOTA_MODE == small ]]; then echo '{"free":2000000}'; exit 0; fi
if [[ $TEST_QUOTA_MODE == malformed ]]; then echo '{"free":true}'; exit 0; fi
if [[ $TEST_QUOTA_MODE == recover && -f $TEST_EVENTS.freed ]]; then
echo '{"free":1000000000000}'
else echo '{"free":0}'; fi
exit 0
fi
if [[ " $* " == *' purge '* ]]; then
/usr/bin/rclone "$@" || exit $?
touch "$TEST_EVENTS.freed"
exit 0
fi
exec /usr/bin/rclone "$@"
''')
wrapper.chmod(0o700)
self.env['TEST_QUOTA_MODE'] = mode
def test_pressure_releases_last_local_copy_and_next_backup_succeeds(self):
self.crypt_config()
current = self.backup()
# Real planning budget, but only a one-byte deficit. Releasing the last
# uploaded set solves preflight, then a real new backup/upload completes.
planning = self.run_script('app-backup.sh', '--prepare-space')
import re
budget = int(re.search(r'Estimated snapshot budget: (\d+)', planning.stdout)[1]) * 2
fake = self.bin/'df'
fake.write_text('#!/usr/bin/env python3\nfrom pathlib import Path\n'
f'available = {budget-1} if Path({str(current)!r}).exists() else 10**12\n'
'print("Filesystem 1B-blocks Used Available Use% Mounted")\n'
'print(f"root 2000000000000 0 {available} 0% /")\n')
fake.chmod(0o700)
result = self.run_script('app-backup.sh')
self.assertIn('Emergency space recovery: removed local copy', result.stdout)
self.assertIn('SUCCESS:', result.stdout)
self.assertFalse(current.exists())
self.assertIn(current.name, self.remote_names())
new = list(current.parent.glob('appbackup_*'))
self.assertEqual(len(new), 1)
self.assertTrue((new[0]/'UPLOADED').is_file())
self.assertEqual(json.loads(self.state.read_text()), self.initial)
def test_remote_only_can_be_disabled(self):
self.crypt_config()
current = self.backup()
with self.config.open('a') as f: f.write('SPACE_ALLOW_REMOTE_ONLY=false\n')
self.fake_pressure(current)
self.space_call('ensure_space "$ARCHIVE_DIR" 1000', ok=False)
self.assertTrue(current.exists())
def test_last_local_copy_is_kept_if_offsite_identity_is_wrong(self):
self.crypt_config()
current = self.backup()
original = (current/'COMPLETE').read_bytes()
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'rcat', 'crypt:sets/'+current.name+'/COMPLETE'],
input=b'X'+original[1:], check=True, capture_output=True)
self.fake_pressure(current)
result = self.space_call('ensure_space "$ARCHIVE_DIR" 1000', ok=False)
self.assertTrue(current.exists())
self.assertIn('Emergency cleanup keeps', result.stdout)
self.assertNotIn('systemctl stop', self.events.read_text())
def test_last_local_copy_is_kept_if_it_cannot_resolve_deficit(self):
self.crypt_config()
current = self.backup()
self.fake_pressure(current)
self.space_call('ensure_space "$ARCHIVE_DIR" 1000000000000', ok=False)
self.assertTrue(current.exists())
def test_pressure_retries_failed_upload_before_releasing_local_set(self):
self.crypt_config()
current = self.backup()
(current/'UPLOADED').unlink()
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'purge', 'crypt:sets/'+current.name], check=True, capture_output=True)
self.fake_pressure(current)
result = self.space_call('ensure_space "$ARCHIVE_DIR" 1000')
self.assertIn('Pending publication confirmed', result.stdout)
self.assertFalse(current.exists())
self.assertIn(current.name, self.remote_names())
def test_pending_complete_upload_reconciles_without_spare_remote_quota(self):
self.crypt_config()
current = self.backup()
(current/'UPLOADED').unlink()
self.fake_pressure(current)
self.quota_wrapper('stuck')
result = self.space_call('ensure_space "$ARCHIVE_DIR" 1000')
self.assertIn('Pending publication already complete offsite', result.stdout)
self.assertNotIn('Remote pressure cleanup: removing', result.stdout)
self.assertFalse(current.exists())
self.assertIn(current.name, self.remote_names())
def test_remote_quota_uses_compressed_archives_instead_of_snapshot_budget(self):
self.crypt_config()
self.quota_wrapper('small')
result = self.run_script('app-backup.sh')
self.assertIn('SUCCESS:', result.stdout)
self.assertNotIn('Remote pressure cleanup: removing', result.stdout)
self.assertTrue(any((p/'UPLOADED').exists() for p in (self.base/'work/archives').iterdir()))
def test_remote_pressure_deletes_oldest_only_and_keeps_newest_and_partial(self):
current, copies = self.pressure_copies()
partial = 'appbackup_2019-01-01_00-00-00_aaaaaaaa'
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'rcat', 'crypt:sets/'+partial+'/manifest.json'],
input=b'not complete', check=True, capture_output=True)
self.quota_wrapper()
result = self.space_call('ensure_remote_space 1000')
self.assertNotIn(copies[0].name, self.remote_names())
self.assertIn(copies[1].name, self.remote_names())
self.assertIn(current.name, self.remote_names())
self.assertIn(partial, self.remote_names())
self.assertTrue(all(p.exists() for p in [current]+copies))
self.assertIn('preserves unverified/incomplete', result.stdout)
self.assertNotIn('forbidden-archive-read', self.events.read_text())
def test_remote_pressure_stops_at_one_when_quota_does_not_recover(self):
current, copies = self.pressure_copies()
self.quota_wrapper('stuck')
result = self.space_call('ensure_remote_space 1000', ok=False)
self.assertIn(current.name, self.remote_names())
self.assertTrue(all(p.name not in self.remote_names() for p in copies))
self.assertIn('Insufficient remote quota', result.stdout)
self.assertTrue(current.exists())
def test_remote_pressure_rejects_size_corruption_and_directory_identity_mismatch(self):
current, copies = self.pressure_copies()
# Latest must be a valid survivor. Invalid ones never count or get purged.
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'deletefile', 'crypt:sets/'+current.name+'/wordpress.tar.zst'],
check=True, capture_output=True)
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'copyto', 'crypt:sets/'+copies[0].name+'/manifest.json',
'crypt:sets/'+copies[1].name+'/manifest.json'], check=True, capture_output=True)
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'copyto', 'crypt:sets/'+copies[0].name+'/COMPLETE',
'crypt:sets/'+copies[1].name+'/COMPLETE'], check=True, capture_output=True)
self.quota_wrapper()
self.space_call('ensure_remote_space 1000', ok=False)
self.assertTrue(all(p.name in self.remote_names() for p in [current]+copies))
def test_remote_quota_unavailable_or_malformed_never_deletes(self):
current, copies = self.pressure_copies()
for mode in ('unavailable', 'malformed'):
self.quota_wrapper(mode)
result = self.space_call('ensure_remote_space 1000')
self.assertIn('quota unavailable', result.stdout)
self.assertTrue(all(p.name in self.remote_names() for p in [current]+copies))
def test_remote_quota_cancellation_is_propagated(self):
current, copies = self.pressure_copies()
self.quota_wrapper('cancel')
result = self.space_call('ensure_remote_space 1000', ok=False)
self.assertEqual(result.returncode, 143, result.stdout)
self.assertTrue(all(p.name in self.remote_names() for p in [current]+copies))
def test_dump_and_copy_failures_do_not_publish(self): def test_dump_and_copy_failures_do_not_publish(self):
for tool in ['mariadb-dump','rsync','zstd']: for tool in ['mariadb-dump','rsync','zstd']:
with self.subTest(tool=tool): with self.subTest(tool=tool):
+6
View File
@@ -29,7 +29,11 @@ source "$LIB_DIR/space.sh"
if [[ $CHECK == false || $PREPARE_SPACE == true ]]; then prepare_backup_space; fi if [[ $CHECK == false || $PREPARE_SPACE == true ]]; then prepare_backup_space; fi
preflight preflight
if [[ $CHECK == true ]]; then if [[ $CHECK == true ]]; then
if [[ $PREPARE_SPACE == true ]]; then
echo 'Space preparation and preflight passed. Pending uploads and pressure cleanup processed; no new application snapshot or service stop performed.'
else
echo 'Preflight passed. Directory metadata probed; no application backup, service stop or upload performed.' echo 'Preflight passed. Directory metadata probed; no application backup, service stop or upload performed.'
fi
exit 0 exit 0
fi fi
STAGING_DIR=$(mktemp -d "$STAGING_ROOT/${RUN_ID}.XXXXXX") STAGING_DIR=$(mktemp -d "$STAGING_ROOT/${RUN_ID}.XXXXXX")
@@ -113,6 +117,8 @@ sync -f "$ARCHIVE_DIR"
echo "-- Complete local backup: $RUN_DIR" echo "-- Complete local backup: $RUN_DIR"
if [[ $ENABLE_UPLOAD == true ]]; then if [[ $ENABLE_UPLOAD == true ]]; then
BACKUP_PHASE='Remote-Platzprüfung'
ensure_remote_space "$(du -sb -- "$RUN_DIR" | awk '{print $1}')" "$RUN_ID"
remote_run="${RCLONE_REMOTE_BASE%/}/$RUN_ID" remote_run="${RCLONE_REMOTE_BASE%/}/$RUN_ID"
# Upload COMPLETE last. An interrupted upload cannot look like a valid set. # Upload COMPLETE last. An interrupted upload cannot look like a valid set.
echo '-- Remote upload started (archives and manifest; completion marker withheld)' echo '-- Remote upload started (archives and manifest; completion marker withheld)'