From ff152c7f0f81a6ca62915bff63d20de77bda09c8 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Thu, 10 Sep 2026 15:28:42 +0200 Subject: [PATCH] Guard writer units against activation and fix backup cancellation --- Dokumentation.md | 60 ++++++++++++- README.md | 1 + UPDATE_ANLEITUNG.md | 51 ++++++++++- lib/common.sh | 106 +++++++++++++++++++++-- systemd/app-backup.service | 3 +- tests/test_backup.py | 154 +++++++++++++++++++++++++++++++++- update.sh | 5 +- usr-local-sbin/app-backup.sh | 6 ++ usr-local-sbin/app-restore.sh | 2 + 9 files changed, 372 insertions(+), 16 deletions(-) diff --git a/Dokumentation.md b/Dokumentation.md index 64fbdca..3dde2bf 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -201,6 +201,40 @@ Gitea: `APP_DATA_PATH`, `[repository] ROOT`, Custom-Verzeichnis und `app.ini` pr `MAIL_BACKUP_MODE=live` und abgeschaltete Stopps sind bewusste Ausnahmen mit Konsistenzrisiko. Das Log weist darauf hin. Home-Verzeichnisse werden mit `--one-file-system` kopiert: Daten anderer Mounts fehlen dann absichtlich. Mailqueues, zusätzliche Spamfilter-/DKIM-Verzeichnisse oder Maildatenbanken sind nur gesichert, wenn sie explizit im Umfang stehen. +Ein einfacher `systemctl stop` verhindert keinen späteren Start durch andere +Aufträge. Auf Gemini starteten am 10.09.2026 um 15:15:00 Uhr Apache und PHP-FPM +während der bis mindestens 15:16:33 laufenden Nextcloud-Datenkopie erneut. Die +Starts erfolgten vor der Wiederaufnahme durch das Backup. Dieser Lauf erfüllt +die vereinbarte Konsistenzbedingung nicht; eine erfolgreiche Hash-/Remote-Prüfung +kann dies nicht nachträglich beweisen. Der auslösende Auftrag ist noch unbekannt. + +Vor dem Stoppen erhält nun jeder konfigurierte Writer eine eigene temporäre +systemd-Ergänzung unter `/run/systemd/system/UNIT.d/zzzz-app-backup-LAUF.conf`. +`ConditionPathExists=!/run/app-backup-guard-ZUFALL` verhindert den Start, solange +das zugehörige Verzeichnis besteht. Das gilt auch für zuvor inaktive konfigurierte +Writer und für Dienste mit eigener Unit-Datei unter `/etc`. Bestehende Maskierungen +werden nicht verändert. Die Laufzeitergänzung muss nach `daemon-reload` als letztes +Drop-in gemeldet werden; andernfalls bricht der Lauf ab, damit spätere +Condition-Resets die Sperre nicht unbemerkt überschreiben. + +Nach dem Anhalten aller Writer, vor und nach den Dumps/Dateikopien und unmittelbar +vor der Freigabe prüft das Backup Dienstzustand, eigene Sperrdateien und die +systemd-Zeitpunkte `ActiveEnterTimestampMonotonic` und `InactiveExitTimestampMonotonic`. +Eine erkannte Aktivierung, veränderte Sperre oder nicht prüfbarer Zustand führt +zum Fehler, bevor Archive als vollständiger Satz veröffentlicht werden. Diese +Kontrollen ersetzen keine vollständige Ereignisaufzeichnung und schützen nicht +vor nicht konfigurierten Prozessen, direktem Programmstart außerhalb systemd oder +gezieltem administrativem Entfernen der Absicherung zwischen zwei Kontrollen. +Die systemd-Startbedingung verhindert reguläre Startaufträge während des Fensters. +Quelle: [systemd-Startbedingungen](https://github.com/systemd/systemd/blob/v256/man/systemd.unit.xml). + +Bei erfolgreicher Kopie und bei normalen Backupfehlern entfernt der Wiederanlauf +ausschließlich die eigenen Drop-ins, lädt systemd neu und entfernt das zugehörige +Sperrverzeichnis. Erst danach werden die zuvor aktiven Dienste gestartet; deren +aktiver Zustand wird überprüft. Scheitert die Freigabe, bleiben Wiederanlaufdaten +erhalten und es wird kein Erfolg gemeldet. Bei teilweise ausgeführtem Restore +bleiben die Startsperren zusammen mit dem Wartungsmodus zur manuellen Prüfung bestehen. + `ENABLE_MAIL_STORE=true` bleibt der kompatible Default: `MAIL_DIR` muss existieren. Bei Mailboxen ausschließlich in Benutzerverzeichnissen kann explizit `ENABLE_MAIL_STORE=false` gesetzt werden. Dann sind aktivierte, nicht leere @@ -250,6 +284,25 @@ Für den täglichen Zeitplan z. B. bei mehr als 36 Stunden ohne erfolgreichen La 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. +Backup-Unit und Update-Auftrag verwenden `KillMode=control-group`: Beim Stoppen +erhalten auch laufende Dump-, rsync-, Kompressions- und rclone-Prozesse TERM. +Dadurch können die wartenden Shells ihre Fehlerbehandlung ausführen, statt bis +zum regulären Ende eines langen Uploads zu warten. Die tee-Prozesse ignorieren +INT/TERM und enden mit dem Schließen ihrer Eingabe; so bleibt die Protokollierung +während der Wiederaufnahme erhalten. Die fünfminütige Stop-Frist bleibt bestehen. +Sie garantiert keine Wiederherstellung bei blockierten Kernelzugriffen oder +fehlgeschlagenen Dienststarts. Quelle: +[systemd-Signalzustellung](https://github.com/systemd/systemd/blob/v256/man/systemd.kill.xml). + +Die Wiederanlaufdatei enthält außerdem `service_guard_files` und +`service_guard_directory`. Bei manueller Wiederherstellung nur die darin genannten +eigenen Drop-in-Dateien entfernen, `systemctl daemon-reload` ausführen und danach +das zugehörige leere Sperrverzeichnis mit `rmdir` entfernen. Keine fremden Drop-ins +oder Maskierungen löschen. Erst nach Prüfung des Datenzustands die aufgezeichneten +Dienste und den Wartungsmodus wiederherstellen. Die Sperren liegen unter `/run` +und überleben keinen Neustart; ein Reboot ist deshalb keine sichere Reparatur +eines teilweise ausgeführten Restores. + ## Wiederherstellung ### Prüfphase @@ -360,10 +413,15 @@ Die Entfernung der drei DB-Dateien aus dem aktuellen Baum löscht deren Historie Reproduzierbar mit `tests/Dockerfile` und `tests/run.sh`. Der Container bekommt das Repository nur lesbar und benötigt während der Tests kein Netzwerk. Die MariaDB läuft ohne TCP-Port in einem temporären Verzeichnis. systemd/occ werden simuliert; alle eigentlichen Archive, Dateikopien, Dumps/Imports und Crypt-Operationen sind real. -Ergebnis des lokalen Abschlusslaufs am 10.09.2026: **44 Tests erfolgreich**, Shell-Syntax und ShellCheck ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. +Verifikation am 10.09.2026: **52 Testfälle erfolgreich** — 50 im vollständigen Durchlauf, anschließend beide zusätzlichen Abbruchtests für Kopie und Upload auf dem ergänzten Stand. Shell-Syntax und ShellCheck abschließend ohne Befund. Testumgebung: Debian 13.6, MariaDB 11.8.6, rclone 1.60.1-DEV (Distributionspaket), ShellCheck 0.10.0. Diese Versionsangaben beschreiben die Testumgebung, keine Empfehlung für die Produktivversion. Geprüfte Fälle umfassen: +- Fremde Startversuche während realer rsync-Kopien werden durch das systemd-Testmodell übersprungen; zuvor inaktive Writer bleiben nach dem Lauf inaktiv. +- Aktive sowie zwischenzeitlich gestartete und wieder gestoppte Writer verhindern die Veröffentlichung eines vollständigen Satzes. +- Nicht geladene Startsperre, Erhalt vorhandener Maskierungen und fehlgeschlagene Sperrfreigabe mit erhaltener Wiederanlaufdatei. +- Bei teilweise ausgeführtem Restore bleiben eigene Startsperren erhalten. `systemd-analyze` prüft die reale Condition-Auswertung und Unit-Syntax separat, ohne einen Dienst zu starten. +- TERM an die gesamte simulierte Unit-Prozessgruppe während blockierter Kopie beziehungsweise blockiertem Upload: zeitnahes Fehlerergebnis, Dienstwiederaufnahme, freigegebene Startsperren, erhaltenes Protokoll und gestoppter Backup-Timer. - Verweigerter rsync-Quellzugriff bricht das Update vor Installation und Dienst-Stopp ab. - Verweigerte Verzeichnisattribute werden vor Wartungsmodus erkannt; der private Prüfbereich wird entfernt. - Attributprobe kopiert keine Kindobjekte; rsync erbt bei Prüfung, Backup und Restore keine Update-/Backup-Sperrdeskriptoren. diff --git a/README.md b/README.md index f0b1947..30ffa69 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Details und geprüfte Grenzen stehen in der Update-Anleitung. - 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. - 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. - 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. Alte Sicherungen werden nicht automatisch gelöscht. - Private Arbeitsverzeichnisse, gemeinsame Sperre für Backup/Restore/Update und Wiederanlaufhinweise nach Abbrüchen. diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index 67484eb..368968a 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -311,6 +311,49 @@ passenden AVC-Meldungen prüfen. Maßgeblich für den erfolgreichen Abschluss bl `SUCCESS` in `result.txt`; erst danach aktiviert der Installer den Backup-Timer. Eine Wiederherstellungsprobe auf einem separaten System steht weiterhin aus. +## Erneuter Dienststart während der Sicherung + +Am 10.09.2026 starteten auf Gemini um 15:15 Uhr Apache und PHP-FPM während der +Nextcloud-Datenkopie erneut. Paket `c0a24b63fc1c` hatte die SELinux-Zugriffe bereits +korrigiert, schützte jedoch nicht vor fremden Startaufträgen. Den betroffenen +Lauf nicht als konsistente Sicherung abnehmen, auch falls Upload/Hashprüfung +anschließend erfolgreich waren. Der konkrete Auslöser des Startauftrags ist +noch offen. Die 90 Sekunden Apache-Stopp sind dagegen durch dessen Journal +erklärt: Timeout beim Beenden, anschließend SIGKILL durch systemd. Das neue Paket +ändert weder Apache-Timeout noch seine Signalbehandlung pauschal. + +Einen noch laufenden alten Update-Auftrag kontrolliert beenden: + +```bash +sudo systemctl stop app-backup-upgrade.service +sudo journalctl --no-pager -u app-backup-upgrade.service -n 60 +sudo systemctl --no-pager is-active httpd php-fpm gitea postfix dovecot crond +``` + +Im Protokoll die Wiederaufnahme und das Verlassen des Wartungsmodus prüfen. +Bei Fehlern die ausgegebene `recovery-*.json` beachten. Erst nach abgeschlossenem +alten Auftrag das aktuelle Paket mit den bisherigen Gitea-/Cron-/Mail-Optionen +starten. Bestehende Sicherungssätze und Arbeitsverzeichnisse nicht pauschal löschen. + +Das neue Paket richtet für die Kopierphase vorübergehende systemd-Startbedingungen +ein und prüft Writer-Zustände sowie Aktivierungszeitpunkte vor/nach den Kopien. +Es entfernt beim Wiederanlauf nur eigene Ergänzungen; bei einem teilweise +ausgeführten Restore bleiben sie bis zur manuellen Prüfung bestehen. Einzelheiten +zur gezielten Freigabe stehen in `Dokumentation.md` bei der Wiederanlaufhilfe. +Die permanente Dienstkonfiguration und vorhandene Maskierungen bleiben erhalten. +Die Meldung `Copy window complete; writer guards verified` bestätigt das Ende +der geprüften Kopierphase. Abgeschlossen ist die Paketabnahme erst bei `SUCCESS` +in der Ergebnisdatei nach vollständiger Remote-Prüfung. + +Der Abbruchpfad wurde ebenfalls korrigiert: Bisher erreichte TERM bei +`KillMode=mixed` zunächst nur den Update-Hauptprozess. Eine wartende Bash konnte +dadurch den Stop bis zum Ende ihres Backup-/Upload-Kindprozesses verzögern. +Die neuen Units verwenden `KillMode=control-group`; die Protokollprozesse bleiben +während der Fehlerbehandlung verfügbar. Nach einem Update-Abbruch das Journal +und die Dienstzustände prüfen; insbesondere ein Stop-Timeout ist kein Nachweis +für eine erfolgreiche Wiederaufnahme. Die Änderung wirkt für neue Aufträge, +nicht nachträglich für einen bereits gestarteten alten Worker. + ## Paket reproduzieren Aus einem sauberen, eingecheckten Repository: @@ -327,7 +370,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -44 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. +52 Testfälle erfolgreich: vollständiger Durchlauf mit 50 Tests, anschließend die zwei zusätzlichen Abbruchtests für Kopie und Upload. ShellCheck und Shell-Syntaxprüfung abschließend erfolgreich. Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips; systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell berücksichtigt jetzt das Warten auf den oneshot-Startauftrag: Der Regressionstest @@ -338,3 +381,9 @@ SELinux-Durchsetzung wird im Debian-Testcontainer nicht nachgebildet; der erfolg Bash-/rsync-Lesetest auf Gemini wurde separat vom Betreiber durchgeführt. Die vollständige Server-/OneDrive-Prüfung erfolgt beim Paketlauf. Ein fachlicher Restore auf einem separaten Testsystem bleibt erforderlich. + +Die neuen Tests bilden fremde Startaufträge während rsync, zwischenzeitliche +Neustarts, bestehende Maskierungen und Fehler bei der Sperrfreigabe ab. Die +Condition-Auswertung (Sperrverzeichnis vorhanden/entfernt) und Unit-Syntax wurden +zusätzlich mit echtem `systemd-analyze` geprüft. Der tatsächliche systemd-Manager +und SELinux auf Gemini bleiben Teil der noch ausstehenden Serverabnahme. diff --git a/lib/common.sh b/lib/common.sh index 800a86f..f1aab7a 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -116,12 +116,14 @@ init_runtime() { RUN_ID="${ARCHIVE_PREFIX}_$(date -u +%Y-%m-%d_%H-%M-%S)_$(python3 -c 'import secrets; print(secrets.token_hex(4))')" LOG_FILE="$LOG_DIR/${OPERATION}_${RUN_ID}.log" # Journald already captures service stdout. No dependency on systemd-cat in terminals. - exec > >(tee -a "$LOG_FILE") 2>&1 + # Keep the log pipe alive while systemd sends TERM to the whole control group. + # tee exits on EOF after the recovery traps close their writers. + exec > >(trap '' INT TERM; exec tee -a "$LOG_FILE") 2>&1 echo "== $OPERATION $RUN_ID ==" local security_context if security_context=$(id -Z 2>/dev/null); then echo "-- SELinux process context: $security_context"; fi need rsync; need tar; need "$COMPRESSOR" - STOPPED_SERVICES=(); NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false + STOPPED_SERVICES=(); GUARDED_SERVICES=(); GUARD_FILES=(); GUARD_STAMPS=(); SERVICE_GUARD_DIR=''; NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false trap 'exit 130' INT trap 'exit 143' TERM trap finish EXIT @@ -284,8 +286,83 @@ exclusions() { fi } +# A stop alone does not exclude timer/monitor/dependency activation. Attach a +# run-owned runtime condition before stopping each writer. Unlike a runtime mask, +# a drop-in also works for administrator units whose main file lives in /etc. +# This runtime root is overridable only by the trusted config (isolated tests). +install_service_guard() { + local unit=$1 id load dir file paths existing + for id in "${GUARDED_SERVICES[@]}"; do [[ $id != "$unit" ]] || return 0; done + load=$(systemctl show "$unit" --property=LoadState --value) + [[ $load == loaded || $load == masked ]] || die "Cannot guard unloaded unit: $unit ($load)" + if [[ $load == masked ]]; then + # Preserve pre-existing masks verbatim; never claim ownership of them. + GUARDED_SERVICES+=("$unit"); GUARD_FILES+=(''); return 0 + fi + id=$(systemctl show "$unit" --property=Id --value) + [[ $id =~ ^[a-zA-Z0-9_@:.\\-]+\.(service|timer|socket|path)$ ]] || die "Unsupported writer unit name: $id" + : "${SERVICE_GUARD_RUNTIME_ROOT:=/run}" + [[ $SERVICE_GUARD_RUNTIME_ROOT =~ ^/[a-zA-Z0-9_./-]+$ ]] || die 'Unsafe service guard runtime root' + if [[ -z $SERVICE_GUARD_DIR ]]; then + SERVICE_GUARD_DIR=$(mktemp -d "$SERVICE_GUARD_RUNTIME_ROOT/app-backup-guard-XXXXXXXX") + fi + dir="$SERVICE_GUARD_RUNTIME_ROOT/systemd/system/$id.d" + [[ ! -L $dir ]] || die "Symlink writer drop-in directory refused: $dir" + mkdir -p -- "$dir" + [[ $(stat -c %u "$dir") == 0 ]] || die "Writer drop-in directory must belong to root: $dir" + (( (8#$(stat -c %a "$dir") & 8#022) == 0 )) || die "Unsafe writer drop-in directory: $dir" + file="$dir/zzzz-app-backup-$RUN_ID.conf" + for existing in "${GUARD_FILES[@]}"; do [[ $existing != "$file" ]] || return 0; done + [[ ! -e $file && ! -L $file ]] || die "Guard file already exists: $file" + GUARDED_SERVICES+=("$unit"); GUARD_FILES+=("$file") + # Record our exact paths BEFORE changing systemd state, including on failures. + save_recovery + (set -o noclobber; printf '[Unit]\nConditionPathExists=!%s\n' "$SERVICE_GUARD_DIR" > "$file") + systemctl daemon-reload + paths=$(systemctl show "$unit" --property=DropInPaths --value) + # Requiring our drop-in last avoids a later Condition*= resetting the guard. + [[ ${paths##* } == "$file" ]] || die "Start guard not loaded last for $unit; review its drop-ins" + echo "-- Start guard installed: $unit" +} + +assert_services_quiescent() { + local i unit state stamp paths + for i in "${!GUARDED_SERVICES[@]}"; do + unit=${GUARDED_SERVICES[i]} + state=$(systemctl show "$unit" --property=ActiveState --value) + [[ $state == inactive || $state == failed ]] || die "Consistency lost: writer $unit became $state during the copy window" + if [[ -n ${GUARD_FILES[i]} ]]; then + [[ -d $SERVICE_GUARD_DIR && -f ${GUARD_FILES[i]} ]] || die "Consistency lost: start guard removed for $unit" + [[ $(cat -- "${GUARD_FILES[i]}") == "$(printf '[Unit]\nConditionPathExists=!%s' "$SERVICE_GUARD_DIR")" ]] || die "Consistency lost: start guard changed for $unit" + paths=$(systemctl show "$unit" --property=DropInPaths --value) + [[ ${paths##* } == "${GUARD_FILES[i]}" ]] || die "Consistency lost: start guard overridden for $unit" + else + [[ $(systemctl show "$unit" --property=LoadState --value) == masked ]] || die "Consistency lost: existing mask removed for $unit" + fi + stamp=$(systemctl show "$unit" --property=ActiveEnterTimestampMonotonic --property=InactiveExitTimestampMonotonic) + if [[ -n ${GUARD_STAMPS[i]:-} && $stamp != "${GUARD_STAMPS[i]}" ]]; then + die "Consistency lost: writer $unit was restarted during the copy window" + fi + [[ -n $stamp ]] || die "Missing writer state timestamps: $unit" + GUARD_STAMPS[i]=$stamp + done +} + +release_service_guards() { + local file + # Remove only this run's drop-ins. Keep the condition directory until reload + # succeeds, so stale manager state continues to refuse starts on failure. + for file in "${GUARD_FILES[@]}"; do + [[ -z $file ]] || rm -f -- "$file" || return 1 + done + if (( ${#GUARDED_SERVICES[@]} )); then systemctl daemon-reload || return 1; fi + if [[ -n $SERVICE_GUARD_DIR ]]; then rmdir -- "$SERVICE_GUARD_DIR" || return 1; fi + SERVICE_GUARD_DIR=''; GUARDED_SERVICES=(); GUARD_FILES=(); GUARD_STAMPS=() +} + stop_service() { local state + install_service_guard "$1" state=$(systemctl show "$1" --property=ActiveState --value) case $state in active|activating|reloading) @@ -330,15 +407,19 @@ quiesce() { helper wait-nextcloud "$NC_DIR" "${WRITER_DRAIN_TIMEOUT:=300}" echo '-- Nextcloud writers finished' fi + assert_services_quiescent + echo '-- All configured writers stopped and protected against activation' } # Survives SIGKILL/power loss, which Bash traps cannot handle. This is an # instruction record for the operator, not an automatically executed recovery. save_recovery() { - python3 - "$WORKDIR/recovery-$RUN_ID.json" "$OPERATION" "$NC_CHANGED" "$NC_WAS_ON" "${STAGING_DIR:-}" "${STOPPED_SERVICES[@]}" <<'PYREC' + python3 - "$WORKDIR/recovery-$RUN_ID.json" "$OPERATION" "$NC_CHANGED" "$NC_WAS_ON" "${STAGING_DIR:-}" "$SERVICE_GUARD_DIR" "${#GUARD_FILES[@]}" "${GUARD_FILES[@]}" "${STOPPED_SERVICES[@]}" <<'PYREC' import json,os,sys -path,operation,changed,was_on,staging,*services=sys.argv[1:] +path,operation,changed,was_on,staging,guard_dir,count,*items=sys.argv[1:] +guard_files=items[:int(count)] +services=items[int(count):] with open(path+'.tmp','w') as stream: - json.dump(dict(operation=operation, staging=staging, nextcloud_changed=changed=='true', nextcloud_was_on=was_on=='true', stopped_services=services),stream,indent=2) + json.dump(dict(operation=operation, staging=staging, nextcloud_changed=changed=='true', nextcloud_was_on=was_on=='true', stopped_services=services, service_guard_directory=guard_dir, service_guard_files=[p for p in guard_files if p]),stream,indent=2) stream.flush() os.fsync(stream.fileno()) os.replace(path+'.tmp',path) @@ -346,16 +427,27 @@ PYREC } resume_services() { local failed=0 i + # Remove all our guards before starting dependencies in reverse order. Never + # start anything if guard cleanup/reload failed: retain manual recovery state. + if ! release_service_guards; then + echo 'ERROR: Could not release writer start guards; manual recovery required' + save_recovery + return 1 + fi if [[ $NC_CHANGED == true ]]; then if occ maintenance:mode --off; then NC_CHANGED=false; else failed=1; fi fi for ((i=${#STOPPED_SERVICES[@]}-1; i>=0; i--)); do [[ -n ${STOPPED_SERVICES[i]} ]] || continue echo "-- Starting service: ${STOPPED_SERVICES[i]} (waiting for systemd)" - if systemctl start "${STOPPED_SERVICES[i]}"; then + if systemctl start "${STOPPED_SERVICES[i]}" && + [[ $(systemctl show "${STOPPED_SERVICES[i]}" --property=ActiveState --value) == active ]]; then echo "-- Service started: ${STOPPED_SERVICES[i]}" STOPPED_SERVICES[i]='' - else failed=1; fi + else + echo "ERROR: Service did not become active: ${STOPPED_SERVICES[i]}" + failed=1 + fi done if (( failed == 0 )); then rm -f -- "$WORKDIR/recovery-$RUN_ID.json" diff --git a/systemd/app-backup.service b/systemd/app-backup.service index a36ea45..1317a55 100644 --- a/systemd/app-backup.service +++ b/systemd/app-backup.service @@ -16,7 +16,8 @@ SELinuxContext=system_u:system_r:unconfined_service_t:s0 # Timeout sends TERM: Bash runs its recovery trap; grace time permits service restart. TimeoutStartSec=12h TimeoutStopSec=5min -KillMode=mixed +# Interrupt current copy/dump/upload too, so Bash can run recovery promptly. +KillMode=control-group UMask=0077 Nice=10 IOSchedulingClass=best-effort diff --git a/tests/test_backup.py b/tests/test_backup.py index 1027ed1..586bbc3 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import signal import subprocess import tarfile import tempfile @@ -126,6 +127,8 @@ class Integration(unittest.TestCase): (self.wp/'nested/data').mkdir(parents=True) (self.wp/'nested/data/keep.txt').write_text('nested data must survive') (self.nc/'occ').write_text('test stub') + self.runtime=self.base/'runtime' + self.runtime.mkdir() self.state=self.base/'state.json' self.initial=dict(services={s:'active' for s in ['httpd','php-fpm','gitea','postfix','dovecot','nc-cron']}, maintenance=False) self.state.write_text(json.dumps(self.initial)) @@ -142,16 +145,36 @@ with open(os.environ['TEST_EVENTS'],'a') as f: f.write(name+' '+' '.join(args)+' if name=='systemctl': cmd=args[0] service=args[1] if len(args)>1 else '' + runtime=Path(os.environ['TEST_RUNTIME']) + unit=service if service.endswith(('.service','.timer','.socket','.path')) else service+'.service' + files=sorted((runtime/'systemd/system'/f'{unit}.d').glob('*.conf')) + guards=[] + for file in files: + for line in file.read_text().splitlines(): + if line.startswith('ConditionPathExists=!'): guards.append(Path(line.split('=!',1)[1])) + stamp_file=Path(str(p)+'.stamps') + stamps=json.loads(stamp_file.read_text()) if stamp_file.exists() else {} if cmd=='show': - if '--property=LoadState' in args: print('loaded' if service in s['services'] else 'not-found') + if '--property=LoadState' in args: print('masked' if service in s.get('masked',[]) else ('loaded' if service in s['services'] else 'not-found')) + elif '--property=Id' in args: print(unit) + elif '--property=DropInPaths' in args: print(' '.join(map(str,files)) if not os.environ.get('TEST_IGNORE_GUARD') else '') + elif '--property=ActiveEnterTimestampMonotonic' in args: print('ActiveEnterTimestampMonotonic='+str(stamps.get(service,1))+'\\nInactiveExitTimestampMonotonic='+str(stamps.get(service,1))) elif '--property=Environment' in args or '--property=TriggeredBy' in args: print('') else: print(s['services'].get(service,'inactive')) elif cmd=='is-active': sys.exit(0 if s['services'].get(args[-1])=='active' else 3) elif cmd in ('stop','start'): if os.environ.get('TEST_FAIL_SERVICE')==cmd+':'+service: sys.exit(1) + if cmd=='start': + if service in s.get('masked',[]): sys.exit(1) + if any(g.exists() for g in guards): + with open(os.environ['TEST_EVENTS'],'a') as f: f.write('guard blocked '+service+'\\n') + sys.exit(0) # systemd skips a failed Condition without starting the unit + stamps[service]=stamps.get(service,1)+1 + stamp_file.write_text(json.dumps(stamps)) s['services'][service]='active' if cmd=='start' else 'inactive' elif cmd=='enable': s['services'][args[-1]]='active' - elif cmd=='daemon-reload': pass + elif cmd=='daemon-reload': + if os.environ.get('TEST_FAIL_GUARD_RELEASE') and list(runtime.glob('app-backup-guard-*')) and not list(runtime.glob('systemd/system/*/zzzz-app-backup-*.conf')): sys.exit(1) elif name=='sudo': os.execvp(args[2],args[2:]) elif name=='php': @@ -181,7 +204,8 @@ exec {real} "$@" self.cnf.chmod(0o600) self.sql('DROP DATABASE IF EXISTS backup_test; CREATE DATABASE backup_test; CREATE TABLE backup_test.t (id INT PRIMARY KEY, body TEXT) ENGINE=MyISAM; INSERT INTO backup_test.t VALUES (1,"original");') self.config=self.base/'config' - self.config.write_text(f'''WORKDIR='{self.base}/work' + self.config.write_text(f'''SERVICE_GUARD_RUNTIME_ROOT='{self.runtime}' +WORKDIR='{self.base}/work' LOG_DIR='{self.base}/logs' LOCKFILE='{self.base}/lock' MIN_FREE_GB=0 @@ -212,7 +236,7 @@ GITEA_DB_CNF='{self.cnf}' QUIESCE_SERVICES=(nc-cron) ''') self.config.chmod(0o600) - self.env=dict(os.environ, PATH=str(self.bin)+':'+os.environ['PATH'],TEST_STATE=str(self.state),TEST_EVENTS=str(self.events)) + self.env=dict(os.environ,TEST_RUNTIME=str(self.runtime), PATH=str(self.bin)+':'+os.environ['PATH'],TEST_STATE=str(self.state),TEST_EVENTS=str(self.events)) def run_script(self, script, *args, ok=True, **env): proc=subprocess.run(['bash',str(REPO/'usr-local-sbin'/script),'--config',str(self.config),*map(str,args)],env=dict(self.env,**env),text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=90) @@ -312,6 +336,77 @@ if '--exclude=/*' in sys.argv: run=self.backup() self.run_script('app-restore.sh','--local-run',run,'--components','wordpress','--apply','--yes') + def test_start_guard_blocks_monitor_during_copy_and_preserves_inactive_writer(self): + state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=False) + self.state.write_text(json.dumps(state)) + wrapper=self.bin/'rsync' + wrapper.write_text('''#!/usr/bin/env bash +if [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]; then + systemctl start httpd + systemctl start php-fpm + systemctl start gitea +fi +exec /usr/bin/rsync "$@" +''') + self.backup() + self.assertEqual(json.loads(self.state.read_text()),state) + for unit in ['httpd','php-fpm','gitea']: + self.assertIn('guard blocked '+unit,self.events.read_text()) + self.assertFalse(list(self.runtime.glob('systemd/system/*/zzzz-app-backup-*.conf'))) + self.assertFalse(list(self.runtime.glob('app-backup-guard-*'))) + + def inject_writer_restart(self, final_state): + wrapper=self.bin/'rsync' + wrapper.write_text('''#!/usr/bin/env python3 +import json,os,subprocess,sys +from pathlib import Path +result=subprocess.run(['/usr/bin/rsync',*sys.argv[1:]]) +if result.returncode: sys.exit(result.returncode) +if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv: + p=Path(os.environ['TEST_STATE']) + state=json.loads(p.read_text()) + state['services']['httpd']='''+repr(final_state)+''' + p.write_text(json.dumps(state)) + p=Path(str(p)+'.stamps') + stamps=json.loads(p.read_text()) if p.exists() else {} + stamps['httpd']=stamps.get('httpd',1)+1 + p.write_text(json.dumps(stamps)) +''') + result=self.run_script('app-backup.sh',ok=False) + self.assertIn('Consistency lost:',result.stdout) + self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + self.assertFalse(list(self.runtime.glob('systemd/system/*/zzzz-app-backup-*.conf'))) + + def test_running_writer_prevents_backup_publication(self): + self.inject_writer_restart('active') + + def test_restarted_then_stopped_writer_prevents_backup_publication(self): + self.inject_writer_restart('inactive') + + def test_unloaded_start_guard_aborts_before_service_stop(self): + result=self.run_script('app-backup.sh',ok=False,TEST_IGNORE_GUARD='1') + self.assertIn('Start guard not loaded last',result.stdout) + self.assertNotIn('systemctl stop',self.events.read_text()) + self.assertEqual(json.loads(self.state.read_text()),self.initial) + self.assertFalse(list(self.runtime.glob('app-backup-guard-*'))) + + def test_existing_service_mask_is_preserved(self): + state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=False,masked=['gitea']) + self.state.write_text(json.dumps(state)) + self.backup() + self.assertEqual(json.loads(self.state.read_text()),state) + self.assertNotIn('systemctl start gitea',self.events.read_text()) + + def test_guard_release_failure_keeps_recovery_and_blocks_publication(self): + result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_GUARD_RELEASE='1') + self.assertIn('Could not release writer start guards',result.stdout) + self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) + self.assertTrue(json.loads(self.state.read_text())['maintenance']) + self.assertTrue(all(s=='inactive' for s in json.loads(self.state.read_text())['services'].values())) + recovery=json.loads(next((self.base/'work').glob('recovery-*.json')).read_text()) + self.assertTrue(Path(recovery['service_guard_directory']).is_dir()) + def test_maintenance_and_inactive_service_preserved(self): state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=True) self.state.write_text(json.dumps(state)) @@ -341,6 +436,9 @@ if '--exclude=/*' in sys.argv: self.assertIn('Partial restore',result.stdout) self.assertTrue(json.loads(self.state.read_text())['maintenance']) self.assertEqual(json.loads(self.state.read_text())['services']['httpd'],'inactive') + recovery=json.loads(next((self.base/'work').glob('recovery-*.json')).read_text()) + self.assertTrue(recovery['service_guard_files']) + self.assertTrue(all(Path(p).is_file() for p in recovery['service_guard_files'])) def test_check_does_not_claim_successful_backup(self): self.run_script('app-backup.sh','--check') @@ -675,6 +773,7 @@ from pathlib import Path args=sys.argv[1:] assert '--unit=app-backup-upgrade' in args assert '--property=Type=oneshot' in args +assert '--property=KillMode=control-group' in args assert '--property=SELinuxContext=system_u:system_r:unconfined_service_t:s0' in args command=args[args.index('/bin/bash'):] state=Path(command[-1]) @@ -682,6 +781,7 @@ if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'reject': sys.exit('systemd refused submission') with open(state/'test-worker.log','w') as log: worker=subprocess.Popen(command,stdin=subprocess.DEVNULL,stdout=log,stderr=subprocess.STDOUT,env=os.environ,close_fds=True,start_new_session=True) +(state/'test-worker.pid').write_text(str(worker.pid)) if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'accepted-error': sys.exit('simulated reply failure after systemd accepted the job') # Type=oneshot remains starting until ExecStart exits. Real systemd-run waits @@ -696,6 +796,52 @@ if '--no-block' not in args: ''') launcher.chmod(0o755) + def assert_group_term_recovers_services(self, tool): + self.configure_upgrade() + self.systemd_launcher() + wrapper=self.bin/tool + if tool=='rsync': + match='[[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]' + else: + match='[[ " $* " == *" copy "* ]]' + wrapper.write_text('#!/usr/bin/env bash\nif '+match+'; then\n echo waiting > "$TEST_EVENTS.waiting"\n sleep 120\nfi\nexec /usr/bin/'+tool+' "$@"\n') + package=self.build_package() + result=subprocess.run(['bash',str(package)],env=self.env,capture_output=True,text=True,timeout=60) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) + pid=int((state/'test-worker.pid').read_text()) + try: + for _ in range(400): + if Path(str(self.events)+'.waiting').exists(): break + time.sleep(.1) + else: self.fail('Worker did not reach '+tool+': '+(state/'test-worker.log').read_text()) + os.killpg(pid,signal.SIGTERM) # emulate systemd KillMode=control-group + for _ in range(200): + status=(state/'result.txt').read_text() + if status.startswith('FAILED'): break + time.sleep(.1) + self.assertTrue(status.startswith('FAILED'),status+(state/'test-worker.log').read_text()) + actual=json.loads(self.state.read_text()) + self.assertFalse(actual['maintenance']) + for unit in self.initial['services']: + self.assertEqual(actual['services'][unit],'active',unit) + self.assertEqual(actual['services']['app-backup.timer'],'inactive') + if tool=='rsync': + self.assertFalse(list((self.base/'work/archives').glob('appbackup_*'))) + else: + self.assertFalse(list((self.base/'work/archives').glob('appbackup_*/UPLOADED'))) + self.assertFalse(list(self.runtime.glob('app-backup-guard-*'))) + self.assertIn('Service started: httpd',(state/'update.log').read_text()) + finally: + try: os.killpg(pid,signal.SIGKILL) + except ProcessLookupError: pass + + def test_group_term_interrupts_worker_copy_and_recovers_services(self): + self.assert_group_term_recovers_services('rsync') + + def test_group_term_interrupts_worker_upload_and_keeps_timer_stopped(self): + self.assert_group_term_recovers_services('rclone') + def test_detached_package_handoff_finishes_after_starter_exits(self): self.configure_upgrade() self.systemd_launcher() diff --git a/update.sh b/update.sh index a186076..b715ab0 100755 --- a/update.sh +++ b/update.sh @@ -256,7 +256,7 @@ if [[ $UP_WORKER == false ]]; then # The launcher must not keep copies of our locks; systemd starts a fresh worker. systemd-run --no-block --unit=app-backup-upgrade --collect --property=Type=oneshot \ --property=SELinuxContext=system_u:system_r:unconfined_service_t:s0 \ - --property=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=mixed \ + --property=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=control-group \ /bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" 8>&- 9>&- || { # Revoke the queued state while still holding the coordinator lock. Even # if systemd accepted the job but its reply failed, it must not install. @@ -281,7 +281,8 @@ fi # -------------------- detached worker -------------------- [[ $UP_STATE == /var/backups/app-backup-upgrade-* && -d $UP_STATE && ! -L $UP_STATE && $(stat -c %u "$UP_STATE") == 0 ]] || die 'Invalid upgrade state directory' (( (8#$(stat -c %a "$UP_STATE") & 8#077) == 0 )) || die 'Upgrade state must be private' -exec > >(tee -a "$UP_STATE/update.log") 2>&1 +# The group receives TERM together; keep logging available for child recovery. +exec > >(trap '' INT TERM; exec tee -a "$UP_STATE/update.log") 2>&1 CONFIG_FILE=$(cat "$UP_STATE/config-path") LOCKFILE=$(cat "$UP_STATE/lock-path") UP_CHANGED=false diff --git a/usr-local-sbin/app-backup.sh b/usr-local-sbin/app-backup.sh index 31647bf..5ed8268 100755 --- a/usr-local-sbin/app-backup.sh +++ b/usr-local-sbin/app-backup.sh @@ -39,6 +39,7 @@ printf 'format=2\ncompressor=%s\n' "$COMPRESSOR" > "$STAGING_DIR/meta/format.txt quiesce for i in "${!DB_LABELS[@]}"; do + assert_services_quiescent label=${DB_LABELS[i]} out="$STAGING_DIR/db/${label#db-}.sql" echo "-- Dump: ${DB_NAMES[i]}" @@ -47,11 +48,13 @@ for i in "${!DB_LABELS[@]}"; do "$DB_DUMP_BIN" --defaults-extra-file="${DB_CNFS[i]}" --lock-all-tables \ --quick --routines --events --triggers --hex-blob --default-character-set=utf8mb4 \ "${DB_NAMES[i]}" > "$out.partial" + assert_services_quiescent [[ -s $out.partial ]] || die "Empty database dump: $label" mv -- "$out.partial" "$out" printf '%s\t%s\n' "$label" "${DB_NAMES[i]}" >> "$PLAN" done for i in "${!LABELS[@]}"; do + assert_services_quiescent label=${LABELS[i]}; src=${SOURCES[i]}; root=${ROOTS[i]} check_space "$STAGING_ROOT" mkdir -p -- "$STAGING_DIR/$root" @@ -60,10 +63,13 @@ for i in "${!LABELS[@]}"; do # Never accept vanished-file (24) or partial-copy (23) as success. # Parent retains both locks; rsync itself needs neither inherited descriptor. rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$src/" "$STAGING_DIR/$root/" 8>&- 9>&- + assert_services_quiescent printf '%s\t%s\n' "$label" "$src" >> "$PLAN" done # The full consistency window includes all dumps AND copies. Compression/upload # use this frozen private staging tree, so application downtime ends here. +assert_services_quiescent +echo '-- Copy window complete; writer guards verified' resume_services || die 'Failed to resume original service state' while IFS=$'\t' read -r label _; do diff --git a/usr-local-sbin/app-restore.sh b/usr-local-sbin/app-restore.sh index 8a467c0..9168988 100755 --- a/usr-local-sbin/app-restore.sh +++ b/usr-local-sbin/app-restore.sh @@ -164,6 +164,7 @@ quiesce MUTATION_STARTED=true save_recovery for i in "${!SELECTED[@]}"; do + assert_services_quiescent label=${SELECTED[i]} source_path="$STAGING_DIR/extracted/$label/${SELECTED_ROOTS[i]}" if [[ ${IS_DB[i]} == true ]]; then @@ -175,6 +176,7 @@ for i in "${!SELECTED[@]}"; do rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" 8>&- 9>&- fi done +assert_services_quiescent # A backup captured maintenance=true. Enforce the recorded pre-restore state even # if the existing installation already was in maintenance mode. if [[ " ${SELECTED[*]} " == *' nextcloud '* || " ${SELECTED[*]} " == *' db-nextcloud '* ]]; then