fix(engine-b): address Codex round 5 (capacity release, activate-last, guest-ip poll, email unique)

- Failed run releases the instance (status=failed) so its quota stops counting
  against host capacity.
- CompleteProvisioning activates instance+order only AFTER onboarding tasks +
  credential delivery succeed.
- ConfigureNetwork polls until the guest has an address (no wrong Traefik route).
- customers.email unique + race-safe customer resolution in the intake action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 12:19:31 +02:00
parent c56aa14f9d
commit e2b8f25e78
8 changed files with 84 additions and 19 deletions

View File

@ -25,13 +25,10 @@ class StartCustomerProvisioning
return null; // already processed
}
try {
[$order, $run] = DB::transaction(function () use ($event) {
$customer = Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
$customer = $this->resolveCustomer($event);
try {
[$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $event['plan'],
@ -61,4 +58,22 @@ class StartCustomerProvisioning
return $order;
}
/**
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
{
try {
return Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
} catch (UniqueConstraintViolationException) {
return Customer::query()->where('email', $event['email'])->firstOrFail();
}
}
}

View File

@ -40,9 +40,14 @@ class Order extends Model implements ProvisioningSubject
return $this->morphMany(ProvisioningRun::class, 'subject');
}
/** Runner hook: a failed provisioning run marks the order failed (no auto-refund). */
/**
* Runner hook: a failed provisioning run marks the order failed (no
* auto-refund) AND releases the reserved instance, so its quota stops
* counting against host capacity.
*/
public function onProvisioningFailed(): void
{
$this->update(['status' => 'failed']);
$this->instance()->update(['status' => 'failed']);
}
}

View File

@ -25,14 +25,12 @@ class CompleteProvisioning extends CustomerStep
$order = $this->order($run);
$instance = $this->instance($run);
$instance->update(['status' => 'active']);
$order->update(['status' => 'active']);
// Do all fallible work FIRST, so a failure can't leave the instance
// active. Onboarding tasks (idempotent) + credential delivery.
foreach (['invite_users', 'upload_logo', 'connect_desktop', 'explore_apps'] as $key) {
$instance->onboardingTasks()->firstOrCreate(['key' => $key], ['done' => false]);
}
// Deliver the initial credentials, then scrub the transient password.
$encrypted = $run->context('admin_password');
if ($encrypted !== null) {
Notification::route('mail', $order->customer->email)->notify(
@ -41,6 +39,10 @@ class CompleteProvisioning extends CustomerStep
$run->forgetContext('admin_password');
}
// Activate LAST — only once everything above succeeded.
$instance->update(['status' => 'active']);
$order->update(['status' => 'active']);
return StepResult::advance();
}
}

View File

@ -17,7 +17,8 @@ class ConfigureNetwork extends CustomerStep
public function maxDuration(): int
{
return 120;
// Above the 90s guest-ip own-deadline below.
return 150;
}
public function execute(ProvisioningRun $run): StepResult
@ -29,12 +30,18 @@ class ConfigureNetwork extends CustomerStep
$this->guest($pve, $run, 'hostnamectl set-hostname '.escapeshellarg($instance->subdomain));
// Capture the guest's own address so Traefik can route to the VM (not the host).
$ipOut = trim($pve->guestExec($node, $vmid, "hostname -I")['out-data'] ?? '');
// Capture the guest's own address so Traefik routes to the VM, not the
// host. Poll until it's available rather than advancing with no address.
$ipOut = trim($pve->guestExec($node, $vmid, 'hostname -I')['out-data'] ?? '');
$guestIp = trim(strtok($ipOut, ' ') ?: '');
if ($guestIp !== '') {
$instance->update(['guest_ip' => $guestIp]);
if ($guestIp === '') {
if ($run->started_at !== null && $run->started_at->copy()->addSeconds(90)->isPast()) {
return StepResult::fail('guest_ip_unavailable');
}
return StepResult::poll(10, 'waiting for the guest to obtain an address');
}
$instance->update(['guest_ip' => $guestIp]);
// Allow HTTP/HTTPS, deny everything else (management stays on the tunnel).
$pve->applyFirewall($node, $vmid, [

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// One customer per email — the dashboard and broadcast auth resolve a
// customer by email, so duplicates would hide a customer's runs.
Schema::table('customers', function (Blueprint $table) {
$table->unique('email');
});
}
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->dropUnique(['email']);
});
}
};

View File

@ -18,12 +18,16 @@ it('resolves an Order as the polymorphic run subject', function () {
->and($order->getRouteKeyName())->toBe('uuid');
});
it('marks the order failed via the ProvisioningSubject hook', function () {
it('marks the order and releases its instance via the ProvisioningSubject hook', function () {
$order = Order::factory()->create(['status' => 'provisioning']);
$instance = Instance::factory()->create([
'order_id' => $order->id, 'customer_id' => $order->customer_id, 'status' => 'provisioning',
]);
$order->onProvisioningFailed();
expect($order->fresh()->status)->toBe('failed');
expect($order->fresh()->status)->toBe('failed')
->and($instance->fresh()->status)->toBe('failed'); // quota no longer counts against capacity
});
it('enforces a unique stripe_event_id (idempotency)', function () {

View File

@ -14,7 +14,8 @@ it('provisions a paid order all the way to active (mocked)', function () {
Notification::fake();
Queue::fake(); // drive the runner manually, one step per advance
$s = fakeServices();
$s['pve']->guestScript('status.php', 0, 'ok'); // deploy health + acceptance probe
$s['pve']->guestScript('status.php', 0, 'ok');
$s['pve']->guestScript('hostname -I', 0, '10.20.0.9'); // deploy health + acceptance probe
// Fake defaults: tasks stopped/OK, guest agent up, cert reachable.
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
@ -62,6 +63,7 @@ it('does not duplicate external resources when a step re-runs after a crash', fu
Queue::fake();
$s = fakeServices();
$s['pve']->guestScript('status.php', 0, 'ok');
$s['pve']->guestScript('hostname -I', 0, '10.20.0.9');
Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]);
$order = Order::factory()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']);

View File

@ -186,6 +186,12 @@ it('configures the guest network, captures the ip, and firewall', function () {
->and($s['pve']->firewallCalls)->toContain('101');
});
it('polls network config until the guest has an address', function () {
fakeServices(); // hostname -I returns empty by default → no address yet
['run' => $run] = reservedRun();
expect(app(ConfigureNetwork::class)->execute($run)->type)->toBe('poll');
});
// 8. DeployApplicationStack
it('deploys the stack, polling until healthy, without persisting the db password', function () {
$s = fakeServices();