diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index e91eb3d..dc48884 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -45,4 +45,119 @@ class FakeProxmoxClient implements ProxmoxClient { return $this->storage; } + + // --- VM lifecycle (deterministic; configurable failure hooks for tests) --- + + public int $vmidCounter = 100; + + /** @var array */ + public array $clonedVmids = []; + + /** @var array */ + public array $runningVmids = []; + + /** @var array */ + public array $cloudInitCalls = []; + + /** @var array */ + public array $resizeCalls = []; + + /** @var array */ + public array $firewallCalls = []; + + /** @var array recorded guest commands */ + public array $guestCommands = []; + + /** @var array */ + private array $guestScripts = []; + + public int $guestDefaultExit = 0; + + public string $guestDefaultOut = ''; + + public bool $guestAgentUp = true; + + public ?string $forceTaskStatus = null; // set to 'running' to force a poll + + public string $taskExitStatus = 'OK'; // set to a non-OK value to fail a task + + public function nextVmid(): int + { + return $this->vmidCounter++; + } + + public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string + { + $this->clonedVmids[] = $newVmid; + + return 'UPID:pve:qmclone:'.$newVmid; + } + + public function setCloudInit(string $node, int $vmid, array $params): void + { + $this->cloudInitCalls[] = $vmid; + } + + public function resizeDisk(string $node, int $vmid, string $disk, string $size): void + { + $this->resizeCalls[] = $vmid.':'.$disk.':'.$size; + } + + public function startVm(string $node, int $vmid): string + { + $this->runningVmids[] = $vmid; + + return 'UPID:pve:qmstart:'.$vmid; + } + + public function vmStatus(string $node, int $vmid): array + { + return ['status' => in_array($vmid, $this->runningVmids, true) ? 'running' : 'stopped']; + } + + public function guestAgentPing(string $node, int $vmid): bool + { + return $this->guestAgentUp; + } + + public function guestScript(string $substring, int $exitcode, string $out = ''): static + { + $this->guestScripts[$substring] = ['exitcode' => $exitcode, 'out-data' => $out]; + + return $this; + } + + public function guestExec(string $node, int $vmid, string $command): array + { + $this->guestCommands[] = $command; + + foreach ($this->guestScripts as $substring => $result) { + if (str_contains($command, $substring)) { + return $result; + } + } + + return ['exitcode' => $this->guestDefaultExit, 'out-data' => $this->guestDefaultOut]; + } + + public function guestRan(string $substring): bool + { + foreach ($this->guestCommands as $command) { + if (str_contains($command, $substring)) { + return true; + } + } + + return false; + } + + public function taskStatus(string $node, string $upid): array + { + return ['status' => $this->forceTaskStatus ?? 'stopped', 'exitstatus' => $this->taskExitStatus]; + } + + public function applyFirewall(string $node, int $vmid, array $rules): void + { + $this->firewallCalls[] = (string) $vmid; + } } diff --git a/app/Services/Proxmox/HttpProxmoxClient.php b/app/Services/Proxmox/HttpProxmoxClient.php index bf7f966..3b1733b 100644 --- a/app/Services/Proxmox/HttpProxmoxClient.php +++ b/app/Services/Proxmox/HttpProxmoxClient.php @@ -49,4 +49,80 @@ class HttpProxmoxClient implements ProxmoxClient { return $this->http()->get("/nodes/{$node}/storage")->throw()->json('data', []); } + + public function nextVmid(): int + { + return (int) $this->http()->get('/cluster/nextid')->throw()->json('data'); + } + + public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string + { + return (string) $this->http()->asForm() + ->post("/nodes/{$node}/qemu/{$templateVmid}/clone", ['newid' => $newVmid, 'name' => $name, 'full' => 1]) + ->throw()->json('data'); + } + + public function setCloudInit(string $node, int $vmid, array $params): void + { + $this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/config", $params)->throw(); + } + + public function resizeDisk(string $node, int $vmid, string $disk, string $size): void + { + $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/resize", ['disk' => $disk, 'size' => $size])->throw(); + } + + public function startVm(string $node, int $vmid): string + { + return (string) $this->http()->asForm() + ->post("/nodes/{$node}/qemu/{$vmid}/status/start")->throw()->json('data'); + } + + public function vmStatus(string $node, int $vmid): array + { + return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->throw()->json('data', []); + } + + public function guestAgentPing(string $node, int $vmid): bool + { + return $this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/agent/ping")->successful(); + } + + public function guestExec(string $node, int $vmid, string $command): array + { + $pid = $this->http()->asForm() + ->post("/nodes/{$node}/qemu/{$vmid}/agent/exec", ['command' => $command]) + ->throw()->json('data.pid'); + + // Poll exec-status until the command has exited. + for ($i = 0; $i < 600; $i++) { + $status = $this->http() + ->get("/nodes/{$node}/qemu/{$vmid}/agent/exec-status", ['pid' => $pid]) + ->throw()->json('data', []); + + if (($status['exited'] ?? 0)) { + return [ + 'exitcode' => (int) ($status['exitcode'] ?? 0), + 'out-data' => (string) ($status['out-data'] ?? ''), + ]; + } + + usleep(500000); + } + + return ['exitcode' => 255, 'out-data' => 'guest exec timed out']; + } + + public function taskStatus(string $node, string $upid): array + { + return $this->http()->get("/nodes/{$node}/tasks/{$upid}/status")->throw()->json('data', []); + } + + public function applyFirewall(string $node, int $vmid, array $rules): void + { + foreach ($rules as $rule) { + $this->http()->asForm()->post("/nodes/{$node}/qemu/{$vmid}/firewall/rules", $rule)->throw(); + } + $this->http()->asForm()->put("/nodes/{$node}/qemu/{$vmid}/firewall/options", ['enable' => 1])->throw(); + } } diff --git a/app/Services/Proxmox/ProxmoxClient.php b/app/Services/Proxmox/ProxmoxClient.php index 3ae0984..63aa014 100644 --- a/app/Services/Proxmox/ProxmoxClient.php +++ b/app/Services/Proxmox/ProxmoxClient.php @@ -23,4 +23,33 @@ interface ProxmoxClient /** @return array> */ public function nodeStorage(string $node): array; + + // --- VM lifecycle (Subsystem B) --- + + public function nextVmid(): int; + + /** Clone a template to a new VM; returns the task UPID. */ + public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string; + + /** @param array $params */ + public function setCloudInit(string $node, int $vmid, array $params): void; + + public function resizeDisk(string $node, int $vmid, string $disk, string $size): void; + + /** Start a VM; returns the task UPID. */ + public function startVm(string $node, int $vmid): string; + + /** @return array ['status' => 'running'|'stopped', …] */ + public function vmStatus(string $node, int $vmid): array; + + public function guestAgentPing(string $node, int $vmid): bool; + + /** @return array{exitcode: int, out-data: string} */ + public function guestExec(string $node, int $vmid, string $command): array; + + /** @return array{status: string, exitstatus?: string} */ + public function taskStatus(string $node, string $upid): array; + + /** @param array> $rules */ + public function applyFirewall(string $node, int $vmid, array $rules): void; } diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index 7a5992a..cbafb0c 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -30,6 +30,40 @@ it('removes a peer via the RemoveWireguardPeer job', function () { expect($hub->peers())->toBe([]); }); +it('provides a deterministic VM lifecycle (FakeProxmoxClient)', function () { + $pve = new FakeProxmoxClient; + + $a = $pve->nextVmid(); + $b = $pve->nextVmid(); + expect($b)->toBe($a + 1); + + $upid = $pve->cloneVm('pve', 9000, $a, 'nc-test'); + expect($upid)->toContain((string) $a) + ->and($pve->clonedVmids)->toContain($a) + ->and($pve->taskStatus('pve', $upid))->toBe(['status' => 'stopped', 'exitstatus' => 'OK']) + ->and($pve->vmStatus('pve', $a)['status'])->toBe('stopped'); + + $pve->startVm('pve', $a); + expect($pve->vmStatus('pve', $a)['status'])->toBe('running') + ->and($pve->guestAgentPing('pve', $a))->toBeTrue(); + + $pve->guestScript('occ status', 0, 'installed: true'); + expect($pve->guestExec('pve', $a, 'occ status')['out-data'])->toContain('installed') + ->and($pve->guestExec('pve', $a, 'whoami')['exitcode'])->toBe(0) + ->and($pve->guestRan('occ status'))->toBeTrue(); +}); + +it('can force a running task and a failed exit status (FakeProxmoxClient)', function () { + $pve = new FakeProxmoxClient; + + $pve->forceTaskStatus = 'running'; + expect($pve->taskStatus('pve', 'UPID')['status'])->toBe('running'); + + $pve->forceTaskStatus = null; + $pve->taskExitStatus = 'clone failed'; + expect($pve->taskStatus('pve', 'UPID')['exitstatus'])->toBe('clone failed'); +}); + it('allocates ips and tracks peers (FakeWireguardHub)', function () { $hub = new FakeWireguardHub;