Protect writer stop jobs from competing healthcheck requests
This commit is contained in:
1 parent
3d16f33f24
commit
3730ab4676
5 files changed
+177
-2
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+7
-1
@@ -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) ;;
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user