CluPilotCloud/docs/superpowers/plans/2026-07-30-host-uebernahme-...

29 KiB
Raw Blame History

Host-Übernahme — Plattformseite, Umsetzungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: CluPilot nimmt einen fertig installierten Proxmox-Host in Betrieb, ohne ihn je zu installieren und ohne dauerhaften Shell-Zugang — der Host meldet sich selbst, holt seine Routen selbst und wird nur noch geprüft.

Architecture: Beim Anlegen eines Hosts erzeugt die Konsole ein WireGuard-Schlüsselpaar und einen Einmal-Code und zeigt beides in einer kopierbaren Befehlszeile. Das Bootstrap-Skript (eigener Plan) tritt damit dem Tunnel bei und meldet sich über drei Endpunkte zurück, die ausschließlich aus dem WireGuard-Subnetz erreichbar sind. Traefik auf dem Host holt seine Routentabelle über denselben Weg ab, statt sie per SSH geschrieben zu bekommen. Die Host-Kette schrumpft von vierzehn auf sechs Schritte, die nur noch nachsehen.

Tech Stack: PHP 8.3, Laravel 13, Livewire 3, Pest 4, MariaDB, Tailwind v3, Docker Compose.

Global Constraints

  • Vollfassung des Entwurfs: docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md. Bei Widerspruch gewinnt die Spec.
  • Die härteste Bedingung, aus §8a: Es entsteht kein einziger neuer Weg von außen — weder auf den Host noch zu CluPilot. register, progress und routes liegen alle drei hinter der Tunnel-Beschränkung. Ein Task, der einen davon öffentlich erreichbar macht, ist gescheitert, auch wenn seine Tests grün sind.
  • Commit-Disziplin, nicht verhandelbar. Weitere Sitzungen arbeiten im selben Repository. Immer git add -- <pfade> und git commit -F - -- <pfade>. Nie git add -A, git add ., git commit -a oder ein nacktes git commit.
  • pint nur auf eigene Pfade, nie --dirty, keine Kosmetik an fremden Dateien.
  • R18R24 aus CLAUDE.md gelten und werden per Test erzwungen. R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken.
  • Pest-Fallen, beide heute erlebt: toThrow(EinInterface::class) beweist nichts — immer eine konkrete Klasse. Und toContain($a, $b) ist variadisch, der zweite Parameter ist kein Meldungstext; dasselbe gilt für toHaveKey().
  • Testisolation: die Suite läuft mit CACHE_STORE=array, der zwischen Testdateien nicht geleert wird. Ein Test, der von Settings abhängt, setzt den Wert selbst.
  • Betreiber-Rollen heißen exakt Owner, Admin, Support, Billing, Read-only, Developer. Fabrik: Operator::factory()->role('Owner')->create(). Testpasswort passwort-fuer-tests. Guard-Name 'operator' — bei Livewire::actingAs($op, 'operator') nicht vergessen.
  • Tests laufen nur im Container, docker compose aus /home/nexxo/clupilot: docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test
  • Immer die volle Suite, nie nur einen Filter. Eine Aufgabe hat heute mit einem Filter gearbeitet und einen Bruch übersehen, der erst zwei Aufgaben später auffiel.
  • Kein migrate gegen die Entwicklungsdatenbank. Die Container bedienen ein anderes Arbeitsverzeichnis; eine Migration von hier aus beschädigt die laufende Installation. Beweise führen die Tests auf SQLite im Speicher.

Was schon da ist

App\Services\Wireguard\WireguardHub allocateIp(), addPeer($pubkey, $ip), removePeer($pubkey), endpoint(), publicKey(), peers()
config('admin_access.trusted_ranges') aus TRUSTED_RANGES, Vorgabe 10.66.0.0/24,127.0.0.1
App\Http\Middleware\RestrictConsoleNetwork das Vorbild für die Tunnel-Beschränkung — nicht neu erfinden
App\Services\Secrets\SecretVault Tresor mit Modus-Plätzen; stripe.secret ist strikt
App\Support\Readiness fünf Prüfgruppen, Spread-Array, Wächter-Test über die Registry
hosts uuid, name, dns_name, datacenter, public_ip, wg_ip, wg_pubkey, ssh_host_key, api_token_ref, total_gb, total_ram_mb, cpu_cores, pve_version, node, status, last_seen_at
SshTraefikWriter::write($trafficHost, $subdomain, array $hostnames, $backend) die abzulösende Stelle
ConfigureDnsAndTls bildet $hostnames = array_filter([$fqdn, $customDomain]) — beide Namen

