diff --git a/Dokumentation.md b/Dokumentation.md index 60a42b4..8836e2f 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -261,6 +261,17 @@ Dateisperre. Der Starter übergibt den langen Ablauf an eine systemd-Unit; das Ergebnis bleibt im Migrationsordner erhalten. Ein kombinierter Rollback stellt Paketdateien und die vorherige Hauptkonfiguration wieder her. +Die Übergabe nutzt `systemd-run --no-block` mit `Type=oneshot`: Der Starter wartet +nur auf die Annahme des Auftrags, gibt danach zuerst die Backup-Sperre und dann +die Update-Sperre frei. Der Worker erwirbt beide selbst und darf nur mit einem +weiterhin als `QUEUED` freigegebenen Migrationsstand beginnen. Bei einer +fehlgeschlagenen Übergabe wird diese Freigabe unter gehaltener Sperre entzogen. +Der Worker richtet Fehlerprotokoll und EXIT-Trap vor dem maximal 60-sekündigen +Warten auf die Update-Sperre ein. Erst `SUCCESS` belegt das Gesamtresultat. +Das behebt das gegenseitige Warten in den früheren Paketen: Ohne `--no-block` +wartete der Starter auf den oneshot-Abschluss, während der Worker auf seine +Sperre wartete. Ursache, Diagnose und Wiederanlauf beschreibt die Update-Anleitung. + Der Abnahmelauf nutzt `app-backup.sh --no-retention`: keinerlei lokale oder entfernte Löschung alter Sicherungssätze. Nach Upload/Rücklesevergleich prüft zusätzlich `app-restore.sh --remote-run ... --verify-only` den erneut heruntergeladenen Satz. @@ -304,7 +315,7 @@ 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: **34 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. +Ergebnis des lokalen Abschlusslaufs am 10.09.2026: **37 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. Geprüfte Fälle umfassen: @@ -321,6 +332,8 @@ Geprüfte Fälle umfassen: - Paketupdate mit Erhalt von Config/Zeitplan und ausführbarem Rollback. - Selbstentpackendes Transferpaket mit erfolgreichem Komplettlauf und kombiniertem Konfigurations-/Paket-Rollback. - Abbruch vor Änderungen bei Vorprüfungsfehlern, gestoppter Timer bei fehlgeschlagenem Abnahmebackup, Ablehnung beschädigter Pakete. -- Übergabe an einen vom Starter unabhängigen Prozess (systemd-run im Test simuliert), ausdrückliche Cron-Policy und Writer-Wartezeit/Timeout. +- Übergabe an einen vom Starter unabhängigen Prozess: Das systemd-run-Testmodell wartet bei `Type=oneshot` ohne `--no-block` auf das Worker-Ende. Der Regressionstest scheiterte am bisherigen gegenseitigen Sperrwarten und besteht nach dem Fix. +- Abgewiesene systemd-Übergabe, zurückgezogene Freigabe bei bereits eingereihtem Auftrag und persistente Fehlerprotokolle bei Sperr-Timeout; jeweils ohne Installationsänderung oder Timer-Stopp. +- Ausdrückliche Cron-Policy und Writer-Wartezeit/Timeout. **Serverabnahme steht separat aus:** echte Rocky-Linux-10-Umgebung, SELinux-/ACL-Verhalten auf den verwendeten Dateisystemen, installierte Nextcloud-/Gitea-Versionen, vollständige Writer-/Pfadliste, Berechtigungen des echten DB-Backupaccounts, tatsächlicher OneDrive-Upload großer Archive, Laufzeit/Speicherbedarf und fachliche Restore-Probe. Es wurde kein Produktivbackup ausgeführt und keine Serverinstallation verändert. diff --git a/README.md b/README.md index 3076a9b..b6c1ce3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,20 @@ anschließend als systemd-Auftrag unabhängig von der SSH-Verbindung. Bei unklar Gitea-/Cron-/rclone-Einstellungen fragt der Starter gezielt nach. Die Bedienung und der kombinierte Rückweg stehen in [UPDATE_ANLEITUNG.md](UPDATE_ANLEITUNG.md). +Der Starter bestätigt nur die Übergabe. Erst `SUCCESS` in der genannten +`result.txt` bestätigt Installation, Backup und Remote-Prüfung. Den Verlauf ohne +Terminal-Pager anzeigen: + +```bash +sudo journalctl -fu app-backup-upgrade.service --no-pager +``` + +Ein Startabbruch nach genau 60 Sekunden in den Paketen `8e6c9a058fa9` und +`56fbcdad74f4` ist durch die asynchrone Sperrübergabe behoben. Bei diesem Fehler +hatte die Installation noch nicht begonnen; das neue Paket erneut verwenden. +Die kurzlebige Unit kann nach einem Fehler verschwinden, das Journal bleibt +abfragbar. Diagnose und Details stehen in der Update-Anleitung. + Die folgende manuelle Anleitung bleibt als Alternative für individuelle Updates. ## Wesentliche Änderungen diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index 0f29143..20e8e7e 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -24,7 +24,8 @@ Backups mit zeitweiliger Unterbrechung der konfigurierten Anwendungen. 5. Crypt-Konfiguration und DB-Dateien prüfen; vorhandene Schlüssel/Passwörter erhalten. 6. Vorprüfung mit geschützten Kopien durchführen. Bei Fehlern bleibt das installierte Paket samt Anwendungskonfiguration und Timer unverändert. -7. Einen von SSH unabhängigen systemd-Auftrag `app-backup-upgrade.service` starten. +7. Einen von SSH unabhängigen systemd-Auftrag `app-backup-upgrade.service` mit + `--no-block` einreihen und die Sperren an ihn übergeben. 8. Backup-Timer stoppen, Paket mit Rollback-Inventar installieren, Konfiguration atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren. 9. Vollständiges Backup ohne lokale/Remote-Retention durchführen. Anschließend den @@ -37,7 +38,7 @@ Remote-Prüflauf belegt Archivlesbarkeit, ersetzt aber keinen fachlichen Test-Re ## Verlauf und Ergebnis ```bash -sudo journalctl -fu app-backup-upgrade.service +sudo journalctl -fu app-backup-upgrade.service --no-pager ``` Der Starter nennt außerdem den **konkreten** Ergebnisordner: @@ -47,9 +48,24 @@ sudo cat /var/backups/app-backup-upgrade-XXXXXXXX/result.txt sudo less /var/backups/app-backup-upgrade-XXXXXXXX/update.log ``` -Erwartet wird `SUCCESS`. Solange `RUNNING` steht, nicht parallel erneut starten. -Nach Ende verschwindet die kurzlebige systemd-Unit; Log und Ergebnis bleiben erhalten. -Bei `QUEUED` wurde der Auftrag übergeben und wartet auf den Start. +Erwartet wird `SUCCESS`. Bei `QUEUED` oder `RUNNING` nicht parallel erneut starten. +`QUEUED` bedeutet, dass der Auftrag zur Übergabe vorbereitet wurde und noch kein +Worker den Start bestätigt hat. Ein erfolgreicher Starter bestätigt nur die +Annahme durch systemd; Installation und Backup können anschließend noch scheitern. +Nach Ende verschwindet die kurzlebige systemd-Unit durch `--collect` auch bei +Fehlern. `Unit ... could not be found` ist daher kein Erfolgsnachweis und verhindert +die Journalabfrage nicht. Log und Ergebnis bleiben erhalten. Diagnose ohne Pager: + +```bash +sudo journalctl -u app-backup-upgrade.service -n 100 --no-pager -l +sudo systemctl status app-backup-upgrade.service --no-pager -l +``` + +Bleibt `QUEUED` stehen, zuerst das Journal prüfen: Ein Fehler noch vor dem Start +des Bash-Workers (z. B. ein nicht ausführbarer Paketpfad) kann noch nicht in +`update.log` oder `result.txt` durch den Worker protokolliert werden. Ein späterer +Sperr-Timeout erscheint dagegen in beiden Dateien. Nicht allein auf den Exit-Code +von `systemd-run` vertrauen. `--foreground` führt den Vorgang stattdessen im aktuellen Terminal aus und ist für Tests/Diagnose gedacht; dabei besteht keine Absicherung gegen SSH-Abbruch. @@ -107,6 +123,30 @@ bleiben erhalten; beim Aktivieren kann `Persistent=true` einen Lauf nachholen. ## Fehler und Rückweg +### Startfehler nach genau 60 Sekunden bei älteren Paketen + +Die Pakete `8e6c9a058fa9` und `56fbcdad74f4` enthielten einen Fehler in der +systemd-Übergabe: Der Starter hielt die Update-Sperre und wartete synchron auf +den Abschluss des Startauftrags einer `Type=oneshot`-Unit. Dieser Startauftrag +endet erst mit dem Worker; der Worker wartete aber selbst auf die Sperre des +Starters. Nach 60 Sekunden brach seine Sperranforderung ab, noch vor Installation +und Timer-Stopp. In diesem Ablauf ist kein Rollback erforderlich. Der ursprüngliche +Worker hatte vor dem Sperrwarten noch kein eigenes Log eingerichtet, daher konnte +die Fehlermeldung auf den systemd-Exit-Code beschränkt bleiben. + +Das korrigierte Paket verwendet `systemd-run --no-block` und gibt zuerst die +Backup-Sperre, dann die Update-Sperre frei. Eine fehlgeschlagene Übergabe entzieht +auch einem möglicherweise schon eingereihten Worker die Freigabe. Die Sperren +bleiben als Schutz vor parallelen Backups/Updates erhalten. Erneut das **neue** +Paket starten; weder Sperrdateien löschen noch alte Worker manuell nachstarten. + +Das Verhalten von `--no-block` und `--collect` ist in der +[systemd-Dokumentation](https://github.com/systemd/systemd/blob/v256/man/systemd-run.xml) +beschrieben, der Startabschluss von `oneshot` in +[systemd.service](https://github.com/systemd/systemd/blob/v256/man/systemd.service.xml). + +### Fehlende Mailablage und erneuter Start + Ein Abbruch `Missing source: /var/vmail` bei der **Vorprüfung**, wie beim Paket `8e6c9a058fa9`, erfordert kein Rollback. Mit dem korrigierten Paket erneut starten und die Mailablage klären. Die bisherige Konfiguration und der Timer sind erhalten; @@ -134,6 +174,8 @@ Vorlage vollständig kopiert, sofern es auf demselben Dateisystem liegt und kein Verweis auf eine externe Ablage ist. Weitere Benutzer müssen in `MAIL_HOME_DIRS` stehen; das Vorhandensein eines einzelnen Postfachs belegt nicht deren Abdeckung. +### Fehler nach Installationsbeginn + Bei einem Fehler nach Beginn der Installation bleibt der Backup-Timer gestoppt. Es erfolgt kein automatisches Zurücksetzen über möglicherweise noch laufende oder teilweise wiederhergestellte Anwendungen. Logs und gegebenenfalls @@ -165,8 +207,12 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez ## Verifikation der Lieferung -34 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. +37 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich. Die Tests enthalten echte MariaDB-Dumps/Imports und lokale rclone-Crypt-Roundtrips; -systemd/occ und die systemd-run-Übergabe sind simuliert. Die tatsächliche +systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell +berücksichtigt jetzt das Warten auf den oneshot-Startauftrag: Der Regressionstest +scheiterte mit dem alten Code und war nach dem Fix erfolgreich. Geprüft sind auch +abgewiesene Übergaben, eingereihte Worker trotz fehlgeschlagener Antwort und +persistente Fehlerprotokolle bei belegter Update-Sperre. Die tatsächliche Server-/OneDrive-Prüfung erfolgt beim ersten Paketlauf. Ein fachlicher Restore auf einem separaten Testsystem bleibt erforderlich. diff --git a/tests/test_backup.py b/tests/test_backup.py index b15c9fb..4d287be 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -541,20 +541,38 @@ QUIESCE_SERVICES=(nc-cron) result=self.run_script('app-backup.sh','--check',ok=False,APP_BACKUP_INHERIT_LOCK='1') self.assertIn('Inherited lock descriptor',result.stdout) - def test_detached_package_handoff_finishes_after_starter_exits(self): - self.configure_upgrade() + def systemd_launcher(self): + """Model start-job waiting, detached FDs, and ambiguous submit failures.""" launcher=self.bin/'systemd-run' launcher.write_text('''#!/usr/bin/env python3 import os,subprocess,sys from pathlib import Path args=sys.argv[1:] assert '--unit=app-backup-upgrade' in args +assert '--property=Type=oneshot' in args command=args[args.index('/bin/bash'):] state=Path(command[-1]) +if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'reject': + sys.exit('systemd refused submission') with open(state/'test-worker.log','w') as log: - subprocess.Popen(command,stdin=subprocess.DEVNULL,stdout=log,stderr=subprocess.STDOUT,env=os.environ,close_fds=True,start_new_session=True) + worker=subprocess.Popen(command,stdin=subprocess.DEVNULL,stdout=log,stderr=subprocess.STDOUT,env=os.environ,close_fds=True,start_new_session=True) +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 +# for that start job unless --no-block is used; our old stub missed this. +if '--no-block' not in args: + try: + sys.exit(worker.wait(timeout=3)) + except subprocess.TimeoutExpired: + worker.terminate() + worker.wait(timeout=5) + sys.exit('oneshot start job still waiting: launcher/worker lock deadlock') ''') launcher.chmod(0o755) + + def test_detached_package_handoff_finishes_after_starter_exits(self): + self.configure_upgrade() + self.systemd_launcher() 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) @@ -566,6 +584,66 @@ with open(state/'test-worker.log','w') as log: self.assertTrue(status.startswith('SUCCESS'),status+(state/'test-worker.log').read_text()) self.assertIn('unabhaengig von SSH',result.stdout) + def test_systemd_submission_failure_preserves_installation_and_reports_result(self): + canonical=self.configure_upgrade() + original=canonical.read_bytes() + self.systemd_launcher() + package=self.build_package() + result=subprocess.run(['bash',str(package)],env=dict(self.env,TEST_SYSTEMD_SUBMIT='reject'),capture_output=True,text=True,timeout=60) + self.assertNotEqual(result.returncode,0) + state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) + self.assertIn('FAILED: systemd submission failed',(state/'result.txt').read_text()) + self.assertIn(str(state/'result.txt'),result.stdout) + self.assertEqual(canonical.read_bytes(),original) + self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') + self.assertNotIn('systemctl stop',self.events.read_text()) + + def test_failed_submission_revokes_even_an_already_queued_worker(self): + canonical=self.configure_upgrade() + original=canonical.read_bytes() + self.systemd_launcher() + package=self.build_package() + result=subprocess.run(['bash',str(package)],env=dict(self.env,TEST_SYSTEMD_SUBMIT='accepted-error'),capture_output=True,text=True,timeout=60) + self.assertNotEqual(result.returncode,0) + state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) + for _ in range(100): + log=(state/'test-worker.log').read_text() + if 'Installation not started' in log: break + time.sleep(.1) + self.assertIn('submission was cancelled',log) + self.assertIn('Installation not started',log) + self.assertTrue((state/'result.txt').read_text().startswith('FAILED')) + self.assertEqual(canonical.read_bytes(),original) + self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') + self.assertNotIn('systemctl stop',self.events.read_text()) + self.assertFalse((state/'package-rollback').exists()) + + def test_worker_lock_timeout_is_persisted_before_installation(self): + import fcntl + canonical=self.configure_upgrade() + original=canonical.read_bytes() + self.systemd_launcher() + package=self.build_package() + # Obtain a complete prepared state without starting an actual worker. + result=subprocess.run(['bash',str(package)],env=dict(self.env,TEST_SYSTEMD_SUBMIT='reject'),capture_output=True,text=True,timeout=60) + self.assertNotEqual(result.returncode,0) + state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0]) + (state/'result.txt').write_text('QUEUED\n') + wrapper=self.bin/'flock' + # Keep real flock contention; shorten only the 60s timeout in this test. + wrapper.write_text('#!/usr/bin/env bash\nif [[ $1 == -w && $2 == 60 ]]; then shift 2; exec /usr/bin/flock -w 0 "$@"; fi\nexec /usr/bin/flock "$@"\n') + wrapper.chmod(0o755) + with open('/run/app-backup-upgrade.lock','a') as lock: + fcntl.flock(lock,fcntl.LOCK_EX | fcntl.LOCK_NB) + failed=subprocess.run(['bash',str(REPO/'update.sh'),'--worker',str(state)],env=self.env,capture_output=True,text=True,timeout=10) + self.assertNotEqual(failed.returncode,0) + self.assertIn('Timed out waiting for update coordinator lock',(state/'update.log').read_text()) + self.assertIn('Installation not started',(state/'result.txt').read_text()) + self.assertTrue((state/'result.txt').read_text().startswith('FAILED')) + self.assertEqual(canonical.read_bytes(),original) + self.assertEqual(json.loads(self.state.read_text())['services']['app-backup.timer'],'active') + self.assertNotIn('systemctl stop',self.events.read_text()) + def test_cron_inventory_requires_explicit_policy_and_sorts_timer_first(self): canonical=self.configure_upgrade() cron=Path('/etc/cron.d/app-backup-test-nextcloud') diff --git a/update.sh b/update.sh index d957fa9..9febaa0 100755 --- a/update.sh +++ b/update.sh @@ -40,7 +40,8 @@ done need systemctl; need flock # This lock excludes two coordinators, independently from ordinary backup jobs. exec 8>/run/app-backup-upgrade.lock -if [[ $UP_WORKER == true ]]; then flock -w 60 8; else flock -n 8 || die 'Another updater is preparing/running'; fi +# Workers install their persistent error reporting before waiting on this lock. +if [[ $UP_WORKER == false ]]; then flock -n 8 || die 'Another updater is preparing/running'; fi prompt() { local answer @@ -244,20 +245,34 @@ if [[ $UP_WORKER == false ]]; then sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256" echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")" echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.' + printf 'QUEUED\n' > "$UP_STATE/result.txt" if [[ $UP_FOREGROUND == true ]]; then flock -u 9; flock -u 8 exec bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" fi - # A new systemd service inherits no shell FDs; it acquires both locks itself. - printf 'QUEUED\n' > "$UP_STATE/result.txt" - systemd-run --unit=app-backup-upgrade --collect --property=Type=oneshot \ + # A oneshot start job completes only when ExecStart exits. Waiting for that + # job while holding FD 8 deadlocks with the worker waiting for the same lock. + # Only enqueue here; result.txt, not systemd-run's exit code, proves success. + # 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=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=mixed \ - /bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" || { - printf 'FAILED: systemd worker could not be started; installed package/config/timer unchanged.\n' > "$UP_STATE/result.txt" + /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. + printf 'FAILED: systemd submission failed; installed package/config/timer unchanged.\n' > "$UP_STATE/result.txt" + cat "$UP_STATE/result.txt" + echo "Details: sudo journalctl -u app-backup-upgrade.service -n 100 --no-pager" + echo "Ergebnis: sudo cat $UP_STATE/result.txt" exit 1 } - echo 'Update laeuft unabhaengig von SSH weiter.' - echo 'Verlauf: sudo journalctl -fu app-backup-upgrade.service' + # Release the backup lock FIRST. Once FD 8 is free, the worker may acquire + # both immediately; reversing the order can make its backup lock check fail. + flock -u 9 + flock -u 8 + exec 9>&- 8>&- + echo 'Update-Auftrag an systemd uebergeben; Ausfuehrung unabhaengig von SSH.' + echo 'Abgeschlossen ist das Update erst bei SUCCESS in der Ergebnisdatei.' + echo 'Verlauf: sudo journalctl -fu app-backup-upgrade.service --no-pager' echo "Ergebnis: sudo cat $UP_STATE/result.txt" exit 0 fi @@ -289,6 +304,10 @@ worker_finish() { trap worker_finish EXIT trap 'exit 130' INT trap 'exit 143' TERM +# Record lock timeouts in update.log/result.txt as well as journald. Previously +# this wait happened before logging/traps, leaving only systemd's exit status. +flock -w 60 8 || die 'Timed out waiting for update coordinator lock (60s); installation not started' +[[ $(cat "$UP_STATE/result.txt") == QUEUED ]] || die 'Upgrade is not queued or submission was cancelled; rerun updater' printf 'RUNNING\n' > "$UP_STATE/result.txt" acquire_backup_lock export APP_BACKUP_INHERIT_LOCK=1