10 KiB
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
@phprules. - Tests: sqlite
:memory:(phpunit force). Secrets never in git/events (external_refnever a secret); generated admin/DB passwordsforgetContext+ 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.php → plans, 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 1–2 (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 3–6 (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 7–10 (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 12–15 + 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 1–15→T4–T8; §5 services→T7; §6 Stripe→T9; §7 live→T10; §8 config→T2; §9 order→T1–T11; §10 DoD→T8/T11; §11 watch-items (poll, recordResource-before-external, guest-agent, traefik path, secrets)→embedded in T5–T8.
- 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.