Task 1: Einmal-Code und Schlüsselpaar beim Anlegen

Files:

  • Create: database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php
  • Create: app/Support/HostEnrolment.php
  • Test: tests/Feature/Host/HostEnrolmentTest.php

Interfaces:

  • Consumes: WireguardHub::allocateIp(), publicKey(), endpoint(), addPeer().

  • Produces: HostEnrolment::issue(Host $host): string — legt Code, Schlüsselpaar und Peer an und gibt den Klartext-Code genau einmal zurück. HostEnrolment::claim(string $code): ?Host — löst einen gültigen, unverbrauchten Code auf und markiert ihn als verbraucht. Spalten auf hosts: enrolment_code_hash, enrolment_expires_at, enrolment_used_at.

  • Step 1: Den fehlschlagenden Test schreiben

<?php

use App\Models\Host;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\DB;

/**
 * Der Code ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft:
 * er öffnet nichts nach außen (§5 der Spec), sondern verhindert nur, dass ein
 * Gerät im Tunnel für einen fremden Host spricht.
 */
it('hands out the code exactly once and stores only its hash', function () {
    $host = Host::factory()->create();

    $code = HostEnrolment::issue($host);

    expect($code)->toHaveLength(32);

    $stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash');
    expect($stored)->not->toBe($code)
        ->and($stored)->not->toContain($code);
});

it('resolves a valid code to its host and consumes it', function () {
    $host = Host::factory()->create();
    $code = HostEnrolment::issue($host);

    expect(HostEnrolment::claim($code)?->id)->toBe($host->id);

    // Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole —
    // eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.
    expect(HostEnrolment::claim($code))->toBeNull();
});

it('refuses a code past its expiry', function () {
    $host = Host::factory()->create();
    $code = HostEnrolment::issue($host);

    $this->travel(25)->hours();

    expect(HostEnrolment::claim($code))->toBeNull();
});

it('refuses a code that was never issued', function () {
    expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull();
});

/**
 * Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist — sonst käme er
 * nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden.
 * Genau das vermeidet dieser Entwurf.
 */
it('allocates a tunnel address and admits the peer up front', function () {
    $host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]);

    HostEnrolment::issue($host);

    $host->refresh();
    expect($host->wg_ip)->not->toBeNull()
        ->and($host->wg_pubkey)->not->toBeNull();

    expect(app(\App\Services\Wireguard\WireguardHub::class)->peers())
        ->toContain($host->wg_pubkey);
});
  • Step 2: Test laufen lassen, Fehlschlag bestätigen

Run: cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test --filter=HostEnrolmentTest Expected: FAIL mit Class "App\Support\HostEnrolment" not found

  • Step 3: Migration und Klasse schreiben

Die Migration legt drei Spalten an hosts an — Hash, Ablauf, Verbrauch. Der private Schlüssel wird nicht gespeichert: er wandert einmal in die Befehlszeile und ist nach dem Tausch (Task 3) wertlos.

public function issue(Host $host): string
{
    $hub = app(WireguardHub::class);

    // Das Paar entsteht HIER und nicht auf dem Host. Erzeugte der Host es
    // selbst, müsste er seinen öffentlichen Schlüssel melden, bevor der Tunnel
    // steht — und dafür bräuchte es einen öffentlichen Endpunkt. Der Preis ist
    // ein Schlüssel, der Minuten lebt (Task 3 tauscht ihn), der Gewinn ist eine
    // Netzgrenze, die unverändert bleibt.
    [$privateKey, $publicKey] = self::keypair();
    $ip = $hub->allocateIp();
    $hub->addPeer($publicKey, $ip);

    $code = Str::random(32);

    $host->update([
        'wg_ip' => $ip,
        'wg_pubkey' => $publicKey,
        'enrolment_code_hash' => hash('sha256', $code),
        'enrolment_expires_at' => now()->addDay(),
        'enrolment_used_at' => null,
    ]);

    // Der private Schlüssel wird bewusst nicht abgelegt. Er geht in die
    // Befehlszeile und sonst nirgendwohin.
    self::$issuedPrivateKey = $privateKey;

    return $code;
}

