CluPilotCloud/tests/Feature/Console/TearDownEndedInstancesTest.php

332 lines
15 KiB
PHP

<?php
// tests/Feature/Console/TearDownEndedInstancesTest.php
use App\Actions\ArchiveAndTearDown;
use App\Models\Host;
use App\Models\Instance;
use App\Provisioning\Jobs\TearDownInstance;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
/**
* Der Zeitplan-Griff für den Abbau: wer ausgewählt wird, wer nicht, und was ein
* Probelauf anfassen darf (nämlich nichts).
*
* Der Befehl fasst selbst keine Maschine an — er reiht `TearDownInstance` auf
* der `provisioning`-Warteschlange ein, weil nur dieser Container im
* WireGuard-Tunnel hängt. Ohne `Queue::fake()` läuft der Auftrag hier trotzdem
* im selben Prozess ab: `Tests\TestCase::setUp()` stellt diese Verbindung für
* die ganze Suite auf `sync`. Genau das ist hier erwünscht, denn dann zeichnet
* die Attrappe des Proxmox-Clients jeden Aufruf auf — eine Prüfung, die nur
* „ein Auftrag wurde eingereiht" behauptet, sagt über ein Löschen nichts.
*/
/**
* Eine Instanz im Zustand NACH dem Laufzeitende (status 'ended', Adresse weg),
* mit Host und VMID — ohne beides gibt es nichts abzubauen.
*
* Eigener Name statt der Vorlage aus ArchiveAndTearDownTest: Pest führt diese
* Funktionen in einem gemeinsamen Namensraum, zwei gleichnamige wären ein
* fataler Fehler beim Laden.
*/
function faelligeInstanz(int $vmid, ?Carbon $endeteAm = null, ?string $subdomain = null): Instance
{
$host = Host::factory()->active()->create(['node' => 'pve']);
return Instance::factory()->create([
'host_id' => $host->id,
'vmid' => $vmid,
'status' => 'ended',
'subdomain' => $subdomain ?? 'nc-'.$vmid,
'service_ends_at' => $endeteAm ?? now()->subDays(20),
]);
}
/** Eine Attrappe, auf deren Ablage ein frisches Archiv liegt — der Erfolgsfall. */
function attrappeMitArchiv(int ...$vmids): FakeProxmoxClient
{
$pve = new FakeProxmoxClient;
foreach ($vmids as $vmid) {
// Die Maschine gibt es, und sie läuft: der gewöhnliche Eingangszustand
// einer gekündigten Cloud. Die Attrappe weist die Bitte zum
// Herunterfahren gegen eine stehende Maschine ab, wie Proxmox es tut.
$pve->clonedVmids[] = $vmid;
$pve->runningVmids[] = $vmid;
$pve->backups[(string) $vmid] = [[
'volid' => 'local:backup/vzdump-qemu-'.$vmid.'.vma.zst',
'ctime' => now()->timestamp,
]];
}
app()->instance(ProxmoxClient::class, $pve);
return $pve;
}
it('fasst unter --dry-run nachweislich nichts an', function () {
// Die Zusicherung, die der ganze Schalter ist. Nicht „es wurde nichts
// eingereiht" — sondern: auf dem Knoten wurde nichts heruntergefahren,
// nichts gesichert und nichts gelöscht, und am Datensatz steht danach
// dasselbe wie davor.
$pve = attrappeMitArchiv(201);
$instance = faelligeInstanz(vmid: 201);
$this->artisan('clupilot:tear-down-ended-instances --dry-run')->assertSuccessful();
expect($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->archive_volid)->toBeNull()
->and($instance->fresh()->teardown_error)->toBeNull();
});
it('nennt im Probelauf trotzdem, was abgebaut würde', function () {
// Ein Probelauf, der schweigt, ist kein Probelauf. Er ist die einzige
// Möglichkeit, vor dem ersten scharfen Lauf zu sehen, wen es trifft.
attrappeMitArchiv(201);
faelligeInstanz(vmid: 201, subdomain: 'nc-probelauf');
$this->artisan('clupilot:tear-down-ended-instances --dry-run')
->expectsOutputToContain('nc-probelauf')
->expectsOutputToContain('Nichts wurde geändert.')
->assertSuccessful();
});
it('baut ab, was seit mehr als vierzehn Tagen fällig ist', function () {
$pve = attrappeMitArchiv(201);
$instance = faelligeInstanz(vmid: 201);
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
expect($pve->deletedVmids)->toBe([201])
->and($instance->fresh()->torn_down_at)->not->toBeNull()
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst');
});
it('lässt eine Instanz innerhalb der vierzehn Tage stehen', function () {
// Die Frist ist der Sinn der Sache: bis hierher ist die Maschine eines
// Menschen unverändert da, der es sich vielleicht gerade anders überlegt.
// Geprüft am Knoten und nicht an der Warteschlange — was eingereiht wird,
// ist eine Zwischenstation, was heruntergefahren wird, ist die Tatsache.
$pve = attrappeMitArchiv(201);
$instance = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(3));
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
expect($pve->shutdownCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull();
});
it('überspringt eine Instanz ohne Host und sagt den Grund', function () {
// Es gibt nichts abzubauen, und ein Auftrag dafür verbrauchte nur einen
// Platz auf der Warteschlange, über die auch bezahlte Bestellungen laufen.
Queue::fake();
$instance = Instance::factory()->create([
'host_id' => null,
'vmid' => null,
'status' => 'ended',
'subdomain' => 'nc-ohnehost',
'service_ends_at' => now()->subDays(20),
]);
$this->artisan('clupilot:tear-down-ended-instances')
// Kurzes Stück, weil die Konsole lange Zeilen umbricht und eine
// Zusicherung auf einen Satz genau am Umbruch scheitern würde. Der
// vollständige Grund wird unten am Datensatz geprüft.
->expectsOutputToContain('nc-ohnehost: übersprungen')
->assertSuccessful();
Queue::assertNothingPushed();
// Und der Grund steht am Datensatz, nicht nur im Protokoll des Zeitplans:
// dort liest ihn niemand, hier zeigt ihn die Konsole über der Instanzliste.
expect($instance->fresh()->teardown_error)->toContain('keine erreichbare Maschine');
});
it('vermerkt im Probelauf auch bei einer Instanz ohne Host nichts', function () {
// Der Probelauf darf auch dort nicht schreiben, wo das Geschriebene nur
// eine Notiz ist — sonst hiesse „ändert nichts" in Wahrheit „ändert nur
// Kleinigkeiten".
$instance = Instance::factory()->create([
'host_id' => null,
'vmid' => null,
'status' => 'ended',
'service_ends_at' => now()->subDays(20),
]);
$this->artisan('clupilot:tear-down-ended-instances --dry-run')->assertSuccessful();
expect($instance->fresh()->teardown_error)->toBeNull();
});
it('bricht den Lauf nicht ab, wenn eine Instanz scheitert', function () {
// Der Fall, den ein Zeitplan über viele Instanzen aushalten muss. Die
// Aktion wirft für die erste Instanz hart — härter, als sie es im Betrieb
// je täte, denn sie fängt selbst alles. Wer die Ausnahme trotzdem nach
// draussen liesse, verlöre alle folgenden Instanzen dieses Laufes.
$pve = attrappeMitArchiv(202);
$erste = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(40));
$zweite = faelligeInstanz(vmid: 202, endeteAm: now()->subDays(20));
app()->bind(ArchiveAndTearDown::class, fn () => new class($erste->uuid, app(ProxmoxClient::class)) extends ArchiveAndTearDown
{
public function __construct(private string $sprengsatz, ProxmoxClient $pve)
{
parent::__construct($pve);
}
public function __invoke(Instance $instance): bool
{
if ($instance->uuid === $this->sprengsatz) {
throw new RuntimeException('Attrappe: dieser Abbau fliegt dem Aufrufer um die Ohren.');
}
return parent::__invoke($instance);
}
});
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
// Die zweite wurde trotzdem abgebaut — das ist die ganze Zusicherung.
expect($zweite->fresh()->torn_down_at)->not->toBeNull()
->and($pve->deletedVmids)->toBe([202]);
});
it('vermerkt einen Fehlschlag am Datensatz und macht mit der nächsten weiter', function () {
// Derselbe Gedanke, aber mit einem echten Fehler statt einer Attrappe:
// Proxmox nimmt das Herunterfahren nicht an. Beide Instanzen müssen
// versucht worden sein — ein Lauf, der nach der ersten abbräche, liesse
// die zweite ohne Grund und ohne Versuch zurück.
$pve = attrappeMitArchiv(201, 202);
$pve->shutdownThrows = true;
$erste = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(40));
$zweite = faelligeInstanz(vmid: 202, endeteAm: now()->subDays(20));
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
expect($erste->fresh()->teardown_error)->not->toBeNull()
->and($zweite->fresh()->teardown_error)->not->toBeNull()
->and($pve->deletedVmids)->toBe([]);
});
it('reiht nie mehr ein, als die Obergrenze erlaubt', function () {
// `provisioning` ist EINE Warteschlange mit EINEM Arbeiter, und dieselbe,
// über die eine bezahlte Bestellung ihre Maschine bekommt. Ohne Grenze
// schöbe der erste Lauf nach dem Ausrollen den ganzen Rückstand davor.
Queue::fake();
faelligeInstanz(vmid: 201, endeteAm: now()->subDays(40));
faelligeInstanz(vmid: 202, endeteAm: now()->subDays(30));
faelligeInstanz(vmid: 203, endeteAm: now()->subDays(20));
$this->artisan('clupilot:tear-down-ended-instances --limit=2')->assertSuccessful();
Queue::assertPushed(TearDownInstance::class, 2);
});
it('lässt einen Dauerfall die übrigen nicht aushungern', function () {
// Die Falle in „am längsten fällig zuerst": eine Instanz, deren Abbau jede
// Nacht an derselben Sache scheitert, ist am nächsten Tag wieder die am
// längsten fällige. Bei einer Obergrenze von eins käme dahinter nie
// jemand an die Reihe — der Rückstand stünde für immer.
Queue::fake();
$dauerfall = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(90));
$dauerfall->update(['teardown_error' => 'scheitert seit Wochen an derselben Stelle']);
$frisch = faelligeInstanz(vmid: 202, endeteAm: now()->subDays(20));
$this->artisan('clupilot:tear-down-ended-instances --limit=1')->assertSuccessful();
Queue::assertPushed(TearDownInstance::class, fn (TearDownInstance $job) => $job->uuid === $frisch->uuid);
Queue::assertNotPushed(TearDownInstance::class, fn (TearDownInstance $job) => $job->uuid === $dauerfall->uuid);
});
it('reiht den Abbau auf der Warteschlange ein, die den Tunnel hat', function () {
// Keine Feinheit: nur `queue-provisioning` steigt in den Netz-Namensraum
// des vpn-hub ein. Der scheduler-Container, in dem dieser Befehl läuft,
// erreicht die Management-Adresse eines Hosts gar nicht — ein Abbau, der
// von dort aus mit Proxmox spräche, liefe jedes Mal in eine
// Zeitüberschreitung.
Queue::fake();
$instance = faelligeInstanz(vmid: 201);
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
Queue::assertPushed(TearDownInstance::class, fn (TearDownInstance $job) => $job->uuid === $instance->uuid
&& $job->connection === 'provisioning'
&& $job->queue === 'provisioning');
});
it('gibt der Aktion Fristen, die vor dem Arbeiter und vor der Warteschlange ablaufen', function () {
// Die bindende Auflage aus der Prüfung von Aufgabe 3, als Prüfung.
//
// Drei Uhren laufen übereinander, und wessen Uhr zuerst klingelt,
// entscheidet, was hinterher dasteht: die Aktion wirft und hinterlässt
// einen lesbaren Grund; der Arbeiter erschlägt den Prozess und hinterlässt
// nichts; `retry_after` startet einen ZWEITEN Abbau gegen eine Maschine
// mitten im vzdump. Dreht jemand eine der Zahlen, dreht sich die
// Reihenfolge — und zwar still.
// Alle drei Wartezeiten der Aktion, nicht nur zwei: seit das Zerstören
// abgewartet wird, ist es die dritte Frist, die sich unter der Uhr des
// Arbeiters ausgehen muss.
$aktion = TearDownInstance::SHUTDOWN_WAIT_SECONDS
+ TearDownInstance::BACKUP_WAIT_SECONDS
+ TearDownInstance::DELETE_WAIT_SECONDS;
$auftrag = (new TearDownInstance('egal'))->timeout;
// Aus der Datei und nicht aus config(): Tests\TestCase::setUp() ersetzt
// diese Verbindung für die ganze Suite durch ein blosses ['driver' =>
// 'sync'], damit kein Test Redis braucht. Zur Laufzeit ist `retry_after`
// hier also gar nicht mehr da — gefragt werden muss der Wert, den ein
// echter Arbeiter bekommt.
$verbindung = (int) (require base_path('config/queue.php'))['connections']['provisioning']['retry_after'];
expect($aktion)->toBeLessThan($auftrag)
->and($auftrag)->toBeLessThan($verbindung);
});
it('gibt die Fristen wirklich an die Aktion weiter', function () {
// Die Zahlen oben nützen nichts, solange sie nur Konstanten sind. Die
// Aktion bringt ihre eigenen, grosszügigeren Vorgaben mit (eine Stunde
// fürs Sichern); wer die Zuweisung im Auftrag entfernte, liefe wieder in
// den Fall, den die Prüfung benannt hat.
$pve = attrappeMitArchiv(201);
$instance = faelligeInstanz(vmid: 201);
$aktion = app(ArchiveAndTearDown::class);
app()->instance(ArchiveAndTearDown::class, $aktion);
(new TearDownInstance($instance->uuid))->handle($aktion);
expect($aktion->shutdownWaitSeconds)->toBe(TearDownInstance::SHUTDOWN_WAIT_SECONDS)
->and($aktion->backupWaitSeconds)->toBe(TearDownInstance::BACKUP_WAIT_SECONDS)
->and($aktion->deleteWaitSeconds)->toBe(TearDownInstance::DELETE_WAIT_SECONDS)
// Und die Frist landet auch wirklich am Knoten, nicht nur im Objekt.
->and($pve->shutdownCalls[0]['timeout'])->toBe(TearDownInstance::SHUTDOWN_WAIT_SECONDS);
});
it('fällt nicht über eine Instanz, die es beim Ausführen nicht mehr gibt', function () {
// Zwischen Einreihen und Ausführen können Stunden liegen — die
// Warteschlange ist seriell und ein Abbau darf eine halbe Stunde dauern.
attrappeMitArchiv(201);
expect(fn () => (new TearDownInstance('gibt-es-nicht'))->handle(app(ArchiveAndTearDown::class)))
->not->toThrow(Throwable::class);
});
it('läuft nach Zeitplan, oder er läuft überhaupt nicht', function () {
// Täglich und nicht stündlich: der Abbau hat keinen Moment, auf den es
// ankommt — der Zeitpunkt, um den es geht, ist vierzehn Tage her.
// 05:30, weil RegisterBackup auf jedem Host einen nächtlichen vzdump um
// 02:00 anlegt und eine dafür gesperrte Maschine sich nicht herunterfahren
// lässt.
expect(File::get(base_path('routes/console.php')))
->toContain("Schedule::command('clupilot:tear-down-ended-instances')")
->toContain("->dailyAt('05:30')");
});