# 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++`, 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_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 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', <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`+`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.
