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(); +});