CluPilotCloud/tests/Feature/Provisioning/ServicesTest.php

354 lines
15 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;
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.
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 = app(HostLoadSeries::class)->forHost(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 = app(HostLoadSeries::class)->forHost(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 = app(HostLoadSeries::class)->forHost(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 = app(HostLoadSeries::class)->forHost(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 = app(HostLoadSeries::class)->forHost(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 = app(HostLoadSeries::class)->forHost($host);
expect($series)->toHaveKeys(['netin', 'netout'])
->and($series['cpu'])->toBe([10.0]); // frisch geholt, nicht der alte Eintrag
});
it('does not ask the host twice while the samples are still fresh', function () {
// Die RRD wird einmal pro Minute fortgeschrieben. Zwei offene Konsolen
// dürfen daraus nicht zwei Anfragen über den Tunnel machen.
Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1, 2)]])]);
$host = proxmoxHost();
app(HostLoadSeries::class)->forHost($host);
app(HostLoadSeries::class)->forHost($host);
Http::assertSentCount(1);
});
// --- 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');
});