392 lines
16 KiB
PHP
392 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Provisioning\Jobs\RemoveWireguardPeer;
|
|
use App\Services\Dns\FileHostDnsDirectory;
|
|
use App\Services\Dns\HttpHetznerDnsClient;
|
|
use App\Services\Proxmox\FakeProxmoxClient;
|
|
use App\Services\Proxmox\HostLoadSeries;
|
|
use App\Services\Proxmox\HttpProxmoxClient;
|
|
use App\Services\Ssh\CommandResult;
|
|
use App\Services\Ssh\FakeRemoteShell;
|
|
use App\Services\Wireguard\FakeWireguardHub;
|
|
use App\Services\Wireguard\LocalWireguardHub;
|
|
use App\Support\PveVersion;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
it('scripts remote command output and records calls (FakeRemoteShell)', function () {
|
|
$shell = new FakeRemoteShell;
|
|
$shell->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');
|
|
});
|