From 24a0f64128ab6db0d638a3d347bcbbc78a940a3d Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 11:15:52 +0200 Subject: [PATCH] Kuendigung B1, Task 3: archivieren, nachsehen, und erst dann abbauen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gekuendigte Kundenmaschinen wurden nie abgebaut. EndInstanceService nimmt der Instanz am Laufzeitende die Adresse weg und laesst die Maschine ausdruecklich stehen; danach kam nichts mehr, und sie belegte fuer immer einen Platz auf dem Host. ArchiveAndTearDown ist der Schritt danach: vierzehn Tage nach dem Ende wird die Maschine archiviert und geloescht. Die eine Regel: niemals loeschen, bevor das Archiv nachweislich existiert. Ein vzdump kann mit einer Auftragskennung enden und trotzdem nichts hinterlassen — volle Ablage, abgebrochener Lauf, ein Fehler im Gast. Deshalb steht zwischen Sichern und Loeschen eine echte Nachschau auf der Ablage (backupsFor), und der juengste Eintrag muss nach dem Beginn dieses Laufes entstanden sein: eine naechtliche Sicherung von gestern ist kein Archiv, dem der letzte Tag fehlen darf. Beide Proxmox-Aufrufe liefern nur eine Kennung, keine Zusage, also wird auf den Auftrag gewartet und danach nachgesehen — beim Herunterfahren, ob der Gast wirklich steht, beim Sichern, ob die Datei liegt. Unmittelbar vor dem Loeschen wird der Datensatz neu gelesen: eine Instanz, die in der Zwischenzeit wiederbelebt wurde, wird nicht geloescht. Keine Ausnahme entkommt — ein Fehlschlag ist false und ein Grund in teardown_error, weil dieser Ablauf spaeter im Zeitplan ueber viele Instanzen laeuft. Die Ablage kommt aus einer Einstellung (provisioning.proxmox.archive_storage, Vorgabe local wie bei den naechtlichen Sicherungen) UND wird gegen die Ablagen des Knotens geprueft. Fehlt sie oder nimmt sie keine Sicherungen auf, bricht der Abbau ab, bevor eine Kundenmaschine dafuer heruntergefahren wurde. Co-Authored-By: Claude Opus 5 --- .env.example | 6 + app/Actions/ArchiveAndTearDown.php | 454 ++++++++++++++++++ app/Services/Proxmox/FakeProxmoxClient.php | 18 + config/provisioning.php | 19 + .../Cancellation/ArchiveAndTearDownTest.php | 391 +++++++++++++++ 5 files changed, 888 insertions(+) create mode 100644 app/Actions/ArchiveAndTearDown.php create mode 100644 tests/Feature/Cancellation/ArchiveAndTearDownTest.php diff --git a/.env.example b/.env.example index e7d6dab..27cb0b5 100644 --- a/.env.example +++ b/.env.example @@ -115,6 +115,12 @@ CLUPILOT_DNS_ZONE=clupilot.cloud # docker-compose.yml. Only change if that volume is mounted somewhere else. CLUPILOT_DNS_HOSTS_DIR=/etc/clupilot/dns-hosts +# ── Archiv einer gekündigten Instanz ───────────────────────────────────── +# Die Proxmox-Ablage, auf die der Abbau (App\Actions\ArchiveAndTearDown) das +# Archiv schreibt, bevor die Maschine gelöscht wird. Muss es auf JEDEM Host +# geben und `backup` tragen — sonst bricht der Abbau ab und löscht nichts. +CLUPILOT_ARCHIVE_STORAGE=local + # ── Traefik (reverse proxy + TLS) ──────────────────────────────────────── # Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files. TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic diff --git a/app/Actions/ArchiveAndTearDown.php b/app/Actions/ArchiveAndTearDown.php new file mode 100644 index 0000000..43e42d1 --- /dev/null +++ b/app/Actions/ArchiveAndTearDown.php @@ -0,0 +1,454 @@ +status === 'ended' + && $instance->torn_down_at === null + && $instance->service_ends_at !== null + && $instance->service_ends_at->lessThan(($at ?? now())->copy()->subDays(self::GRACE_DAYS)); + } + + /** + * Archivieren und abbauen, oder mit einem Grund stehen bleiben. + * + * @return bool ob diese Instanz jetzt abgebaut ist (auch dann, wenn ein + * früherer Lauf sie abgebaut hat) + */ + public function __invoke(Instance $instance): bool + { + // Wiederholbarkeit vor allem anderen: ein zweiter Lauf nach einem + // Absturz darf nicht versuchen, eine Maschine zu löschen, die es nicht + // mehr gibt. + if ($instance->torn_down_at !== null) { + return true; + } + + if (! $this->isDue($instance)) { + return false; + } + + // Zwei Läufe über dieselbe Instanz — 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. Niemand + // wartet auf die Sperre: wer sie nicht bekommt, hat nichts + // hinzuzufügen, weil der Lauf, der sie hält, dasselbe tut. Die Frist + // deckt den ganzen Dump ab, sonst liefe sie mitten im Sichern aus. + $lock = Cache::lock('instance-teardown:'.$instance->uuid, $this->backupWaitSeconds + 600); + + if (! $lock->get()) { + return false; + } + + try { + return $this->tearDown($instance); + } catch (Throwable $e) { + // Keine Ausnahme entkommt: dieser Ablauf läuft im Zeitplan über + // viele Instanzen, und ein Wurf hier nähme die übrigen mit. + $this->recordFailure($instance, $e); + + return false; + } finally { + $lock->release(); + } + } + + /** + * Der eigentliche Ablauf. Jeder Riegel wirft, der Aufrufer oben fängt und + * schreibt den Grund an den Datensatz — ein Wurf ist hier Ablaufsteuerung + * und verlässt die Aktion nie. + */ + private function tearDown(Instance $instance): bool + { + $host = $instance->host; + $vmid = $instance->vmid; + + if ($host === null || $vmid === null) { + throw new RuntimeException( + 'Zu dieser Instanz gehört keine erreichbare Maschine mehr (Host oder VMID fehlt). ' + .'Es wird nichts gesichert und nichts gelöscht.' + ); + } + + $node = (string) ($host->node ?? 'pve'); + $pve = $this->pve->forHost($host); + $vmid = (int) $vmid; + + // Der Weg zurück aus einem Absturz zwischen Löschen und Buchen: die + // Maschine ist fort, das Archiv steht aber schon am Datensatz. Ohne + // diesen Zweig versuchte jeder weitere Lauf, einen Geist + // herunterzufahren, und die Instanz bliebe für immer im Fehler stehen. + // Bewusst an `archive_volid` gebunden und nicht an `vmExists()` allein: + // die Frage beantwortet auch ein hakendes Proxmox mit „nein", und ohne + // ein verbrieftes Archiv wäre das „schon weg" eine Maschine, die + // niemand mehr anfasst. + if ($instance->archive_volid !== null && ! $pve->vmExists($node, $vmid)) { + $instance->update(['torn_down_at' => now(), 'teardown_error' => null]); + + Log::warning('Abbau: die Maschine war schon fort, das Archiv stand bereits fest.', [ + 'instance' => $instance->uuid, + 'vmid' => $vmid, + 'archive_volid' => $instance->archive_volid, + ]); + + return true; + } + + // Vor dem Herunterfahren: eine Instanz, deren Archiv nirgends hin + // kann, wird gar nicht erst angefasst. + $storage = $this->archiveStorage($pve, $node); + + $shutdownUpid = $pve->shutdownVm($node, $vmid, $this->shutdownWaitSeconds); + $this->awaitTask($pve, $node, $shutdownUpid, $this->shutdownWaitSeconds, 'Das Herunterfahren'); + + // Die Auftragskennung ist keine Zusage. Ein Gast, der ACPI ignoriert, + // lässt den Auftrag auslaufen und läuft weiter — gesichert wird eine + // laufende Datenbank hier nicht, und gelöscht schon gar nicht. + if (($pve->vmStatus($node, $vmid)['status'] ?? '') === 'running') { + throw new RuntimeException( + 'Der Gast läuft nach der Bitte zum Herunterfahren weiter. Es wird nichts gesichert ' + .'und nichts gelöscht; die Maschine wird bewusst nicht hart ausgeschaltet.' + ); + } + + // Vor dem Dump abgelesen, nicht danach: die Zeit ist die Grenze, an der + // sich später „mein Archiv" von „das von gestern Nacht" unterscheidet. + $begonnen = now(); + + $backupUpid = $pve->backupNow($node, $vmid, $storage); + $this->awaitTask($pve, $node, $backupUpid, $this->backupWaitSeconds, 'Die Sicherung'); + + $volid = $this->verifiedArchive($pve, $node, $vmid, $storage, $begonnen); + + // Das Archiv ist eine Tatsache, sobald es daliegt — festgehalten, bevor + // gelöscht wird. + $instance->update(['archive_volid' => $volid, 'archived_at' => now()]); + + // Zwischen Sichern und Löschen liegen Minuten. Was in dieser Zeit an der + // Instanz passiert ist, entscheidet: eine wiederbelebte Instanz + // (`status` nicht mehr `ended`) ist keine, die gelöscht werden darf. + // Der Sperre ist dabei nicht zu trauen — sie hält nur andere LÄUFE ab, + // nicht einen Menschen in der Konsole. + $frisch = $instance->fresh(); + + if ($frisch === null || $frisch->torn_down_at !== null || $frisch->status !== 'ended') { + throw new RuntimeException( + 'Die Instanz hat sich während der Sicherung geändert (Zustand "' + .($frisch?->status ?? 'gelöscht').'"). Das Archiv liegt und ist vermerkt, ' + .'gelöscht wird die Maschine nicht.' + ); + } + + $pve->deleteVm($node, $vmid); + + $instance->update(['torn_down_at' => now(), 'teardown_error' => null]); + + Log::info('Gekündigte Instanz archiviert und abgebaut.', [ + 'instance' => $instance->uuid, + 'vmid' => $vmid, + 'node' => $node, + 'archive_volid' => $volid, + 'service_ended_at' => $instance->service_ends_at?->toIso8601String(), + ]); + + return true; + } + + /** + * Welche Ablage das Archiv trägt — als Name aus der Einstellung und als + * geprüfte Tatsache auf dem Knoten. + */ + private function archiveStorage(ProxmoxClient $pve, string $node): string + { + $name = (string) config('provisioning.proxmox.archive_storage', 'local'); + + $vorhanden = collect($pve->nodeStorage($node)); + $treffer = $vorhanden->firstWhere('storage', $name); + + if ($treffer === null) { + throw new RuntimeException( + 'Die Ablage "'.$name.'" gibt es auf dem Knoten '.$node.' nicht (vorhanden: ' + .($vorhanden->pluck('storage')->filter()->implode(', ') ?: 'keine') + .'). Ohne Ziel für das Archiv wird nichts gelöscht.' + ); + } + + // `content` zählt auf, was eine Ablage überhaupt aufnehmen darf. Eine + // Ablage ohne `backup` nimmt den Dump nicht an — das fiele sonst erst + // beim vzdump auf, mit einer heruntergefahrenen Kundenmaschine daneben. + if (! in_array('backup', explode(',', (string) ($treffer['content'] ?? '')), true)) { + throw new RuntimeException( + 'Die Ablage "'.$name.'" auf dem Knoten '.$node.' nimmt keine Sicherungen auf (content: "' + .((string) ($treffer['content'] ?? '')).'"). Es wird nichts gelöscht.' + ); + } + + return $name; + } + + /** + * Auf einen Proxmox-Auftrag warten, bis er wirklich fertig ist. + * + * Sowohl `shutdownVm()` als auch `backupNow()` liefern nur eine Kennung; die + * Arbeit läuft danach. Ein Aufrufer, der sofort weiterliest, sieht den + * Zustand von vorher — beim Herunterfahren eine laufende Maschine, beim + * Sichern eine Ablage ohne das Archiv, das gerade geschrieben wird. + */ + private function awaitTask(ProxmoxClient $pve, string $node, string $upid, int $waitSeconds, string $was): void + { + $frist = now()->addSeconds($waitSeconds); + + while (true) { + $status = $pve->taskStatus($node, $upid); + + if (($status['status'] ?? '') !== 'running') { + $ende = (string) ($status['exitstatus'] ?? 'OK'); + + // Proxmox beendet einen vzdump auch dann mit "WARNINGS: n", wenn + // das Archiv sauber geschrieben wurde (eine übersprungene Platte, + // ein Hinweis aus dem Gast). Das als Fehlschlag zu lesen liesse + // den Abbau an einer Meldung hängen, die kein Archiv verhindert — + // und der Beweis, auf den es ankommt, ist ohnehin die Nachschau + // auf der Ablage, nicht diese Zeichenkette. + if ($ende !== 'OK' && ! str_starts_with($ende, 'WARNINGS')) { + throw new RuntimeException($was.' endete mit "'.$ende.'". Es wird nichts gelöscht.'); + } + + if ($ende !== 'OK') { + Log::warning('Abbau: Proxmox-Auftrag mit Hinweisen beendet.', [ + 'node' => $node, + 'upid' => $upid, + 'exitstatus' => $ende, + ]); + } + + return; + } + + if (now()->greaterThanOrEqualTo($frist)) { + throw new RuntimeException( + $was.' lief nach '.$waitSeconds.' Sekunden noch. Es wird nichts gelöscht; ' + .'der nächste Lauf sieht erneut nach.' + ); + } + + Sleep::for($this->pollSeconds)->seconds(); + } + } + + /** + * Nachsehen, was wirklich auf der Ablage liegt — der Riegel, an dem alles + * hängt. + * + * @return string die Kennung des Archivs (volid) + */ + private function verifiedArchive(ProxmoxClient $pve, string $node, int $vmid, string $storage, Carbon $begonnen): string + { + $eintraege = $pve->backupsFor($node, $vmid, $storage); + + if ($eintraege === []) { + throw new RuntimeException( + 'Nach der Sicherung liegt auf der Ablage "'.$storage.'" kein Archiv für VM '.$vmid.'. ' + .'Die Maschine bleibt stehen und wird nicht gelöscht.' + ); + } + + /** @var array $juengstes */ + $juengstes = collect($eintraege)->sortBy(fn (array $e) => (int) ($e['ctime'] ?? 0))->last(); + + $ctime = $juengstes['ctime'] ?? null; + + if ($ctime === null) { + // Eine echte Ablage nennt zu jedem Eintrag ein `ctime`; fehlt es, + // ist das Alter des Archivs schlicht nicht feststellbar. Dann wird + // es angenommen statt abgelehnt — es LIEGT ein Archiv dieser VM da, + // und der Fall, den diese Aktion zu verhindern hat, ist das Löschen + // ohne jedes Archiv. Laut genug, dass es auffällt, wenn es je öfter + // vorkommt. + Log::warning('Abbau: das Archiv nennt keinen Zeitpunkt, sein Alter ist ungeprüft.', [ + 'vmid' => $vmid, + 'storage' => $storage, + ]); + } elseif ((int) $ctime < $begonnen->getTimestamp() - self::CLOCK_SKEW_SECONDS) { + // Der Fall, den die reine Ja/Nein-Frage „liegt da etwas?" durchlässt: + // die nächtliche Sicherung von gestern. Gegen sie zu löschen hiesse, + // ein Archiv aufzubewahren, dem der letzte Tag fehlt — und niemand + // würde es je merken. + throw new RuntimeException( + 'Das jüngste Archiv für VM '.$vmid.' auf "'.$storage.'" stammt vom ' + .Carbon::createFromTimestamp((int) $ctime)->toIso8601String() + .' und damit von vor diesem Lauf. Die Sicherung dieses Laufs ist nicht auffindbar; ' + .'es wird nichts gelöscht.' + ); + } + + $volid = (string) ($juengstes['volid'] ?? ''); + + if ($volid === '') { + throw new RuntimeException( + 'Das Archiv für VM '.$vmid.' auf "'.$storage.'" hat keine Kennung (volid). ' + .'Ein Archiv, dessen Ort nicht notiert werden kann, ist in zwölf Monaten keins — ' + .'es wird nichts gelöscht.' + ); + } + + return $volid; + } + + /** Der Grund gehört an den Datensatz, wo ein Betreiber ihn ohne Logdurchsicht findet. */ + private function recordFailure(Instance $instance, Throwable $e): void + { + $instance->update(['teardown_error' => $e->getMessage()]); + + Log::error('Abbau einer gekündigten Instanz gestoppt.', [ + 'instance' => $instance->uuid, + 'vmid' => $instance->vmid, + 'reason' => $e->getMessage(), + ]); + } +} diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index b874b03..7aa0378 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -150,6 +150,18 @@ class FakeProxmoxClient implements ProxmoxClient */ public bool $shutdownIgnored = false; + /** + * Ein Herunterfahren, das gar nicht erst angenommen wird — der Knoten + * antwortet nicht, das Token darf es nicht, die VMID gibt es dort nicht + * mehr. Der echte Client ruft auf jeder Antwort `->throw()`, das ist also + * kein erfundener Fall. + * + * Nicht dasselbe wie $shutdownIgnored: dort kommt die Bitte an und der Gast + * befolgt sie nur nicht. Beide Fälle nebeneinander, weil ein Aufrufer, der + * nur den einen kennt, den anderen für unmöglich hält. + */ + public bool $shutdownThrows = false; + public function startVm(string $node, int $vmid): string { $this->runningVmids[] = $vmid; @@ -162,8 +174,14 @@ class FakeProxmoxClient implements ProxmoxClient public function shutdownVm(string $node, int $vmid, int $timeoutSeconds): string { + // Der Versuch wird auch dann vermerkt, wenn er scheitert: gefragt wurde, + // und eine Prüfung soll das sehen können. $this->shutdownCalls[] = ['vmid' => $vmid, 'timeout' => $timeoutSeconds]; + if ($this->shutdownThrows) { + throw new \RuntimeException('Proxmox nahm das Herunterfahren nicht an (Attrappe).'); + } + if (! $this->shutdownIgnored) { $this->runningVmids = array_values(array_diff($this->runningVmids, [$vmid])); unset($this->bootedConfig[$vmid]); diff --git a/config/provisioning.php b/config/provisioning.php index c306e6f..31dcce0 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -585,5 +585,24 @@ return [ 'role_privs' => 'VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.PowerMgmt,VM.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit,Sys.Modify', 'user' => 'automation@pve', 'token_name' => 'clupilot', + + /* + | Die Ablage, auf die der Abbau einer gekündigten Instanz ihr Archiv + | schreibt (App\Actions\ArchiveAndTearDown). + | + | `local` als Vorgabe, weil dort schon die nächtlichen Sicherungen + | landen — HttpProxmoxClient::createBackupJob() hat den Namen fest + | verdrahtet, und ein Archiv woanders hinzuschreiben als die Sicherungen + | wäre ein zweiter Ort, an dem in zwölf Monaten jemand suchen muss. + | + | Einstellbar und nicht fest, weil es eine Annahme über die Einrichtung + | JEDES Hosts ist: eine Flotte, deren Sicherungen auf einer PBS-Ablage + | oder einem NFS-Ziel liegen, hat diesen Namen nicht, und eine feste + | Zeichenkette fiele erst beim ersten Abbau auf — also genau dann, wenn + | er gebraucht wird. Der Name wird zusätzlich gegen die Ablagen des + | Knotens geprüft; fehlt sie oder nimmt sie keine Sicherungen auf, bricht + | der Abbau ab und löscht nichts. + */ + 'archive_storage' => env('CLUPILOT_ARCHIVE_STORAGE', 'local'), ], ]; diff --git a/tests/Feature/Cancellation/ArchiveAndTearDownTest.php b/tests/Feature/Cancellation/ArchiveAndTearDownTest.php new file mode 100644 index 0000000..6ee7472 --- /dev/null +++ b/tests/Feature/Cancellation/ArchiveAndTearDownTest.php @@ -0,0 +1,391 @@ +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([]); +}); + +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([]); +}); + +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, 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('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(); +});