CluPilotCloud/docs/superpowers/plans/2026-07-25-host-onboarding.md

20 KiB
Raw Blame History

Host-Onboarding (Subsystem A) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:test-driven-development per task. Steps use checkbox (- [ ]) syntax.

Goal: Ein Admin trägt einen frischen Debian-Server (Root-SSH) im Panel ein; CluPilot bringt ihn vollautomatisch als Proxmox-Host (active, mit Kapazität + API-Token) in die Flotte.

Architecture: Gemeinsamer DB-State-Machine + Tick-Orchestrator-Kern (Ansatz ①, wiederverwendbar für Subsystem B). Host-Onboarding = pipeline=host aus 11 idempotenten Step-Klassen. I/O (SSH/WireGuard/Proxmox) hinter Interfaces → in Tests Fakes.

Tech Stack: Laravel 13.8, Livewire 3 (class-based), phpseclib3, Reverb, Redis-Queue, Pest 4, sqlite :memory: (Tests), Tailwind v3 Tokens.

Global Constraints

  • R1/R2: Full-page class-based Livewire als Routen (kein Volt). R3: nur Token-Utilities, kein Hex. R4: keine Inline-Styles außer Progress-width. R5: destruktiv → wire-elements/modal. R7: responsive 375/768/1280, Touch ≥44px. R8: alles im Container. R9: UI-Copy DE, keine Emoji (Status via Farbe/Dots/Pills). R10: Design-System wiederverwenden. R11: URLs per UUID. R12: Browser 0 Konsolenfehler. R13: Routen-Pfade/Namen englisch. R14: Fonts self-hosted. R15: Codex clean vor „fertig". R16: alles DE+EN, identische Keys. R17: Blade-@php-Regeln.
  • Tests gegen sqlite :memory: (phpunit.xml force="true" — nicht kaputt machen).
  • Secrets nie in Git; api_token_ref Crypt-verschlüsselt; Root-Passwort transient + gescrubbt.
  • Testlauf: docker compose run --rm --no-deps -u 1000:1000 -e HOME=/tmp app ./vendor/bin/pest

File Structure

Domäne / Models

  • app/Models/Host.php, app/Models/ProvisioningRun.php, app/Models/ProvisioningStepEvent.php, app/Models/RunResource.php
  • Migrations: *_create_hosts_table, *_create_provisioning_runs_table, *_create_provisioning_step_events_table, *_create_run_resources_table

Orchestrator-Kern (app/Provisioning/)

  • StepResult.php (Wert), Contracts/ProvisioningStep.php (Interface)
  • PipelineRegistry.php (pipeline → Step-Klassen)
  • RunRunner.php (eine Advance-Ausführung: Lock, Step, Result, Event, Broadcast)
  • Jobs/AdvanceRunJob.php
  • Events/StepAdvanced.php (Broadcast)

Services (app/Services/)

  • Ssh/RemoteShell.php (Interface), Ssh/CommandResult.php, Ssh/PhpseclibRemoteShell.php, Ssh/FakeRemoteShell.php
  • Wireguard/WireguardHub.php (Interface), Wireguard/LocalWireguardHub.php, Wireguard/FakeWireguardHub.php
  • Proxmox/ProxmoxClient.php (Interface), Proxmox/HttpProxmoxClient.php, Proxmox/FakeProxmoxClient.php

Host-Steps (app/Provisioning/Steps/Host/): ValidateHostInput, EstablishSshTrust, PrepareBaseSystem, ConfigureWireguard, InstallProxmoxVe, RebootIntoPveKernel, ConfigureProxmox, CreateAutomationToken, VerifyProxmoxApi, RegisterCapacity, CompleteHostOnboarding

Orchestrierung-Anbindung

  • routes/console.php (Tick-Schedule), app/Actions/StartHostOnboarding.php (Host+Run anlegen+dispatch)

Admin-UI

  • app/Livewire/Admin/Hosts.php (Fixture→echt) + resources/views/livewire/admin/hosts.blade.php
  • app/Livewire/Admin/HostDetail.php + resources/views/livewire/admin/host-detail.blade.php
  • app/Livewire/Admin/HostCreate.php (Formular) + View
  • routes/web.php (+ /admin/hosts/{uuid}, add-host route)
  • lang/{de,en}/hosts.php

Tests (tests/Feature/..., tests/Unit/...): je Kern-Komponente + je Step + Admin-Gate/CRUD.


Global Interfaces (verbatim — von allen Tasks konsumiert)

