CluPilotCloud/docs/superpowers/plans/2026-07-25-customer-pipelin...

10 KiB
Raw Blame History

Customer Provisioning Pipeline (Subsystem B) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:test-driven-development per task. Contract of record: docs/specs/2026-07-25-engine-customer-pipeline.md (the 15-step Section 3) + docs/handoffs/2026-07-25-clupilot-engine-v1.0-handoff.md (roadmap). This plan maps them to ordered TDD tasks + exact signatures.

Goal: A paid Stripe order provisions a running, secured, monitored Nextcloud instance automatically, on the shared orchestrator core built for Subsystem A.

Architecture: pipeline='customer' (subject = Order) on the existing DB-state-machine + tick orchestrator. 15 idempotent CustomerStep classes, VM lifecycle via the extended ProxmoxClient (API + guest-agent), DNS via Hetzner, TLS via Traefik/HTTP-01. Fully mocked in tests via the Fakes.

Tech Stack: Laravel 13.8, Livewire 3, Reverb, Redis provisioning queue, Pest 4 (sqlite :memory:), Stripe (test), phpseclib (present).

Global Constraints

  • R1/R2 class-based full-page Livewire · R3 token utilities only · R5 destructive→modal · R7 responsive · R8 container · R9 no emoji · R11 UUID URLs · R12 browser 0 console errors · R13 English routes · R14 self-hosted fonts · R15 Codex clean · R16 DE+EN identical keys · R17 blade @php rules.
  • Tests: sqlite :memory: (phpunit force). Secrets never in git/events (external_ref never a secret); generated admin/DB passwords forgetContext + never plaintext-persisted.
  • Reuse the core, do NOT rebuild (spec §0): StepResult (advance/retry/poll/fail), ProvisioningStep, RunRunner, ProvisioningRun/RunResource/StepEvent, PipelineRegistry, RemoteShell/WireguardHub, read-only ProxmoxClient.
  • Test cmd: docker compose run --rm --no-deps -u 1000:1000 -e HOME=/tmp app ./vendor/bin/pest

File Structure

Models/Migrations: Customer, Order, Instance, DnsRecord, Backup, MonitoringTarget, OnboardingTask (+ 7 migrations). Order implements App\Provisioning\Contracts\ProvisioningSubject.

Steps: app/Provisioning/Steps/Customer/CustomerStep.php (base) + 15 step classes (spec §3 table).

Shared: pull recordResource()/hasResource() into a App\Provisioning\Steps\ManagesRunResources trait used by both HostStep and CustomerStep.

Services: extend App\Services\Proxmox\{ProxmoxClient,FakeProxmoxClient,HttpProxmoxClient}; new App\Services\Dns\{HetznerDnsClient(interface),HttpHetznerDnsClient,FakeHetznerDnsClient}; new App\Services\Traefik\{TraefikWriter(interface),SshTraefikWriter,FakeTraefikWriter}.

Intake: app/Http/Controllers/StripeWebhookController.php + route (POST, no CSRF), app/Actions/StartCustomerProvisioning.php.

UI: rebind app/Livewire/Admin/Provisioning.php; customer dashboard stepper (app/Livewire/Dashboard.php); extend StepAdvanced::broadcastOn() + routes/channels.php (customer.{id}.run). lang/{de,en}/provisioning.php.

Config: config/provisioning.phpplans, pipelines.customer, dns, traefik.

Interfaces (verbatim — consumed across tasks)

// ProxmoxClient additions (spec §4)
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'=>...,'exitstatus'=>'OK'|...]
public function applyFirewall(string $node, int $vmid, array $rules): void;

// App\Services\Dns\HetznerDnsClient
public function upsertRecord(string $fqdn, string $type, string $value): string; // record_id
public function deleteRecord(string $recordId): void;

// App\Services\Traefik\TraefikWriter
public function write(string $subdomain, string $targetHost): void;
public function remove(string $subdomain): void;
public function certReachable(string $fqdn): bool;

CustomerStep base helpers: order(run):Order, instance(run):Instance (via context('instance_id')), plan(run):array (config('provisioning.plans.'.$order->plan)), guest(ProxmoxClient,Instance,string $cmd):string (guestExec, throws on exit≠0). RunResource kinds: instance_id, vmid, dns_record_id, nc_admin, backup_job_id, monitoring_target_id. Context keys: instance_id, host_id, node, vmid, subdomain, plan.

Tasks (ordered — spec §9 build order)

Task 1: Models + migrations (Phase 1)

Create 7 migrations + 7 models. Order implements ProvisioningSubject (onProvisioningFailed→status failed). Casts (uuid where URL-addressable; encrypted nc_admin_ref only stores username/ref never password; booleans). subdomain unique, orders.stripe_event_id unique. Capacity: add Host::committedGb() / free calc filtered on datacenter+active.

  • Test: create order→run subject resolves; unique stripe_event_id enforced; instance subdomain unique; freeGb minus committed quotas. → implement → green → commit.

Task 2: ManagesRunResources trait + CustomerStep base + config (Phase 2)

