$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 jede Brückenfunktion 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. // // Die Liste wächst mit bridge.sh mit: wer eine Funktion hierher zieht und // die alte Fassung stehen lässt, fällt hier auf und nicht auf einem Host. // // 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')) ?: [], ); $functions = [ 'detect_primary_interface', 'detect_network_style', 'bridge_exists', 'bridge_carries_default_route', 'bridge_has_address', 'build_bridge', 'write_bridge_stanza', 'backup_network_config', 'schedule_network_rollback', 'cancel_network_rollback', ]; foreach ($functions as $function) { $hits = []; foreach ($files as $file) { if (preg_match('/^'.$function.'\(\)/m', (string) file_get_contents($file))) { $hits[] = str_replace(base_path().'/', '', $file); } } expect($hits)->toBe(['deploy/bootstrap/lib/bridge.sh'], $function.' steht nicht genau einmal im Repo'); } }); /* |-------------------------------------------------------------------------- | Die Strophe |-------------------------------------------------------------------------- | | Vier Anbieterfälle, und sie unterscheiden sich in genau der Zeile, die | entscheidet, ob die Maschine danach noch da ist. */ it('schreibt für eine geroutete /32 eine pointopoint-Strophe', function () { // Hetzner dediziert. Ohne `pointopoint` findet der Kernel keinen Weg zum // Gateway: die Route zeigte auf ein Netz, in dem das Gateway nicht liegt, // und die Maschine wäre still weg. $ip = fakeIp($this->dir, [ '-4 route show dev enp0s31f6' => "49.12.121.65 scope link\ndefault via 49.12.121.65", '-6 -o addr show dev enp0s31f6 scope global' => '', '-6 route show default' => '', ]); $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); $out = $this->dir.'/interfaces'; runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [ 'CLUPILOT_IP' => $ip, 'CLUPILOT_SYS_NET' => $sys, 'CLUPILOT_INTERFACES_FILE' => $out, ]); expect(file_get_contents($out)) ->toContain('address 49.12.121.79/32') ->toContain('pointopoint 49.12.121.65') ->toContain('gateway 49.12.121.65') ->toContain('bridge-ports enp0s31f6') ->toContain('iface enp0s31f6 inet manual') // Festgenagelt, damit die Brücke nicht irgendeine MAC wählt: manche // Anbieter binden den Switchport an die der Karte. ->toContain('hwaddress ether a8:a1:59:00:11:22') ->toContain('auto vmbr0'); }); it('schreibt für ein gewöhnliches Subnetz Adresse und Gateway ohne pointopoint', function () { // netcup und die meisten anderen. `pointopoint` wäre hier falsch. $ip = fakeIp($this->dir, [ '-4 route show dev ens3' => '192.168.10.0/24 proto kernel scope link src 192.168.10.7', '-6 -o addr show dev ens3 scope global' => '', '-6 route show default' => '', ]); $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:01'); $out = $this->dir.'/interfaces'; runBridgeSh('write_bridge_stanza ens3 subnet 192.168.10.7/24 192.168.10.1', [ 'CLUPILOT_IP' => $ip, 'CLUPILOT_SYS_NET' => $sys, 'CLUPILOT_INTERFACES_FILE' => $out, ]); expect(file_get_contents($out)) ->toContain('address 192.168.10.7/24') ->toContain('gateway 192.168.10.1') ->not->toContain('pointopoint') // Die Route zum eigenen Subnetz gehört NICHT in die Zusatzrouten: die // legt der Kernel selbst an, sobald die Adresse steht. Ein zweites Mal // hinzufügen scheitert — und ein gescheitertes `up` nimmt bei // `ifreload` die ganze Strophe mit. ->not->toContain('up ip route add 192.168.10.0/24'); }); it('schreibt für DHCP keine Adresse in die Strophe', function () { $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:02'); $out = $this->dir.'/interfaces'; runBridgeSh('write_bridge_stanza ens3 dhcp 10.0.0.7/24 10.0.0.1', [ 'CLUPILOT_IP' => $ip, 'CLUPILOT_SYS_NET' => $sys, 'CLUPILOT_INTERFACES_FILE' => $out, ]); expect(file_get_contents($out)) ->toContain('iface vmbr0 inet dhcp') ->not->toContain('address 10.0.0.7') ->toContain('bridge-ports ens3'); }); it('nimmt eine statische globale IPv6 samt link-local Gateway mit', function () { // Hetzners Form: 2a01:…::2/64 mit fe80::1. Statisch, also derselbe Dreisatz // wie bei v4 — und damit der Fall, der laut Entwurf nichts kostet. $ip = fakeIp($this->dir, [ '-4 route show dev enp0s31f6' => '', '-6 -o addr show dev enp0s31f6 scope global' => '2: enp0s31f6 inet6 2a01:4f8:1c1c::2/64 scope global \ valid_lft forever preferred_lft forever', '-6 route show default' => 'default via fe80::1 dev enp0s31f6 metric 1024', ]); $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); $out = $this->dir.'/interfaces'; runBridgeSh('write_bridge_stanza enp0s31f6 routed 49.12.121.79/32 49.12.121.65', [ 'CLUPILOT_IP' => $ip, 'CLUPILOT_SYS_NET' => $sys, 'CLUPILOT_INTERFACES_FILE' => $out, ]); expect(file_get_contents($out)) ->toContain('iface vmbr0 inet6 static') ->toContain('address 2a01:4f8:1c1c::2/64') ->toContain('gateway fe80::1'); }); it('lässt SLAAC-IPv6 liegen und sagt es ins Protokoll', function () { // Proxmox setzt forwarding=1, und der Kernel verwirft // Router-Advertisements dann ohne accept_ra=2. Das sauber hinzubekommen // wäre ein zweiter Satz Fehlerfälle in genau dem Fenster, das diese // Konstruktion zu überleben versucht — und v6 trägt hier keinen Verkehr. // Still verlieren gilt trotzdem nicht. $ip = fakeIp($this->dir, [ '-4 route show dev ens3' => '', '-6 -o addr show dev ens3 scope global' => '2: ens3 inet6 2a01:4f8:aaaa::17/64 scope global dynamic \ valid_lft 86000sec preferred_lft 14000sec', '-6 route show default' => 'default via fe80::1 dev ens3', ]); $sys = fakeSysNet($this->dir, 'ens3', 'de:ad:be:ef:00:03'); $out = $this->dir.'/interfaces'; $log = runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [ 'CLUPILOT_IP' => $ip, 'CLUPILOT_SYS_NET' => $sys, 'CLUPILOT_INTERFACES_FILE' => $out, ]); expect(file_get_contents($out))->not->toContain('inet6 static') ->and($log)->toContain('IPv6'); }); it('nimmt die Zusatzrouten des Anbieters mit auf die Brücke', function () { // Was der Anbieter extra eingetragen hat, verliert der Host sonst — und es // fällt erst auf, wenn jemand das Ziel dahinter braucht. $ip = fakeIp($this->dir, [ '-4 route show dev enp0s31f6' => "49.12.121.65 scope link\n10.128.0.0/16 via 49.12.121.65\n49.12.121.64/26 proto kernel scope link src 49.12.121.79", '-6 -o addr show dev enp0s31f6 scope global' => '', '-6 route show default' => '', ]); $sys = fakeSysNet($this->dir, 'enp0s31f6', 'a8:a1:59:00:11:22'); $out = $this->dir.'/interfaces'; runBridgeSh('write_bridge_stanza enp0s31f6 subnet 49.12.121.79/26 49.12.121.65', [ 'CLUPILOT_IP' => $ip, 'CLUPILOT_SYS_NET' => $sys, 'CLUPILOT_INTERFACES_FILE' => $out, ]); expect(file_get_contents($out)) ->toContain('up ip route add 10.128.0.0/16 via 49.12.121.65 dev vmbr0') // Weder die Link-Route zum Gateway noch die Kernel-Route zum eigenen // Subnetz: beide entstehen von selbst, und ein zweiter Versuch // scheitert und nimmt die Strophe mit. ->not->toContain('up ip route add 49.12.121.65 ') ->not->toContain('up ip route add 49.12.121.64/26'); }); it('schreibt eine Strophe, die ein Mensch als von CluPilot erkennt', function () { // Wer in sechs Monaten in diese Datei sieht, muss wissen, wer sie // geschrieben hat und woraus. $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:04'); $out = $this->dir.'/interfaces'; runBridgeSh('write_bridge_stanza ens3 subnet 10.0.0.7/24 10.0.0.1', [ 'CLUPILOT_IP' => $ip, 'CLUPILOT_SYS_NET' => $sys, 'CLUPILOT_INTERFACES_FILE' => $out, ]); expect(file_get_contents($out)) ->toContain('Von CluPilot geschrieben') ->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 // 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); } });