375 lines
13 KiB
PHP
375 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Proxmox;
|
|
|
|
use App\Models\Host;
|
|
|
|
/**
|
|
* Test double fuer den Proxmox-Client. Gastbefehle (`guestExec()`) werden
|
|
* nicht ausgefuehrt, sondern nur verskriptet und aufgezeichnet.
|
|
*
|
|
* `guestExec()` beachtet ein angehaengtes `|| true` und liefert dann immer
|
|
* Exitcode 0 — siehe die Begruendung dort. `App\Services\Ssh\FakeRemoteShell`,
|
|
* das Gegenstueck fuer SSH-Befehle, tut das ausdruecklich NICHT: es fuehrt
|
|
* keine echte Shell aus und kann `|| true` daher gar nicht abbilden. Die
|
|
* beiden Fakes behandeln dieselbe Shell-Redewendung also unterschiedlich, und
|
|
* wer nur den einen kennt, nimmt sein Verhalten sonst irrtuemlich vom anderen
|
|
* an — deshalb steht dieser Hinweis an beiden Stellen.
|
|
*/
|
|
class FakeProxmoxClient implements ProxmoxClient
|
|
{
|
|
public ?Host $host = null;
|
|
|
|
/** @var array<int, array<string, mixed>> */
|
|
public array $nodes = [['node' => 'pve']];
|
|
|
|
/** @var array<string, mixed> */
|
|
public array $status = [
|
|
'cpuinfo' => ['cpus' => 16],
|
|
'memory' => ['total' => 68719476736], // 64 GiB
|
|
'pveversion' => 'pve-manager/8.2.2',
|
|
];
|
|
|
|
/** @var array<int, array<string, mixed>> */
|
|
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<int, int> */
|
|
public array $clonedVmids = [];
|
|
|
|
/** @var array<int, int> */
|
|
public array $runningVmids = [];
|
|
|
|
/** @var array<int, string> */
|
|
public array $cloudInitCalls = [];
|
|
|
|
/** @var array<int, array<string, mixed>> vmid => params */
|
|
public array $cloudInitParams = [];
|
|
|
|
/** @var array<int, string> */
|
|
public array $resizeCalls = [];
|
|
|
|
/** @var array<int, string> */
|
|
public array $firewallCalls = [];
|
|
|
|
/** @var array<int, string> 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<string, array{exitcode?:int,out-data?:string}>
|
|
*/
|
|
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<int, array<string, mixed>> vmid => the VM's definition on disk */
|
|
public array $vmConfig = [];
|
|
|
|
/** @var array<int, array<string, mixed>> vmid => the definition the running guest booted with */
|
|
public array $bootedConfig = [];
|
|
|
|
/** @var array<int, array{vmid: int, timeout: int}> 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<int, array<string, mixed>>
|
|
*/
|
|
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<int, int>
|
|
*/
|
|
public array $templateVmids = [];
|
|
|
|
public function isTemplate(string $node, int $vmid): bool
|
|
{
|
|
return in_array($vmid, $this->templateVmids, true);
|
|
}
|
|
|
|
/** @var array<int, int> */
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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<int, \Throwable>
|
|
*/
|
|
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
|
|
{
|
|
return ['status' => $this->forceTaskStatus ?? 'stopped', 'exitstatus' => $this->taskExitStatus];
|
|
}
|
|
|
|
public function applyFirewall(string $node, int $vmid, array $rules): void
|
|
{
|
|
$this->firewallCalls[] = (string) $vmid;
|
|
}
|
|
|
|
/** @var array<int, string> */
|
|
public array $backupJobs = [];
|
|
|
|
public function createBackupJob(string $node, int $vmid, string $schedule): string
|
|
{
|
|
$id = 'backup-'.$vmid;
|
|
$this->backupJobs[] = $id;
|
|
|
|
return $id;
|
|
}
|
|
|
|
/** @var array<int, array{node: string, vmid: int, storage: string}> 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<string, array<int, array<string, mixed>>>
|
|
*/
|
|
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] ?? [];
|
|
}
|
|
}
|