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
parent
3e19778046
commit
42fa92cf20
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue