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
parent
770a6cf7cd
commit
e5aeb3989e
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue