fix(engine-b): apply plan CPU/RAM; idempotent backup + monitoring registration

- ConfigureCloudInit sets cores/memory from the plan (not the template default).
- RegisterBackup uses a deterministic Proxmox job id (ignore-exists) + firstOrCreate.
- RegisterMonitoring is idempotent by URL + firstOrCreate — no duplicate schedules
  or monitors after a crash/retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 12:42:10 +02:00
parent 5cf1964d7d
commit 3c5a18b5d7
7 changed files with 36 additions and 16 deletions

View File

@ -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');

View File

@ -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();
}

View File

@ -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();
}

View File

@ -7,11 +7,15 @@ class FakeMonitoringClient implements MonitoringClient
/** @var array<string, string> 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;

View File

@ -59,6 +59,9 @@ class FakeProxmoxClient implements ProxmoxClient
/** @var array<int, string> */
public array $cloudInitCalls = [];
/** @var array<int, array<string, mixed>> vmid => params */
public array $cloudInitParams = [];
/** @var array<int, string> */
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

View File

@ -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;
}
}

View File

@ -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();
});