Recover disk space safely and complete metadata-only backup updates
This commit is contained in:
1 parent
04283a607c
commit
a5a7e38853
17 files changed
+827
-209
No files matched your search
+48
-38
@@ -1,10 +1,17 @@
|
|||||||
# Dokumentation app-backup v2
|
# Dokumentation app-backup v2
|
||||||
|
|
||||||
**Aktualisiert am 15. September 2026:** Das Code-Paket `3730ab4676a5` enthält
|
**Aktualisiert am 21. September 2026:** Die Platzbereinigung schützt mit
|
||||||
den lokal geprüften Schutz laufender Writer-Stopps gegen konkurrierende
|
`SPACE_KEEP_LOCAL=1` mindestens eine vollständig geprüfte, als hochgeladen markierte
|
||||||
Healthcheck-Aufträge. Code und Paket sind bereitgestellt; Installation und neue
|
lokale Sicherung; reguläre Retention und Speicherreserve bleiben separat bestehen.
|
||||||
Abnahme auf Gemini sind noch nicht bestätigt. Aktuelle Deployment-Befehle und
|
Manifest und Abschlussmarker werden vor lokaler Entfernung bytegenau remote verglichen.
|
||||||
Erfolgskriterien stehen in der [Update-Anleitung](UPDATE_ANLEITUNG.md#deployment-des-bereitgestellten-pakets-3730ab4676a5).
|
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.
|
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. |
|
| **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:** 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:** 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:** `--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 Offsite-Inhaltsprüfung und Mindestanzahl. |
|
| **Hoch:** Retention lief vor dem neuen Backup und löschte remote einzelne alte Objekte rekursiv. | Alte Wiederherstellungspunkte konnten verloren gehen, bevor ein neuer verfügbar war; Sätze konnten unvollständig werden. | Reguläre Retention nach einem neuen erfolgreichen Lauf; zusätzliche lokale Platzbereinigung nur mit erneuter 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:** 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. |
|
| **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. |
|
| **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:** 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. |
|
| **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
|
## 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:
|
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.
|
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.
|
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.
|
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.
|
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]
|
E --> F[Je Komponente komprimieren und Tar-Inhalt prüfen]
|
||||||
F --> G[Manifest und lokaler COMPLETE-Marker]
|
F --> G[Manifest und lokaler COMPLETE-Marker]
|
||||||
G --> H[Archive und Manifest über Crypt hochladen]
|
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]
|
I --> J[COMPLETE zuletzt hochladen und zurücklesen]
|
||||||
J --> K[Retention vollständiger Sätze / Status / Bericht]
|
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.
|
auswertbar. Alte Konfigurationen erhalten den Intervallstandard automatisch.
|
||||||
Eine Anpassung wirkt erst beim nächsten rclone-Prozess. Die bestehende
|
Eine Anpassung wirkt erst beim nächsten rclone-Prozess. Die bestehende
|
||||||
I/O-Leerlauffrist `RCLONE_TIMEOUT="1h"` ist keine Gesamtlaufzeitbegrenzung.
|
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.
|
Leerlauffristen, eine Prozessfrist je Versuch und mehrere vollständige Versuche.
|
||||||
Dort zeigen mehrzeilige Statistiken aktive Prüfungen und ein Ergebnis pro Datei
|
Dort zeigen mehrzeilige Statistiken aktive Prüfungen und ein Ergebnis pro Datei
|
||||||
den tatsächlichen Fortschritt. Details: [Rückleseprüfung](REMOTE_READBACK.md).
|
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
|
manifest.json
|
||||||
COMPLETE
|
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 |
|
| 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.
|
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:
|
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:
|
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
|
Exportierte `RCLONE_*`-Variablen sind ausschließlich für die vom Paket ausdrücklich
|
||||||
unterstützten Einstellungen und `RCLONE_CONFIG_PASS` erlaubt. Andere Optionen
|
unterstützten Einstellungen und `RCLONE_CONFIG_PASS` erlaubt. Andere Optionen
|
||||||
werden vor dem ersten rclone-Aufruf und vor dem Writer-Stopp mit ihrem Namen,
|
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
|
ohne ihren Wert, abgewiesen. Auch `RCLONE_SIZE_ONLY` bleibt als globale
|
||||||
auch bei `check --download` den Inhaltsvergleich überspringen. Ein Regressionstest
|
Umgebungsvariable verboten: Der Größenvergleich wird ausschließlich am Check-Aufruf
|
||||||
hat diesen Fehler vor der Härtung mit einer veränderten Remote-Datei gleicher Größe
|
gesetzt. Zusätzliche Filter, Dry-run- und TLS-Overrides aus der Umgebung werden
|
||||||
reproduziert. Zusätzliche Filter, Dry-run- und TLS-Overrides aus der Umgebung werden
|
|
||||||
ebenfalls abgewiesen. Einstellungen über die dokumentierte Backup-Konfiguration
|
ebenfalls abgewiesen. Einstellungen über die dokumentierte Backup-Konfiguration
|
||||||
und Remote-Definitionen verwalten; eine absichtlich manipulierte root-eigene
|
und Remote-Definitionen verwalten; eine absichtlich manipulierte root-eigene
|
||||||
Bash-Konfiguration liegt außerhalb dieser Absicherung.
|
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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
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.
|
serverseitiger WORM-/Ransomware-Schutz gegen andere berechtigte Zugriffe.
|
||||||
|
|
||||||
Die sechs Gesamtversuche gelten nicht pauschal für jeden rclone-Befehl:
|
Die sechs Gesamtversuche gelten nicht pauschal für jeden rclone-Befehl:
|
||||||
`check --download` verwendet in rclone 1.74.3 keine Wiederholung des ganzen Befehls,
|
`check` verwendet in rclone 1.74.3 keine Wiederholung des ganzen Befehls. Deshalb
|
||||||
jedoch Wiederholungen fehlgeschlagener Datei-Leseoperationen. Deshalb verwendet
|
verwendet app-backup einen eigenen begrenzten Wrapper für Remote-Datei-/
|
||||||
app-backup jetzt einen eigenen begrenzten Wrapper nur für vollständige
|
Größenvergleiche (Backup und Platzbereinigung). Er greift auch mit alten
|
||||||
Rücklesevergleiche (Backup und Platzbereinigung). Er greift auch mit alten
|
|
||||||
Konfigurationen. Uploads und manuelle `app-restore.sh`-Downloads behalten ihre
|
Konfigurationen. Uploads und manuelle `app-restore.sh`-Downloads behalten ihre
|
||||||
bisherigen Einstellungen. Der separate monatliche Restore-Test verwendet seit
|
bisherigen Einstellungen. Der separate monatliche Restore-Test verwendet seit
|
||||||
dem Folgefix ebenfalls begrenzte Downloads; siehe [RESTORE_TEST.md](RESTORE_TEST.md).
|
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.
|
frei. Erst nach erfolgreicher Platzbeschaffung werden Writer angehalten.
|
||||||
|
|
||||||
Reicht Scratch-Bereinigung nicht, werden ältere geeignete lokale Sätze unter
|
Reicht Scratch-Bereinigung nicht, werden ältere geeignete lokale Sätze 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
|
Nur danach darf ihre lokale Kopie entfernt werden. Offsite-Dateien bleiben
|
||||||
unverändert. Diese Platzbereinigung gilt auch bei `--no-retention` während einer
|
unverändert. Diese Platzbereinigung gilt auch bei `--no-retention` während einer
|
||||||
Update-Abnahme und kann das lokale Aufbewahrungsalter unterschreiten.
|
Update-Abnahme und kann das lokale Aufbewahrungsalter unterschreiten.
|
||||||
@@ -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
|
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
|
Phase, Konfigurations-/Arbeits-/Archivpfade, Kompression, Crypt-Ziel und getrennten
|
||||||
Uploadstatus. Dieser wird erst nach erfolgreichem Rücklesen aller Inhalte und
|
Uploadstatus. Dieser wird erst nach erfolgreicher Remote-Datei-/Größenprüfung und
|
||||||
bytegenauer Markerbestätigung `OK`. Bei deaktiviertem Upload steht ausdrücklich
|
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.
|
„nur lokal“; bei Fehlern bleibt die nicht abgeschlossene Uploadphase sichtbar.
|
||||||
Ein Fehler bei nachgelagerter Retention kann deshalb korrekt `Status: FAIL` bei
|
Ein Fehler bei nachgelagerter Retention kann deshalb korrekt `Status: FAIL` bei
|
||||||
bereits verifiziertem Upload ergeben.
|
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.
|
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.
|
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.
|
Sperre wartete. Ursache, Diagnose und Wiederanlauf beschreibt die Update-Anleitung.
|
||||||
|
|
||||||
Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keine reguläre Altersretention;
|
Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keine reguläre Altersretention;
|
||||||
die sichere lokale Platzbereinigung bleibt erlaubt. Nach Upload/Rücklesevergleich
|
die lokale Platzbereinigung bleibt erlaubt. Nach Upload, Remote-Datei-/
|
||||||
prüft bei aktiviertem Monatstest `app-restore-test.sh --remote-run ...` den konkreten
|
Größenprüfung und Markerbestätigung werden die konfigurierten Timer aktiviert.
|
||||||
Satz vollständig mit Entpacken und isoliertem DB-Import. Andernfalls erfolgt eine
|
Die Update-Abnahme startet keinen Archivdownload und keinen Offsite-Restore.
|
||||||
komponentenweise Archivprüfung mit `app-restore.sh --verify-only`. Erst nach Erfolg
|
Entpacken und isolierter DB-Import finden beim Monatslauf statt. Remote-Retention bleibt
|
||||||
werden die konfigurierten Timer aktiviert. Remote-Retention bleibt
|
|
||||||
nach dieser Migration bewusst aus, bis die fachliche Restore-Abnahme erfolgt ist.
|
nach dieser Migration bewusst aus, bis die fachliche Restore-Abnahme erfolgt ist.
|
||||||
|
|
||||||
Direkt erkennbare Nextcloud-systemd-Writer werden übernommen. Bei klassischen
|
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
|
### 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
|
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
|
(906,3 Sekunden). Die drei Benutzer-systemd-Fälle bestanden separat: alter
|
||||||
Reload-Konflikt reproduziert, geschützter Stop gegen Reload und Start erfolgreich,
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Shell-Syntax und ShellCheck der Paketdateien.
|
||||||
- MyISAM-Dump und tatsächlicher SQL-Import samt Dateiwiederherstellung.
|
- MyISAM-Dump und tatsächlicher SQL-Import samt Dateiwiederherstellung.
|
||||||
- gzip/zstd, korrekte Excludes, vorhandener Wartungsmodus und inaktive Dienste.
|
- gzip/zstd, korrekte Excludes, vorhandener Wartungsmodus und inaktive Dienste.
|
||||||
- Lokaler Crypt-Upload mit vollständigem Rücklesen und Restore aus diesem Remote.
|
- Lokaler Crypt-Upload mit Remote-Dateiprüfung und separatem Restore aus diesem Remote.
|
||||||
- Fehler bei Dump, rsync, Kompression, Diensten und Remote-Rückleseprüfung.
|
- Fehler bei Dump, rsync, Kompression, Diensten und Remote-Dateiprüfung.
|
||||||
- Fehlende/beschädigte Archive, Pfadtraversal, Link-Pivots, unzulässige Tar-Dateitypen.
|
- Fehlende/beschädigte Archive, Pfadtraversal, Link-Pivots, unzulässige Tar-Dateitypen.
|
||||||
- Gemeinsame Sperre, TERM-Abbruch, Prüflauf ohne falsche Erfolgsmeldung.
|
- Gemeinsame Sperre, TERM-Abbruch, Prüflauf ohne falsche Erfolgsmeldung.
|
||||||
- Selektiver Restore mit Löschung und Schutz ausgeschlossener Nextcloud-Daten.
|
- Selektiver Restore mit Löschung und Schutz ausgeschlossener Nextcloud-Daten.
|
||||||
|
|||||||
@@ -1,35 +1,39 @@
|
|||||||
# app-backup – geprüfte Sicherungen für den JR IT Server
|
# 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).
|
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)
|
Seit 16.09.2026 laden Backup, Platzbereinigung und Update-Abnahme keine Archive
|
||||||
sind dokumentiert: begrenzte vollständige Prüfanläufe, eigene Zeitlimits,
|
zur Prüfung zurück. Nur kleine Metadaten und der Abschlussmarker werden gelesen.
|
||||||
sichtbare Dateiergebnisse und eindeutiger Hinweis auf erhaltene lokale Archive.
|
Dateien mit falschem Inhalt, aber passender Größe werden erst beim Restore erkannt.
|
||||||
Die anschließende Restore-Abnahme verwendet jetzt ebenfalls begrenzte Downloads
|
[Hintergrund und Prüfumfang](REMOTE_READBACK.md).
|
||||||
mit Wiederholungen und sicheren Fehlerdetails; siehe
|
|
||||||
[Restore-Test: Downloadfehler](RESTORE_TEST.md#downloadfehler-bei-der-abnahme-am-15092026).
|
|
||||||
|
|
||||||
Neu: [Monatlicher Offsite-Restore-Test](RESTORE_TEST.md) mit vollständiger Prüfung
|
Neu: [Monatlicher Offsite-Restore-Test](RESTORE_TEST.md) mit vollständiger Prüfung
|
||||||
aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern.
|
aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern.
|
||||||
Für knappen Speicher wird jeweils nur eine Komponente verarbeitet. Das Update
|
Für knappen Speicher wird jeweils nur eine Komponente verarbeitet. Das Update
|
||||||
mit `--enable-restore-test` erkennt die Produktions-MariaDB, bereitet das passende
|
mit `--enable-restore-test` erkennt die Produktions-MariaDB, bereitet das passende
|
||||||
lokale Docker-Image vor und prüft Initialisierung, Import und Bereinigung. Nach
|
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
|
[Speicherbereinigung und Fehlerbehebung](SPEICHER.md) beschreibt die automatische
|
||||||
Entfernung erkannter Arbeitsreste und die geschützte lokale Notfallaufbewahrung.
|
Entfernung erkannter Arbeitsreste und die geschützte lokale Notfallaufbewahrung.
|
||||||
Ohne Aktivierungsoption bleibt ein bisher deaktivierter Restore-Test deaktiviert.
|
Ohne Aktivierungsoption bleibt ein bisher deaktivierter Restore-Test deaktiviert.
|
||||||
|
|
||||||
## Produktionsstand und verbleibende Abnahme
|
## Produktionsstand und verbleibende Abnahme
|
||||||
|
|
||||||
**Stand 15.09.2026:** Das geprüfte Code-Paket `3730ab4676a5` mit der Korrektur
|
**Stand 16.09.2026:** Nach erneutem Rücklesefehler bei `nextcloud-data.tar.zst`
|
||||||
für den durch einen Healthcheck verdrängten Apache-Stop ist bereitgestellt;
|
wird der tägliche Inhaltsvergleich durch eine Remote-Datei-/Größenprüfung ersetzt.
|
||||||
der Commit ist auf `origin/master` veröffentlicht. Übertragung und Installation
|
Die Installation dieses neuen Stands auf Gemini steht aus; konkrete Befehle und
|
||||||
auf Gemini sowie ein erfolgreiches Ergebnis der neuen Abnahme sind noch nicht
|
Erfolgskriterien stehen in der [Update-Anleitung](UPDATE_ANLEITUNG.md).
|
||||||
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).
|
|
||||||
|
|
||||||
Auf Gemini bestand am 10.09.2026 ein vollständiger Paketlauf mit geschützter
|
Auf Gemini bestand am 10.09.2026 ein vollständiger Paketlauf mit geschützter
|
||||||
Dateikopie, DB-Dumps, OneDrive-Upload, vollständigem Rücklesen und separater
|
Dateikopie, DB-Dumps, OneDrive-Upload, vollständigem Rücklesen und separater
|
||||||
@@ -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
|
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
|
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.
|
Fehler müssen untersucht werden; ein aktiver Timer allein belegt kein aktuelles Backup.
|
||||||
Details: [Fehlerbehandlung und Betriebsgrenzen](Dokumentation.md#upload-fehlerbehandlung-und-betriebsgrenzen).
|
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)
|
## Automatisches Update-Paket (empfohlen)
|
||||||
|
|
||||||
Das neue Paket übernimmt Konfigurationsabgleich, Rechteprüfung, Installation,
|
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
|
Server keinen Git-Checkout. Übertrage die gelieferte `.run`-Datei und ihre Prüfsumme
|
||||||
per SCP und starte im Zielverzeichnis:
|
per SCP und starte im Zielverzeichnis:
|
||||||
|
|
||||||
@@ -101,11 +105,11 @@ Details und geprüfte Grenzen stehen in der Update-Anleitung.
|
|||||||
## Wesentliche Änderungen
|
## Wesentliche Änderungen
|
||||||
|
|
||||||
- Funktionierender Restore mit Prüflauf, Entpackmodus, Komponentenauswahl und ausdrücklichem `--apply --yes`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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).
|
- 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.
|
- 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_CONFIG="/root/.config/rclone/rclone.conf"
|
||||||
RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" # vorhandenes Crypt-Ziel beibehalten!
|
RCLONE_REMOTE_BASE="OneDriveCrypt:gemini" # vorhandenes Crypt-Ziel beibehalten!
|
||||||
RETENTION_KEEP_LAST=3
|
RETENTION_KEEP_LAST=3
|
||||||
|
SPACE_KEEP_LOCAL=1 # nur bei Platznot, nach erneuter Prüfung
|
||||||
ENABLE_REMOTE_RETENTION="false" # während der Migration zunächst aus
|
ENABLE_REMOTE_RETENTION="false" # während der Migration zunächst aus
|
||||||
MAIL_BACKUP_MODE="stop"
|
MAIL_BACKUP_MODE="stop"
|
||||||
# Zentrale Mailablage muss existieren; bei ausschließlich in Homes liegenden
|
# Zentrale Mailablage muss existieren; bei ausschließlich in Homes liegenden
|
||||||
@@ -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
|
Modus `600` erhalten. Versandfehler werden protokolliert und verändern das
|
||||||
Backup-Ergebnis nicht. `--check` und Restore-Prüfungen senden keine Backup-Mail.
|
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
|
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
|
## 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,
|
Der Restore-Test ist separat unter [RESTORE_TEST.md](RESTORE_TEST.md) dokumentiert,
|
||||||
einschließlich Aktivierung, Platzbedarf, Statusdateien, Fehlern und Bereinigung.
|
einschließlich Aktivierung, Platzbedarf, Statusdateien, Fehlern und Bereinigung.
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -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
|
## Befund und Grenzen
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -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`
|
werden zusätzlich in eine isolierte MariaDB importiert und mit `mariadb-check`
|
||||||
geprüft. Produktive Anwendungen werden dabei nicht angehalten oder überschrieben.
|
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
|
## Downloadfehler bei der Abnahme am 15.09.2026
|
||||||
|
|
||||||
Das Update `4f220dbc6447` erzeugte den Satz
|
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
|
### Korrigierter Ablauf
|
||||||
|
|
||||||
Der Restore-Test verwendet für seine lesenden Remote-Aufrufe jetzt dieselben
|
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_ATTEMPTS=3`: höchstens drei Versuche je Metadatenabruf/Download.
|
||||||
- `RCLONE_CHECK_DEADLINE=3600`: maximal eine Stunde je Versuch einschließlich
|
- `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`
|
vorherigen Restore-Erfolgsnachweis, meldet die betroffene Komponente als `FAIL`
|
||||||
und bereinigt die Testdaten. Nach fehlgeschlagener Update-Abnahme bleiben
|
und bereinigt die Testdaten. Nach fehlgeschlagener Update-Abnahme bleiben
|
||||||
Backup- und Restore-Test-Timer gestoppt. Das neue Paket erneut im Wartungsfenster
|
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.
|
installieren; es führt ein neues Backup samt Remote-Dateiprüfung aus.
|
||||||
Erst die neue `result.txt` mit `SUCCESS` bestätigt die gesamte Abnahme.
|
Die neue `result.txt` mit `SUCCESS` bestätigt die Update-Abnahme;
|
||||||
|
der vollständige Restore-Test erfolgt separat beim Monatstermin.
|
||||||
[Installationsschritte](UPDATE_ANLEITUNG.md).
|
[Installationsschritte](UPDATE_ANLEITUNG.md).
|
||||||
|
|
||||||
Der vorhandene erfolgreiche Backup-Satz darf alternativ nach Installation des
|
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
|
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
|
`$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
|
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
|
Es gibt kein allgemeines `docker prune`. Das einmal vorbereitete DB-Image bleibt
|
||||||
als wiederverwendbare Abhängigkeit erhalten; sein einmaliger Platzbedarf kommt
|
als wiederverwendbare Abhängigkeit erhalten; sein einmaliger Platzbedarf kommt
|
||||||
zum Arbeitsbudget hinzu. Images werden im Test weder heruntergeladen noch gebaut.
|
zum Arbeitsbudget hinzu. Images werden im Test weder heruntergeladen noch gebaut.
|
||||||
|
|||||||
+71
-13
@@ -1,5 +1,60 @@
|
|||||||
# Automatische Speicherbereinigung
|
# 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
|
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
|
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
|
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.
|
Platzprüfung werden produktive Writer angehalten.
|
||||||
4. Bei Platzmangel ältere geeignete lokale v2-Sätze prüfen. Nur Sätze mit gültigem
|
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
|
Manifest, `COMPLETE` und lokalem `UPLOADED` kommen infrage. Die neuesten
|
||||||
`RETENTION_KEEP_LAST` geeigneten Kopien bleiben geschützt (Default drei,
|
`SPACE_KEEP_LOCAL` geeigneten Kopien bleiben geschützt (Default und Minimum eins).
|
||||||
mindestens zwei). Ältere Kandidaten werden lokal vollständig validiert und mit
|
Ältere Kandidaten werden lokal vollständig validiert und mit
|
||||||
`rclone check --download` inklusive Manifest und `COMPLETE` erneut vollständig
|
`rclone check --size-only` inklusive Manifest und `COMPLETE` auf
|
||||||
gegen ihre Offsite-Kopie gelesen. Erst bei erfolgreicher Prüfung wird die lokale
|
Remote-Dateiexistenz und passende Größen geprüft (ohne Archivdownload). Erst bei erfolgreicher Prüfung wird die lokale
|
||||||
Kopie entfernt. Ein nicht mehr lesbarer oder veränderter Remote-Satz bleibt
|
Kopie entfernt; zusätzlich müssen Remote-Manifest und `COMPLETE` bytegenau
|
||||||
lokal erhalten. Es wird nur so lange bereinigt, bis der geplante Platz reicht.
|
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
|
Diese Offsite-Vergleiche verwenden ebenfalls die begrenzten Prüfanläufe und
|
||||||
Zeitlimits aus [REMOTE_READBACK.md](REMOTE_READBACK.md). Ein fehlgeschlagener
|
Zeitlimits aus [REMOTE_READBACK.md](REMOTE_READBACK.md). Ein fehlgeschlagener
|
||||||
Vergleich erlaubt auch nach mehreren Versuchen keine lokale Löschung.
|
Vergleich erlaubt auch nach mehreren Versuchen keine lokale Löschung.
|
||||||
|
|
||||||
Die Platzbereinigung benötigt keinen vorherigen neuen Sicherungslauf. Sie darf
|
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
|
Sie verändert keine Offsite-Daten. Die normale Altersretention nach einem neuen
|
||||||
erfolgreichen Backup bleibt davon getrennt. `--no-retention` im Update unterdrückt
|
erfolgreichen Backup bleibt davon getrennt. `--no-retention` im Update unterdrückt
|
||||||
die Altersretention, erlaubt aber weiterhin die hier beschriebene Platzbereinigung.
|
die Altersretention, erlaubt aber weiterhin die hier beschriebene Platzbereinigung.
|
||||||
@@ -60,7 +117,7 @@ fehlgeschlagen ist, bleibt erhalten.
|
|||||||
Folgende Daten bleiben geschützt:
|
Folgende Daten bleiben geschützt:
|
||||||
|
|
||||||
- vollständige lokale Sätze ohne bestätigten Upload;
|
- 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;
|
- ein `.partial`-Verzeichnis, das bereits einen `COMPLETE`-Marker enthält;
|
||||||
- sämtliche Arbeitsreste bei vorhandenem `recovery-*.json` oder angefangener
|
- sämtliche Arbeitsreste bei vorhandenem `recovery-*.json` oder angefangener
|
||||||
Recovery-Datei: zunächst muss der Dienst-/Restore-Zustand geklärt werden;
|
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
|
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
|
`--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
|
Arbeitsreste, bereitet MariaDB vor und führt Backup mit Remote-Dateiprüfung
|
||||||
aus. Erst danach aktiviert es die Timer. Der alte fehlgeschlagene Update-Ordner
|
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.
|
bleibt als Diagnose-/Rollback-Nachweis erhalten; ein pauschales `rm` ist unnötig.
|
||||||
|
|
||||||
Nach bereits erfolgter Installation kann die Platzvorbereitung separat gestartet
|
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
|
`--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`
|
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
|
und `result.txt`. `SUCCESS` im aktuellen `result.txt` bestätigt das Update; der
|
||||||
Restore-Teststatus sind die Abnahmenachweise. Scheitert ein Update trotz der
|
Restore-Teststatus ist ein separater Nachweis nach dem Monatslauf. Scheitert ein Update trotz der
|
||||||
sicheren Bereinigung, bleiben die Timer nach begonnener Installation gestoppt;
|
sicheren Bereinigung, bleiben die Timer nach begonnener Installation gestoppt;
|
||||||
der Fehler muss behoben und das Paket erneut ausgeführt werden.
|
der Fehler muss behoben und das Paket erneut ausgeführt werden.
|
||||||
+126
-41
@@ -1,55 +1,140 @@
|
|||||||
# Automatisches Update-Paket
|
# 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`
|
Das Paket enthält die unten beschriebenen Änderungen vom 16.09. und die neue
|
||||||
veröffentlicht. Das daraus gebaute Paket und seine SHA-256-Datei liegen lokal
|
Speicherstrategie aus [SPEICHER.md](SPEICHER.md). Bei Platznot darf die automatische
|
||||||
unter `dist/`; äußere Prüfsumme, interne Payload-Prüfsumme und alle 33 enthaltenen
|
Bereinigung jetzt bis auf `SPACE_KEEP_LOCAL=1` bestätigte lokale Sicherung
|
||||||
Dateien wurden geprüft. Die nachfolgende Dokumentationsaktualisierung ändert den
|
zurückgehen. Die normale Retention bleibt bei `RETENTION_KEEP_LAST=3`.
|
||||||
Programmstand nicht. Das Paket bleibt für die bereits übermittelte Installation
|
Der neue Standard gilt auch ohne Ergänzung der bestehenden Konfiguration.
|
||||||
gültig. Übertragung, Installation und neue Serverabnahme sind noch nicht bestätigt.
|
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:
|
Auf dem Arbeitsplatz:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/johannes/localdev/app-backup/dist
|
cd /home/johannes/localdev/app-backup/dist
|
||||||
scp app-backup-update-3730ab4676a5.run \
|
scp app-backup-update-VERSION.run app-backup-update-VERSION.run.sha256 \
|
||||||
app-backup-update-3730ab4676a5.run.sha256 \
|
|
||||||
johannes@gemini.jr-it-services.de:~/
|
johannes@gemini.jr-it-services.de:~/
|
||||||
```
|
```
|
||||||
|
|
||||||
Auf Gemini im Wartungsfenster starten; während der Datenkopie werden die
|
Auf Gemini im Wartungsfenster (Anwendungen werden während der Kopie angehalten):
|
||||||
konfigurierten Anwendungen angehalten:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh johannes@gemini.jr-it-services.de
|
ssh johannes@gemini.jr-it-services.de
|
||||||
cd ~
|
cd ~
|
||||||
sha256sum -c app-backup-update-3730ab4676a5.run.sha256 &&
|
sha256sum -c app-backup-update-VERSION.run.sha256 &&
|
||||||
sudo bash app-backup-update-3730ab4676a5.run
|
sudo bash app-backup-update-VERSION.run --enable-ai-rem --enable-restore-test
|
||||||
```
|
```
|
||||||
|
|
||||||
Der bereits aktivierte Restore-Test wird übernommen; ein vorheriger Rollback
|
Ein vorheriger Rollback ist nicht erforderlich. Bestehende Zugangsdaten und
|
||||||
ist nicht erforderlich. Der Auftrag läuft unabhängig von der SSH-Verbindung.
|
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
|
```bash
|
||||||
sudo journalctl -fu app-backup-upgrade.service --no-pager
|
sudo journalctl -fu app-backup-upgrade.service --no-pager
|
||||||
```
|
# Strg+C beendet nur die Loganzeige.
|
||||||
|
# Den tatsächlichen Ergebnisordner aus der Starter-Ausgabe einsetzen:
|
||||||
`Strg+C` beendet nur die Loganzeige. Den vom Starter genannten Ergebnisordner
|
|
||||||
verwenden und `XXXXXXXX` durch dessen tatsächlichen Suffix ersetzen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cat /var/backups/app-backup-upgrade-XXXXXXXX/result.txt
|
sudo cat /var/backups/app-backup-upgrade-XXXXXXXX/result.txt
|
||||||
sudo systemctl list-timers app-backup.timer app-restore-test.timer --no-pager
|
sudo systemctl list-timers app-backup.timer app-restore-test.timer --no-pager
|
||||||
```
|
```
|
||||||
|
|
||||||
Erst `SUCCESS` in dieser neuen Ergebnisdatei bestätigt Installation, Backup und
|
Erst `SUCCESS` in der neuen `result.txt` bestätigt den Abschluss. Bei `QUEUED` oder
|
||||||
Remote-Restore-Prüfung. Bei `QUEUED` oder `RUNNING` keinen zweiten Lauf starten.
|
`RUNNING` keinen zweiten Auftrag starten. Der abgenommene Lauf steht in
|
||||||
Bei `FAILED` die Ergebnisdatei und die letzten Journalzeilen auswerten:
|
`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
|
Der fehlgeschlagene lokale Satz `appbackup_2026-09-15_21-03-40_21b6efeb`
|
||||||
sudo journalctl -u app-backup-upgrade.service -n 150 --no-pager -l
|
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
|
## 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
|
einen isolierten Probeimport einschließlich Bereinigung ausführen. Manuelles
|
||||||
`docker pull` ist auf Gemini nicht mehr erforderlich. Die Option aktiviert
|
`docker pull` ist auf Gemini nicht mehr erforderlich. Die Option aktiviert
|
||||||
zugleich das adaptive Arbeitsbudget; Details: [RESTORE_TEST.md](RESTORE_TEST.md).
|
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
|
Nach erfolgreichem Backup mit Remote-Dateiprüfung aktiviert der Worker
|
||||||
DB-Import. Erst bei Erfolg aktiviert er Monatstimer und Boot-Bereinigung.
|
Monatstimer und Boot-Bereinigung. Der vollständige Offsite-Restore folgt beim Monatstermin.
|
||||||
|
|
||||||
Vor der Vorprüfung werden erkannte Überreste fehlgeschlagener Backups bereinigt
|
Vor der Vorprüfung werden erkannte Überreste fehlgeschlagener Backups bereinigt
|
||||||
und der nächste Snapshot geplant. Reicht der Platz nicht, werden geeignete ältere
|
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
|
`RETENTION_KEEP_LAST` bleiben geschützt. Diese Platzbereinigung gilt auch während
|
||||||
einer Update-Abnahme ohne reguläre Retention. Sie wird nicht durch Paketrollback
|
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.
|
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
|
```bash
|
||||||
sha256sum -c app-backup-update-VERSION.run.sha256
|
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
|
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
|
8. Backup-Timer stoppen, Paket mit Rollback-Inventar installieren, Konfiguration
|
||||||
atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren.
|
atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren.
|
||||||
9. Vollständiges Backup ohne reguläre lokale/Remote-Altersretention durchführen.
|
9. Vollständiges Backup ohne reguläre lokale/Remote-Altersretention durchführen.
|
||||||
Platzbereinigung bleibt erlaubt. Bei aktiviertem Restore-Test den Satz
|
Platzbereinigung bleibt erlaubt. Upload mit anschließender Dateiexistenz-/
|
||||||
komponentenweise herunterladen, entpacken und seine DB-Dumps isoliert importieren;
|
Größenprüfung und Bestätigung des Abschlussmarkers; keine Archivdownloads.
|
||||||
sonst alle Archive einzeln herunterladen und vollständig prüfen.
|
|
||||||
10. Erst bei Erfolg den Backup-Timer aktivieren und das Ergebnis protokollieren.
|
10. Erst bei Erfolg den Backup-Timer aktivieren und das Ergebnis protokollieren.
|
||||||
|
|
||||||
Es findet kein Import in produktive Anwendungen oder Datenbanken statt. Der
|
Es findet kein Import in produktive Anwendungen oder Datenbanken statt. Der
|
||||||
Prüflauf belegt bei aktiviertem Restore-Test auch Entpacken und DB-Import, ersetzt
|
Update-Prüflauf bestätigt den Upload. Entpacken und DB-Import werden separat
|
||||||
aber keinen fachlichen Anwendungstest.
|
beim Monatslauf geprüft; ein fachlicher Anwendungstest bleibt zusätzlich erforderlich.
|
||||||
|
|
||||||
## Verlauf und Ergebnis
|
## 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
|
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
|
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,
|
`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
|
MTA-Queue und Empfänger-/Aliaszustellung prüfen. Keine erneute Sicherung allein
|
||||||
zum Testen der Mailzustellung starten. Die Entwicklungstests verwenden eine lokale
|
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:
|
Die bisherigen Aufträge zeigten normale Fortschrittsstatistiken nicht an:
|
||||||
rclone meldet sie standardmäßig auf INFO, die normale Logschwelle ist NOTICE.
|
rclone meldet sie standardmäßig auf INFO, die normale Logschwelle ist NOTICE.
|
||||||
Das aktuelle Paket setzt `--stats-log-level NOTICE --stats-one-line` mit
|
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
|
Freigabe des Abschlussmarkers. Statistiken gehen nach stderr, damit maschinell
|
||||||
gelesene Marker und Dateilisten auf stdout unverändert bleiben.
|
gelesene Marker und Dateilisten auf stdout unverändert bleiben.
|
||||||
Quelle: [rclone-Statistiken](https://rclone.org/docs/#stats-log-level-loglevel).
|
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
|
Eine neue Paketdatei oder geänderte Config ergänzt keine Statistiken im bereits
|
||||||
laufenden rclone-Prozess. Keinen zweiten Upload parallel starten und den
|
laufenden rclone-Prozess. Keinen zweiten Upload parallel starten und den
|
||||||
vorhandenen lokalen Satz nicht löschen. Ein einzelner 409-Fehler begründet keine
|
vorhandenen lokalen Satz nicht löschen. Ein einzelner 409-Fehler begründet keine
|
||||||
Abschwächung der Prüfungen durch `--ignore-existing`/`--size-only`.
|
Änderung der Upload-Optionen wie `--ignore-existing`. Seit 16.09.2026 wird
|
||||||
Entscheidend bleiben erfolgreiches Rücklesen,
|
`--size-only` gezielt beim Metadatenvergleich eingesetzt. Entscheidend sind lokale
|
||||||
Manifest-/Archivprüfung und `SUCCESS` in der Ergebnisdatei des konkreten Auftrags.
|
Manifest-/Archivprüfung, erfolgreicher Upload mit Remote-Dateiprüfung und
|
||||||
|
`SUCCESS` in der Ergebnisdatei; der Monatslauf liefert den separaten Restore-Nachweis.
|
||||||
|
|
||||||
## Paket reproduzieren
|
## Paket reproduzieren
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ LOG_RETENTION_DAYS=30
|
|||||||
LOCAL_RETENTION_DAYS=7
|
LOCAL_RETENTION_DAYS=7
|
||||||
REMOTE_RETENTION_DAYS=30
|
REMOTE_RETENTION_DAYS=30
|
||||||
RETENTION_KEEP_LAST=3
|
RETENTION_KEEP_LAST=3
|
||||||
|
# Under disk pressure only: keep at least this many uploaded local sets.
|
||||||
|
# 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 deliberately after a successful restore rehearsal. v1 runs are retained.
|
||||||
ENABLE_REMOTE_RETENTION="false"
|
ENABLE_REMOTE_RETENTION="false"
|
||||||
|
|
||||||
@@ -31,9 +35,9 @@ RCLONE_TIMEOUT="1h"
|
|||||||
RCLONE_CONTIMEOUT="30s"
|
RCLONE_CONTIMEOUT="30s"
|
||||||
# Periodic transfer/check statistics in journald, including otherwise quiet uploads.
|
# Periodic transfer/check statistics in journald, including otherwise quiet uploads.
|
||||||
RCLONE_STATS_INTERVAL="1m"
|
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.
|
# 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_ATTEMPTS=3
|
||||||
RCLONE_CHECK_RETRY_DELAY=30
|
RCLONE_CHECK_RETRY_DELAY=30
|
||||||
RCLONE_CHECK_TIMEOUT=300
|
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.
|
# Additional directories; only files, no automatic DB/container snapshot semantics.
|
||||||
# Keep ordering stable: entries become extra-0, extra-1, ...
|
# 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=()
|
EXTRA_PATHS=()
|
||||||
# Restore target directories must already exist. Deletion is off by default.
|
# Restore target directories must already exist. Deletion is off by default.
|
||||||
RESTORE_STRICT_DELETE="false"
|
RESTORE_STRICT_DELETE="false"
|
||||||
+43
-14
@@ -28,6 +28,7 @@ load_config() {
|
|||||||
: "${LOG_RETENTION_DAYS:=30}"
|
: "${LOG_RETENTION_DAYS:=30}"
|
||||||
: "${MIN_FREE_GB:=10}" "${LOCAL_RETENTION_DAYS:=7}" "${REMOTE_RETENTION_DAYS:=30}"
|
: "${MIN_FREE_GB:=10}" "${LOCAL_RETENTION_DAYS:=7}" "${REMOTE_RETENTION_DAYS:=30}"
|
||||||
: "${RETENTION_KEEP_LAST:=3}" "${ENABLE_REMOTE_RETENTION:=false}"
|
: "${RETENTION_KEEP_LAST:=3}" "${ENABLE_REMOTE_RETENTION:=false}"
|
||||||
|
: "${SPACE_KEEP_LOCAL:=1}"
|
||||||
: "${ENABLE_UPLOAD:=true}" "${RCLONE_BIN:=rclone}"
|
: "${ENABLE_UPLOAD:=true}" "${RCLONE_BIN:=rclone}"
|
||||||
: "${RCLONE_REMOTE_BASE:=}" "${RCLONE_CONFIG:=/root/.config/rclone/rclone.conf}"
|
: "${RCLONE_REMOTE_BASE:=}" "${RCLONE_CONFIG:=/root/.config/rclone/rclone.conf}"
|
||||||
: "${RCLONE_RETRIES:=6}" "${RCLONE_LOW_LEVEL_RETRIES:=20}"
|
: "${RCLONE_RETRIES:=6}" "${RCLONE_LOW_LEVEL_RETRIES:=20}"
|
||||||
@@ -73,12 +74,13 @@ load_config() {
|
|||||||
[[ ${!v} =~ ^(0|[1-9][0-9]*)$ ]] || die "$v must be a non-negative integer"
|
[[ ${!v} =~ ^(0|[1-9][0-9]*)$ ]] || die "$v must be a non-negative integer"
|
||||||
done
|
done
|
||||||
(( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2"
|
(( RETENTION_KEEP_LAST >= 2 )) || die "RETENTION_KEEP_LAST must be >= 2"
|
||||||
|
[[ $SPACE_KEEP_LOCAL =~ ^[1-9][0-9]{0,5}$ ]] || die 'SPACE_KEEP_LOCAL must be an integer between 1 and 999999'
|
||||||
# Seconds/counts only: never permit disabled deadlines or unbounded retries.
|
# Seconds/counts only: never permit disabled deadlines or unbounded retries.
|
||||||
for v in RCLONE_CHECK_ATTEMPTS RCLONE_CHECK_LOW_LEVEL_RETRIES RCLONE_CHECK_TIMEOUT RCLONE_CHECK_DEADLINE RCLONE_CHECK_RETRY_DELAY; do
|
for v in RCLONE_CHECK_ATTEMPTS RCLONE_CHECK_LOW_LEVEL_RETRIES RCLONE_CHECK_TIMEOUT RCLONE_CHECK_DEADLINE RCLONE_CHECK_RETRY_DELAY; do
|
||||||
[[ ${!v} =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "$v must be a bounded integer"
|
[[ ${!v} =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "$v must be a bounded integer"
|
||||||
done
|
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_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 readback time 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'
|
(( 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"
|
(( 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"
|
[[ $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
|
for key in ${!RCLONE_CONFIG_@}; do
|
||||||
[[ $key == RCLONE_CONFIG_PASS ]] || die "Remote overrides through $key are unsupported; use the config file"
|
[[ $key == RCLONE_CONFIG_PASS ]] || die "Remote overrides through $key are unsupported; use the config file"
|
||||||
done
|
done
|
||||||
# rclone reads global flags from exported RCLONE_* variables too. For example,
|
# rclone reads global flags from exported RCLONE_* variables too. Filters
|
||||||
# RCLONE_SIZE_ONLY bypasses byte comparison even with check --download; filters
|
|
||||||
# can hide missing archives from both copy and check. Refuse such ambient flags
|
# can hide missing archives from both copy and check. Refuse such ambient flags
|
||||||
# before the first remote operation, rather than silently weakening verification.
|
# before the first remote operation, rather than silently weakening verification.
|
||||||
# Allow only our explicit settings (passed as CLI flags) and config decryption.
|
# 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.
|
# by callers. NOTICE makes periodic progress visible without verbose HTTP logs.
|
||||||
rc() { "$RCLONE_BIN" "${RCLONE_ARGS[@]}" "$@"; }
|
rc() { "$RCLONE_BIN" "${RCLONE_ARGS[@]}" "$@"; }
|
||||||
|
|
||||||
remote_readback() {
|
remote_metadata_check() {
|
||||||
local attempt code=1
|
local attempt code=1
|
||||||
# rclone check uses cmd.Run(false, ...): --retries does NOT repeat the whole
|
# rclone check uses cmd.Run(false, ...): --retries does NOT repeat the whole
|
||||||
# check. A fresh process also refreshes backend/download state after failures.
|
# check. Retry the complete metadata comparison; never download archive data.
|
||||||
# Always recheck every file; never turn a partial match report into success.
|
# Content integrity is checked by the monthly restore test.
|
||||||
for ((attempt=1; attempt<=RCLONE_CHECK_ATTEMPTS; attempt++)); do
|
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
|
# 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.
|
# 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
|
# Combined output names missing files and size mismatches.
|
||||||
# a crypt header is still pending. Combined output names each check result.
|
|
||||||
if timeout --kill-after=10s "${RCLONE_CHECK_DEADLINE}s" \
|
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" \
|
--timeout "${RCLONE_CHECK_TIMEOUT}s" --low-level-retries "$RCLONE_CHECK_LOW_LEVEL_RETRIES" \
|
||||||
--stats-one-line=false --stats-one-line-date=false --combined -; then
|
--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
|
return 0
|
||||||
else
|
else
|
||||||
code=$?
|
code=$?
|
||||||
fi
|
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
|
# Preserve cancellation semantics, including a forced kill after the grace
|
||||||
# period. Do not restart a command an operator/service manager has stopped.
|
# period. Do not restart a command an operator/service manager has stopped.
|
||||||
if (( code >= 128 )); then return "$code"; fi
|
if (( code >= 128 )); then return "$code"; fi
|
||||||
@@ -336,10 +336,39 @@ remote_readback() {
|
|||||||
sleep "$RCLONE_CHECK_RETRY_DELAY" || return $?
|
sleep "$RCLONE_CHECK_RETRY_DELAY" || return $?
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo 'ERROR: Remote readback exhausted; remote contents remain unverified'
|
echo 'ERROR: Remote metadata check exhausted; remote file presence/sizes remain unverified'
|
||||||
return "$code"
|
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 are rooted so a nested directory named data is not accidentally lost.
|
||||||
exclusions() {
|
exclusions() {
|
||||||
EXCLUDES=()
|
EXCLUDES=()
|
||||||
|
|||||||
+2
-2
@@ -65,9 +65,9 @@ def deliver(args):
|
|||||||
with open(args.log, encoding='utf-8', errors='replace') as stream:
|
with open(args.log, encoding='utf-8', errors='replace') as stream:
|
||||||
tail = ''.join(deque(stream, maxlen=args.tail_lines))
|
tail = ''.join(deque(stream, maxlen=args.tail_lines))
|
||||||
upload = {'NOT_STARTED': 'Nicht gestartet', 'DISABLED': 'Deaktiviert (nur lokal)',
|
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',
|
'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 = [
|
body = [
|
||||||
'Backup Report', '=============', f'Zeit: {now.isoformat(timespec="seconds")}',
|
'Backup Report', '=============', f'Zeit: {now.isoformat(timespec="seconds")}',
|
||||||
f'Host: {host}', f'Status: {state}', f'Exit code: {args.exit_code}',
|
f'Host: {host}', f'Status: {state}', f'Exit code: {args.exit_code}',
|
||||||
|
|||||||
+49
-8
@@ -9,6 +9,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import backup_set
|
import backup_set
|
||||||
|
|
||||||
@@ -18,12 +19,21 @@ def private(path):
|
|||||||
path.stat().st_uid == 0 and not path.stat().st_mode & 0o077)
|
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):
|
def remove_tree(path):
|
||||||
if not private(path):
|
if not private(path):
|
||||||
raise ValueError('Refusing non-private/aliased scratch: ' + str(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
|
device = path.stat().st_dev
|
||||||
for parent, dirs, _ in os.walk(path, followlinks=False):
|
for parent, dirs, files in os.walk(path, followlinks=False):
|
||||||
for name in dirs:
|
for name in dirs + files:
|
||||||
child = Path(parent) / name
|
child = Path(parent) / name
|
||||||
if not child.is_symlink() and (child.stat().st_dev != device or os.path.ismount(child)):
|
if not child.is_symlink() and (child.stat().st_dev != device or os.path.ismount(child)):
|
||||||
raise ValueError('Refusing mounted scratch: ' + str(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)
|
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 = []
|
valid = []
|
||||||
for path in archives.iterdir():
|
for path in archives.iterdir():
|
||||||
if not private(path) or not path.name.startswith(prefix+'_'):
|
if not private(path) or not path.name.startswith(prefix+'_'):
|
||||||
@@ -81,19 +93,48 @@ def eligible(archives, prefix, keep):
|
|||||||
except (ValueError, OSError, KeyError, TypeError):
|
except (ValueError, OSError, KeyError, TypeError):
|
||||||
continue
|
continue
|
||||||
valid.append(path.name)
|
valid.append(path.name)
|
||||||
# Emergency pruning may ignore age, NEVER the minimum newest-copy count.
|
return sorted(valid, reverse=True)
|
||||||
for name in sorted(valid, reverse=True)[keep:][::-1]:
|
|
||||||
|
|
||||||
|
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)
|
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__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser()
|
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='+')
|
parser.add_argument('paths', nargs='+')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.mode == 'clean':
|
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)
|
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':
|
elif args.mode in ('eligible', 'protected'):
|
||||||
eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2]))
|
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:
|
else:
|
||||||
remove_tree(Path(args.paths[0]))
|
remove_tree(Path(args.paths[0]))
|
||||||
+40
-8
@@ -4,34 +4,66 @@ clean_backup_scratch() {
|
|||||||
python3 "$LIB_DIR/space.py" clean "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$ARCHIVE_PREFIX" "$RESTORE_ROOT"
|
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() {
|
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'
|
[[ $additional =~ ^(0|[1-9][0-9]{0,15})$ ]] || die 'Invalid or excessive space request'
|
||||||
local required=$((MIN_FREE_GB * 1024 * 1024 * 1024 + additional))
|
local required=$((MIN_FREE_GB * 1024 * 1024 * 1024 + additional))
|
||||||
avail=$(df -PB1 -- "$path" | awk 'END{print $4}')
|
avail=$(available_space "$path")
|
||||||
(( avail < required )) || return 0
|
(( avail < required )) || return 0
|
||||||
echo "-- Space recovery: need $required bytes, available $avail on $path"
|
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
|
if [[ $ENABLE_UPLOAD == true && $(stat -c %d "$path") == "$(stat -c %d "$ARCHIVE_DIR")" ]]; then
|
||||||
check_remote
|
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
|
while IFS= read -r name; do
|
||||||
[[ -n $name ]] || continue
|
[[ -n $name ]] || continue
|
||||||
|
avail=$(available_space "$path")
|
||||||
|
(( avail < required )) || return 0
|
||||||
dir="$ARCHIVE_DIR/$name"
|
dir="$ARCHIVE_DIR/$name"
|
||||||
# A manually selected local restore source is never a cleanup candidate.
|
# A manually selected local restore source is never a cleanup candidate.
|
||||||
[[ -z ${LOCAL_RUN:-} || $(realpath -m "$LOCAL_RUN") != "$dir" ]] || continue
|
[[ -z ${LOCAL_RUN:-} || $(realpath -m "$LOCAL_RUN") != "$dir" ]] || continue
|
||||||
echo "-- Space recovery: verifying offsite copy before local removal: $name"
|
echo "-- Space recovery: verifying offsite copy before local removal: $name"
|
||||||
if helper verify "$dir" >/dev/null &&
|
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"
|
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
|
else
|
||||||
|
code=$?
|
||||||
|
(( code < 128 )) || return "$code"
|
||||||
echo "WARNING: Keeping $name: local/offsite verification failed"
|
echo "WARNING: Keeping $name: local/offsite verification failed"
|
||||||
fi
|
fi
|
||||||
avail=$(df -PB1 -- "$path" | awk 'END{print $4}')
|
avail=$(available_space "$path")
|
||||||
(( avail < required )) || return 0
|
if (( avail >= required )); then
|
||||||
|
echo "-- Space recovery complete: available $avail bytes, required $required"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
done <<< "$candidates"
|
done <<< "$candidates"
|
||||||
fi
|
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() {
|
prepare_backup_space() {
|
||||||
|
|||||||
+213
-42
@@ -198,6 +198,15 @@ p.write_text(json.dumps(s))
|
|||||||
printf '%s\\n' '{name}' >> "$TEST_EVENTS"
|
printf '%s\\n' '{name}' >> "$TEST_EVENTS"
|
||||||
if [[ ${{TEST_FAIL_TOOL:-}} == {name} ]]; then exit 42; fi
|
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_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} "$@"
|
exec {real} "$@"
|
||||||
'''
|
'''
|
||||||
path=self.bin/name
|
path=self.bin/name
|
||||||
@@ -288,6 +297,8 @@ QUIESCE_SERVICES=(nc-cron)
|
|||||||
self.assertEqual(json.loads(self.state.read_text()),self.initial)
|
self.assertEqual(json.loads(self.state.read_text()),self.initial)
|
||||||
|
|
||||||
def test_emergency_retention_reads_remote_and_preserves_minimum(self):
|
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()
|
self.crypt_config()
|
||||||
current=self.backup()
|
current=self.backup()
|
||||||
archives=current.parent
|
archives=current.parent
|
||||||
@@ -302,7 +313,8 @@ QUIESCE_SERVICES=(nc-cron)
|
|||||||
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n')
|
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n')
|
||||||
subprocess.run(['/usr/bin/rclone','--config',str(self.base/'rclone.conf'),'copy',str(path),'crypt:sets/'+name],check=True,capture_output=True)
|
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)
|
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)
|
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=self.bin/'df'
|
||||||
fake.write_text('#!/bin/sh\nprintf "Filesystem 1B-blocks Used Available Use%% Mounted\\nroot 100 100 0 100%% /\\n"\n')
|
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.assertIn(old[1].name,remote)
|
||||||
self.assertNotIn('systemctl stop',result.stdout)
|
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):
|
def test_dump_and_copy_failures_do_not_publish(self):
|
||||||
for tool in ['mariadb-dump','rsync','zstd']:
|
for tool in ['mariadb-dump','rsync','zstd']:
|
||||||
with self.subTest(tool=tool):
|
with self.subTest(tool=tool):
|
||||||
@@ -655,26 +767,30 @@ else: raise SystemExit('Unexpected Docker command')
|
|||||||
docker.chmod(0o700)
|
docker.chmod(0o700)
|
||||||
canonical=self.configure_upgrade()
|
canonical=self.configure_upgrade()
|
||||||
original=canonical.read_bytes()
|
original=canonical.read_bytes()
|
||||||
|
self.env['TEST_METADATA_ONLY']='true'
|
||||||
package=self.build_package()
|
package=self.build_package()
|
||||||
result=subprocess.run(['bash',str(package),'--foreground','--enable-restore-test'],env=self.env,capture_output=True,text=True,timeout=120)
|
result=subprocess.run(['bash',str(package),'--foreground','--enable-restore-test'],env=self.env,capture_output=True,text=True,timeout=120)
|
||||||
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
||||||
state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0])
|
state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0])
|
||||||
self.assertIn('SUCCESS',(state/'result.txt').read_text())
|
self.assertIn('SUCCESS',(state/'result.txt').read_text())
|
||||||
|
self.assertNotIn('forbidden-archive-read',self.events.read_text())
|
||||||
message,body=self.read_report()
|
message,body=self.read_report()
|
||||||
self.assertEqual(str(message['From']),'app-backup@example.invalid')
|
self.assertEqual(str(message['From']),'app-backup@example.invalid')
|
||||||
self.assertIn(' SUCCESS gemini.jr-it-services.de ',str(message['Subject']))
|
self.assertIn(' SUCCESS gemini.jr-it-services.de ',str(message['Subject']))
|
||||||
self.assertIn('Host: gemini.jr-it-services.de',body.splitlines())
|
self.assertIn('Host: gemini.jr-it-services.de',body.splitlines())
|
||||||
self.assertIn('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text())
|
self.assertIn('MAIL_HOSTNAME="gemini.jr-it-services.de"',canonical.read_text())
|
||||||
self.assertIn('Bereinigung: OK',body)
|
self.assertIn('Upload Status: OK (Dateien vorhanden',body)
|
||||||
self.assertIn('[app-restore-test] SUCCESS',message['Subject'])
|
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'))
|
saved=next((self.base/'logs').glob('report_*.eml'))
|
||||||
backup_message=BytesParser(policy=policy.default).parsebytes(saved.read_bytes())
|
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/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.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.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.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('ENABLE_REMOTE_RETENTION=false',canonical.read_text())
|
||||||
self.assertIn('RESTORE_TEST_MAX_WORK_GB=0',canonical.read_text())
|
self.assertIn('RESTORE_TEST_MAX_WORK_GB=0',canonical.read_text())
|
||||||
self.assertTrue(Path(str(self.events)+'.image-prepared').exists())
|
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(canonical.read_bytes(),original)
|
||||||
self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'inactive')
|
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):
|
def test_package_preflight_failure_preserves_live_configuration_and_timer(self):
|
||||||
canonical=self.configure_upgrade()
|
canonical=self.configure_upgrade()
|
||||||
original=canonical.read_bytes()
|
original=canonical.read_bytes()
|
||||||
@@ -832,7 +989,7 @@ else: raise SystemExit('Unexpected Docker command')
|
|||||||
self.assertIn('FAILED',(state/'result.txt').read_text())
|
self.assertIn('FAILED',(state/'result.txt').read_text())
|
||||||
self.assertTrue((state/'rollback-update.sh').is_file())
|
self.assertTrue((state/'rollback-update.sh').is_file())
|
||||||
self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'inactive')
|
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)
|
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(rollback.returncode,0,rollback.stdout+rollback.stderr)
|
||||||
self.assertEqual(canonical.read_bytes(),original)
|
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.assertIn('FAILED (exit 42)',body)
|
||||||
self.assertEqual(json.loads(self.state.read_text()),self.initial)
|
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.enable_report()
|
||||||
self.crypt_config()
|
self.crypt_config()
|
||||||
self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true')
|
self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true')
|
||||||
_,body=self.read_report()
|
_,body=self.read_report()
|
||||||
self.assertIn('Status: FAIL',body)
|
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)
|
self.assertNotIn('Upload Status: OK',body)
|
||||||
|
|
||||||
def test_report_mta_failure_keeps_backup_result_and_local_message(self):
|
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.run_script('app-restore.sh','--local-run',run,'--verify-only')
|
||||||
self.assertFalse((self.base/'mail.eml').exists())
|
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()
|
self.crypt_config()
|
||||||
# Exercise periodic stderr statistics during a real throttled crypt
|
# Exercise periodic stderr statistics during a real throttled crypt
|
||||||
# transfer; they must not contaminate captured COMPLETE-marker stdout.
|
# 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()
|
log=next((self.base/'logs').glob('backup_*.log')).read_text()
|
||||||
self.assertIn('NOTICE:',log)
|
self.assertIn('NOTICE:',log)
|
||||||
self.assertIn('Remote upload started',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')
|
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()
|
self.crypt_config()
|
||||||
result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true')
|
result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_CHECK='true')
|
||||||
self.assertEqual(result.returncode,43)
|
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)
|
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)
|
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
|
# The first check fails/stalls before real crypt I/O. Subsequent checks
|
||||||
# use real rclone; event order proves COMPLETE was withheld until success.
|
# use real rclone; event order proves COMPLETE was withheld until success.
|
||||||
wrapper=self.bin/'rclone'
|
wrapper=self.bin/'rclone'
|
||||||
@@ -1199,9 +1356,9 @@ import os, pathlib, subprocess, sys, time
|
|||||||
args=sys.argv[1:]
|
args=sys.argv[1:]
|
||||||
events=pathlib.Path(os.environ['TEST_EVENTS'])
|
events=pathlib.Path(os.environ['TEST_EVENTS'])
|
||||||
if 'check' in args:
|
if 'check' in args:
|
||||||
with events.open('a') as log: log.write('readback-check\\n')
|
with events.open('a') as log: log.write('metadata_check-check\\n')
|
||||||
count=events.read_text().count('readback-check')
|
count=events.read_text().count('metadata_check-check')
|
||||||
mode=os.environ['TEST_READBACK_MODE']
|
mode=os.environ['TEST_METADATA_CHECK_MODE']
|
||||||
if mode=='signal': sys.exit(143)
|
if mode=='signal': sys.exit(143)
|
||||||
if mode=='hang' or (mode=='hang-once' and count==1): time.sleep(30)
|
if mode=='hang' or (mode=='hang-once' and count==1): time.sleep(30)
|
||||||
if mode=='fail-once' and count==1:
|
if mode=='fail-once' and count==1:
|
||||||
@@ -1212,23 +1369,23 @@ if 'copyto' in args:
|
|||||||
sys.exit(subprocess.call(['/usr/bin/rclone',*args]))
|
sys.exit(subprocess.call(['/usr/bin/rclone',*args]))
|
||||||
''')
|
''')
|
||||||
wrapper.chmod(0o755)
|
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.crypt_config()
|
||||||
self.inject_readback_failure('fail-once')
|
self.inject_metadata_check_failure('fail-once')
|
||||||
result=self.run_script('app-backup.sh')
|
result=self.run_script('app-backup.sh')
|
||||||
self.assertIn('attempt 2 passed',result.stdout)
|
self.assertIn('attempt 2 passed',result.stdout)
|
||||||
events=self.events.read_text()
|
events=self.events.read_text()
|
||||||
self.assertEqual(events.count('readback-check'),2)
|
self.assertEqual(events.count('metadata_check-check'),2)
|
||||||
self.assertLess(events.rindex('readback-check'),events.index('publish-marker'))
|
self.assertLess(events.rindex('metadata_check-check'),events.index('publish-marker'))
|
||||||
run=next((self.base/'work/archives').glob('appbackup_*'))
|
run=next((self.base/'work/archives').glob('appbackup_*'))
|
||||||
self.assertTrue((run/'UPLOADED').exists())
|
self.assertTrue((run/'UPLOADED').exists())
|
||||||
self.run_script('app-restore.sh','--remote-run',run.name,'--verify-only')
|
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.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')
|
with self.config.open('a') as f: f.write('RCLONE_CHECK_DEADLINE=2\n')
|
||||||
result=self.run_script('app-backup.sh')
|
result=self.run_script('app-backup.sh')
|
||||||
self.assertIn('exit 124',result.stdout)
|
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):
|
def test_persistent_stall_is_bounded_and_preserves_local_backup(self):
|
||||||
self.crypt_config()
|
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')
|
with self.config.open('a') as f: f.write('RCLONE_CHECK_DEADLINE=1\nRCLONE_CHECK_ATTEMPTS=2\n')
|
||||||
started=time.monotonic()
|
started=time.monotonic()
|
||||||
result=self.run_script('app-backup.sh',ok=False)
|
result=self.run_script('app-backup.sh',ok=False)
|
||||||
self.assertLess(time.monotonic()-started,20)
|
self.assertLess(time.monotonic()-started,20)
|
||||||
self.assertEqual(result.returncode,124)
|
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)
|
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.crypt_config()
|
||||||
self.inject_readback_failure('signal')
|
self.inject_metadata_check_failure('signal')
|
||||||
result=self.run_script('app-backup.sh',ok=False)
|
result=self.run_script('app-backup.sh',ok=False)
|
||||||
self.assertEqual(result.returncode,143)
|
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)
|
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'),
|
for key,value in [('RCLONE_CHECK_ATTEMPTS','0'),('RCLONE_CHECK_ATTEMPTS','11'),
|
||||||
('RCLONE_CHECK_DEADLINE','0'),('RCLONE_CHECK_DEADLINE','1h'),
|
('RCLONE_CHECK_DEADLINE','0'),('RCLONE_CHECK_DEADLINE','1h'),
|
||||||
('RCLONE_CHECK_TIMEOUT','0'),('RCLONE_CHECK_LOW_LEVEL_RETRIES','0'),
|
('RCLONE_CHECK_TIMEOUT','0'),('RCLONE_CHECK_LOW_LEVEL_RETRIES','0'),
|
||||||
@@ -1267,14 +1424,14 @@ sys.exit(subprocess.call(['/usr/bin/rclone',*args]))
|
|||||||
finally:
|
finally:
|
||||||
self.config.write_text(original)
|
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()
|
self.crypt_config()
|
||||||
result=self.remote_failure('bad-header')
|
self.remote_failure('bad-header',ok=True)
|
||||||
self.assertIn('bad magic string',result.stdout)
|
run=next((self.base/'work/archives').glob('appbackup_*'))
|
||||||
self.assertIn('attempt 3/3 failed',result.stdout)
|
self.assertTrue((run/'UPLOADED').exists())
|
||||||
self.assert_remote_failure_retained(result)
|
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
|
# Keep real crypt transfers. Inject errors at the command boundary, not
|
||||||
# a pretend OneDrive server: provider retry behaviour is checked separately.
|
# a pretend OneDrive server: provider retry behaviour is checked separately.
|
||||||
wrapper = self.bin/'rclone'
|
wrapper = self.bin/'rclone'
|
||||||
@@ -1300,6 +1457,11 @@ if 'copy' in args:
|
|||||||
if mode=='upload':
|
if mode=='upload':
|
||||||
# Models an unsuccessful command even though the remote received bytes.
|
# Models an unsuccessful command even though the remote received bytes.
|
||||||
sys.exit(44)
|
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':
|
if mode=='bad-header':
|
||||||
# Damage the real encrypted header in place without changing object size.
|
# Damage the real encrypted header in place without changing object size.
|
||||||
from pathlib import Path
|
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=bytearray(rc([*prefix,'cat',remote],capture_output=True,check=True).stdout)
|
||||||
data[len(data)//2]^=1
|
data[len(data)//2]^=1
|
||||||
# Same-size plaintext corruption, re-encrypted through the real crypt backend.
|
# 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)
|
rc([*prefix,'rcat',remote],input=bytes(data),check=True)
|
||||||
''')
|
''')
|
||||||
wrapper.chmod(0o755)
|
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):
|
def assert_remote_failure_retained(self, result, marker_present=False):
|
||||||
status=json.loads((self.base/'work/last-backup.json').read_text())
|
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.assertTrue(path.is_dir())
|
||||||
self.assertIn(path.name,remote)
|
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()
|
self.crypt_config()
|
||||||
result=self.remote_failure('corrupt')
|
self.remote_failure('corrupt',ok=True)
|
||||||
self.assert_remote_failure_retained(result)
|
run=next((self.base/'work/archives').glob('appbackup_*'))
|
||||||
self.assertFalse((self.base/'work/last-successful-backup.json').exists())
|
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()
|
self.crypt_config()
|
||||||
result=self.remote_failure('corrupt',RCLONE_SIZE_ONLY='true')
|
result=self.remote_failure('corrupt',RCLONE_SIZE_ONLY='true')
|
||||||
self.assertIn('Unsupported rclone environment override: RCLONE_SIZE_ONLY',result.stdout)
|
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):
|
def test_marker_read_mismatch_blocks_upload_success(self):
|
||||||
self.crypt_config()
|
self.crypt_config()
|
||||||
result=self.remote_failure('marker-read')
|
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.
|
# confirmation failed. Local success and retention must remain blocked.
|
||||||
self.assert_remote_failure_retained(result,marker_present=True)
|
self.assert_remote_failure_retained(result,marker_present=True)
|
||||||
self.assertIn('Remote completion marker mismatch',result.stdout)
|
self.assertIn('Remote completion marker mismatch',result.stdout)
|
||||||
|
|||||||
@@ -149,6 +149,27 @@ MAIL_SENDMAIL_BIN="{self.sendmail}"
|
|||||||
self.assertIn('stale',self.state()['error'])
|
self.assertIn('stale',self.state()['error'])
|
||||||
self.clean()
|
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):
|
def test_corrupt_archive_is_not_success_and_cleans(self):
|
||||||
(self.folder/'wordpress.tar.gz').write_bytes(b'corrupt')
|
(self.folder/'wordpress.tar.gz').write_bytes(b'corrupt')
|
||||||
self.upload()
|
self.upload()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -12,6 +13,55 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'lib'))
|
|||||||
import space
|
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')
|
@unittest.skipUnless(os.geteuid() == 0, 'root-only cleanup policy')
|
||||||
class SpaceRecovery(unittest.TestCase):
|
class SpaceRecovery(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -70,6 +120,29 @@ class SpaceRecovery(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'mounted'): self.clean()
|
with self.assertRaisesRegex(ValueError, 'mounted'): self.clean()
|
||||||
self.assertTrue(mount.exists())
|
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):
|
def test_verification_download_recovered_manual_restore_kept(self):
|
||||||
verify = self.restore/(self.run+'.abcdef')
|
verify = self.restore/(self.run+'.abcdef')
|
||||||
verify.mkdir(mode=0o700)
|
verify.mkdir(mode=0o700)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def build(output, version):
|
|||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
if [[ ${{1:-}} == --help ]]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
[[ $EUID == 0 ]] || {{ echo 'Bitte mit sudo bash starten.' >&2; exit 1; }}
|
[[ $EUID == 0 ]] || {{ echo 'Bitte mit sudo bash starten.' >&2; exit 1; }}
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ UP_MAIL_HOMES=false
|
|||||||
UP_STATE=''
|
UP_STATE=''
|
||||||
UP_WORKER=false
|
UP_WORKER=false
|
||||||
UP_RESTORE_TEST=false
|
UP_RESTORE_TEST=false
|
||||||
|
UP_AI_REM=false
|
||||||
while (( $# )); do
|
while (( $# )); do
|
||||||
case $1 in
|
case $1 in
|
||||||
--config) CONFIG_FILE=${2:?Missing configuration}; shift 2 ;;
|
--config) CONFIG_FILE=${2:?Missing configuration}; shift 2 ;;
|
||||||
--foreground) UP_FOREGROUND=true; shift ;;
|
--foreground) UP_FOREGROUND=true; shift ;;
|
||||||
--install-deps) UP_INSTALL_DEPS=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-restore-test) UP_RESTORE_TEST=true; shift ;;
|
||||||
--enable-gitea) UP_GITEA=true; shift ;;
|
--enable-gitea) UP_GITEA=true; shift ;;
|
||||||
--no-gitea) UP_GITEA=false; 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-dir) UP_MAIL_DIR=${2:?Missing mailbox directory}; shift 2 ;;
|
||||||
--mail-homes-only) UP_MAIL_HOMES=true; shift ;;
|
--mail-homes-only) UP_MAIL_HOMES=true; shift ;;
|
||||||
--worker) UP_WORKER=true; UP_STATE=${2:?Missing state}; shift 2 ;;
|
--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" ;;
|
*) die "Unknown argument: $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -200,6 +202,40 @@ if [[ $UP_WORKER == false ]]; then
|
|||||||
append_setting RCLONE_CONFIG "$RCLONE_CONFIG"
|
append_setting RCLONE_CONFIG "$RCLONE_CONFIG"
|
||||||
printf '%s\n' "$RCLONE_CONFIG" > "$UP_STATE/rclone-path"
|
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 [[ -n $UP_CRON ]]; then IFS=, read -r -a additional <<< "$UP_CRON"; QUIESCE_SERVICES+=("${additional[@]}"); fi
|
||||||
if [[ $ENABLE_NEXTCLOUD == true ]]; then
|
if [[ $ENABLE_NEXTCLOUD == true ]]; then
|
||||||
discover cron-units "$NC_DIR" > "$UP_STATE/cron-units"
|
discover cron-units "$NC_DIR" > "$UP_STATE/cron-units"
|
||||||
@@ -263,7 +299,7 @@ if [[ $UP_WORKER == false ]]; then
|
|||||||
fi
|
fi
|
||||||
sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256"
|
sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256"
|
||||||
echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")"
|
echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")"
|
||||||
echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.'
|
echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Dateipruefung.'
|
||||||
printf 'QUEUED\n' > "$UP_STATE/result.txt"
|
printf 'QUEUED\n' > "$UP_STATE/result.txt"
|
||||||
if [[ $UP_FOREGROUND == true ]]; then
|
if [[ $UP_FOREGROUND == true ]]; then
|
||||||
flock -u 9; flock -u 8
|
flock -u 9; flock -u 8
|
||||||
@@ -311,7 +347,7 @@ worker_finish() {
|
|||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
set +e
|
set +e
|
||||||
if [[ $UP_OK == true && $code == 0 ]]; then
|
if [[ $UP_OK == true && $code == 0 ]]; then
|
||||||
printf 'SUCCESS\nPaket aktualisiert, Backup und Remote-Restore-Pruefung erfolgreich.\nBackup-Timer aktiv.\n' > "$UP_STATE/result.txt"
|
printf 'SUCCESS\nPaket aktualisiert, Backup und Remote-Dateipruefung erfolgreich.\nArchiv-Inhaltspruefung erfolgt beim monatlichen Restore-Test.\nBackup-Timer aktiv.\n' > "$UP_STATE/result.txt"
|
||||||
else
|
else
|
||||||
if [[ $UP_CHANGED == true ]]; then systemctl stop app-backup.timer app-restore-test.timer; fi
|
if [[ $UP_CHANGED == true ]]; then systemctl stop app-backup.timer app-restore-test.timer; fi
|
||||||
printf 'FAILED (exit %s)\nDetails: %s/update.log\n' "$code" "$UP_STATE" > "$UP_STATE/result.txt"
|
printf 'FAILED (exit %s)\nDetails: %s/update.log\n' "$code" "$UP_STATE" > "$UP_STATE/result.txt"
|
||||||
@@ -390,12 +426,10 @@ assert value['exit_code'] == 0
|
|||||||
print(value['run'])
|
print(value['run'])
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
if [[ $ENABLE_RESTORE_TEST == true ]]; then
|
# The backup already confirmed remote file presence, sizes and COMPLETE.
|
||||||
bash /usr/local/sbin/app-restore-test.sh --config "$CONFIG_FILE" --remote-run "$backup_run"
|
# Archive downloads belong to the monthly rehearsal, not update acceptance.
|
||||||
else
|
printf '%s\n' "$backup_run" > "$UP_STATE/uploaded-backup-run"
|
||||||
bash /usr/local/sbin/app-restore.sh --config "$CONFIG_FILE" --remote-run "$backup_run" --verify-only
|
echo '-- Update acceptance: upload and remote metadata confirmed; archive restore deferred to monthly test'
|
||||||
fi
|
|
||||||
printf '%s\n' "$backup_run" > "$UP_STATE/verified-backup-run"
|
|
||||||
# A missed persistent timer can fire immediately, so release the shared lock first.
|
# A missed persistent timer can fire immediately, so release the shared lock first.
|
||||||
unset APP_BACKUP_INHERIT_LOCK
|
unset APP_BACKUP_INHERIT_LOCK
|
||||||
flock -u 9
|
flock -u 9
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -Eeuo pipefail
|
||||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
LIB_DIR="$SCRIPT_DIR/../lib/app-backup"
|
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)'
|
echo '-- Remote upload started (archives and manifest; completion marker withheld)'
|
||||||
BACKUP_PHASE='Remote-Upload'; UPLOAD_STATUS=COPY
|
BACKUP_PHASE='Remote-Upload'; UPLOAD_STATUS=COPY
|
||||||
rc copy "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED --immutable
|
rc copy "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED --immutable
|
||||||
echo '-- Remote upload finished; reading all uploaded files back for verification'
|
echo '-- Remote upload finished; checking uploaded file presence and sizes (no archive download)'
|
||||||
BACKUP_PHASE='Remote-Rückleseprüfung'; UPLOAD_STATUS=CHECK
|
BACKUP_PHASE='Remote-Dateiprüfung'; UPLOAD_STATUS=CHECK
|
||||||
remote_readback "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED
|
remote_metadata_check "$RUN_DIR" "$remote_run" --exclude /COMPLETE --exclude /UPLOADED
|
||||||
echo '-- Remote readback passed; publishing completion marker'
|
echo '-- Remote metadata check passed; publishing completion marker'
|
||||||
BACKUP_PHASE='Remote-Abschlussmarker'; UPLOAD_STATUS=MARKER
|
BACKUP_PHASE='Remote-Abschlussmarker'; UPLOAD_STATUS=MARKER
|
||||||
rc copyto "$RUN_DIR/COMPLETE" "$remote_run/COMPLETE" --immutable
|
rc copyto "$RUN_DIR/COMPLETE" "$remote_run/COMPLETE" --immutable
|
||||||
# Command substitution strips trailing newlines, which could accept a marker
|
# Command substitution strips trailing newlines, which could accept a marker
|
||||||
@@ -136,7 +136,7 @@ PY
|
|||||||
date -u -Is > "$RUN_DIR/UPLOADED"
|
date -u -Is > "$RUN_DIR/UPLOADED"
|
||||||
sync -f "$RUN_DIR"
|
sync -f "$RUN_DIR"
|
||||||
UPLOAD_STATUS=VERIFIED
|
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
|
fi
|
||||||
|
|
||||||
if [[ $SKIP_RETENTION == true ]]; then
|
if [[ $SKIP_RETENTION == true ]]; then
|
||||||
|
|||||||
Reference in new issue
Block a user