// app/Provisioning/StepResult.php
final class StepResult {
    public const ADVANCE = 'advance';
    public const RETRY   = 'retry';
    public const FAIL    = 'fail';
    public string $type; public int $afterSeconds; public string $reason;
    public static function advance(): self;
    public static function retry(int $afterSeconds, string $reason): self;
    public static function fail(string $reason): self;
}

// app/Provisioning/Contracts/ProvisioningStep.php
interface ProvisioningStep {
    public function key(): string;        // stabil, snake_case
    public function label(): string;      // i18n-Key 'hosts.step.<key>'
    public function maxDuration(): int;   // Sekunden
    public function execute(ProvisioningRun $run): StepResult;
}

// app/Services/Ssh/CommandResult.php
final class CommandResult { public int $exitCode; public string $stdout; public string $stderr;
    public function ok(): bool; /* exitCode === 0 */ }

// app/Services/Ssh/RemoteShell.php
interface RemoteShell {
    public function connectWithPassword(string $host, string $user, string $password): void;
    public function connectWithKey(string $host, string $user, string $privateKey): void;
    public function run(string $command): CommandResult;
    public function putFile(string $remotePath, string $contents): void;
    public function hostKeyFingerprint(): string;
}

// app/Services/Wireguard/WireguardHub.php
interface WireguardHub {
    public function allocateIp(): string;                       // z.B. 10.66.0.7
    public function addPeer(string $publicKey, string $ip): void;
    public function removePeer(string $publicKey): void;
    public function endpoint(): string;                          // Hub public endpoint host:port
    public function publicKey(): string;                        // Hub WG pubkey
}

// app/Services/Proxmox/ProxmoxClient.php
interface ProxmoxClient {
    public function forHost(Host $host): static;                 // Basis-URL + Token aus Host
    public function listNodes(): array;
    public function nodeStatus(string $node): array;            // cpuinfo.cpus, memory.total ...
    public function nodeStorage(string $node): array;
    public function createRole(string $roleId, string $privs): void;
    public function createUserAndToken(string $user, string $roleId): array; // ['token_id','secret']
}

ProvisioningRun-Status: pending, running, waiting, paused, completed, failed. Felder: uuid, subject_type, subject_id, pipeline, current_step (int, 0-basiert), status, attempt, max_attempts, next_attempt_at, started_at, finished_at, error, context (json).

Host-Status: pending, onboarding, active, error, disabled.


Phase 1 — Datenmodell

Task 1: Migrations + Models (hosts, runs, step_events, run_resources)

Files: Create 4 Migrations + 4 Models. Test: tests/Unit/ModelsTest.php. Produces: Eloquent-Models mit Casts (context→array/json, api_token_ref→encrypted, next_attempt_at/started_at/finished_at/last_seen_at→datetime), Host::uuid/ProvisioningRun::uuid auto (booted creating), Relationen (run->events, run->resources, run->subject() morphTo).

  • Step 1: Test: Host mit uuid erzeugen → uuid gesetzt, api_token_ref roundtrip verschlüsselt (DB-Rohwert ≠ plaintext). ProvisioningRun context array-cast. StepEvent append (kein updated_at). RunResource FK.
  • Step 2: pest tests/Unit/ModelsTest.php → FAIL.
  • Step 3: Migrations + Models schreiben (Felder s. Spec §3). uuid via HasUuid-Trait oder booted. StepEvent $timestamps=false + created_at manuell / useCurrent.
  • Step 4: pest tests/Unit/ModelsTest.php → PASS. Voller Lauf grün.
  • Step 5: Commit feat(engine): core provisioning data model + hosts.

Phase 2 — Orchestrator-Kern (test-first, mit Fake-Steps)

Task 2: StepResult

  • Test: advance()->type===ADVANCE; retry(30,'x')->afterSeconds===30; fail('y')->reason==='y'. → FAIL → implement → PASS → Commit.

Task 3: ProvisioningStep-Interface + PipelineRegistry

Produces: PipelineRegistry::stepsFor(string $pipeline): array (Klassennamen), ::resolve($pipeline,$index): ProvisioningStep (aus Container). Registrierung in config/provisioning.php ('pipelines'=>['host'=>[...]]).

  • Test mit 2 Fake-Steps (tests/Fixtures/FakeAdvanceStep, FakeRetryStep): Registry liefert korrekte Klasse je Index; unbekannte Pipeline → Exception.
  • Implement → PASS → Commit.

Task 4: RunRunner (Herzstück)

