From 81df08aaa665baa00f65f28764664d8ac5aaf1ad Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:34:26 +0200 Subject: [PATCH] bridge.sh: die Erkennung herausgeloest und allein lauffaehig gemacht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Bruecken-Erkennung stand in network.sh, geschrieben fuer den stillgelegten Rettungssystem-Weg, und borgte sich vier Dinge von woanders: detect_primary_interface aus proxmox.sh, log, http_get und CLUPILOT_PROBE_URL aus clupilot-bootstrap.sh. Der Debian-Weg laedt die Bibliothek EINZELN auf den Host und faehrt sie dort — geborgte Helfer waeren dann nicht da. Also allein lauffaehig, mit log und http_get unter einem command-v-Schutz, damit der Bootstrap seine eigenen behaelt. Neu und aus dem laufenden Zustand abgeleitet: - interface_is_physical — bridge_ports auf einem Bond oder einer bestehenden Bridge ist falsch, und aus der Ferne nicht reparierbar. - address_is_dynamic — der Kernel markiert eine geleaste Adresse, das steht bei jedem Anbieter gleich da. Die Datei des Anbieters ist nur noch das Zweitsignal. Geprueft gegen eine echte sh mit aufgezeichneten ip-Ausgaben, plus die Zusicherung, dass es detect_primary_interface im Repo genau einmal gibt. Co-Authored-By: Claude Opus 5 --- deploy/bootstrap/clupilot-bootstrap.sh | 4 + deploy/bootstrap/lib/bridge.sh | 184 +++++++++++++++++ deploy/bootstrap/lib/network.sh | 70 +------ deploy/bootstrap/lib/proxmox.sh | 10 +- .../Feature/Provisioning/BridgeScriptTest.php | 194 ++++++++++++++++++ 5 files changed, 396 insertions(+), 66 deletions(-) create mode 100644 deploy/bootstrap/lib/bridge.sh create mode 100644 tests/Feature/Provisioning/BridgeScriptTest.php diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 0881dbc..dfc3240 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -965,6 +965,10 @@ main() { CLUPILOT_BOOTSTRAP_DIR="$(resolve_bootstrap_dir)" # shellcheck source=lib/report.sh . "${CLUPILOT_BOOTSTRAP_DIR}/lib/report.sh" + # VOR proxmox.sh und network.sh: bridge.sh hält seit dem Debian-Weg die + # Brücke samt `detect_primary_interface`, und beide greifen darauf zu. + # shellcheck source=lib/bridge.sh + . "${CLUPILOT_BOOTSTRAP_DIR}/lib/bridge.sh" # shellcheck source=lib/proxmox.sh . "${CLUPILOT_BOOTSTRAP_DIR}/lib/proxmox.sh" # shellcheck source=lib/network.sh diff --git a/deploy/bootstrap/lib/bridge.sh b/deploy/bootstrap/lib/bridge.sh new file mode 100644 index 0000000..4b10723 --- /dev/null +++ b/deploy/bootstrap/lib/bridge.sh @@ -0,0 +1,184 @@ +# shellcheck shell=sh +# +# Die Brücke über die primäre Netzkarte — Erkennen, Sichern, Zeitgeber, Bauen, +# Nachsehen. +# +# --------------------------------------------------------------------------- +# Warum das eine eigene Datei ist +# --------------------------------------------------------------------------- +# +# Diese Zeilen standen in `network.sh`, geschrieben für den stillgelegten +# Rettungssystem-Weg, und borgten sich vier Dinge von woanders: +# `detect_primary_interface` aus `proxmox.sh`, `log`, `http_get` und +# `CLUPILOT_PROBE_URL` aus `clupilot-bootstrap.sh`. Der Debian-Weg lädt sie +# einzeln auf den Host und fährt sie dort — geborgte Helfer wären dann nicht da. +# +# Also allein lauffähig. `network.sh` behält seine WireGuard- und +# nftables-Hälfte; diese Datei wird von beiden Wegen benutzt und existiert genau +# einmal. Eine zweite Fassung wären zwei Installationen, die bei jeder +# Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst auf, +# wenn jemand sie benutzt. +# +# --------------------------------------------------------------------------- +# Warum aus dem LAUFENDEN Zustand abgeleitet wird +# --------------------------------------------------------------------------- +# +# Nicht aus `/etc/network/interfaces`. Was läuft, ist bei jedem Anbieter gleich +# strukturiert; wie es aufgeschrieben wurde, nicht. Das ist der Teil, der +# Hetzner und netcup zugleich trägt. + +# --------------------------------------------------------------------------- +# Stellschrauben — überschreibbar, damit die Bibliothek ohne Host prüfbar ist +# --------------------------------------------------------------------------- + +CLUPILOT_BRIDGE="${CLUPILOT_BRIDGE:-vmbr0}" +CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/bridge}" +CLUPILOT_INTERFACES_FILE="${CLUPILOT_INTERFACES_FILE:-/etc/network/interfaces}" +CLUPILOT_INTERFACES_D="${CLUPILOT_INTERFACES_D:-/etc/network/interfaces.d}" +CLUPILOT_SYS_NET="${CLUPILOT_SYS_NET:-/sys/class/net}" +CLUPILOT_IP="${CLUPILOT_IP:-ip}" +CLUPILOT_IFRELOAD="${CLUPILOT_IFRELOAD:-ifreload}" +CLUPILOT_PROBE_URL="${CLUPILOT_PROBE_URL:-http://deb.debian.org/}" +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}" + +# --------------------------------------------------------------------------- +# Die zwei Helfer, die diese Datei früher geborgt hat +# --------------------------------------------------------------------------- +# +# Unter Definitionsschutz: `clupilot-bootstrap.sh` bringt eigene mit, und wer +# zuerst da ist, behält recht. Sonst überschriebe das Laden dieser Bibliothek +# die Protokollierung des Rettungssystem-Wegs. + +if ! command -v log >/dev/null 2>&1; then + log() { + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" + } +fi + +if ! command -v http_get >/dev/null 2>&1; then + http_get() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-delay 2 --max-time 20 "$1" + else + wget -qO- --timeout=20 "$1" + fi + } +fi + +# --------------------------------------------------------------------------- +# Feststellen, was da ist +# --------------------------------------------------------------------------- + +# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die +# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und +# eine nicht. +detect_primary_interface() { + "$CLUPILOT_IP" -4 route show default 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' +} + +# Ist das eine physische Karte? +# +# `bridge_ports` auf einem Bond oder einer bestehenden Bridge ist falsch — die +# Brücke nähme sich ihren eigenen Unterbau als Port, und was dabei herauskommt, +# ist von hier aus nicht mehr zu reparieren. VLAN-Geräte und Bonds haben keinen +# `device`-Verweis; die beiden weiteren Prüfungen sind der Gürtel dazu. +interface_is_physical() { + _if="${1:-}" + + [ -n "$_if" ] || return 1 + [ -e "${CLUPILOT_SYS_NET}/${_if}/device" ] || return 1 + [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bridge" ] || return 1 + [ ! -d "${CLUPILOT_SYS_NET}/${_if}/bonding" ] || return 1 + + return 0 +} + +# Kam die Adresse per DHCP? +# +# Aus dem laufenden Zustand: der Kernel markiert eine geleaste Adresse mit +# `dynamic`. Das steht bei jedem Anbieter gleich da — anders als die Datei, in +# die er es geschrieben hat. +address_is_dynamic() { + "$CLUPILOT_IP" -4 -o addr show dev "$1" scope global 2>/dev/null \ + | grep -q '[[:space:]]dynamic[[:space:]]' +} + +# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall +# richtig ist, nimmt den anderen vom Netz. +# +# - `dhcp` — die Adresse kommt per DHCP (Cloud-Produkte). +# - `routed` — geroutete Einzeladresse, Gateway AUSSERHALB des eigenen Subnetzes +# (Hetzner-dediziert mit /32). Braucht eine pointopoint-Route, +# sonst findet der Kernel das Gateway nicht. +# - `subnet` — gewöhnliches Subnetz, Gateway darin. +detect_network_style() { + _iface="${1:-}" + [ -n "$_iface" ] || _iface="$(detect_primary_interface)" + + # Laufender Zustand zuerst. Die Datei des Anbieters ist nur das Zweitsignal + # — sie sagt, was jemand aufgeschrieben hat, nicht was gilt. + if address_is_dynamic "$_iface"; then + printf 'dhcp' + return 0 + fi + + if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" \ + "$CLUPILOT_INTERFACES_FILE" "${CLUPILOT_INTERFACES_D}"/* 2>/dev/null; then + printf 'dhcp' + return 0 + fi + + _cidr="$("$CLUPILOT_IP" -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')" + _gw="$("$CLUPILOT_IP" -4 route show default 2>/dev/null | awk '{ print $3; exit }')" + _prefix="${_cidr##*/}" + + # /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway + # darin kann es nicht geben. + if [ "$_prefix" = '32' ]; then + printf 'routed' + return 0 + fi + + # Liegt das Gateway im eigenen Subnetz? `ip route get` beantwortet das, ohne + # dass dieses Skript Netzmasken rechnen muss — und rechnet dabei mit + # derselben Logik, die der Kernel später anwendet. + if [ -n "$_gw" ] && "$CLUPILOT_IP" -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then + printf 'subnet' + return 0 + fi + + printf 'routed' +} + +bridge_exists() { + "$CLUPILOT_IP" link show "${1:-$CLUPILOT_BRIDGE}" >/dev/null 2>&1 +} + +# Trägt die Brücke wirklich den Verkehr, oder existiert sie nur? +# +# Eine `vmbr0` ohne Adresse und ohne Vorgaberoute ist eine Brücke im Sinne von +# `ip link show` und sonst nichts. Der alte Schritt prüfte genau das und warf das +# Ergebnis weg; sein Kommentar behauptete, er halte die Abwesenheit fest, und er +# hielt nichts fest. +bridge_carries_default_route() { + _want="${1:-$CLUPILOT_BRIDGE}" + _dev="$("$CLUPILOT_IP" -4 route show default 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')" + + [ "$_dev" = "$_want" ] +} + +bridge_has_address() { + [ -n "$("$CLUPILOT_IP" -4 -o addr show dev "${1:-$CLUPILOT_BRIDGE}" scope global 2>/dev/null | awk '{ print $4; exit }')" ] +} + +# Die drei zusammen. „Da" reicht nicht — sie muss tragen. +bridge_is_up() { + bridge_exists "$CLUPILOT_BRIDGE" \ + && bridge_carries_default_route "$CLUPILOT_BRIDGE" \ + && bridge_has_address "$CLUPILOT_BRIDGE" +} diff --git a/deploy/bootstrap/lib/network.sh b/deploy/bootstrap/lib/network.sh index aeead2b..18004c1 100644 --- a/deploy/bootstrap/lib/network.sh +++ b/deploy/bootstrap/lib/network.sh @@ -37,70 +37,20 @@ # Die Sperre steht deshalb in Abschnitt `registered` (Task 9), an derselben # Stelle wie früher: nach dem bewiesenen Handshake, nach Traefik. -CLUPILOT_ROLLBACK_UNIT='clupilot-network-rollback' -CLUPILOT_NET_BACKUP='/var/lib/clupilot/interfaces.vor-der-bruecke' - # --------------------------------------------------------------------------- -# Feststellen, was da ist +# Feststellen, was da ist — steht jetzt in lib/bridge.sh # --------------------------------------------------------------------------- - -bridge_exists() { - ip link show "${1:-vmbr0}" >/dev/null 2>&1 -} - -# Trägt die Brücke wirklich den Verkehr, oder existiert sie nur? # -# Eine `vmbr0` ohne Adresse und ohne Vorgaberoute ist eine Brücke im Sinne von -# `ip link show` und sonst nichts. Der alte Schritt prüfte genau das und warf das -# Ergebnis weg; sein Kommentar behauptete, er halte die Abwesenheit fest, und er -# hielt nichts fest. -bridge_carries_default_route() { - _dev="$(ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }')" - [ "$_dev" = "${1:-vmbr0}" ] -} - -bridge_has_address() { - [ -n "$(ip -4 -o addr show dev "${1:-vmbr0}" scope global 2>/dev/null | awk '{ print $4; exit }')" ] -} - -# Wie der Anbieter das Netz aufzieht. Eine Brücke, die für den einen Fall -# richtig ist, nimmt den anderen vom Netz. +# `bridge_exists`, `bridge_carries_default_route`, `bridge_has_address`, +# `detect_network_style`, `detect_primary_interface` und die beiden Variablen +# `CLUPILOT_ROLLBACK_UNIT`/`CLUPILOT_NET_BACKUP` sind nach `lib/bridge.sh` +# gezogen, samt der Helfer, die sie sich früher aus `clupilot-bootstrap.sh` +# borgten. # -# - `dhcp` — die Adresse kommt per DHCP (Cloud-Produkte). -# - `routed` — geroutete Einzeladresse, Gateway AUSSERHALB des eigenen Subnetzes -# (Hetzner-dediziert mit /32). Braucht eine pointopoint-Route, -# sonst findet der Kernel das Gateway nicht. -# - `subnet` — gewöhnliches Subnetz, Gateway darin. -detect_network_style() { - _iface="${1:-}" - [ -n "$_iface" ] || _iface="$(detect_primary_interface)" - - if grep -qsE "iface[[:space:]]+${_iface}[[:space:]]+inet[[:space:]]+dhcp" /etc/network/interfaces /etc/network/interfaces.d/* 2>/dev/null; then - printf 'dhcp' - return 0 - fi - - _cidr="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')" - _gw="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')" - _prefix="${_cidr##*/}" - - # /32 heißt: das eigene Subnetz besteht aus der eigenen Adresse. Ein Gateway - # darin kann es nicht geben. - if [ "$_prefix" = '32' ]; then - printf 'routed' - return 0 - fi - - # Liegt das Gateway im eigenen Subnetz? `ip route get` beantwortet das, ohne - # dass dieses Skript Netzmasken rechnen muss — und rechnet dabei mit - # derselben Logik, die der Kernel später anwendet. - if [ -n "$_gw" ] && ip -4 route get "$_gw" 2>/dev/null | grep -q "dev ${_iface}.*src"; then - printf 'subnet' - return 0 - fi - - printf 'routed' -} +# Der Grund ist der Debian-Weg: dort lädt `EnsureNetworkBridge` die Bibliothek +# EINZELN auf den Host und fährt sie, und geborgte Helfer wären dann nicht da. +# `clupilot-bootstrap.sh` sourced `bridge.sh` vor dieser Datei, hier ändert sich +# also nichts — außer, dass es die Brücke nur noch einmal im Repo gibt. # --------------------------------------------------------------------------- # Die Selbstrücknahme diff --git a/deploy/bootstrap/lib/proxmox.sh b/deploy/bootstrap/lib/proxmox.sh index d9680bb..3e597da 100644 --- a/deploy/bootstrap/lib/proxmox.sh +++ b/deploy/bootstrap/lib/proxmox.sh @@ -126,12 +126,10 @@ detect_boot_mode() { fi } -# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die -# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und -# eine nicht. -detect_primary_interface() { - ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }' -} +# `detect_primary_interface` steht in lib/bridge.sh — eine Fassung, zwei +# Benutzer. clupilot-bootstrap.sh lädt bridge.sh vor dieser Datei, und die +# Funktion wird ohnehin erst zur Aufrufzeit aufgelöst, also ist die Reihenfolge +# unkritisch. # Setzt CLUPILOT_NET_CIDR, _GATEWAY, _DNS, _MAC aus dem, was das Rettungssystem # gerade benutzt. diff --git a/tests/Feature/Provisioning/BridgeScriptTest.php b/tests/Feature/Provisioning/BridgeScriptTest.php new file mode 100644 index 0000000..72d1144 --- /dev/null +++ b/tests/Feature/Provisioning/BridgeScriptTest.php @@ -0,0 +1,194 @@ + $answers Argumentmuster => Ausgabe + */ +function fakeIp(string $dir, array $answers): string +{ + $cases = ''; + foreach ($answers as $pattern => $output) { + $cases .= " '{$pattern}') cat <<'OUT'\n{$output}\nOUT\n ;;\n"; + } + + $script = "#!/bin/sh\ncase \"\$*\" in\n{$cases} *) exit 1 ;;\nesac\n"; + file_put_contents("{$dir}/ip", $script); + chmod("{$dir}/ip", 0o755); + + return "{$dir}/ip"; +} + +/** + * Fährt eine Zeile Shell mit bridge.sh im Rücken und gibt stdout zurück. + * + * `$pre` läuft VOR dem Sourcen. Damit kann ein Test `log` oder `http_get` selbst + * definieren — bridge.sh legt beide unter einem `command -v`-Schutz an, wer + * zuerst da ist, behält recht. Das ist zugleich die Probe auf diesen Schutz und + * spart dem Test eine Abhängigkeit von curl im Container. + */ +function runBridgeSh(string $body, array $env = [], string $pre = ''): string +{ + $lib = base_path('deploy/bootstrap/lib/bridge.sh'); + $exports = ''; + foreach ($env as $key => $value) { + $exports .= "export {$key}=".escapeshellarg($value)."\n"; + } + + $result = Process::input("{$exports}{$pre}\n. '{$lib}'\n{$body}\n")->run('sh'); + + return trim($result->output()); +} + +/** Ein /sys/class/net-Baum aus Textdateien. */ +function fakeSysNet(string $dir, string $iface, string $mac, bool $physical = true): string +{ + $root = "{$dir}/sys"; + mkdir("{$root}/{$iface}", 0o755, true); + file_put_contents("{$root}/{$iface}/address", $mac."\n"); + if ($physical) { + mkdir("{$root}/{$iface}/device", 0o755, true); + } + + return $root; +} + +beforeEach(function () { + $this->dir = sys_get_temp_dir().'/bridge-'.bin2hex(random_bytes(6)); + mkdir($this->dir, 0o755, true); +}); + +it('nimmt die Karte, über die die Standardroute geht — nicht die erste beste', function () { + // Eine Maschine mit zwei Karten hat oft eine angeschlossene und eine nicht. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6 proto static', + ]); + + expect(runBridgeSh('detect_primary_interface', ['CLUPILOT_IP' => $ip])) + ->toBe('enp0s31f6'); +}); + +it('erkennt eine geroutete Einzeladresse an der /32', function () { + // Hetzner dediziert. Das eigene Subnetz besteht aus der eigenen Adresse — + // ein Gateway darin kann es nicht geben, es braucht pointopoint. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6', + '-4 -o addr show dev enp0s31f6 scope global' => '2: enp0s31f6 inet 49.12.121.79/32 scope global enp0s31f6\ valid_lft forever preferred_lft forever', + ]); + + expect(runBridgeSh('detect_network_style enp0s31f6', ['CLUPILOT_IP' => $ip])) + ->toBe('routed'); +}); + +it('erkennt DHCP am laufenden Zustand, nicht an der Datei des Anbieters', function () { + // Der Kernel markiert eine geleaste Adresse als `dynamic`. Das steht bei + // jedem Anbieter gleich da — anders als die Datei, in die er es geschrieben + // hat. + $ip = fakeIp($this->dir, [ + '-4 route show default' => 'default via 10.0.0.1 dev ens3', + '-4 -o addr show dev ens3 scope global' => '2: ens3 inet 10.0.0.7/24 brd 10.0.0.255 scope global dynamic ens3\ valid_lft 3521sec preferred_lft 3521sec', + ]); + + expect(runBridgeSh('detect_network_style ens3', [ + 'CLUPILOT_IP' => $ip, + // Absichtlich auf eine Datei zeigen, die es nicht gibt: die Antwort darf + // nicht davon abhängen. + 'CLUPILOT_INTERFACES_FILE' => $this->dir.'/gibtesnicht', + ]))->toBe('dhcp'); +}); + +it('hält eine Bridge und einen Bond für keine physische Karte', function () { + // bridge_ports auf einem Bond oder einer bestehenden Bridge ist falsch: die + // Brücke nähme sich ihren eigenen Unterbau als Port. + $sys = fakeSysNet($this->dir, 'bond0', 'aa:bb:cc:dd:ee:ff', physical: false); + mkdir("{$sys}/bond0/bonding", 0o755, true); + + $out = runBridgeSh( + 'if interface_is_physical bond0; then echo JA; else echo NEIN; fi', + ['CLUPILOT_SYS_NET' => $sys] + ); + + expect($out)->toBe('NEIN'); +}); + +it('hält eine gewöhnliche Karte für eine physische', function () { + $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); + + $out = runBridgeSh( + 'if interface_is_physical enp0s31f6; then echo JA; else echo NEIN; fi', + ['CLUPILOT_SYS_NET' => $sys] + ); + + expect($out)->toBe('JA'); +}); + +it('sagt nur dann „Brücke steht", wenn sie Route UND Adresse trägt', function () { + // Eine vmbr0 ohne Adresse und ohne Standardroute ist eine Brücke im Sinne + // von `ip link show` und sonst nichts. Der alte Schritt prüfte genau das + // und warf das Ergebnis weg; sein Kommentar behauptete, er halte die + // Abwesenheit fest, und er hielt nichts fest. + $ip = fakeIp($this->dir, [ + 'link show vmbr0' => '5: vmbr0: mtu 1500', + '-4 route show default' => 'default via 49.12.121.65 dev enp0s31f6', + '-4 -o addr show dev vmbr0 scope global' => '', + ]); + + $out = runBridgeSh( + 'if bridge_is_up; then echo JA; else echo NEIN; fi', + ['CLUPILOT_IP' => $ip] + ); + + expect($out)->toBe('NEIN'); +}); + +it('hält detect_primary_interface an genau einer Stelle im Repo', function () { + // Zwei Fassungen wären zwei Installationen, die bei jeder Proxmox-Version + // nachgezogen werden müssten — und die zweite fiele erst auf, wenn jemand + // sie benutzt. Genau die Entscheidung, die den Rettungssystem-Weg + // stillgelegt hat. + // + // array_merge, NICHT `+`: bei numerischen Schlüsseln behält der + // Vereinigungsoperator die linke Seite und wirft die rechte still weg. Und + // PHPs glob() kennt kein rekursives `**`, also zwei Muster. + $files = array_merge( + glob(base_path('deploy/bootstrap/lib/*.sh')) ?: [], + glob(base_path('deploy/bootstrap/*.sh')) ?: [], + ); + + $hits = []; + foreach ($files as $file) { + if (preg_match('/^detect_primary_interface\(\)/m', (string) file_get_contents($file))) { + $hits[] = str_replace(base_path().'/', '', $file); + } + } + + expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh']); +}); + +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 + // der wird selten gefahren. + foreach (glob(base_path('deploy/bootstrap/lib/*.sh')) ?: [] as $file) { + expect(Process::run(['sh', '-n', $file])->successful()) + ->toBeTrue('kein gültiges POSIX-sh: '.$file); + } +});