diff --git a/docs/superpowers/specs/2026-07-25-host-onboarding-design.md b/docs/superpowers/specs/2026-07-25-host-onboarding-design.md new file mode 100644 index 0000000..ec5d60e --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-host-onboarding-design.md @@ -0,0 +1,235 @@ +# Design-Spec — Subsystem A: Host-Onboarding / Proxmox-Bootstrap (v1.0) + +**Datum:** 2026-07-25 +**Art:** Feature-Design (Brainstorming → Spec) +**Kontext-Dokumente:** `docs/handoffs/2026-07-25-clupilot-state-handoff.md` (Zustand/Workflow/Regeln), `docs/handoffs/2026-07-25-clupilot-engine-v1.0-handoff.md` (Subsystem B, teilt das Fundament). + +--- + +## 1. Ziel & Abgrenzung + +**Ziel:** Ein Admin trägt im Panel einen **frischen Server** (Debian, nur Root-SSH) ein. CluPilot bringt ihn **vollautomatisch** in Betrieb: SSH-Vertrauen → WireGuard-Tunnel zum Hub (CluPilot-VM) → Proxmox VE installieren → Reboot in PVE-Kernel → Proxmox-Grundkonfiguration → `automation@pve`-API-Token erzeugen → Kapazität registrieren → Host `active`. Danach ist der Host für die **Kunden-Provisionierung (Subsystem B)** platzierbar. + +**A endet, wo B anfängt:** A installiert per **SSH** und übergibt am Ende einen Host mit `wg_ip` + `api_token_ref` + Kapazität. B spricht diesen Host per **Proxmox-API** an. + +### Entscheidungen (fix, aus Brainstorming) +- **Ausgangszustand:** frisches Debian + Root-SSH (kein Bare-Metal-PXE, kein vorinstalliertes Proxmox). +- **Root-Zugang:** einmaliges **Root-Passwort** im Formular. A deployt sofort CluPilots SSH-Key, danach key-only. Passwort wird **nie dauerhaft** gespeichert (verschlüsselt im Run-Context, nach `EstablishSshTrust` gescrubbt). +- **Topologie:** **Standalone-Flotte** (Laufzeit). `hosts.datacenter` + nullable `hosts.cluster`. Cluster-pro-RZ ist Wachstumsziel (v1.1+), Modell verbaut es nicht. +- **Management-Netz:** **CluPilot-VM = WireGuard-Hub.** A legt Peer auf dem Hub an, richtet WG auf dem Host ein, Proxmox-API läuft nur über `wg_ip:8006` (Port nie öffentlich). +- **Tests:** voll **gemockt** (SSH/WG/Proxmox als Fake-Doubles), konsistent mit B. Echter End-to-End-Lauf gegen einen realen Server ist ein späterer, separater Schritt. +- **Architektur:** **Ansatz ①** — gemeinsamer DB-State-Machine + Tick-Orchestrator-Kern (kein Monolith-Job, kein Ansible). A ist `pipeline=host` auf demselben Kern, den B erbt. + +### Explizit NICHT in A v1.0 (YAGNI) +- Hetzner-Robot-Bestellautomatik (Server manuell bereitgestellt). +- Bare-Metal-Autoinstall (IPMI/PXE/ISO). +- Echte corosync-Cluster-Bildung, Live-Migration, HA, Rebalancing. +- Automatisches Wipe/Reinstall eines Hosts. Entfernen = **nur Deregistrieren** in CluPilot (physischer Server bleibt unangetastet). +- ZFS/Ceph-Storage-Setup (v1.0 nutzt die von der Debian-Installation gelieferte lokale Storage; ZFS-für-Replikation ist v1.1, ggf. Disk-Layout beim Server-Bestellen). + +--- + +## 2. Architektur-Überblick + +``` +Admin-UI (Livewire, /admin/hosts) + │ "Host hinzufügen" → Host(status=pending) + ProvisioningRun(pipeline=host) + ▼ +Orchestrator-Kern (gemeinsam mit B) + ProvisioningRun ──(Tick/Sofort-Dispatch)──▶ AdvanceRunJob ──▶ RunRunner + │ Per-Run-Lock, Step-Auflösung, StepResult (advance/retry/fail), Timeout + ▼ + Host-Pipeline: 11 idempotente Step-Klassen (App\Provisioning\Steps\Host\*) + │ nutzen RemoteShell (SSH), WireguardHub, ProxmoxClient (Read) + ▼ + provisioning_step_events (append-only) ──Reverb──▶ Live-Stepper (Admin) + run_resources (externe IDs) · hosts (Ergebnis: active + Kapazität) +``` + +**Isolationsgrenzen (jede Unit eine Verantwortung):** +- **Orchestrator-Kern** weiß nichts über Hosts/Proxmox — nur über Runs, Steps, Results, Events. Wiederverwendbar für B. +- **Step-Klassen** kennen nur ihren einen Schritt + die Service-Interfaces (RemoteShell/WireguardHub/ProxmoxClient), nie einander. +- **Services** (RemoteShell, WireguardHub, ProxmoxClient) kapseln I/O hinter Interfaces → in Tests durch Fakes ersetzbar. + +--- + +## 3. Datenmodell (Migrations + Models) + +Gemeinsames Fundament (A baut es, B erbt es). Reihenfolge: runs/events/resources → hosts. + +### `hosts` (R11: UUID-adressierbar) +| Feld | Typ | Zweck | +|---|---|---| +| `id` | bigint PK | intern | +| `uuid` | uuid, unique | URL/Adressierung | +| `name` | string | z. B. `pve-fsn-4` | +| `datacenter` | string | `fsn`, `hel` … (Placement-Filter) | +| `cluster` | string, **nullable** | v1.0 immer `null` (Wachstumsziel) | +| `public_ip` | string | Ersteinwahl per SSH | +| `wg_ip` | string, nullable | Mgmt-Adresse (Proxmox-API) | +| `wg_pubkey` | string, nullable | WG-Peer-Key des Hosts | +| `ssh_host_key` | text, nullable | Fingerprint-Pinning (known_hosts) | +| `api_token_ref` | text, nullable, **encrypted** | `automation@pve!=` | +| `total_gb` | int, nullable | Storage-Kapazität (nach RegisterCapacity) | +| `total_ram_mb` | int, nullable | RAM | +| `cpu_cores` | int, nullable | Kerne | +| `cpu_weight` | int, nullable | relative Platzierungs-Gewichtung | +| `reserve_pct` | int, default 15 | Kapazitäts-Reserve (Placement-Abzug) | +| `pve_version` | string, nullable | dokumentarisch | +| `status` | string | `pending, onboarding, active, error, disabled` | +| `last_seen_at` | timestamp, nullable | letzter API-Kontakt | +| timestamps | | | + +**Kapazität = berechnet** (v1.0 keine Instanzen → frei = `total_gb − reserve`). Placement (B) filtert später auf `datacenter` + `status='active'` + freie Zusage; `cluster` ignoriert die Query in v1.0. + +### `provisioning_runs` (polymorph, für A **und** B) +`id, uuid, subject_type, subject_id, pipeline (string), current_step (int), status, attempt, max_attempts, next_attempt_at, started_at, finished_at, error (text null), context (json), timestamps` +- `status`: `pending → running ⇄ waiting → completed`, plus `paused` (Admin), `failed` (Retries erschöpft/fatal). +- `subject` = Host (A) bzw. Order/Customer (B). +- `context`: json (bei A z. B. verschlüsseltes Root-Passwort **transient**, WG-Zuweisung, Marker). + +### `provisioning_step_events` (append-only) +`id, run_id, step (string), attempt, outcome (advanced|retry|failed|info), message, external_ref (null), created_at` — **kein** `updated_at`. + +### `run_resources` (Idempotenz-Brotkrumen) +`id, run_id, host_id (null), kind, external_id, created_at` — z. B. `kind=wg_peer`, `kind=pve_token`. + +--- + +## 4. Orchestrator-Kern (Herzstück, test-first) + +### Interfaces / Werte +```php +interface ProvisioningStep { + public function key(): string; // stabiler Step-Name für Events/Idempotenz + public function label(): string; // lokalisierbarer Anzeigename (i18n-Key) + public function maxDuration(): int; // Sekunden bis Timeout + public function execute(ProvisioningRun $run): StepResult; +} + +final class StepResult { + // ::advance() | ::retry(int $afterSeconds, string $reason) | ::fail(string $reason) +} +``` + +### PipelineRegistry +Map `pipeline-name → [StepClass, …]`. `host` → die 11 Host-Steps. `customer` → (B, später). Aufgelöst via Container. + +### RunRunner (eine Advance-Ausführung) +1. `Cache::lock("run:{uuid}")` (nie zwei Worker auf einem Run). +2. Aktuellen Step auflösen (`current_step` → Klasse). +3. **Timeout-Check:** `started_at + step.maxDuration` überschritten → als Fehler behandeln (Step entscheidet Retry/Fail-Politik über Attempt-Zählung). +4. `execute($run)` → `StepResult`. +5. Event in `provisioning_step_events` schreiben (append-only) + Reverb broadcast. +6. Result anwenden: + - **advance:** `current_step++`; letzter Step → `status=completed`, `finished_at`. Sonst **Sofort-Dispatch** `AdvanceRunJob`. + - **retry:** `attempt++`; `attempt >= max_attempts` → `fail`. Sonst `status=waiting`, `next_attempt_at = now + afterSeconds`. + - **fail:** `status=failed`, `error` setzen. +7. Lock freigeben. + +**Runner-Pflichten (aus B-Handoff, gelten für A):** +- **Idempotenz-Check zuerst** in jedem Step (externe ID / DB-State „schon getan?"). +- **Externe IDs sofort** in `run_resources` **bevor** weitergemacht wird. + +### Advancement +- **AdvanceRunJob** (Queue `provisioning`) → ruft RunRunner. +- **Tick** (`routes/console.php` Schedule, **jede Minute**, im `scheduler`-Service): Runs `status IN (running,waiting) AND next_attempt_at <= now` → `AdvanceRunJob` dispatchen. +- **Sofort-Dispatch** bei Run-Anlage + nach jedem `advance`. + +--- + +## 5. Host-Pipeline — 11 Schritte + +Namespace `App\Provisioning\Steps\Host\`. `[E]` = externe ID sofort persistieren, `[P]` = Polling, `[F]` = Fail (nicht retrybar). + +1. **ValidateHostInput** — Pflichtfelder (IP-Format, `datacenter`, Root-Passwort im Context vorhanden), TCP:22 erreichbar. Ungültig → `[F]`. +2. **EstablishSshTrust** — Erst-Login mit Root-Passwort → CluPilots Public-Key in `/root/.ssh/authorized_keys`, Host-Key erfassen (`ssh_host_key` `[E]`), Key-Login verifizieren. Danach **Passwort aus `context` scrubben**. Idempotent: Key schon vorhanden + Key-Login ok → skip. +3. **PrepareBaseSystem** — FQDN/Hostname setzen, `/etc/hosts` (FQDN → eigene IP, Proxmox-Pflicht), `apt update`, Basis-Pakete (`curl gnupg ifupdown2 chrony`). Idempotent. +4. **ConfigureWireguard** — **Hub (CluPilot-VM):** `wg_ip` aus Mgmt-Subnetz vergeben, Peer (Host-Pubkey) hinzufügen, Hub reloaden. **Host:** `wireguard` installieren, Keypair erzeugen, `wg0.conf` schreiben (Address=`wg_ip`, Peer=Hub-Pubkey+Endpoint, AllowedIPs), enable+start, **Handshake verifizieren** (Ping Hub-`wg_ip`). `wg_ip`+`wg_pubkey` → `hosts` `[E]`, `run_resources kind=wg_peer`. Idempotent (Peer/Handshake vorhanden → skip). +5. **InstallProxmoxVe** — PVE-Repo (`bookworm pve-no-subscription`) + GPG-Key, Enterprise-Repo deaktivieren, `apt update`, `apt full-upgrade -y`, `apt install -y proxmox-ve postfix open-iscsi`. Langläufer → großzügiges `maxDuration`. Idempotent (`proxmox-ve` installiert → skip). Proxmox-5xx/Netz = Retry; „Repo/Key ungültig" = Fail. +6. **RebootIntoPveKernel** — falls noch nicht rebootet (Marker in `context`): Debian-Kernel entfernen (`linux-image-amd64`), `update-grub`, **reboot** auslösen, Marker setzen, `retry(afterSeconds, "reboot")`. Folge-Ausführungen `[P]`: SSH zurück + `pveversion` liefert PVE-Kernel (`*-pve`) → advance. Deadline großzügig; überschritten → Fail. Idempotent (schon auf PVE-Kernel → advance). +7. **ConfigureProxmox** — `vmbr0`-Bridge sicherstellen (falls nicht vorhanden), Datacenter-Baseline-Firewall, No-Subscription-Nag entschärfen (optional), lokale VM-Storage bestätigen. Idempotent. +8. **CreateAutomationToken** — `automation@pve`-User (PVE-Realm) + Minimal-Rolle (VM.Allocate/Config/PowerMgmt, Datastore.AllocateSpace, Sys.Audit …), API-Token erzeugen, Secret **einmalig** erfassen → `hosts.api_token_ref` **verschlüsselt** `[E]`, `run_resources kind=pve_token`. Idempotenz via Marker: existiert Token-Marker + persistiert → skip; Crash vor Persistenz → Token löschen+neu (Secret ist nur bei Erzeugung sichtbar). +9. **VerifyProxmoxApi** — `ProxmoxClient` über `wg_ip:8006` + Token → `GET /nodes` → erreichbar + autorisiert. `[P]` (kurze Deadline). Fehlschlag → Retry, dann Fail. +10. **RegisterCapacity** — Node-Ressourcen via API (Kerne, RAM, Storage der VM-Storage) → `hosts.total_gb/total_ram_mb/cpu_cores/cpu_weight`, `pve_version`, `last_seen_at`. Idempotent (überschreibt). +11. **CompleteHostOnboarding** — `hosts.status=active` → platzierbar. Abschluss-Event. Run → `completed`. + +**Reboot-Modellierung (der Knackpunkt):** Der Reboot ist kein Sonderfall im Runner, sondern Step 6 gibt `retry()` zurück und pollt beim nächsten Tick, bis der Host im PVE-Kernel antwortet. Die State-Machine überlebt den Reboot **per Definition** — genau der Grund für Ansatz ①. + +--- + +## 6. Services (I/O hinter Interfaces, Fakes in Tests) + +- **`App\Services\Ssh\RemoteShell`** (Interface): `connectWithPassword`, `connectWithKey`, `run(cmd): CommandResult{exitCode,stdout,stderr}`, `putFile`, `hostKeyFingerprint`. Impl `PhpseclibRemoteShell` (phpseclib3). Test-Double `FakeRemoteShell` (scripted). +- **`App\Services\Wireguard\WireguardHub`** (Interface): `allocateIp(): string`, `addPeer(pubkey, ip)`, `removePeer(pubkey)`, `reload`. Impl schreibt Hub-Config auf der VM (**Watch-Item**: Schreibweg aus dem Container). Test-Double `FakeWireguardHub`. +- **`App\Services\Proxmox\ProxmoxClient`** — REST `/api2/json`, Token-Auth `PVEAPIToken=automation@pve!=`, Basis aus `hosts.wg_ip`. **A v1.0 Read-Methoden:** `listNodes`, `nodeStatus`, `nodeStorage`, `createUserAndToken`, `createRole`. (B erweitert um `cloneVm`, `setCloudInit`, `guestExec`, `getTaskStatus` …). Test-Double `FakeProxmoxClient`. + +**Secrets:** Root-Passwort (transient, verschlüsselt, gescrubbt), CluPilot-SSH-Privatekey + WG-Hub-Privatekey aus `.env`/Config, `api_token_ref` `Crypt`-verschlüsselt. **Nie in Git.** + +--- + +## 7. Admin-UI (R1/R2/R3/R5/R7/R9/R13/R16) + +- **`/admin/hosts`** (bestehende `Admin\Hosts`, Fixture → echt): Host-Liste mit Status-Pills (Farbe/Dots, **keine Emoji** R9), Datacenter, Kapazität (frei/total), „Host hinzufügen"-Button. Dunkles Token-Theme (bestehend). +- **Host hinzufügen** (Formular; Anlegen ist nicht destruktiv → Full-page oder Panel, kein Modal-Zwang): `name`, `datacenter` (Select), `public_ip`, `root_password` (Passwort-Feld, transient). Submit → `Host(status=pending)` + `ProvisioningRun(pipeline=host, subject=Host, context={root_password: Crypt::encrypt(...)})` + **Sofort-Dispatch**. R11 UUID in URL. +- **`/admin/hosts/{uuid}`** (neu): Live-**Stepper** (`x-ui.progress-stepper`, `state: pending|running|done|failed`) an `provisioning_step_events` gebunden, **live via Reverb** (privater, authentifizierter Admin-Kanal). Zeigt aktuellen Schritt, Event-Log, Kapazität (wenn active), Fehler + **„Erneut versuchen"** (setzt `status=running`, `next_attempt_at=now`, Dispatch — idempotente Steps machen Retry sicher). +- **Host entfernen** (destruktiv → **wire-elements/modal**-Bestätigung, R5): löscht nur den CluPilot-Record; **kein** Server-Wipe (klar kommuniziert). + +Routen englisch (R13). Alle Texte DE+EN identische Keys (R16). Nur Token-Utilities (R3), keine Inline-Styles außer Progress-`width` (R4). + +--- + +## 8. Fehler & Rollback + +- **Fehlgeschlagener Run** → `status=failed`, Host `status=error`, Fehler im Panel sichtbar, **manuelles** „Erneut versuchen" (kein Auto-Reprovision). Idempotente Steps → Retry nimmt beim gescheiterten Schritt wieder auf, ohne Doppel-Ressourcen. +- **Kein automatisches Wipe.** Ein halb-installierter Host bleibt physisch wie er ist; Admin kann retryen oder den Record entfernen und den Server manuell neu aufsetzen. +- **Per-Run-Lock** verhindert Doppelausführung; **externe IDs vor Weitergabe persistiert** → Crash-sicher (Test simuliert Crash nach Persistenz). + +--- + +## 9. Tests (voll gemockt, TDD) + +- **Orchestrator-Kern:** `StepResult`; RunRunner advance/retry/fail/timeout/Lock; `AdvanceRunJob`; Tick-Query — mit 2–3 Fake-Steps. +- **Jeder Host-Step** gegen `FakeRemoteShell`/`FakeWireguardHub`/`FakeProxmoxClient`: Idempotenz (zweimal ausführen → keine Doppel-Ressource), externe-ID-Persistenz **vor** advance, Fail-vs-Retry-Klassifizierung. +- **RebootIntoPveKernel:** Host-nicht-zurück → retry; Host-zurück-auf-PVE → advance. +- **EstablishSshTrust:** Passwort nach Erfolg aus `context` entfernt. +- **Admin:** Gate (Gast→login, Nicht-Admin→403, Admin→200); „Host hinzufügen" legt Host+Run an und dispatcht; Liste rendert echte Daten; Retry-Action; Entfernen-Bestätigung. +- **R12 Browser (0 Konsolenfehler):** `/admin/hosts` + `/admin/hosts/{uuid}`. +- **R15 Codex-Review clean** vor „fertig". **Keine Secrets im Repo.** + +--- + +## 10. Build-Reihenfolge (jede Phase grün) + +1. Migrations + Models (`hosts`, `provisioning_runs`, `provisioning_step_events`, `run_resources`). +2. Orchestrator-Kern test-first (`StepResult`, `ProvisioningStep`, `PipelineRegistry`, `RunRunner`, `AdvanceRunJob`, Tick) mit Fake-Steps. +3. Service-Interfaces + Fakes (`RemoteShell`, `WireguardHub`, `ProxmoxClient`) + reale Impl (phpseclib/HTTP), Impl-Details gegen Fakes gespiegelt. +4. Host-Steps 1–4 (Validate, SshTrust, PrepareBase, Wireguard). +5. Host-Steps 5–7 (InstallPve, Reboot, ConfigureProxmox). +6. Host-Steps 8–11 (Token, VerifyApi, RegisterCapacity, Complete). +7. Admin-UI: Hosts-Liste echt + „Host hinzufügen" + Host-Detail-Stepper + Reverb + Retry/Entfernen. +8. Lokalisierung DE+EN, R12 Browser, R15 Codex, Commit(s). + +--- + +## 11. Watch-Items + +1. **WG-Hub-Schreibweg aus dem Container:** App läuft im Docker-`app`-Container, der WG-Hub auf der VM. Realen Schreib-/Reload-Weg festlegen (gemounteter Config-Pfad + `wg syncconf`, oder Hub-Service). v1.0 hinter `WireguardHub` abstrahiert; reale Verdrahtung markiert. +2. **Debian-Kernel-Entfernung** darf den Host nicht bricken — dokumentierte Befehlssequenz, **echte** Verifikation nötig (mocked baut die Sequenz nach). +3. **Reboot-Polling-Deadlines** großzügig (Reboot + Kernelwechsel). +4. **`apt full-upgrade`-Dauer** → großzügiges Step-Timeout. +5. **Token-Idempotenz:** Secret nur bei Erzeugung sichtbar → Crash-vor-Persistenz muss Token löschen+neu. +6. **Regeln-Gotchas** (State-Handoff §6): `@js()` in Blade-Komponenten, Test-Isolation (sqlite `:memory:`), CSRF in Tests, `@verbatim`, Locale, Vite-Reload. + +--- + +## 12. Definition of Done (A v1.0) + +- [ ] „Host hinzufügen" erzeugt Host+Run, der **ohne manuelles Zutun** (gemockt) bis `completed`/`active` läuft. +- [ ] Jeder Step idempotent: künstlicher Abbruch + erneuter Tick → keine Doppel-Ressourcen (`run_resources` verifiziert). +- [ ] Reboot-Step überlebt (Poll bis PVE-Kernel), Retry+Backoff+Timeout+Per-Run-Lock getestet. +- [ ] Externe IDs (wg_peer, pve_token) **vor** Weitergabe persistiert (Crash-Test). +- [ ] Root-Passwort nach SshTrust aus Context entfernt; `api_token_ref` verschlüsselt; keine Secrets im Repo. +- [ ] Admin-Liste + Host-Detail live (Reverb); Retry + Entfernen funktionieren. +- [ ] `datacenter` gesetzt, `cluster` nullable/ungenutzt. +- [ ] Pest grün; R12 (0 Konsolenfehler); R15 Codex clean; DE+EN vollständig.