# 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 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`=`<vm-ip>: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.
