Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6737405b35 | ||
|
|
a79f48bc2a |
No files matched your search
+12
-8
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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)'
|
||||||
|
|||||||
Reference in new issue
Block a user