Extract recordResource/hasResource into trait; HostStep + CustomerStep use it. CustomerStep helpers above. config/provisioning.php: plans (start/team/business/enterprise with quota/disk/ram/cores/template_vmid), pipelines.customer (15 classes — textual, built incrementally), dns, traefik.

  • Test: trait keeps HostStep tests green; CustomerStep resolves order/instance/plan. → implement → green → commit.

Task 3: ProxmoxClient extension (Phase 3)

Add the §4 methods to interface + FakeProxmoxClient (deterministic: nextVmid counter, cloneVm→UPID string, taskStatus→stopped/OK, vmStatus→running after start, guestAgentPing→true, guestExec→exit 0 configurable-fail, applyFirewall records) + HttpProxmoxClient (real endpoints, UPID poll via /tasks/{upid}/status).

  • Test (Fake): clone→vmid+upid; taskStatus OK; guestExec exit code; configurable failure. → implement → green → commit.

Task 4: Steps 12 (Phase 4)

ValidateOrder (paid? plan known? datacenter valid? → advance/[F]), ReserveResources (idempotent on existing instance; placement = first active host in datacenter with free committed ≥ quota; create instance; context instance_id/host_id/node; [E] instance_id; [F] no_capacity).

  • Tests: validate fail on unpaid/unknown plan; reserve creates instance once (idempotent), picks datacenter host, no_capacity fails. → green → commit.

Task 5: Steps 36 (Phase 5) — against FakeProxmox

CloneVirtualMachine (idempotent on vmid; [E] vmid before poll; poll taskStatus; [F] template_missing/clone_failed), ConfigureCloudInit (setCloudInit+resizeDisk idempotent), StartVirtualMachine (idempotent on running; poll), WaitForGuestAgent (poll ping; own deadline→[F]).

  • Tests each: [E] persistence before poll, idempotency (crash after recordResource→no dup vmid), poll vs advance, fail classification. → green → commit.

Task 6: Steps 710 (Phase 6) — via guestExec fake

ConfigureNetwork (guest verify + applyFirewall), DeployApplicationStack (write compose .env with generated DB pw → forgetContext; compose up; poll healthy), ConfigureNextcloud (occ trusted_domains/cron/install-guard idempotent), CreateCustomerAdmin (idempotent on nc_admin; generate pw via OC_PASS env; [E] nc_admin=username; never persist password).

  • Tests: secrets never plaintext-persisted (context scrubbed, events carry no secret); idempotency; admin created once. → green → commit.

Task 7: Step 11 + DNS/Traefik services (Phase 7)

HetznerDnsClient (interface+Fake+Http), TraefikWriter (interface+Fake+SSH). ConfigureDnsAndTls: upsert DNS [E] dns_record_id + dns_records row; Traefik write + route_written; [P] certReachable → cert_ok; [F] cert_timeout / invalid domain; DNS 5xx→retry.

  • Tests: dns record recorded before advance; cert poll→advance; timeout→fail. → green → commit.

Task 8: Steps 1215 + e2e (Phase 8)

RegisterBackup ([E] backup_job_id), RegisterMonitoring ([E] monitoring_target_id), RunAcceptanceChecks (gate: cert/status.php/admin-login/backup/monitoring; [F] acceptance_failed:), CompleteProvisioning (instance+order active, onboarding_tasks, notify; last step→completed). Mocked e2e: paid order → drive runner → completed, instance active, resources each once, no secret leaks, customer access only after 14.

  • Tests + e2e + crash-idempotency. → green → commit.

Task 9: Stripe webhook (Phase 9)

StripeWebhookController (verify signature in non-test; test-mode path), idempotent on orders.stripe_event_id unique → create customer+order+run+dispatch. StartCustomerProvisioning action (transaction, dispatch after commit).

  • Tests: duplicate event → one run; paid event → run created + AdvanceRunJob pushed. → green → commit.

Task 10: Live progress (Phase 10)

Rebind Admin\Provisioning to real runs+events (live via admin.runs). Customer dashboard: bind stepper to logged-in customer's run; add PrivateChannel('customer.{id}.run') to StepAdvanced::broadcastOn() (+ channels.php authz: own customer only); onboarding_tasks feed. Access to cloud only when active.

  • Tests: admin provisioning renders real runs; customer sees only own run; channel authz. → green → commit.

Task 11: Verify (Phase 11)

DE+EN provisioning.step.* (+ any UI keys) identical keys; full Pest green; R12 Puppeteer (admin provisioning + customer dashboard) 0 console errors; R15 Codex --scope branch --base <pre-B commit> loop until clean; seeder demo order/run gated local/testing; commit + push (token inline).

Self-Review

  • Spec coverage: §1 models→T1; §2 base→T2; §4 client→T3; §3 steps 115→T4T8; §5 services→T7; §6 Stripe→T9; §7 live→T10; §8 config→T2; §9 order→T1T11; §10 DoD→T8/T11; §11 watch-items (poll, recordResource-before-external, guest-agent, traefik path, secrets)→embedded in T5T8.
  • Placeholder scan: none — each task has concrete conditions + tests referencing the spec table.
  • Type consistency: ProxmoxClient/HetznerDnsClient/TraefikWriter signatures fixed above and reused; RunResource kinds + context keys enumerated once.