From 5e6d6b11b6e4db81eb037badc24fb86eb3e4cac0 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 11:27:28 +0200 Subject: [PATCH] docs: engine customer-pipeline spec (the missing Section 3, aligned to the built core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed build contract for Subsystem B (15 customer-provisioning steps), written against the real Subsystem-A contracts: ProvisioningStep/StepResult (advance/retry/poll/fail), RunResource idempotency breadcrumbs, ProvisioningRun context, the config/provisioning.php pipeline registry, and the read-only ProxmoxClient it must extend. Covers new models (customers/orders/instances/…), ProxmoxClient VM-lifecycle additions, Hetzner-DNS + Traefik services, Stripe idempotent intake, live progress binding to the existing admin/customer views, build order and DoD. Unblocks B. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-25-engine-customer-pipeline.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/specs/2026-07-25-engine-customer-pipeline.md diff --git a/docs/specs/2026-07-25-engine-customer-pipeline.md b/docs/specs/2026-07-25-engine-customer-pipeline.md new file mode 100644 index 0000000..40b6ae2 --- /dev/null +++ b/docs/specs/2026-07-25-engine-customer-pipeline.md @@ -0,0 +1,157 @@ +# Engine-Design-Spec — Subsystem B: Kunden-Provisionierung (die 15 Schritte) + +**Erstellt:** 2026-07-25 · **Status:** Bau-Vertrag für Subsystem B +**Ergänzt:** `docs/handoffs/2026-07-25-clupilot-engine-v1.0-handoff.md` (Fahrplan) — **dies ist die fehlende „Sektion 3"** (Detailverhalten), passgenau zum bereits gebauten Kern (Subsystem A). + +Bau **test-first** (Superpowers `test-driven-development`), `ProxmoxClient`/`RemoteShell`/DNS in Step-Tests über die **Fakes** mocken. R15-Codex-Loop bis clean (der Host-Onboarding-Bau brauchte ~19 Runden — die Idempotenz-/Race-/External-ID-Disziplin unten ernst nehmen). + +--- + +## 0. Was der Kern schon liefert (NICHT neu bauen) + +- **Step-Contract** `App\Provisioning\Contracts\ProvisioningStep`: `key()`, `label()`, `maxDuration()`, `execute(ProvisioningRun): StepResult`. +- **`StepResult`**: `advance()` · `retry($afterSec,$reason)` (zählt aufs `max_attempts`-Budget → sonst `fail`) · **`poll($afterSec,$reason)`** (KEIN Budget — für lange Operationen; Schritt erzwingt eigene Deadline via `fail`) · `fail($reason)`. +- **`RunRunner`**: Per-Run-Lock (2100 s), Timeout via `maxDuration()` → automatisch `retry`, Backoff, Event-Record, Continuation-Dispatch **nach** Lock-Release. Fehlende Pipeline/Step = **fatal**. +- **`ProvisioningRun`**: polymorpher `subject`, `context` (json) mit `context($key,$default)` / `mergeContext([])` / `forgetContext($key)`, `current_step`, `attempt`/`max_attempts`, `next_attempt_at`, `started_at`. +- **`RunResource`** (Idempotenz-Brotkrumen): `firstOrCreate(['run_id','kind'], ['host_id','external_id'])` — **kind ist je Run eindeutig**, **VOR** dem Advance persistieren. Helper im `HostStep`: `recordResource()`, `hasResource()`. +- **`ProvisioningStepEvent`** (append-only): `run_id, step, attempt, outcome, message, external_ref`. +- **`StepAdvanced`** broadcastet bereits auf `PrivateChannel('admin.runs')` → Admin-Live-Fortschritt ist verdrahtbar; für Kunden zusätzlichen Kanal ergänzen (§7). +- **`ProxmoxClient`** (Interface) read-only (`forHost`, `listNodes`, `nodeStatus`, `nodeStorage`) + `HttpProxmoxClient` + `FakeProxmoxClient`. **B erweitert das Interface** (§4). +- **`RemoteShell`** (SSH, phpseclib + Fake), **`WireguardHub`**. Proxmox-Automation-Token hat bereits die nötigen Privs (`VM.Clone, VM.Config.*, VM.PowerMgmt, VM.GuestAgent.Unrestricted, Datastore.AllocateSpace` — s. `config/provisioning.php`). +- Registrierung: Pipelines in **`config/provisioning.php` → `pipelines`**; `PipelineRegistry` als Singleton im `AppServiceProvider`. + +--- + +## 1. Neue Modelle / Migrations + +Alle mit `HasUuid` wo in URLs adressierbar (**R11**). + +- **`customers`** — `name, email, locale, stripe_customer_id?, status`. +- **`orders`** (= **Run-Subject**, implements `ProvisioningSubject`) — `customer_id`, `plan` (start|team|business|enterprise), `amount_cents`, `currency`, `datacenter` (Zielregion), **`stripe_event_id` UNIQUE** (Idempotenz), `status` (paid|provisioning|active|failed). `onProvisioningFailed()` → `status='failed'` (**kein Auto-Refund**). +- **`instances`** — `customer_id, order_id, host_id`, `vmid?`, `plan`, `quota_gb`, `disk_gb`, `ram_mb`, `cores`, `subdomain` UNIQUE, `custom_domain?`, `nc_admin_ref?` (nur Username/verschlüsselte Ref — **nie Passwort**), `route_written` bool, `cert_ok` bool, `status` (reserving|provisioning|active|suspended|failed). FKs. +- **`dns_records`** — `instance_id, provider, record_id, fqdn, type, value`. +- **`backups`** — `instance_id, external_job_id, schedule, last_ok_at?, status`. +- **`monitoring_targets`** — `instance_id, external_id, url, status`. +- **`onboarding_tasks`** — `instance_id, key, done bool, done_at?` (füttert den Kunden-Onboarding-Stepper aus dem Portal). + +**Kapazität bleibt berechnet** (kein Ledger): freie Zusage = `hosts.total_gb − reserve(reserve_pct) − Σ instances.quota_gb (nicht failed)` — **gefiltert auf `datacenter` + `status='active'`** (der `Host` hat bereits `freeGb()`; ggf. dort einhängen). Cluster wird v1.0 ignoriert. + +--- + +## 2. `CustomerStep` Basisklasse (analog `HostStep`) + +`app/Provisioning/Steps/Customer/CustomerStep.php` — Helper: +- `label()` → `'provisioning.step.'.$this->key()`, Default `maxDuration()` = 300 (Schritte überschreiben). +- `order(ProvisioningRun): Order` (= `$run->subject`). +- `instance(ProvisioningRun): Instance` (über `context('instance_id')`). +- `plan(ProvisioningRun): array` (aus `config('provisioning.plans.'.$order->plan)`). +- `recordResource()/hasResource()` (aus `HostStep` hochziehen → gemeinsame Basis oder Trait). +- `guest(ProxmoxClient $pve, Instance $i, string $cmd): string` — `guestExec` + Exit-Code prüfen, wirft bei ≠0 (Runner macht daraus `retry`). + +**RunResource-`kind`s (Kunden):** `instance_id, vmid, dns_record_id, nc_admin, backup_job_id, monitoring_target_id`. +**Context-Keys:** `instance_id, host_id, node, vmid, subdomain, plan`. + +--- + +## 3. Die 15 Schritte (`App\Provisioning\Steps\Customer\`) + +Registrieren als `'customer' => [...]` in `config/provisioning.php`. Legende: **[E]** externe ID sofort in `run_resources`, **[P]** `poll()`, **[F]** `fail()` (fatal). + +| # | Key / Klasse | maxDuration | Kernverhalten | +|---|---|---|---| +| 1 | `validate_order` `ValidateOrder` | 60 | Order `paid`? Plan bekannt (`config.plans`)? Ziel-`datacenter` gültig? Read-only, idempotent. **[F]** `order_not_paid` / `unknown_plan`. → advance. | +| 2 | `reserve_resources` `ReserveResources` | 60 | **Idempotenz:** existiert `instance` zu `order_id` → skip. Sonst **Placement** (§1) → Host wählen. `instance`-Row anlegen (`status=reserving`, quota/disk/ram/cores aus Plan, `subdomain` = eindeutig aus Kundenname+rand). `context: instance_id, host_id, node`. **[E]** kind=`instance_id`. **[F]** `no_capacity` (Operator-Eingriff; kein endloses Retry). → advance. | +| 3 | `clone_vm` `CloneVirtualMachine` | 900 | **Idempotenz:** `hasResource('vmid')` → direkt zum Poll. Sonst `vmid = pve.nextVmid()`, `upid = pve.cloneVm(node, plan.template_vmid, vmid, name)`. **[E] kind=`vmid` SOFORT** (vor Poll), `context.vmid`. Dann **[P]** `pve.taskStatus(node,upid)`: running→`poll(10)`, ok→advance, error→**[F]** `clone_failed`. **[F]** `template_missing`. | +| 4 | `configure_cloud_init` `ConfigureCloudInit` | 120 | `pve.setCloudInit(node,vmid,{ipconfig0, ciuser, sshkeys?, nameserver})` + `pve.resizeDisk(node,vmid,'scsi0','+'.(disk_gb).'G')` (nur wenn kleiner). Idempotent (set/resize prüfen Ziel). → advance. **[F]** ungültige Cloud-Init-Config. | +| 5 | `start_vm` `StartVirtualMachine` | 180 | **Idempotenz:** `pve.vmStatus(node,vmid).status==running` → advance. Sonst `upid=pve.startVm` → **[P]** taskStatus. Fehler→**[F]** `start_failed`. | +| 6 | `wait_for_guest_agent` `WaitForGuestAgent` | 300 | **[P]** `pve.guestAgentPing(node,vmid)` bis Antwort → advance; eigene Deadline via `started_at`+270s → **[F]** `guest_agent_timeout`. | +| 7 | `configure_network` `ConfigureNetwork` | 120 | Via `guest()`: Netz/Hostname im Gast verifizieren (falls nicht rein cloud-init). `pve.applyFirewall(node,vmid, allow 80/443, deny Mgmt)`. Idempotent. → advance. | +| 8 | `deploy_application_stack` `DeployApplicationStack` | 1200 | Via `guest()`: Compose-`.env` schreiben (DB-Passwörter generieren, **nicht** in Run-Context lassen → nach Gebrauch `forgetContext`), `docker compose up -d` (Nextcloud+Collabora+MariaDB — Blueprint hat Docker). **[P]** bis Container healthy (`docker compose ps`/occ status) → advance; Fehler→`retry` (Backoff), nach Budget `fail`. | +| 9 | `configure_nextcloud` `ConfigureNextcloud` | 300 | Via `guest()` `occ`: trusted_domains (subdomain [+custom_domain]), Cron→`background:cron`, `maintenance:install`-Idempotenz-Guard (nur wenn nicht installiert), Default-Apps, `config:system:set`. Idempotent. → advance. | +| 10 | `create_customer_admin` `CreateCustomerAdmin` | 120 | **Idempotenz:** `hasResource('nc_admin')` → skip. Sonst Initial-Passwort **generieren**, `occ user:add` (via `OC_PASS`-Env, nicht Argv). **Passwort verschlüsselt an Kunde** (Notification/Mail), **nie plaintext persistieren**; nur Username → `instance.nc_admin_ref`, **[E] kind=`nc_admin`** (external_id=Username). → advance. | +| 11 | `configure_dns_and_tls` `ConfigureDnsAndTls` | 900 | **DNS:** `HetznerDnsClient.upsertRecord(subdomain → host.public_ip)` → **[E] kind=`dns_record_id` sofort**, `dns_records`-Row. **Traefik:** `TraefikWriter.write(subdomain → guest)` (File-Provider-YAML), `instance.route_written=true`. **TLS:** **[P]** `certReachable(fqdn)` (HTTP-01; Port 80 muss offen sein, DNS propagieren) bis Cert da → `instance.cert_ok=true`, advance; großzügige Deadline → **[F]** `cert_timeout`. DNS-API-5xx=`retry`, ungültige Domain=**[F]**. | +| 12 | `register_backup` `RegisterBackup` | 120 | Backup-Job anlegen (vzdump-Schedule/externer Dienst). **[E] kind=`backup_job_id`**, `backups`-Row. Idempotent. → advance. | +| 13 | `register_monitoring` `RegisterMonitoring` | 120 | Instanz als Monitoring-/Uptime-Target registrieren. **[E] kind=`monitoring_target_id`**, `monitoring_targets`-Row. Idempotent. → advance. | +| 14 | `run_acceptance_checks` `RunAcceptanceChecks` | 180 | **Gate.** Alles prüfen: HTTPS/Cert erreichbar, Nextcloud-`status.php` healthy, Admin-Login klappt, Backup-Job existiert, Monitoring grün. Ein Fehler → **[F]** `acceptance_failed:` (manueller Retry). **Kein** Kundenzugang vor Bestehen. → advance nur wenn alles grün. | +| 15 | `complete_provisioning` `CompleteProvisioning` | 60 | `instance.status=active`, `order.status=active`, `onboarding_tasks` anlegen, „Ihre Cloud ist bereit"-Notification mit Zugang. **Erst hier** Kundenzugang. → advance (letzter Schritt → Run `completed`). | + +**Harte Regeln (aus Handoff):** Kundenzugang ERST nach 14. Backup(12)+Monitoring(13) sind Teil der Bereitstellung. Zahlung ok trotz Fehler → `failed` + manueller Retry, kein Auto-Refund. occ/Compose via **guest-agent**, nicht SSH-Warten. DNS via Hetzner **[E]**, Cert **HTTP-01** (kein DNS-01). + +--- + +## 4. `ProxmoxClient`-Erweiterung (Interface + Http + Fake) + +Zum Interface hinzufügen (der Kommentar dort kündigt es an): +```php +public function nextVmid(): int; +public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string; // UPID +public function setCloudInit(string $node, int $vmid, array $params): void; +public function resizeDisk(string $node, int $vmid, string $disk, string $size): void; +public function startVm(string $node, int $vmid): string; // UPID +public function vmStatus(string $node, int $vmid): array; // ['status'=>'running'|'stopped', …] +public function guestAgentPing(string $node, int $vmid): bool; +public function guestExec(string $node, int $vmid, string $command): array; // ['exitcode'=>int,'out-data'=>string] +public function taskStatus(string $node, string $upid): array; // ['status'=>'running'|'stopped','exitstatus'=>'OK'|…] +public function applyFirewall(string $node, int $vmid, array $rules): void; +``` +**Fake** deterministisch (Task sofort „stopped/OK", guestExec exit 0, konfigurierbare Fehlerfälle für Tests). **Http** gegen `/api2/json/nodes/{node}/qemu/...` (UPID-Polling über `/tasks/{upid}/status`). + +## 5. Neue Services + +- **`App\Services\Dns\HetznerDnsClient`** (Interface + Http + Fake): `upsertRecord(fqdn, type, value): string /*record_id*/`, `deleteRecord(record_id)`. Token verschlüsselt/aus `.env`. +- **`App\Services\Traefik\TraefikWriter`** (Interface + Fake): schreibt/entfernt die File-Provider-YAML (Router subdomain→Gast). **Schreibweg festlegen** (Watch-Item): via Host-SSH in ein gemountetes Verzeichnis, wo Traefik läuft. `certReachable(fqdn): bool` (HTTP-01-Poll) hier oder eigener `CertPoller`. + +## 6. Bestell-Eingang (Stripe) + +- Webhook-Controller (Signatur prüfen, Test-Modus). Auf `checkout.session.completed`/`payment_intent.succeeded`: **Idempotenz über `orders.stripe_event_id` UNIQUE** → doppelte Webhooks erzeugen keinen 2. Run. +- Order (`paid`) + `customer` anlegen → `ProvisioningRun` (`pipeline='customer'`, `subject=Order`, `max_attempts` sinnvoll) → `AdvanceRunJob::dispatch($run->uuid)` (sofort, nicht auf Tick warten). +- Kein Live-Stripe/echte Zahlungen in Dev. + +## 7. Live-Fortschritt (an bestehende UI binden) + +- **Admin `/admin/provisioning`** (aktuell Fixtures, `app/Livewire/Admin/Provisioning.php`) → echte `provisioning_runs` + `provisioning_step_events`. `StepAdvanced` broadcastet bereits auf `admin.runs` → Livewire via Echo aktualisieren (Kanal in `routes/channels.php` autorisieren, admin-only). +- **Kundendashboard** (`/dashboard`, aktuell Fixture-Stepper) → an den Run **des eingeloggten Kunden** binden; zusätzlichen `PrivateChannel('customer.'.$customerId.'.run')` in `StepAdvanced::broadcastOn()` ergänzen (pro-Kunde scopen, nur eigener Run). `onboarding_tasks` speisen den Stepper. Zugang zur Cloud erst nach `active`. +- Panel = reine View auf die Tabellen. + +## 8. Config-Ergänzungen (`config/provisioning.php`) + +- `'pipelines' => ['customer' => [ Customer\ValidateOrder::class, … Customer\CompleteProvisioning::class ]]`. +- `'plans' => ['start'=>['quota_gb'=>100,'disk_gb'=>120,'ram_mb'=>4096,'cores'=>2,'template_vmid'=>9000], 'team'=>[…500…], 'business'=>[…1000…], 'enterprise'=>[…]]` (Template-VMID je Plan/Blueprint-Version). +- `'dns' => ['provider'=>'hetzner','token'=>env('HETZNER_DNS_TOKEN'),'zone'=>'clupilot.com']`. +- `'traefik' => ['dynamic_path'=>env('TRAEFIK_DYNAMIC_PATH')]`. + +## 9. Build-Reihenfolge (jede Phase grün) + +1. Migrations+Models (customers, orders, instances, dns_records, backups, monitoring_targets, onboarding_tasks). Order⇒`ProvisioningSubject`. +2. `CustomerStep`-Basis + `plans`/`customer`-Pipeline-Config (leere/`fail`-Stubs zum Registrieren). +3. `ProxmoxClient`-Erweiterung + Fake/Http. +4. Steps 1–2 (reine DB/Placement — früh testbar, `datacenter`-Filter, no_capacity). +5. Steps 3–6 (Klon/CloudInit/Start/GuestAgent) gegen FakeProxmox; **[E]-Persistenz + Idempotenz je Step** testen (Crash nach recordResource simulieren → kein Doppel). +6. Steps 7–10 (Firewall/Deploy/Nextcloud/Admin) via guestExec-Fake; Secret-Handling (nie plaintext) testen. +7. Step 11 (Hetzner-DNS-Fake + TraefikWriter-Fake + Cert-Poll). +8. Steps 12–13, dann 14–15. +9. Stripe-Webhook + Idempotenz + Run-Anlage. +10. Live-Fortschritt Admin+Kunde (Reverb). +11. E2E gegen Dev-Host+Template falls verfügbar, sonst voll gemockt grün. + +## 10. Definition of Done (B, v1.0) + +- [ ] Bezahlte Test-Order → Run läuft ohne Zutun bis `completed` (voll gemockt grün; echt gegen Dev-Host+Template wenn verfügbar). +- [ ] Jeder Step idempotent: Abbruch nach `recordResource` + erneuter Tick → **keine** Doppel-Ressourcen (über `run_resources` verifiziert). +- [ ] `poll` vs `retry` korrekt genutzt (lange Ops zehren nicht am Retry-Budget; eigene Deadlines via `fail`). +- [ ] Externe IDs (vmid/dns/backup/monitoring) **vor** Weitergabe persistiert (Crash-danach-Test). +- [ ] Admin-Passwort/DB-Secrets nie plaintext persistiert (`forgetContext`, verschlüsselt). +- [ ] Kundenzugang erst nach Acceptance (14). +- [ ] Stripe-Doppel-Webhook → nur EINE Provisionierung. +- [ ] Placement respektiert `datacenter`+freie Zusage; `cluster` bleibt `null`/ungenutzt. +- [ ] Admin+Kunden-Fortschritt live (Reverb). +- [ ] Pest grün; R12 (Admin/Kunden-Views 0 Konsolenfehler); R15 Codex clean; keine Secrets im Repo. + +## 11. Watch-Items + +1. **`poll()` nutzen** für Klon/Start/GuestAgent/Deploy/Cert — sonst laufen dir die `max_attempts` weg. Eigene Deadline im Step (via `started_at`) für Poll-Schritte. +2. **`recordResource` VOR jeder externen Weitergabe** (vmid vor Poll!). Sonst hinterlässt ein Crash Waisen ohne Brotkrumen. +3. **Guest-Agent statt SSH** — Blueprint-Template muss guest-agent + docker enthalten (Voraussetzung, Handoff §5). +4. **Traefik-Schreibweg** konkret festlegen (wo läuft Traefik, wie erreicht die YAML es). HTTP-01 braucht Port 80 + DNS-Propagation → Cert-Poll-Deadline großzügig. +5. **Secrets** (Hetzner-DNS-Token, Proxmox-Token, generierte Admin-/DB-Passwörter) verschlüsselt/`forgetContext`, nie in Git/Events (`external_ref` nie Secret). +6. Kern-Gotchas (State-Handoff §6): `@js()`-in-Komponenten, Test-Isolation (sqlite), Locale-aware Formate.