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