Guard writer units against activation and fix backup cancellation

This commit is contained in:
admin committed 2026-09-10 15:28:42 +02:00
1 parent c0a24b63fc
commit ff152c7f0f
9 files changed
+372 -16

No files matched your search

+59 -1
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+2 -1
View File
@@ -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
View File
@@ -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()
+3 -2
View File
@@ -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
+6
View File
@@ -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
+2
View File
@@ -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