Fix systemd oneshot upgrade handoff deadlock

This commit is contained in:
admin committed 2026-09-10 14:40:24 +02:00
1 parent 56fbcdad74
commit 898df2e926
5 files changed
+190 -20

No files matched your search

+15 -2
View File
@@ -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.
+14
View File
@@ -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
+53 -7
View File
@@ -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.
+81 -3
View File
@@ -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')
+27 -8
View File
@@ -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