From 3730ab4676a57fe7ca6f82f98ee6d7a34af48106 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Tue, 15 Sep 2026 23:00:35 +0200 Subject: [PATCH] Protect writer stop jobs from competing healthcheck requests --- README.md | 21 ++++++++ UPDATE_ANLEITUNG.md | 45 ++++++++++++++++ lib/common.sh | 8 ++- tests/test_backup.py | 3 +- tests/test_systemd_stop.py | 102 +++++++++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/test_systemd_stop.py diff --git a/README.md b/README.md index f8c56a1..fd66fd5 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Details und geprüfte Grenzen stehen in der Update-Anleitung. - 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. +- Laufende Stop-Aufträge sind zusätzlich gegen Verdrängung durch fremde Reload-/Start-Aufträge geschützt. Damit kann ein Healthcheck den geplanten Dienststopp nicht abbrechen; echte Stopfehler brechen das Backup weiterhin ab. - Upload und Rückleseprüfung melden ihre Phase und standardmäßig jede Minute rclone-Statistiken im Journal (`RCLONE_STATS_INTERVAL`). Ein laufender Auftrag ist erst nach erfolgreicher Remote-Prüfung abgenommen. - Vollständige Sicherungssätze mit SHA-256-Manifest, Archivprüfung und zuletzt hochgeladenem `COMPLETE`-Marker. - Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Historische v1-Sicherungen bleiben erhalten; bei Platzdruck gilt zusätzlich die abgesicherte lokale Bereinigung aus [SPEICHER.md](SPEICHER.md). @@ -299,3 +300,23 @@ docker run --rm --network none -v "$PWD:/src:ro" app-backup-test ``` ShellCheck, echte MariaDB-Dumps/Imports, gzip/zstd, rsync, lokale Crypt-Uploads und Remote-Restore sowie Fehlerfälle laufen im Container. systemd, Nextcloud-occ und der Mailversand sind simuliert; Nachrichten werden nur lokal erfasst. Am 12.09.2026 bestand der Standardlauf mit 81 Fällen (80 erfolgreich, ein separater Docker-Test planmäßig übersprungen). Zusätzlich bestanden alle 19 gezielten Restore-Tests einschließlich echter DB-Imports und Abbruchbereinigung sowie der Installer-Test mit aktiviertem Restore-Timer. ShellCheck, Shell-Syntax und systemd-Unit-Prüfung bestanden. Eine echte Rocky-/Nextcloud-/Gitea-/OneDrive-Wiederherstellungsprobe und die tatsächliche Mailzustellung bleiben Teil der Serverabnahme. + +Der Konflikt zwischen Stop und fremdem Reload/Start lässt sich separat gegen einen +echten lokalen Benutzer-systemd prüfen (ohne sudo). Der Test erzeugt nur drei +eindeutig benannte, kurzlebige Testdienste und räumt sie wieder auf: + +```bash +APP_BACKUP_TEST_USER_SYSTEMD=1 python3 -m unittest discover -s tests -p test_systemd_stop.py -v +``` + +Er reproduziert zunächst den abgebrochenen ungeschützten Stop trotz Startbedingung +und prüft danach den produktiven Stop-Code gegen konkurrierende Reloads und Starts. +Ohne diese ausdrückliche Aktivierung werden die drei Fälle übersprungen. + +Verifikation der Stop-Korrektur am 15.09.2026: Der Container-Standardlauf bestand +mit 119 Fällen (111 erfolgreich, fünf separate Docker-Integrationstests und die +drei Benutzer-systemd-Tests planmäßig übersprungen; 906,3 Sekunden). Die drei +Benutzer-systemd-Tests bestanden separat, einschließlich der Prüfung, dass der +geschützte Stop den Dienst tatsächlich inaktiv hinterlässt. ShellCheck und +Shell-Syntaxprüfung bestanden ebenfalls. Die Abnahme dieser Korrektur auf Gemini +steht noch aus. diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index aa736e3..05f4960 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -1,5 +1,50 @@ # Automatisches Update-Paket +## Nachtrag vom 15.09.2026: Healthcheck verdrängt Apache-Stop + +Im Update mit Paket `3d16f33f24c0` wurde die alte Sicherung vom 12.09. um +14:29:24 MESZ im dritten Rückleseversuch vollständig bestätigt (16 Dateien, +keine Unterschiede). Erst danach entfernte die Platzbereinigung ihre lokale +Kopie. Die neue Installation und ihre Vorprüfung waren erfolgreich. + +Der anschließende Abnahmelauf `appbackup_2026-09-15_12-29-36_75975f52` brach +bereits beim Anhalten der Writer ab: Um 14:29:56 stoppte das Backup `httpd`; +um 14:30:00 meldete `ai-rem-healthcheck.service` einen fehlgeschlagenen öffentlichen +Healthcheck und veranlasste einen Apache-Reload. Dieser verdrängte den laufenden +Stop-Auftrag (`Job for httpd.service canceled`). Ein neues Backup oder dessen +Restore-Test wurde in diesem Lauf noch nicht fertiggestellt. Die Fehlerbereinigung +schaltete den Nextcloud-Wartungsmodus aus und startete Apache und Cron wieder. +Backup- und Restore-Test-Timer blieben wegen der fehlenden Abnahme gestoppt. + +Die Korrektur schützt jeden Writer-Stop mit +`systemctl stop UNIT --job-mode=replace-irreversibly`. Die vorhandene +`ConditionPathExists` verhindert Starts während der Kopie, schützt aber allein +keinen noch laufenden Stop-Auftrag gegen einen Reload. Der zusätzliche Jobmodus +verhindert dessen Verdrängung, solange der Stop läuft. Er setzt keine dauerhafte +Maskierung, ändert keine Stop-Zeitlimits und verhindert den normalen Wiederanlauf +nach Freigabe der Startsperren nicht. Ein explizites `systemctl cancel` oder ein +echter Stopfehler bleibt ein Abbruchgrund. Siehe die +[systemd-Dokumentation zum Jobmodus](https://github.com/systemd/systemd/blob/v257/man/systemctl.xml). + +Ein Regressionstest gegen echten Benutzer-systemd reproduziert den alten +Reload-Konflikt trotz gesetzter Startbedingung und prüft den korrigierten Stop +gegen Reload und Start. Die Container-Tests prüfen weiterhin die Wiederaufnahme +nach Stopfehlern und die Konsistenzsperren während der Kopie. + +Lokale Verifikation am 15.09.2026: 119 Fälle im Container-Standardlauf, davon +111 erfolgreich und acht planmäßig übersprungen (fünf separate +Docker-Integrationstests und drei Benutzer-systemd-Tests); Laufzeit 906,3 Sekunden. +Die drei Benutzer-systemd-Tests bestanden separat und prüfen auch den inaktiven +Dienstzustand nach dem geschützten Stop. ShellCheck und Shell-Syntaxprüfung +bestanden. Die Serverabnahme dieser Korrektur steht noch aus. + +Das neue Paket wie unten per SCP übertragen und normal starten; ein vorheriger +Rollback ist nicht erforderlich. Der Healthcheck muss für diese Korrektur nicht +dauerhaft abgeschaltet werden. Der Installer erstellt erneut ein Abnahmebackup +und führt den vollständigen Restore-Test aus. Erst `SUCCESS` in seiner neuen +`result.txt` bestätigt den Abschluss und die Timer-Aktivierung. Diese Korrektur +betrifft den Dienststopp; die ursprüngliche OneDrive-Ursache bleibt ungeklärt. + ## Nachtrag vom 15.09.2026: Restore-Abnahme nach erfolgreichem Backup Das Paket `4f220dbc6447` bestand auf Gemini das neue Backup einschließlich diff --git a/lib/common.sh b/lib/common.sh index ed68e18..802fc73 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -444,7 +444,13 @@ stop_service() { STOPPED_SERVICES+=("$1") save_recovery echo "-- Stopping service: $1 (waiting for systemd)" - systemctl stop "$1" + # Conditions block activation, but a concurrent reload/start can still + # replace a pending stop job before conditions are evaluated. Protect the + # job until it finishes; this is not a persistent mask and does not alter + # stop timeouts or the normal recovery start after releasing our guards. + if ! systemctl stop "$1" --job-mode=replace-irreversibly; then + die "Could not stop writer $1; inspect its systemd journal (stop failure or explicit job cancellation)" + fi echo "-- Service stopped: $1" ;; inactive|failed) ;; diff --git a/tests/test_backup.py b/tests/test_backup.py index 42e5c99..5bcc653 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -494,7 +494,8 @@ if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv and '--dry-r self.assertNotIn('systemctl stop',self.events.read_text()) def test_service_stop_and_start_failures_are_reported(self): - self.run_script('app-backup.sh',ok=False,TEST_FAIL_SERVICE='stop:gitea') + result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_SERVICE='stop:gitea') + self.assertIn('Could not stop writer gitea',result.stdout) self.assertEqual(json.loads(self.state.read_text()),self.initial) self.run_script('app-backup.sh',ok=False,TEST_FAIL_SERVICE='start:gitea') self.assertTrue(list((self.base/'work').glob('recovery-*.json'))) diff --git a/tests/test_systemd_stop.py b/tests/test_systemd_stop.py new file mode 100644 index 0000000..33459e2 --- /dev/null +++ b/tests/test_systemd_stop.py @@ -0,0 +1,102 @@ +"""Opt-in regression against real user systemd, using only unique test units. + +APP_BACKUP_TEST_USER_SYSTEMD=1 python3 -m unittest discover -s tests \ + -p test_systemd_stop.py -v +No root, production services, or persistent unit files are involved. +""" +import os +from pathlib import Path +import subprocess +import tempfile +import time +import unittest +from unittest.mock import patch +import uuid + + +REPO = Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(os.environ.get('APP_BACKUP_TEST_USER_SYSTEMD') == '1', + 'opt in to isolated real user-systemd test') +class SystemdStop(unittest.TestCase): + def setUp(self): + locale = patch.dict(os.environ, {'LC_ALL': 'C'}) + locale.start() + self.addCleanup(locale.stop) + + def exercise(self, protected, competing): + unit = 'app-backup-stop-test-' + uuid.uuid4().hex + '.service' + with tempfile.TemporaryDirectory(prefix='app-backup-stop-test-') as directory: + guard = Path(directory) / 'blocked' + stopper = None + try: + subprocess.run([ + 'systemd-run', '--user', '--quiet', '--unit=' + unit, + '--property=Type=oneshot', '--property=RemainAfterExit=yes', + '--property=ExecStop=/bin/sleep 3', '--property=TimeoutStopSec=10s', + '--property=ExecReload=/bin/true', + '--property=ConditionPathExists=!' + str(guard), '/bin/true', + ], check=True, capture_output=True, text=True, timeout=15) + guard.touch() + if protected: + # Exercise production stop_service. The transient test unit + # already owns its condition; recovery persistence is covered + # by the backup integration tests, without root here. + command = ['bash', '-c', ''' +source "$1/lib/common.sh" +systemctl() { command systemctl --user "$@"; } +install_service_guard() { :; } +save_recovery() { :; } +STOPPED_SERVICES=() +stop_service "$2" +''', 'stop-test', str(REPO), unit] + else: + command = ['systemctl', '--user', 'stop', unit] + stopper = subprocess.Popen(command, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True) + deadline = time.monotonic() + 5 + while True: + state = subprocess.run([ + 'systemctl', '--user', 'show', unit, '-p', 'ActiveState', '--value', + ], capture_output=True, text=True, timeout=5).stdout.strip() + if state == 'deactivating': + break + self.assertIsNone(stopper.poll(), 'stop exited before concurrent request') + self.assertLess(time.monotonic(), deadline, 'stop never became pending') + time.sleep(.02) + competitor = subprocess.run(['systemctl', '--user', competing, unit], + capture_output=True, text=True, timeout=15) + output, _ = stopper.communicate(timeout=15) + if protected: + self.assertEqual(stopper.returncode, 0, output) + self.assertNotEqual(competitor.returncode, 0, + competitor.stdout + competitor.stderr) + self.assertIn('-- Service stopped: ' + unit, output) + state = subprocess.check_output([ + 'systemctl', '--user', 'show', unit, + '-p', 'ActiveState', '--value', + ], text=True, timeout=5).strip() + self.assertEqual(state, 'inactive') + else: + self.assertNotEqual(stopper.returncode, 0, output) + self.assertIn('canceled', output.lower()) + finally: + if stopper is not None and stopper.poll() is None: + stopper.terminate() + stopper.communicate(timeout=15) + # Only our UUID-named unit is stopped/reset. A canceled stop may + # still have its ExecStop process running, so wait for cleanup. + subprocess.run(['systemctl', '--user', 'stop', unit], + capture_output=True, timeout=15) + subprocess.run(['systemctl', '--user', 'reset-failed', unit], + capture_output=True, timeout=15) + + def test_reload_cancels_unprotected_stop_despite_condition(self): + self.exercise(False, 'reload') + + def test_reload_cannot_cancel_protected_stop(self): + self.exercise(True, 'reload') + + def test_start_cannot_cancel_protected_stop(self): + self.exercise(True, 'start')