diff --git a/app/Provisioning/Steps/Customer/ConfigureCloudInit.php b/app/Provisioning/Steps/Customer/ConfigureCloudInit.php index a131745..a0fac0b 100644 --- a/app/Provisioning/Steps/Customer/ConfigureCloudInit.php +++ b/app/Provisioning/Steps/Customer/ConfigureCloudInit.php @@ -32,6 +32,8 @@ class ConfigureCloudInit extends CustomerStep 'ipconfig0' => 'ip=dhcp', 'ciuser' => 'clupilot', 'nameserver' => '1.1.1.1', + 'cores' => $instance->cores, // apply the plan's CPU/RAM, not the template's + 'memory' => $instance->ram_mb, ]); // Absolute target (not '+…') so a retry can't grow the disk twice. $pve->resizeDisk($node, $vmid, 'scsi0', $instance->disk_gb.'G'); diff --git a/app/Provisioning/Steps/Customer/RegisterBackup.php b/app/Provisioning/Steps/Customer/RegisterBackup.php index 123ef49..365d648 100644 --- a/app/Provisioning/Steps/Customer/RegisterBackup.php +++ b/app/Provisioning/Steps/Customer/RegisterBackup.php @@ -34,11 +34,12 @@ class RegisterBackup extends CustomerStep ->createBackupJob((string) $run->context('node'), (int) $instance->vmid, $schedule); $this->recordResource($run, $instance->host, 'backup_job_id', $jobId); - $instance->backups()->create([ - 'external_job_id' => $jobId, - 'schedule' => $schedule, - 'status' => 'scheduled', - ]); + // firstOrCreate keyed on the deterministic job id → no duplicate row if a + // crash re-runs this step before the breadcrumb was recorded. + $instance->backups()->firstOrCreate( + ['external_job_id' => $jobId], + ['schedule' => $schedule, 'status' => 'scheduled'], + ); return StepResult::advance(); } diff --git a/app/Provisioning/Steps/Customer/RegisterMonitoring.php b/app/Provisioning/Steps/Customer/RegisterMonitoring.php index 33e704b..a73cc7c 100644 --- a/app/Provisioning/Steps/Customer/RegisterMonitoring.php +++ b/app/Provisioning/Steps/Customer/RegisterMonitoring.php @@ -33,11 +33,10 @@ class RegisterMonitoring extends CustomerStep $targetId = $this->monitoring->registerTarget($instance->subdomain, $url); $this->recordResource($run, $instance->host, 'monitoring_target_id', $targetId); - $instance->monitoringTargets()->create([ - 'external_id' => $targetId, - 'url' => $url, - 'status' => 'up', - ]); + $instance->monitoringTargets()->firstOrCreate( + ['external_id' => $targetId], + ['url' => $url, 'status' => 'up'], + ); return StepResult::advance(); } diff --git a/app/Services/Monitoring/FakeMonitoringClient.php b/app/Services/Monitoring/FakeMonitoringClient.php index 0766ca2..f5ac92c 100644 --- a/app/Services/Monitoring/FakeMonitoringClient.php +++ b/app/Services/Monitoring/FakeMonitoringClient.php @@ -7,11 +7,15 @@ class FakeMonitoringClient implements MonitoringClient /** @var array id => url */ public array $targets = []; - private int $counter = 1; - public function registerTarget(string $name, string $url): string { - $id = 'mon-'.$this->counter++; + // Idempotent by URL — a retry after a crash returns the same target id. + $existing = array_search($url, $this->targets, true); + if ($existing !== false) { + return $existing; + } + + $id = 'mon-'.substr(sha1($url), 0, 12); $this->targets[$id] = $url; return $id; diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index ea8e041..314e7fd 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -59,6 +59,9 @@ class FakeProxmoxClient implements ProxmoxClient /** @var array */ public array $cloudInitCalls = []; + /** @var array> vmid => params */ + public array $cloudInitParams = []; + /** @var array */ public array $resizeCalls = []; @@ -96,6 +99,7 @@ class FakeProxmoxClient implements ProxmoxClient public function setCloudInit(string $node, int $vmid, array $params): void { $this->cloudInitCalls[] = $vmid; + $this->cloudInitParams[$vmid] = $params; } public function resizeDisk(string $node, int $vmid, string $disk, string $size): void diff --git a/app/Services/Proxmox/HttpProxmoxClient.php b/app/Services/Proxmox/HttpProxmoxClient.php index 81e6623..3d256a0 100644 --- a/app/Services/Proxmox/HttpProxmoxClient.php +++ b/app/Services/Proxmox/HttpProxmoxClient.php @@ -141,14 +141,22 @@ class HttpProxmoxClient implements ProxmoxClient public function createBackupJob(string $node, int $vmid, string $schedule): string { - $data = $this->http()->asForm()->post('/cluster/backup', [ + // Deterministic job id → creating it twice (crash/retry) is idempotent. + $jobId = 'clupilot-'.$vmid; + + $response = $this->http()->asForm()->post('/cluster/backup', [ + 'id' => $jobId, 'vmid' => $vmid, 'schedule' => $schedule, 'storage' => 'local', 'mode' => 'snapshot', 'enabled' => 1, - ])->throw()->json('data'); + ]); - return is_string($data) ? $data : (string) ($data['id'] ?? 'backup-'.$vmid); + if ($response->failed() && ! str_contains($response->body(), 'already')) { + $response->throw(); + } + + return $jobId; } } diff --git a/tests/Feature/Provisioning/CustomerStepsTest.php b/tests/Feature/Provisioning/CustomerStepsTest.php index 875d243..39a8ea9 100644 --- a/tests/Feature/Provisioning/CustomerStepsTest.php +++ b/tests/Feature/Provisioning/CustomerStepsTest.php @@ -154,6 +154,8 @@ it('configures cloud-init and resizes the disk', function () { ['run' => $run] = reservedRun(); expect(app(ConfigureCloudInit::class)->execute($run)->type)->toBe('advance') ->and($s['pve']->cloudInitCalls)->toContain(101) + ->and($s['pve']->cloudInitParams[101]['cores'])->toBe(2) // start plan + ->and($s['pve']->cloudInitParams[101]['memory'])->toBe(4096) ->and($s['pve']->resizeCalls)->not->toBeEmpty(); });