claim() sucht über den Hash, prüft enrolment_expires_at > now() und enrolment_used_at === null, setzt enrolment_used_at und gibt den Host zurück.

Hash statt Hash::make: ein sha256 über einen 32-Zeichen-Zufallswert reicht hier und ist suchbar. Ein bcrypt-Hash wäre nicht in einer where-Bedingung auflösbar, ohne alle Zeilen durchzuprobieren.

  • Step 4: Test laufen lassen, Erfolg bestätigen

Run: wie Step 2 Expected: PASS, 5 Tests

  • Step 5: Volle Suite

Run: cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test Expected: PASS

  • Step 6: Committen
git add -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php
git commit -F - -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php <<'EOF'
Let the console admit a host to the tunnel before it exists

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF

Task 2: Die Tunnel-Beschränkung — der wichtigste Test dieses Plans

Files:

  • Create: app/Http/Middleware/RestrictTunnelOnly.php
  • Modify: bootstrap/app.php (Alias registrieren)
  • Test: tests/Feature/Host/TunnelOnlyTest.php

Interfaces:

  • Consumes: config('admin_access.trusted_ranges'), das Muster von RestrictConsoleNetwork.
  • Produces: Middleware-Alias tunnel-only.

Lies zuerst app/Http/Middleware/RestrictConsoleNetwork.php. Es löst dieselbe Frage bereits — CIDR-Vergleich gegen trusted_ranges — und dieser Task soll ihm folgen, nicht eine zweite Formulierung danebenstellen. Wenn sich die Logik herausziehen lässt, statt sie zu verdoppeln, tu das.

  • Step 1: Den fehlschlagenden Test schreiben
<?php

use App\Models\Host;

/**
 * Die härteste Bedingung der Spec (§8a): es entsteht kein einziger neuer Weg von
 * außen. Dieser Test ist der Grund, warum der Entwurf so aussieht, wie er
 * aussieht — er hält fest, dass alle drei Host-Endpunkte unsichtbar bleiben.
 */
beforeEach(function () {
    config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
});

dataset('host endpoints', [
    'register' => ['post', '/host/register'],
    'progress' => ['post', '/host/progress'],
    'routes' => ['get', '/host/routes'],
]);

it('answers 404 from outside the tunnel', function (string $method, string $path) {
    // 404, nicht 403: eine Adresse, die es für Fremde nicht gibt, verrät auch
    // nicht, dass es sie gibt. Dasselbe Muster wie RestrictAdminHost.
    $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
        ->call($method, $path)
        ->assertNotFound();
})->with('host endpoints');

it('is reachable from inside the tunnel', function (string $method, string $path) {
    // Erreichbar heißt nicht erlaubt — ohne gültigen Ausweis folgt weiter unten
    // eine Abweisung. Hier zählt nur, dass die Beschränkung nicht schon greift.
    $this->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
        ->call($method, $path)
        ->assertStatus(fn (int $status) => $status !== 404);
})->with('host endpoints');
  • Step 2: Test laufen lassen, Fehlschlag bestätigen

Expected: FAIL — die Routen gibt es noch nicht

  • Step 3: Middleware und Routen-Gerüst schreiben

RestrictTunnelOnly prüft $request->ip() gegen admin_access.trusted_ranges und wirft sonst abort(404). Die drei Routen entstehen als Gerüst, das noch nichts tut außer zu antworten — gefüllt werden sie in Task 3 bis 5.

  • Step 4: Test laufen lassen, Erfolg bestätigen

Expected: PASS, 6 Tests

  • Step 5: Volle Suite, dann committen

Nachricht: Make the host endpoints invisible from outside the tunnel


Task 3: POST /host/register — Token in den Tresor, Schlüssel tauschen

Files:

  • Create: app/Http/Controllers/HostRegistrationController.php
  • Modify: app/Services/Secrets/SecretVault.php (Registry-Eintrag je Host)
  • Test: tests/Feature/Host/HostRegistrationTest.php

Interfaces:

  • Consumes: HostEnrolment::claim() (Task 1), Tunnel-Middleware (Task 2), WireguardHub::addPeer/removePeer.

  • Produces: hosts.api_token_ref gefüllt, hosts.wg_pubkey auf den frischen Schlüssel, Antwort enthält das dauerhafte Host-Token für den Routen-Abruf.

  • Step 1: Den fehlschlagenden Test schreiben

<?php

