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; $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]); 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->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. $pve = new class extends FakeProxmoxClient { public function deleteVm(string $node, int $vmid): void { 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(); }); 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(); });