diff --git a/app/Provisioning/Steps/Customer/CustomerStep.php b/app/Provisioning/Steps/Customer/CustomerStep.php new file mode 100644 index 0000000..c105652 --- /dev/null +++ b/app/Provisioning/Steps/Customer/CustomerStep.php @@ -0,0 +1,69 @@ +key(); + } + + public function maxDuration(): int + { + return 300; + } + + protected function order(ProvisioningRun $run): Order + { + /** @var Order $order */ + $order = $run->subject; + + return $order; + } + + protected function instance(ProvisioningRun $run): ?Instance + { + $id = $run->context('instance_id'); + + return $id ? Instance::query()->find($id) : null; + } + + /** @return array */ + protected function plan(ProvisioningRun $run): array + { + return config('provisioning.plans.'.$this->order($run)->plan, []); + } + + /** + * Run a command inside the guest via qemu-guest-agent. Throws on a non-zero + * exit code so the runner turns it into a retry. + */ + protected function guest(ProxmoxClient $pve, ProvisioningRun $run, string $command): string + { + $node = (string) $run->context('node'); + $vmid = (int) $run->context('vmid'); + + $result = $pve->guestExec($node, $vmid, $command); + + if ((int) ($result['exitcode'] ?? 1) !== 0) { + throw new RuntimeException("guest exec failed (exit {$result['exitcode']}): {$command}"); + } + + return (string) ($result['out-data'] ?? ''); + } +} diff --git a/app/Provisioning/Steps/Host/HostStep.php b/app/Provisioning/Steps/Host/HostStep.php index fa66b95..805d054 100644 --- a/app/Provisioning/Steps/Host/HostStep.php +++ b/app/Provisioning/Steps/Host/HostStep.php @@ -4,8 +4,8 @@ namespace App\Provisioning\Steps\Host; use App\Models\Host; use App\Models\ProvisioningRun; -use App\Models\RunResource; use App\Provisioning\Contracts\ProvisioningStep; +use App\Provisioning\Steps\ManagesRunResources; use App\Services\Ssh\RemoteShell; /** @@ -14,6 +14,8 @@ use App\Services\Ssh\RemoteShell; */ abstract class HostStep implements ProvisioningStep { + use ManagesRunResources; + public function label(): string { return 'hosts.step.'.$this->key(); @@ -41,17 +43,4 @@ abstract class HostStep implements ProvisioningStep $host->ssh_host_key, // pinned during EstablishSshTrust ); } - - protected function recordResource(ProvisioningRun $run, Host $host, string $kind, string $externalId): void - { - RunResource::firstOrCreate( - ['run_id' => $run->id, 'kind' => $kind], - ['host_id' => $host->id, 'external_id' => $externalId], - ); - } - - protected function hasResource(ProvisioningRun $run, string $kind): bool - { - return RunResource::query()->where('run_id', $run->id)->where('kind', $kind)->exists(); - } } diff --git a/app/Provisioning/Steps/ManagesRunResources.php b/app/Provisioning/Steps/ManagesRunResources.php new file mode 100644 index 0000000..a6433bd --- /dev/null +++ b/app/Provisioning/Steps/ManagesRunResources.php @@ -0,0 +1,27 @@ + $run->id, 'kind' => $kind], + ['host_id' => $host?->id, 'external_id' => $externalId], + ); + } + + protected function hasResource(ProvisioningRun $run, string $kind): bool + { + return RunResource::query()->where('run_id', $run->id)->where('kind', $kind)->exists(); + } +} diff --git a/config/provisioning.php b/config/provisioning.php index 42c3bf3..cdd8b2d 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -1,5 +1,6 @@ [ + Customer\ValidateOrder::class, + Customer\ReserveResources::class, + Customer\CloneVirtualMachine::class, + Customer\ConfigureCloudInit::class, + Customer\StartVirtualMachine::class, + Customer\WaitForGuestAgent::class, + Customer\ConfigureNetwork::class, + Customer\DeployApplicationStack::class, + Customer\ConfigureNextcloud::class, + Customer\CreateCustomerAdmin::class, + Customer\ConfigureDnsAndTls::class, + Customer\RegisterBackup::class, + Customer\RegisterMonitoring::class, + Customer\RunAcceptanceChecks::class, + Customer\CompleteProvisioning::class, + ], + ], + + // Plan → resource sizing + Nextcloud blueprint template VMID (per DC/version). + 'plans' => [ + 'start' => ['quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'template_vmid' => 9000], + 'team' => ['quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'template_vmid' => 9000], + 'business' => ['quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'template_vmid' => 9000], + 'enterprise' => ['quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'template_vmid' => 9000], + ], + + 'dns' => [ + 'provider' => 'hetzner', + 'token' => env('HETZNER_DNS_TOKEN', ''), + 'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.com'), + ], + + 'traefik' => [ + 'dynamic_path' => env('TRAEFIK_DYNAMIC_PATH', '/etc/traefik/dynamic'), ], // CluPilot VM acts as the WireGuard hub; hosts join it during onboarding. diff --git a/tests/Feature/Provisioning/CustomerStepBaseTest.php b/tests/Feature/Provisioning/CustomerStepBaseTest.php new file mode 100644 index 0000000..49ecb41 --- /dev/null +++ b/tests/Feature/Provisioning/CustomerStepBaseTest.php @@ -0,0 +1,29 @@ +create(['plan' => 'team']); + $instance = Instance::factory()->create(); + $run = ProvisioningRun::factory()->create([ + 'subject_type' => Order::class, + 'subject_id' => $order->id, + 'pipeline' => 'customer', + 'context' => ['instance_id' => $instance->id], + ]); + + $step = new ProbeCustomerStep; + + expect($step->orderOf($run)->is($order))->toBeTrue() + ->and($step->instanceOf($run)->is($instance))->toBeTrue() + ->and($step->planOf($run)['quota_gb'])->toBe(500) + ->and($step->label())->toBe('provisioning.step.probe'); +}); + +it('registers the 15-step customer pipeline in config', function () { + expect(config('provisioning.pipelines.customer'))->toHaveCount(15) + ->and(config('provisioning.plans.start.template_vmid'))->toBe(9000); +}); diff --git a/tests/Support/Steps/ProbeCustomerStep.php b/tests/Support/Steps/ProbeCustomerStep.php new file mode 100644 index 0000000..35b8f54 --- /dev/null +++ b/tests/Support/Steps/ProbeCustomerStep.php @@ -0,0 +1,39 @@ +order($run); + } + + public function instanceOf(ProvisioningRun $run): ?Instance + { + return $this->instance($run); + } + + /** @return array */ + public function planOf(ProvisioningRun $run): array + { + return $this->plan($run); + } +}