use App\Models\Host;
use App\Services\Wireguard\WireguardHub;
use App\Support\HostEnrolment;

beforeEach(function () {
    config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
    $this->host = Host::factory()->create(['status' => 'onboarding']);
    $this->code = HostEnrolment::issue($this->host);
});

function registerAs(string $code, array $payload = []): \Illuminate\Testing\TestResponse
{
    return test()->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
        ->postJson('/host/register', array_merge([
            'code' => $code,
            'api_token' => 'root@pam!clupilot=abc-123',
            'wg_pubkey' => 'FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=',
            'node' => 'pve-fsn-1',
            'pve_version' => '9.0-1',
            'total_gb' => 1000,
            'total_ram_mb' => 65536,
            'cpu_cores' => 16,
            'ssh_host_key' => 'SHA256:abc',
        ], $payload));
}

it('puts the proxmox token in the vault and never in the response', function () {
    $response = registerAs($this->code);

    $response->assertOk();
    expect($response->json())->not->toContain('root@pam!clupilot=abc-123');
    expect($this->host->fresh()->api_token_ref)->not->toBeNull();
});

/**
 * Die Reihenfolge ist die ganze Aussage: erst aufnehmen, dann entfernen.
 * Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem er
 * sitzt, und die Antwort erreicht ihn nie.
 */
it('admits the new key before dropping the old one', function () {
    $old = $this->host->fresh()->wg_pubkey;

    registerAs($this->code)->assertOk();

    $peers = app(WireguardHub::class)->peers();
    expect($peers)->toContain('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=')
        ->and($peers)->not->toContain($old);
    expect($this->host->fresh()->wg_pubkey)->toBe('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=');
});

it('refuses a code that belongs to another host', function () {
    $other = Host::factory()->create();
    $otherCode = HostEnrolment::issue($other);

    registerAs($otherCode)->assertOk();

    // Der fremde Host wurde eingerichtet, DIESER nicht.
    expect($this->host->fresh()->api_token_ref)->toBeNull();
});

it('refuses a spent code', function () {
    registerAs($this->code)->assertOk();
    registerAs($this->code)->assertStatus(422);
});

it('hands back a durable host token for the route pull', function () {
    expect(registerAs($this->code)->json('host_token'))->toBeString()->not->toBeEmpty();
});
  • Step 2 bis 6: Fehlschlag bestätigen, schreiben, bestätigen, volle Suite, committen.

Beim Schreiben beachten: Der Proxmox-Token gehört in den Tresor (verschlüsselt mit SECRETS_KEY), nicht in eine Spalte. hosts.api_token_ref hält nur den Verweis — so war es schon vor diesem Plan, und der Handoff nennt die Verknüpfung mit SECRETS_KEY ausdrücklich als das teuerste Einzelstück.

Nachricht: Take the host's token through the tunnel and swap its key


Task 4: POST /host/progress — mit Nachreichen

Files:

  • Create: app/Http/Controllers/HostProgressController.php
  • Create: database/migrations/2026_08_02_100000_record_what_a_host_reported.php
  • Test: tests/Feature/Host/HostProgressTest.php

Interfaces:

  • Produces: Tabelle host_progress_events (host_id, section, state, message, occurred_at), die Abschnittsliste aus §7 der Spec.

Die Abschnitte, in dieser Reihenfolge und mit genau diesen Schlüsseln — sie sind die einzige Absprache zwischen diesem Plan und dem Skript-Plan:

rescue_checked, debian_installed, rebooted, proxmox_installed, network_bridged, wireguard_joined, traefik_running, template_built, registered

  • Step 1: Den fehlschlagenden Test schreiben

Der wichtigste Fall ist das Nachreichen: alles vor wireguard_joined kann der Host nicht melden, weil es bis dahin keinen Weg zu CluPilot gibt. Er schreibt es lokal mit und reicht es beim ersten erreichbaren Aufruf nach.

it('keeps the reported timestamps, not the arrival time', function () {
    // Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine
    // Sekunde, und man kann nicht erkennen, welcher Abschnitt lange gedauert hat.
    $reported = now()->subMinutes(18);

    postProgress($this->code, [
        ['section' => 'rescue_checked', 'state' => 'done', 'occurred_at' => $reported->toIso8601String()],
        ['section' => 'debian_installed', 'state' => 'done', 'occurred_at' => $reported->addMinutes(6)->toIso8601String()],
    ])->assertOk();

    expect($this->host->progressEvents()->first()->occurred_at->toIso8601String())
        ->toBe($reported->copy()->subMinutes(6)->toIso8601String());
});

