320 lines
14 KiB
PHP
320 lines
14 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) {
|
|
$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.
|
|
$aktion = TearDownInstance::SHUTDOWN_WAIT_SECONDS + TearDownInstance::BACKUP_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)
|
|
// 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')");
|
|
});
|