EnsureNetworkBridge in die Pipeline: nach dem Neustart, vor der Nachpruefung

Der Schritt haengt jetzt zwischen RebootIntoPveKernel und
ConfigureProxmox. Nach dem Neustart, weil dort ifupdown2 steht und der
Tunnel gerade bewiesen hat, dass er einen Neustart ueberlebt. Vor
ConfigureProxmox, dessen refuseWithoutBridge() unveraendert stehen bleibt
und damit zur Nachpruefung wird — bauen UND pruefen, dasselbe Paar wie
BuildVmTemplate -> VerifyVmTemplate.

Beschriftung in beiden Sprachen, und ein Test verlangt das kuenftig von
JEDEM Pipeline-Schritt statt nur vom neuen: wer einen einhaengt und die
Sprachdateien vergisst, faellt im Test auf statt in der Konsole.

bridge.sh und bridge-run.sh gehoeren ins Bootstrap-Archiv — es ist der
einzige Weg, auf dem das Skript auf eine nackte Maschine kommt.

Der End-to-End-Test lief rot, und zu Recht: sein Attrappen-Host sagte
nichts ueber sein Netz, also lehnte der neue Schritt ab. Das war der
Beweis, dass er wirklich haengt. Er bekommt jetzt einen Host, der seine
Bruecke schon hat — den Bau-Pfad kann eine Attrappe nicht nachstellen,
er lebt davon, dass die Verbindung abreisst und wiederkommt. Dafuer gibt
es die Schritt-Tests und die drei Sandkasten-Laeufe.

scriptBridgeState/scriptBridgeStatus liegen in tests/Pest.php, nicht in
einer einzelnen Testdatei — zwei Dateien brauchen sie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/hostnamen-vergabe
nexxo 2026-08-01 13:14:40 +02:00
parent 9888f898b8
commit 66bddd4bce
7 changed files with 123 additions and 27 deletions

View File

@ -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,

View File

@ -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',

View File

@ -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',

View File

@ -1,5 +1,9 @@
<?php
use App\Models\Host;
use App\Support\HostEnrolment;
use Illuminate\Testing\TestResponse;
/**
* Das Archiv ist der einzige Weg, auf dem das Skript auf eine nackte Maschine
* kommt. Was hier schiefgeht, merkt man erst im Rettungssystem eines Servers,
@ -7,10 +11,10 @@
*/
beforeEach(function () {
fakeServices();
$this->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')

View File

@ -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'])

View File

@ -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})");
}
}
});

View File

@ -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.
*