diff --git a/config/provisioning.php b/config/provisioning.php index ea83881..11b4c19 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -36,6 +36,15 @@ return [ Host\ConfigureWireguard::class, Host\InstallProxmoxVe::class, Host\RebootIntoPveKernel::class, + // Nach dem Neustart, weil dort ifupdown2 steht (PrepareBaseSystem + // installiert es) und der Tunnel gerade bewiesen hat, dass er einen + // Neustart überlebt. + // + // Vor ConfigureProxmox, dessen refuseWithoutBridge() unverändert + // stehen bleibt und damit zur Nachprüfung wird — dasselbe Paar wie + // BuildVmTemplate → VerifyVmTemplate. Ein Bau, der still danebenging, + // fällt dann hier auf und nicht erst beim ersten bezahlten Klon. + Host\EnsureNetworkBridge::class, Host\ConfigureProxmox::class, Host\CreateAutomationToken::class, Host\VerifyProxmoxApi::class, diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 3435c7c..2ffedd8 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -140,6 +140,7 @@ return [ 'configure_wireguard' => 'WireGuard einrichten', 'install_proxmox_ve' => 'Proxmox VE installieren', 'reboot_into_pve_kernel' => 'In Proxmox-Kernel neu starten', + 'ensure_network_bridge' => 'Netzbrücke vmbr0 bauen', 'configure_proxmox' => 'Proxmox konfigurieren', 'create_automation_token' => 'Automation-Token erstellen', 'verify_proxmox_api' => 'Proxmox-API prüfen', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index b143500..97f1452 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -140,6 +140,7 @@ return [ 'configure_wireguard' => 'Configure WireGuard', 'install_proxmox_ve' => 'Install Proxmox VE', 'reboot_into_pve_kernel' => 'Reboot into Proxmox kernel', + 'ensure_network_bridge' => 'Build the vmbr0 network bridge', 'configure_proxmox' => 'Configure Proxmox', 'create_automation_token' => 'Create automation token', 'verify_proxmox_api' => 'Verify Proxmox API', diff --git a/tests/Feature/Host/BootstrapArchiveTest.php b/tests/Feature/Host/BootstrapArchiveTest.php index 35e9e91..d6e9459 100644 --- a/tests/Feature/Host/BootstrapArchiveTest.php +++ b/tests/Feature/Host/BootstrapArchiveTest.php @@ -1,5 +1,9 @@ code = App\Support\HostEnrolment::issue(App\Models\Host::factory()->create()); + $this->code = HostEnrolment::issue(Host::factory()->create()); }); -function fetchArchive(): \Illuminate\Testing\TestResponse +function fetchArchive(): TestResponse { return test()->get('http://files.clupilot.test/bootstrap.tar.gz?code='.test()->code); } @@ -39,6 +43,8 @@ it('unpacks to bootstrap/ with the script and its library', function () { ->toContain('bootstrap/lib/report.sh') ->toContain('bootstrap/lib/proxmox.sh') ->toContain('bootstrap/lib/network.sh') + ->toContain('bootstrap/lib/bridge.sh') + ->toContain('bootstrap/lib/bridge-run.sh') ->toContain('bootstrap/lib/traefik.sh') ->toContain('bootstrap/lib/template.sh') ->toContain('bootstrap/lib/register.sh') diff --git a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php index d5680b5..b5ec325 100644 --- a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php +++ b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php @@ -24,6 +24,13 @@ it('drives a fresh host all the way to active (mocked)', function () { $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUBKEY0000=')); scriptLiveTunnel($s['shell']); + // Ein Host, der seine Brücke schon hat — der ISO-installierte oder der von + // Hand gebaute. EnsureNetworkBridge fasst den nicht an und meldet advance. + // Der BAU-Pfad steckt nicht hier: er ist abgekoppelt und lebt davon, dass + // die Verbindung abreißt und wiederkommt, was eine Attrappe nicht + // nachstellt. Dafür gibt es die Schritt-Tests und die drei Sandkasten-Läufe + // in BridgeScriptTest, die bridge-run.sh wirklich ausführen. + scriptBridgeState($s['shell'], up: true); $s['shell']->script('uname -r', CommandResult::success('6.8.12-4-pve')); $s['shell']->script('pveum user token add', CommandResult::success( json_encode(['full-tokenid' => 'automation@pve!clupilot', 'value' => 'tok-secret-123']) @@ -89,6 +96,13 @@ it('does not duplicate external resources when a step re-runs after a crash', fu $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUBKEY0000=')); scriptLiveTunnel($s['shell']); + // Ein Host, der seine Brücke schon hat — der ISO-installierte oder der von + // Hand gebaute. EnsureNetworkBridge fasst den nicht an und meldet advance. + // Der BAU-Pfad steckt nicht hier: er ist abgekoppelt und lebt davon, dass + // die Verbindung abreißt und wiederkommt, was eine Attrappe nicht + // nachstellt. Dafür gibt es die Schritt-Tests und die drei Sandkasten-Läufe + // in BridgeScriptTest, die bridge-run.sh wirklich ausführen. + scriptBridgeState($s['shell'], up: true); $s['shell']->script('uname -r', CommandResult::success('6.8.12-4-pve')); $s['shell']->script('pveum user token add', CommandResult::success( json_encode(['full-tokenid' => 'automation@pve!clupilot', 'value' => 'tok-secret-123']) diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index 14e7979..c9228e4 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -1655,31 +1655,6 @@ it('asks only for privileges Proxmox 9 still knows', function () { // Deshalb: erst den Zeitgeber stellen, dann umstellen, dann neu verbinden, // nachsehen, abbestellen. -/** Was der Host auf die Zustandsfrage antwortet. */ -function scriptBridgeState(FakeRemoteShell $shell, bool $up, string $iface = 'enp0s31f6', bool $physical = true): void -{ - $shell->script('clupilot-bridge-state', CommandResult::success(implode("\n", [ - 'up='.($up ? 'yes' : 'no'), - 'iface='.$iface, - 'physical='.($physical ? 'yes' : 'no'), - 'route=default via 49.12.121.65 dev '.($up ? 'vmbr0' : $iface), - '', - ]))); -} - -/** Was der Host auf die Statusfrage antwortet. */ -function scriptBridgeStatus(FakeRemoteShell $shell, string $state, bool $alive = false, string $phase = '', string $note = '', bool $rolledBack = false): void -{ - $shell->script('clupilot-bridge-status', CommandResult::success(implode("\n", [ - 'state='.$state, - 'alive='.($alive ? 'yes' : 'no'), - 'phase='.$phase, - 'note='.$note, - 'rolledback='.($rolledBack ? 'yes' : 'no'), - '', - ]))); -} - it('fasst einen Host, der die Brücke schon hat, nicht an', function () { // pve-fns-1 hat sie von Hand. Ein Wiederanlauf, der sie umbaut, baut ein // funktionierendes Netz um — und riskiert dafür genau das, wofür es die @@ -1980,3 +1955,56 @@ it('schickt nur Shell, die eine Shell auch parsen kann', function () { expect($checked)->toBeGreaterThanOrEqual(6); }); + +it('baut die Brücke nach dem Neustart und vor ConfigureProxmox', function () { + // Nach dem Neustart, weil dort ifupdown2 steht und der Tunnel gerade + // bewiesen hat, dass er einen Neustart überlebt. Vor ConfigureProxmox, weil + // dessen refuseWithoutBridge() die Nachprüfung ist — bauen UND prüfen, + // dasselbe Paar wie BuildVmTemplate → VerifyVmTemplate. + $pipeline = config('provisioning.pipelines.host'); + + $bridge = array_search(EnsureNetworkBridge::class, $pipeline, true); + $reboot = array_search(RebootIntoPveKernel::class, $pipeline, true); + $proxmox = array_search(ConfigureProxmox::class, $pipeline, true); + + expect($bridge)->not->toBeFalse() + ->and($reboot)->toBeLessThan($bridge) + ->and($bridge)->toBeLessThan($proxmox); +}); + +it('lässt ConfigureProxmox die Nachprüfung bleiben', function () { + // Der Schritt davor baut. Dieser prüft. Beides, nicht eines von beiden — + // sonst meldet ein Bau, der still danebenging, trotzdem Erfolg. + // + // CommandResult::failure(int $exitCode = 1, string $stderr = '') — zwei + // Parameter, nicht drei. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + $run = hostRun($host); + proveTunnel($run, $host); + $s['shell']->script('ip link show vmbr0', CommandResult::failure(1, 'Device does not exist')); + + expect(app(ConfigureProxmox::class)->execute($run)->type)->toBe('fail'); +}); + +it('beschriftet den Schritt in beiden Sprachen', function () { + // Die Konsole zeigt eine Zeile je Schritt. Eine ohne Beschriftung zeigt den + // Schlüssel — der Betreiber säße beim Zusehen vor 'hosts.step.…'. + foreach (['de', 'en'] as $locale) { + expect(trans('hosts.step.ensure_network_bridge', [], $locale)) + ->not->toBe('hosts.step.ensure_network_bridge', "Beschriftung fehlt in {$locale}"); + } +}); + +it('legt jeden Pipeline-Schritt mit einer Beschriftung ab', function () { + // Nicht nur den neuen. Wer künftig einen Schritt einhängt und die + // Sprachdateien vergisst, fällt hier auf statt in der Konsole. + foreach (config('provisioning.pipelines.host') as $class) { + $key = app($class)->key(); + + foreach (['de', 'en'] as $locale) { + expect(trans("hosts.step.{$key}", [], $locale)) + ->not->toBe("hosts.step.{$key}", "Beschriftung fehlt: {$key} ({$locale})"); + } + } +}); diff --git a/tests/Pest.php b/tests/Pest.php index f901758..83919ce 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -224,6 +224,43 @@ function withRealEnv(array $vars, Closure $callback): mixed } } +/** + * Was der Host auf die Zustandsfrage von EnsureNetworkBridge antwortet. + * + * `up` ist absichtlich EIN Wert aus drei Fakten — Brücke da, trägt die + * Standardroute, hat eine Adresse. Der Schritt fragt sie in einem Rundlauf ab, + * weil eine vmbr0 ohne Adresse und ohne Route eine Brücke im Sinne von + * `ip link show` ist und sonst nichts. + */ +function scriptBridgeState(FakeRemoteShell $shell, bool $up, string $iface = 'enp0s31f6', bool $physical = true): void +{ + $shell->script('clupilot-bridge-state', CommandResult::success(implode("\n", [ + 'up='.($up ? 'yes' : 'no'), + 'iface='.$iface, + 'physical='.($physical ? 'yes' : 'no'), + 'route=default via 49.12.121.65 dev '.($up ? 'vmbr0' : $iface), + '', + ]))); +} + +/** + * Was der Host über einen laufenden Brückenbau meldet. + * + * `alive` wird auf dem Host gerechnet (`kill -0`), weil die PID hier nichts + * bedeutet; `rolledback` ist die Marke, die der Zeitgeber ZULETZT setzt. + */ +function scriptBridgeStatus(FakeRemoteShell $shell, string $state, bool $alive = false, string $phase = '', string $note = '', bool $rolledBack = false): void +{ + $shell->script('clupilot-bridge-status', CommandResult::success(implode("\n", [ + 'state='.$state, + 'alive='.($alive ? 'yes' : 'no'), + 'phase='.$phase, + 'note='.$note, + 'rolledback='.($rolledBack ? 'yes' : 'no'), + '', + ]))); +} + /** * Was ein stehender Tunnel auf dem Host meldet. *