CluPilotCloud/tests/Feature/Admin/HostManagementTest.php

494 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Livewire\Admin\ConfirmRemoveHost;
use App\Livewire\Admin\HostCreate;
use App\Livewire\Admin\HostDetail;
use App\Livewire\Admin\Hosts;
use App\Livewire\Admin\ReissueTakeover;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Models\User;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\Jobs\CollectHostLoad;
use App\Provisioning\Jobs\PurgeHost;
use App\Provisioning\Jobs\RemoveWireguardPeer;
use App\Services\Proxmox\HostLoadSeries;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('gates the add-host page', function () {
$this->get(route('admin.hosts.create'))->assertRedirect(route('admin.login'));
$this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.create'))->assertForbidden();
$this->actingAs(admin(), 'operator')->get(route('admin.hosts.create'))->assertOk()->assertSee(__('hosts.create_title'));
});
it('gates the host detail page', function () {
$host = Host::factory()->create();
$this->get(route('admin.hosts.show', $host))->assertRedirect(route('admin.login'));
$this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.show', $host))->assertForbidden();
$this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))->assertOk()->assertSee($host->name);
});
it('lists real hosts for admins', function () {
Host::factory()->active()->create(['name' => 'pve-zzz-1']);
$this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->assertOk()->assertSee('pve-zzz-1');
});
it('creates a host and starts onboarding with an encrypted password', function () {
Queue::fake();
// Das Anlegen erzeugt jetzt auch das WireGuard-Schlüsselpaar und nimmt den
// Peer beim Hub auf, damit der Host schon im Tunnel bekannt ist, bevor er
// das erste Mal bootet (Spec §5). Ohne den gefälschten Hub griffe das nach
// `wg` auf einer Maschine, die keine wg0 hat.
fakeServices();
Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-7')
->set('datacenter', 'fsn')
->set('public_ip', '203.0.113.7')
->set('root_password', 'supersecret')
->call('save')
// KEINE Weiterleitung mehr, und das ist der Punkt: die Befehlszeile für
// das Rettungssystem gibt es nur hier und nur jetzt. Wer den Betreiber
// weiterschickt, schickt ihn von dem einzigen Wert weg, den er braucht.
->assertNoRedirect()
->assertSee('clupilot-bootstrap.sh');
$host = Host::query()->where('name', 'pve-fsn-7')->first();
expect($host)->not->toBeNull()->and($host->status)->toBe('pending');
$run = ProvisioningRun::query()->where('subject_id', $host->id)->first();
expect($run)->not->toBeNull()
->and($run->pipeline)->toBe('host')
->and(Crypt::decryptString($run->context('root_password')))->toBe('supersecret');
Queue::assertPushed(AdvanceRunJob::class);
});
it('rejects a duplicate public ip', function () {
Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
Host::factory()->create(['public_ip' => '203.0.113.99']);
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-dup')
->set('datacenter', 'fsn')
->set('public_ip', '203.0.113.99')
->set('root_password', 'supersecret')
->call('save')
->assertHasErrors(['public_ip']);
});
it('validates the add-host form', function () {
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', '')
->set('public_ip', 'not-an-ip')
->set('root_password', 'short')
->call('save')
->assertHasErrors(['name', 'public_ip', 'root_password']);
});
it('retries a failed run from the detail page', function () {
Queue::fake();
$host = Host::factory()->create(['status' => 'error']);
$run = ProvisioningRun::factory()->forHost($host)->create([
'status' => 'failed', 'current_step' => 3, 'error' => 'boom',
'started_at' => now()->subHour(),
]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('retry');
$run->refresh();
expect($run->status)->toBe('running')
->and($run->error)->toBeNull()
->and($run->started_at->greaterThan(now()->subMinute()))->toBeTrue()
->and($host->fresh()->status)->toBe('onboarding');
Queue::assertPushed(AdvanceRunJob::class);
});
it('deactivates the host and queues an async purge', function () {
Queue::fake();
$host = Host::factory()->active()->create(['wg_pubkey' => 'HOSTPUB=']);
ProvisioningRun::factory()->forHost($host)->create();
Livewire::actingAs(admin(), 'operator')
->test(ConfirmRemoveHost::class, ['uuid' => $host->uuid])
->call('remove')
->assertRedirect(route('admin.hosts'));
expect($host->fresh()->status)->toBe('disabled');
Queue::assertPushed(PurgeHost::class, fn ($job) => $job->uuid === $host->uuid);
});
it('purges a host: deletes its runs and record and queues peer removal', function () {
Queue::fake([RemoveWireguardPeer::class]);
$host = Host::factory()->active()->create(['wg_pubkey' => 'HOSTPUB=']);
ProvisioningRun::factory()->forHost($host)->create();
(new PurgeHost($host->uuid))->handle();
expect(Host::query()->find($host->id))->toBeNull();
Queue::assertPushed(RemoveWireguardPeer::class, fn ($job) => $job->publicKey === 'HOSTPUB=');
});
it('renders the live stepper for a running host', function () {
$host = Host::factory()->create(['status' => 'onboarding']);
ProvisioningRun::factory()->forHost($host)->create(['status' => 'running', 'current_step' => 2]);
$this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))
->assertOk()
->assertSee(__('hosts.step.prepare_base_system'))
->assertSee(__('hosts.progress'));
});
it('filters the host list by search and datacenter', function () {
Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']);
Host::factory()->create(['name' => 'pve-fsn-9', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.9']);
Host::factory()->create(['name' => 'pve-hel-9', 'datacenter' => 'hel', 'public_ip' => '203.0.113.19']);
Livewire::actingAs(admin(), 'operator')->test(Hosts::class)
->set('search', 'fsn-9')
->assertSee('pve-fsn-9')
->assertDontSee('pve-hel-9')
->set('search', '')
->set('datacenter', 'hel')
->assertSee('pve-hel-9')
->assertDontSee('pve-fsn-9');
});
it('edits host reserve and toggles maintenance', function () {
$host = Host::factory()->active()->create(['reserve_pct' => 15]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->call('saveReserve', 25)
->call('toggleMaintenance');
$host->refresh();
expect($host->reserve_pct)->toBe(25)->and($host->status)->toBe('disabled');
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('toggleMaintenance');
expect($host->fresh()->status)->toBe('active');
});
it('clamps host reserve to a sane range', function () {
$host = Host::factory()->active()->create(['reserve_pct' => 15]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->call('saveReserve', 500);
expect($host->fresh()->reserve_pct)->toBe(90);
});
it('reports host heartbeat health from last_seen_at', function () {
$online = Host::factory()->create(['last_seen_at' => now()->subMinutes(2)]);
$stale = Host::factory()->create(['last_seen_at' => now()->subMinutes(20)]);
$offline = Host::factory()->create(['last_seen_at' => now()->subHours(3)]);
$never = Host::factory()->create(['last_seen_at' => null]);
expect($online->healthState())->toBe('online')
->and($stale->healthState())->toBe('stale')
->and($offline->healthState())->toBe('offline')
->and($never->healthState())->toBe('offline');
});
/*
|--------------------------------------------------------------------------
| Die Befehlszeile noch einmal
|--------------------------------------------------------------------------
|
| Es gab sie genau einmal, auf der Seite direkt nach dem Anlegen. Wer sie dort
| nicht kopierte — oder wem sie wegbrach, wie beim WireGuard-Fehler bis
| 1.3.80 — hatte danach einen Host in der Liste, für den es keinen Weg zu
| einer Befehlszeile mehr gab: ein zweites Anlegen scheitert an der eindeutigen
| IP, also blieb nur Entfernen und von vorn.
*/
it('hands out a fresh command line for a host that already exists', function () {
Queue::fake();
$host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]);
$component = Livewire::actingAs(admin(), 'operator')
->test(ReissueTakeover::class, ['uuid' => $host->uuid])
->assertSet('command', null)
->call('issue');
$command = $component->get('command');
// Die ganze Zeile, nicht nur der Code — das ist der Punkt: sie trägt auch
// den privaten WireGuard-Schlüssel, die Tunneladresse und den FQDN.
expect($command)->toContain('clupilot-bootstrap.sh')
->and($command)->toContain('--code ')
->and($command)->toContain('--wg-private ')
->and($command)->toContain('--fqdn ');
});
it('makes the previously issued code worthless', function () {
// Sonst hätte eine Maschine, die im Rettungssystem hängengeblieben ist,
// weiter einen gültigen Ausweis für einen Host, den der Betreiber gerade
// neu übernimmt.
Queue::fake();
$host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]);
$alt = HostEnrolment::issue($host);
Livewire::actingAs(admin(), 'operator')
->test(ReissueTakeover::class, ['uuid' => $host->uuid])
->call('issue');
expect(HostEnrolment::resolve($alt))->toBeNull();
});
it('does not hand the takeover line to an operator who may not manage hosts', function () {
// In der Antwort steht ein privater WireGuard-Schlüssel. Ein Modal ist ohne
// die Route-Middleware der Seite erreichbar (R20), also prüft es selbst.
$host = Host::factory()->create(['status' => 'onboarding']);
Livewire::actingAs(operator('Read-only'), 'operator')
->test(ReissueTakeover::class, ['uuid' => $host->uuid])
->call('issue')
->assertForbidden();
});
it('refuses to rotate the credentials of a host that is already running', function () {
// Der Knopf steht dort gar nicht — aber ein Modal, das offen blieb, während
// der Host aktiv wurde, ruft die Methode trotzdem, und eine
// Livewire-Methode ist ohnehin von außen aufrufbar. Ein neuer Schlüssel
// schnitte die laufende Maschine aus dem Tunnel.
Queue::fake();
$host = Host::factory()->active()->create(['wg_ip' => null, 'wg_pubkey' => null]);
$alt = HostEnrolment::issue($host);
Livewire::actingAs(admin(), 'operator')
->test(ReissueTakeover::class, ['uuid' => $host->uuid])
->call('issue')
->assertSet('command', null)
->assertSet('refusal', __('hosts.reissue.not_eligible'));
// Und der bisherige Code gilt weiter: eine abgelehnte Ausstellung darf
// nichts entwerten.
expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id);
});
it('issues nothing while the tunnel settings are incomplete', function () {
// Sonst wird ein gültiger Code entwertet und dafür eine Zeile ausgegeben,
// der die Tunnel-Angaben fehlen — sie läuft und endet in einem Tunnel ohne
// Handshake, und das fällt erst auf der Maschine auf.
Queue::fake();
config()->set('provisioning.wireguard.hub_public_key', '');
$host = Host::factory()->create(['status' => 'onboarding', 'wg_ip' => null, 'wg_pubkey' => null]);
$alt = HostEnrolment::issue($host);
Livewire::actingAs(admin(), 'operator')
->test(ReissueTakeover::class, ['uuid' => $host->uuid])
->call('issue')
->assertSet('command', null)
->assertSet('refusal', __('hosts.takeover.missing_title'));
expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id);
});
it('treats a drained host as running, not as one waiting for takeover', function () {
// `disabled` sieht aus wie „noch nicht fertig" und ist das Gegenteil:
// toggleMaintenance() schaltet einen LAUFENDEN Host so still, um ihn aus
// der Platzierung zu nehmen. Ein Knopf, der dort eine Neuinstallation
// anbietet, trifft eine Produktionsmaschine.
Queue::fake();
$host = Host::factory()->active()->create(['status' => 'disabled', 'wg_ip' => null, 'wg_pubkey' => null]);
$alt = HostEnrolment::issue($host);
Livewire::actingAs(admin(), 'operator')
->test(ReissueTakeover::class, ['uuid' => $host->uuid])
->call('issue')
->assertSet('command', null)
->assertSet('refusal', __('hosts.reissue.not_eligible'));
expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id)
->and(ReissueTakeover::eligible($host))->toBeFalse();
});
// --- Detailseite: lebendig und fest getrennt ---------------------------------
it('shows the version number rather than the build hash it is buried in', function () {
// In der schmalen Karte wurde `pve-manager/9.2.6/7f8d…` mitten in der
// Bau-Kennung abgeschnitten — sichtbar war alles außer der Zahl, um die es
// geht.
fakeServices();
$host = Host::factory()->active()->create(['pve_version' => 'pve-manager/9.2.6/7f8d010005bd7231']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee('Proxmox VE 9.2.6');
});
it('gives every measurement its own tile, so none of them needs a legend', function () {
// Eine Reihe je Kachel: die Beschriftung benennt sie, und damit entfällt
// die Legende, die vorher unter der gemeinsamen Kurve stand.
$s = fakeServices();
// Bewusst unverwechselbare Zahlen: „50" träfe auch die 500 GB in der
// Instanzenliste darunter, und ein Test, der aus Versehen recht behält,
// prüft nichts. Siehe die lose Zahlen-Falle, die das Repo schon kennt.
$s['pve']->rrd = [
['time' => 1754035200, 'cpu' => 0.11, 'memused' => 11, 'memtotal' => 100, 'netin' => 1_048_576, 'netout' => 1_048_576],
['time' => 1754035260, 'cpu' => 0.37, 'memused' => 63, 'memtotal' => 100, 'netin' => 3_145_728, 'netout' => 5_242_880],
];
$host = Host::factory()->active()->create(['node' => 'pve-fns-1', 'api_token_ref' => 'ref']);
// Der Sammler läuft im provisioning-Container, die Seite liest nur. Genau
// diese Reihenfolge, sonst prüft der Test einen Weg, den es nicht gibt.
app(HostLoadSeries::class)->collect($host);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.cpu_load'))
->assertSee(__('hosts.detail.ram_load'))
->assertSee(__('hosts.detail.net_in'))
->assertSee(__('hosts.detail.net_out'))
// Der jüngste Messwert je Kachel, jeder eine Zahl, die sonst nirgends
// auf dieser Seite vorkommt.
->assertSee('37') // CPU
->assertSee('63') // RAM
->assertSee('3,00') // eingehend, MiB/s
->assertSee('5,00') // ausgehend
->assertDontSee(__('hosts.detail.no_load_short'));
});
it('shows nothing until the collector has run, because the page cannot fetch', function () {
// Der Fehler auf echter Hardware: der `app`-Container hängt nicht im
// WireGuard-Tunnel. Eine Seite, die selbst holt, zeigt dort für immer
// Leerstellen — und behauptet damit etwas über den Host, das nur über den
// Weg dorthin stimmt. Queue::fake() hält den Sammler an, damit dieser Test
// den Zustand VOR seinem ersten Lauf zeigt.
Queue::fake();
$s = fakeServices();
$s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.25, 'memused' => 1, 'memtotal' => 2]];
$host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.no_load_short'));
});
it('shows the public ip on the page, not only in the small print', function () {
// Die Adresse, unter der der Host wirklich erreichbar ist, stand nirgends
// in der Seite — nur klein unter der Überschrift.
fakeServices();
$host = Host::factory()->active()->create(['public_ip' => '49.12.121.79']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.public_ip'))
->assertSee('49.12.121.79');
});
it('keeps the reserve control with the settings, not on a reading tile', function () {
fakeServices();
$host = Host::factory()->active()->create(['reserve_pct' => 15]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.spec'))
->assertSee(__('hosts.detail.reserve_label'));
});
it('says a silent host has no measurements instead of drawing a quiet hour', function () {
// Der ganze Grund, warum HostLoadSeries eine leere Reihe liefert statt
// Nullen: eine Null-Linie behauptet, der Host habe nichts zu tun gehabt.
fakeServices(); // rrd bleibt leer
$host = Host::factory()->active()->create();
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
// Jede Messkachel sagt es für sich, und keine zeigt eine 0.
->assertSee(__('hosts.detail.no_load_short'))
->assertSee(__('hosts.detail.cpu_load'));
});
it('folds the finished onboarding away but keeps it reachable', function () {
fakeServices();
$host = Host::factory()->active()->create();
ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_COMPLETED]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
// Fünfzehn abgehakte Schritte sind auf einem laufenden Host kein
// Dauerinhalt — aufklappbar, nicht weg.
->assertSee('<details', false)
->assertSee(__('hosts.detail.onboarding_done'));
});
it('keeps the stepper open while the onboarding is still running', function () {
fakeServices();
$host = Host::factory()->create(['status' => 'onboarding']);
ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_RUNNING]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertDontSee('<details', false)
->assertSee(__('hosts.progress'));
});
it('asks for a collection at once when the page finds no measurements', function () {
// Sonst bleiben die Kacheln bis zum nächsten minütlichen Lauf leer, und ein
// leerer Kasten liest sich als "kaputt", nicht als "gleich".
Queue::fake();
fakeServices();
$host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]);
Queue::assertPushed(CollectHostLoad::class);
});
it('does not ask again when the measurements are already there', function () {
Queue::fake();
$s = fakeServices();
$s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]];
$host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
app(HostLoadSeries::class)->collect($host);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]);
Queue::assertNotPushed(CollectHostLoad::class);
});
it('draws an idle host as idle, on the page itself', function () {
// Der Test für den Maßstab prüfte bisher nur das Bauteil. Die Kacheln
// müssen ihm die Grenzen aber auch WIRKLICH mitgeben — ohne max=100 zeichnet
// ein Host mit 0,2 % CPU wieder eine Welle über die volle Höhe und
// widerspricht der Zahl daneben.
$s = fakeServices();
$s['pve']->rrd = collect(range(0, 20))->map(fn ($i) => [
'time' => 1754035200 + $i * 60,
'cpu' => 0.001 + ($i % 3) * 0.001, // 0,10,3 %
'memused' => 2 + ($i % 3),
'memtotal' => 100, // 24 %
])->all();
$host = Host::factory()->active()->create(['api_token_ref' => 'ref']);
app(HostLoadSeries::class)->collect($host);
$html = Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->html();
preg_match_all('/class="line" d="M([^"]+)"/', $html, $m);
expect($m[1])->not->toBeEmpty();
foreach ($m[1] as $d) {
foreach (preg_split('/\s+/', trim($d)) as $i => $n) {
// 30 ist die Grundlinie, 4 die Oberkante.
if ($i % 2 === 1) {
expect((float) $n)->toBeGreaterThan(28.0);
}
}
}
});
it('does not start a fleet-wide collection for a host the collector skips', function () {
// Ein Host mitten in der Übernahme hat noch keinen Token. Der Sammler
// überspringt ihn — ein Aufruf seiner Seite würde also die ganze Flotte
// abklappern und diese Kacheln trotzdem leer lassen.
Queue::fake();
fakeServices();
$host = Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => null]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]);
Queue::assertNotPushed(CollectHostLoad::class);
});