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 Ergebnis bleibt im Migrationsordner erhalten. Ein kombinierter Rollback stellt
Paketdateien und die vorherige Hauptkonfiguration wieder her. 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 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 Löschung alter Sicherungssätze. Nach Upload/Rücklesevergleich prüft zusätzlich
`app-restore.sh --remote-run ... --verify-only` den erneut heruntergeladenen Satz. `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. 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: Geprüfte Fälle umfassen:
@@ -321,6 +332,8 @@ Geprüfte Fälle umfassen:
- Paketupdate mit Erhalt von Config/Zeitplan und ausführbarem Rollback. - Paketupdate mit Erhalt von Config/Zeitplan und ausführbarem Rollback.
- Selbstentpackendes Transferpaket mit erfolgreichem Komplettlauf und kombiniertem Konfigurations-/Paket-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. - 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. **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 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 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. Die folgende manuelle Anleitung bleibt als Alternative für individuelle Updates.
## Wesentliche Änderungen ## 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. 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 6. Vorprüfung mit geschützten Kopien durchführen. Bei Fehlern bleibt das installierte
Paket samt Anwendungskonfiguration und Timer unverändert. 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 8. Backup-Timer stoppen, Paket mit Rollback-Inventar installieren, Konfiguration
atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren. atomar übernehmen und Rechte der aktiven root-eigenen Zugangsdaten korrigieren.
9. Vollständiges Backup ohne lokale/Remote-Retention durchführen. Anschließend den 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 ## Verlauf und Ergebnis
```bash ```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: 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 sudo less /var/backups/app-backup-upgrade-XXXXXXXX/update.log
``` ```
Erwartet wird `SUCCESS`. Solange `RUNNING` steht, nicht parallel erneut starten. Erwartet wird `SUCCESS`. Bei `QUEUED` oder `RUNNING` nicht parallel erneut starten.
Nach Ende verschwindet die kurzlebige systemd-Unit; Log und Ergebnis bleiben erhalten. `QUEUED` bedeutet, dass der Auftrag zur Übergabe vorbereitet wurde und noch kein
Bei `QUEUED` wurde der Auftrag übergeben und wartet auf den Start. 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 `--foreground` führt den Vorgang stattdessen im aktuellen Terminal aus und ist
für Tests/Diagnose gedacht; dabei besteht keine Absicherung gegen SSH-Abbruch. 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 ## 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 Ein Abbruch `Missing source: /var/vmail` bei der **Vorprüfung**, wie beim Paket
`8e6c9a058fa9`, erfordert kein Rollback. Mit dem korrigierten Paket erneut starten `8e6c9a058fa9`, erfordert kein Rollback. Mit dem korrigierten Paket erneut starten
und die Mailablage klären. Die bisherige Konfiguration und der Timer sind erhalten; 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` Verweis auf eine externe Ablage ist. Weitere Benutzer müssen in `MAIL_HOME_DIRS`
stehen; das Vorhandensein eines einzelnen Postfachs belegt nicht deren Abdeckung. 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. Bei einem Fehler nach Beginn der Installation bleibt der Backup-Timer gestoppt.
Es erfolgt kein automatisches Zurücksetzen über möglicherweise noch laufende oder Es erfolgt kein automatisches Zurücksetzen über möglicherweise noch laufende oder
teilweise wiederhergestellte Anwendungen. Logs und gegebenenfalls 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 ## 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; 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 Server-/OneDrive-Prüfung erfolgt beim ersten Paketlauf. Ein fachlicher Restore
auf einem separaten Testsystem bleibt erforderlich. 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') result=self.run_script('app-backup.sh','--check',ok=False,APP_BACKUP_INHERIT_LOCK='1')
self.assertIn('Inherited lock descriptor',result.stdout) self.assertIn('Inherited lock descriptor',result.stdout)
def test_detached_package_handoff_finishes_after_starter_exits(self): def systemd_launcher(self):
self.configure_upgrade() """Model start-job waiting, detached FDs, and ambiguous submit failures."""
launcher=self.bin/'systemd-run' launcher=self.bin/'systemd-run'
launcher.write_text('''#!/usr/bin/env python3 launcher.write_text('''#!/usr/bin/env python3
import os,subprocess,sys import os,subprocess,sys
from pathlib import Path from pathlib import Path
args=sys.argv[1:] args=sys.argv[1:]
assert '--unit=app-backup-upgrade' in args assert '--unit=app-backup-upgrade' in args
assert '--property=Type=oneshot' in args
command=args[args.index('/bin/bash'):] command=args[args.index('/bin/bash'):]
state=Path(command[-1]) 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: 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) launcher.chmod(0o755)
def test_detached_package_handoff_finishes_after_starter_exits(self):
self.configure_upgrade()
self.systemd_launcher()
package=self.build_package() package=self.build_package()
result=subprocess.run(['bash',str(package)],env=self.env,capture_output=True,text=True,timeout=60) 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) 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.assertTrue(status.startswith('SUCCESS'),status+(state/'test-worker.log').read_text())
self.assertIn('unabhaengig von SSH',result.stdout) 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): def test_cron_inventory_requires_explicit_policy_and_sorts_timer_first(self):
canonical=self.configure_upgrade() canonical=self.configure_upgrade()
cron=Path('/etc/cron.d/app-backup-test-nextcloud') cron=Path('/etc/cron.d/app-backup-test-nextcloud')
+27 -8
View File
@@ -40,7 +40,8 @@ done
need systemctl; need flock need systemctl; need flock
# This lock excludes two coordinators, independently from ordinary backup jobs. # This lock excludes two coordinators, independently from ordinary backup jobs.
exec 8>/run/app-backup-upgrade.lock 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() { prompt() {
local answer local answer
@@ -244,20 +245,34 @@ if [[ $UP_WORKER == false ]]; then
sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256" sha256sum "$UP_STATE/candidate.conf" | cut -d ' ' -f 1 > "$UP_STATE/candidate.sha256"
echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")" echo "Vorpruefung erfolgreich. Writer: $(tr '\n' ' ' < "$UP_STATE/writers")"
echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.' echo 'Jetzt folgen Installation, ein vollstaendiges Backup und Remote-Restore-Pruefung.'
printf 'QUEUED\n' > "$UP_STATE/result.txt"
if [[ $UP_FOREGROUND == true ]]; then if [[ $UP_FOREGROUND == true ]]; then
flock -u 9; flock -u 8 flock -u 9; flock -u 8
exec bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" exec bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE"
fi fi
# A new systemd service inherits no shell FDs; it acquires both locks itself. # A oneshot start job completes only when ExecStart exits. Waiting for that
printf 'QUEUED\n' > "$UP_STATE/result.txt" # job while holding FD 8 deadlocks with the worker waiting for the same lock.
systemd-run --unit=app-backup-upgrade --collect --property=Type=oneshot \ # 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 \ --property=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=mixed \
/bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" || { /bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" 8>&- 9>&- || {
printf 'FAILED: systemd worker could not be started; installed package/config/timer unchanged.\n' > "$UP_STATE/result.txt" # 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 exit 1
} }
echo 'Update laeuft unabhaengig von SSH weiter.' # Release the backup lock FIRST. Once FD 8 is free, the worker may acquire
echo 'Verlauf: sudo journalctl -fu app-backup-upgrade.service' # 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" echo "Ergebnis: sudo cat $UP_STATE/result.txt"
exit 0 exit 0
fi fi
@@ -289,6 +304,10 @@ worker_finish() {
trap worker_finish EXIT trap worker_finish EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM 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" printf 'RUNNING\n' > "$UP_STATE/result.txt"
acquire_backup_lock acquire_backup_lock
export APP_BACKUP_INHERIT_LOCK=1 export APP_BACKUP_INHERIT_LOCK=1