active()->create(['node' => 'pve']); return Instance::factory()->create([ 'host_id' => $host->id, 'vmid' => $vmid, 'status' => 'ended', 'service_ends_at' => $endeteVor ?? now()->subDays(20), ]); } it('faehrt herunter, archiviert, prueft nach, und loescht erst dann', function () { $pve = new FakeProxmoxClient; // Die Maschine gibt es, und sie läuft — der gewöhnliche Eingangszustand. // Ohne das fragt die Attrappe zu Recht zurück, warum ein stehender Gast // heruntergefahren werden soll. $pve->clonedVmids = [201]; $pve->runningVmids = [201]; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'size' => 4096]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue(); // Die Reihenfolge IST die Zusicherung: Herunterfahren vor dem Sichern, // Sichern vor dem Loeschen. expect($pve->shutdownCalls)->toHaveCount(1) ->and($pve->backupCalls)->toHaveCount(1) ->and($pve->deletedVmids)->toBe([201]) // Und der Abbau ist wirklich vollzogen, nicht nur beauftragt: der // qmdestroy-Auftrag ist durchgelaufen. ->and($pve->destroyedVmids)->toBe([201]); expect($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst') ->and($instance->fresh()->torn_down_at)->not->toBeNull(); }); it('LOESCHT NICHT, wenn nach dem Sichern kein Archiv daliegt', function () { // Der Fall, der alles traegt. Ein vzdump kann mit einer Auftragskennung // enden und trotzdem nichts hinterlassen — volle Ablage, abgebrochener // Lauf, ein Fehler im Gast. Wer sich auf die Kennung verlaesst statt // nachzusehen, loescht eine Maschine, deren Archiv es nicht gibt. $pve = new FakeProxmoxClient; // backups bleibt leer app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->teardown_error)->not->toBeNull(); }); it('loescht auch dann nicht, wenn das Herunterfahren scheitert', function () { $pve = new FakeProxmoxClient; $pve->clonedVmids = [201]; $pve->runningVmids = [201]; $pve->shutdownThrows = true; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->backupCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->teardown_error)->not->toBeNull(); }); it('faellt nicht ueber eine Instanz, die schon abgebaut ist', function () { // Wiederholbarkeit: ein zweiter Lauf nach einem Absturz darf nicht // versuchen, eine Maschine zu loeschen, die es nicht mehr gibt. $pve = new FakeProxmoxClient; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); $instance->update(['torn_down_at' => now()->subDay()]); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue() ->and($pve->deletedVmids)->toBe([]); }); it('ruehrt eine Instanz nicht an, deren Frist noch nicht um ist', function () { // Die vierzehn Tage sind der Sinn der Sache. Eine Maschine, die gestern // ablief, ist die Rueckfahrkarte eines Menschen, der es sich vielleicht // gerade anders ueberlegt. $pve = new FakeProxmoxClient; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(3)); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]); }); it('rührt eine Instanz nicht an, solange der vierzehnte Tag noch läuft', function () { // Der Rand der Frist, fünf Minuten davor. Er gehört noch dem Kunden: eine // Frist, die einen Tag zu früh zuschlägt, nimmt jedem Menschen einen ganzen // Tag seiner Rückfahrkarte weg — und niemandem fiele es auf. $pve = new FakeProxmoxClient; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(14)->addMinutes(5)); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]); }); it('baut ab, sobald der vierzehnte Tag um ist', function () { // Die andere Seite desselben Randes, fünf Minuten danach. Beide zusammen // halten die Frist fest: verschöbe sie jemand um einen Tag — in welche // Richtung auch immer —, wird eine der beiden Prüfungen rot. $pve = new FakeProxmoxClient; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(14)->subMinutes(5)); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue() ->and($pve->deletedVmids)->toBe([201]); }); it('rührt eine Instanz nicht an, deren Dienst gar nicht beendet ist', function () { // Eine laufende Instanz mit einem alten service_ends_at ist kein Abbaufall: // solange EndInstanceService sie nicht auf 'ended' gesetzt hat, ist die // Adresse noch da und jemand arbeitet damit. Nur der eine Zustand zählt, // nicht das Datum allein. $pve = new FakeProxmoxClient; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); $instance->update(['status' => 'active']); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]); }); it('fährt gar nichts herunter, wenn es die eingestellte Ablage auf dem Knoten nicht gibt', function () { // Die Ablage ist eine Annahme über die Einrichtung der Hosts, kein Wissen. // Ein Name, den es dort nicht gibt, muss laut scheitern — und zwar bevor // eine Kundenmaschine dafür heruntergefahren wurde. $pve = new FakeProxmoxClient; $pve->storage = [['storage' => 'local-lvm', 'type' => 'lvmthin', 'content' => 'images,rootdir']]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->backupCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->teardown_error)->toContain('local'); }); it('fährt gar nichts herunter, wenn die Ablage keine Sicherungen aufnimmt', function () { // Es gibt sie, sie heißt richtig — und sie darf keine Sicherungen tragen. // Ohne diese Prüfung fiele das erst beim vzdump auf, mit einer bereits // heruntergefahrenen Kundenmaschine daneben. $pve = new FakeProxmoxClient; $pve->storage = [['storage' => 'local', 'type' => 'dir', 'content' => 'iso,vztmpl']]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->backupCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->teardown_error)->toContain('local'); }); it('sichert nicht, wenn der Gast nach dem Herunterfahren weiterläuft', function () { // shutdownVm() liefert eine Auftragskennung, keine Zusage. Ein Gast, der // ACPI ignoriert, läuft weiter — und eine laufende Datenbank zu sichern // und dann zu löschen hieße, ein Archiv mit halb geschriebenen Tabellen // aufzubewahren. $pve = new FakeProxmoxClient; $pve->shutdownIgnored = true; $pve->runningVmids = [201]; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->shutdownCalls)->toHaveCount(1) ->and($pve->backupCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->teardown_error)->not->toBeNull(); }); it('löscht nicht gegen ein Archiv von gestern', function () { // Der zweite Weg, an dem die reine Ja/Nein-Frage „liegt da etwas?" // vorbeigeht: die nächtliche Sicherung liegt auf derselben Ablage. Gegen // sie zu löschen hieße, ein Archiv aufzubewahren, dem der letzte Tag // fehlt — und es fiele erst auf, wenn es zu spät ist. $pve = new FakeProxmoxClient; $pve->backups['201'] = [[ 'volid' => 'local:backup/vzdump-qemu-201-gestern.vma.zst', 'ctime' => now()->subDay()->timestamp, ]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->backupCalls)->toHaveCount(1) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->archive_volid)->toBeNull(); }); it('merkt sich das frische Archiv, nicht das danebenliegende alte', function () { // Der Normalfall auf einer echten Ablage: die nächtliche Sicherung von // gestern liegt neben dem Dump von gerade eben. Vermerkt werden muss der // frische — ein Kunde, der in zwölf Monaten nach seinen Daten fragt, soll // nicht den Stand von vorgestern bekommen. $pve = new FakeProxmoxClient; $pve->backups['201'] = [ ['volid' => 'local:backup/vzdump-qemu-201-gestern.vma.zst', 'ctime' => now()->subDay()->timestamp], ['volid' => 'local:backup/vzdump-qemu-201-jetzt.vma.zst', 'ctime' => now()->timestamp], ]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue() ->and($pve->deletedVmids)->toBe([201]) ->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201-jetzt.vma.zst') ->and($instance->fresh()->archived_at)->not->toBeNull(); }); it('löscht nicht gegen mehrere Archive, von denen keines einen Zeitpunkt nennt', function () { // Auf `local` liegen neben dem frischen Dump die nächtlichen Sicherungen // derselben VM. Fehlt allen der Zeitpunkt, lassen sie sich nicht ordnen — // vermerkt würde irgendeines, womöglich das von vorgestern. Eine Reihe, die // sich nicht sortieren lässt, ist keine Grundlage für ein Löschen. Ein // EINZELNES Archiv ohne Zeitpunkt bleibt dagegen gültig; das ist der Fall // der ersten Prüfung dieser Datei. $pve = new FakeProxmoxClient; $pve->backups['201'] = [ ['volid' => 'local:backup/vzdump-qemu-201-a.vma.zst', 'size' => 4096], ['volid' => 'local:backup/vzdump-qemu-201-b.vma.zst', 'size' => 4096], ]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->archive_volid)->toBeNull() ->and($instance->fresh()->teardown_error)->not->toBeNull(); }); it('löscht nicht, wenn das Archiv keine Kennung trägt', function () { // Ein Archiv, dessen Ort nicht notiert werden kann, ist in zwölf Monaten // keins: niemand findet es wieder, und niemand räumt es je auf. $pve = new FakeProxmoxClient; $pve->backups['201'] = [['size' => 4096, 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->teardown_error)->not->toBeNull(); }); it('löscht nicht, wenn der Sicherungsauftrag mit einem Fehler endet', function () { // Die volle Ablage, wie Proxmox sie meldet. Dass daneben ein älteres // Archiv derselben VM liegt, macht den Fehlschlag nicht zum Erfolg. $pve = new FakeProxmoxClient; $pve->taskExitStatus = 'no space left on device'; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->teardown_error)->toContain('no space left on device'); }); it('lässt sich von Hinweisen im Sicherungsauftrag nicht aufhalten', function () { // Proxmox beendet einen vzdump auch mit „WARNINGS: n", wenn das Archiv // sauber geschrieben wurde. Das als Fehlschlag zu lesen hieße, den Abbau an // einer Meldung hängen zu lassen, die kein Archiv verhindert — der Beweis // ist die Nachschau auf der Ablage, nicht diese Zeichenkette. $pve = new FakeProxmoxClient; $pve->taskExitStatus = 'WARNINGS: 1'; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue() ->and($pve->deletedVmids)->toBe([201]); }); it('löscht nicht, solange der Sicherungsauftrag noch läuft', function () { // Ein vzdump liefert seine Kennung sofort und arbeitet danach minutenlang // weiter. Wer nicht abwartet, fragt die Ablage, bevor das Archiv darauf // liegt. Läuft er über die Frist hinaus, bleibt die Maschine stehen — der // nächste Lauf sieht erneut nach. $pve = new class extends FakeProxmoxClient { public function taskStatus(string $node, string $upid): array { // Nur der vzdump hängt. Das Herunterfahren davor war fertig — // sonst käme diese Prüfung nie bis zum Sichern. return str_contains($upid, 'vzdump') ? ['status' => 'running'] : parent::taskStatus($node, $upid); } }; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); // Ohne Wartezeit, damit die Prüfung nicht wirklich eine Stunde wartet. $abbau = app(ArchiveAndTearDown::class); $abbau->backupWaitSeconds = 0; expect($abbau($instance))->toBeFalse() ->and($pve->backupCalls)->toHaveCount(1) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->teardown_error)->not->toBeNull(); }); it('löscht nicht, wenn die Instanz zwischen Sichern und Löschen wiederbelebt wird', function () { // Zwischen dem Dump und dem Löschen liegen Minuten. Wird die Instanz in // dieser Zeit von jemandem angefasst, ist sie keine, die gelöscht werden // darf — die gemerkte Kopie im Speicher weiß davon nichts, also wird der // Datensatz unmittelbar davor neu gelesen. $pve = new class extends FakeProxmoxClient { public ?Instance $wiederbelebt = null; public function backupsFor(string $node, int $vmid, string $storage): array { $this->wiederbelebt?->update(['status' => 'active']); return parent::backupsFor($node, $vmid, $storage); } }; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); // Eine ZWEITE Kopie desselben Datensatzes, so wie sie ein anderer Vorgang // hätte — die Aktion darf ihrer eigenen nicht glauben. $pve->wiederbelebt = Instance::find($instance->id); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() // Das Archiv existiert wirklich und bleibt vermerkt: der Dump ist // gelaufen, und ihn zu vergessen hieße, ihn beim nächsten Lauf noch // einmal zu bezahlen. ->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst') ->and($instance->fresh()->teardown_error)->not->toBeNull(); }); it('behält das Archiv am Datensatz, wenn das Löschen scheitert', function () { // Der Zwischenstand, für den es zwei Zeitstempel gibt: gesichert ja, // gelöscht nein. Ginge archive_volid dabei verloren, liefe der nächste Lauf // gegen dieselbe Maschine noch einen Dump — und der erste läge unauffindbar // auf der Ablage. // // Hier wird schon die ANFRAGE abgewiesen (Proxmox antwortet nicht mit 200). // Der andere Weg — Anfrage angenommen, Auftrag scheitert danach — steht // weiter unten und war der stille von beiden. $pve = new class extends FakeProxmoxClient { public function deleteVm(string $node, int $vmid): string { throw new RuntimeException('Proxmox: VM is locked (backup)'); } }; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst') ->and($instance->fresh()->archived_at)->not->toBeNull() ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->teardown_error)->toContain('locked'); }); it('trägt den Abbau nach, wenn die Maschine schon fort ist und das Archiv feststeht', function () { // Der Absturz im Millisekundenfenster zwischen dem Löschen und dem // Vermerk. Ohne diesen Weg zurück versuchte jeder weitere Lauf, einen // Geist herunterzufahren, und die Instanz bliebe für immer im Fehler // stehen. Es wird dabei nichts gelöscht — es gibt nichts mehr. $pve = new FakeProxmoxClient; // kennt vmid 201 nicht, vmExists() ist falsch app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); $instance->update([ 'archive_volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'archived_at' => now()->subMinutes(5), ]); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->backupCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->not->toBeNull(); }); it('bucht keine Maschine als abgebaut, wenn der Knoten gar nicht antwortet', function () { // Die Eingangslage entsteht im Regelbetrieb: ein Lauf hat gesichert und ist // am Löschen gescheitert, weil der Knoten hängt. Am nächsten Tag steht // `archive_volid` am Datensatz, und `vmExists()` — ein GET ohne `->throw()` — // antwortet wegen desselben hakenden Knotens mit „nein". Ohne ein // Lebenszeichen davor würde hier eine LAUFENDE Kundenmaschine als abgebaut // verbucht und danach nie wieder angesehen. $pve = new class extends FakeProxmoxClient { public function nodeStorage(string $node): array { throw new RuntimeException('Proxmox: 595 Connection refused'); } }; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); $instance->update([ 'archive_volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'archived_at' => now()->subDay(), 'teardown_error' => 'Proxmox: VM is locked (backup)', ]); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->teardown_error)->toContain('Connection refused'); }); it('lässt keinen zweiten Lauf über dieselbe Instanz', function () { // Zwei Läufe nebeneinander — der Zeitplan und ein Betreiber von Hand — // würden zweimal sichern und zweimal löschen; der zweite fände dabei eine // Maschine vor, die der erste gerade wegnimmt. Hier hält bereits jemand die // Sperre, und der Lauf hat nichts hinzuzufügen. $pve = new FakeProxmoxClient; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); $fremdeSperre = Cache::lock('instance-teardown:'.$instance->uuid, 60); expect($fremdeSperre->get())->toBeTrue(); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->backupCalls)->toBe([]) ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull(); // Und die fremde Sperre steht noch: der abgewiesene Lauf gibt im `finally` // nicht frei, was ihm nie gehört hat. expect(Cache::lock('instance-teardown:'.$instance->uuid, 60)->get())->toBeFalse(); $fremdeSperre->release(); }); // ---- Der Platz wird wieder verkaufbar -------------------------------------- it('gibt den Platz auf dem Host wirklich wieder frei', function () { // Der Zweck des ganzen Vorhabens, und die Prüfung, die gefehlt hat. // // Eine abgebaute Instanz behält `status = 'ended'`, ihre `vmid` und ihr // `disk_gb` — der Datensatz bleibt als Nachweis stehen, wo das Archiv // liegt. Zählte sie deshalb weiter als belegend, wäre der Knoten physisch // leer und die Buchhaltung voll: die nächste bezahlte Bestellung derselben // Grösse fände keinen Platz, würde geparkt und verworfen, und das Paket // verschwände vom Angebot der Landingpage. Der Abbau löschte Kundenplatten // und brächte nichts ein. $pve = new FakeProxmoxClient; $pve->clonedVmids = [201]; $pve->runningVmids = [201]; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $host = Host::factory()->active()->create(['node' => 'pve', 'datacenter' => 'fsn']); $instance = Instance::factory()->create([ 'host_id' => $host->id, 'vmid' => 201, 'disk_gb' => 540, 'status' => 'ended', 'service_ends_at' => now()->subDays(20), ]); // Vorher: die Platte ist gebucht, und ein Paket derselben Grösse passt // nicht mehr auf diesen Host. expect($host->fresh()->committedGb())->toBe(540) ->and(Host::placeableIn('fsn', 540))->toBeNull(); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue(); // Nachher: nichts mehr gebucht, der ganze freie Platz wieder zu haben, und // dieselbe Bestellung findet ihren Host. expect($host->fresh()->committedGb())->toBe(0) ->and($host->fresh()->availableGb())->toBe($host->fresh()->freeGb()) ->and(Host::placeableIn('fsn', 540)?->id)->toBe($host->id); }); it('zählt eine abgebaute Instanz nicht mehr als belegend', function () { // Dieselbe Regel eine Ebene tiefer, ohne Proxmox: es ist der Zeitstempel // `torn_down_at`, der die Zeile aus der Summe nimmt, und nicht ein // geleertes `vmid`. Wer statt seiner die VMID nullte, liesse // `status != 'failed'` allein die Zeile in der Summe halten — der Platz // bliebe belegt, und niemandem fiele es auf. $host = Host::factory()->active()->create(); $abgebaut = Instance::factory()->create([ 'host_id' => $host->id, 'vmid' => 201, 'disk_gb' => 300, 'status' => 'ended', 'archive_volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'archived_at' => now(), 'torn_down_at' => now(), ]); Instance::factory()->create([ 'host_id' => $host->id, 'vmid' => 202, 'disk_gb' => 40, 'status' => 'active', ]); expect($host->fresh()->committedGb())->toBe(40); // Und die Gegenprobe: solange sie nicht abgebaut ist, zählt genau dieselbe // gekündigte Instanz mit — die Frist von vierzehn Tagen ist bezahlter Platz. $abgebaut->update(['torn_down_at' => null]); expect($host->fresh()->committedGb())->toBe(340); }); // ---- Das Löschen ist ein Auftrag, kein Vollzug ----------------------------- it('bucht keinen Abbau, wenn der Zerstörungsauftrag hinterher scheitert', function () { // `DELETE /nodes/…/qemu/…` prüft synchron nur Schutzflagge und // HA-Verwendung, forkt dann `qmdestroy` und antwortet mit 200. Scheitert // die Zerstörung dreissig Sekunden später — eine fremde Sicherung hält die // Ablage —, steht die Maschine, liegt die Platte, und ein auf das 200 // gebuchtes `torn_down_at` nähme sie für immer aus jeder Auswahl und jedem // Kasten. Der teuerste stille Fehlschlag dieses Ablaufs. $pve = new FakeProxmoxClient; $pve->clonedVmids = [201]; $pve->runningVmids = [201]; $pve->destroyExitStatus = 'VM is locked (backup)'; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() // Beauftragt ja, vollzogen nein — und genau das ist der Unterschied. ->and($pve->deletedVmids)->toBe([201]) ->and($pve->destroyedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->teardown_error)->toContain('locked') // Das Archiv bleibt vermerkt: der Dump ist gelaufen und soll nicht ein // zweites Mal bezahlt werden. ->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst'); }); it('bucht keinen Abbau, solange der Zerstörungsauftrag noch läuft', function () { // Dieselbe Lage in langsam: der Auftrag hängt an einer trägen Ablage. Wer // nicht abwartet, schreibt „abgebaut" über eine Maschine, deren Platten // gerade erst weggeräumt werden — und erfährt nie, ob es geklappt hat. $pve = new FakeProxmoxClient; $pve->clonedVmids = [201]; $pve->runningVmids = [201]; $pve->destroyHangs = true; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); // Ohne Wartezeit, damit die Prüfung nicht wirklich zehn Minuten wartet. $abbau = app(ArchiveAndTearDown::class); $abbau->deleteWaitSeconds = 0; expect($abbau($instance))->toBeFalse() ->and($pve->destroyedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->teardown_error)->toContain('Das Löschen'); }); // ---- Der stehende Gast ----------------------------------------------------- it('bittet einen bereits stehenden Gast nicht ums Herunterfahren', function () { // Der Riegel, den der Nachbarschritt ShutDownVirtualMachine seit jeher hat. // `shutdownVm()` ruft im echten Client `->throw()`, und eine stehende // Maschine ist hier kein Rand: eine wegen offener Zahlung gesperrte Cloud // steht seit Wochen still, wenn ihr Kunde kündigt. Ohne den Riegel wäre // ihr Abbau unheilbar — jede Nacht derselbe Wurf. $pve = new FakeProxmoxClient; $pve->clonedVmids = [201]; // die Maschine gibt es … $pve->runningVmids = []; // … sie läuft nur nicht $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue() ->and($pve->shutdownCalls)->toBe([]) ->and($pve->backupCalls)->toHaveCount(1) ->and($pve->destroyedVmids)->toBe([201]) ->and($instance->fresh()->torn_down_at)->not->toBeNull() ->and($instance->fresh()->teardown_error)->toBeNull(); }); it('kommt im zweiten Lauf nach einem Teilfehlschlag durch', function () { // Der Satz „der nächste Lauf sieht erneut nach" steht an fünf Stellen im // Code und war nirgends belegt — kein Test fuhr je einen zweiten Lauf. // // Die Lage entsteht im Regelbetrieb: der erste Lauf fährt herunter, // sichert, und scheitert am Zerstören. Die Maschine bleibt STEHEN — // niemand startet sie wieder. In der nächsten Nacht ist das // Herunterfahren die erste Stufe und trifft einen gestoppten Gast. $pve = new FakeProxmoxClient; $pve->clonedVmids = [201]; $pve->runningVmids = [201]; $pve->destroyExitStatus = 'VM is locked (backup)'; $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]]; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($instance->fresh()->teardown_error)->toContain('locked') // Und sie steht seither: `mode=stop` startet nichts wieder. ->and($pve->runningVmids)->toBe([]); // Die nächste Nacht. Die fremde Sicherung ist inzwischen durch. $pve->destroyExitStatus = null; expect(app(ArchiveAndTearDown::class)($instance->fresh()))->toBeTrue() // Kein zweites Herunterfahren — und vor allem kein Wurf dabei. ->and($pve->shutdownCalls)->toHaveCount(1) ->and($pve->destroyedVmids)->toBe([201]) ->and($instance->fresh()->torn_down_at)->not->toBeNull() // Der alte Grund ist fort, nicht überschrieben: der Abbau ist durch. ->and($instance->fresh()->teardown_error)->toBeNull(); }); it('rührt eine Instanz ohne Maschine nicht an', function () { // Ein Datensatz ohne VMID hat nichts, was sich sichern oder löschen ließe. // Er wird nicht stillschweigend als abgebaut gebucht: das wäre ein Haken an // einer Maschine, die vielleicht doch irgendwo läuft. $pve = new FakeProxmoxClient; app()->instance(ProxmoxClient::class, $pve); $instance = abgelaufeneInstanz(vmid: 201); $instance->update(['vmid' => null]); expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() ->and($pve->deletedVmids)->toBe([]) ->and($instance->fresh()->torn_down_at)->toBeNull() ->and($instance->fresh()->teardown_error)->not->toBeNull(); });