diff --git a/docs/superpowers/plans/2026-07-25-host-onboarding.md b/docs/superpowers/plans/2026-07-25-host-onboarding.md new file mode 100644 index 0000000..9efffa0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-host-onboarding.md @@ -0,0 +1,279 @@ +# 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) + +```php +// 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.' + 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` 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 1–4 + +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`); `hostKeyFingerprint`→`host.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 5–7 + +### 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 8–11 + +### Task 17: CreateAutomationToken +Idempotenz: `run_resources kind=pve_token` vorhanden + `host.api_token_ref` gesetzt → advance. Sonst `ProxmoxClient::forHost($host)->createRole('CluPilotAutomation', )` (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`+`nodeStorage` → `host.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: 1–2 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.