> */ 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 = []; /** * Public rather than private: ScanForIntrusionsTest scripts entries by * direct array assignment (`$pve->guestScripts['nextcloud.log'] = […]`) * instead of guestScript(), because the fixture needs to omit `exitcode` * or `out-data` outright to simulate a guest that gives back nothing * usable — guestScript()'s signature always supplies both. * * @var array */ public 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; /** * Ein Herunterfahren, das gar nicht erst angenommen wird — der Knoten * antwortet nicht, das Token darf es nicht, die VMID gibt es dort nicht * mehr. Der echte Client ruft auf jeder Antwort `->throw()`, das ist also * kein erfundener Fall. * * Nicht dasselbe wie $shutdownIgnored: dort kommt die Bitte an und der Gast * befolgt sie nur nicht. Beide Fälle nebeneinander, weil ein Aufrufer, der * nur den einen kennt, den anderen für unmöglich hält. */ public bool $shutdownThrows = false; /** * Maschinen, die es gibt und die laufen — der gewöhnliche Eingangszustand * einer verkauften Cloud. * * Zwei Listen in einem Griff, weil sie zwei verschiedene Fragen * beantworten: `clonedVmids` sagt „diese VM steht auf dem Knoten" * (`vmExists()`), `runningVmids` sagt „und sie läuft gerade" * (`vmStatus()`). Eine Prüfung, die nur die zweite füllt, beschreibt eine * laufende Maschine, die es nicht gibt. */ public function running(int ...$vmids): static { foreach ($vmids as $vmid) { if (! in_array($vmid, $this->clonedVmids, true)) { $this->clonedVmids[] = $vmid; } if (! in_array($vmid, $this->runningVmids, true)) { $this->runningVmids[] = $vmid; } } return $this; } 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 { // Der Versuch wird auch dann vermerkt, wenn er scheitert: gefragt wurde, // und eine Prüfung soll das sehen können. $this->shutdownCalls[] = ['vmid' => $vmid, 'timeout' => $timeoutSeconds]; if ($this->shutdownThrows) { throw new \RuntimeException('Proxmox nahm das Herunterfahren nicht an (Attrappe).'); } // Eine stehende Maschine nimmt die Bitte NICHT klaglos an: Proxmox // antwortet auf `status/shutdown` gegen einen gestoppten Gast mit einem // Fehler, und der echte Client ruft auf jeder Antwort `->throw()`. // Diese Attrappe hat das jahrelang verschwiegen — und genau deshalb // konnte ein Aufrufer ohne Riegel davor durch jede Prüfung kommen. Der // Fall ist der Regelbetrieb, nicht der Rand: eine wegen offener Zahlung // gesperrte Cloud steht seit Wochen, und jeder Wiederholungslauf nach // einem Fehlschlag trifft eine Maschine, die der vorige Lauf // heruntergefahren hat. if (! in_array($vmid, $this->runningVmids, true)) { throw new \RuntimeException('Proxmox: VM '.$vmid.' is not running (Attrappe).'); } 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); } /** * Jede angenommene Löschanfrage — der AUFTRAG, nicht sein Vollzug. * * @var array */ public array $deletedVmids = []; /** * Die Maschinen, deren `qmdestroy` wirklich durchgelaufen ist — erst hier * ist die Platte fort. * * @var array */ public array $destroyedVmids = []; /** * Womit der `qmdestroy`-Auftrag endet, wenn er nicht mit dem allgemeinen * `$taskExitStatus` enden soll — z. B. 'VM is locked (backup)'. Der * gewöhnlichste Fehlschlag überhaupt: die nächtliche Sicherung eines * anderen Kunden hält die Ablage, und die Zerstörung scheitert Sekunden * NACH dem 200 auf die Anfrage. */ public ?string $destroyExitStatus = null; /** Der `qmdestroy`-Auftrag, der nicht fertig wird (hakende Ablage). */ public bool $destroyHangs = false; /** * Die Anfrage wird angenommen und liefert eine Kennung — mehr nicht. * * Bewusst NICHT der sofortige, untrügliche Vollzug, den diese Attrappe * bisher vorspielte: Proxmox prüft hier synchron nur Schutzflagge und * HA-Verwendung, forkt dann `qmdestroy` und antwortet mit 200. Solange der * Auftrag nicht durchgelaufen ist, steht die Maschine und liegt die Platte * — deshalb bleibt der VMID hier in `clonedVmids`/`runningVmids` stehen und * verschwindet erst in `taskStatus()`. */ public function deleteVm(string $node, int $vmid): string { $this->deletedVmids[] = $vmid; return 'UPID:pve:qmdestroy:'.$vmid; } /** * Der Vollzug, den die Attrappe an die Nachfrage nach dem Auftrag hängt. * * Ein Aufrufer, der die Kennung wegwirft, sieht die Maschine also nie * verschwinden — das ist der Punkt: er weiss nicht, ob sie fort ist. */ private function destroyTaskStatus(string $upid): array { if ($this->destroyHangs) { return ['status' => 'running']; } $ende = $this->destroyExitStatus ?? $this->taskExitStatus; // Wie beim echten vzdump: "WARNINGS: n" ist ein fertiger Auftrag, kein // Fehlschlag — die Maschine ist dann tatsächlich fort. if ($ende === 'OK' || str_starts_with($ende, 'WARNINGS')) { $vmid = (int) substr($upid, (int) strrpos($upid, ':') + 1); if (! in_array($vmid, $this->destroyedVmids, true)) { $this->destroyedVmids[] = $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]); } return ['status' => 'stopped', 'exitstatus' => $ende]; } 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; } /** * VMIDs whose guest agent calls throw instead of answering at all — a * genuinely unreachable guest (powered off, agent not started yet), as * opposed to `guestScripts`, which simulates a clean non-zero exit code. * The real client's `guestExec()` calls `->throw()` on every HTTP * response, so this is the failure mode a clean exit code cannot stand * in for. * * @var array */ public array $guestThrows = []; public function guestExec(string $node, int $vmid, string $command): array { $this->guestCommands[] = $command; if (isset($this->guestThrows[$vmid])) { throw $this->guestThrows[$vmid]; } $ergebnis = ['exitcode' => $this->guestDefaultExit, 'out-data' => $this->guestDefaultOut]; foreach ($this->guestScripts as $substring => $result) { if (str_contains($command, $substring)) { $ergebnis = $result; break; } } // Ein abschliessendes `|| true` ist eine Aussage der SHELL, nicht des // Aufrufers: der Gastagent startet jede Zeile über `/bin/sh -c`, und // dash beendet `A || true` immer mit 0 — egal, womit A endete. Ein // Fake, der hier trotzdem den verskripteten Fehlercode zurückgäbe, // liesse einen Test „beweisen", dass ein Befehl scheitert, den keine // echte Shell je scheitern lässt (`group:removeuser` auf eine Gruppe, // die es im Gast nicht gibt — siehe NextcloudUsers::applyRole()). // // Ein geworfener Fehler oben bleibt davon unberührt: ein nicht // erreichbarer Gastagent führt gar keine Shell aus, da gibt es kein // `|| true`, das etwas auffangen könnte. if (str_ends_with(rtrim($command), '|| true')) { $ergebnis['exitcode'] = 0; } return $ergebnis; } 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 { // Der Zerstörungsauftrag hat einen eigenen Ausgang und einen eigenen // Vollzug — siehe destroyTaskStatus(). if (str_contains($upid, 'qmdestroy')) { return $this->destroyTaskStatus($upid); } 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; } /** @var array jeder einzelne vzdump-Lauf, der angestossen wurde */ public array $backupCalls = []; /** * Was auf der Ablage wirklich liegt — vom Auftrag getrennt gefuehrt, weil * genau das der Punkt ist: backupNow() hinterlaesst hier standardmaessig * NICHTS, ein Test muss das Archiv wie ein echtes vzdump extra eintragen. * Schluessel ist die VMID als String, nicht die Ablage — reicht fuer die * Faelle, die dieser Fake abbilden muss. * * @var array>> */ public array $backups = []; public function backupNow(string $node, int $vmid, string $storage): string { $this->backupCalls[] = ['node' => $node, 'vmid' => $vmid, 'storage' => $storage]; return 'UPID:pve:vzdump:'.$vmid; } public function backupsFor(string $node, int $vmid, string $storage): array { return $this->backups[(string) $vmid] ?? []; } }