Consumes: StepResult, ProvisioningStep, PipelineRegistry, Models. Produces: RunRunner::advance(ProvisioningRun $run): void. Verhalten: Lock run:{uuid}; wenn status nicht in (running,waiting,pending) → return; Step auflösen (current_step); Timeout: waiting+started_at+maxDuration<now → als Fehler zählt zur Retry-Politik; execute in try/catch (Exception → wie retry mit Backoff bis max_attempts, dann fail); Event schreiben (outcome, message); StepResult anwenden (advance→current_step++, letzter→completed+finished_at+Host active bleibt Step-Sache; retry→attempt++/next_attempt_at/status=waiting oder fail bei max; fail→failed+error); nach advance (nicht letzter) AdvanceRunJob::dispatch.

  • Step 1: Tests:
    • advance-Step → current_step +1, Event outcome=advanced, Folge-Job dispatched (Queue::fake assertPushed).
    • letzter advance-Step → status=completed, finished_at gesetzt.
    • retry-Step → status=waiting, attempt=1, next_attempt_at≈now+afterSeconds, kein Sofort-Job.
    • retry bis max_attemptsstatus=failed.
    • fail-Step → status=failed, error gesetzt.
    • Exception im Step → behandelt wie retry (kein Fatal-Leak).
    • Lock: zweiter paralleler advance no-op (Lock gehalten) — via Cache::lock gehaltenem Lock simulieren.
  • Implement → alle PASS → Commit.

Task 5: AdvanceRunJob + Tick

Produces: AdvanceRunJob(string $runUuid) (queue provisioning) → RunRunner::advance. Tick in routes/console.php: Schedule::call(fn()=> dispatch alle runs status in (running,waiting) && next_attempt_at<=now)->everyMinute() — als invokable App\Console\TickProvisioning testbar.

  • Test: Tick-Action dispatcht Job nur für fällige Runs (waiting+due), nicht für completed/paused/future. Queue::fake.
  • Implement → PASS → Commit.

Task 6: StepAdvanced Broadcast-Event

Produces: StepAdvanced implements ShouldBroadcast, PrivateChannel('admin.runs') (+ später kunden-scoped). Payload: run uuid, step key, outcome, current_step, status.

  • Test: Event broadcastet auf erwartetem Kanal mit Payload (Event::fake, RunRunner feuert es). Channel-Authz in routes/channels.php (nur is_admin).
  • Implement → PASS → Commit.

Phase 3 — Service-Interfaces + Fakes + reale Impl

Task 7: RemoteShell (Interface, CommandResult, Fake, phpseclib-Impl)

Fake: FakeRemoteShell mit script(string $cmdSubstring, CommandResult|callable), recorded() (ausgeführte Kommandos), Default ok(''). putFile merkt files().

  • Test (Fake): run('proxmox-ve installed?') liefert skript-Antwort; unbekannt → Default ok. recorded() enthält Kommandos. putFile speichert.
  • Implement Fake → PASS. PhpseclibRemoteShell (dünn, kein Unit-Test des echten I/O; nur Klassenexistenz + Konstruktion) → Commit.

Task 8: WireguardHub (Interface, Fake, LocalWireguardHub)

Fake: FakeWireguardHub (allocateIp inkrementell aus Config-Pool, peers()-Liste, endpoint/publicKey fest).

  • Test (Fake): allocateIp() vergibt fortlaufend; addPeer erscheint in peers(); removePeer entfernt.
  • Implement Fake + LocalWireguardHub (schreibt via RemoteShell auf localhost / config-Pfad — Watch-Item, dünn) → Commit.

Task 9: ProxmoxClient (Interface, Fake, HttpProxmoxClient)

Fake: FakeProxmoxClient (forHost merkt Host; listNodes konfigurierbar; nodeStatus/nodeStorage liefern feste Kapazität; createUserAndToken liefert ['token_id'=>'automation@pve!clupilot','secret'=>'s3cr3t'], merkt createdToken()).

  • Test (Fake): Kapazitäts-/Token-Rückgaben; forHost immutability (neue Instanz je Host ok).
  • Implement Fake + HttpProxmoxClient (Laravel Http mit Token-Header, withoutVerifying für self-signed über WG — dünn) → Commit.

Binding: AppServiceProvider bindet Interfaces → reale Impl in prod, Fakes in Tests via $this->app->instance(...) (Test-Setup-Helper bindFakeServices() in tests/Pest.php).


Phase 4 — Host-Steps 14

Jeder Step: Test-first gegen Fakes. Muster je Step: (a) Idempotenz (zweiter Lauf → keine Doppel-Ressource, advance), (b) Happy-Path advance, (c) Fehler → retry/fail korrekt, (d) externe ID [E] vor advance in run_resources.

Task 10: ValidateHostInput

