$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); } });