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
parent
406251dfde
commit
770a6cf7cd
|
|
@ -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'] ?? '');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue