> */ public array $nodes = [['node' => 'pve']]; /** @var array */ public array $status = [ 'cpuinfo' => ['cpus' => 16], 'memory' => ['total' => 68719476736], // 64 GiB 'pveversion' => 'pve-manager/8.2.2', ]; /** @var array> */ public array $storage = [ ['storage' => 'local', 'type' => 'dir', 'content' => 'iso,vztmpl,backup', 'total' => 1099511627776], ['storage' => 'local-lvm', 'type' => 'lvmthin', 'content' => 'images,rootdir', 'total' => 1099511627776], // 1 TiB ]; public function forHost(Host $host): static { $this->host = $host; return $this; } public function listNodes(): array { return $this->nodes; } public function nodeStatus(string $node): array { return $this->status; } public function nodeStorage(string $node): array { 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> vmid => params */ public array $cloudInitParams = []; /** @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; $this->cloudInitParams[$vmid] = $params; // A config PUT lands on the VM's DEFINITION, not on the qemu process // that is already running with the old one. Kept apart from // $bootedConfig for exactly that reason — it is the whole point of a // pending restart, and a fake that merged the two would let a test // "prove" a resize had taken effect on a machine that never stopped. $this->vmConfig[$vmid] = array_merge($this->vmConfig[$vmid] ?? [], $params); } public function resizeDisk(string $node, int $vmid, string $disk, string $size): void { $this->resizeCalls[] = $vmid.':'.$disk.':'.$size; } /** @var array> vmid => the VM's definition on disk */ public array $vmConfig = []; /** @var array> vmid => the definition the running guest booted with */ public array $bootedConfig = []; /** @var array every graceful shutdown that was asked for */ public array $shutdownCalls = []; /** * A guest that will not go away when it is asked to — an unresponsive * ACPI handler, a machine busy with something it will not be interrupted * in. Set it to prove what the product does when the polite request is * ignored, which is the case the whole shutdown design turns on. */ public bool $shutdownIgnored = false; public function startVm(string $node, int $vmid): string { $this->runningVmids[] = $vmid; // A cold boot is where the definition becomes what the guest actually // runs on. This is the only place $bootedConfig is filled. $this->bootedConfig[$vmid] = $this->vmConfig[$vmid] ?? []; return 'UPID:pve:qmstart:'.$vmid; } public function shutdownVm(string $node, int $vmid, int $timeoutSeconds): string { $this->shutdownCalls[] = ['vmid' => $vmid, 'timeout' => $timeoutSeconds]; if (! $this->shutdownIgnored) { $this->runningVmids = array_values(array_diff($this->runningVmids, [$vmid])); unset($this->bootedConfig[$vmid]); } return 'UPID:pve:qmshutdown:'.$vmid; } /** Set to e.g. 'clone' to simulate a VM still locked by a running clone task. */ public ?string $vmLock = null; /** Cumulative counters per vmid, as Proxmox reports them: [netin, netout]. */ public array $counters = []; /** vmid => MB/s currently configured, or null when unlimited. */ public array $networkRates = []; public function vmStatus(string $node, int $vmid): array { $status = [ 'status' => in_array($vmid, $this->runningVmids, true) ? 'running' : 'stopped', 'lock' => $this->vmLock, 'netin' => $this->counters[$vmid]['netin'] ?? 0, 'netout' => $this->counters[$vmid]['netout'] ?? 0, ]; // Only for a guest this fake has actually booted. Proxmox reports what // the RUNNING machine has, so a test that simply put a vmid in // $runningVmids has said nothing about its size and must not have an // invented figure answered back to it. $booted = $this->bootedConfig[$vmid] ?? []; if (isset($booted['cores'])) { $status['cpus'] = (int) $booted['cores']; } if (isset($booted['memory'])) { $status['maxmem'] = (int) $booted['memory'] * 1048576; // Proxmox reports bytes } return $status; } public function setNetworkRate(string $node, int $vmid, ?float $mbytesPerSecond): void { $this->networkRates[$vmid] = $mbytesPerSecond; } /** * The node's recorded history, as PVE would hand it back. Empty by default: * a host nobody scripted samples for has none. * * @var array> */ public array $rrd = []; public function nodeRrdData(string $node, string $timeframe = 'hour'): array { return $this->rrd; } public function vmExists(string $node, int $vmid): bool { return in_array($vmid, $this->clonedVmids, true) || in_array($vmid, $this->runningVmids, true); } /** * Deliberately NOT derived from clonedVmids: existing and being a template * are the two different facts the real client tells apart, and a fake that * conflated them would let a test pass that the API would not. * * @var array */ public array $templateVmids = []; public function isTemplate(string $node, int $vmid): bool { return in_array($vmid, $this->templateVmids, true); } /** @var array */ public array $deletedVmids = []; public function deleteVm(string $node, int $vmid): void { $this->deletedVmids[] = $vmid; $this->clonedVmids = array_values(array_diff($this->clonedVmids, [$vmid])); $this->runningVmids = array_values(array_diff($this->runningVmids, [$vmid])); unset($this->vmConfig[$vmid], $this->bootedConfig[$vmid]); } 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; } /** @var array */ public array $backupJobs = []; public function createBackupJob(string $node, int $vmid, string $schedule): string { $id = 'backup-'.$vmid; $this->backupJobs[] = $id; return $id; } }