CluPilotCloud/docs/superpowers/specs/2026-07-25-host-onboarding-...

237 lines
18 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.

# 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!<id>=<secret>` |
| `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!<id>=<secret>`, 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 23 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 14 (Validate, SshTrust, PrepareBase, Wireguard).
5. Host-Steps 57 (InstallPve, Reboot, ConfigureProxmox).
6. Host-Steps 811 (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.
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.