script('dpkg -l proxmox-ve', CommandResult::success('ii proxmox-ve 8.2')); $shell->connectWithPassword('1.2.3.4', 'root', 'pw'); expect($shell->run('dpkg -l proxmox-ve')->stdout)->toContain('proxmox-ve') ->and($shell->run('whoami')->ok())->toBeTrue() // default success ->and($shell->ran('dpkg -l proxmox-ve'))->toBeTrue(); $shell->putFile('/etc/wireguard/wg0.conf', '[Interface]'); expect($shell->files()['/etc/wireguard/wg0.conf'])->toBe('[Interface]') ->and($shell->connectionsWith('password'))->toHaveCount(1); }); it('removes a peer via the RemoveWireguardPeer job', function () { $hub = new FakeWireguardHub; $hub->addPeer('PK', '10.66.0.9'); (new RemoveWireguardPeer('PK'))->handle($hub); expect($hub->peerIps())->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; $a = $hub->allocateIp(); $b = $hub->allocateIp(); expect($a)->not->toBe($b); $hub->addPeer('PUBKEY', '10.66.0.9'); expect($hub->peerIps())->toBe(['PUBKEY' => '10.66.0.9']); $hub->removePeer('PUBKEY'); expect($hub->peerIps())->toBe([]); }); it('allocates addresses within a /24 subnet, skipping hub and used (LocalWireguardHub)', function () { config()->set('provisioning.wireguard.subnet', '10.66.0.0/24'); config()->set('provisioning.wireguard.hub_ip', '10.66.0.1'); Host::factory()->create(['wg_ip' => '10.66.0.2']); // .0 network, .1 hub, .2 used → first free is .3 expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.3'); }); it('respects a non-/24 subnet (LocalWireguardHub)', function () { config()->set('provisioning.wireguard.subnet', '10.66.5.128/25'); config()->set('provisioning.wireguard.hub_ip', '10.66.5.129'); // network .128, hub .129 → first free is .130 expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.5.130'); }); it('reports node capacity for a host (FakeProxmoxClient)', function () { $host = Host::factory()->create(['wg_ip' => '10.66.0.5']); $client = (new FakeProxmoxClient)->forHost($host); expect($client->listNodes())->not->toBeEmpty() ->and($client->nodeStatus('pve')['memory']['total'])->toBeGreaterThan(0) ->and($client->nodeStorage('pve')[0]['total'])->toBeGreaterThan(0); }); // --- HttpProxmoxClient::isTemplate ------------------------------------------ // // Die eine Frage in diesem Client, deren falsche Antwort ETWAS ZERSTÖRT: // BuildVmTemplate liest ein `false` als „Vorlage fehlt" und startet einen Bau, // der mit `qm destroy --purge` anfängt. Eine Störung von zwanzig Sekunden darf // deshalb niemals wie eine fehlende Vorlage aussehen. function proxmoxHost(): Host { return Host::factory()->create(['wg_ip' => '10.66.0.5', 'api_token_ref' => 'automation@pve!clupilot=secret']); } it('never mistakes a proxmox outage for a missing template', function () { Http::fake(['*' => Http::response('gateway timeout', 502)]); // Muss WERFEN, nicht false liefern: der Aufrufer fängt Ausnahmen und // wiederholt, und genau dieser Weg rettet die vorhandene Vorlage. expect(fn () => app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000)) ->toThrow(RequestException::class); }); it('reports a vmid that is not on the node at all as no template', function () { // Die Liste ist eindeutig, wo der Rückgabecode es nicht ist: Proxmox // beantwortet die Konfiguration einer nicht vorhandenen VM mit 500, nicht // mit 404 — „nicht da" und „kaputt" wären am Code nicht zu unterscheiden. Http::fake(['*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 101, 'template' => 0]]])]); expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse(); }); it('confirms a template from the configuration document', function () { Http::fake([ '*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 9000]]]), '*/nodes/pve/qemu/9000/config' => Http::response(['data' => ['template' => 1, 'name' => 'clupilot-nextcloud-vorlage']]), ]); expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeTrue(); }); it('reports an ordinary virtual machine carrying the vmid as no template', function () { // Der abgebrochene Bau: VMID 9000 ist da, `template: 1` ist es nicht. Http::fake([ '*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 9000]]]), '*/nodes/pve/qemu/9000/config' => Http::response(['data' => ['name' => 'halbfertig']]), ]); expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse(); }); // --- HostLoadSeries --------------------------------------------------------- // // Die Stundenkurve auf der Host-Seite. Proxmox zeichnet CPU und Speicher // ohnehin auf; hier wird daraus nur gerechnet — und die eine Regel eingehalten, // die eine Messreihe ehrlich hält: eine Lücke ist keine Null. /** Holen (dort, wo der Tunnel ist) und lesen (dort, wo die Seite ist). */ function collectAndRead(Host $host): array { app(HostLoadSeries::class)->collect($host); return app(HostLoadSeries::class)->forHost($host); } function rrdRow(int $time, ?float $cpu, ?int $memused, ?int $memtotal): array { return array_filter([ 'time' => $time, 'cpu' => $cpu, 'memused' => $memused, 'memtotal' => $memtotal, ], fn ($v) => $v !== null); } it('turns proxmox rrd rows into cpu and ram percentages', function () { Http::fake(['*/rrddata*' => Http::response(['data' => [ rrdRow(1754035200, 0.0125, 8_589_934_592, 68_719_476_736), // 1.25 % CPU, 12.5 % RAM rrdRow(1754035260, 0.5, 34_359_738_368, 68_719_476_736), // 50 % CPU, 50 % RAM ]])]); $series = collectAndRead(proxmoxHost()); expect($series['cpu'])->toBe([1.25, 50.0]) ->and($series['ram'])->toBe([12.5, 50.0]) ->and($series['labels'])->toHaveCount(2); }); it('turns the recorded network rates into mebibytes per second', function () { // Proxmox zählt Bytes je Sekunde. 2 097 152 B/s sind 2 MiB/s — und die // Einheit steht in der Kachel daneben, damit niemand MB und MiB rät. Http::fake(['*/rrddata*' => Http::response(['data' => [ ['time' => 1754035200, 'netin' => 2_097_152, 'netout' => 524_288], ]])]); $series = collectAndRead(proxmoxHost()); expect($series['netin'])->toBe([2.0]) ->and($series['netout'])->toBe([0.5]); }); it('leaves a missing network sample as a gap too', function () { Http::fake(['*/rrddata*' => Http::response(['data' => [ ['time' => 1754035200, 'netin' => 1_048_576, 'netout' => 1_048_576], ['time' => 1754035260], // Proxmox lässt die Felder weg ]])]); $series = collectAndRead(proxmoxHost()); expect($series['netin'])->toBe([1.0, null]) ->and($series['netout'])->toBe([1.0, null]); }); it('leaves a missing sample as a gap, never as zero', function () { // Dieselbe Regel, die in instance_metrics begründet steht: eine Messung, // die scheiterte, muss von einer Messung mit dem Wert null unterscheidbar // sein. Eine Null-Linie behauptet einen ruhigen Host. Http::fake(['*/rrddata*' => Http::response(['data' => [ rrdRow(1754035200, 0.25, 34_359_738_368, 68_719_476_736), rrdRow(1754035260, null, null, null), // Proxmox lässt die Felder weg ]])]); $series = collectAndRead(proxmoxHost()); expect($series['cpu'])->toBe([25.0, null]) ->and($series['ram'])->toBe([50.0, null]); }); it('reports no series at all when the host does not answer', function () { // Nicht eine Reihe aus Nullen: der Host sagt nichts, und die Tafel muss das // sagen dürfen statt eine ruhige Stunde zu zeichnen. Http::fake(['*' => Http::response('gateway timeout', 502)]); $series = collectAndRead(proxmoxHost()); expect($series['cpu'])->toBe([]) ->and($series['ram'])->toBe([]) ->and($series['available'])->toBeFalse(); }); it('ignores a cached series written by an older version of itself', function () { // Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus der Fassung // vor den Netz-Reihen kennt netin/netout nicht — gelesen wie einer von // heute, stirbt die Host-Seite 55 Sekunden lang an einem fehlenden // Schlüssel, und zwar genau in dem Moment, in dem jemand nachsieht, ob das // Update durch ist. $host = proxmoxHost(); Cache::put('host-load:'.$host->id, ['labels' => [1], 'cpu' => [1.0], 'ram' => [2.0], 'available' => true], 600); Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1_048_576, 2_097_152)]])]); $series = collectAndRead($host); expect($series)->toHaveKeys(['netin', 'netout']) ->and($series['cpu'])->toBe([10.0]); // frisch geholt, nicht der alte Eintrag }); it('never reaches for the host when the page asks', function () { // DER Fehler, der die Kacheln auf echter Hardware leer ließ: nur der // provisioning-Container hängt im WireGuard-Tunnel (NET_ADMIN, /dev/net/tun). // Der `app`-Container, der die Seite rendert, erreicht 10.66.0.x gar nicht — // ein API-Aufruf aus render() konnte deshalb nie etwas anderes als eine // Zeitüberschreitung sein. Gelesen wird ab jetzt ausschließlich, was der // Sammler hinterlegt hat. Http::fake(); $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); Http::assertNothingSent(); expect($series['available'])->toBeFalse(); }); it('serves the page from what the collector left behind', function () { Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.4, 1_048_576, 2_097_152)]])]); $host = proxmoxHost(); app(HostLoadSeries::class)->collect($host); // im Tunnel Http::fake(); // ab hier darf nichts mehr raus $series = app(HostLoadSeries::class)->forHost($host); Http::assertNothingSent(); expect($series['cpu'])->toBe([40.0]) ->and($series['available'])->toBeTrue(); }); it('leaves no measurements behind when the host cannot be reached', function () { // Und schreibt den Grund ins Protokoll, statt ihn zu verschlucken: eine // leere Kachel sagt nicht, ob der Host schweigt oder der Weg fehlt. Log::spy(); Http::fake(['*' => Http::response('gateway timeout', 502)]); $host = proxmoxHost(); app(HostLoadSeries::class)->collect($host); expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeFalse(); Log::shouldHaveReceived('warning')->once(); }); // --- PveVersion ------------------------------------------------------------- it('reads the version out of what proxmox calls itself', function () { $v = PveVersion::parse('pve-manager/9.2.6/7f8d010005bd7231'); expect($v['version'])->toBe('9.2.6') ->and($v['build'])->toBe('7f8d010005bd7231'); }); it('shows an unexpected version string unchanged rather than nothing', function () { // Eine Anzeige, die an einer ungewohnten Form lieber nichts zeigt, ist // schlechter als eine, die das Rohe durchreicht. $v = PveVersion::parse('irgendwas-anderes'); expect($v['version'])->toBe('irgendwas-anderes') ->and($v['build'])->toBeNull(); }); it('has nothing to say about a host that never reported a version', function () { expect(PveVersion::parse(null)['version'])->toBeNull(); }); // Der Hetzner-DNS-Client steht in HetznerCloudDnsTest — seit dem Umzug auf die // Cloud-API (RRSets statt einzelner Records) gehört dazu mehr als zwei Fälle. // Die alte Seitenlauf-Falle, die hier stand, kann es nicht mehr geben: es gibt // keinen Lookup mehr, den sie überspringen könnte. Warum das so ist, steht im // Kopfkommentar von HttpHetznerDnsClient. it('writes and removes a real hostsdir entry on disk (FileHostDnsDirectory)', function () { $dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid(); config()->set('provisioning.dns.hosts_dir', $dir); $writer = new FileHostDnsDirectory; // The directory itself is created on first write — dnsmasq --hostsdir // only needs it to exist, not to be pre-provisioned by anything else. $writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.11'); $path = $dir.'/fsn-01.hosts'; expect(is_file($path))->toBeTrue() ->and(trim(file_get_contents($path)))->toBe('10.66.0.11 fsn-01.node.clupilot.com'); // Overwrite is the update path — RegisterHostDns calls write() again on // retry with the same name, and that must replace, not append. $writer->write('fsn-01', 'fsn-01.node.clupilot.com', '10.66.0.99'); expect(trim(file_get_contents($path)))->toBe('10.66.0.99 fsn-01.node.clupilot.com'); $writer->remove('fsn-01'); expect(is_file($path))->toBeFalse(); File::deleteDirectory($dir); }); it('treats removing a name that was never written as done (FileHostDnsDirectory)', function () { $dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid(); config()->set('provisioning.dns.hosts_dir', $dir); // Mirrors PurgeHost's guard (filled($host->dns_name)) not always holding // — a host that never got a name still has to purge cleanly. (new FileHostDnsDirectory)->remove('never-written'); })->throwsNoExceptions(); it('starts people at the bottom of the subnet and machines at the host offset', function () { // Ein Bereich, keine zweite Grenze: `from` verschiebt nur den Anfang. Liefe // er als Obergrenze, stünde die Vergabe irgendwann still, obwohl das // Subnetz noch Platz hat. expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.2') ->and((new LocalWireguardHub)->allocateIp(100))->toBe('10.66.0.100'); }); it('falls back into the lower range when the host offset does not fit the subnet', function () { // Ein /26 hat 64 Adressen; der Versatz von 100 liegt ausserhalb. Eine // Vergabe, die dann "Subnetz erschöpft" meldet, während unten alles frei // ist, wäre die schlechtere Antwort — der Versatz ist eine Bevorzugung, // keine Bedingung. config()->set('provisioning.wireguard.subnet', '10.66.7.0/26'); config()->set('provisioning.wireguard.hub_ip', '10.66.7.1'); expect((new LocalWireguardHub)->allocateIp(100))->toBe('10.66.7.2'); });