From 42fa92cf2020572d10e7b4d294e757427551aea7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 12:05:31 +0200 Subject: [PATCH] fix(engine-b): address Codex (paid-only stripe, guest routing, absolute disk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stripe: only checkout.session.completed with payment_status=paid (dedupes the paired payment_intent event and blocks unpaid async sessions). - Capture the guest IP (ConfigureNetwork) and point Traefik at the VM, not the Proxmox host, so ACME/HTTP-01 can reach Nextcloud. - Resize the disk to an absolute target (not '+…') so a retry can't double it. (Codex #4 timeout was a false positive — started_at resets per step.) Co-Authored-By: Claude Opus 4.8 --- .../Controllers/StripeWebhookController.php | 8 ++++--- app/Models/Instance.php | 2 +- .../Steps/Customer/ConfigureCloudInit.php | 3 ++- .../Steps/Customer/ConfigureDnsAndTls.php | 6 +++-- .../Steps/Customer/ConfigureNetwork.php | 7 ++++++ ...080009_add_guest_ip_to_instances_table.php | 24 +++++++++++++++++++ .../Provisioning/CustomerStepsTest.php | 10 ++++---- .../Provisioning/StripeWebhookTest.php | 13 ++++++++++ 8 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 database/migrations/2026_07_25_080009_add_guest_ip_to_instances_table.php diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index 6476279..e95a99e 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -23,13 +23,15 @@ class StripeWebhookController extends Controller } $event = json_decode($payload, true) ?: []; - $type = $event['type'] ?? ''; + $object = $event['data']['object'] ?? []; - if (! in_array($type, ['checkout.session.completed', 'payment_intent.succeeded'], true)) { + // Handle exactly ONE canonical event (a card checkout also emits + // payment_intent.succeeded with a different id) and only when actually + // paid — async methods complete the session while still unpaid. + if (($event['type'] ?? '') !== 'checkout.session.completed' || ($object['payment_status'] ?? null) !== 'paid') { return response()->json(['ignored' => true]); } - $object = $event['data']['object'] ?? []; $meta = $object['metadata'] ?? []; $action->fromStripeEvent([ diff --git a/app/Models/Instance.php b/app/Models/Instance.php index 78b7ef9..1100229 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -14,7 +14,7 @@ class Instance extends Model use HasFactory, HasUuid; protected $fillable = [ - 'customer_id', 'order_id', 'host_id', 'vmid', 'plan', 'quota_gb', 'disk_gb', + 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'disk_gb', 'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref', 'route_written', 'cert_ok', 'status', ]; diff --git a/app/Provisioning/Steps/Customer/ConfigureCloudInit.php b/app/Provisioning/Steps/Customer/ConfigureCloudInit.php index 2672f22..a131745 100644 --- a/app/Provisioning/Steps/Customer/ConfigureCloudInit.php +++ b/app/Provisioning/Steps/Customer/ConfigureCloudInit.php @@ -33,7 +33,8 @@ class ConfigureCloudInit extends CustomerStep 'ciuser' => 'clupilot', 'nameserver' => '1.1.1.1', ]); - $pve->resizeDisk($node, $vmid, 'scsi0', '+'.$instance->disk_gb.'G'); + // Absolute target (not '+…') so a retry can't grow the disk twice. + $pve->resizeDisk($node, $vmid, 'scsi0', $instance->disk_gb.'G'); return StepResult::advance(); } diff --git a/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php b/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php index 9cc14af..c29ab02 100644 --- a/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php +++ b/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php @@ -40,9 +40,11 @@ class ConfigureDnsAndTls extends CustomerStep $this->recordResource($run, $host, 'dns_record_id', $recordId); } - // Traefik file-provider route (subdomain → guest over the tunnel). + // Traefik file-provider route → the guest VM (fall back to the host only + // if the guest address wasn't captured). if (! $instance->route_written) { - $this->traefik->write($instance->subdomain, $host->wg_ip ?? $host->public_ip); + $target = $instance->guest_ip ?: ($host->wg_ip ?? $host->public_ip); + $this->traefik->write($instance->subdomain, $target); $instance->update(['route_written' => true]); } diff --git a/app/Provisioning/Steps/Customer/ConfigureNetwork.php b/app/Provisioning/Steps/Customer/ConfigureNetwork.php index 27e5da9..30cb4a3 100644 --- a/app/Provisioning/Steps/Customer/ConfigureNetwork.php +++ b/app/Provisioning/Steps/Customer/ConfigureNetwork.php @@ -29,6 +29,13 @@ 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'] ?? ''); + $guestIp = trim(strtok($ipOut, ' ') ?: ''); + if ($guestIp !== '') { + $instance->update(['guest_ip' => $guestIp]); + } + // Allow HTTP/HTTPS, deny everything else (management stays on the tunnel). $pve->applyFirewall($node, $vmid, [ ['type' => 'in', 'action' => 'ACCEPT', 'proto' => 'tcp', 'dport' => '80'], diff --git a/database/migrations/2026_07_25_080009_add_guest_ip_to_instances_table.php b/database/migrations/2026_07_25_080009_add_guest_ip_to_instances_table.php new file mode 100644 index 0000000..270ce3f --- /dev/null +++ b/database/migrations/2026_07_25_080009_add_guest_ip_to_instances_table.php @@ -0,0 +1,24 @@ +string('guest_ip')->nullable()->after('vmid'); + }); + } + + public function down(): void + { + Schema::table('instances', function (Blueprint $table) { + $table->dropColumn('guest_ip'); + }); + } +}; diff --git a/tests/Feature/Provisioning/CustomerStepsTest.php b/tests/Feature/Provisioning/CustomerStepsTest.php index cec3cbf..be8b419 100644 --- a/tests/Feature/Provisioning/CustomerStepsTest.php +++ b/tests/Feature/Provisioning/CustomerStepsTest.php @@ -176,11 +176,13 @@ it('fails when the guest agent never answers before the deadline', function () { }); // 7. ConfigureNetwork -it('configures the guest network and firewall', function () { +it('configures the guest network, captures the ip, and firewall', function () { $s = fakeServices(); - ['run' => $run] = reservedRun(); + $s['pve']->guestScript('hostname -I', 0, '10.20.0.7 fe80::1'); + ['run' => $run, 'instance' => $instance] = reservedRun(); expect(app(ConfigureNetwork::class)->execute($run)->type)->toBe('advance') ->and($s['pve']->guestRan('set-hostname'))->toBeTrue() + ->and($instance->fresh()->guest_ip)->toBe('10.20.0.7') ->and($s['pve']->firewallCalls)->toContain('101'); }); @@ -233,7 +235,7 @@ it('creates the admin once, never persisting the password in plaintext', functio it('creates the dns record, writes the route, and polls for the cert', function () { $s = fakeServices(); $s['traefik']->certReady = false; - ['run' => $run, 'instance' => $instance] = reservedRun(); + ['run' => $run, 'instance' => $instance] = reservedRun([], ['guest_ip' => '10.20.0.7']); expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('poll'); $run->refresh(); @@ -241,7 +243,7 @@ it('creates the dns record, writes the route, and polls for the cert', function expect(RunResource::where('run_id', $run->id)->where('kind', 'dns_record_id')->count())->toBe(1) ->and($instance->dnsRecords()->count())->toBe(1) ->and($instance->route_written)->toBeTrue() - ->and($s['traefik']->routes)->toHaveKey($instance->subdomain); + ->and($s['traefik']->routes[$instance->subdomain])->toBe('10.20.0.7'); // cert now reachable → advance, no duplicate dns record $s['traefik']->certReady = true; diff --git a/tests/Feature/Provisioning/StripeWebhookTest.php b/tests/Feature/Provisioning/StripeWebhookTest.php index 9aec8e9..3b98504 100644 --- a/tests/Feature/Provisioning/StripeWebhookTest.php +++ b/tests/Feature/Provisioning/StripeWebhookTest.php @@ -12,6 +12,7 @@ function stripeEvent(string $id = 'evt_1', string $plan = 'team'): array 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'customer' => 'cus_1', + 'payment_status' => 'paid', 'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'], 'amount_total' => 4900, 'currency' => 'eur', @@ -53,6 +54,18 @@ it('ignores unrelated event types', function () { Queue::assertNothingPushed(); }); +it('ignores a completed but unpaid checkout session', function () { + Queue::fake(); + + $event = stripeEvent('evt_unpaid'); + $event['data']['object']['payment_status'] = 'unpaid'; + + $this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['ignored' => true]); + + expect(Order::query()->where('stripe_event_id', 'evt_unpaid')->exists())->toBeFalse(); + Queue::assertNothingPushed(); +}); + it('rejects an invalid signature when a secret is configured', function () { config()->set('services.stripe.webhook_secret', 'whsec_test');