312 lines
13 KiB
PHP
312 lines
13 KiB
PHP
<?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\PurgeHost;
|
|
use App\Provisioning\Jobs\RemoveWireguardPeer;
|
|
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();
|
|
});
|