Recover disk space safely and complete metadata-only backup updates

This commit is contained in:
admin committed 2026-09-21 16:30:38 +02:00
1 parent 04283a607c
commit a5a7e38853
17 files changed
+827 -209

No files matched your search

+48 -38
View File
@@ -1,10 +1,17 @@
# Dokumentation app-backup v2
**Aktualisiert am 15. September 2026:** Das Code-Paket `3730ab4676a5` enthält
den lokal geprüften Schutz laufender Writer-Stopps gegen konkurrierende
Healthcheck-Aufträge. Code und Paket sind bereitgestellt; Installation und neue
Abnahme auf Gemini sind noch nicht bestätigt. Aktuelle Deployment-Befehle und
Erfolgskriterien stehen in der [Update-Anleitung](UPDATE_ANLEITUNG.md#deployment-des-bereitgestellten-pakets-3730ab4676a5).
**Aktualisiert am 21. September 2026:** Die Platzbereinigung schützt mit
`SPACE_KEEP_LOCAL=1` mindestens eine vollständig geprüfte, als hochgeladen markierte
lokale Sicherung; reguläre Retention und Speicherreserve bleiben separat bestehen.
Manifest und Abschlussmarker werden vor lokaler Entfernung bytegenau remote verglichen.
Siehe [Speicherstrategie und Fehlerbehebung](SPEICHER.md).
Seit 16. September 2026: Backup, Platzbereinigung und Update-Abnahme
prüfen remote nur Dateiexistenz und Größen; Archive werden erst beim monatlichen
Restore-Test vollständig geladen. Der Abschlussmarker wird weiterhin bytegenau
bestätigt. Die neue Option `--enable-ai-rem` nimmt auf Gemini die Daten und
Konfiguration samt Writer-Stopps wieder auf. Installation auf Gemini steht aus.
Aktuelle Befehle: [Update-Anleitung](UPDATE_ANLEITUNG.md).
Stand des Reviews: 10. September 2026. Untersucht wurde der Gitea-Stand `099688e` einschließlich Backup, Restore, Installer, Konfiguration, systemd und bisheriger README. Die Erweiterung wird als zusammengehöriges Paket installiert; einzelne Skripte dürfen nicht ohne die gemeinsamen Bibliotheken aktualisiert werden.
@@ -17,8 +24,8 @@ Stand des Reviews: 10. September 2026. Untersucht wurde der Gitea-Stand `099688e
| **Kritisch:** Nextcloud-Wartungsmodus wurde nach dem DB-Dump vor der Dateikopie ausgeschaltet. WordPress blieb live. | Datenbank und Dateien konnten unterschiedliche Zustände abbilden. | Gemeinsames Ruhefenster über sämtliche Dumps und Dateikopien. |
| **Hoch:** Maildienste hatten keine zuverlässige Wiederanlaufbehandlung; Fehler und rsync-Teilergebnisse wurden ignoriert. | Unvollständige Mailsicherung oder unbeabsichtigte Dienstausfälle. | Zustandsabhängige Stopps, geprüfter Wiederanlauf, Exit-Code und Wiederanlaufdatei. |
| **Hoch:** Crypt-Prüfung ließ u. a. `no_data_encryption=true` und reine Namensverschleierung zu. Restore verlangte dagegen explizite Optionen, obwohl sichere Defaults gültig sind. | Inhalt konnte trotz Remote-Typ `crypt` unverschlüsselt bleiben; gültige Remotes konnten abgelehnt werden. | Gemeinsame Prüfung von Typ, Inhalt, Dateinamen und Verzeichnisnamen; Defaults werden berücksichtigt. |
| **Hoch:** `--checksum` und Remote-Listing ersetzten keine Prüfung des entschlüsselten Inhalts. | Transfererfolg war kein belastbarer Rücklesetest. | Vollständiger `rclone check --download` vor Veröffentlichung des Abschlussmarkers. |
| **Hoch:** Retention lief vor dem neuen Backup und löschte remote einzelne alte Objekte rekursiv. | Alte Wiederherstellungspunkte konnten verloren gehen, bevor ein neuer verfügbar war; Sätze konnten unvollständig werden. | Reguläre Retention nach einem neuen erfolgreichen Lauf; zusätzliche lokale Platzbereinigung nur mit erneuter Offsite-Inhaltsprüfung und Mindestanzahl. |
| **Hoch:** `--checksum` und Remote-Listing ersetzten keine Prüfung des entschlüsselten Inhalts. | Transfererfolg war kein belastbarer Rücklesetest. | Lokale Inhaltsprüfung; seit 16.09. Remote-Datei-/Größenprüfung vor dem Marker, vollständige Remote-Inhaltsprüfung beim Monats-Restore. |
| **Hoch:** Retention lief vor dem neuen Backup und löschte remote einzelne alte Objekte rekursiv. | Alte Wiederherstellungspunkte konnten verloren gehen, bevor ein neuer verfügbar war; Sätze konnten unvollständig werden. | Reguläre Retention nach einem neuen erfolgreichen Lauf; zusätzliche lokale Platzbereinigung nur mit erneuter Remote-Datei-/Größenprüfung und Mindestanzahl. |
| **Hoch:** Drei nicht als Vorlage gekennzeichnete DB-Dateien enthielten mögliche echte Zugangsdaten. | Geheimnisse im aktuellen Baum und in der Git-Historie. | Dateien entfernt, ausschließlich Vorlagen, `.gitignore`; Passwortrotation bleibt Betriebsaufgabe. Keine Historienumschreibung. |
| **Hoch:** Gitea war im ausgelieferten Config-Beispiel nicht eingeschaltet; `APP_DATA_PATH` allein deckt externe Repositories/custom nicht ab. | Erwartete Gitea-Daten konnten fehlen. | Sichtbarer Aktivierungsschalter und zusätzliche Pfade; erforderliche Verzeichnisse fehlen nicht stillschweigend. |
| **Mittel:** Konfigurierbare WordPress-Excludes wurden nicht vollständig angewendet; Nextcloud `data/` war fest angenommen. | Falscher Umfang bei abweichender Verzeichnisstruktur. | Ableitung relativer Excludes aus den konfigurierten Pfaden, auch beim Restore. |
@@ -26,14 +33,14 @@ Stand des Reviews: 10. September 2026. Untersucht wurde der Gitea-Stand `099688e
| **Mittel:** Installer aktivierte den Timer sofort und überschrieb Unit-Dateien ohne Rückweg. | Ungeprüfte Erstläufe; individuelle Zeitpläne konnten verloren gehen. | Config-/Timer-Erhalt, `.dist`-Vorlagen, gemeinsame Sperre, Rollback-Paket, frische Timer zunächst aus. |
| **Mittel:** Eigene Logdatei je Lauf plus `logrotate`/`notifempty`. | Rotierte Dateien konnten wegen leerer Nachfolgedateien dauerhaft liegen bleiben. | Altersbasierte Bereinigung neuer Laufprotokolle nach erfolgreichen Backups. |
Die vollständige Rückleseprüfung erhöht den Datenverkehr um ungefähr die hochgeladene komprimierte Datenmenge. Dieser Aufwand ist bewusst Teil des Sicherheitskonzepts.
Die vollständige Rückleseprüfung findet seit 16.09.2026 beim monatlichen Restore-Test statt. Tägliche Backups und Update-Abnahmen laden keine Archive zurück. Gleich große Remote-Beschädigungen können deshalb erst beim Restore erkannt werden.
## Sicherheitsziel und Grenzen
Das Paket soll Fehler sichtbar machen, vollständige Sicherungssätze erhalten und eine nachprüfbare Wiederherstellung ermöglichen. Es kann keine absolute Datensicherheit garantieren. Folgende Ebenen sind getrennt zu betrachten:
1. **Vertraulichkeit:** Das Crypt-Remote verschlüsselt Inhalt und Namen vor dem Upload. Lokales Staging, lokale Archive und entpackte Restores sind Klartext und benötigen ein verschlüsseltes Servervolume, sichere Dateirechte und Zugriffsschutz.
2. **Integrität:** SHA-256, Kompressions-/Tar-Prüfung, Manifest und Rückleseprüfung erkennen fehlende bzw. veränderte Bestandteile. Das Manifest ist nicht digital signiert. Wer Archive und Manifest gemeinsam kontrolliert, kann neue passende Prüfsummen erzeugen.
2. **Integrität:** SHA-256, Kompressions-/Tar-Prüfung, Manifest und monatliche Rückleseprüfung erkennen fehlende bzw. veränderte Bestandteile. Das Manifest ist nicht digital signiert. Wer Archive und Manifest gemeinsam kontrolliert, kann neue passende Prüfsummen erzeugen.
3. **Konsistenz:** Alle relevanten Writer müssen während der Dumps/Kopien ruhen. Nicht erfasste Cronjobs, externe DB-Schreiber, systemd-Sockets, Container, S3-Speicher oder Remote-Mounts werden nicht automatisch eingefroren. Ein erfolgreicher Dateivergleich beweist keine Anwendungskonsistenz.
4. **Verfügbarkeit:** OneDrive mit demselben löschberechtigten Konto ist keine unabhängige, unveränderliche Kopie. Für Schutz vor kompromittiertem Server/Konto, Ransomware oder versehentlicher Löschung eine weitere getrennte bzw. offline/immutable Sicherung vorsehen.
5. **Wiederherstellbarkeit:** Ein getesteter Restore mit Anwendungskontrollen ist erforderlich. Passende Anwendungsversionen, Systempakete, UID/GID, Datenbankrechte und außerhalb des Umfangs liegende Schlüssel/Konfigurationen müssen verfügbar sein.
@@ -51,7 +58,7 @@ flowchart TD
E --> F[Je Komponente komprimieren und Tar-Inhalt prüfen]
F --> G[Manifest und lokaler COMPLETE-Marker]
G --> H[Archive und Manifest über Crypt hochladen]
H --> I[Alles entschlüsselt zurücklesen und vergleichen]
H --> I[Remote-Dateiexistenz und Größen prüfen]
I --> J[COMPLETE zuletzt hochladen und zurücklesen]
J --> K[Retention vollständiger Sätze / Status / Bericht]
```
@@ -65,7 +72,7 @@ Sie gehen nach stderr; Marker und Dateilisten auf stdout bleiben maschinell
auswertbar. Alte Konfigurationen erhalten den Intervallstandard automatisch.
Eine Anpassung wirkt erst beim nächsten rclone-Prozess. Die bestehende
I/O-Leerlauffrist `RCLONE_TIMEOUT="1h"` ist keine Gesamtlaufzeitbegrenzung.
Für vollständige Rücklesevergleiche gelten seit 15.09.2026 eigene kürzere
Für Remote-Dateiprüfungen und monatliche Downloads gelten eigene kürzere
Leerlauffristen, eine Prozessfrist je Versuch und mehrere vollständige Versuche.
Dort zeigen mehrzeilige Statistiken aktive Prüfungen und ein Ergebnis pro Datei
den tatsächlichen Fortschritt. Details: [Rückleseprüfung](REMOTE_READBACK.md).
@@ -130,7 +137,7 @@ Ein neuer Lauf heißt beispielsweise `appbackup_2026-09-10_02-30-00_a1b2c3d4`. Z
...
manifest.json
COMPLETE
UPLOADED # nur lokal, nach erfolgreicher Remote-Rückleseprüfung
UPLOADED # nur lokal, nach erfolgreicher Remote-Datei-/Größenprüfung und Markerbestätigung
```
| Komponente | Inhalt / Ziel aus lokaler Config |
@@ -154,7 +161,7 @@ Indexbasierte Pfade müssen ihre Reihenfolge behalten. Bei `extra-*` und `mail-h
Zielumgebung: Rocky Linux 10 mit systemd und nativen Anwendungen. Die automatische Datenbankbehandlung ist auf MariaDB/MySQL ausgerichtet; PostgreSQL, SQLite-Livekopien und Container-Volume-Snapshots benötigen ein eigenes Verfahren.
Erforderliche Werkzeuge: Bash ≥ 4.4 (`inherit_errexit`), Python ≥ 3.9, GNU coreutils/findutils/tar, `flock`, `rsync`, `gzip`/`zstd`, `systemctl`; bei Nextcloud `sudo` und CLI-PHP; für Datenbanken `mariadb-dump`/`mariadb` oder kompatible MySQL-Clients. Ein konfiguriertes Crypt-Remote benötigt rclone mit den verwendeten Crypt-Flags und `check --download`; die Integrationstests laufen auch mit rclone 1.60.1. Für den tatsächlichen Server einen aktuell gepflegten rclone-Stand einsetzen und `--check` ausführen.
Erforderliche Werkzeuge: Bash ≥ 4.4 (`inherit_errexit`), Python ≥ 3.9, GNU coreutils/findutils/tar, `flock`, `rsync`, `gzip`/`zstd`, `systemctl`; bei Nextcloud `sudo` und CLI-PHP; für Datenbanken `mariadb-dump`/`mariadb` oder kompatible MySQL-Clients. Ein konfiguriertes Crypt-Remote benötigt rclone mit den verwendeten Crypt-Flags und `check --size-only`; die Integrationstests laufen auch mit rclone 1.60.1. Für den tatsächlichen Server einen aktuell gepflegten rclone-Stand einsetzen und `--check` ausführen.
Ein Ausgangspunkt auf Rocky, abhängig von aktivierten Paketquellen und vorhandener PHP-Version:
@@ -189,15 +196,14 @@ Vorhandene Passwörter/Salt und Zuordnung **nicht neu generieren**, wenn bestehe
sudo rclone --config /root/.config/rclone/rclone.conf lsd OneDriveCrypt:
```
`rclone check --download` vergleicht die heruntergeladenen Inhalte und benötigt keinen gemeinsamen Backend-Hash. Es verändert die geprüften Dateien nicht. Das Paket verwendet es vor dem abschließenden Marker. Quelle: [rclone check](https://rclone.org/commands/rclone_check/).
`rclone check --size-only` prüft Dateiexistenz und Größe ohne Archivdownload. Das Paket verwendet es vor dem abschließenden Marker. Die Remote-Inhalte werden erst beim monatlichen Restore-Test anhand der SHA-256-Werte im Manifest geprüft. Quelle: [rclone check](https://rclone.org/commands/rclone_check/).
Exportierte `RCLONE_*`-Variablen sind ausschließlich für die vom Paket ausdrücklich
unterstützten Einstellungen und `RCLONE_CONFIG_PASS` erlaubt. Andere Optionen
werden vor dem ersten rclone-Aufruf und vor dem Writer-Stopp mit ihrem Namen,
ohne ihren Wert, abgewiesen. Insbesondere kann `RCLONE_SIZE_ONLY=true` in rclone
auch bei `check --download` den Inhaltsvergleich überspringen. Ein Regressionstest
hat diesen Fehler vor der Härtung mit einer veränderten Remote-Datei gleicher Größe
reproduziert. Zusätzliche Filter, Dry-run- und TLS-Overrides aus der Umgebung werden
ohne ihren Wert, abgewiesen. Auch `RCLONE_SIZE_ONLY` bleibt als globale
Umgebungsvariable verboten: Der Größenvergleich wird ausschließlich am Check-Aufruf
gesetzt. Zusätzliche Filter, Dry-run- und TLS-Overrides aus der Umgebung werden
ebenfalls abgewiesen. Einstellungen über die dokumentierte Backup-Konfiguration
und Remote-Definitionen verwalten; eine absichtlich manipulierte root-eigene
Bash-Konfiguration liegt außerhalb dieser Absicherung.
@@ -209,9 +215,9 @@ Quelle: [rclone 1.74.3 Inhaltsprüfung](https://github.com/rclone/rclone/blob/v1
| --- | --- |
| Vorübergehender Upload-Fehler | rclone wiederholt den Kopierauftrag bis zu `RCLONE_RETRIES=6` Versuche, soweit der Fehler wiederholbar ist; Wartezeit standardmäßig `RCLONE_RETRIES_SLEEP=30s`, zusätzlich gegebenenfalls Provider-Backoff. Kein erneuter DB-Dump oder Writer-Stopp innerhalb dieser Versuche. |
| Upload endgültig fehlgeschlagen | Fehlerexit; lokaler vollständiger Satz bleibt erhalten; Staging wird nach erfolgreichem Dienstwiederanlauf bereinigt. Kein neuer Remote-Abschlussmarker und keine nachgelagerte Altersretention. |
| Rückleseprüfung scheitert oder hängt | Bis zu drei vollständige Prüfanläufe, jeweils maximal 3600 Sekunden plus bis zu 10 Sekunden Abbruchfrist, 30 Sekunden Pause dazwischen. Jeder Versuch vergleicht alle ausgewählten Inhalte erneut. Bleibt ein Fehler, Fehlerexit und kein Abschlussmarker. Auch gleich große Dateien werden inhaltlich verglichen. |
| Remote-Dateiprüfung scheitert oder hängt | Bis zu drei Metadaten-Prüfanläufe, jeweils maximal 3600 Sekunden plus bis zu 10 Sekunden Abbruchfrist, 30 Sekunden Pause dazwischen. Jeder Versuch prüft alle ausgewählten Dateinamen und Größen erneut. Bleibt ein Fehler, Fehlerexit und kein Abschlussmarker. Keine Archivdownloads; gleich große Beschädigungen erkennt erst der Restore. |
| Abschlussmarker kann nicht geschrieben oder bestätigt werden | Fehlerexit; kein lokales `UPLOADED`, kein neuer Erfolgsstatus, keine Retention. Der Vergleich prüft die exakten Bytes einschließlich abschließender Zeilenumbrüche, passend zum strikten Restore-Parser. Ist der Marker schon geschrieben und nur seine Bestätigung fehlgeschlagen, kann der Remote-Satz vollständig vorliegen; der Lauf meldet konservativ trotzdem einen Fehler. |
| Wiederholungen erfolgreich und Rücklesen bestanden | Remote-Marker wird zuletzt geschrieben und zurückgelesen; erst danach gilt der Upload als erfolgreich. Ein früheres `ERROR` im Log allein ist kein Endergebnis. |
| Wiederholungen erfolgreich und Remote-Dateiprüfung bestanden | Remote-Marker wird zuletzt geschrieben und zurückgelesen; erst danach gilt der Upload als erfolgreich. Ein früheres `ERROR` im Log allein ist kein Endergebnis. |
| Prozess hängt oder wird abgebrochen | systemd begrenzt den regulären Backup-Dienst auf 12 Stunden, den Update-Auftrag auf 24 Stunden, mit 5 Minuten Stop-Frist. Direkte CLI-/Foreground-Aufrufe besitzen diese systemd-Gesamtfrist nicht. |
HTTP 409 benötigt nach dem beobachteten erfolgreichen Wiederholungsversuch keinen
@@ -224,10 +230,9 @@ Keine automatischen Remote-Löschungen oder Überschreibungen zur Konfliktbehebu
serverseitiger WORM-/Ransomware-Schutz gegen andere berechtigte Zugriffe.
Die sechs Gesamtversuche gelten nicht pauschal für jeden rclone-Befehl:
`check --download` verwendet in rclone 1.74.3 keine Wiederholung des ganzen Befehls,
jedoch Wiederholungen fehlgeschlagener Datei-Leseoperationen. Deshalb verwendet
app-backup jetzt einen eigenen begrenzten Wrapper nur für vollständige
Rücklesevergleiche (Backup und Platzbereinigung). Er greift auch mit alten
`check` verwendet in rclone 1.74.3 keine Wiederholung des ganzen Befehls. Deshalb
verwendet app-backup einen eigenen begrenzten Wrapper für Remote-Datei-/
Größenvergleiche (Backup und Platzbereinigung). Er greift auch mit alten
Konfigurationen. Uploads und manuelle `app-restore.sh`-Downloads behalten ihre
bisherigen Einstellungen. Der separate monatliche Restore-Test verwendet seit
dem Folgefix ebenfalls begrenzte Downloads; siehe [RESTORE_TEST.md](RESTORE_TEST.md).
@@ -351,7 +356,12 @@ Dateisystem-Metadaten und Wachstum. `MIN_FREE_GB` (Default 10 GiB) bleibt zusät
frei. Erst nach erfolgreicher Platzbeschaffung werden Writer angehalten.
Reicht Scratch-Bereinigung nicht, werden ältere geeignete lokale Sätze unter
Wahrung von `RETENTION_KEEP_LAST` vollständig lokal und offsite zurückgelesen.
Wahrung von `SPACE_KEEP_LOCAL` (Default und Minimum eins, seit 21.09.2026) lokal
vollständig validiert und remote auf Dateiexistenz und passende Größen geprüft.
Remote-Manifest und Abschlussmarker müssen zusätzlich bytegenau übereinstimmen.
Dabei werden keine Archive geladen. Die normale Retention bleibt separat bei
`RETENTION_KEEP_LAST` (Default drei). Bei weiterem Platzmangel nennt das Log das
Defizit und die geschützten Archivbestände mit belegten Bytes und Schutzgrund.
Nur danach darf ihre lokale Kopie entfernt werden. Offsite-Dateien bleiben
unverändert. Diese Platzbereinigung gilt auch bei `--no-retention` während einer
Update-Abnahme und kann das lokale Aufbewahrungsalter unterschreiten.
@@ -419,8 +429,9 @@ abgewiesen; ein Berichtsfehler verändert den Backup-Exit-Code nicht.
Der UTF-8-Bericht enthält Zeit, Host, Status, Exit-Code, Dauer, Lauf-ID und letzte
Phase, Konfigurations-/Arbeits-/Archivpfade, Kompression, Crypt-Ziel und getrennten
Uploadstatus. Dieser wird erst nach erfolgreichem Rücklesen aller Inhalte und
bytegenauer Markerbestätigung `OK`. Bei deaktiviertem Upload steht ausdrücklich
Uploadstatus. Dieser wird erst nach erfolgreicher Remote-Datei-/Größenprüfung und
bytegenauer Markerbestätigung `OK`. Der Bericht nennt den begrenzten Prüfumfang
und verweist für die Inhaltsprüfung auf den Restore-Test. Bei deaktiviertem Upload steht ausdrücklich
„nur lokal“; bei Fehlern bleibt die nicht abgeschlossene Uploadphase sichtbar.
Ein Fehler bei nachgelagerter Retention kann deshalb korrekt `Status: FAIL` bei
bereits verifiziertem Upload ergeben.
@@ -471,7 +482,7 @@ systemctl list-timers app-backup.timer
Exit `0` bedeutet, dass die ausgeführten erforderlichen Schritte erfolgreich waren. Jeder Fehlercode ist ein Fehler; `130`/`143` kennzeichnen INT/TERM. `last-backup.json` wird beim Beenden geschrieben und zeigt während eines laufenden Backups noch den vorherigen beendeten Lauf. `last-successful-backup.json` wird ausschließlich nach einem vollständig erfolgreichen Backup aktualisiert. `--check` aktualisiert diese Erfolgsmarkierung nicht. Sehr frühe Fehler vor der Laufinitialisierung/Sperrübernahme können nur auf stderr bzw. im Journal erscheinen; deshalb sowohl Servicefehler als auch Alter des letzten Erfolgs überwachen. Bei `ENABLE_UPLOAD=false` belegt ein Erfolg nur eine lokale Sicherung; für Offsite-Überwachung zusätzlich die Upload-Konfiguration und `UPLOADED` des gemeldeten Satzes berücksichtigen.
Für den täglichen Zeitplan z. B. bei mehr als 36 Stunden ohne erfolgreichen Lauf alarmieren, zusätzlich freien Speicher, offene Recovery-Dateien und wachsende Partial-Verzeichnisse prüfen. Diese Überwachung läuft idealerweise außerhalb des gesicherten Servers. `TimeoutStartSec=12h` und `TimeoutStopSec=5min` sind Ausgangswerte und müssen zum ersten gemessenen Vollbackup plus Rückleseprüfung passen.
Für den täglichen Zeitplan z. B. bei mehr als 36 Stunden ohne erfolgreichen Lauf alarmieren, zusätzlich freien Speicher, offene Recovery-Dateien und wachsende Partial-Verzeichnisse prüfen. Diese Überwachung läuft idealerweise außerhalb des gesicherten Servers. `TimeoutStartSec=12h` und `TimeoutStopSec=5min` sind Ausgangswerte und müssen zum ersten gemessenen Vollbackup mit Upload und Remote-Dateiprüfung passen.
Ein geplanter Backupabbruch erfolgt über `systemctl stop app-backup.service`. Normale INT-/TERM-Behandlung versucht die ursprünglichen Dienste wiederherzustellen. SIGKILL, Kernelabsturz oder Stromverlust können keine Shell-Traps ausführen. Dafür liegt `recovery-LAUFNAME.json` mit vorher aufgezeichnetem Wartungs-/Dienstzustand und Arbeitsordner im Workdir. Diese Datei ist eine manuelle Wiederanlaufhilfe, kein automatischer Neustartauftrag. Das System startet nicht blind Anwendungen über einen teilweise restaurierten Datenbestand.
@@ -562,11 +573,10 @@ wartete der Starter auf den oneshot-Abschluss, während der Worker auf seine
Sperre wartete. Ursache, Diagnose und Wiederanlauf beschreibt die Update-Anleitung.
Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keine reguläre Altersretention;
die sichere lokale Platzbereinigung bleibt erlaubt. Nach Upload/Rücklesevergleich
prüft bei aktiviertem Monatstest `app-restore-test.sh --remote-run ...` den konkreten
Satz vollständig mit Entpacken und isoliertem DB-Import. Andernfalls erfolgt eine
komponentenweise Archivprüfung mit `app-restore.sh --verify-only`. Erst nach Erfolg
werden die konfigurierten Timer aktiviert. Remote-Retention bleibt
die lokale Platzbereinigung bleibt erlaubt. Nach Upload, Remote-Datei-/
Größenprüfung und Markerbestätigung werden die konfigurierten Timer aktiviert.
Die Update-Abnahme startet keinen Archivdownload und keinen Offsite-Restore.
Entpacken und isolierter DB-Import finden beim Monatslauf statt. Remote-Retention bleibt
nach dieser Migration bewusst aus, bis die fachliche Restore-Abnahme erfolgt ist.
Direkt erkennbare Nextcloud-systemd-Writer werden übernommen. Bei klassischen
@@ -626,7 +636,7 @@ separate Abnahmen. Aktivierung und Prüfung auf Gemini erfolgen nach Installatio
### Entwicklungstests
Aktuelle Verifikation vom 15.09.2026 für den Code-Stand `3730ab4676a5`:
Historische Verifikation vom 15.09.2026 für den Code-Stand `3730ab4676a5`:
119 Fälle im Standardlauf, davon 111 erfolgreich und acht planmäßig übersprungen
(906,3 Sekunden). Die drei Benutzer-systemd-Fälle bestanden separat: alter
Reload-Konflikt reproduziert, geschützter Stop gegen Reload und Start erfolgreich,
@@ -647,9 +657,9 @@ Geprüfte Fälle umfassen:
- MTA-Fehler und begrenzter Versand-Timeout verändern den Backup-Exit-Code nicht; die lokale Nachricht bleibt erhalten. Header-Injection wird abgewiesen; Prüfläufe/Restore versenden keine Backup-Mail.
- Paketupdate installiert die Berichtskomponente, erhält alte Mail-Einstellungen und erzeugt beim Abnahmebackup einen Bericht über die MTA-Attrappe.
- Endgültiger Upload-Fehler trotz bereits übertragener Bytes: vollständiger lokaler Satz, vorheriger Erfolgsstatus und alte lokale/entfernte Retention-Kandidaten bleiben erhalten.
- Veränderte Remote-Datei gleicher Größe, Marker-Schreibfehler, abweichender Marker-Inhalt und zusätzliche Zeilenumbrüche verhindern den lokalen Upload-Erfolg und die Retention.
- Fehlende Remote-Dateien, falsche Größen, Marker-Schreibfehler, abweichender Marker-Inhalt und zusätzliche Zeilenumbrüche verhindern Upload-Erfolg und Retention. Gleich große Beschädigungen erkennt seit 16.09. erst der Restore.
- Nicht unterstützte rclone-Umgebungsoptionen werden vor Remote-Zugriff und Writer-Stopp abgewiesen; unterstützte exportierte Einstellungen bleiben nutzbar.
- Periodische NOTICE-Statistiken bei gedrosseltem echten Crypt-Upload, erfolgreicher Rückleseprüfung und Remote-Restore; die Statistik verändert den maschinell gelesenen Abschlussmarker nicht.
- Periodische NOTICE-Statistiken bei gedrosseltem echten Crypt-Upload, erfolgreicher Remote-Dateiprüfung und separatem Remote-Restore; die Statistik verändert den maschinell gelesenen Abschlussmarker nicht.
- Fremde Startversuche während realer rsync-Kopien werden durch das systemd-Testmodell übersprungen; zuvor inaktive Writer bleiben nach dem Lauf inaktiv.
- Aktive sowie zwischenzeitlich gestartete und wieder gestoppte Writer verhindern die Veröffentlichung eines vollständigen Satzes.
- Nicht geladene Startsperre, Erhalt vorhandener Maskierungen und fehlgeschlagene Sperrfreigabe mit erhaltener Wiederanlaufdatei.
@@ -664,8 +674,8 @@ Geprüfte Fälle umfassen:
- Shell-Syntax und ShellCheck der Paketdateien.
- MyISAM-Dump und tatsächlicher SQL-Import samt Dateiwiederherstellung.
- gzip/zstd, korrekte Excludes, vorhandener Wartungsmodus und inaktive Dienste.
- Lokaler Crypt-Upload mit vollständigem Rücklesen und Restore aus diesem Remote.
- Fehler bei Dump, rsync, Kompression, Diensten und Remote-Rückleseprüfung.
- Lokaler Crypt-Upload mit Remote-Dateiprüfung und separatem Restore aus diesem Remote.
- Fehler bei Dump, rsync, Kompression, Diensten und Remote-Dateiprüfung.
- Fehlende/beschädigte Archive, Pfadtraversal, Link-Pivots, unzulässige Tar-Dateitypen.
- Gemeinsame Sperre, TERM-Abbruch, Prüflauf ohne falsche Erfolgsmeldung.
- Selektiver Restore mit Löschung und Schutz ausgeschlossener Nextcloud-Daten.
+32 -20
View File
@@ -1,35 +1,39 @@
# app-backup – geprüfte Sicherungen für den JR IT Server
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 wird der gesamte Inhalt durch dieses Remote zurückgelesen und verglichen.
**21.09.2026 – Speicherblockade:** Bei Platznot bleiben mit `SPACE_KEEP_LOCAL=1`
mindestens eine bestätigte lokale Sicherung und alle unbestätigten Sätze erhalten.
Ältere Kopien werden erst nach lokalen Inhalts- und erneuten Offsite-Prüfungen
entfernt, bis der geplante Platzbedarf gedeckt ist. Die normale Retention bleibt
bei drei Kopien. [Ablauf und Grenzen](SPEICHER.md),
[Deployment](UPDATE_ANLEITUNG.md). Die Installation auf Gemini steht aus.
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.
Die vollständige Analyse, Architektur, Konfiguration, Betriebsanleitung und verbleibenden Grenzen stehen in [Dokumentation.md](Dokumentation.md).
Der [Vorfall vom 15.09.2026 und die korrigierte Rückleseprüfung](REMOTE_READBACK.md)
sind dokumentiert: begrenzte vollständige Prüfanläufe, eigene Zeitlimits,
sichtbare Dateiergebnisse und eindeutiger Hinweis auf erhaltene lokale Archive.
Die anschließende Restore-Abnahme verwendet jetzt ebenfalls begrenzte Downloads
mit Wiederholungen und sicheren Fehlerdetails; siehe
[Restore-Test: Downloadfehler](RESTORE_TEST.md#downloadfehler-bei-der-abnahme-am-15092026).
Seit 16.09.2026 laden Backup, Platzbereinigung und Update-Abnahme keine Archive
zur Prüfung zurück. Nur kleine Metadaten und der Abschlussmarker werden gelesen.
Dateien mit falschem Inhalt, aber passender Größe werden erst beim Restore erkannt.
[Hintergrund und Prüfumfang](REMOTE_READBACK.md).
Neu: [Monatlicher Offsite-Restore-Test](RESTORE_TEST.md) mit vollständiger Prüfung
aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern.
Für knappen Speicher wird jeweils nur eine Komponente verarbeitet. Das Update
mit `--enable-restore-test` erkennt die Produktions-MariaDB, bereitet das passende
lokale Docker-Image vor und prüft Initialisierung, Import und Bereinigung. Nach
Backup und vollständigem Offsite-Restore-Test aktiviert es den Monatstimer.
Backup mit Remote-Dateiprüfung aktiviert es den Monatstimer.
`--enable-ai-rem` nimmt auf Gemini `/srv/ai-rem` und `/etc/ai-rem` samt
Dienststopps wieder auf; siehe [Update-Anleitung](UPDATE_ANLEITUNG.md).
[Speicherbereinigung und Fehlerbehebung](SPEICHER.md) beschreibt die automatische
Entfernung erkannter Arbeitsreste und die geschützte lokale Notfallaufbewahrung.
Ohne Aktivierungsoption bleibt ein bisher deaktivierter Restore-Test deaktiviert.
## Produktionsstand und verbleibende Abnahme
**Stand 15.09.2026:** Das geprüfte Code-Paket `3730ab4676a5` mit der Korrektur
für den durch einen Healthcheck verdrängten Apache-Stop ist bereitgestellt;
der Commit ist auf `origin/master` veröffentlicht. Übertragung und Installation
auf Gemini sowie ein erfolgreiches Ergebnis der neuen Abnahme sind noch nicht
bestätigt. Im zuletzt dokumentierten Fehlversuch wurden die Dienste wieder
gestartet; Backup- und Restore-Test-Timer blieben gestoppt. Die konkreten Befehle
stehen unter [Deployment des bereitgestellten Pakets](UPDATE_ANLEITUNG.md#deployment-des-bereitgestellten-pakets-3730ab4676a5).
**Stand 16.09.2026:** Nach erneutem Rücklesefehler bei `nextcloud-data.tar.zst`
wird der tägliche Inhaltsvergleich durch eine Remote-Datei-/Größenprüfung ersetzt.
Die Installation dieses neuen Stands auf Gemini steht aus; konkrete Befehle und
Erfolgskriterien stehen in der [Update-Anleitung](UPDATE_ANLEITUNG.md).
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
@@ -41,7 +45,7 @@ für einen belastbaren Produktionsbetrieb; ihre Abnahme ist noch nicht bestätig
Ein OneDrive-HTTP-409 wurde im zweiten Upload-Versuch überwunden. Der Code erlaubt
standardmäßig bis zu sechs Kopierversuche und veröffentlicht den Remote-Abschlussmarker
erst nach erfolgreichem Inhaltsvergleich. Wiederkehrende Konflikte oder endgültige
erst nach erfolgreicher Remote-Datei-/Größenprüfung. Wiederkehrende Konflikte oder endgültige
Fehler müssen untersucht werden; ein aktiver Timer allein belegt kein aktuelles Backup.
Details: [Fehlerbehandlung und Betriebsgrenzen](Dokumentation.md#upload-fehlerbehandlung-und-betriebsgrenzen).
@@ -61,7 +65,7 @@ Seit 12.09.2026 lässt sich der Hostname in Mail-Betreff und Bericht mit
## Automatisches Update-Paket (empfohlen)
Das neue Paket übernimmt Konfigurationsabgleich, Rechteprüfung, Installation,
vollständigen Backup-Probelauf und Remote-Restore-Prüfung. Du benötigst auf dem
vollständigen Backup-Probelauf und Remote-Datei-/Größenprüfung. Du benötigst auf dem
Server keinen Git-Checkout. Übertrage die gelieferte `.run`-Datei und ihre Prüfsumme
per SCP und starte im Zielverzeichnis:
@@ -101,11 +105,11 @@ Details und geprüfte Grenzen stehen in der Update-Anleitung.
## Wesentliche Änderungen
- Funktionierender Restore mit Prüflauf, Entpackmodus, Komponentenauswahl und ausdrücklichem `--apply --yes`.
- Fehler bei Dump, rsync, Kompression und Rückleseprüfung brechen den Lauf ab.
- Fehler bei Dump, rsync, Kompression und Remote-Dateiprüfung brechen den Lauf ab.
- Wartungsmodus und Dienststopps umfassen Datenbankdump **und** Dateikopie. Danach endet die Ausfallzeit; Kompression und Upload laufen aus dem Staging.
- Vorübergehende systemd-Startsperren verhindern die erneute Aktivierung konfigurierter Writer während der Kopie. Zustands- und Zeitstempelprüfungen brechen bei erkannten Neustarts vor Veröffentlichung ab. Eigene Sperren werden beim Wiederanlauf entfernt; bestehende Maskierungen bleiben erhalten.
- Laufende Stop-Aufträge sind zusätzlich gegen Verdrängung durch fremde Reload-/Start-Aufträge geschützt. Damit kann ein Healthcheck den geplanten Dienststopp nicht abbrechen; echte Stopfehler brechen das Backup weiterhin ab.
- Upload und Rückleseprüfung melden ihre Phase und standardmäßig jede Minute rclone-Statistiken im Journal (`RCLONE_STATS_INTERVAL`). Ein laufender Auftrag ist erst nach erfolgreicher Remote-Prüfung abgenommen.
- Upload und Remote-Dateiprüfung melden ihre Phase und standardmäßig jede Minute rclone-Statistiken im Journal (`RCLONE_STATS_INTERVAL`). Ein laufender Auftrag ist erst nach erfolgreicher Remote-Prüfung abgenommen.
- Vollständige Sicherungssätze mit SHA-256-Manifest, Archivprüfung und zuletzt hochgeladenem `COMPLETE`-Marker.
- Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Historische v1-Sicherungen bleiben erhalten; bei Platzdruck gilt zusätzlich die abgesicherte lokale Bereinigung aus [SPEICHER.md](SPEICHER.md).
- Private Arbeitsverzeichnisse, gemeinsame Sperre für Backup/Restore/Update und Wiederanlaufhinweise nach Abbrüchen.
@@ -174,6 +178,7 @@ In der Konfiguration insbesondere ergänzen/prüfen:
RCLONE_CONFIG="/root/.config/rclone/rclone.conf"
RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" # vorhandenes Crypt-Ziel beibehalten!
RETENTION_KEEP_LAST=3
SPACE_KEEP_LOCAL=1 # nur bei Platznot, nach erneuter Prüfung
ENABLE_REMOTE_RETENTION="false" # während der Migration zunächst aus
MAIL_BACKUP_MODE="stop"
# Zentrale Mailablage muss existieren; bei ausschließlich in Homes liegenden
@@ -295,10 +300,17 @@ Der Bericht bleibt zusätzlich unter `/var/log/app-backup/report_LAUF.eml` mit
Modus `600` erhalten. Versandfehler werden protokolliert und verändern das
Backup-Ergebnis nicht. `--check` und Restore-Prüfungen senden keine Backup-Mail.
Die Mail bestätigt den Backup-Lauf; beim Update ist weiterhin dessen abschließende
`result.txt` nach der zusätzlichen Remote-Restore-Prüfung maßgeblich.
`result.txt` nach Backup und Remote-Dateiprüfung maßgeblich. Der monatliche
Restore-Test führt seinen eigenen Erfolgsnachweis.
## Tests
Verifikation am 21.09.2026: **132 unterschiedliche Tests bestanden**, einschließlich
Speicherblockade, Schutz der letzten lokalen Sicherung, Metadaten-Retries,
Paketinstallation/Rollback und echtem Benutzer-systemd. Fünf optionale zusätzliche
Docker-DB-Tests wurden nicht erneut ausgeführt. ShellCheck und Shell-Syntax sind
erfolgreich; [Details und Grenzen](UPDATE_ANLEITUNG.md#lokale-prüfung-am-21092026).
Der Restore-Test ist separat unter [RESTORE_TEST.md](RESTORE_TEST.md) dokumentiert,
einschließlich Aktivierung, Platzbedarf, Statusdateien, Fehlern und Bereinigung.
+38 -1
View File
@@ -1,4 +1,41 @@
# Rücklesefehler am 15.09.2026
# Remote-Prüfung seit 16.09.2026
Der erneute Fehler beim Lauf `appbackup_2026-09-15_21-03-40_21b6efeb` endete
am 16.09. um 00:50:33 MESZ nach drei Rückleseversuchen. 14 Dateien passten;
`nextcloud-data.tar.zst` ließ sich nicht öffnen (`needs retry`). Der Bericht
beweist keinen konkreten OneDrive-Throttling-Mechanismus.
Auf Betreiberwunsch gilt jetzt:
- Backup: Archive und Manifest hochladen, dann `rclone check --size-only` über
das Crypt-Remote. Das prüft Dateiexistenz und Größen ohne Archivdownloads.
- Fehlende Dateien, falsche Größen oder endgültige Prüffehler verhindern die
Veröffentlichung von `COMPLETE`, `UPLOADED` und eines neuen Erfolgsstatus.
- `COMPLETE` wird zuletzt hochgeladen und bytegenau zurückgelesen. Das ist nur
ein kleiner Marker; auch Retention liest weiterhin kleine Metadaten.
- Platzbereinigung: lokale Inhalte vollständig prüfen, remote Dateiexistenz
und Größen. Seit 21.09. werden Manifest und Marker zusätzlich bytegenau verglichen;
`SPACE_KEEP_LOCAL=1` schützt die neueste bestätigte lokale Kopie bei Platznot.
- Update-Abnahme: Backup und Remote-Dateiprüfung, kein anschließender Archivdownload.
- Monatslauf: alle Archive des neuesten vollständigen Satzes nacheinander laden,
SHA-256 und Größe prüfen, validieren, entpacken und SQL in eine isolierte MariaDB
importieren. Wiederholungen, Fristen und Bereinigung bleiben bestehen.
Die bisherigen `RCLONE_CHECK_*`-Werte gelten weiterhin für begrenzte
Metadatenprüfungen und monatliche Downloads. Es gibt keinen Schalter, der den
alten täglichen Vollvergleich wieder einschaltet. Lokale Archivprüfungen bleiben
unverändert. Gleich große Remote-Beschädigungen werden erst beim Restore erkannt;
`UPLOADED` bestätigt nun die Veröffentlichung, keinen Inhaltsvergleich.
Quelle zur Größenprüfung: [rclone check](https://rclone.org/commands/rclone_check/).
Installation: [UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md).
## Historische Befunde und frühere Rücklesekorrektur
Die folgenden Befunde beschreiben den bisherigen Ablauf bis 15.09.2026. Seine
vollständigen Rücklesevergleiche wurden durch die oben beschriebene Regel ersetzt.
## Rücklesefehler am 15.09.2026
## Befund und Grenzen
+13 -4
View File
@@ -6,6 +6,12 @@ entschlüsselt, validiert und in ein privates Testverzeichnis entpackt. SQL-Dump
werden zusätzlich in eine isolierte MariaDB importiert und mit `mariadb-check`
geprüft. Produktive Anwendungen werden dabei nicht angehalten oder überschrieben.
Seit 16.09.2026 werden die Archive beim täglichen Backup und bei der
Update-Abnahme nur auf Remote-Dateiexistenz und Größe geprüft. Der hier
beschriebene Monatslauf übernimmt die vollständige Inhalts- und Restore-Prüfung.
Auch ai-rem-Zusatzarchive (`extra-N`) werden heruntergeladen und entpackt;
ein Start der ai-rem-Anwendung oder ein Kuzu-Datenbanktest gehört nicht dazu.
## Downloadfehler bei der Abnahme am 15.09.2026
Das Update `4f220dbc6447` erzeugte den Satz
@@ -32,7 +38,7 @@ Quelle: [rclone-1.74.3-cat-Aufruf](https://github.com/rclone/rclone/blob/v1.74.3
### Korrigierter Ablauf
Der Restore-Test verwendet für seine lesenden Remote-Aufrufe jetzt dieselben
Konfigurationswerte wie die Backup-Rückleseprüfung:
Konfigurationswerte wie die Remote-Metadatenprüfung:
- `RCLONE_CHECK_ATTEMPTS=3`: höchstens drei Versuche je Metadatenabruf/Download.
- `RCLONE_CHECK_DEADLINE=3600`: maximal eine Stunde je Versuch einschließlich
@@ -73,8 +79,9 @@ Ein bleibender Fehler erhält den bereits verifizierten Backup-Satz und den
vorherigen Restore-Erfolgsnachweis, meldet die betroffene Komponente als `FAIL`
und bereinigt die Testdaten. Nach fehlgeschlagener Update-Abnahme bleiben
Backup- und Restore-Test-Timer gestoppt. Das neue Paket erneut im Wartungsfenster
installieren; es führt ein neues Backup samt vollständiger Restore-Abnahme aus.
Erst die neue `result.txt` mit `SUCCESS` bestätigt die gesamte Abnahme.
installieren; es führt ein neues Backup samt Remote-Dateiprüfung aus.
Die neue `result.txt` mit `SUCCESS` bestätigt die Update-Abnahme;
der vollständige Restore-Test erfolgt separat beim Monatstermin.
[Installationsschritte](UPDATE_ANLEITUNG.md).
Der vorhandene erfolgreiche Backup-Satz darf alternativ nach Installation des
@@ -135,7 +142,9 @@ Bei einem ausgeschalteten Server erfolgt sie naturgemäß erst nach dem Neustart
Nur der eindeutig zu diesem Test gehörende Container und das markierte
`$WORKDIR/restore-test/scratch` werden bereinigt. Reguläre manuelle Restore-Verzeichnisse und fremde Docker-Ressourcen bleiben
erhalten. Die getrennte Platzbereinigung darf ältere lokale Sicherungskopien nur
nach erneuter vollständiger Offsite-Prüfung und unter Wahrung der Mindestanzahl entfernen.
nach lokaler Inhaltsprüfung und erneuter Remote-Datei-/Größenprüfung sowie bytegenauem
Vergleich von Manifest/Abschlussmarker entfernen. Die Mindestanzahl bei Platznot
ist `SPACE_KEEP_LOCAL` (Default eins); Details stehen in [SPEICHER.md](SPEICHER.md).
Es gibt kein allgemeines `docker prune`. Das einmal vorbereitete DB-Image bleibt
als wiederverwendbare Abhängigkeit erhalten; sein einmaliger Platzbedarf kommt
zum Arbeitsbudget hinzu. Images werden im Test weder heruntergeladen noch gebaut.
+71 -13
View File
@@ -1,5 +1,60 @@
# Automatische Speicherbereinigung
## Platznot vor dem Backup: Korrektur vom 21.09.2026
Der Mailbericht vom 20.09. nennt 55.823.183.174 benötigte und
47.615.086.592 verfügbare Bytes: Es fehlen 8.208.096.582 Bytes (7,64 GiB).
Die alte Bereinigung schützt dieselben drei lokalen Kopien wie die reguläre
Retention. Dadurch kann sie bereits vor dem nächsten Backup blockieren.
`SPACE_KEEP_LOCAL=1` setzt jetzt eine eigene Untergrenze für Platznot, auch bei
bestehender Konfiguration ohne diesen Eintrag. Die neuesten so vielen gültigen,
als hochgeladen markierten lokalen Sätze bleiben erhalten. `RETENTION_KEEP_LAST=3`
gilt weiterhin für die normale Retention und wird nicht verändert. Wer die alte
Grenze ausdrücklich beibehalten möchte, setzt `SPACE_KEEP_LOCAL=3`. Null ist
unzulässig. Die Reserve `MIN_FREE_GB=10` und die Snapshot-Schätzung bleiben bestehen.
Gelöscht wird ältester Satz zuerst, nur bis der Bedarf gedeckt ist und nur nach
vollständiger lokaler Inhaltsprüfung, Remote-Datei-/Größenvergleich und bytegenauem
Vergleich von Remote-Manifest und `COMPLETE`. Für die beiden kleinen Kontrolldateien
gelten dieselben begrenzten Versuche/Zeitlimits; Archive werden nicht heruntergeladen.
Die geschützten neuesten lokalen Kopien werden zuvor ebenfalls vollständig geprüft.
Ist eine davon beschädigt oder unvollständig, bleiben alle älteren Kandidaten
erhalten; ein bloßer `UPLOADED`-Marker genügt nicht als Rückfallabsicherung.
Signalisierte Abbrüche beenden die Bereinigung, statt weitere Kandidaten zu versuchen.
Nach jeder Entfernung wird der tatsächlich freie Platz neu gemessen. Mountpoints,
auch Bind-Mounts einzelner Dateien, werden nicht rekursiv gelöscht.
Das schafft Platz, wenn ausreichend bestätigte lokale Kopien vorhanden sind.
Bei geschützten Beständen, Remote-Fehlern oder einem zu kleinen Datenträger kann
die Software keinen sicheren Platz erzwingen: Sie bricht vor dem Writer-Stopp ab
und protokolliert Bedarf, Verfügbarkeit, Defizit sowie verbleibende Archivbestände
mit belegten Bytes und Schutzgrund. Nicht bestätigte Uploads bleiben erhalten.
Das Update führt diese Bereinigung bereits vor der Installation aus; sie ist
daher auch dann wirksam, wenn der bisher installierte Stand nicht mehr starten kann.
### Wenn die geschützten Bestände den Platz belegen
`preflight.log` beziehungsweise das Backup-Log zeigt, ob bestätigte lokale Sätze
oder unbestätigte/unklare Bestände übrig sind. Zunächst lesend prüfen:
```bash
sudo du -xhd2 /var/backups/app-backup
df -h /var/backups/app-backup
sudo find /var/backups/app-backup/archives -maxdepth 2 -name UPLOADED -printf '%h\n'
```
Unbestätigte vollständige Sätze müssen außerhalb dieses automatischen Ablaufs
gesondert geprüft und offsite veröffentlicht oder auf einen anderen Datenträger
verschoben werden. Keinen `UPLOADED`-Marker manuell vortäuschen. Reicht auch danach
der Platz nicht, das Backup-Dateisystem erweitern oder `WORKDIR`/`STAGING_ROOT`/
`ARCHIVE_DIR` auf einen ausreichend großen dedizierten Datenträger umstellen.
Eine Verlagerung erfolgt bei gestoppten Backup-/Restore-/Update-Aufträgen; vorhandene
Sätze, Besitzrechte und Modus 700 müssen erhalten bleiben. Anschließend
`--prepare-space` und `--check` mit der neuen Konfiguration ausführen.
Weder geschützte Recovery-Daten noch die letzte bestätigte lokale Sicherung
werden als Ausweg automatisch gelöscht.
Der auf Gemini gemeldete Abbruch vom 12.09.2026 erfolgte nach dem erfolgreichen
Wiederanlauf der Dienste, während der Kompression: Unter `/var` waren nur noch
9,2 GiB frei, die konfigurierte Reserve beträgt 10 GiB. Rund 29 GiB Staging und
@@ -27,19 +82,21 @@ oder der Update-Vorbereitung werden folgende Schritte ausgeführt:
Platzprüfung werden produktive Writer angehalten.
4. Bei Platzmangel ältere geeignete lokale v2-Sätze prüfen. Nur Sätze mit gültigem
Manifest, `COMPLETE` und lokalem `UPLOADED` kommen infrage. Die neuesten
`RETENTION_KEEP_LAST` geeigneten Kopien bleiben geschützt (Default drei,
mindestens zwei). Ältere Kandidaten werden lokal vollständig validiert und mit
`rclone check --download` inklusive Manifest und `COMPLETE` erneut vollständig
gegen ihre Offsite-Kopie gelesen. Erst bei erfolgreicher Prüfung wird die lokale
Kopie entfernt. Ein nicht mehr lesbarer oder veränderter Remote-Satz bleibt
lokal erhalten. Es wird nur so lange bereinigt, bis der geplante Platz reicht.
`SPACE_KEEP_LOCAL` geeigneten Kopien bleiben geschützt (Default und Minimum eins).
Ältere Kandidaten werden lokal vollständig validiert und mit
`rclone check --size-only` inklusive Manifest und `COMPLETE` auf
Remote-Dateiexistenz und passende Größen geprüft (ohne Archivdownload). Erst bei erfolgreicher Prüfung wird die lokale
Kopie entfernt; zusätzlich müssen Remote-Manifest und `COMPLETE` bytegenau
mit den lokalen Dateien übereinstimmen. Fehlende Remote-Dateien oder Größenabweichungen verhindern die Löschung.
Gleich große Inhaltsbeschädigungen werden erst beim Restore erkannt. Es wird nur so lange bereinigt, bis der geplante Platz reicht.
Diese Offsite-Vergleiche verwenden ebenfalls die begrenzten Prüfanläufe und
Zeitlimits aus [REMOTE_READBACK.md](REMOTE_READBACK.md). Ein fehlgeschlagener
Vergleich erlaubt auch nach mehreren Versuchen keine lokale Löschung.
Die Platzbereinigung benötigt keinen vorherigen neuen Sicherungslauf. Sie darf
das lokale Aufbewahrungsalter unterschreiten, **niemals die Mindestanzahl**.
das lokale Aufbewahrungsalter und `RETENTION_KEEP_LAST` unterschreiten,
**niemals die eigene Mindestanzahl `SPACE_KEEP_LOCAL`**.
Sie verändert keine Offsite-Daten. Die normale Altersretention nach einem neuen
erfolgreichen Backup bleibt davon getrennt. `--no-retention` im Update unterdrückt
die Altersretention, erlaubt aber weiterhin die hier beschriebene Platzbereinigung.
@@ -60,7 +117,7 @@ fehlgeschlagen ist, bleibt erhalten.
Folgende Daten bleiben geschützt:
- vollständige lokale Sätze ohne bestätigten Upload;
- die neuesten `RETENTION_KEEP_LAST` geeigneten lokalen Sicherungen;
- die neuesten `SPACE_KEEP_LOCAL` geeigneten lokalen Sicherungen;
- ein `.partial`-Verzeichnis, das bereits einen `COMPLETE`-Marker enthält;
- sämtliche Arbeitsreste bei vorhandenem `recovery-*.json` oder angefangener
Recovery-Datei: zunächst muss der Dienst-/Restore-Zustand geklärt werden;
@@ -112,8 +169,9 @@ von der Platzbereinigung ausgenommen.
Das neue Update-Paket wie in [UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md) mit
`--enable-restore-test` starten. Es bereinigt vor der Vorprüfung erkannte alte
Arbeitsreste, bereitet MariaDB vor und führt Backup und vollständigen Restore-Test
aus. Erst danach aktiviert es die Timer. Der alte fehlgeschlagene Update-Ordner
Arbeitsreste, bereitet MariaDB vor und führt Backup mit Remote-Dateiprüfung
aus. Nach erfolgreicher Update-Abnahme aktiviert es die Timer; der vollständige
Restore-Test folgt beim Monatstermin. Der alte fehlgeschlagene Update-Ordner
bleibt als Diagnose-/Rollback-Nachweis erhalten; ein pauschales `rm` ist unnötig.
Nach bereits erfolgter Installation kann die Platzvorbereitung separat gestartet
@@ -127,9 +185,9 @@ df -h /var/backups/app-backup
```
`--check` allein bleibt eine Prüfung ohne Bereinigung. Vollständige
Offsite-Rückleseprüfungen während der Platzbeschaffung können dauern. Der Starter
lokale Inhaltsprüfungen können dauern; remote werden nur Dateiexistenz und Größen geprüft. Der Starter
nennt sein Verzeichnis mit `preflight.log`; der spätere Worker schreibt `update.log`
und `result.txt`. `SUCCESS` im aktuellen `result.txt` und `cleanup: OK` im
Restore-Teststatus sind die Abnahmenachweise. Scheitert ein Update trotz der
und `result.txt`. `SUCCESS` im aktuellen `result.txt` bestätigt das Update; der
Restore-Teststatus ist ein separater Nachweis nach dem Monatslauf. Scheitert ein Update trotz der
sicheren Bereinigung, bleiben die Timer nach begonnener Installation gestoppt;
der Fehler muss behoben und das Paket erneut ausgeführt werden.
+126 -41
View File
@@ -1,55 +1,140 @@
# Automatisches Update-Paket
## Deployment des bereitgestellten Pakets 3730ab4676a5
## Update vom 21.09.2026: Speicherblockade auflösen
Stand 15.09.2026: Der Code-Commit `3730ab4676a5` ist auf `origin/master`
veröffentlicht. Das daraus gebaute Paket und seine SHA-256-Datei liegen lokal
unter `dist/`; äußere Prüfsumme, interne Payload-Prüfsumme und alle 33 enthaltenen
Dateien wurden geprüft. Die nachfolgende Dokumentationsaktualisierung ändert den
Programmstand nicht. Das Paket bleibt für die bereits übermittelte Installation
gültig. Übertragung, Installation und neue Serverabnahme sind noch nicht bestätigt.
Das Paket enthält die unten beschriebenen Änderungen vom 16.09. und die neue
Speicherstrategie aus [SPEICHER.md](SPEICHER.md). Bei Platznot darf die automatische
Bereinigung jetzt bis auf `SPACE_KEEP_LOCAL=1` bestätigte lokale Sicherung
zurückgehen. Die normale Retention bleibt bei `RETENTION_KEEP_LAST=3`.
Der neue Standard gilt auch ohne Ergänzung der bestehenden Konfiguration.
Vor jeder Entfernung werden lokale Inhalte, Remote-Größen sowie Manifest und
Abschlussmarker geprüft. Die OneDrive-Sicherung wird dabei nicht gelöscht.
Die Übertragungs- und Startbefehle im folgenden Abschnitt gelten auch für dieses
Paket. Die Speicherbereinigung läuft bereits in der Paket-Vorprüfung. Es ist daher
kein vorheriges manuelles Löschen nötig. Der Starter kann für die lokale
Archivprüfung einige Zeit benötigen; das ausgegebene `preflight.log` zeigt den
Fortschritt. Bei weiterem Platzmangel enthält es eine Bestandsliste und das
verbleibende Defizit. Die Grenzen und das weitere Vorgehen stehen in `SPEICHER.md`.
Nach Installation lässt sich die Vorbereitung auch separat wiederholen:
```bash
sudo bash /usr/local/sbin/app-backup.sh --prepare-space
sudo bash /usr/local/sbin/app-backup.sh --check
df -h /var/backups/app-backup
```
`--prepare-space` löscht geeignete lokale Kopien nach den beschriebenen Prüfungen.
Es stoppt keine Anwendungen und erzeugt kein neues Backup. Das eigentliche Update
erstellt dagegen ein Abnahmebackup mit Anhalten der konfigurierten Writer.
Ein Rollback stellt gelöschte lokale Kopien nicht wieder her; sie bleiben offsite.
Die Abnahme auf Gemini steht bis zum neuen `SUCCESS` in `result.txt` aus.
### Lokale Prüfung am 21.09.2026
Der Gesamtlauf bestand mit 131 Fällen (123 erfolgreich, acht planmäßig
übersprungen; 993,3 Sekunden). Danach beziehungsweise ergänzend bestanden die
zwölf abschließenden Speicher-/Retry-Tests, zwei zusätzliche Integrationsfälle
für beschädigte Rückfallkopien und Signalabbrüche sowie alle drei Tests gegen
echten Benutzer-systemd. Zusammen sind **132 unterschiedliche Tests** erfolgreich
abgedeckt. Fünf optionale Tests mit zusätzlichen Docker-Datenbankcontainern wurden
bei dieser Änderung nicht erneut ausgeführt. Die Basissuite verwendet eine echte
isolierte MariaDB und lokale rclone-Crypt-Ziele; produktive Dienste und Mailversand
werden simuliert. ShellCheck, Shell-Syntax und `git diff --check` bestanden.
Geprüft sind insbesondere die Speicherblockade bei drei lokalen Kopien,
sofortiger Bereinigungsstopp bei ausreichendem Platz, Erhalt der letzten geprüften
und aller unbestätigten Kopien, unveränderte Offsite-Daten, beschädigte Manifeste/
Marker, begrenzte Wiederholungen, Signalabbrüche und Mount-/Recovery-Schutz.
Paketinstallation, ai-rem-Einbindung und Rollback bestanden ebenfalls. Das ersetzt
nicht die noch ausstehende Installation und Speicher-/Backup-Abnahme auf Gemini.
## Update vom 16.09.2026: Upload ohne Archiv-Rücklesen, ai-rem wieder aufnehmen
Der tägliche Lauf lädt Archive und Manifest über Crypt hoch und prüft anschließend
Dateiexistenz und Größe mit `rclone check --size-only`. Der kleine `COMPLETE`-Marker
wird zuletzt hochgeladen und weiterhin bytegenau bestätigt. Die Platzbereinigung
verwendet ebenfalls nur Remote-Dateiexistenz und Größen. Lokale Archivprüfungen
bleiben vollständig erhalten. Es gibt keinen täglichen Download der Archive.
Auch die Update-Abnahme endet nach erfolgreichem Backup und Remote-Dateiprüfung.
Sie startet keinen Offsite-Restore mehr. `SUCCESS` in `result.txt` bestätigt daher
Installation, Backup, Upload und Dateiprüfung; der separate Inhalts- und
Wiederherstellungsnachweis entsteht beim monatlichen Restore-Test. Schäden mit
unveränderter Dateigröße können erst beim Restore erkannt werden.
`--enable-ai-rem` ergänzt die auf Gemini geprüften Pfade `/srv/ai-rem`
(einschließlich Daten, Compose-Datei und eigener Sicherungen) und `/etc/ai-rem`
(einschließlich Environment-Datei). Vorhandene `EXTRA_PATHS` bleiben in ihrer
Reihenfolge erhalten; neue Pfade werden als weitere `extra-N` angehängt.
Wiederholte Aktivierung erzeugt keine zusätzlichen Einträge dieser Pfade.
Während der Kopie werden zuerst Healthcheck-, Backup- und Prune-Timer, danach ihre
Services und `ai-rem.service` angehalten. So wird die eingebettete Datenbank bei
beendetem Container kopiert. Vorher aktive Dienste starten nach der Kopie in
umgekehrter Reihenfolge wieder. Fehlende Pfade oder Units brechen die Vorbereitung
ab. Der Monatslauf prüft und entpackt auch diese Zusatzarchive; er startet keinen
ai-rem-Anwendungscontainer und führt keinen fachlichen Datenbanktest für ai-rem aus.
Die systemd-Units und Hilfsprogramme außerhalb dieser beiden Pfade müssen für eine
Neuinstallation separat verfügbar sein.
### Übertragen und starten
Nach dem Commit wird das Paket mit `python3 tools/build-update-package.py` aus dem
sauberen Repository gebaut. Der Dateiname enthält die ersten zwölf Zeichen des
Commits. In den folgenden Befehlen `VERSION` durch diesen Wert ersetzen; die
bereitgestellte Datei liegt unter `dist/`.
Auf dem Arbeitsplatz:
```bash
cd /home/johannes/localdev/app-backup/dist
scp app-backup-update-3730ab4676a5.run \
app-backup-update-3730ab4676a5.run.sha256 \
scp app-backup-update-VERSION.run app-backup-update-VERSION.run.sha256 \
johannes@gemini.jr-it-services.de:~/
```
Auf Gemini im Wartungsfenster starten; während der Datenkopie werden die
konfigurierten Anwendungen angehalten:
Auf Gemini im Wartungsfenster (Anwendungen werden während der Kopie angehalten):
```bash
ssh johannes@gemini.jr-it-services.de
cd ~
sha256sum -c app-backup-update-3730ab4676a5.run.sha256 &&
sudo bash app-backup-update-3730ab4676a5.run
sha256sum -c app-backup-update-VERSION.run.sha256 &&
sudo bash app-backup-update-VERSION.run --enable-ai-rem --enable-restore-test
```
Der bereits aktivierte Restore-Test wird übernommen; ein vorheriger Rollback
ist nicht erforderlich. Der Auftrag läuft unabhängig von der SSH-Verbindung.
Ein vorheriger Rollback ist nicht erforderlich. Bestehende Zugangsdaten und
Zeitpläne werden übernommen. `--enable-restore-test` stellt sicher, dass der
Monatstest aktiv ist, und bereitet sein MariaDB-Testimage vor. Diese kleine lokale
DB-Vorprüfung lädt keine Backup-Archive von OneDrive. Neue Timer laufen am ersten
Tag des Monats um 06:00 Uhr plus bis zu 15 Minuten Verzögerung; vorhandene Timer
behalten ihren Zeitplan. `Persistent=true` kann einen verpassten Monatstermin nachholen.
Der Auftrag läuft unabhängig von SSH. Verlauf und Ergebnis:
```bash
sudo journalctl -fu app-backup-upgrade.service --no-pager
```
`Strg+C` beendet nur die Loganzeige. Den vom Starter genannten Ergebnisordner
verwenden und `XXXXXXXX` durch dessen tatsächlichen Suffix ersetzen:
```bash
# Strg+C beendet nur die Loganzeige.
# Den tatsächlichen Ergebnisordner aus der Starter-Ausgabe einsetzen:
sudo cat /var/backups/app-backup-upgrade-XXXXXXXX/result.txt
sudo systemctl list-timers app-backup.timer app-restore-test.timer --no-pager
```
Erst `SUCCESS` in dieser neuen Ergebnisdatei bestätigt Installation, Backup und
Remote-Restore-Prüfung. Bei `QUEUED` oder `RUNNING` keinen zweiten Lauf starten.
Bei `FAILED` die Ergebnisdatei und die letzten Journalzeilen auswerten:
Erst `SUCCESS` in der neuen `result.txt` bestätigt den Abschluss. Bei `QUEUED` oder
`RUNNING` keinen zweiten Auftrag starten. Der abgenommene Lauf steht in
`uploaded-backup-run`; ein neuer Restore-Erfolg wird beim Update nicht behauptet.
Nach dem Monatstest stehen Ergebnis und letzte erfolgreiche Probe unter
`/var/backups/app-backup/restore-test/state.json` und `last-success.json`.
```bash
sudo journalctl -u app-backup-upgrade.service -n 150 --no-pager -l
```
Der fehlgeschlagene lokale Satz `appbackup_2026-09-15_21-03-40_21b6efeb`
bleibt erhalten. Das Update veröffentlicht ihn nicht nachträglich und setzt
seinen fehlenden `UPLOADED`-Nachweis nicht künstlich. Es erstellt einen neuen Lauf.
### Historische Fehleranalysen bis 15.09.2026
Die folgenden Nachträge beschreiben den damaligen Ablauf einschließlich sofortiger
Rücklese- und Restore-Abnahme. Für neue Installationen gilt der oben beschriebene
Ablauf vom 16.09.; die damaligen Paketnamen sind keine aktuelle Installationsanweisung.
## Nachtrag vom 15.09.2026: Healthcheck verdrängt Apache-Stop
@@ -143,12 +228,12 @@ Produktionsserie lesen, passendes lokales Image wiederverwenden oder laden und
einen isolierten Probeimport einschließlich Bereinigung ausführen. Manuelles
`docker pull` ist auf Gemini nicht mehr erforderlich. Die Option aktiviert
zugleich das adaptive Arbeitsbudget; Details: [RESTORE_TEST.md](RESTORE_TEST.md).
Danach prüft der Worker sein neues Offsite-Backup vollständig mit Entpacken und
DB-Import. Erst bei Erfolg aktiviert er Monatstimer und Boot-Bereinigung.
Nach erfolgreichem Backup mit Remote-Dateiprüfung aktiviert der Worker
Monatstimer und Boot-Bereinigung. Der vollständige Offsite-Restore folgt beim Monatstermin.
Vor der Vorprüfung werden erkannte Überreste fehlgeschlagener Backups bereinigt
und der nächste Snapshot geplant. Reicht der Platz nicht, werden geeignete ältere
lokale Kopien nach vollständiger Offsite-Rückleseprüfung entfernt; die neuesten
lokale Kopien nach lokaler Inhaltsprüfung und Remote-Datei-/Größenprüfung entfernt; die neuesten
`RETENTION_KEEP_LAST` bleiben geschützt. Diese Platzbereinigung gilt auch während
einer Update-Abnahme ohne reguläre Retention. Sie wird nicht durch Paketrollback
rückgängig gemacht; entfernte vollständige lokale Sätze bleiben offsite verfügbar.
@@ -169,7 +254,7 @@ auf den Server übertragen. Auf dem Server im selben Verzeichnis prüfen/starten
```bash
sha256sum -c app-backup-update-VERSION.run.sha256
sudo bash app-backup-update-VERSION.run --enable-restore-test
sudo bash app-backup-update-VERSION.run --enable-ai-rem --enable-restore-test
```
VERSION durch den tatsächlichen Dateinamen ersetzen. Ein Git-Checkout auf dem
@@ -212,14 +297,13 @@ abschwächen und sind im Backup-Prozess nicht unterstützt.
8. Backup-Timer stoppen, Paket mit Rollback-Inventar installieren, Konfiguration
atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren.
9. Vollständiges Backup ohne reguläre lokale/Remote-Altersretention durchführen.
Platzbereinigung bleibt erlaubt. Bei aktiviertem Restore-Test den Satz
komponentenweise herunterladen, entpacken und seine DB-Dumps isoliert importieren;
sonst alle Archive einzeln herunterladen und vollständig prüfen.
Platzbereinigung bleibt erlaubt. Upload mit anschließender Dateiexistenz-/
Größenprüfung und Bestätigung des Abschlussmarkers; keine Archivdownloads.
10. Erst bei Erfolg den Backup-Timer aktivieren und das Ergebnis protokollieren.
Es findet kein Import in produktive Anwendungen oder Datenbanken statt. Der
Prüflauf belegt bei aktiviertem Restore-Test auch Entpacken und DB-Import, ersetzt
aber keinen fachlichen Anwendungstest.
Update-Prüflauf bestätigt den Upload. Entpacken und DB-Import werden separat
beim Monatslauf geprüft; ein fachlicher Anwendungstest bleibt zusätzlich erforderlich.
## Verlauf und Ergebnis
@@ -288,7 +372,7 @@ Die neue Python-Berichtskomponente wird vom Installer samt Rollback installiert.
Beim Abnahmebackup entsteht eine Mail mit Laufzeit, Größen, Upload-Prüfstatus und
Log-Auszug. Das Update ist erst mit `SUCCESS` in seiner `result.txt` nach der
zusätzlichen Restore-Prüfung abgeschlossen. Fehlt die Mail, im Backup-Log nach
Remote-Dateiprüfung abgeschlossen. Fehlt die Mail, im Backup-Log nach
`mail report failed` suchen und die lokale `report_LAUF.eml`, sendmail-Verfügbarkeit,
MTA-Queue und Empfänger-/Aliaszustellung prüfen. Keine erneute Sicherung allein
zum Testen der Mailzustellung starten. Die Entwicklungstests verwenden eine lokale
@@ -604,7 +688,7 @@ Quelle: [rclone 1.74.3 OneDrive-Code](https://github.com/rclone/rclone/blob/v1.7
Die bisherigen Aufträge zeigten normale Fortschrittsstatistiken nicht an:
rclone meldet sie standardmäßig auf INFO, die normale Logschwelle ist NOTICE.
Das aktuelle Paket setzt `--stats-log-level NOTICE --stats-one-line` mit
`RCLONE_STATS_INTERVAL="1m"` und meldet die Phasen Upload, Rücklesen und
`RCLONE_STATS_INTERVAL="1m"` und meldet die Phasen Upload, Remote-Dateiprüfung und
Freigabe des Abschlussmarkers. Statistiken gehen nach stderr, damit maschinell
gelesene Marker und Dateilisten auf stdout unverändert bleiben.
Quelle: [rclone-Statistiken](https://rclone.org/docs/#stats-log-level-loglevel).
@@ -619,9 +703,10 @@ Quelle: [I/O-Leerlauffrist](https://rclone.org/docs/#timeout-duration).
Eine neue Paketdatei oder geänderte Config ergänzt keine Statistiken im bereits
laufenden rclone-Prozess. Keinen zweiten Upload parallel starten und den
vorhandenen lokalen Satz nicht löschen. Ein einzelner 409-Fehler begründet keine
Abschwächung der Prüfungen durch `--ignore-existing`/`--size-only`.
Entscheidend bleiben erfolgreiches Rücklesen,
Manifest-/Archivprüfung und `SUCCESS` in der Ergebnisdatei des konkreten Auftrags.
Änderung der Upload-Optionen wie `--ignore-existing`. Seit 16.09.2026 wird
`--size-only` gezielt beim Metadatenvergleich eingesetzt. Entscheidend sind lokale
Manifest-/Archivprüfung, erfolgreicher Upload mit Remote-Dateiprüfung und
`SUCCESS` in der Ergebnisdatei; der Monatslauf liefert den separaten Restore-Nachweis.
## Paket reproduzieren
+8 -2
View File
@@ -17,6 +17,10 @@ LOG_RETENTION_DAYS=30
LOCAL_RETENTION_DAYS=7
REMOTE_RETENTION_DAYS=30
RETENTION_KEEP_LAST=3
# Under disk pressure only: keep at least this many uploaded local sets.
# Other local copies require a fresh offsite check before removal; never zero.
# Set equal to RETENTION_KEEP_LAST to retain the former emergency floor.
SPACE_KEEP_LOCAL=1
# Enable deliberately after a successful restore rehearsal. v1 runs are retained.
ENABLE_REMOTE_RETENTION="false"
@@ -31,9 +35,9 @@ RCLONE_TIMEOUT="1h"
RCLONE_CONTIMEOUT="30s"
# Periodic transfer/check statistics in journald, including otherwise quiet uploads.
RCLONE_STATS_INTERVAL="1m"
# Readback policy: full comparisons and individual monthly restore-test reads.
# Retry policy: metadata checks and individual monthly restore-test downloads.
# Independent of upload retries. All times are seconds.
# Deadline includes backend waits; raise it for larger/slower full comparisons.
# Deadline includes backend waits and applies per attempt.
RCLONE_CHECK_ATTEMPTS=3
RCLONE_CHECK_RETRY_DELAY=30
RCLONE_CHECK_TIMEOUT=300
@@ -125,6 +129,8 @@ MAIL_HOME_EXCLUDES=("OneDrive/" ".cache/" ".ccache/" ".local/share/Trash/")
# Additional directories; only files, no automatic DB/container snapshot semantics.
# Keep ordering stable: entries become extra-0, extra-1, ...
# Gemini updater --enable-ai-rem appends /srv/ai-rem and /etc/ai-rem here,
# and adds ai-rem plus its maintenance timers/services to QUIESCE_SERVICES.
EXTRA_PATHS=()
# Restore target directories must already exist. Deletion is off by default.
RESTORE_STRICT_DELETE="false"
+43 -14
View File
@@ -28,6 +28,7 @@ load_config() {
: "${LOG_RETENTION_DAYS:=30}"
: "${MIN_FREE_GB:=10}" "${LOCAL_RETENTION_DAYS:=7}" "${REMOTE_RETENTION_DAYS:=30}"
: "${RETENTION_KEEP_LAST:=3}" "${ENABLE_REMOTE_RETENTION:=false}"
: "${SPACE_KEEP_LOCAL:=1}"
: "${ENABLE_UPLOAD:=true}" "${RCLONE_BIN:=rclone}"
: "${RCLONE_REMOTE_BASE:=}" "${RCLONE_CONFIG:=/root/.config/rclone/rclone.conf}"
: "${RCLONE_RETRIES:=6}" "${RCLONE_LOW_LEVEL_RETRIES:=20}"
@@ -73,12 +74,13 @@ load_config() {
[[ ${!v} =~ ^(0|[1-9][0-9]*)$ ]] || die "$v must be a non-negative integer"
done
(( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2"
[[ $SPACE_KEEP_LOCAL =~ ^[1-9][0-9]{0,5}$ ]] || die 'SPACE_KEEP_LOCAL must be an integer between 1 and 999999'
# 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
[[ ${!v} =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "$v must be a bounded integer"
done
(( RCLONE_CHECK_ATTEMPTS >= 1 && RCLONE_CHECK_ATTEMPTS <= 10 && RCLONE_CHECK_LOW_LEVEL_RETRIES >= 1 && RCLONE_CHECK_LOW_LEVEL_RETRIES <= 20 )) || die 'Invalid readback retry limits'
(( RCLONE_CHECK_TIMEOUT >= 1 && RCLONE_CHECK_TIMEOUT <= 3600 && RCLONE_CHECK_DEADLINE >= 1 && RCLONE_CHECK_DEADLINE <= 86400 && RCLONE_CHECK_RETRY_DELAY <= 3600 )) || die 'Invalid readback time limits'
(( RCLONE_CHECK_ATTEMPTS >= 1 && RCLONE_CHECK_ATTEMPTS <= 10 && RCLONE_CHECK_LOW_LEVEL_RETRIES >= 1 && RCLONE_CHECK_LOW_LEVEL_RETRIES <= 20 )) || die 'Invalid remote check retry limits'
(( RCLONE_CHECK_TIMEOUT >= 1 && RCLONE_CHECK_TIMEOUT <= 3600 && RCLONE_CHECK_DEADLINE >= 1 && RCLONE_CHECK_DEADLINE <= 86400 && RCLONE_CHECK_RETRY_DELAY <= 3600 )) || die 'Invalid remote check time limits'
(( MIN_FREE_GB <= 1000000 )) || die 'MIN_FREE_GB must not exceed 1000000'
(( ZSTD_LEVEL >= 1 && ZSTD_LEVEL <= 19 && ZSTD_THREADS >= 1 )) || die "Invalid zstd settings"
[[ $ARCHIVE_PREFIX =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || die "Unsafe ARCHIVE_PREFIX"
@@ -277,8 +279,7 @@ check_remote() {
for key in ${!RCLONE_CONFIG_@}; do
[[ $key == RCLONE_CONFIG_PASS ]] || die "Remote overrides through $key are unsupported; use the config file"
done
# rclone reads global flags from exported RCLONE_* variables too. For example,
# RCLONE_SIZE_ONLY bypasses byte comparison even with check --download; filters
# rclone reads global flags from exported RCLONE_* variables too. Filters
# can hide missing archives from both copy and check. Refuse such ambient flags
# before the first remote operation, rather than silently weakening verification.
# Allow only our explicit settings (passed as CLI flags) and config decryption.
@@ -308,27 +309,26 @@ check_remote() {
# by callers. NOTICE makes periodic progress visible without verbose HTTP logs.
rc() { "$RCLONE_BIN" "${RCLONE_ARGS[@]}" "$@"; }
remote_readback() {
remote_metadata_check() {
local attempt code=1
# rclone check uses cmd.Run(false, ...): --retries does NOT repeat the whole
# check. A fresh process also refreshes backend/download state after failures.
# Always recheck every file; never turn a partial match report into success.
# check. Retry the complete metadata comparison; never download archive data.
# Content integrity is checked by the monthly restore test.
for ((attempt=1; attempt<=RCLONE_CHECK_ATTEMPTS; attempt++)); do
echo "-- Remote readback attempt $attempt/$RCLONE_CHECK_ATTEMPTS (deadline ${RCLONE_CHECK_DEADLINE}s, idle ${RCLONE_CHECK_TIMEOUT}s)"
echo "-- Remote metadata check attempt $attempt/$RCLONE_CHECK_ATTEMPTS (deadline ${RCLONE_CHECK_DEADLINE}s, idle ${RCLONE_CHECK_TIMEOUT}s)"
# GNU timeout bounds backend pacing/retries as well as stalled I/O. It owns
# a process group so TERM/KILL cannot leave an old check running on retry.
# Multiline stats expose active checks; byte totals alone can say 100% while
# a crypt header is still pending. Combined output names each check result.
# Combined output names missing files and size mismatches.
if timeout --kill-after=10s "${RCLONE_CHECK_DEADLINE}s" \
"$RCLONE_BIN" "${RCLONE_ARGS[@]}" check "$@" --download \
"$RCLONE_BIN" "${RCLONE_ARGS[@]}" check "$@" --size-only \
--timeout "${RCLONE_CHECK_TIMEOUT}s" --low-level-retries "$RCLONE_CHECK_LOW_LEVEL_RETRIES" \
--stats-one-line=false --stats-one-line-date=false --combined -; then
echo "-- Remote readback attempt $attempt passed (all selected files)"
echo "-- Remote metadata check attempt $attempt passed (all selected files)"
return 0
else
code=$?
fi
echo "WARNING: Remote readback attempt $attempt/$RCLONE_CHECK_ATTEMPTS failed (exit $code; 124 means deadline exceeded)"
echo "WARNING: Remote metadata check attempt $attempt/$RCLONE_CHECK_ATTEMPTS failed (exit $code; 124 means deadline exceeded)"
# Preserve cancellation semantics, including a forced kill after the grace
# period. Do not restart a command an operator/service manager has stopped.
if (( code >= 128 )); then return "$code"; fi
@@ -336,10 +336,39 @@ remote_readback() {
sleep "$RCLONE_CHECK_RETRY_DELAY" || return $?
fi
done
echo 'ERROR: Remote readback exhausted; remote contents remain unverified'
echo 'ERROR: Remote metadata check exhausted; remote file presence/sizes remain unverified'
return "$code"
}
# Before reclaiming a local copy, bind the remote sizes to the exact local
# manifest and completion marker. No archive download or temporary disk space.
remote_identity_check() {
local dir=$1 remote=$2 file attempt code size
local -a statuses
need cmp
for file in manifest.json COMPLETE; do
size=$(stat -c %s -- "$dir/$file") || return 1
code=1
for ((attempt=1; attempt<=RCLONE_CHECK_ATTEMPTS; attempt++)); do
if timeout --kill-after=10s "${RCLONE_CHECK_DEADLINE}s" \
"$RCLONE_BIN" "${RCLONE_ARGS[@]}" cat "$remote/$file" --head "$((size + 1))" \
--timeout "${RCLONE_CHECK_TIMEOUT}s" --low-level-retries "$RCLONE_CHECK_LOW_LEVEL_RETRIES" |
cmp -s -- "$dir/$file" -; then
code=0
break
else
statuses=("${PIPESTATUS[@]}")
code=${statuses[0]}
if (( code == 0 )); then code=${statuses[1]}; fi
fi
(( code < 128 )) || return "$code"
echo "WARNING: Remote identity check failed: $file (attempt $attempt/$RCLONE_CHECK_ATTEMPTS)"
if (( attempt < RCLONE_CHECK_ATTEMPTS )); then sleep "$RCLONE_CHECK_RETRY_DELAY" || return $?; fi
done
(( code == 0 )) || return "$code"
done
}
# Exclusions are rooted so a nested directory named data is not accidentally lost.
exclusions() {
EXCLUDES=()
+2 -2
View File
@@ -65,9 +65,9 @@ def deliver(args):
with open(args.log, encoding='utf-8', errors='replace') as stream:
tail = ''.join(deque(stream, maxlen=args.tail_lines))
upload = {'NOT_STARTED': 'Nicht gestartet', 'DISABLED': 'Deaktiviert (nur lokal)',
'COPY': 'Nicht abgeschlossen: Upload', 'CHECK': 'Nicht abgeschlossen: Rückleseprüfung',
'COPY': 'Nicht abgeschlossen: Upload', 'CHECK': 'Nicht abgeschlossen: Dateiprüfung',
'MARKER': 'Nicht abgeschlossen: Abschlussmarker',
'VERIFIED': 'OK (Inhalt und Abschlussmarker zurückgelesen)'}[args.upload_status]
'VERIFIED': 'OK (Dateien vorhanden, Größen und Abschlussmarker geprüft; Inhaltsprüfung beim Restore-Test)'}[args.upload_status]
body = [
'Backup Report', '=============', f'Zeit: {now.isoformat(timespec="seconds")}',
f'Host: {host}', f'Status: {state}', f'Exit code: {args.exit_code}',
+49 -8
View File
@@ -9,6 +9,7 @@ import os
from pathlib import Path
import re
import shutil
import subprocess
import backup_set
@@ -18,12 +19,21 @@ def private(path):
path.stat().st_uid == 0 and not path.stat().st_mode & 0o077)
def mounted_paths():
# st_dev/ismount alone miss same-filesystem bind mounts. Linux mountinfo
# lists file and directory mounts, escaping whitespace and backslashes.
return {Path(re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), line.split()[4]))
for line in Path('/proc/self/mountinfo').read_text().splitlines()}
def remove_tree(path):
if not private(path):
raise ValueError('Refusing non-private/aliased scratch: ' + str(path))
if os.path.ismount(path) or any(p == path or path in p.parents for p in mounted_paths()):
raise ValueError('Refusing mounted scratch: ' + str(path))
device = path.stat().st_dev
for parent, dirs, _ in os.walk(path, followlinks=False):
for name in dirs:
for parent, dirs, files in os.walk(path, followlinks=False):
for name in dirs + files:
child = Path(parent) / name
if not child.is_symlink() and (child.stat().st_dev != device or os.path.ismount(child)):
raise ValueError('Refusing mounted scratch: ' + str(child))
@@ -66,7 +76,9 @@ def clean(work, staging, archives, prefix, restore=None):
print('-- Removed abandoned verification download: ' + path.name, flush=True)
def eligible(archives, prefix, keep):
def uploaded_sets(archives, prefix):
if not private(archives):
raise ValueError('Cleanup requires private canonical archives: ' + str(archives))
valid = []
for path in archives.iterdir():
if not private(path) or not path.name.startswith(prefix+'_'):
@@ -81,19 +93,48 @@ def eligible(archives, prefix, keep):
except (ValueError, OSError, KeyError, TypeError):
continue
valid.append(path.name)
# Emergency pruning may ignore age, NEVER the minimum newest-copy count.
for name in sorted(valid, reverse=True)[keep:][::-1]:
return sorted(valid, reverse=True)
def eligible(archives, prefix, keep, protected=False):
if keep < 1:
raise ValueError('At least one uploaded local set must be retained')
# Pressure policy is independent of normal age/count retention.
valid = uploaded_sets(archives, prefix)
for name in (valid[:keep] if protected else valid[keep:][::-1]):
print(name)
def inventory(archives, prefix, keep):
valid = uploaded_sets(archives, prefix)
protected = set(valid[:keep])
print('-- Remaining local archive inventory (allocated bytes; no deletion):', flush=True)
for path in sorted(archives.iterdir()):
if not private(path):
print(f' {path.name}: protected unknown/aliased/non-private entry', flush=True)
continue
if path.name in protected:
reason = 'protected local minimum'
elif path.name in valid:
reason = 'uploaded; retained (verification/source/device constraints)'
else:
reason = 'protected unconfirmed/incomplete/unknown set'
result = subprocess.run(['du', '-sx', '-B1', '--', str(path)],
text=True, capture_output=True, check=True)
size = int(result.stdout.split()[0])
print(f' {path.name}: {size} bytes; {reason}', flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=('clean', 'eligible', 'remove'))
parser.add_argument('mode', choices=('clean', 'eligible', 'protected', 'inventory', 'remove'))
parser.add_argument('paths', nargs='+')
args = parser.parse_args()
if args.mode == 'clean':
clean(*(Path(p) for p in args.paths[:3]), args.paths[3], Path(args.paths[4]) if len(args.paths) > 4 else None)
elif args.mode == 'eligible':
eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2]))
elif args.mode in ('eligible', 'protected'):
eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2]), args.mode == 'protected')
elif args.mode == 'inventory':
inventory(Path(args.paths[0]), args.paths[1], int(args.paths[2]))
else:
remove_tree(Path(args.paths[0]))
+40 -8
View File
@@ -4,34 +4,66 @@ clean_backup_scratch() {
python3 "$LIB_DIR/space.py" clean "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$RESTORE_ROOT"
}
available_space() {
local value
value=$(df -PB1 -- "$1" | awk 'END{print $4}') || return 1
[[ $value =~ ^(0|[1-9][0-9]{0,17})$ ]] || die "Cannot determine available space on $1"
printf '%s\n' "$value"
}
ensure_space() {
local path=$1 additional=${2:-0} avail name dir candidates
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'
local required=$((MIN_FREE_GB * 1024 * 1024 * 1024 + additional))
avail=$(df -PB1 -- "$path" | awk 'END{print $4}')
avail=$(available_space "$path")
(( avail < required )) || return 0
echo "-- Space recovery: need $required bytes, available $avail on $path"
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
check_remote
candidates=$(python3 "$LIB_DIR/space.py" eligible "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$RETENTION_KEEP_LAST")
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")
[[ -n $protected ]] || die 'No protected local backup; refusing space recovery'
while IFS= read -r name; do
echo "-- Space recovery: validating protected local copy: $name"
if helper verify "$ARCHIVE_DIR/$name" >/dev/null; then continue; else code=$?; fi
(( code < 128 )) || return "$code"
echo "WARNING: Protected local copy is damaged; keeping all candidates: $name"
candidates=''
break
done <<< "$protected"
fi
while IFS= read -r name; do
[[ -n $name ]] || continue
avail=$(available_space "$path")
(( avail < required )) || return 0
dir="$ARCHIVE_DIR/$name"
# A manually selected local restore source is never a cleanup candidate.
[[ -z ${LOCAL_RUN:-} || $(realpath -m "$LOCAL_RUN") != "$dir" ]] || continue
echo "-- Space recovery: verifying offsite copy before local removal: $name"
if helper verify "$dir" >/dev/null &&
remote_readback "$dir" "${RCLONE_REMOTE_BASE%/}/$name" --exclude /UPLOADED; then
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"
echo "-- Space recovery: removed local copy $name; verified offsite copy retained"
echo "-- Space recovery: removed local copy $name; offsite copy with matching file sizes retained"
else
code=$?
(( code < 128 )) || return "$code"
echo "WARNING: Keeping $name: local/offsite verification failed"
fi
avail=$(df -PB1 -- "$path" | awk 'END{print $4}')
(( avail < required )) || return 0
avail=$(available_space "$path")
if (( avail >= required )); then
echo "-- Space recovery complete: available $avail bytes, required $required"
return 0
fi
done <<< "$candidates"
fi
die "Insufficient space after safe cleanup on $path: need $required bytes, available $avail; protected backups/recovery data retained"
avail=$(available_space "$path")
(( avail < required )) || return 0
python3 "$LIB_DIR/space.py" inventory "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$SPACE_KEEP_LOCAL" ||
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."
}
prepare_backup_space() {
+213 -42
View File
@@ -198,6 +198,15 @@ p.write_text(json.dumps(s))
printf '%s\\n' '{name}' >> "$TEST_EVENTS"
if [[ ${{TEST_FAIL_TOOL:-}} == {name} ]]; then exit 42; fi
if [[ {name} == rclone && ${{TEST_FAIL_CHECK:-}} == true && " $* " == *' check '* ]]; then exit 43; fi
if [[ {name} == rclone && ${{TEST_METADATA_ONLY:-}} == true ]]; then
if [[ " $* " == *' --download '* || " $* " == *' cryptcheck '* ||
( " $* " == *' check '* && " $* " != *' --size-only '* ) ||
( " $* " == *' cat '* && " $* " == *'.tar.'* ) ||
" $* " == *' copy crypt:'* || " $* " == *' copyto crypt:'* ]]; then
echo forbidden-archive-read >> "$TEST_EVENTS"
exit 88
fi
fi
exec {real} "$@"
'''
path=self.bin/name
@@ -288,6 +297,8 @@ QUIESCE_SERVICES=(nc-cron)
self.assertEqual(json.loads(self.state.read_text()),self.initial)
def test_emergency_retention_reads_remote_and_preserves_minimum(self):
# Explicitly retain the old floor; default pressure policy is tested below.
with self.config.open('a') as f: f.write('SPACE_KEEP_LOCAL=3\n')
self.crypt_config()
current=self.backup()
archives=current.parent
@@ -302,7 +313,8 @@ QUIESCE_SERVICES=(nc-cron)
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n')
subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'copy',str(path),'crypt:sets/'+name],check=True,capture_output=True)
old.append(path)
# Full remote readback must fail for the oldest copy: retain it.
self.env['TEST_METADATA_ONLY']='true'
# Metadata check must fail for the oldest copy with a missing file: retain it.
subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'deletefile','crypt:sets/'+old[0].name+'/wordpress.tar.zst'],check=True,capture_output=True)
fake=self.bin/'df'
fake.write_text('#!/bin/sh\nprintf "Filesystem 1B-blocks Used Available Use%% Mounted\\nroot 100 100 0 100%% /\\n"\n')
@@ -316,6 +328,106 @@ QUIESCE_SERVICES=(nc-cron)
self.assertIn(old[1].name,remote)
self.assertNotIn('systemctl stop',result.stdout)
def pressure_copies(self):
self.crypt_config()
current = self.backup()
copies = []
for day in range(1, 3):
path = current.parent / f'appbackup_2020-01-0{day}_00-00-00_aaaaaaaa'
shutil.copytree(current, path)
manifest = json.loads((path/'manifest.json').read_text())
manifest['run_id'] = path.name
(path/'manifest.json').write_text(json.dumps(manifest))
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n')
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'copy', str(path), 'crypt:sets/'+path.name], check=True, capture_output=True)
copies.append(path)
self.env['TEST_METADATA_ONLY'] = 'true'
self.events.write_text('')
return current, copies
def fake_pressure(self, oldest, always=False):
# Model allocation pressure, then make reclaimed blocks visible to df.
fake = self.bin/'df'
fake.write_text('#!/usr/bin/env python3\nfrom pathlib import Path\n'
f'available = 0 if {always!r} or Path({str(oldest)!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)
def test_pressure_can_break_three_copy_deadlock_and_stops_when_enough(self):
current, copies = self.pressure_copies()
self.fake_pressure(copies[0])
result = self.run_script('app-backup.sh', '--prepare-space')
self.assertIn('Space recovery complete', result.stdout)
self.assertFalse(copies[0].exists())
self.assertTrue(copies[1].exists())
self.assertTrue(current.exists())
remote = subprocess.check_output(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'lsf', 'crypt:sets', '--dirs-only'], text=True)
self.assertIn(copies[0].name, remote)
self.assertNotIn('systemctl stop', self.events.read_text())
def test_pressure_keeps_last_uploaded_and_unconfirmed_copies(self):
current, copies = self.pressure_copies()
(copies[0]/'UPLOADED').unlink()
self.fake_pressure(copies[0], always=True)
result = self.run_script('app-backup.sh', '--prepare-space', ok=False)
self.assertTrue(current.exists())
self.assertTrue(copies[0].exists())
self.assertFalse(copies[1].exists())
self.assertIn('protected local minimum', result.stdout)
self.assertIn('protected unconfirmed', result.stdout)
self.assertIn('deficit ', result.stdout)
self.assertNotIn('systemctl stop', self.events.read_text())
def test_pressure_rejects_same_size_remote_manifest_and_marker_corruption(self):
current, copies = self.pressure_copies()
for path, file in zip(copies, ('manifest.json', 'COMPLETE')):
original = (path/file).read_bytes()
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
'rcat', 'crypt:sets/'+path.name+'/'+file],
input=b'X'+original[1:], check=True, capture_output=True)
self.fake_pressure(copies[0], always=True)
result = self.run_script('app-backup.sh', '--prepare-space', ok=False)
self.assertTrue(all(p.exists() for p in [current]+copies))
self.assertIn('Remote identity check failed: manifest.json', result.stdout)
self.assertIn('Remote identity check failed: COMPLETE', result.stdout)
def test_pressure_cancellation_does_not_continue_to_next_copy(self):
current, copies = self.pressure_copies()
self.fake_pressure(copies[0], always=True)
wrapper = self.bin/'rclone'
wrapper.write_text('#!/bin/bash\nif [[ " $* " == *" check "* ]]; then exit 143; fi\nexec /usr/bin/rclone "$@"\n')
result = self.run_script('app-backup.sh', '--prepare-space', ok=False)
self.assertEqual(result.returncode, 143, result.stdout)
self.assertTrue(all(p.exists() for p in [current]+copies))
self.assertNotIn('before local removal: '+copies[1].name, result.stdout)
def test_pressure_preserves_all_copies_when_last_protected_copy_is_corrupt(self):
current, copies = self.pressure_copies()
(current/'wordpress.tar.zst').write_bytes(b'damaged')
self.fake_pressure(copies[0], always=True)
result = self.run_script('app-backup.sh', '--prepare-space', ok=False)
self.assertIn('Protected local copy is damaged', result.stdout)
self.assertTrue(all(p.exists() for p in [current]+copies))
def test_pressure_identity_cancellation_is_not_swallowed_by_comparison(self):
current, copies = self.pressure_copies()
self.fake_pressure(copies[0], always=True)
wrapper = self.bin/'rclone'
wrapper.write_text('#!/bin/bash\nif [[ " $* " == *" cat "* ]]; then exit 143; fi\nexec /usr/bin/rclone "$@"\n')
result = self.run_script('app-backup.sh', '--prepare-space', ok=False)
self.assertEqual(result.returncode, 143, result.stdout)
self.assertTrue(all(p.exists() for p in [current]+copies))
def test_pressure_floor_rejects_zero_and_unbounded_values(self):
for value in ('0', '-1', '01', '999999999999999999999', 'bad'):
with self.subTest(value=value):
with self.config.open('a') as f: f.write('SPACE_KEEP_LOCAL='+value+'\n')
result = self.run_script('app-backup.sh', '--check', ok=False)
self.assertIn('SPACE_KEEP_LOCAL must', result.stdout)
def test_dump_and_copy_failures_do_not_publish(self):
for tool in ['mariadb-dump','rsync','zstd']:
with self.subTest(tool=tool):
@@ -655,26 +767,30 @@ else: raise SystemExit('Unexpected Docker command')
docker.chmod(0o700)
canonical=self.configure_upgrade()
original=canonical.read_bytes()
self.env['TEST_METADATA_ONLY']='true'
package=self.build_package()
result=subprocess.run(['bash',str(package),'--foreground','--enable-restore-test'],env=self.env,capture_output=True,text=True,timeout=120)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0])
self.assertIn('SUCCESS',(state/'result.txt').read_text())
self.assertNotIn('forbidden-archive-read',self.events.read_text())
message,body=self.read_report()
self.assertEqual(str(message['From']),'app-backup@example.invalid')
self.assertIn(' SUCCESS gemini.jr-it-services.de ',str(message['Subject']))
self.assertIn('Host: gemini.jr-it-services.de',body.splitlines())
self.assertIn('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text())
self.assertIn('Bereinigung: OK',body)
self.assertIn('[app-restore-test] SUCCESS',message['Subject'])
self.assertIn('Upload Status: OK (Dateien vorhanden',body)
self.assertNotIn('[app-restore-test]',message['Subject'])
self.assertFalse((self.base/'work/restore-test/last-success.json').exists())
self.assertNotIn('Remote-Restore-Pruefung erfolgreich', (state/'result.txt').read_text())
saved=next((self.base/'logs').glob('report_*.eml'))
backup_message=BytesParser(policy=policy.default).parsebytes(saved.read_bytes())
self.assertIn('OK (Inhalt und Abschlussmarker',backup_message.get_content())
self.assertIn('OK (Dateien vorhanden, Größen und Abschlussmarker',backup_message.get_content())
self.assertEqual(Path('/usr/local/lib/app-backup/report.py').read_bytes(),(REPO/'lib/report.py').read_bytes())
self.assertEqual(Path('/usr/local/lib/app-backup/restore_test.py').read_bytes(),(REPO/'lib/restore_test.py').read_bytes())
self.assertTrue(Path('/etc/systemd/system/app-restore-test.timer').is_file())
self.assertEqual(json.loads(self.state.read_text())['services']['app-restore-test.timer'],'active')
self.assertTrue((state/'verified-backup-run').is_file())
self.assertTrue((state/'uploaded-backup-run').is_file())
self.assertIn('ENABLE_REMOTE_RETENTION=false',canonical.read_text())
self.assertIn('RESTORE_TEST_MAX_WORK_GB=0',canonical.read_text())
self.assertTrue(Path(str(self.events)+'.image-prepared').exists())
@@ -685,6 +801,47 @@ else: raise SystemExit('Unexpected Docker command')
self.assertEqual(canonical.read_bytes(),original)
self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'inactive')
def test_package_ai_rem_inclusion_is_idempotent_and_quiesces_jobs(self):
ai_paths=[Path('/srv/ai-rem'),Path('/etc/ai-rem')]
for path in ai_paths:
path.mkdir(parents=True,exist_ok=True)
(path/'snapshot.txt').write_text('ai-rem snapshot '+str(path))
self.addCleanup(shutil.rmtree,path)
units=['ai-rem-healthcheck.timer','ai-rem-backup.timer','ai-rem-prune.timer',
'ai-rem-healthcheck.service','ai-rem-backup.service','ai-rem-prune.service','ai-rem.service']
state=json.loads(self.state.read_text())
for unit in units: state['services'][unit]='active'
self.state.write_text(json.dumps(state))
canonical=self.configure_upgrade()
old_extra=self.src/'extra'
old_extra.mkdir()
(old_extra/'payload.txt').write_text('existing extra')
with canonical.open('a') as f:
f.write(f'EXTRA_PATHS=("{old_extra}")\n')
package=self.build_package()
self.env['TEST_METADATA_ONLY']='true'
for attempt in range(2):
self.events.write_text('')
result=subprocess.run(['bash',str(package),'--foreground','--enable-ai-rem'],
env=self.env,capture_output=True,text=True,timeout=120)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
self.assertNotIn('forbidden-archive-read',self.events.read_text())
status=json.loads((self.base/'work/last-backup.json').read_text())
run=self.base/'work/archives'/status['run']
manifest=json.loads((run/'manifest.json').read_text())
extra=[a for a in manifest['archives'] if a['label'].startswith('extra-')]
self.assertEqual([(a['label'],a['source']) for a in extra],
[('extra-0',str(old_extra)),('extra-1','/srv/ai-rem'),('extra-2','/etc/ai-rem')])
events=self.events.read_text()
for unit in units:
self.assertEqual(events.count('systemctl stop '+unit+' '),1)
self.assertLess(events.index('systemctl stop '+unit+' '),events.index('mariadb-dump'))
self.assertEqual(json.loads(self.state.read_text())['services'][unit],'active')
for unit in units[:-1]:
self.assertLess(events.index('systemctl stop '+unit+' '),events.index('systemctl stop ai-rem.service '))
self.assertLess(events.index('systemctl start ai-rem.service'),events.index('systemctl start ai-rem-healthcheck.timer'))
self.assertFalse((self.base/'work/restore-test/last-success.json').exists())
def test_package_preflight_failure_preserves_live_configuration_and_timer(self):
canonical=self.configure_upgrade()
original=canonical.read_bytes()
@@ -832,7 +989,7 @@ else: raise SystemExit('Unexpected Docker command')
self.assertIn('FAILED',(state/'result.txt').read_text())
self.assertTrue((state/'rollback-update.sh').is_file())
self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'inactive')
self.assertFalse((state/'verified-backup-run').exists())
self.assertFalse((state/'uploaded-backup-run').exists())
rollback=subprocess.run(['bash',str(state/'rollback-update.sh')],env=self.env,capture_output=True,text=True,timeout=30)
self.assertEqual(rollback.returncode,0,rollback.stdout+rollback.stderr)
self.assertEqual(canonical.read_bytes(),original)
@@ -1123,13 +1280,13 @@ REPORT_SECRET='config-content-must-not-enter-the-mail'
self.assertIn('FAILED (exit 42)',body)
self.assertEqual(json.loads(self.state.read_text()),self.initial)
def test_report_upload_readback_failure_is_not_reported_as_ok(self):
def test_report_upload_metadata_check_failure_is_not_reported_as_ok(self):
self.enable_report()
self.crypt_config()
self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true')
_,body=self.read_report()
self.assertIn('Status: FAIL',body)
self.assertIn('Upload Status: Nicht abgeschlossen: Rückleseprüfung',body)
self.assertIn('Upload Status: Nicht abgeschlossen: Dateiprüfung',body)
self.assertNotIn('Upload Status: OK',body)
def test_report_mta_failure_keeps_backup_result_and_local_message(self):
@@ -1162,7 +1319,7 @@ REPORT_SECRET='config-content-must-not-enter-the-mail'
self.run_script('app-restore.sh','--local-run',run,'--verify-only')
self.assertFalse((self.base/'mail.eml').exists())
def test_real_crypt_upload_readback_remote_restore(self):
def test_real_crypt_upload_metadata_check_remote_restore(self):
self.crypt_config()
# Exercise periodic stderr statistics during a real throttled crypt
# transfer; they must not contaminate captured COMPLETE-marker stdout.
@@ -1175,10 +1332,10 @@ REPORT_SECRET='config-content-must-not-enter-the-mail'
log=next((self.base/'logs').glob('backup_*.log')).read_text()
self.assertIn('NOTICE:',log)
self.assertIn('Remote upload started',log)
self.assertIn('Remote readback passed',log)
self.assertIn('Remote metadata check passed',log)
self.run_script('app-restore.sh','--remote-run',run.name,'--verify-only')
def test_failed_readback_does_not_publish_remote_marker(self):
def test_failed_metadata_check_does_not_publish_remote_marker(self):
self.crypt_config()
result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true')
self.assertEqual(result.returncode,43)
@@ -1190,7 +1347,7 @@ REPORT_SECRET='config-content-must-not-enter-the-mail'
proc=subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'cat',f'crypt:sets/{run.name}/COMPLETE'],capture_output=True)
self.assertNotEqual(proc.returncode,0)
def inject_readback_failure(self, mode):
def inject_metadata_check_failure(self, mode):
# The first check fails/stalls before real crypt I/O. Subsequent checks
# use real rclone; event order proves COMPLETE was withheld until success.
wrapper=self.bin/'rclone'
@@ -1199,9 +1356,9 @@ import os, pathlib, subprocess, sys, time
args=sys.argv[1:]
events=pathlib.Path(os.environ['TEST_EVENTS'])
if 'check' in args:
with events.open('a') as log: log.write('readback-check\\n')
count=events.read_text().count('readback-check')
mode=os.environ['TEST_READBACK_MODE']
with events.open('a') as log: log.write('metadata_check-check\\n')
count=events.read_text().count('metadata_check-check')
mode=os.environ['TEST_METADATA_CHECK_MODE']
if mode=='signal': sys.exit(143)
if mode=='hang' or (mode=='hang-once' and count==1): time.sleep(30)
if mode=='fail-once' and count==1:
@@ -1212,23 +1369,23 @@ if 'copyto' in args:
sys.exit(subprocess.call(['/usr/bin/rclone',*args]))
''')
wrapper.chmod(0o755)
self.env['TEST_READBACK_MODE']=mode
self.env['TEST_METADATA_CHECK_MODE']=mode
def test_transient_readback_error_rechecks_before_publishing(self):
def test_transient_metadata_check_error_rechecks_before_publishing(self):
self.crypt_config()
self.inject_readback_failure('fail-once')
self.inject_metadata_check_failure('fail-once')
result=self.run_script('app-backup.sh')
self.assertIn('attempt 2 passed',result.stdout)
events=self.events.read_text()
self.assertEqual(events.count('readback-check'),2)
self.assertLess(events.rindex('readback-check'),events.index('publish-marker'))
self.assertEqual(events.count('metadata_check-check'),2)
self.assertLess(events.rindex('metadata_check-check'),events.index('publish-marker'))
run=next((self.base/'work/archives').glob('appbackup_*'))
self.assertTrue((run/'UPLOADED').exists())
self.run_script('app-restore.sh','--remote-run',run.name,'--verify-only')
def test_stalled_readback_is_terminated_then_retried(self):
def test_stalled_metadata_check_is_terminated_then_retried(self):
self.crypt_config()
self.inject_readback_failure('hang-once')
self.inject_metadata_check_failure('hang-once')
with self.config.open('a') as f: f.write('RCLONE_CHECK_DEADLINE=2\n')
result=self.run_script('app-backup.sh')
self.assertIn('exit 124',result.stdout)
@@ -1236,24 +1393,24 @@ sys.exit(subprocess.call(['/usr/bin/rclone',*args]))
def test_persistent_stall_is_bounded_and_preserves_local_backup(self):
self.crypt_config()
self.inject_readback_failure('hang')
self.inject_metadata_check_failure('hang')
with self.config.open('a') as f: f.write('RCLONE_CHECK_DEADLINE=1\nRCLONE_CHECK_ATTEMPTS=2\n')
started=time.monotonic()
result=self.run_script('app-backup.sh',ok=False)
self.assertLess(time.monotonic()-started,20)
self.assertEqual(result.returncode,124)
self.assertEqual(self.events.read_text().count('readback-check'),2)
self.assertEqual(self.events.read_text().count('metadata_check-check'),2)
self.assert_remote_failure_retained(result)
def test_cancelled_readback_is_not_retried(self):
def test_cancelled_metadata_check_is_not_retried(self):
self.crypt_config()
self.inject_readback_failure('signal')
self.inject_metadata_check_failure('signal')
result=self.run_script('app-backup.sh',ok=False)
self.assertEqual(result.returncode,143)
self.assertEqual(self.events.read_text().count('readback-check'),1)
self.assertEqual(self.events.read_text().count('metadata_check-check'),1)
self.assert_remote_failure_retained(result)
def test_readback_limits_reject_unbounded_or_invalid_values(self):
def test_metadata_check_limits_reject_unbounded_or_invalid_values(self):
for key,value in [('RCLONE_CHECK_ATTEMPTS','0'),('RCLONE_CHECK_ATTEMPTS','11'),
('RCLONE_CHECK_DEADLINE','0'),('RCLONE_CHECK_DEADLINE','1h'),
('RCLONE_CHECK_TIMEOUT','0'),('RCLONE_CHECK_LOW_LEVEL_RETRIES','0'),
@@ -1267,14 +1424,14 @@ sys.exit(subprocess.call(['/usr/bin/rclone',*args]))
finally:
self.config.write_text(original)
def test_invalid_crypt_header_stays_failed_after_retries(self):
def test_invalid_crypt_header_is_deferred_to_restore(self):
self.crypt_config()
result=self.remote_failure('bad-header')
self.assertIn('bad magic string',result.stdout)
self.assertIn('attempt 3/3 failed',result.stdout)
self.assert_remote_failure_retained(result)
self.remote_failure('bad-header',ok=True)
run=next((self.base/'work/archives').glob('appbackup_*'))
self.assertTrue((run/'UPLOADED').exists())
self.run_script('app-restore.sh','--remote-run',run.name,'--verify-only',ok=False)
def remote_failure(self, mode, **env):
def remote_failure(self, mode, ok=False, **env):
# Keep real crypt transfers. Inject errors at the command boundary, not
# a pretend OneDrive server: provider retry behaviour is checked separately.
wrapper = self.bin/'rclone'
@@ -1300,6 +1457,11 @@ if 'copy' in args:
if mode=='upload':
# Models an unsuccessful command even though the remote received bytes.
sys.exit(44)
if mode in ('missing','wrong-size'):
config=args[args.index('--config')+1]
remote=args[args.index('copy')+2]+'/wordpress.tar.zst'
if mode=='missing': rc(['--config',config,'deletefile',remote],check=True)
else: rc(['--config',config,'rcat',remote],input=b'too short',check=True)
if mode=='bad-header':
# Damage the real encrypted header in place without changing object size.
from pathlib import Path
@@ -1314,11 +1476,11 @@ if 'copy' in args:
data=bytearray(rc([*prefix,'cat',remote],capture_output=True,check=True).stdout)
data[len(data)//2]^=1
# Same-size plaintext corruption, re-encrypted through the real crypt backend.
# The backup must read contents back, not accept a matching size alone.
# Daily metadata checks accept this; the restore checksum must reject it.
rc([*prefix,'rcat',remote],input=bytes(data),check=True)
''')
wrapper.chmod(0o755)
return self.run_script('app-backup.sh',ok=False,TEST_REMOTE_FAILURE=mode,**env)
return self.run_script('app-backup.sh',ok=ok,TEST_REMOTE_FAILURE=mode,**env)
def assert_remote_failure_retained(self, result, marker_present=False):
status=json.loads((self.base/'work/last-backup.json').read_text())
@@ -1363,13 +1525,22 @@ if 'copy' in args:
self.assertTrue(path.is_dir())
self.assertIn(path.name,remote)
def test_same_size_remote_corruption_blocks_completion(self):
def test_same_size_remote_corruption_is_deferred_to_restore(self):
self.crypt_config()
result=self.remote_failure('corrupt')
self.assert_remote_failure_retained(result)
self.assertFalse((self.base/'work/last-successful-backup.json').exists())
self.remote_failure('corrupt',ok=True)
run=next((self.base/'work/archives').glob('appbackup_*'))
self.assertTrue((run/'UPLOADED').exists())
self.run_script('app-restore.sh','--remote-run',run.name,'--verify-only',ok=False)
def test_environment_cannot_weaken_remote_readback(self):
def test_missing_remote_archive_blocks_completion(self):
self.crypt_config()
self.assert_remote_failure_retained(self.remote_failure('missing'))
def test_wrong_size_remote_archive_blocks_completion(self):
self.crypt_config()
self.assert_remote_failure_retained(self.remote_failure('wrong-size'))
def test_environment_cannot_weaken_remote_metadata_check(self):
self.crypt_config()
result=self.remote_failure('corrupt',RCLONE_SIZE_ONLY='true')
self.assertIn('Unsupported rclone environment override: RCLONE_SIZE_ONLY',result.stdout)
@@ -1397,7 +1568,7 @@ if 'copy' in args:
def test_marker_read_mismatch_blocks_upload_success(self):
self.crypt_config()
result=self.remote_failure('marker-read')
# Archives passed readback and COMPLETE was written, but its final
# Archives passed metadata_check and COMPLETE was written, but its final
# confirmation failed. Local success and retention must remain blocked.
self.assert_remote_failure_retained(result,marker_present=True)
self.assertIn('Remote completion marker mismatch',result.stdout)
+21
View File
@@ -149,6 +149,27 @@ MAIL_SENDMAIL_BIN="{self.sendmail}"
self.assertIn('stale',self.state()['error'])
self.clean()
def test_same_size_corruption_is_detected_by_monthly_restore(self):
path=self.folder/'wordpress.tar.gz'
data=bytearray(path.read_bytes())
data[len(data)//2]^=1
path.write_bytes(data)
self.upload()
result=self.execute(ok=False)
self.assertIn('Archive checksum/size mismatch',result)
self.assertFalse((self.root/'last-success.json').exists())
self.assertFalse((self.root/'scratch').exists())
def test_extra_ai_rem_files_are_downloaded_and_extracted(self):
self.make_archive('extra-0','files/extra-0',b'ai-rem database snapshot')
self.make_archive('extra-1','files/extra-1',b'ai-rem configuration')
self.manifest()
self.upload()
self.execute()
state=json.loads((self.root/'state.json').read_text())
for label in ('extra-0','extra-1'):
self.assertTrue(any(c['label']==label and c['status']=='SUCCESS' for c in state['components']))
def test_corrupt_archive_is_not_success_and_cleans(self):
(self.folder/'wordpress.tar.gz').write_bytes(b'corrupt')
self.upload()
+73
View File
@@ -4,6 +4,7 @@ import json
import os
from pathlib import Path
import sys
import subprocess
import tempfile
import unittest
from unittest.mock import patch
@@ -12,6 +13,55 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'lib'))
import space
class RemoteIdentityRetry(unittest.TestCase):
def check(self, mode):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for file in ('manifest.json', 'COMPLETE'):
(root/file).write_bytes(b'exact bytes\n')
remote = root/'rclone'
remote.write_text('''#!/usr/bin/env python3
import os, sys, time
from pathlib import Path
root = Path(__file__).parent
file = Path(sys.argv[sys.argv.index('cat')+1]).name
counter = root/(file+'.attempts')
attempt = int(counter.read_text())+1 if counter.exists() else 1
counter.write_text(str(attempt))
if os.environ['TEST_MODE'] == 'deadline' and attempt == 1:
time.sleep(20)
sys.stdout.buffer.write((root/file).read_bytes())
sys.stdout.buffer.flush()
if os.environ['TEST_MODE'] == 'late_failure':
sys.exit(7)
''')
remote.chmod(0o700)
result = subprocess.run(['bash', '-c', '''
source "$1/lib/common.sh"
RCLONE_BIN=$2/rclone
RCLONE_ARGS=()
RCLONE_CHECK_ATTEMPTS=2
RCLONE_CHECK_RETRY_DELAY=0
RCLONE_CHECK_DEADLINE=1
RCLONE_CHECK_TIMEOUT=1
RCLONE_CHECK_LOW_LEVEL_RETRIES=1
remote_identity_check "$2" remote:run
''', 'test', str(Path(__file__).resolve().parents[1]), str(root)],
env=dict(os.environ, TEST_MODE=mode), text=True, capture_output=True, timeout=8)
attempts = {p.name: int(p.read_text()) for p in root.glob('*.attempts')}
return result, attempts
def test_identity_deadline_retries_complete_files(self):
result, attempts = self.check('deadline')
self.assertEqual(result.returncode, 0, result.stdout+result.stderr)
self.assertEqual(attempts, {'manifest.json.attempts': 2, 'COMPLETE.attempts': 2})
def test_matching_bytes_with_failed_remote_command_are_not_success(self):
result, attempts = self.check('late_failure')
self.assertEqual(result.returncode, 7, result.stdout+result.stderr)
self.assertEqual(attempts, {'manifest.json.attempts': 2})
@unittest.skipUnless(os.geteuid() == 0, 'root-only cleanup policy')
class SpaceRecovery(unittest.TestCase):
def setUp(self):
@@ -70,6 +120,29 @@ class SpaceRecovery(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'mounted'): self.clean()
self.assertTrue(mount.exists())
def test_root_mount_is_never_removed(self):
with patch('space.os.path.ismount', side_effect=lambda p: p == self.partial):
with self.assertRaisesRegex(ValueError, 'mounted'): self.clean()
self.assertTrue(self.partial.exists())
self.assertTrue(self.stage.exists())
def test_file_bind_mount_is_never_removed(self):
mount = self.partial/'meta.tar.zst'
with patch('space.os.path.ismount', side_effect=lambda p: p == mount):
with self.assertRaisesRegex(ValueError, 'mounted'): self.clean()
self.assertTrue(mount.exists())
def test_same_device_bind_mount_from_mountinfo_is_never_removed(self):
mount = self.partial/'meta.tar.zst'
with patch('space.mounted_paths', return_value={mount}), patch('space.os.path.ismount', return_value=False):
with self.assertRaisesRegex(ValueError, 'mounted'): self.clean()
self.assertTrue(mount.exists())
def test_mountinfo_decodes_escaped_mount_paths(self):
text = '42 1 0:9 / /tmp/backup\\040space/file\\134name rw - ext4 /dev/example rw\n'
with patch('space.Path.read_text', return_value=text):
self.assertEqual(space.mounted_paths(), {Path('/tmp/backup space/file\\name')})
def test_verification_download_recovered_manual_restore_kept(self):
verify = self.restore/(self.run+'.abcdef')
verify.mkdir(mode=0o700)
+1 -1
View File
@@ -50,7 +50,7 @@ def build(output, version):
set -Eeuo pipefail
umask 077
if [[ ${{1:-}} == --help ]]; then
echo 'Usage: sudo bash app-backup-update-{version}.run [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps] [--enable-restore-test]'
echo 'Usage: sudo bash app-backup-update-{version}.run [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps] [--enable-restore-test] [--enable-ai-rem]'
exit 0
fi
[[ $EUID == 0 ]] || {{ echo 'Bitte mit sudo bash starten.' >&2; exit 1; }}
+43 -9
View File
@@ -20,11 +20,13 @@ UP_MAIL_HOMES=false
UP_STATE=''
UP_WORKER=false
UP_RESTORE_TEST=false
UP_AI_REM=false
while (( $# )); do
case $1 in
--config) CONFIG_FILE=${2:?Missing configuration}; shift 2 ;;
--foreground) UP_FOREGROUND=true; shift ;;
--install-deps) UP_INSTALL_DEPS=true; shift ;;
--enable-ai-rem) UP_AI_REM=true; shift ;;
--enable-restore-test) UP_RESTORE_TEST=true; shift ;;
--enable-gitea) UP_GITEA=true; shift ;;
--no-gitea) UP_GITEA=false; shift ;;
@@ -33,7 +35,7 @@ while (( $# )); do
--mail-dir) UP_MAIL_DIR=${2:?Missing mailbox directory}; shift 2 ;;
--mail-homes-only) UP_MAIL_HOMES=true; shift ;;
--worker) UP_WORKER=true; UP_STATE=${2:?Missing state}; shift 2 ;;
--help) echo 'Usage: sudo bash update.sh [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps] [--enable-restore-test]'; exit 0 ;;
--help) echo 'Usage: sudo bash update.sh [--foreground] [--config FILE] [--rclone-config FILE] [--enable-gitea | --no-gitea] [--cron-services UNIT,...] [--mail-dir DIR | --mail-homes-only] [--install-deps] [--enable-restore-test] [--enable-ai-rem]'; exit 0 ;;
*) die "Unknown argument: $1" ;;
esac
done
@@ -200,6 +202,40 @@ if [[ $UP_WORKER == false ]]; then
append_setting RCLONE_CONFIG "$RCLONE_CONFIG"
printf '%s\n' "$RCLONE_CONFIG" > "$UP_STATE/rclone-path"
if [[ $UP_AI_REM == true ]]; then
# Gemini's ai-rem is managed by Compose through ai-rem.service. Stop its
# schedulers and their jobs before the container; resume in reverse order.
# Append paths without reordering existing extra-N archive/restore labels.
for path in /srv/ai-rem /etc/ai-rem; do
check_source_path "$path"
found=false
for existing in "${EXTRA_PATHS[@]}"; do
if [[ $(realpath -m -- "$existing") == "$path" ]]; then found=true; break; fi
done
if [[ $found == false ]]; then EXTRA_PATHS+=("$path"); fi
done
{
printf 'EXTRA_PATHS=('
printf '%q ' "${EXTRA_PATHS[@]}"
printf ')\n'
} >> "$UP_STATE/candidate.conf"
# Repeated activation also restores the required stop order.
ai_rem_units=(ai-rem-healthcheck.timer ai-rem-backup.timer ai-rem-prune.timer
ai-rem-healthcheck.service ai-rem-backup.service ai-rem-prune.service ai-rem.service)
remaining=()
for unit in "${QUIESCE_SERVICES[@]}"; do
case ${unit%.service} in
ai-rem|ai-rem-healthcheck|ai-rem-backup|ai-rem-prune|ai-rem-healthcheck.timer|ai-rem-backup.timer|ai-rem-prune.timer) ;;
*) remaining+=("$unit") ;;
esac
done
for unit in "${ai_rem_units[@]}"; do
[[ $(systemctl show "$unit" --property=LoadState --value) == loaded ]] || die "Missing ai-rem unit: $unit"
done
QUIESCE_SERVICES=("${ai_rem_units[@]}" "${remaining[@]}")
echo 'ai-rem included: /srv/ai-rem and /etc/ai-rem; container and maintenance jobs stopped during snapshot'
fi
if [[ -n $UP_CRON ]]; then IFS=, read -r -a additional <<< "$UP_CRON"; QUIESCE_SERVICES+=("${additional[@]}"); fi
if [[ $ENABLE_NEXTCLOUD == true ]]; then
discover cron-units "$NC_DIR" > "$UP_STATE/cron-units"
@@ -263,7 +299,7 @@ if [[ $UP_WORKER == false ]]; then
fi
sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256"
echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")"
echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.'
echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Dateipruefung.'
printf 'QUEUED\n' > "$UP_STATE/result.txt"
if [[ $UP_FOREGROUND == true ]]; then
flock -u 9; flock -u 8
@@ -311,7 +347,7 @@ worker_finish() {
trap - EXIT INT TERM
set +e
if [[ $UP_OK == true && $code == 0 ]]; then
printf 'SUCCESS\nPaket aktualisiert, Backup und Remote-Restore-Pruefung erfolgreich.\nBackup-Timer aktiv.\n' > "$UP_STATE/result.txt"
printf 'SUCCESS\nPaket aktualisiert, Backup und Remote-Dateipruefung erfolgreich.\nArchiv-Inhaltspruefung erfolgt beim monatlichen Restore-Test.\nBackup-Timer aktiv.\n' > "$UP_STATE/result.txt"
else
if [[ $UP_CHANGED == true ]]; then systemctl stop app-backup.timer app-restore-test.timer; fi
printf 'FAILED (exit %s)\nDetails: %s/update.log\n' "$code" "$UP_STATE" > "$UP_STATE/result.txt"
@@ -390,12 +426,10 @@ assert value['exit_code'] == 0
print(value['run'])
PY
)
if [[ $ENABLE_RESTORE_TEST == true ]]; then
bash /usr/local/sbin/app-restore-test.sh --config "$CONFIG_FILE" --remote-run "$backup_run"
else
bash /usr/local/sbin/app-restore.sh --config "$CONFIG_FILE" --remote-run "$backup_run" --verify-only
fi
printf '%s\n' "$backup_run" > "$UP_STATE/verified-backup-run"
# The backup already confirmed remote file presence, sizes and COMPLETE.
# Archive downloads belong to the monthly rehearsal, not update acceptance.
printf '%s\n' "$backup_run" > "$UP_STATE/uploaded-backup-run"
echo '-- Update acceptance: upload and remote metadata confirmed; archive restore deferred to monthly test'
# A missed persistent timer can fire immediately, so release the shared lock first.
unset APP_BACKUP_INHERIT_LOCK
flock -u 9
+6 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# A backup is published only after every source, archive and remote readback passed.
# A backup is published only after every source, archive and remote metadata check passed.
set -Eeuo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
LIB_DIR="$SCRIPT_DIR/../lib/app-backup"
@@ -118,10 +118,10 @@ if [[ $ENABLE_UPLOAD == true ]]; then
echo '-- Remote upload started (archives and manifest; completion marker withheld)'
BACKUP_PHASE='Remote-Upload'; UPLOAD_STATUS=COPY
rc copy "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED --immutable
echo '-- Remote upload finished; reading all uploaded files back for verification'
BACKUP_PHASE='Remote-Rückleseprüfung'; UPLOAD_STATUS=CHECK
remote_readback "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED
echo '-- Remote readback passed; publishing completion marker'
echo '-- Remote upload finished; checking uploaded file presence and sizes (no archive download)'
BACKUP_PHASE='Remote-Dateiprüfung'; UPLOAD_STATUS=CHECK
remote_metadata_check "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED
echo '-- Remote metadata check passed; publishing completion marker'
BACKUP_PHASE='Remote-Abschlussmarker'; UPLOAD_STATUS=MARKER
rc copyto "$RUN_DIR/COMPLETE" "$remote_run/COMPLETE" --immutable
# Command substitution strips trailing newlines, which could accept a marker
@@ -136,7 +136,7 @@ PY
date -u -Is > "$RUN_DIR/UPLOADED"
sync -f "$RUN_DIR"
UPLOAD_STATUS=VERIFIED
echo '-- Remote backup fully read back and verified'
echo '-- Remote upload confirmed (file presence, sizes and completion marker; content verification deferred to restore test)'
fi
if [[ $SKIP_RETENTION == true ]]; then