it('refuses a section it does not know', function () {
    // Ein Abschnittsname aus dem Netz. Ohne diese Prüfung füllt jemand die
    // Fortschrittsanzeige mit erfundenen Zeilen.
    postProgress($this->code, [['section' => 'erfunden', 'state' => 'done']])
        ->assertStatus(422);
});
  • Steps 2 bis 6 wie gehabt. Nachricht: Let the host tell the console how far it got

Task 5: GET /host/routes — Traefik holt sich seine Tabelle

Files:

  • Create: app/Http/Controllers/HostRoutesController.php
  • Create: app/Services/Traefik/RouteTable.php
  • Test: tests/Feature/Host/HostRoutesTest.php

Interfaces:

  • Consumes: das dauerhafte Host-Token aus Task 3, Instance mit subdomain, custom_domain, routed_hostnames.

  • Produces: RouteTable::forHost(Host $host): array — Traefiks dynamische Konfiguration für genau diesen Host.

  • Step 1: Den fehlschlagenden Test schreiben

it('serves only the instances of this host', function () {
    // Ein entwendetes Token zeigt die Routen genau eines Hosts, nicht die aller.
    $mine = Instance::factory()->create(['host_id' => $this->host->id, 'subdomain' => 'meine']);
    $theirs = Instance::factory()->create(['subdomain' => 'fremde']);

    $body = json_encode(fetchRoutes($this->hostToken)->json());

    expect($body)->toContain('meine')->not->toContain('fremde');
});

it('serves both the subdomain and a proven custom domain', function () {
    // Dazunehmen, nicht ersetzen — dieselbe Regel wie ConfigureDnsAndTls.
    Instance::factory()->create([
        'host_id' => $this->host->id,
        'subdomain' => 'mueller',
        'custom_domain' => 'cloud.mueller-gmbh.de',
        'domain_verified_at' => now(),
    ]);

    $body = json_encode(fetchRoutes($this->hostToken)->json());

    expect($body)->toContain('mueller.')->and($body)->toContain('cloud.mueller-gmbh.de');
});

it('leaves out a custom domain that was never proven', function () {
    Instance::factory()->create([
        'host_id' => $this->host->id,
        'custom_domain' => 'nicht-bewiesen.de',
        'domain_verified_at' => null,
    ]);

    expect(json_encode(fetchRoutes($this->hostToken)->json()))
        ->not->toContain('nicht-bewiesen.de');
});

Beim Schreiben: Die Entrypoint- und certResolver-Namen müssen zu dem passen, was SshTraefikWriter::render() heute ausgibt. Der Handoff warnt in Block C ausdrücklich: „hier zuerst nachlesen, nicht raten." Das gilt unverändert.

  • Steps 2 bis 6. Nachricht: Serve a host the routes it is supposed to answer for

Task 6: SshTraefikWriter ablösen

Files:

  • Modify: app/Services/Traefik/ (neuer TraefikWriter, der in die Datenbank schreibt)
  • Delete: app/Services/Traefik/SshTraefikWriter.php
  • Test: tests/Feature/Host/NoShellForRoutesTest.php

Interfaces:

  • Die Aufrufer (ConfigureDnsAndTls, RunAcceptanceChecks, EndInstanceService) ändern sich nicht. Sie rufen weiter TraefikWriter::write(...); nur was dahinter passiert, ändert sich.

  • Step 1: Den fehlschlagenden Test schreiben

/**
 * Der Beweis, dass die Plattform keinen Shell-Zugang mehr auf Kundenhosts
 * braucht. Solange irgendwo eine RemoteShell für Routen aufgemacht wird, ist der
 * Entwurf nicht eingelöst — egal wie grün alles andere ist.
 */
it('opens no shell when an address is configured', function () {
    $s = fakeServices();
    $instance = Instance::factory()->create(['host_id' => $this->host->id]);

    app(ConfigureDnsAndTls::class)->execute(runFor($instance));

    expect($s['shell']->connectionsWith('key'))->toBeEmpty();
});

it('has no SshTraefikWriter left in the repository', function () {
    expect(file_exists(base_path('app/Services/Traefik/SshTraefikWriter.php')))->toBeFalse();
});
  • Steps 2 bis 6. Nachricht: Stop reaching into customer hosts to write a route

