Guard writer units against activation and fix backup cancellation
This commit is contained in:
1 parent
c0a24b63fc
commit
ff152c7f0f
9 files changed
+372
-16
No files matched your search
+59
-1
@@ -201,6 +201,40 @@ Gitea: `APP_DATA_PATH`, `[repository] ROOT`, Custom-Verzeichnis und `app.ini` pr
|
|||||||
|
|
||||||
`MAIL_BACKUP_MODE=live` und abgeschaltete Stopps sind bewusste Ausnahmen mit Konsistenzrisiko. Das Log weist darauf hin. Home-Verzeichnisse werden mit `--one-file-system` kopiert: Daten anderer Mounts fehlen dann absichtlich. Mailqueues, zusätzliche Spamfilter-/DKIM-Verzeichnisse oder Maildatenbanken sind nur gesichert, wenn sie explizit im Umfang stehen.
|
`MAIL_BACKUP_MODE=live` und abgeschaltete Stopps sind bewusste Ausnahmen mit Konsistenzrisiko. Das Log weist darauf hin. Home-Verzeichnisse werden mit `--one-file-system` kopiert: Daten anderer Mounts fehlen dann absichtlich. Mailqueues, zusätzliche Spamfilter-/DKIM-Verzeichnisse oder Maildatenbanken sind nur gesichert, wenn sie explizit im Umfang stehen.
|
||||||
|
|
||||||
|
Ein einfacher `systemctl stop` verhindert keinen späteren Start durch andere
|
||||||
|
Aufträge. Auf Gemini starteten am 10.09.2026 um 15:15:00 Uhr Apache und PHP-FPM
|
||||||
|
während der bis mindestens 15:16:33 laufenden Nextcloud-Datenkopie erneut. Die
|
||||||
|
Starts erfolgten vor der Wiederaufnahme durch das Backup. Dieser Lauf erfüllt
|
||||||
|
die vereinbarte Konsistenzbedingung nicht; eine erfolgreiche Hash-/Remote-Prüfung
|
||||||
|
kann dies nicht nachträglich beweisen. Der auslösende Auftrag ist noch unbekannt.
|
||||||
|
|
||||||
|
Vor dem Stoppen erhält nun jeder konfigurierte Writer eine eigene temporäre
|
||||||
|
systemd-Ergänzung unter `/run/systemd/system/UNIT.d/zzzz-app-backup-LAUF.conf`.
|
||||||
|
`ConditionPathExists=!/run/app-backup-guard-ZUFALL` verhindert den Start, solange
|
||||||
|
das zugehörige Verzeichnis besteht. Das gilt auch für zuvor inaktive konfigurierte
|
||||||
|
Writer und für Dienste mit eigener Unit-Datei unter `/etc`. Bestehende Maskierungen
|
||||||
|
werden nicht verändert. Die Laufzeitergänzung muss nach `daemon-reload` als letztes
|
||||||
|
Drop-in gemeldet werden; andernfalls bricht der Lauf ab, damit spätere
|
||||||
|
Condition-Resets die Sperre nicht unbemerkt überschreiben.
|
||||||
|
|
||||||
|
Nach dem Anhalten aller Writer, vor und nach den Dumps/Dateikopien und unmittelbar
|
||||||
|
vor der Freigabe prüft das Backup Dienstzustand, eigene Sperrdateien und die
|
||||||
|
systemd-Zeitpunkte `ActiveEnterTimestampMonotonic` und `InactiveExitTimestampMonotonic`.
|
||||||
|
Eine erkannte Aktivierung, veränderte Sperre oder nicht prüfbarer Zustand führt
|
||||||
|
zum Fehler, bevor Archive als vollständiger Satz veröffentlicht werden. Diese
|
||||||
|
Kontrollen ersetzen keine vollständige Ereignisaufzeichnung und schützen nicht
|
||||||
|
vor nicht konfigurierten Prozessen, direktem Programmstart außerhalb systemd oder
|
||||||
|
gezieltem administrativem Entfernen der Absicherung zwischen zwei Kontrollen.
|
||||||
|
Die systemd-Startbedingung verhindert reguläre Startaufträge während des Fensters.
|
||||||
|
Quelle: [systemd-Startbedingungen](https://github.com/systemd/systemd/blob/v256/man/systemd.unit.xml).
|
||||||
|
|
||||||
|
Bei erfolgreicher Kopie und bei normalen Backupfehlern entfernt der Wiederanlauf
|
||||||
|
ausschließlich die eigenen Drop-ins, lädt systemd neu und entfernt das zugehörige
|
||||||
|
Sperrverzeichnis. Erst danach werden die zuvor aktiven Dienste gestartet; deren
|
||||||
|
aktiver Zustand wird überprüft. Scheitert die Freigabe, bleiben Wiederanlaufdaten
|
||||||
|
erhalten und es wird kein Erfolg gemeldet. Bei teilweise ausgeführtem Restore
|
||||||
|
bleiben die Startsperren zusammen mit dem Wartungsmodus zur manuellen Prüfung bestehen.
|
||||||
|
|
||||||
`ENABLE_MAIL_STORE=true` bleibt der kompatible Default: `MAIL_DIR` muss existieren.
|
`ENABLE_MAIL_STORE=true` bleibt der kompatible Default: `MAIL_DIR` muss existieren.
|
||||||
Bei Mailboxen ausschließlich in Benutzerverzeichnissen kann explizit
|
Bei Mailboxen ausschließlich in Benutzerverzeichnissen kann explizit
|
||||||
`ENABLE_MAIL_STORE=false` gesetzt werden. Dann sind aktivierte, nicht leere
|
`ENABLE_MAIL_STORE=false` gesetzt werden. Dann sind aktivierte, nicht leere
|
||||||
@@ -250,6 +284,25 @@ Für den täglichen Zeitplan z. B. bei mehr als 36 Stunden ohne erfolgreichen La
|
|||||||
|
|
||||||
Ein geplanter Backupabbruch erfolgt über `systemctl stop app-backup.service`. Normale INT-/TERM-Behandlung versucht die ursprünglichen Dienste wiederherzustellen. SIGKILL, Kernelabsturz oder Stromverlust können keine Shell-Traps ausführen. Dafür liegt `recovery-LAUFNAME.json` mit vorher aufgezeichnetem Wartungs-/Dienstzustand und Arbeitsordner im Workdir. Diese Datei ist eine manuelle Wiederanlaufhilfe, kein automatischer Neustartauftrag. Das System startet nicht blind Anwendungen über einen teilweise restaurierten Datenbestand.
|
Ein geplanter Backupabbruch erfolgt über `systemctl stop app-backup.service`. Normale INT-/TERM-Behandlung versucht die ursprünglichen Dienste wiederherzustellen. SIGKILL, Kernelabsturz oder Stromverlust können keine Shell-Traps ausführen. Dafür liegt `recovery-LAUFNAME.json` mit vorher aufgezeichnetem Wartungs-/Dienstzustand und Arbeitsordner im Workdir. Diese Datei ist eine manuelle Wiederanlaufhilfe, kein automatischer Neustartauftrag. Das System startet nicht blind Anwendungen über einen teilweise restaurierten Datenbestand.
|
||||||
|
|
||||||
|
Backup-Unit und Update-Auftrag verwenden `KillMode=control-group`: Beim Stoppen
|
||||||
|
erhalten auch laufende Dump-, rsync-, Kompressions- und rclone-Prozesse TERM.
|
||||||
|
Dadurch können die wartenden Shells ihre Fehlerbehandlung ausführen, statt bis
|
||||||
|
zum regulären Ende eines langen Uploads zu warten. Die tee-Prozesse ignorieren
|
||||||
|
INT/TERM und enden mit dem Schließen ihrer Eingabe; so bleibt die Protokollierung
|
||||||
|
während der Wiederaufnahme erhalten. Die fünfminütige Stop-Frist bleibt bestehen.
|
||||||
|
Sie garantiert keine Wiederherstellung bei blockierten Kernelzugriffen oder
|
||||||
|
fehlgeschlagenen Dienststarts. Quelle:
|
||||||
|
[systemd-Signalzustellung](https://github.com/systemd/systemd/blob/v256/man/systemd.kill.xml).
|
||||||
|
|
||||||
|
Die Wiederanlaufdatei enthält außerdem `service_guard_files` und
|
||||||
|
`service_guard_directory`. Bei manueller Wiederherstellung nur die darin genannten
|
||||||
|
eigenen Drop-in-Dateien entfernen, `systemctl daemon-reload` ausführen und danach
|
||||||
|
das zugehörige leere Sperrverzeichnis mit `rmdir` entfernen. Keine fremden Drop-ins
|
||||||
|
oder Maskierungen löschen. Erst nach Prüfung des Datenzustands die aufgezeichneten
|
||||||
|
Dienste und den Wartungsmodus wiederherstellen. Die Sperren liegen unter `/run`
|
||||||
|
und überleben keinen Neustart; ein Reboot ist deshalb keine sichere Reparatur
|
||||||
|
eines teilweise ausgeführten Restores.
|
||||||
|
|
||||||
## Wiederherstellung
|
## Wiederherstellung
|
||||||
|
|
||||||
### Prüfphase
|
### Prüfphase
|
||||||
@@ -360,10 +413,15 @@ 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: **44 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.
|
Verifikation am 10.09.2026: **52 Testfälle erfolgreich** — 50 im vollständigen Durchlauf, anschließend beide zusätzlichen Abbruchtests für Kopie und Upload auf dem ergänzten Stand. Shell-Syntax und ShellCheck abschließend 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:
|
||||||
|
|
||||||
|
- Fremde Startversuche während realer rsync-Kopien werden durch das systemd-Testmodell übersprungen; zuvor inaktive Writer bleiben nach dem Lauf inaktiv.
|
||||||
|
- Aktive sowie zwischenzeitlich gestartete und wieder gestoppte Writer verhindern die Veröffentlichung eines vollständigen Satzes.
|
||||||
|
- Nicht geladene Startsperre, Erhalt vorhandener Maskierungen und fehlgeschlagene Sperrfreigabe mit erhaltener Wiederanlaufdatei.
|
||||||
|
- Bei teilweise ausgeführtem Restore bleiben eigene Startsperren erhalten. `systemd-analyze` prüft die reale Condition-Auswertung und Unit-Syntax separat, ohne einen Dienst zu starten.
|
||||||
|
- TERM an die gesamte simulierte Unit-Prozessgruppe während blockierter Kopie beziehungsweise blockiertem Upload: zeitnahes Fehlerergebnis, Dienstwiederaufnahme, freigegebene Startsperren, erhaltenes Protokoll und gestoppter Backup-Timer.
|
||||||
- Verweigerter rsync-Quellzugriff bricht das Update vor Installation und Dienst-Stopp ab.
|
- Verweigerter rsync-Quellzugriff bricht das Update vor Installation und Dienst-Stopp ab.
|
||||||
- Verweigerte Verzeichnisattribute werden vor Wartungsmodus erkannt; der private Prüfbereich wird entfernt.
|
- Verweigerte Verzeichnisattribute werden vor Wartungsmodus erkannt; der private Prüfbereich wird entfernt.
|
||||||
- Attributprobe kopiert keine Kindobjekte; rsync erbt bei Prüfung, Backup und Restore keine Update-/Backup-Sperrdeskriptoren.
|
- Attributprobe kopiert keine Kindobjekte; rsync erbt bei Prüfung, Backup und Restore keine Update-/Backup-Sperrdeskriptoren.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ Details und geprüfte Grenzen stehen in der Update-Anleitung.
|
|||||||
- Funktionierender Restore mit Prüflauf, Entpackmodus, Komponentenauswahl und ausdrücklichem `--apply --yes`.
|
- Funktionierender Restore mit Prüflauf, Entpackmodus, Komponentenauswahl und ausdrücklichem `--apply --yes`.
|
||||||
- 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.
|
||||||
- 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. Alte Sicherungen werden nicht automatisch gelöscht.
|
- Retention löscht nur vollständige neue Sicherungssätze und behält mindestens `RETENTION_KEEP_LAST` Läufe. Alte Sicherungen werden nicht automatisch gelöscht.
|
||||||
- Private Arbeitsverzeichnisse, gemeinsame Sperre für Backup/Restore/Update und Wiederanlaufhinweise nach Abbrüchen.
|
- Private Arbeitsverzeichnisse, gemeinsame Sperre für Backup/Restore/Update und Wiederanlaufhinweise nach Abbrüchen.
|
||||||
|
|||||||
+50
-1
@@ -311,6 +311,49 @@ passenden AVC-Meldungen prüfen. Maßgeblich für den erfolgreichen Abschluss bl
|
|||||||
`SUCCESS` in `result.txt`; erst danach aktiviert der Installer den Backup-Timer.
|
`SUCCESS` in `result.txt`; erst danach aktiviert der Installer den Backup-Timer.
|
||||||
Eine Wiederherstellungsprobe auf einem separaten System steht weiterhin aus.
|
Eine Wiederherstellungsprobe auf einem separaten System steht weiterhin aus.
|
||||||
|
|
||||||
|
## Erneuter Dienststart während der Sicherung
|
||||||
|
|
||||||
|
Am 10.09.2026 starteten auf Gemini um 15:15 Uhr Apache und PHP-FPM während der
|
||||||
|
Nextcloud-Datenkopie erneut. Paket `c0a24b63fc1c` hatte die SELinux-Zugriffe bereits
|
||||||
|
korrigiert, schützte jedoch nicht vor fremden Startaufträgen. Den betroffenen
|
||||||
|
Lauf nicht als konsistente Sicherung abnehmen, auch falls Upload/Hashprüfung
|
||||||
|
anschließend erfolgreich waren. Der konkrete Auslöser des Startauftrags ist
|
||||||
|
noch offen. Die 90 Sekunden Apache-Stopp sind dagegen durch dessen Journal
|
||||||
|
erklärt: Timeout beim Beenden, anschließend SIGKILL durch systemd. Das neue Paket
|
||||||
|
ändert weder Apache-Timeout noch seine Signalbehandlung pauschal.
|
||||||
|
|
||||||
|
Einen noch laufenden alten Update-Auftrag kontrolliert beenden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop app-backup-upgrade.service
|
||||||
|
sudo journalctl --no-pager -u app-backup-upgrade.service -n 60
|
||||||
|
sudo systemctl --no-pager is-active httpd php-fpm gitea postfix dovecot crond
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Protokoll die Wiederaufnahme und das Verlassen des Wartungsmodus prüfen.
|
||||||
|
Bei Fehlern die ausgegebene `recovery-*.json` beachten. Erst nach abgeschlossenem
|
||||||
|
alten Auftrag das aktuelle Paket mit den bisherigen Gitea-/Cron-/Mail-Optionen
|
||||||
|
starten. Bestehende Sicherungssätze und Arbeitsverzeichnisse nicht pauschal löschen.
|
||||||
|
|
||||||
|
Das neue Paket richtet für die Kopierphase vorübergehende systemd-Startbedingungen
|
||||||
|
ein und prüft Writer-Zustände sowie Aktivierungszeitpunkte vor/nach den Kopien.
|
||||||
|
Es entfernt beim Wiederanlauf nur eigene Ergänzungen; bei einem teilweise
|
||||||
|
ausgeführten Restore bleiben sie bis zur manuellen Prüfung bestehen. Einzelheiten
|
||||||
|
zur gezielten Freigabe stehen in `Dokumentation.md` bei der Wiederanlaufhilfe.
|
||||||
|
Die permanente Dienstkonfiguration und vorhandene Maskierungen bleiben erhalten.
|
||||||
|
Die Meldung `Copy window complete; writer guards verified` bestätigt das Ende
|
||||||
|
der geprüften Kopierphase. Abgeschlossen ist die Paketabnahme erst bei `SUCCESS`
|
||||||
|
in der Ergebnisdatei nach vollständiger Remote-Prüfung.
|
||||||
|
|
||||||
|
Der Abbruchpfad wurde ebenfalls korrigiert: Bisher erreichte TERM bei
|
||||||
|
`KillMode=mixed` zunächst nur den Update-Hauptprozess. Eine wartende Bash konnte
|
||||||
|
dadurch den Stop bis zum Ende ihres Backup-/Upload-Kindprozesses verzögern.
|
||||||
|
Die neuen Units verwenden `KillMode=control-group`; die Protokollprozesse bleiben
|
||||||
|
während der Fehlerbehandlung verfügbar. Nach einem Update-Abbruch das Journal
|
||||||
|
und die Dienstzustände prüfen; insbesondere ein Stop-Timeout ist kein Nachweis
|
||||||
|
für eine erfolgreiche Wiederaufnahme. Die Änderung wirkt für neue Aufträge,
|
||||||
|
nicht nachträglich für einen bereits gestarteten alten Worker.
|
||||||
|
|
||||||
## Paket reproduzieren
|
## Paket reproduzieren
|
||||||
|
|
||||||
Aus einem sauberen, eingecheckten Repository:
|
Aus einem sauberen, eingecheckten Repository:
|
||||||
@@ -327,7 +370,7 @@ Das ausführbare Paket ausschließlich über euren vertrauenswürdigen Kanal bez
|
|||||||
|
|
||||||
## Verifikation der Lieferung
|
## Verifikation der Lieferung
|
||||||
|
|
||||||
44 Container-Tests sowie ShellCheck und Shell-Syntaxprüfung erfolgreich.
|
52 Testfälle erfolgreich: vollständiger Durchlauf mit 50 Tests, anschließend die zwei zusätzlichen Abbruchtests für Kopie und Upload. ShellCheck und Shell-Syntaxprüfung abschließend 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. Das Übergabemodell
|
systemd/occ und die systemd-run-Übergabe sind simuliert. Das Übergabemodell
|
||||||
berücksichtigt jetzt das Warten auf den oneshot-Startauftrag: Der Regressionstest
|
berücksichtigt jetzt das Warten auf den oneshot-Startauftrag: Der Regressionstest
|
||||||
@@ -338,3 +381,9 @@ SELinux-Durchsetzung wird im Debian-Testcontainer nicht nachgebildet; der erfolg
|
|||||||
Bash-/rsync-Lesetest auf Gemini wurde separat vom Betreiber durchgeführt. Die vollständige
|
Bash-/rsync-Lesetest auf Gemini wurde separat vom Betreiber durchgeführt. Die vollständige
|
||||||
Server-/OneDrive-Prüfung erfolgt beim Paketlauf. Ein fachlicher Restore
|
Server-/OneDrive-Prüfung erfolgt beim Paketlauf. Ein fachlicher Restore
|
||||||
auf einem separaten Testsystem bleibt erforderlich.
|
auf einem separaten Testsystem bleibt erforderlich.
|
||||||
|
|
||||||
|
Die neuen Tests bilden fremde Startaufträge während rsync, zwischenzeitliche
|
||||||
|
Neustarts, bestehende Maskierungen und Fehler bei der Sperrfreigabe ab. Die
|
||||||
|
Condition-Auswertung (Sperrverzeichnis vorhanden/entfernt) und Unit-Syntax wurden
|
||||||
|
zusätzlich mit echtem `systemd-analyze` geprüft. Der tatsächliche systemd-Manager
|
||||||
|
und SELinux auf Gemini bleiben Teil der noch ausstehenden Serverabnahme.
|
||||||
+99
-7
@@ -116,12 +116,14 @@ init_runtime() {
|
|||||||
RUN_ID="${ARCHIVE_PREFIX}_$(date -u +%Y-%m-%d_%H-%M-%S)_$(python3 -c 'import secrets; print(secrets.token_hex(4))')"
|
RUN_ID="${ARCHIVE_PREFIX}_$(date -u +%Y-%m-%d_%H-%M-%S)_$(python3 -c 'import secrets; print(secrets.token_hex(4))')"
|
||||||
LOG_FILE="$LOG_DIR/${OPERATION}_${RUN_ID}.log"
|
LOG_FILE="$LOG_DIR/${OPERATION}_${RUN_ID}.log"
|
||||||
# Journald already captures service stdout. No dependency on systemd-cat in terminals.
|
# Journald already captures service stdout. No dependency on systemd-cat in terminals.
|
||||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
# Keep the log pipe alive while systemd sends TERM to the whole control group.
|
||||||
|
# tee exits on EOF after the recovery traps close their writers.
|
||||||
|
exec > >(trap '' INT TERM; exec tee -a "$LOG_FILE") 2>&1
|
||||||
echo "== $OPERATION $RUN_ID =="
|
echo "== $OPERATION $RUN_ID =="
|
||||||
local security_context
|
local security_context
|
||||||
if security_context=$(id -Z 2>/dev/null); then echo "-- SELinux process context: $security_context"; fi
|
if security_context=$(id -Z 2>/dev/null); then echo "-- SELinux process context: $security_context"; fi
|
||||||
need rsync; need tar; need "$COMPRESSOR"
|
need rsync; need tar; need "$COMPRESSOR"
|
||||||
STOPPED_SERVICES=(); NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false
|
STOPPED_SERVICES=(); GUARDED_SERVICES=(); GUARD_FILES=(); GUARD_STAMPS=(); SERVICE_GUARD_DIR=''; NC_CHANGED=false; NC_WAS_ON=false; MUTATION_STARTED=false
|
||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
trap finish EXIT
|
trap finish EXIT
|
||||||
@@ -284,8 +286,83 @@ exclusions() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A stop alone does not exclude timer/monitor/dependency activation. Attach a
|
||||||
|
# run-owned runtime condition before stopping each writer. Unlike a runtime mask,
|
||||||
|
# a drop-in also works for administrator units whose main file lives in /etc.
|
||||||
|
# This runtime root is overridable only by the trusted config (isolated tests).
|
||||||
|
install_service_guard() {
|
||||||
|
local unit=$1 id load dir file paths existing
|
||||||
|
for id in "${GUARDED_SERVICES[@]}"; do [[ $id != "$unit" ]] || return 0; done
|
||||||
|
load=$(systemctl show "$unit" --property=LoadState --value)
|
||||||
|
[[ $load == loaded || $load == masked ]] || die "Cannot guard unloaded unit: $unit ($load)"
|
||||||
|
if [[ $load == masked ]]; then
|
||||||
|
# Preserve pre-existing masks verbatim; never claim ownership of them.
|
||||||
|
GUARDED_SERVICES+=("$unit"); GUARD_FILES+=(''); return 0
|
||||||
|
fi
|
||||||
|
id=$(systemctl show "$unit" --property=Id --value)
|
||||||
|
[[ $id =~ ^[a-zA-Z0-9_@:.\\-]+\.(service|timer|socket|path)$ ]] || die "Unsupported writer unit name: $id"
|
||||||
|
: "${SERVICE_GUARD_RUNTIME_ROOT:=/run}"
|
||||||
|
[[ $SERVICE_GUARD_RUNTIME_ROOT =~ ^/[a-zA-Z0-9_./-]+$ ]] || die 'Unsafe service guard runtime root'
|
||||||
|
if [[ -z $SERVICE_GUARD_DIR ]]; then
|
||||||
|
SERVICE_GUARD_DIR=$(mktemp -d "$SERVICE_GUARD_RUNTIME_ROOT/app-backup-guard-XXXXXXXX")
|
||||||
|
fi
|
||||||
|
dir="$SERVICE_GUARD_RUNTIME_ROOT/systemd/system/$id.d"
|
||||||
|
[[ ! -L $dir ]] || die "Symlink writer drop-in directory refused: $dir"
|
||||||
|
mkdir -p -- "$dir"
|
||||||
|
[[ $(stat -c %u "$dir") == 0 ]] || die "Writer drop-in directory must belong to root: $dir"
|
||||||
|
(( (8#$(stat -c %a "$dir") & 8#022) == 0 )) || die "Unsafe writer drop-in directory: $dir"
|
||||||
|
file="$dir/zzzz-app-backup-$RUN_ID.conf"
|
||||||
|
for existing in "${GUARD_FILES[@]}"; do [[ $existing != "$file" ]] || return 0; done
|
||||||
|
[[ ! -e $file && ! -L $file ]] || die "Guard file already exists: $file"
|
||||||
|
GUARDED_SERVICES+=("$unit"); GUARD_FILES+=("$file")
|
||||||
|
# Record our exact paths BEFORE changing systemd state, including on failures.
|
||||||
|
save_recovery
|
||||||
|
(set -o noclobber; printf '[Unit]\nConditionPathExists=!%s\n' "$SERVICE_GUARD_DIR" > "$file")
|
||||||
|
systemctl daemon-reload
|
||||||
|
paths=$(systemctl show "$unit" --property=DropInPaths --value)
|
||||||
|
# Requiring our drop-in last avoids a later Condition*= resetting the guard.
|
||||||
|
[[ ${paths##* } == "$file" ]] || die "Start guard not loaded last for $unit; review its drop-ins"
|
||||||
|
echo "-- Start guard installed: $unit"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_services_quiescent() {
|
||||||
|
local i unit state stamp paths
|
||||||
|
for i in "${!GUARDED_SERVICES[@]}"; do
|
||||||
|
unit=${GUARDED_SERVICES[i]}
|
||||||
|
state=$(systemctl show "$unit" --property=ActiveState --value)
|
||||||
|
[[ $state == inactive || $state == failed ]] || die "Consistency lost: writer $unit became $state during the copy window"
|
||||||
|
if [[ -n ${GUARD_FILES[i]} ]]; then
|
||||||
|
[[ -d $SERVICE_GUARD_DIR && -f ${GUARD_FILES[i]} ]] || die "Consistency lost: start guard removed for $unit"
|
||||||
|
[[ $(cat -- "${GUARD_FILES[i]}") == "$(printf '[Unit]\nConditionPathExists=!%s' "$SERVICE_GUARD_DIR")" ]] || die "Consistency lost: start guard changed for $unit"
|
||||||
|
paths=$(systemctl show "$unit" --property=DropInPaths --value)
|
||||||
|
[[ ${paths##* } == "${GUARD_FILES[i]}" ]] || die "Consistency lost: start guard overridden for $unit"
|
||||||
|
else
|
||||||
|
[[ $(systemctl show "$unit" --property=LoadState --value) == masked ]] || die "Consistency lost: existing mask removed for $unit"
|
||||||
|
fi
|
||||||
|
stamp=$(systemctl show "$unit" --property=ActiveEnterTimestampMonotonic --property=InactiveExitTimestampMonotonic)
|
||||||
|
if [[ -n ${GUARD_STAMPS[i]:-} && $stamp != "${GUARD_STAMPS[i]}" ]]; then
|
||||||
|
die "Consistency lost: writer $unit was restarted during the copy window"
|
||||||
|
fi
|
||||||
|
[[ -n $stamp ]] || die "Missing writer state timestamps: $unit"
|
||||||
|
GUARD_STAMPS[i]=$stamp
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
release_service_guards() {
|
||||||
|
local file
|
||||||
|
# Remove only this run's drop-ins. Keep the condition directory until reload
|
||||||
|
# succeeds, so stale manager state continues to refuse starts on failure.
|
||||||
|
for file in "${GUARD_FILES[@]}"; do
|
||||||
|
[[ -z $file ]] || rm -f -- "$file" || return 1
|
||||||
|
done
|
||||||
|
if (( ${#GUARDED_SERVICES[@]} )); then systemctl daemon-reload || return 1; fi
|
||||||
|
if [[ -n $SERVICE_GUARD_DIR ]]; then rmdir -- "$SERVICE_GUARD_DIR" || return 1; fi
|
||||||
|
SERVICE_GUARD_DIR=''; GUARDED_SERVICES=(); GUARD_FILES=(); GUARD_STAMPS=()
|
||||||
|
}
|
||||||
|
|
||||||
stop_service() {
|
stop_service() {
|
||||||
local state
|
local state
|
||||||
|
install_service_guard "$1"
|
||||||
state=$(systemctl show "$1" --property=ActiveState --value)
|
state=$(systemctl show "$1" --property=ActiveState --value)
|
||||||
case $state in
|
case $state in
|
||||||
active|activating|reloading)
|
active|activating|reloading)
|
||||||
@@ -330,15 +407,19 @@ quiesce() {
|
|||||||
helper wait-nextcloud "$NC_DIR" "${WRITER_DRAIN_TIMEOUT:=300}"
|
helper wait-nextcloud "$NC_DIR" "${WRITER_DRAIN_TIMEOUT:=300}"
|
||||||
echo '-- Nextcloud writers finished'
|
echo '-- Nextcloud writers finished'
|
||||||
fi
|
fi
|
||||||
|
assert_services_quiescent
|
||||||
|
echo '-- All configured writers stopped and protected against activation'
|
||||||
}
|
}
|
||||||
# Survives SIGKILL/power loss, which Bash traps cannot handle. This is an
|
# Survives SIGKILL/power loss, which Bash traps cannot handle. This is an
|
||||||
# instruction record for the operator, not an automatically executed recovery.
|
# instruction record for the operator, not an automatically executed recovery.
|
||||||
save_recovery() {
|
save_recovery() {
|
||||||
python3 - "$WORKDIR/recovery-$RUN_ID.json" "$OPERATION" "$NC_CHANGED" "$NC_WAS_ON" "${STAGING_DIR:-}" "${STOPPED_SERVICES[@]}" <<'PYREC'
|
python3 - "$WORKDIR/recovery-$RUN_ID.json" "$OPERATION" "$NC_CHANGED" "$NC_WAS_ON" "${STAGING_DIR:-}" "$SERVICE_GUARD_DIR" "${#GUARD_FILES[@]}" "${GUARD_FILES[@]}" "${STOPPED_SERVICES[@]}" <<'PYREC'
|
||||||
import json,os,sys
|
import json,os,sys
|
||||||
path,operation,changed,was_on,staging,*services=sys.argv[1:]
|
path,operation,changed,was_on,staging,guard_dir,count,*items=sys.argv[1:]
|
||||||
|
guard_files=items[:int(count)]
|
||||||
|
services=items[int(count):]
|
||||||
with open(path+'.tmp','w') as stream:
|
with open(path+'.tmp','w') as stream:
|
||||||
json.dump(dict(operation=operation, staging=staging, nextcloud_changed=changed=='true', nextcloud_was_on=was_on=='true', stopped_services=services),stream,indent=2)
|
json.dump(dict(operation=operation, staging=staging, nextcloud_changed=changed=='true', nextcloud_was_on=was_on=='true', stopped_services=services, service_guard_directory=guard_dir, service_guard_files=[p for p in guard_files if p]),stream,indent=2)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
os.fsync(stream.fileno())
|
os.fsync(stream.fileno())
|
||||||
os.replace(path+'.tmp',path)
|
os.replace(path+'.tmp',path)
|
||||||
@@ -346,16 +427,27 @@ PYREC
|
|||||||
}
|
}
|
||||||
resume_services() {
|
resume_services() {
|
||||||
local failed=0 i
|
local failed=0 i
|
||||||
|
# Remove all our guards before starting dependencies in reverse order. Never
|
||||||
|
# start anything if guard cleanup/reload failed: retain manual recovery state.
|
||||||
|
if ! release_service_guards; then
|
||||||
|
echo 'ERROR: Could not release writer start guards; manual recovery required'
|
||||||
|
save_recovery
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
if [[ $NC_CHANGED == true ]]; then
|
if [[ $NC_CHANGED == true ]]; then
|
||||||
if occ maintenance:mode --off; then NC_CHANGED=false; else failed=1; fi
|
if occ maintenance:mode --off; then NC_CHANGED=false; else failed=1; fi
|
||||||
fi
|
fi
|
||||||
for ((i=${#STOPPED_SERVICES[@]}-1; i>=0; i--)); do
|
for ((i=${#STOPPED_SERVICES[@]}-1; i>=0; i--)); do
|
||||||
[[ -n ${STOPPED_SERVICES[i]} ]] || continue
|
[[ -n ${STOPPED_SERVICES[i]} ]] || continue
|
||||||
echo "-- Starting service: ${STOPPED_SERVICES[i]} (waiting for systemd)"
|
echo "-- Starting service: ${STOPPED_SERVICES[i]} (waiting for systemd)"
|
||||||
if systemctl start "${STOPPED_SERVICES[i]}"; then
|
if systemctl start "${STOPPED_SERVICES[i]}" &&
|
||||||
|
[[ $(systemctl show "${STOPPED_SERVICES[i]}" --property=ActiveState --value) == active ]]; then
|
||||||
echo "-- Service started: ${STOPPED_SERVICES[i]}"
|
echo "-- Service started: ${STOPPED_SERVICES[i]}"
|
||||||
STOPPED_SERVICES[i]=''
|
STOPPED_SERVICES[i]=''
|
||||||
else failed=1; fi
|
else
|
||||||
|
echo "ERROR: Service did not become active: ${STOPPED_SERVICES[i]}"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
if (( failed == 0 )); then
|
if (( failed == 0 )); then
|
||||||
rm -f -- "$WORKDIR/recovery-$RUN_ID.json"
|
rm -f -- "$WORKDIR/recovery-$RUN_ID.json"
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ SELinuxContext=system_u:system_r:unconfined_service_t:s0
|
|||||||
# Timeout sends TERM: Bash runs its recovery trap; grace time permits service restart.
|
# Timeout sends TERM: Bash runs its recovery trap; grace time permits service restart.
|
||||||
TimeoutStartSec=12h
|
TimeoutStartSec=12h
|
||||||
TimeoutStopSec=5min
|
TimeoutStopSec=5min
|
||||||
KillMode=mixed
|
# Interrupt current copy/dump/upload too, so Bash can run recovery promptly.
|
||||||
|
KillMode=control-group
|
||||||
UMask=0077
|
UMask=0077
|
||||||
Nice=10
|
Nice=10
|
||||||
IOSchedulingClass=best-effort
|
IOSchedulingClass=best-effort
|
||||||
|
|||||||
+150
-4
@@ -9,6 +9,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -126,6 +127,8 @@ class Integration(unittest.TestCase):
|
|||||||
(self.wp/'nested/data').mkdir(parents=True)
|
(self.wp/'nested/data').mkdir(parents=True)
|
||||||
(self.wp/'nested/data/keep.txt').write_text('nested data must survive')
|
(self.wp/'nested/data/keep.txt').write_text('nested data must survive')
|
||||||
(self.nc/'occ').write_text('test stub')
|
(self.nc/'occ').write_text('test stub')
|
||||||
|
self.runtime=self.base/'runtime'
|
||||||
|
self.runtime.mkdir()
|
||||||
self.state=self.base/'state.json'
|
self.state=self.base/'state.json'
|
||||||
self.initial=dict(services={s:'active' for s in ['httpd','php-fpm','gitea','postfix','dovecot','nc-cron']}, maintenance=False)
|
self.initial=dict(services={s:'active' for s in ['httpd','php-fpm','gitea','postfix','dovecot','nc-cron']}, maintenance=False)
|
||||||
self.state.write_text(json.dumps(self.initial))
|
self.state.write_text(json.dumps(self.initial))
|
||||||
@@ -142,16 +145,36 @@ with open(os.environ['TEST_EVENTS'],'a') as f: f.write(name+' '+' '.join(args)+'
|
|||||||
if name=='systemctl':
|
if name=='systemctl':
|
||||||
cmd=args[0]
|
cmd=args[0]
|
||||||
service=args[1] if len(args)>1 else ''
|
service=args[1] if len(args)>1 else ''
|
||||||
|
runtime=Path(os.environ['TEST_RUNTIME'])
|
||||||
|
unit=service if service.endswith(('.service','.timer','.socket','.path')) else service+'.service'
|
||||||
|
files=sorted((runtime/'systemd/system'/f'{unit}.d').glob('*.conf'))
|
||||||
|
guards=[]
|
||||||
|
for file in files:
|
||||||
|
for line in file.read_text().splitlines():
|
||||||
|
if line.startswith('ConditionPathExists=!'): guards.append(Path(line.split('=!',1)[1]))
|
||||||
|
stamp_file=Path(str(p)+'.stamps')
|
||||||
|
stamps=json.loads(stamp_file.read_text()) if stamp_file.exists() else {}
|
||||||
if cmd=='show':
|
if cmd=='show':
|
||||||
if '--property=LoadState' in args: print('loaded' if service in s['services'] else 'not-found')
|
if '--property=LoadState' in args: print('masked' if service in s.get('masked',[]) else ('loaded' if service in s['services'] else 'not-found'))
|
||||||
|
elif '--property=Id' in args: print(unit)
|
||||||
|
elif '--property=DropInPaths' in args: print(' '.join(map(str,files)) if not os.environ.get('TEST_IGNORE_GUARD') else '')
|
||||||
|
elif '--property=ActiveEnterTimestampMonotonic' in args: print('ActiveEnterTimestampMonotonic='+str(stamps.get(service,1))+'\\nInactiveExitTimestampMonotonic='+str(stamps.get(service,1)))
|
||||||
elif '--property=Environment' in args or '--property=TriggeredBy' in args: print('')
|
elif '--property=Environment' in args or '--property=TriggeredBy' in args: print('')
|
||||||
else: print(s['services'].get(service,'inactive'))
|
else: print(s['services'].get(service,'inactive'))
|
||||||
elif cmd=='is-active': sys.exit(0 if s['services'].get(args[-1])=='active' else 3)
|
elif cmd=='is-active': sys.exit(0 if s['services'].get(args[-1])=='active' else 3)
|
||||||
elif cmd in ('stop','start'):
|
elif cmd in ('stop','start'):
|
||||||
if os.environ.get('TEST_FAIL_SERVICE')==cmd+':'+service: sys.exit(1)
|
if os.environ.get('TEST_FAIL_SERVICE')==cmd+':'+service: sys.exit(1)
|
||||||
|
if cmd=='start':
|
||||||
|
if service in s.get('masked',[]): sys.exit(1)
|
||||||
|
if any(g.exists() for g in guards):
|
||||||
|
with open(os.environ['TEST_EVENTS'],'a') as f: f.write('guard blocked '+service+'\\n')
|
||||||
|
sys.exit(0) # systemd skips a failed Condition without starting the unit
|
||||||
|
stamps[service]=stamps.get(service,1)+1
|
||||||
|
stamp_file.write_text(json.dumps(stamps))
|
||||||
s['services'][service]='active' if cmd=='start' else 'inactive'
|
s['services'][service]='active' if cmd=='start' else 'inactive'
|
||||||
elif cmd=='enable': s['services'][args[-1]]='active'
|
elif cmd=='enable': s['services'][args[-1]]='active'
|
||||||
elif cmd=='daemon-reload': pass
|
elif cmd=='daemon-reload':
|
||||||
|
if os.environ.get('TEST_FAIL_GUARD_RELEASE') and list(runtime.glob('app-backup-guard-*')) and not list(runtime.glob('systemd/system/*/zzzz-app-backup-*.conf')): sys.exit(1)
|
||||||
elif name=='sudo':
|
elif name=='sudo':
|
||||||
os.execvp(args[2],args[2:])
|
os.execvp(args[2],args[2:])
|
||||||
elif name=='php':
|
elif name=='php':
|
||||||
@@ -181,7 +204,8 @@ exec {real} "$@"
|
|||||||
self.cnf.chmod(0o600)
|
self.cnf.chmod(0o600)
|
||||||
self.sql('DROP DATABASE IF EXISTS backup_test; CREATE DATABASE backup_test; CREATE TABLE backup_test.t (id INT PRIMARY KEY, body TEXT) ENGINE=MyISAM; INSERT INTO backup_test.t VALUES (1,"original");')
|
self.sql('DROP DATABASE IF EXISTS backup_test; CREATE DATABASE backup_test; CREATE TABLE backup_test.t (id INT PRIMARY KEY, body TEXT) ENGINE=MyISAM; INSERT INTO backup_test.t VALUES (1,"original");')
|
||||||
self.config=self.base/'config'
|
self.config=self.base/'config'
|
||||||
self.config.write_text(f'''WORKDIR='{self.base}/work'
|
self.config.write_text(f'''SERVICE_GUARD_RUNTIME_ROOT='{self.runtime}'
|
||||||
|
WORKDIR='{self.base}/work'
|
||||||
LOG_DIR='{self.base}/logs'
|
LOG_DIR='{self.base}/logs'
|
||||||
LOCKFILE='{self.base}/lock'
|
LOCKFILE='{self.base}/lock'
|
||||||
MIN_FREE_GB=0
|
MIN_FREE_GB=0
|
||||||
@@ -212,7 +236,7 @@ GITEA_DB_CNF='{self.cnf}'
|
|||||||
QUIESCE_SERVICES=(nc-cron)
|
QUIESCE_SERVICES=(nc-cron)
|
||||||
''')
|
''')
|
||||||
self.config.chmod(0o600)
|
self.config.chmod(0o600)
|
||||||
self.env=dict(os.environ, PATH=str(self.bin)+':'+os.environ['PATH'],TEST_STATE=str(self.state),TEST_EVENTS=str(self.events))
|
self.env=dict(os.environ,TEST_RUNTIME=str(self.runtime), PATH=str(self.bin)+':'+os.environ['PATH'],TEST_STATE=str(self.state),TEST_EVENTS=str(self.events))
|
||||||
|
|
||||||
def run_script(self, script, *args, ok=True, **env):
|
def run_script(self, script, *args, ok=True, **env):
|
||||||
proc=subprocess.run(['bash',str(REPO/'usr-local-sbin'/script),'--config',str(self.config),*map(str,args)],env=dict(self.env,**env),text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=90)
|
proc=subprocess.run(['bash',str(REPO/'usr-local-sbin'/script),'--config',str(self.config),*map(str,args)],env=dict(self.env,**env),text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=90)
|
||||||
@@ -312,6 +336,77 @@ if '--exclude=/*' in sys.argv:
|
|||||||
run=self.backup()
|
run=self.backup()
|
||||||
self.run_script('app-restore.sh','--local-run',run,'--components','wordpress','--apply','--yes')
|
self.run_script('app-restore.sh','--local-run',run,'--components','wordpress','--apply','--yes')
|
||||||
|
|
||||||
|
def test_start_guard_blocks_monitor_during_copy_and_preserves_inactive_writer(self):
|
||||||
|
state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=False)
|
||||||
|
self.state.write_text(json.dumps(state))
|
||||||
|
wrapper=self.bin/'rsync'
|
||||||
|
wrapper.write_text('''#!/usr/bin/env bash
|
||||||
|
if [[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]; then
|
||||||
|
systemctl start httpd
|
||||||
|
systemctl start php-fpm
|
||||||
|
systemctl start gitea
|
||||||
|
fi
|
||||||
|
exec /usr/bin/rsync "$@"
|
||||||
|
''')
|
||||||
|
self.backup()
|
||||||
|
self.assertEqual(json.loads(self.state.read_text()),state)
|
||||||
|
for unit in ['httpd','php-fpm','gitea']:
|
||||||
|
self.assertIn('guard blocked '+unit,self.events.read_text())
|
||||||
|
self.assertFalse(list(self.runtime.glob('systemd/system/*/zzzz-app-backup-*.conf')))
|
||||||
|
self.assertFalse(list(self.runtime.glob('app-backup-guard-*')))
|
||||||
|
|
||||||
|
def inject_writer_restart(self, final_state):
|
||||||
|
wrapper=self.bin/'rsync'
|
||||||
|
wrapper.write_text('''#!/usr/bin/env python3
|
||||||
|
import json,os,subprocess,sys
|
||||||
|
from pathlib import Path
|
||||||
|
result=subprocess.run(['/usr/bin/rsync',*sys.argv[1:]])
|
||||||
|
if result.returncode: sys.exit(result.returncode)
|
||||||
|
if '--list-only' not in sys.argv and '--exclude=/*' not in sys.argv:
|
||||||
|
p=Path(os.environ['TEST_STATE'])
|
||||||
|
state=json.loads(p.read_text())
|
||||||
|
state['services']['httpd']='''+repr(final_state)+'''
|
||||||
|
p.write_text(json.dumps(state))
|
||||||
|
p=Path(str(p)+'.stamps')
|
||||||
|
stamps=json.loads(p.read_text()) if p.exists() else {}
|
||||||
|
stamps['httpd']=stamps.get('httpd',1)+1
|
||||||
|
p.write_text(json.dumps(stamps))
|
||||||
|
''')
|
||||||
|
result=self.run_script('app-backup.sh',ok=False)
|
||||||
|
self.assertIn('Consistency lost:',result.stdout)
|
||||||
|
self.assertFalse(list((self.base/'work/archives').glob('appbackup_*')))
|
||||||
|
self.assertEqual(json.loads(self.state.read_text()),self.initial)
|
||||||
|
self.assertFalse(list(self.runtime.glob('systemd/system/*/zzzz-app-backup-*.conf')))
|
||||||
|
|
||||||
|
def test_running_writer_prevents_backup_publication(self):
|
||||||
|
self.inject_writer_restart('active')
|
||||||
|
|
||||||
|
def test_restarted_then_stopped_writer_prevents_backup_publication(self):
|
||||||
|
self.inject_writer_restart('inactive')
|
||||||
|
|
||||||
|
def test_unloaded_start_guard_aborts_before_service_stop(self):
|
||||||
|
result=self.run_script('app-backup.sh',ok=False,TEST_IGNORE_GUARD='1')
|
||||||
|
self.assertIn('Start guard not loaded last',result.stdout)
|
||||||
|
self.assertNotIn('systemctl stop',self.events.read_text())
|
||||||
|
self.assertEqual(json.loads(self.state.read_text()),self.initial)
|
||||||
|
self.assertFalse(list(self.runtime.glob('app-backup-guard-*')))
|
||||||
|
|
||||||
|
def test_existing_service_mask_is_preserved(self):
|
||||||
|
state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=False,masked=['gitea'])
|
||||||
|
self.state.write_text(json.dumps(state))
|
||||||
|
self.backup()
|
||||||
|
self.assertEqual(json.loads(self.state.read_text()),state)
|
||||||
|
self.assertNotIn('systemctl start gitea',self.events.read_text())
|
||||||
|
|
||||||
|
def test_guard_release_failure_keeps_recovery_and_blocks_publication(self):
|
||||||
|
result=self.run_script('app-backup.sh',ok=False,TEST_FAIL_GUARD_RELEASE='1')
|
||||||
|
self.assertIn('Could not release writer start guards',result.stdout)
|
||||||
|
self.assertFalse(list((self.base/'work/archives').glob('appbackup_*')))
|
||||||
|
self.assertTrue(json.loads(self.state.read_text())['maintenance'])
|
||||||
|
self.assertTrue(all(s=='inactive' for s in json.loads(self.state.read_text())['services'].values()))
|
||||||
|
recovery=json.loads(next((self.base/'work').glob('recovery-*.json')).read_text())
|
||||||
|
self.assertTrue(Path(recovery['service_guard_directory']).is_dir())
|
||||||
|
|
||||||
def test_maintenance_and_inactive_service_preserved(self):
|
def test_maintenance_and_inactive_service_preserved(self):
|
||||||
state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=True)
|
state=dict(services=dict(self.initial['services'],gitea='inactive'),maintenance=True)
|
||||||
self.state.write_text(json.dumps(state))
|
self.state.write_text(json.dumps(state))
|
||||||
@@ -341,6 +436,9 @@ if '--exclude=/*' in sys.argv:
|
|||||||
self.assertIn('Partial restore',result.stdout)
|
self.assertIn('Partial restore',result.stdout)
|
||||||
self.assertTrue(json.loads(self.state.read_text())['maintenance'])
|
self.assertTrue(json.loads(self.state.read_text())['maintenance'])
|
||||||
self.assertEqual(json.loads(self.state.read_text())['services']['httpd'],'inactive')
|
self.assertEqual(json.loads(self.state.read_text())['services']['httpd'],'inactive')
|
||||||
|
recovery=json.loads(next((self.base/'work').glob('recovery-*.json')).read_text())
|
||||||
|
self.assertTrue(recovery['service_guard_files'])
|
||||||
|
self.assertTrue(all(Path(p).is_file() for p in recovery['service_guard_files']))
|
||||||
|
|
||||||
def test_check_does_not_claim_successful_backup(self):
|
def test_check_does_not_claim_successful_backup(self):
|
||||||
self.run_script('app-backup.sh','--check')
|
self.run_script('app-backup.sh','--check')
|
||||||
@@ -675,6 +773,7 @@ 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
|
assert '--property=Type=oneshot' in args
|
||||||
|
assert '--property=KillMode=control-group' in args
|
||||||
assert '--property=SELinuxContext=system_u:system_r:unconfined_service_t:s0' in args
|
assert '--property=SELinuxContext=system_u:system_r:unconfined_service_t:s0' in args
|
||||||
command=args[args.index('/bin/bash'):]
|
command=args[args.index('/bin/bash'):]
|
||||||
state=Path(command[-1])
|
state=Path(command[-1])
|
||||||
@@ -682,6 +781,7 @@ if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'reject':
|
|||||||
sys.exit('systemd refused submission')
|
sys.exit('systemd refused submission')
|
||||||
with open(state/'test-worker.log','w') as log:
|
with open(state/'test-worker.log','w') as log:
|
||||||
worker=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)
|
||||||
|
(state/'test-worker.pid').write_text(str(worker.pid))
|
||||||
if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'accepted-error':
|
if os.environ.get('TEST_SYSTEMD_SUBMIT') == 'accepted-error':
|
||||||
sys.exit('simulated reply failure after systemd accepted the job')
|
sys.exit('simulated reply failure after systemd accepted the job')
|
||||||
# Type=oneshot remains starting until ExecStart exits. Real systemd-run waits
|
# Type=oneshot remains starting until ExecStart exits. Real systemd-run waits
|
||||||
@@ -696,6 +796,52 @@ if '--no-block' not in args:
|
|||||||
''')
|
''')
|
||||||
launcher.chmod(0o755)
|
launcher.chmod(0o755)
|
||||||
|
|
||||||
|
def assert_group_term_recovers_services(self, tool):
|
||||||
|
self.configure_upgrade()
|
||||||
|
self.systemd_launcher()
|
||||||
|
wrapper=self.bin/tool
|
||||||
|
if tool=='rsync':
|
||||||
|
match='[[ " $* " != *" --list-only "* && " $* " != *" --exclude=/* "* ]]'
|
||||||
|
else:
|
||||||
|
match='[[ " $* " == *" copy "* ]]'
|
||||||
|
wrapper.write_text('#!/usr/bin/env bash\nif '+match+'; then\n echo waiting > "$TEST_EVENTS.waiting"\n sleep 120\nfi\nexec /usr/bin/'+tool+' "$@"\n')
|
||||||
|
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)
|
||||||
|
state=Path(result.stdout.split('Update-Sicherung und Protokoll: ')[1].splitlines()[0])
|
||||||
|
pid=int((state/'test-worker.pid').read_text())
|
||||||
|
try:
|
||||||
|
for _ in range(400):
|
||||||
|
if Path(str(self.events)+'.waiting').exists(): break
|
||||||
|
time.sleep(.1)
|
||||||
|
else: self.fail('Worker did not reach '+tool+': '+(state/'test-worker.log').read_text())
|
||||||
|
os.killpg(pid,signal.SIGTERM) # emulate systemd KillMode=control-group
|
||||||
|
for _ in range(200):
|
||||||
|
status=(state/'result.txt').read_text()
|
||||||
|
if status.startswith('FAILED'): break
|
||||||
|
time.sleep(.1)
|
||||||
|
self.assertTrue(status.startswith('FAILED'),status+(state/'test-worker.log').read_text())
|
||||||
|
actual=json.loads(self.state.read_text())
|
||||||
|
self.assertFalse(actual['maintenance'])
|
||||||
|
for unit in self.initial['services']:
|
||||||
|
self.assertEqual(actual['services'][unit],'active',unit)
|
||||||
|
self.assertEqual(actual['services']['app-backup.timer'],'inactive')
|
||||||
|
if tool=='rsync':
|
||||||
|
self.assertFalse(list((self.base/'work/archives').glob('appbackup_*')))
|
||||||
|
else:
|
||||||
|
self.assertFalse(list((self.base/'work/archives').glob('appbackup_*/UPLOADED')))
|
||||||
|
self.assertFalse(list(self.runtime.glob('app-backup-guard-*')))
|
||||||
|
self.assertIn('Service started: httpd',(state/'update.log').read_text())
|
||||||
|
finally:
|
||||||
|
try: os.killpg(pid,signal.SIGKILL)
|
||||||
|
except ProcessLookupError: pass
|
||||||
|
|
||||||
|
def test_group_term_interrupts_worker_copy_and_recovers_services(self):
|
||||||
|
self.assert_group_term_recovers_services('rsync')
|
||||||
|
|
||||||
|
def test_group_term_interrupts_worker_upload_and_keeps_timer_stopped(self):
|
||||||
|
self.assert_group_term_recovers_services('rclone')
|
||||||
|
|
||||||
def test_detached_package_handoff_finishes_after_starter_exits(self):
|
def test_detached_package_handoff_finishes_after_starter_exits(self):
|
||||||
self.configure_upgrade()
|
self.configure_upgrade()
|
||||||
self.systemd_launcher()
|
self.systemd_launcher()
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ if [[ $UP_WORKER == false ]]; then
|
|||||||
# The launcher must not keep copies of our locks; systemd starts a fresh worker.
|
# 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 \
|
systemd-run --no-block --unit=app-backup-upgrade --collect --property=Type=oneshot \
|
||||||
--property=SELinuxContext=system_u:system_r:unconfined_service_t:s0 \
|
--property=SELinuxContext=system_u:system_r:unconfined_service_t:s0 \
|
||||||
--property=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=mixed \
|
--property=TimeoutStartSec=24h --property=TimeoutStopSec=5min --property=KillMode=control-group \
|
||||||
/bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" 8>&- 9>&- || {
|
/bin/bash "$UPDATE_ROOT/update.sh" --worker "$UP_STATE" 8>&- 9>&- || {
|
||||||
# Revoke the queued state while still holding the coordinator lock. Even
|
# Revoke the queued state while still holding the coordinator lock. Even
|
||||||
# if systemd accepted the job but its reply failed, it must not install.
|
# if systemd accepted the job but its reply failed, it must not install.
|
||||||
@@ -281,7 +281,8 @@ fi
|
|||||||
# -------------------- detached worker --------------------
|
# -------------------- detached worker --------------------
|
||||||
[[ $UP_STATE == /var/backups/app-backup-upgrade-* && -d $UP_STATE && ! -L $UP_STATE && $(stat -c %u "$UP_STATE") == 0 ]] || die 'Invalid upgrade state directory'
|
[[ $UP_STATE == /var/backups/app-backup-upgrade-* && -d $UP_STATE && ! -L $UP_STATE && $(stat -c %u "$UP_STATE") == 0 ]] || die 'Invalid upgrade state directory'
|
||||||
(( (8#$(stat -c %a "$UP_STATE") & 8#077) == 0 )) || die 'Upgrade state must be private'
|
(( (8#$(stat -c %a "$UP_STATE") & 8#077) == 0 )) || die 'Upgrade state must be private'
|
||||||
exec > >(tee -a "$UP_STATE/update.log") 2>&1
|
# The group receives TERM together; keep logging available for child recovery.
|
||||||
|
exec > >(trap '' INT TERM; exec tee -a "$UP_STATE/update.log") 2>&1
|
||||||
CONFIG_FILE=$(cat "$UP_STATE/config-path")
|
CONFIG_FILE=$(cat "$UP_STATE/config-path")
|
||||||
LOCKFILE=$(cat "$UP_STATE/lock-path")
|
LOCKFILE=$(cat "$UP_STATE/lock-path")
|
||||||
UP_CHANGED=false
|
UP_CHANGED=false
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ printf 'format=2\ncompressor=%s\n' "$COMPRESSOR" > "$STAGING_DIR/meta/format.txt
|
|||||||
quiesce
|
quiesce
|
||||||
|
|
||||||
for i in "${!DB_LABELS[@]}"; do
|
for i in "${!DB_LABELS[@]}"; do
|
||||||
|
assert_services_quiescent
|
||||||
label=${DB_LABELS[i]}
|
label=${DB_LABELS[i]}
|
||||||
out="$STAGING_DIR/db/${label#db-}.sql"
|
out="$STAGING_DIR/db/${label#db-}.sql"
|
||||||
echo "-- Dump: ${DB_NAMES[i]}"
|
echo "-- Dump: ${DB_NAMES[i]}"
|
||||||
@@ -47,11 +48,13 @@ for i in "${!DB_LABELS[@]}"; do
|
|||||||
"$DB_DUMP_BIN" --defaults-extra-file="${DB_CNFS[i]}" --lock-all-tables \
|
"$DB_DUMP_BIN" --defaults-extra-file="${DB_CNFS[i]}" --lock-all-tables \
|
||||||
--quick --routines --events --triggers --hex-blob --default-character-set=utf8mb4 \
|
--quick --routines --events --triggers --hex-blob --default-character-set=utf8mb4 \
|
||||||
"${DB_NAMES[i]}" > "$out.partial"
|
"${DB_NAMES[i]}" > "$out.partial"
|
||||||
|
assert_services_quiescent
|
||||||
[[ -s $out.partial ]] || die "Empty database dump: $label"
|
[[ -s $out.partial ]] || die "Empty database dump: $label"
|
||||||
mv -- "$out.partial" "$out"
|
mv -- "$out.partial" "$out"
|
||||||
printf '%s\t%s\n' "$label" "${DB_NAMES[i]}" >> "$PLAN"
|
printf '%s\t%s\n' "$label" "${DB_NAMES[i]}" >> "$PLAN"
|
||||||
done
|
done
|
||||||
for i in "${!LABELS[@]}"; do
|
for i in "${!LABELS[@]}"; do
|
||||||
|
assert_services_quiescent
|
||||||
label=${LABELS[i]}; src=${SOURCES[i]}; root=${ROOTS[i]}
|
label=${LABELS[i]}; src=${SOURCES[i]}; root=${ROOTS[i]}
|
||||||
check_space "$STAGING_ROOT"
|
check_space "$STAGING_ROOT"
|
||||||
mkdir -p -- "$STAGING_DIR/$root"
|
mkdir -p -- "$STAGING_DIR/$root"
|
||||||
@@ -60,10 +63,13 @@ for i in "${!LABELS[@]}"; do
|
|||||||
# Never accept vanished-file (24) or partial-copy (23) as success.
|
# Never accept vanished-file (24) or partial-copy (23) as success.
|
||||||
# Parent retains both locks; rsync itself needs neither inherited descriptor.
|
# Parent retains both locks; rsync itself needs neither inherited descriptor.
|
||||||
rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$src/" "$STAGING_DIR/$root/" 8>&- 9>&-
|
rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$src/" "$STAGING_DIR/$root/" 8>&- 9>&-
|
||||||
|
assert_services_quiescent
|
||||||
printf '%s\t%s\n' "$label" "$src" >> "$PLAN"
|
printf '%s\t%s\n' "$label" "$src" >> "$PLAN"
|
||||||
done
|
done
|
||||||
# The full consistency window includes all dumps AND copies. Compression/upload
|
# The full consistency window includes all dumps AND copies. Compression/upload
|
||||||
# use this frozen private staging tree, so application downtime ends here.
|
# use this frozen private staging tree, so application downtime ends here.
|
||||||
|
assert_services_quiescent
|
||||||
|
echo '-- Copy window complete; writer guards verified'
|
||||||
resume_services || die 'Failed to resume original service state'
|
resume_services || die 'Failed to resume original service state'
|
||||||
|
|
||||||
while IFS=$'\t' read -r label _; do
|
while IFS=$'\t' read -r label _; do
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ quiesce
|
|||||||
MUTATION_STARTED=true
|
MUTATION_STARTED=true
|
||||||
save_recovery
|
save_recovery
|
||||||
for i in "${!SELECTED[@]}"; do
|
for i in "${!SELECTED[@]}"; do
|
||||||
|
assert_services_quiescent
|
||||||
label=${SELECTED[i]}
|
label=${SELECTED[i]}
|
||||||
source_path="$STAGING_DIR/extracted/$label/${SELECTED_ROOTS[i]}"
|
source_path="$STAGING_DIR/extracted/$label/${SELECTED_ROOTS[i]}"
|
||||||
if [[ ${IS_DB[i]} == true ]]; then
|
if [[ ${IS_DB[i]} == true ]]; then
|
||||||
@@ -175,6 +176,7 @@ for i in "${!SELECTED[@]}"; do
|
|||||||
rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" 8>&- 9>&-
|
rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" 8>&- 9>&-
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
assert_services_quiescent
|
||||||
# A backup captured maintenance=true. Enforce the recorded pre-restore state even
|
# A backup captured maintenance=true. Enforce the recorded pre-restore state even
|
||||||
# if the existing installation already was in maintenance mode.
|
# if the existing installation already was in maintenance mode.
|
||||||
if [[ " ${SELECTED[*]} " == *' nextcloud '* || " ${SELECTED[*]} " == *' db-nextcloud '* ]]; then
|
if [[ " ${SELECTED[*]} " == *' nextcloud '* || " ${SELECTED[*]} " == *' db-nextcloud '* ]]; then
|
||||||
|
|||||||
Reference in new issue
Block a user