diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh index 1df0ea3..d31dc7a 100644 --- a/deploy/bootstrap/lib/bridge.sh +++ b/deploy/bootstrap/lib/bridge.sh @@ -43,6 +43,9 @@ CLUPILOT_ROLLBACK_UNIT="${CLUPILOT_ROLLBACK_UNIT:-clupilot-network-rollback}" CLUPILOT_NET_BACKUP="${CLUPILOT_NET_BACKUP:-/var/lib/clupilot/interfaces.vor-der-bruecke}" CLUPILOT_WG_HUB_PUBKEY="${CLUPILOT_WG_HUB_PUBKEY:-}" CLUPILOT_WG_HANDSHAKE_MAX_AGE="${CLUPILOT_WG_HANDSHAKE_MAX_AGE:-180}" +CLUPILOT_SYSTEMCTL="${CLUPILOT_SYSTEMCTL:-systemctl}" +CLUPILOT_UNIT_DIR="${CLUPILOT_UNIT_DIR:-/etc/systemd/system}" +CLUPILOT_SBIN_DIR="${CLUPILOT_SBIN_DIR:-/usr/local/sbin}" # --------------------------------------------------------------------------- # Die zwei Helfer, die diese Datei früher geborgt hat @@ -307,6 +310,110 @@ $(extra_routes "$_iface" "$_gw")${_inet6} EOF } +# --------------------------------------------------------------------------- +# Die Selbstrücknahme +# --------------------------------------------------------------------------- +# +# Sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen. +# +# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf +# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die +# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die +# Netzkonfiguration überhaupt anfassen darf. +# +# Abbestellt wird er NICHT vom Treiber. Das tut CluPilot, nachdem es sich über +# den Tunnel neu verbunden und nachgesehen hat — ein Skript auf dem Host kann +# über seine eigene Erreichbarkeit von außen nur raten. + +backup_network_config() { + mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")" + tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \ + -C / "${CLUPILOT_INTERFACES_FILE#/}" \ + $( [ -d "$CLUPILOT_INTERFACES_D" ] && printf '%s' "${CLUPILOT_INTERFACES_D#/}" ) \ + 2>/dev/null +} + +# Der Inhalt des Rücknahme-Skripts — als Text, nicht als Datei. +# +# Getrennt, damit die Reihenfolge darin prüfbar ist, ohne systemd zu brauchen. +# Und die Reihenfolge ist der Punkt: das Urteil wird geschrieben, BEVOR +# zurückgespielt wird, damit auch ein halb geglücktes Zurückspielen einen Grund +# hinterlässt. `rolled-back` kommt zuletzt — das ist das Signal, auf das CluPilot +# wartet, und es darf erst stehen, wenn der alte Zustand wirklich zurück ist. +render_rollback_script() { + _minutes="${1:-5}" + + cat < '${CLUPILOT_WORK_DIR}/state' +printf 'Die Bruecke nahm den Host vom Netz; die Netzkonfiguration von vorher wurde zurueckgespielt.' > '${CLUPILOT_WORK_DIR}/note' + +tar xzf '${CLUPILOT_NET_BACKUP}.tar.gz' -C / +if command -v ifreload >/dev/null 2>&1; then + ifreload -a +else + systemctl restart networking +fi + +logger -t clupilot 'Netzkonfiguration zurueckgespielt: die Bruecke hat den Host vom Netz genommen.' + +# Erst jetzt. Vorher hiesse es: zurueckgespielt, obwohl es noch laeuft. +: > '${CLUPILOT_WORK_DIR}/rolled-back' + +# Selbst wegraeumen, sonst liegen Unit-Dateien herum, die aussehen, als stuende +# noch eine Ruecknahme aus. +rm -f '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer' \\ + '${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service' +systemctl daemon-reload 2>/dev/null || true +EOF +} + +# Stellt den Zeitgeber. Ab hier gibt es einen Weg zurück, und erst ab hier darf +# irgendetwas am Netz verändert werden. +schedule_network_rollback() { + _minutes="${1:-5}" + + mkdir -p "$CLUPILOT_SBIN_DIR" "$CLUPILOT_UNIT_DIR" "$CLUPILOT_WORK_DIR" + + render_rollback_script "$_minutes" > "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" + chmod 700 "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" + + cat > "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" < "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" </dev/null || true + rm -f "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.timer" \ + "${CLUPILOT_UNIT_DIR}/${CLUPILOT_ROLLBACK_UNIT}.service" \ + "${CLUPILOT_SBIN_DIR}/${CLUPILOT_ROLLBACK_UNIT}.sh" + "$CLUPILOT_SYSTEMCTL" daemon-reload 2>/dev/null || true + log 'Selbstrücknahme abbestellt' +} + # Strophe schreiben und anwenden. Ab hier ist die Leitung in Gefahr. build_bridge() { write_bridge_stanza "$@" diff --git a/deploy/bootstrap/lib/network.sh b/deploy/bootstrap/lib/network.sh index a5a258e..730a624 100644 --- a/deploy/bootstrap/lib/network.sh +++ b/deploy/bootstrap/lib/network.sh @@ -53,76 +53,21 @@ # also nichts — außer, dass es die Brücke nur noch einmal im Repo gibt. # --------------------------------------------------------------------------- -# Die Selbstrücknahme +# Die Selbstrücknahme — steht jetzt in lib/bridge.sh # --------------------------------------------------------------------------- # -# Das Muster aus Block B, unverändert, nur im Skript statt in der Pipeline: -# sichern, Zeitgeber auf fünf Minuten, umstellen, nachsehen, abbestellen. +# `backup_network_config`, `schedule_network_rollback` und +# `cancel_network_rollback` sind mit der Brücke nach `lib/bridge.sh` gezogen. +# Gleiche Namen, gleicher Zweck, also ruft dieser Weg sie unverändert auf. # -# Der Zeitgeber ist eine systemd-Einheit und kein `sleep &`: ein Hintergrundlauf -# stirbt mit seiner Sitzung, und die Sitzung ist genau das, was abreißt, wenn die -# Umstellung schiefgeht. Er ist der einzige Grund, warum dieser Abschnitt die -# Netzkonfiguration überhaupt anfassen darf. - -backup_network_config() { - mkdir -p "$(dirname -- "$CLUPILOT_NET_BACKUP")" - tar czf "${CLUPILOT_NET_BACKUP}.tar.gz" \ - -C / etc/network/interfaces $( [ -d /etc/network/interfaces.d ] && printf 'etc/network/interfaces.d' ) \ - 2>/dev/null -} - -# Stellt einen Zeitgeber, der die gesicherte Konfiguration zurückspielt und das -# Netz neu startet, falls ihn niemand abbestellt. -schedule_network_rollback() { - _minutes="${1:-5}" - - cat > "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" </dev/null 2>&1; then - ifreload -a -else - systemctl restart networking -fi -logger -t clupilot 'Netzkonfiguration zurückgespielt: die Brücke hat den Host vom Netz genommen.' -EOF - chmod 700 "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" - - cat > "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" < "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" </dev/null || true - rm -f "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.timer" \ - "/etc/systemd/system/${CLUPILOT_ROLLBACK_UNIT}.service" \ - "/usr/local/sbin/${CLUPILOT_ROLLBACK_UNIT}.sh" - systemctl daemon-reload 2>/dev/null || true - log 'Selbstrücknahme abbestellt' -} +# Zwei Dinge kann die dortige Fassung mehr: +# +# - Das Rücknahme-Skript schreibt `failed` samt Grund in die Statusdatei, BEVOR +# es zurückspielt, und legt zuletzt `rolled-back` an. Der Debian-Weg pollt von +# außen und muss unterscheiden können, ob eine Rücknahme aussteht oder durch +# ist; ohne diese Zeilen liest er `running` und pollt gegen einen Lauf, den es +# nicht mehr gibt — der Zeitgeber beendet den Treiber ja gerade nicht. +# - Der Zeitgeber räumt seine Unit-Dateien nach dem Feuern selbst weg. # Antwortet die Maschine noch? # diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php index b615fb6..022179c 100644 --- a/tests/Feature/Provisioning/BridgeScriptTest.php +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -184,6 +184,9 @@ it('hält jede Brückenfunktion an genau einer Stelle im Repo', function () { 'bridge_has_address', 'build_bridge', 'write_bridge_stanza', + 'backup_network_config', + 'schedule_network_rollback', + 'cancel_network_rollback', ]; foreach ($functions as $function) { @@ -380,6 +383,113 @@ it('schreibt eine Strophe, die ein Mensch als von CluPilot erkennt', function () ->toContain('EnsureNetworkBridge'); }); +/* +|-------------------------------------------------------------------------- +| Die Rückfahrkarte +|-------------------------------------------------------------------------- +*/ + +it('schreibt ein Rücknahme-Skript, das den Grund festhält BEVOR es zurückspielt', function () { + // Ohne diese zwei Zeilen liest der Schritt nach dem Wiederverbinden + // `running` und pollt gegen einen Lauf, den es nicht mehr gibt — der + // Zeitgeber beendet den Treiber ja gerade nicht. Und zuerst schreiben, nicht + // zuletzt: ein halb geglücktes Zurückspielen soll das Urteil trotzdem + // hinterlassen. + $script = runBridgeSh('render_rollback_script 5', [ + 'CLUPILOT_WORK_DIR' => '/var/lib/clupilot/bridge', + ]); + + $stateAt = strpos($script, "printf 'failed'"); + $restoreAt = strpos($script, 'tar xzf'); + $doneAt = strpos($script, 'rolled-back'); + + expect($stateAt)->not->toBeFalse() + ->and($restoreAt)->not->toBeFalse() + ->and($doneAt)->not->toBeFalse() + ->and($stateAt)->toBeLessThan($restoreAt) + ->and($script)->toContain('/var/lib/clupilot/bridge/note') + // Das Signal, auf das CluPilot wartet: erst wenn das da ist, ist der + // alte Zustand wirklich zurück. + ->and($doneAt)->toBeGreaterThan($restoreAt); +}); + +it('räumt der Zeitgeber sich nach dem Feuern selbst weg', function () { + // Sonst liegen Unit-Dateien herum, die aussehen, als stünde noch eine + // Rücknahme aus. + expect(runBridgeSh('render_rollback_script 5')) + ->toContain('clupilot-network-rollback.timer'); +}); + +it('stellt den Zeitgeber, bevor irgendetwas verändert wird', function () { + // Die tragende Zusicherung des ganzen Entwurfs, geprüft an der Reihenfolge + // der Aufrufe. Umgekehrt wäre es die Reihenfolge, die einen Host dauerhaft + // unerreichbar macht. + $bin = $this->dir.'/bin'; + mkdir($bin, 0o755, true); + file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n"); + file_put_contents("{$bin}/ifreload", "#!/bin/sh\necho 'ifreload' >> '{$this->dir}/calls'\n"); + chmod("{$bin}/systemctl", 0o755); + chmod("{$bin}/ifreload", 0o755); + + $ip = fakeIp($this->dir, [ + '-4 route show dev ens3' => '', + '-6 -o addr show dev ens3 scope global' => '', + '-6 route show default' => '', + ]); + $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:05'); + file_put_contents($this->dir.'/interfaces', "# alt\n"); + + runBridgeSh( + "backup_network_config\nschedule_network_rollback 5\nbuild_bridge ens3 subnet 10.0.0.7/24 10.0.0.1", + [ + 'CLUPILOT_IP' => $ip, + 'CLUPILOT_SYS_NET' => $sys, + 'CLUPILOT_INTERFACES_FILE' => $this->dir.'/interfaces', + 'CLUPILOT_INTERFACES_D' => $this->dir.'/interfaces.d', + 'CLUPILOT_NET_BACKUP' => $this->dir.'/sicherung', + 'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl", + 'CLUPILOT_IFRELOAD' => "{$bin}/ifreload", + 'CLUPILOT_UNIT_DIR' => $this->dir.'/units', + 'CLUPILOT_SBIN_DIR' => $this->dir.'/sbin', + 'CLUPILOT_WORK_DIR' => $this->dir, + ] + ); + + $calls = file_get_contents($this->dir.'/calls'); + + expect(strpos($calls, 'start clupilot-network-rollback.timer'))->not->toBeFalse() + ->and(strpos($calls, 'ifreload'))->not->toBeFalse() + ->and(strpos($calls, 'start clupilot-network-rollback.timer')) + ->toBeLessThan(strpos($calls, 'ifreload')) + // Und die Sicherung muss existieren, bevor der Zeitgeber sie + // überhaupt zurückspielen könnte. + ->and(file_exists($this->dir.'/sicherung.tar.gz'))->toBeTrue(); +}); + +it('bestellt beim Abbestellen alles ab, was gestellt wurde', function () { + // Ein Zeitgeber, dessen Unit-Dateien liegen bleiben, sieht beim nächsten + // Hinsehen aus wie eine ausstehende Rücknahme. + $bin = $this->dir.'/bin'; + mkdir($bin, 0o755, true); + file_put_contents("{$bin}/systemctl", "#!/bin/sh\necho \"systemctl \$*\" >> '{$this->dir}/calls'\n"); + chmod("{$bin}/systemctl", 0o755); + + runBridgeSh( + "schedule_network_rollback 5\ncancel_network_rollback", + [ + 'CLUPILOT_SYSTEMCTL' => "{$bin}/systemctl", + 'CLUPILOT_UNIT_DIR' => $this->dir.'/units', + 'CLUPILOT_SBIN_DIR' => $this->dir.'/sbin', + 'CLUPILOT_WORK_DIR' => $this->dir, + ] + ); + + expect(file_get_contents($this->dir.'/calls'))->toContain('stop clupilot-network-rollback.timer') + ->and(file_exists($this->dir.'/units/clupilot-network-rollback.timer'))->toBeFalse() + ->and(file_exists($this->dir.'/units/clupilot-network-rollback.service'))->toBeFalse() + ->and(file_exists($this->dir.'/sbin/clupilot-network-rollback.sh'))->toBeFalse(); +}); + it('lässt jede Bibliothek des Bootstraps von einer Shell parsen', function () { // network.sh und proxmox.sh werden beim Herauslösen beschnitten. Ein // verrutschter Schnitt fiele sonst erst auf dem Rettungssystem-Weg auf, und