Task 7: Adminbereich — Befehlszeile und Fortschritt

Files:

  • Modify: app/Livewire/Admin/HostCreate.php, HostDetail.php und deren Blades

  • Test: tests/Feature/Admin/HostEnrolmentPageTest.php

  • Step 1: Den fehlschlagenden Test schreiben

it('shows the command exactly once, right after creating the host', function () {
    // Der Code steht nur als Hash in der Datenbank. Wer die Seite neu lädt,
    // bekommt ihn nicht wieder — er legt einen neuen an.
    $page = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
        ->test(HostCreate::class)
        ->set('name', 'pve-fsn-2')
        ->set('datacenter', 'fsn')
        ->set('publicIp', '198.51.45.9')
        ->call('create');

    $page->assertSee('curl');

    Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
        ->test(HostDetail::class, ['uuid' => Host::latest('id')->first()->uuid])
        ->assertDontSee('curl');
});

it('shows which section the host is stuck in', function () {
    // „Irgendwas ging schief" ist keine Auskunft. Welcher Abschnitt offen ist,
    // ist eine.
    $this->host->progressEvents()->create([
        'section' => 'proxmox_installed', 'state' => 'done', 'occurred_at' => now()->subMinutes(30),
    ]);

    Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
        ->test(HostDetail::class, ['uuid' => $this->host->uuid])
        ->assertSee(__('hosts.section.network_bridged'));
});

R18, R19, R23, R24 gelten — Blades werden angefasst. Die vier Wächter-Tests am Ende ausdrücklich mitlaufen lassen. Zeiten der Fortschrittsmeldungen gehen durch ->local().

  • Steps 2 bis 6. Nachricht: Give the operator one line to copy and a progress to watch

Task 8: Die Subdomain gehört dem Kunden

Files:

  • Modify: app/Livewire/Order.php bzw. die Kasse, app/Provisioning/Steps/Customer/ReserveResources.php
  • Create: app/Rules/AvailableSubdomain.php
  • Test: tests/Feature/SubdomainChoiceTest.php

Interfaces:

  • Heute: ReserveResources::uniqueSubdomain() bildet Str::slug($customer->name).'-'.Str::random(5). Künftig kommt die Subdomain aus dem Auftrag; die Ableitung bleibt nur als Rückfall für einen leeren Wunsch.

  • Step 1: Den fehlschlagenden Test schreiben

it('refuses a name the platform needs itself', function (string $name) {
    expect(validator(['subdomain' => $name], ['subdomain' => new AvailableSubdomain])->passes())
        ->toBeFalse();
})->with(['www', 'mail', 'admin', 'api', 'app', 'ws', 'status', 'vpn']);

it('refuses a name another customer already has, whatever the case', function () {
    Instance::factory()->create(['subdomain' => 'mueller']);

    expect(validator(['subdomain' => 'MUELLER'], ['subdomain' => new AvailableSubdomain])->passes())
        ->toBeFalse();
});

it('falls back to a neutral name when the field is left empty', function () {
    // Die Vorgabe darf NICHT der Nachname sein: jede Subdomain landet über das
    // Zertifikat dauerhaft in den öffentlichen Certificate-Transparency-Logs.
    $order = orderWithoutSubdomainWish(customerNamed('Anna Müller'));

    $subdomain = app(ReserveResources::class)->execute(runFor($order))->context('subdomain');

    expect($subdomain)->not->toContain('mueller');
});

it('keeps the name the customer chose', function () {
    $order = orderWishing('meine-wolke');

    expect(app(ReserveResources::class)->execute(runFor($order))->context('subdomain'))
        ->toBe('meine-wolke');
});
  • Steps 2 bis 6. Der Hinweis neben dem Feld muss sagen, dass die Adresse öffentlich und dauerhaft ist. Nachricht: Let the customer pick the address they will keep

Task 9: Die Kette schrumpfen

Files:

  • Modify: config/provisioning.php
  • Delete: app/Provisioning/Steps/Host/{EstablishSshTrust,PrepareBaseSystem,ConfigureWireguard,InstallProxmoxVe,RebootIntoPveKernel,ConfigureProxmox,CreateAutomationToken}.php und ihre Tests
  • Modify: app/Provisioning/Steps/Host/SecureHostFirewall.php — vom Schreiben zum Prüfen
  • Test: tests/Feature/Provisioning/HostPipelineShapeTest.php

