fix(engine-b): address Codex (paid-only stripe, guest routing, absolute disk)

- 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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 12:05:31 +02:00
parent 3e19778046
commit 42fa92cf20
8 changed files with 62 additions and 11 deletions

View File

@ -23,13 +23,15 @@ class StripeWebhookController extends Controller
} }
$event = json_decode($payload, true) ?: []; $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]); return response()->json(['ignored' => true]);
} }
$object = $event['data']['object'] ?? [];
$meta = $object['metadata'] ?? []; $meta = $object['metadata'] ?? [];
$action->fromStripeEvent([ $action->fromStripeEvent([

View File

@ -14,7 +14,7 @@ class Instance extends Model
use HasFactory, HasUuid; use HasFactory, HasUuid;
protected $fillable = [ 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', 'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref',
'route_written', 'cert_ok', 'status', 'route_written', 'cert_ok', 'status',
]; ];

View File

@ -33,7 +33,8 @@ class ConfigureCloudInit extends CustomerStep
'ciuser' => 'clupilot', 'ciuser' => 'clupilot',
'nameserver' => '1.1.1.1', '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(); return StepResult::advance();
} }

View File

@ -40,9 +40,11 @@ class ConfigureDnsAndTls extends CustomerStep
$this->recordResource($run, $host, 'dns_record_id', $recordId); $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) { 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]); $instance->update(['route_written' => true]);
} }

View File

@ -29,6 +29,13 @@ class ConfigureNetwork extends CustomerStep
$this->guest($pve, $run, 'hostnamectl set-hostname '.escapeshellarg($instance->subdomain)); $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). // Allow HTTP/HTTPS, deny everything else (management stays on the tunnel).
$pve->applyFirewall($node, $vmid, [ $pve->applyFirewall($node, $vmid, [
['type' => 'in', 'action' => 'ACCEPT', 'proto' => 'tcp', 'dport' => '80'], ['type' => 'in', 'action' => 'ACCEPT', 'proto' => 'tcp', 'dport' => '80'],

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
{
// The guest's own address (captured after boot), so Traefik routes to the
// Nextcloud VM rather than the Proxmox host.
Schema::table('instances', function (Blueprint $table) {
$table->string('guest_ip')->nullable()->after('vmid');
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn('guest_ip');
});
}
};

View File

@ -176,11 +176,13 @@ it('fails when the guest agent never answers before the deadline', function () {
}); });
// 7. ConfigureNetwork // 7. ConfigureNetwork
it('configures the guest network and firewall', function () { it('configures the guest network, captures the ip, and firewall', function () {
$s = fakeServices(); $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') expect(app(ConfigureNetwork::class)->execute($run)->type)->toBe('advance')
->and($s['pve']->guestRan('set-hostname'))->toBeTrue() ->and($s['pve']->guestRan('set-hostname'))->toBeTrue()
->and($instance->fresh()->guest_ip)->toBe('10.20.0.7')
->and($s['pve']->firewallCalls)->toContain('101'); ->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 () { it('creates the dns record, writes the route, and polls for the cert', function () {
$s = fakeServices(); $s = fakeServices();
$s['traefik']->certReady = false; $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'); expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('poll');
$run->refresh(); $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) expect(RunResource::where('run_id', $run->id)->where('kind', 'dns_record_id')->count())->toBe(1)
->and($instance->dnsRecords()->count())->toBe(1) ->and($instance->dnsRecords()->count())->toBe(1)
->and($instance->route_written)->toBeTrue() ->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 // cert now reachable → advance, no duplicate dns record
$s['traefik']->certReady = true; $s['traefik']->certReady = true;

View File

@ -12,6 +12,7 @@ function stripeEvent(string $id = 'evt_1', string $plan = 'team'): array
'type' => 'checkout.session.completed', 'type' => 'checkout.session.completed',
'data' => ['object' => [ 'data' => ['object' => [
'customer' => 'cus_1', 'customer' => 'cus_1',
'payment_status' => 'paid',
'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'], 'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'],
'amount_total' => 4900, 'amount_total' => 4900,
'currency' => 'eur', 'currency' => 'eur',
@ -53,6 +54,18 @@ it('ignores unrelated event types', function () {
Queue::assertNothingPushed(); 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 () { it('rejects an invalid signature when a secret is configured', function () {
config()->set('services.stripe.webhook_secret', 'whsec_test'); config()->set('services.stripe.webhook_secret', 'whsec_test');