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')"); });