HALT — die einzige echte Kopplung zwischen den zwei Plänen.

Diese sieben Dateien sind die Quelle für den Skript-Plan. Sie enthalten Wissen, das aus echten Ausfällen entstanden ist: die Codename-Tabelle für Debian 13/PVE 9, die vollständige vmbr0-Diagnose, die nftables-Regeln samt der ICMP-Korrektur, die systemd-Aktivierung von wg0, und die Proxmox-Rolle inklusive Sys.Modify, ohne das jede Kundenbereitstellung am Backup-Schritt stirbt.

Führe diesen Task erst aus, wenn Task 4 bis 9 des Skript-Plans geschrieben sind — oder vergewissere dich, dass der Skript-Strang sie gelesen hat. Aus git log sind sie zwar wiederherstellbar, aber niemand liest die Historie, wenn er nicht weiß, dass dort etwas fehlt. Genau so gehen Erkenntnisse verloren, die einmal einen Ausfall gekostet haben.

  • Step 1: Den fehlschlagenden Test schreiben
it('has six steps that only look, never change', function () {
    expect(config('provisioning.pipelines.host'))->toBe([
        Host\ValidateHostInput::class,
        Host\VerifyProxmoxApi::class,
        Host\VerifyVmTemplate::class,
        Host\RegisterHostDns::class,
        Host\RegisterCapacity::class,
        Host\CompleteHostOnboarding::class,
    ]);
});

it('opens no shell anywhere in the host pipeline', function () {
    // Der Beweis für §1 der Spec: der einzige dauerhafte Zugang ist der
    // Proxmox-Token.
    $s = fakeServices();

    foreach (config('provisioning.pipelines.host') as $step) {
        app($step)->execute($this->run);
    }

    expect($s['shell']->connectionsWith('key'))->toBeEmpty()
        ->and($s['shell']->connectionsWith('password'))->toBeEmpty();
});

Achtung: SecureHostFirewall bleibt, wird aber zur Prüfung. Ob es dafür noch in der Kette steht oder in die Bereitschaftsprüfungen wandert, entscheidet der Implementierer — begründet im Bericht.

  • Steps 2 bis 6. Nachricht: Shrink the host pipeline to six steps that only look

Task 10: Bereitschaftsprüfungen nachziehen

Files:

  • Modify: app/Support/Readiness/OnboardingChecks.php, ProvisioningChecks.php
  • Modify: lang/de/readiness.php, lang/en/readiness.php
  • Test: tests/Feature/Readiness/OnboardingChecksTest.php, ProvisioningChecksTest.php

Zu ändern, aus §10 der Spec:

  • onboarding.ssh_private_keynicht mehr blockierend. Die Plattform braucht keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Notfallzugang), sein Fehlen hält nichts auf.
  • provisioning.traefik_pathentfällt. Es gibt kein Verzeichnis mehr, in das CluPilot schreibt.
  • Neu: mindestens ein Host holt seine Routen tatsächlich ab (letzter Abruf jünger als das Intervall). Ein Host, der das nicht mehr tut, serviert stillschweigend einen veralteten Stand.

Der Wächter-Test aus dem Betriebsmodus-Vorhaben erzwingt, dass jeder Tresor-Eintrag auf der Bereitschaftsseite auftaucht. Wer hier einen Eintrag entfernt oder umbenennt, zieht ihn nach.

  • Steps 1 bis 6. Nachricht: Teach the readiness page that nobody writes to a host any more

Abschluss

  • Volle Suite grün.
  • VERSION nicht anheben, kein Tag. Das passiert erst, wenn auch der Skript-Plan durch ist und beide auf echter Hardware zusammen liefen.
  • Branch pushen:
git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" feature/host-bootstrap

Was danach offen bleibt

  • Der Skript-Plan (2026-07-30-host-uebernahme-bootstrap-skript.md) — läuft parallel, trifft sich mit diesem nur an den Abschnittsschlüsseln aus Task 4.
  • Die Abnahme auf echter Hardware. Sie ist die einzige, die zählt.
  • Die zwei Seed-Hosts aus DatabaseSeeder entfernen.
  • Nextcloud zurücksetzen und die Störungsmeldung — beschlossen, eigene Entwürfe, §14 der Spec.