From a1a843a6f7de2f26c9593e50941d03018689ffa9 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Mon, 26 Oct 2026 12:38:11 +0100 Subject: [PATCH] Pause backup scheduling and atomically deploy workstation updates --- Dokumentation.md | 4 ++-- scripts/install.sh | 16 ++++++++++++---- scripts/nextcloud-borg-runner.py | 1 + tests/test_backup.py | 24 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Dokumentation.md b/Dokumentation.md index 618c4be..d9b2d12 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -106,9 +106,9 @@ Passphrase und exportierten Schlüssel unabhängig vom NAS und der Workstation s `make check` prüft Bash-Syntax, Python, alle vier systemd-Units und automatisierte Ausfallszenarien. Integrationstests verwenden echtes rclone mit lokalem Test-Remote sowie echtes Borg in temporären Verzeichnissen: Archiv erstellen, Daten prüfen, extrahieren und Bytevergleich. Sie verändern weder echte Cloud noch produktives Repository. -17 Tests prüfen außerdem leere/geschrumpfte Quelle, falsches NAS, fremdes/symlinkendes Staging, Netzfehler in Sync und Check, geänderte Cloud-Inventur, Repository-Austausch, konkurrierende Läufe, Timeout, Zeitrücksprung und Wartungswiederholung. Gleiche Dateigröße und gleicher Zeitstempel bei geändertem Inhalt werden im Integrationstest ausdrücklich abgedeckt. +18 Tests prüfen außerdem leere/geschrumpfte Quelle, falsches NAS, fremdes/symlinkendes Staging, Netzfehler in Sync und Check, geänderte Cloud-Inventur, Repository-Austausch, konkurrierende Läufe, Timeout, Zeitrücksprung und Wartungswiederholung. Gleiche Dateigröße und gleicher Zeitstempel bei geändertem Inhalt werden im Integrationstest ausdrücklich abgedeckt. -Der Installer aktualisiert Programme und Units, erhält private Konfiguration und Schlüssel und startet die Timer neu. Eine fehlende Passphrase bei vorhandenem Repository führt zum Abbruch. Die Deinstallation stoppt Backup/Timer und entfernt Programme/Units; Daten, Schlüssel, State und Archive bleiben erhalten. +Der Installer erhält private Konfiguration und Schlüssel, hält vorhandenen Backup-Dienst und Timer gemeinsam an, ersetzt Programme und Units atomar und startet die Timer neu. Der Update-Test prüft die Erhaltung von Konfiguration und Schlüssel sowie den Timer-Stopp. Eine fehlende Passphrase bei vorhandenem Repository führt zum Abbruch. Die Deinstallation stoppt Backup/Timer und entfernt Programme/Units; Daten, Schlüssel, State und Archive bleiben erhalten. ## Grenzen diff --git a/scripts/install.sh b/scripts/install.sh index d4414b4..8c173be 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -20,9 +20,6 @@ EXISTING_CONFIG=false mkdir -p "${HOME}/.local/libexec" "$BIN_DIR" "$CONFIG_DIR" "$STATE_DIR/logs" "$SYSTEMD_USER_DIR" -install -m 0750 "$ROOT_DIR/scripts/nextcloud-borg-runner.py" "${HOME}/.local/libexec/nextcloud-borg-runner.py" -install -m 0750 "$ROOT_DIR/scripts/nextcloud-borg-backup" "$BIN_DIR/nextcloud-borg-backup" - if [[ ! -f "$CONFIG_DIR/config" ]]; then install -m 0600 "$ROOT_DIR/etc/config.example" "$CONFIG_DIR/config" printf 'Installed config: %s\n' "$CONFIG_DIR/config" @@ -60,8 +57,19 @@ if [[ ! -f "$KEY_PATH" ]]; then printf 'Store a copy of this passphrase somewhere safe for disaster recovery.\n' fi +# Prevent a pending timer event from starting between replacing wrapper and runner. +if systemctl --user cat nextcloud-borg-backup.service >/dev/null 2>&1; then + systemctl --user stop nextcloud-borg-backup.service nextcloud-borg-backup.timer +fi +atomic_install() { + install -m "$3" "$1" "$2.new" + mv -f "$2.new" "$2" +} +atomic_install "$ROOT_DIR/scripts/nextcloud-borg-runner.py" "${HOME}/.local/libexec/nextcloud-borg-runner.py" 0750 +atomic_install "$ROOT_DIR/scripts/nextcloud-borg-backup" "$BIN_DIR/nextcloud-borg-backup" 0750 + for unit in nextcloud-borg-backup.service nextcloud-borg-backup.timer nextcloud-borg-health.service nextcloud-borg-health.timer; do - install -m 0644 "$ROOT_DIR/systemd/user/$unit" "$SYSTEMD_USER_DIR/$unit" + atomic_install "$ROOT_DIR/systemd/user/$unit" "$SYSTEMD_USER_DIR/$unit" 0644 done systemctl --user daemon-reload systemctl --user enable nextcloud-borg-backup.timer nextcloud-borg-health.timer diff --git a/scripts/nextcloud-borg-runner.py b/scripts/nextcloud-borg-runner.py index 9f26d42..325ee4a 100755 --- a/scripts/nextcloud-borg-runner.py +++ b/scripts/nextcloud-borg-runner.py @@ -400,6 +400,7 @@ def main(): sys.stdout = Tee() # Child output goes to journal; lifecycle/phase/status additionally go to the run log. try: + log('Verified cloud-to-NAS runner 2.0.0, revision 3') runner.backup('--if-due' in sys.argv) finally: sys.stdout = sys.__stdout__ diff --git a/tests/test_backup.py b/tests/test_backup.py index 1c3e138..19d476f 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -175,6 +175,30 @@ class BackupTests(unittest.TestCase): with self.assertRaises(m.BackupError): self.runner.validate_final_inventory(before, after) + def test_installer_preserves_config_and_key_and_pauses_timer(self): + home = self.root/'install-home' + config = home/'.config/nextcloud-borg-backup' + config.mkdir(parents=True) + (config/'config').write_text('# preserved custom config\n') + (config/'passphrase').write_text('existing-test-key') + fake = self.root/'fake-bin' + fake.mkdir() + commands = self.root/'systemctl-calls' + (fake/'systemctl').write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALL_LOG"\nexit 0\n') + (fake/'systemctl').chmod(0o755) + env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home/'.config'), + XDG_STATE_HOME=str(home/'.local/state'), PATH=str(fake)+':'+os.environ['PATH'], + CALL_LOG=str(commands)) + subprocess.run([str(ROOT/'scripts/install.sh')], env=env, check=True, stdout=subprocess.DEVNULL) + self.assertEqual((config/'config').read_text(), '# preserved custom config\n') + self.assertEqual((config/'passphrase').read_text(), 'existing-test-key') + calls = commands.read_text() + self.assertIn('stop nextcloud-borg-backup.service nextcloud-borg-backup.timer', calls) + self.assertIn('restart nextcloud-borg-backup.timer nextcloud-borg-health.timer', calls) + self.assertEqual((home/'.local/libexec/nextcloud-borg-runner.py').read_bytes(), + (ROOT/'scripts/nextcloud-borg-runner.py').read_bytes()) + self.assertEqual(list(home.rglob('*.new')), []) + def test_pending_maintenance_survives_interrupted_attempt_record(self): import time self.runner.success = {'completed_at': time.time(), 'maintenance_pending': True}