Prüft context.root_password vorhanden, Host hat public_ip+datacenter, TCP:22 (über RemoteShell-Connect-Versuch oder simplen Port-Check via Fake). Ungültig → fail. Host→onboarding.

  • Tests: fehlendes Passwort→fail; gültig→advance + Host status onboarding. Implement → PASS → Commit.

Task 11: EstablishSshTrust

connectWithPassword; putFile ~/.ssh/authorized_keys (append CluPilot pubkey aus config services.clupilot.ssh_public_key); hostKeyFingerprinthost.ssh_host_key [E]; connectWithKey verifizieren; context.root_password entfernen (unset + save). Idempotenz: key-login ok → skip password-Phase.

  • Tests: happy→advance, ssh_host_key gesetzt, context hat kein root_password mehr; key schon vorhanden→advance ohne Passwort-Connect; connect-Fehler→retry. Implement → PASS → Commit.

Task 12: PrepareBaseSystem

Kommandos: hostname/FQDN, /etc/hosts, apt-get update, apt-get install -y curl gnupg ifupdown2 chrony. Fehlerhafter apt-exit → retry. Idempotent (Kommandos sind es).

  • Tests: happy→advance (recorded enthält apt-get update); apt-Fehler→retry. Implement → PASS → Commit.

Task 13: ConfigureWireguard

Hub: allocateIp (wenn host.wg_ip leer), Host-Keypair via wg genkey (RemoteShell), Pubkey lesen, addPeer(pubkey, wg_ip), wg0.conf putFile, wg-quick up, Handshake-Verify (ping -c1 hub-wg-ip ok). host.wg_ip+wg_pubkey [E], run_resources kind=wg_peer external_id=pubkey. Idempotent: wg_ip schon gesetzt + Peer existiert → advance.

  • Tests: happy→advance, wg_ip+wg_pubkey gesetzt, run_resource wg_peer genau einmal bei Doppellauf; ping-Fehler→retry. Implement → PASS → Commit.

Phase 5 — Host-Steps 57

Task 14: InstallProxmoxVe

Idempotenz: dpkg -l proxmox-ve ok → advance. Sonst: PVE-Repo + Key (putFile /etc/apt/sources.list.d/pve.list + key), Enterprise-Repo deaktivieren, apt-get update, apt-get -y full-upgrade, apt-get -y install proxmox-ve postfix open-iscsi. maxDuration groß (z.B. 1800). apt-Netzfehler→retry; Repo/Key-Fehler→fail.

  • Tests: bereits installiert→advance (kein install-Kommando); frisch→advance (recorded enthält install); apt-Fehler→retry. Implement → PASS → Commit.

Task 15: RebootIntoPveKernel

Wenn context.reboot_issued fehlt: apt-get -y remove linux-image-amd64, update-grub, reboot, context.reboot_issued=true, retry(30,'reboot'). Sonst Poll: connectWithKey + uname -r endet auf -pve → advance; sonst retry(15,'waiting for pve kernel'); Deadline (context.reboot_deadline) überschritten → fail.

  • Tests: erste Ausführung→retry + reboot_issued gesetzt + recorded enthält reboot; Poll nicht-pve→retry; Poll pve→advance; Deadline über→fail. Implement → PASS → Commit.

Task 16: ConfigureProxmox

vmbr0 sicherstellen (Check ip link show vmbr0; fehlt → minimal anlegen), Datacenter-Firewall-Baseline, No-Subscription-Nag (optional, best-effort ok). Idempotent.

  • Tests: happy→advance; vorhandene Bridge→kein Neu-Anlegen. Implement → PASS → Commit.

Phase 6 — Host-Steps 811

Task 17: CreateAutomationToken

Idempotenz: run_resources kind=pve_token vorhanden + host.api_token_ref gesetzt → advance. Sonst ProxmoxClient::forHost($host)->createRole('CluPilotAutomation', <privs>) (idempotent/ignore-exists), createUserAndToken('automation@pve','CluPilotAutomation')['token_id','secret']host.api_token_ref = token_id . '=' . secret (encrypted cast) vor advance, run_resources kind=pve_token external_id=token_id. Crash-vor-Persistenz: Token-Marker fehlt → neu (alte via delete best-effort).

  • Tests: happy→advance, api_token_ref gesetzt (entschlüsselt enthält secret), run_resource genau einmal bei Doppellauf; bereits vorhanden→advance ohne createUserAndToken-Call. Implement → PASS → Commit.

Task 18: VerifyProxmoxApi

ProxmoxClient::forHost($host)->listNodes() nicht leer → advance; leer/Fehler → retry (kurze Deadline) dann fail.

  • Tests: nodes→advance; leer→retry. Implement → PASS → Commit.

