# 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:** Gelöst — der `queue-provisioning`-Worker-Container ist der WG-Hub (`cap_add: NET_ADMIN`, `/dev/net/tun`, persistentes `wireguard`-Volume, UDP-Port veröffentlicht). `LocalWireguardHub` führt `wg set wg0` dort aus. Vor echtem Betrieb: `CLUPILOT_WG_ENDPOINT`=`:51820` und `CLUPILOT_WG_HUB_PUBKEY`=wg0-Key setzen, wg0 im Container hochfahren. 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. 7. **Langlaufende Schritte:** `InstallProxmoxVe` blockiert einen Worker bis zu 1800 s synchron. v1.0 löst das über eine dedizierte `provisioning`-Queue (eigener Worker, `--timeout=2100`, `retry_after=2400`, Run-Lock 2100 s). v1.1-Verbesserung: lange Kommandos in resumierbare Teil-Jobs zerlegen statt synchron zu blockieren. --- ## 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.