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

18 KiB
Raw Blame History

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_atkein 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

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_attemptsfail. 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 <= nowAdvanceRunJob 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. ConfigureWireguardHub (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_pubkeyhosts [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. ConfigureProxmoxvmbr0-Bridge sicherstellen (falls nicht vorhanden), Datacenter-Baseline-Firewall, No-Subscription-Nag entschärfen (optional), lokale VM-Storage bestätigen. Idempotent.
  8. CreateAutomationTokenautomation@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. VerifyProxmoxApiProxmoxClient ü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. CompleteHostOnboardinghosts.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 Runstatus=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: 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.