CluPilotCloud/tests/Feature/Console/RefreshHostFirewallTest.php

141 lines
5.5 KiB
PHP

<?php
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\RunRunner;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Ssh\RemoteShell;
use Illuminate\Support\Facades\Queue;
// QUEUE_CONNECTION ist in dieser Suite `sync`: ohne das hier faehrt jedes
// `AdvanceRunJob::dispatch()` den Lauf sofort im Test, gegen eine echte,
// unerreichbare Adresse — zwei Minuten TCP-Zeitueberlauf je Host. Die Laeufe,
// die dieser Test wirklich fahren will, stoesst er unten selbst an.
beforeEach(fn () => Queue::fake());
/**
* Der Nachzieh-Griff für Hosts, die vor dem Frühwarnsystem übernommen wurden.
*
* Ihr /etc/nftables.conf kennt `clupilot_blocked` nicht, dort scheitert jedes
* `nft add element` — und die Sperre steht in Datenbank, Portal, Konsole und in
* der Mail an den Kunden als aktiv, in der Firewall aber nie. Der
* Übernahme-Lauf hilft nicht: `SecureHostFirewall` kürzt sich über den
* `host_firewall`-Brotkrumen ab, und der hängt am LAUF, nicht am Host — genau
* das behauptete das Runbook bis zu dieser Fix-Welle fälschlich.
*/
function uebernommenerHost(array $attributes = []): Host
{
return Host::factory()->active()->create(array_merge([
'node' => 'pve',
'ssh_host_key' => 'SHA256:abc',
], $attributes));
}
it('startet je uebernommenem Host einen host-firewall-Lauf', function () {
$host = uebernommenerHost();
$this->artisan('clupilot:refresh-host-firewall')->assertSuccessful();
$run = ProvisioningRun::query()->where('subject_id', $host->id)->firstOrFail();
expect($run->pipeline)->toBe('host-firewall')
->and($run->subject_type)->toBe(Host::class)
->and($run->current_step)->toBe(0);
});
it('schreibt das Regelwerk MIT den Sperrmengen, obwohl der Host laengst uebernommen ist', function () {
// Der eigentliche Punkt: der Brotkrumen des ALTEN Laufs darf den neuen
// nicht abkuerzen. Ohne den eigenen Lauf taete der Schritt nichts und
// meldete trotzdem „erledigt".
$shell = new FakeRemoteShell;
app()->instance(RemoteShell::class, $shell);
$host = uebernommenerHost();
// Der Brotkrumen der Uebernahme, an ihrem eigenen Lauf.
$alt = ProvisioningRun::factory()->forHost($host)->create([
'status' => ProvisioningRun::STATUS_COMPLETED,
]);
$alt->resources()->create([
'host_id' => $host->id,
'kind' => 'host_firewall',
'external_id' => 'nftables',
]);
$this->artisan('clupilot:refresh-host-firewall')->assertSuccessful();
$neu = ProvisioningRun::query()->where('pipeline', 'host-firewall')->firstOrFail();
app(RunRunner::class)->advance($neu);
$regelwerk = $shell->files()['/etc/nftables.conf'] ?? '';
expect($regelwerk)->toContain('clupilot_blocked')
->and($neu->fresh()->status)->toBe(ProvisioningRun::STATUS_COMPLETED);
});
it('laesst einen Host in Ruhe, der keinen Tunnel hat', function () {
// Der Schritt verbindet ueber die WireGuard-Adresse und prueft den Tunnel
// von der Hostseite. Ohne beides gibt es nichts nachzuziehen.
Host::factory()->create(['status' => 'pending']);
$this->artisan('clupilot:refresh-host-firewall')->assertSuccessful();
expect(ProvisioningRun::query()->where('pipeline', 'host-firewall')->count())->toBe(0);
});
it('faengt einen Host nicht zweimal an, solange ein Lauf offen ist', function () {
$host = uebernommenerHost();
ProvisioningRun::factory()->forHost($host)->create([
'status' => ProvisioningRun::STATUS_RUNNING,
]);
$this->artisan('clupilot:refresh-host-firewall')->assertSuccessful();
expect(ProvisioningRun::query()->where('pipeline', 'host-firewall')->count())->toBe(0);
});
it('aendert unter --dry-run gar nichts', function () {
uebernommenerHost();
$this->artisan('clupilot:refresh-host-firewall', ['--dry-run' => true])->assertSuccessful();
expect(ProvisioningRun::count())->toBe(0);
});
it('nimmt mit --host genau einen', function () {
$gemeint = uebernommenerHost(['name' => 'pve-fsn-07']);
uebernommenerHost(['name' => 'pve-fsn-08']);
$this->artisan('clupilot:refresh-host-firewall', ['--host' => 'pve-fsn-07'])->assertSuccessful();
expect(ProvisioningRun::query()->where('pipeline', 'host-firewall')->pluck('subject_id')->all())
->toBe([$gemeint->id]);
});
it('stellt einen laufenden Host NICHT auf error, wenn der Nachzieh-Lauf scheitert', function () {
// Deshalb eine eigene Pipeline und nicht 'host': RunRunner::failRun()
// loest den Subjekt-Haken nur aus, wenn der gescheiterte Lauf DER Lauf des
// Subjekts ist. Ein Nachziehen darf einen bezahlten, laufenden Host nicht
// als kaputt markieren.
$shell = new FakeRemoteShell;
$shell->failConnect = true;
app()->instance(RemoteShell::class, $shell);
$host = uebernommenerHost();
$this->artisan('clupilot:refresh-host-firewall')->assertSuccessful();
$run = ProvisioningRun::query()->where('pipeline', 'host-firewall')->firstOrFail();
// Bis zum Ende der Versuche fahren, statt eine Zahl zu raten: was hier
// zaehlt, ist der Zustand des HOSTS, nachdem der Lauf endgueltig
// gescheitert ist.
for ($i = 0; $i < 20 && ! in_array($run->fresh()->status, [ProvisioningRun::STATUS_FAILED, ProvisioningRun::STATUS_COMPLETED], true); $i++) {
$run->forceFill(['next_attempt_at' => null])->save();
app(RunRunner::class)->advance($run);
}
expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_FAILED)
->and($host->fresh()->status)->toBe('active');
});