Protect writer stop jobs from competing healthcheck requests

This commit is contained in:
admin committed 2026-09-15 23:00:35 +02:00
1 parent 3d16f33f24
commit 3730ab4676
5 files changed
+177 -2

No files matched your search

+21
View File
@@ -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. - 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. - Wartungsmodus und Dienststopps umfassen Datenbankdump **und** Dateikopie. Danach endet die Ausfallzeit; Kompression und Upload laufen aus dem Staging.
- Vorübergehende systemd-Startsperren verhindern die erneute Aktivierung konfigurierter Writer während der Kopie. Zustands- und Zeitstempelprüfungen brechen bei erkannten Neustarts vor Veröffentlichung ab. Eigene Sperren werden beim Wiederanlauf entfernt; bestehende Maskierungen bleiben erhalten. - Vorübergehende systemd-Startsperren verhindern die erneute Aktivierung konfigurierter Writer während der Kopie. Zustands- und Zeitstempelprüfungen brechen bei erkannten Neustarts vor Veröffentlichung ab. Eigene Sperren werden beim Wiederanlauf entfernt; bestehende Maskierungen bleiben erhalten.
- Laufende Stop-Aufträge sind zusätzlich gegen Verdrängung durch fremde Reload-/Start-Aufträge geschützt. Damit kann ein Healthcheck den geplanten Dienststopp nicht abbrechen; echte Stopfehler brechen das Backup weiterhin ab.
- Upload und Rückleseprüfung melden ihre Phase und standardmäßig jede Minute rclone-Statistiken im Journal (`RCLONE_STATS_INTERVAL`). Ein laufender Auftrag ist erst nach erfolgreicher Remote-Prüfung abgenommen. - Upload und 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. - Vollständige Sicherungssätze mit SHA-256-Manifest, Archivprüfung und zuletzt hochgeladenem `COMPLETE`-Marker.
- Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Historische v1-Sicherungen bleiben erhalten; bei Platzdruck gilt zusätzlich die abgesicherte lokale Bereinigung aus [SPEICHER.md](SPEICHER.md). - Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Historische v1-Sicherungen bleiben erhalten; bei Platzdruck gilt zusätzlich die abgesicherte lokale Bereinigung aus [SPEICHER.md](SPEICHER.md).
@@ -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. 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.
+45
View File
@@ -1,5 +1,50 @@
# Automatisches Update-Paket # 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 ## Nachtrag vom 15.09.2026: Restore-Abnahme nach erfolgreichem Backup
Das Paket `4f220dbc6447` bestand auf Gemini das neue Backup einschließlich Das Paket `4f220dbc6447` bestand auf Gemini das neue Backup einschließlich
+7 -1
View File
@@ -444,7 +444,13 @@ stop_service() {
STOPPED_SERVICES+=("$1") STOPPED_SERVICES+=("$1")
save_recovery save_recovery
echo "-- Stopping service: $1 (waiting for systemd)" 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" echo "-- Service stopped: $1"
;; ;;
inactive|failed) ;; inactive|failed) ;;
+2 -1
View File
@@ -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()) self.assertNotIn('systemctl stop',self.events.read_text())
def test_service_stop_and_start_failures_are_reported(self): 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.assertEqual(json.loads(self.state.read_text()),self.initial)
self.run_script('app-backup.sh',ok=False,TEST_FAIL_SERVICE='start:gitea') self.run_script('app-backup.sh',ok=False,TEST_FAIL_SERVICE='start:gitea')
self.assertTrue(list((self.base/'work').glob('recovery-*.json'))) self.assertTrue(list((self.base/'work').glob('recovery-*.json')))
+102
View File
@@ -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')