16 KiB
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 aufsmax_attempts-Budget → sonstfail) ·poll($afterSec,$reason)(KEIN Budget — für lange Operationen; Schritt erzwingt eigene Deadline viafail) ·fail($reason).RunRunner: Per-Run-Lock (2100 s), Timeout viamaxDuration()→ automatischretry, Backoff, Event-Record, Continuation-Dispatch nach Lock-Release. Fehlende Pipeline/Step = fatal.ProvisioningRun: polymorphersubject,context(json) mitcontext($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 imHostStep:recordResource(),hasResource().ProvisioningStepEvent(append-only):run_id, step, attempt, outcome, message, external_ref.StepAdvancedbroadcastet bereits aufPrivateChannel('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;PipelineRegistryals Singleton imAppServiceProvider.
1. Neue Modelle / Migrations
Alle mit HasUuid wo in URLs adressierbar (R11).
customers—name, email, locale, stripe_customer_id?, status.orders(= Run-Subject, implementsProvisioningSubject) —customer_id,plan(start|team|business|enterprise),amount_cents,currency,datacenter(Zielregion),stripe_event_idUNIQUE (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,subdomainUNIQUE,custom_domain?,nc_admin_ref?(nur Username/verschlüsselte Ref — nie Passwort),route_writtenbool,cert_okbool,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(), DefaultmaxDuration()= 300 (Schritte überschreiben).order(ProvisioningRun): Order(=$run->subject).instance(ProvisioningRun): Instance(übercontext('instance_id')).plan(ProvisioningRun): array(ausconfig('provisioning.plans.'.$order->plan)).recordResource()/hasResource()(ausHostStephochziehen → gemeinsame Basis oder Trait).guest(ProxmoxClient $pve, Instance $i, string $cmd): string—guestExec+ Exit-Code prüfen, wirft bei ≠0 (Runner macht darausretry).
RunResource-kinds (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:<was> (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):
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 eigenerCertPoller.
6. Bestell-Eingang (Stripe)
- Webhook-Controller (Signatur prüfen, Test-Modus). Auf
checkout.session.completed/payment_intent.succeeded: Idempotenz überorders.stripe_event_idUNIQUE → doppelte Webhooks erzeugen keinen 2. Run. - Order (
paid) +customeranlegen →ProvisioningRun(pipeline='customer',subject=Order,max_attemptssinnvoll) →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) → echteprovisioning_runs+provisioning_step_events.StepAdvancedbroadcastet bereits aufadmin.runs→ Livewire via Echo aktualisieren (Kanal inroutes/channels.phpautorisieren, admin-only). - Kundendashboard (
/dashboard, aktuell Fixture-Stepper) → an den Run des eingeloggten Kunden binden; zusätzlichenPrivateChannel('customer.'.$customerId.'.run')inStepAdvanced::broadcastOn()ergänzen (pro-Kunde scopen, nur eigener Run).onboarding_tasksspeisen den Stepper. Zugang zur Cloud erst nachactive. - 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)
- Migrations+Models (customers, orders, instances, dns_records, backups, monitoring_targets, onboarding_tasks). Order⇒
ProvisioningSubject. CustomerStep-Basis +plans/customer-Pipeline-Config (leere/fail-Stubs zum Registrieren).ProxmoxClient-Erweiterung + Fake/Http.- Steps 1–2 (reine DB/Placement — früh testbar,
datacenter-Filter, no_capacity). - Steps 3–6 (Klon/CloudInit/Start/GuestAgent) gegen FakeProxmox; [E]-Persistenz + Idempotenz je Step testen (Crash nach recordResource simulieren → kein Doppel).
- Steps 7–10 (Firewall/Deploy/Nextcloud/Admin) via guestExec-Fake; Secret-Handling (nie plaintext) testen.
- Step 11 (Hetzner-DNS-Fake + TraefikWriter-Fake + Cert-Poll).
- Steps 12–13, dann 14–15.
- Stripe-Webhook + Idempotenz + Run-Anlage.
- Live-Fortschritt Admin+Kunde (Reverb).
- 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 (überrun_resourcesverifiziert). pollvsretrykorrekt genutzt (lange Ops zehren nicht am Retry-Budget; eigene Deadlines viafail).- 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;clusterbleibtnull/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
poll()nutzen für Klon/Start/GuestAgent/Deploy/Cert — sonst laufen dir diemax_attemptsweg. Eigene Deadline im Step (viastarted_at) für Poll-Schritte.recordResourceVOR jeder externen Weitergabe (vmid vor Poll!). Sonst hinterlässt ein Crash Waisen ohne Brotkrumen.- Guest-Agent statt SSH — Blueprint-Template muss guest-agent + docker enthalten (Voraussetzung, Handoff §5).
- 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.
- Secrets (Hetzner-DNS-Token, Proxmox-Token, generierte Admin-/DB-Passwörter) verschlüsselt/
forgetContext, nie in Git/Events (external_refnie Secret). - Kern-Gotchas (State-Handoff §6):
@js()-in-Komponenten, Test-Isolation (sqlite), Locale-aware Formate.