Task 19: RegisterCapacity

nodeStatus+nodeStoragehost.cpu_cores, total_ram_mb, total_gb, cpu_weight (=cores), pve_version, last_seen_at=now. Idempotent (überschreibt).

  • Tests: Kapazität geschrieben aus Fake-Werten. Implement → PASS → Commit.

Task 20: CompleteHostOnboarding

host.status='active'; letzter Step (advance → Run completed via Runner).

  • Tests: host active; Run über Runner→completed. Implement → PASS → Commit.

Task 21: End-to-End (gemockt) durch die ganze Pipeline

StartHostOnboarding::run($input) legt Host+Run an, dispatcht; dann alle Ticks/Jobs synchron abarbeiten (Queue::fake aus, sync driver in Test) bis completed. Assert: Host active, Kapazität gesetzt, run_resources (wg_peer, pve_token) je einmal, keine root_password-Reste. Plus Crash-Idempotenz-Test: Run nach Step X abbrechen, erneut ticken → keine Doppel-Ressource.

  • Tests → PASS → Commit.

Phase 7 — Admin-UI + Anbindung

Task 22: StartHostOnboarding-Action + Route/Livewire HostCreate

Produces: StartHostOnboarding::run(array $input): Host. Admin\HostCreate Livewire-Formular (name, datacenter select, public_ip, root_password) → Validierung → Action → redirect /admin/hosts/{uuid}. Route /admin/hosts/create (name admin.hosts.create).

  • Tests: Gast→login, Nicht-Admin→403, Admin submit→Host+Run angelegt, dispatch (Queue::fake assertPushed AdvanceRunJob), redirect. Implement → PASS → Commit.

Task 23: Hosts-Liste echt

Admin\Hosts Fixture→Host::all() mit Status-Pills (Farbe), Datacenter, Kapazität frei/total, Link je Host (uuid), „Host hinzufügen"-Button. lang/{de,en}/hosts.php.

  • Tests: rendert echte Hosts; Gate. R16 Keys DE+EN. Implement → PASS → Commit.

Task 24: HostDetail + Live-Stepper (Reverb)

Admin\HostDetail (route /admin/hosts/{uuid}, name admin.hosts.show): lädt Host+aktiven Run+Events, baut steps[] (label aus i18n, state aus current_step/status) für x-ui.progress-stepper; Livewire-Listener auf Reverb StepAdvanced (#[On('echo-private:admin.runs,StepAdvanced')]) → $refresh. Fehler-Panel + „Erneut versuchen" (Action: run status=running/next_attempt_at=now/dispatch). „Host entfernen" (wire-elements/modal → delete record, R5).

  • Tests: rendert Stepper aus Run-State; Retry-Action setzt Run + dispatch; Delete entfernt Host (kein Server-Wipe); Gate. Implement → PASS → Commit.

Phase 8 — Verifikation

Task 25: Lokalisierung, R12, R15

  • DE+EN vollständig, identische Keys (hosts.php, dashboard.step_state.* ggf. erweitern für Host-Steps → eigener hosts.step.*-Namespace).
  • Voller Pest-Lauf grün.
  • R12: npm run build → Vite stop + rm public/hot → Puppeteer über /admin/hosts, /admin/hosts/create, /admin/hosts/{uuid} (authed admin) → HTTP 200 + 0 Konsolenfehler → docker compose restart app.
  • R15: Codex-Review --scope working-tree --background, Loop bis „no actionable regressions".
  • Seeder: 12 Demo-Hosts (active) für lokale Ansicht, gated local/testing.
  • Commit(s), push (Token inline).

Self-Review

Spec coverage: §3 Datenmodell→T1; §4 Kern→T2-6; §5 Steps 1-11→T10-20; §6 Services→T7-9; §7 Admin-UI→T22-24; §8 Fehler/Rollback→T24 (Retry/Delete) + Runner (T4); §9 Tests→jede Task test-first + T21/T25; §11 Watch-Items→in Tasks vermerkt (WG-Schreibweg T8, Kernel/Reboot T15, Deadlines T15/18); §12 DoD→T21+T25. Vollständig abgedeckt. Placeholder-Scan: keine TBD/„handle edge cases" — jede Task hat konkrete Bedingungen + Tests. Type-Konsistenz: StepResult::advance/retry/fail, ProvisioningStep::execute/key/label/maxDuration, RemoteShell::run/putFile, WireguardHub::allocateIp/addPeer, ProxmoxClient::forHost/createUserAndToken durchgängig identisch verwendet.