feat(engine-b): ProxmoxClient VM-lifecycle extension (interface + fake + http)

nextVmid/cloneVm/setCloudInit/resizeDisk/startVm/vmStatus/guestAgentPing/
guestExec/taskStatus/applyFirewall. Fake is deterministic with configurable
task/guest failure hooks; Http polls UPID + guest exec-status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 11:41:48 +02:00
parent 770a6cf7cd
commit e5aeb3989e
4 changed files with 254 additions and 0 deletions

View File

@ -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<int, int> */
public array $clonedVmids = [];
/** @var array<int, int> */
public array $runningVmids = [];
/** @var array<int, string> */
public array $cloudInitCalls = [];
/** @var array<int, string> */
public array $resizeCalls = [];
/** @var array<int, string> */
public array $firewallCalls = [];
/** @var array<int, string> recorded guest commands */
public array $guestCommands = [];
/** @var array<string, array{exitcode:int,out-data:string}> */
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;
}
}

View File

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

View File

@ -23,4 +23,33 @@ interface ProxmoxClient
/** @return array<int, array<string, mixed>> */
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<string, mixed> $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<string, mixed> ['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<int, array<string, mixed>> $rules */
public function applyFirewall(string $node, int $vmid, array $rules): void;
}

View File

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