feat(engine-b): CustomerStep base + shared resource trait + config

ManagesRunResources trait (host+customer), CustomerStep base (order/instance/
plan/guest helpers), config plans + 15-step customer pipeline + dns/traefik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 11:39:56 +02:00
parent 406251dfde
commit 770a6cf7cd
6 changed files with 203 additions and 14 deletions

View File

@ -0,0 +1,69 @@
<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Contracts\ProvisioningStep;
use App\Provisioning\Steps\ManagesRunResources;
use App\Services\Proxmox\ProxmoxClient;
use RuntimeException;
/**
* Shared helpers for customer-provisioning steps: subject/instance/plan
* resolution, guest-agent command execution, and idempotency breadcrumbs.
*/
abstract class CustomerStep implements ProvisioningStep
{
use ManagesRunResources;
public function label(): string
{
return 'provisioning.step.'.$this->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<string, mixed> */
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'] ?? '');
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Provisioning\Steps;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Models\RunResource;
/**
* Idempotency breadcrumbs shared by host and customer steps: persist an external
* resource id (kind unique per run) BEFORE the creating step advances.
*/
trait ManagesRunResources
{
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();
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Provisioning\Steps\Customer;
use App\Provisioning\Steps\Host;
$wgSubnet = env('CLUPILOT_WG_SUBNET', '10.66.0.0/24');
@ -26,6 +27,41 @@ return [
Host\RegisterCapacity::class,
Host\CompleteHostOnboarding::class,
],
'customer' => [
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.

View File

@ -0,0 +1,29 @@
<?php
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use Tests\Support\Steps\ProbeCustomerStep;
it('resolves order, instance and plan from the run', function () {
$order = Order::factory()->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);
});

View File

@ -0,0 +1,39 @@
<?php
namespace Tests\Support\Steps;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Provisioning\Steps\Customer\CustomerStep;
/** Exposes CustomerStep's protected helpers for testing the base class. */
class ProbeCustomerStep extends CustomerStep
{
public function key(): string
{
return 'probe';
}
public function execute(ProvisioningRun $run): StepResult
{
return StepResult::advance();
}
public function orderOf(ProvisioningRun $run): Order
{
return $this->order($run);
}
public function instanceOf(ProvisioningRun $run): ?Instance
{
return $this->instance($run);
}
/** @return array<string, mixed> */
public function planOf(ProvisioningRun $run): array
{
return $this->plan($run);
}
}