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

280 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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.<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++`, letztercompleted+finished_at+Host active bleibt Step-Sache; retryattempt++/next_attempt_at/status=waiting oder fail bei max; failfailed+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_attempts` `status=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 Passwortfail; gültigadvance + 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: happyadvance, `ssh_host_key` gesetzt, `context` hat **kein** `root_password` mehr; key schon vorhandenadvance ohne Passwort-Connect; connect-Fehlerretry. 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: happyadvance (recorded enthält apt-get update); apt-Fehlerretry. 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: happyadvance, wg_ip+wg_pubkey gesetzt, run_resource `wg_peer` genau **einmal** bei Doppellauf; ping-Fehlerretry. 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-Netzfehlerretry; Repo/Key-Fehlerfail.
- [ ] Tests: bereits installiertadvance (kein install-Kommando); frischadvance (recorded enthält install); apt-Fehlerretry. 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ührungretry + reboot_issued gesetzt + recorded enthält reboot; Poll nicht-pveretry; Poll pveadvance; Deadline überfail. 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: happyadvance; vorhandene Bridgekein 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: happyadvance, api_token_ref gesetzt (entschlüsselt enthält secret), run_resource genau einmal bei Doppellauf; bereits vorhandenadvance ohne createUserAndToken-Call. Implement PASS Commit.
### Task 18: VerifyProxmoxApi
`ProxmoxClient::forHost($host)->listNodes()` nicht leer advance; leer/Fehler retry (kurze Deadline) dann fail.
- [ ] Tests: nodesadvance; leerretry. 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 Runnercompleted. 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: Gastlogin, Nicht-Admin403, Admin submitHost+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 DatenmodellT1; §4 KernT2-6; §5 Steps 1-11T10-20; §6 ServicesT7-9; §7 Admin-UIT22-24; §8 Fehler/RollbackT24 (Retry/Delete) + Runner (T4); §9 Testsjede Task test-first + T21/T25; §11 Watch-Itemsin Tasks vermerkt (WG-Schreibweg T8, Kernel/Reboot T15, Deadlines T15/18); §12 DoDT21+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.