diff --git a/app/Actions/ArchiveAndTearDown.php b/app/Actions/ArchiveAndTearDown.php index 674f874..abd1271 100644 --- a/app/Actions/ArchiveAndTearDown.php +++ b/app/Actions/ArchiveAndTearDown.php @@ -36,19 +36,25 @@ use Throwable; * ## Die Reihenfolge, und warum jede Stufe da steht, wo sie steht * * 1. **Ablage klären**, bevor irgendetwas angefasst wird — siehe unten. - * 2. **Herunterfahren** und darauf warten. Eine laufende Datenbank zu sichern - * und danach zu löschen hiesse, ein Archiv mit halb geschriebenen Tabellen - * aufzubewahren. Es wird nie hart ausgeschaltet: `ProxmoxClient` bietet den - * Stromstecker bewusst nicht an (siehe dort), also ist die Frist eine - * Grenze fürs WARTEN und keine Zündschnur. Ein Gast, der nicht geht, ist ein - * Fall für einen Menschen. + * 2. **Herunterfahren**, sofern der Gast überhaupt läuft, und darauf warten. + * Eine laufende Datenbank zu sichern und danach zu löschen hiesse, ein + * Archiv mit halb geschriebenen Tabellen aufzubewahren. Es wird nie hart + * ausgeschaltet: `ProxmoxClient` bietet den Stromstecker bewusst nicht an + * (siehe dort), also ist die Frist eine Grenze fürs WARTEN und keine + * Zündschnur. Ein Gast, der nicht geht, ist ein Fall für einen Menschen. + * Das „sofern" ist kein Zierrat, sondern der Regelfall — die Begründung + * steht am Riegel selbst. * 3. **Sichern** über `backupNow()` und auf den Auftrag warten. Der Aufruf * liefert nur eine Kennung; der Dump läuft danach noch minutenlang. Ohne * dieses Warten fragte die Nachschau in Stufe 4 die Ablage, bevor das * Archiv darauf liegt — der Abbau schlüge jedes Mal fehl und liesse bei * jedem Lauf einen weiteren Dump auf der Ablage zurück. * 4. **Nachsehen.** Liegt nichts da, wird nicht gelöscht. - * 5. **Löschen**, und erst dann `torn_down_at` schreiben. + * 5. **Löschen** — den Zerstörungsauftrag abwarten wie die beiden davor —, und + * erst dann `torn_down_at` schreiben. Auch `deleteVm()` liefert nur eine + * Kennung; `qmdestroy` läuft danach und kann scheitern. Ein „abgebaut" auf + * einer stehenden Maschine ist der einzige Fehlschlag dieses Ablaufs, den + * danach niemand mehr sieht. * * `archive_volid`/`archived_at` werden VOR dem Löschen geschrieben, nicht * danach: das Archiv existiert ab Stufe 4 wirklich, und ein Absturz zwischen @@ -127,6 +133,18 @@ class ArchiveAndTearDown */ public int $backupWaitSeconds = 3600; + /** + * Wie lange auf das Zerstören der Maschine gewartet wird. + * + * Zehn Minuten. `qmdestroy` räumt jede Platte auf jeder Ablage weg; auf + * einem Thin-LVM sind das Sekunden, auf einer Verzeichnisablage mit einer + * grossen qcow2 Minuten, und eine Ablage, die gerade eine fremde nächtliche + * Sicherung schreibt, ist träge. Was danach noch läuft, hakt — und dann + * bleibt die Instanz lieber ohne `torn_down_at` stehen, damit der nächste + * Lauf erneut nachsieht. + */ + public int $deleteWaitSeconds = 600; + /** Abstand zwischen zwei Nachfragen nach einem laufenden Auftrag. */ public int $pollSeconds = 15; @@ -215,14 +233,15 @@ class ArchiveAndTearDown * Wie lange die Sperre halten muss. * * Der ganze Lauf, nicht nur der Dump: im schlechtesten Fall wartet er erst - * die Frist fürs Herunterfahren und danach die fürs Sichern ab. Eine kürzere - * Sperre liefe ausgerechnet kurz vor dem Löschen aus — also genau dann, wenn - * ein zweiter Lauf am meisten Schaden anrichtet. Der Zuschlag deckt die - * Ablagenprüfung, die Nachschau und die HTTP-Umläufe dazwischen. + * die Frist fürs Herunterfahren, dann die fürs Sichern und zuletzt die fürs + * Zerstören ab. Eine kürzere Sperre liefe ausgerechnet während des Löschens + * aus — also genau dann, wenn ein zweiter Lauf am meisten Schaden + * anrichtet. Der Zuschlag deckt die Ablagenprüfung, die Nachschau und die + * HTTP-Umläufe dazwischen. */ private function lockSeconds(): int { - return $this->shutdownWaitSeconds + $this->backupWaitSeconds + 600; + return $this->shutdownWaitSeconds + $this->backupWaitSeconds + $this->deleteWaitSeconds + 600; } /** @@ -281,17 +300,39 @@ class ArchiveAndTearDown return true; } - $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. + // Erst fragen, dann bitten — derselbe Riegel, den der Nachbarschritt + // `ShutDownVirtualMachine` seit jeher hat, und auf den sich der + // Kopfkommentar dieser Klasse beruft. + // + // `shutdownVm()` ruft im echten Client `->throw()`, und eine bereits + // stehende Maschine ist hier kein Rand, sondern ein gewöhnlicher + // Eingangszustand. Zwei Wege führen darauf: + // + // 1. Wegen offener Zahlung gesperrt. `SuspendInstance` fährt herunter + // und lässt sie so stehen; kündigt dieser Kunde, ist der Gast beim + // ERSTEN Abbauversuch seit Wochen gestoppt. + // 2. Wiederholung nach Teilfehlschlag. Jeder Fehlschlag ab dem Sichern + // lässt die Maschine angehalten stehen — `mode=stop` startet sie + // nicht wieder. In der nächsten Nacht ist das Herunterfahren die + // erste Stufe und trifft einen gestoppten Gast. + // + // Ohne den Riegel wäre so eine Instanz unheilbar: jede Nacht derselbe + // Wurf, und der neue Grund (ein nacktes „HTTP request returned status + // code 500") überschriebe den echten, der noch am Datensatz stand. 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.' - ); + $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 @@ -322,7 +363,20 @@ class ArchiveAndTearDown ); } - $pve->deleteVm($node, $vmid); + // Die eine unumkehrbare Stufe — und die einzige, die bis hierher einer + // Auftragskennung glaubte. + // + // `DELETE /nodes/…/qemu/…` prüft synchron nur Schutzflagge und + // HA-Verwendung, forkt dann `qmdestroy` und antwortet mit 200. Die + // Sperre, `check_running` und jede Platte auf jeder Ablage kommen + // danach und können scheitern. Wer den Abbau auf das 200 bucht, hat im + // schlimmsten Fall eine stehende Maschine mit einem Datensatz, der + // „abgebaut" sagt: der nächtliche Befehl filtert `torn_down_at` und + // sieht sie nie wieder, kein Kasten zeigt sie, niemand sieht sie je + // wieder an. Deshalb dasselbe Warten wie beim Herunterfahren und beim + // Sichern — ausgerechnet hier hatte es gefehlt. + $destroyUpid = $pve->deleteVm($node, $vmid); + $this->awaitTask($pve, $node, $destroyUpid, $this->deleteWaitSeconds, 'Das Löschen'); $instance->update(['torn_down_at' => now(), 'teardown_error' => null]); @@ -372,10 +426,11 @@ class ArchiveAndTearDown /** * 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. + * `shutdownVm()`, `backupNow()` und `deleteVm()` liefern alle drei 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, und beim Löschen eine Platte, die noch daliegt. */ private function awaitTask(ProxmoxClient $pve, string $node, string $upid, int $waitSeconds, string $was): void { diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php index de1b841..8a91f6f 100644 --- a/app/Livewire/Admin/Instances.php +++ b/app/Livewire/Admin/Instances.php @@ -121,6 +121,15 @@ class Instances extends Component // unten wandern. 'stuck' => Instance::query() ->with(['customer', 'host']) + // Derselbe Zustand, den `isDue()` verlangt, und aus demselben + // Grund: nur eine Instanz mit `ended` wird überhaupt noch + // einmal angefasst. Wird eine wiederbelebt (`status` zurück auf + // `active`), bleibt ihr alter `teardown_error` stehen, die + // Aktion läuft nie wieder — und die Zeile stünde FÜR IMMER im + // roten Kasten, für eine Maschine, die fröhlich läuft. Der + // Untertitel verspricht das Gegenteil: eine Zeile verschwindet + // von selbst, sobald der Abbau durchläuft. + ->where('status', 'ended') ->whereNotNull('teardown_error') ->whereNull('torn_down_at') ->orderByDesc('updated_at') diff --git a/app/Models/Instance.php b/app/Models/Instance.php index f8d35e0..02b46eb 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -137,11 +137,27 @@ class Instance extends Model * placement would call a host comfortable while orders were being refused * on it. * + * `torn_down_at` ist die Zeile, die den ganzen Abbau erst wirtschaftlich + * macht. Eine abgebaute Instanz behält ihren Zustand `ended`, ihre `vmid` + * und ihr `disk_gb` — der Datensatz bleibt als Nachweis stehen, wo das + * Archiv liegt. Ohne diese Bedingung wäre `status != 'failed'` für sie + * wahr, und sie zählte weiter als belegend: der Platz wurde auf dem Knoten + * physisch frei, `availableGb()` gäbe ihn aber nie wieder her. Eine + * bezahlte Bestellung derselben Grösse liefe dann in `ReserveResources` auf + * einen Host, den es für voll hält, würde geparkt und nach `PARK_DAYS` als + * `no_capacity` verworfen — und `HostCapacity::unplaceablePlans()` nähme + * das Paket auf der Landingpage aus dem Angebot. Der Abbau löschte + * Kundenplatten und brächte nichts ein. + * + * Das Nullen von `vmid` täte es NICHT: `status != 'failed'` allein hält die + * Zeile in der Summe. Es muss der Abbau-Zeitstempel sein. + * * @param Builder $query */ public function scopeOccupyingHost($query): void { - $query->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')); + $query->whereNull('torn_down_at') + ->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')); } /** diff --git a/app/Provisioning/Jobs/TearDownInstance.php b/app/Provisioning/Jobs/TearDownInstance.php index 1121b4c..c2deab6 100644 --- a/app/Provisioning/Jobs/TearDownInstance.php +++ b/app/Provisioning/Jobs/TearDownInstance.php @@ -34,7 +34,7 @@ use Illuminate\Queue\SerializesModels; * ## Die Fristen, und warum sie ineinander stehen müssen * * `ArchiveAndTearDown` wartet blockierend (`Sleep::for`), mit seinen Vorgaben - * bis zu 600 + 3600 = 4200 Sekunden je Instanz. Auf einer Warteschlange gibt es + * bis zu 600 + 3600 + 600 = 4800 Sekunden je Instanz. Auf einer Warteschlange gibt es * darüber zwei weitere Uhren, und wessen Uhr zuerst klingelt, entscheidet, was * hinterher dasteht: * @@ -59,7 +59,7 @@ use Illuminate\Queue\SerializesModels; * * ## Der Preis dieser Entscheidung, ausgesprochen * - * Zwanzig Minuten fürs Sichern statt der Stunde, die die Aktion allein + * Fünfzehn Minuten fürs Sichern statt der Stunde, die die Aktion allein * zuliesse. Eine sehr grosse Kundenplatte kann darin nicht fertig werden. Dann * wirft die Aktion mit genau diesem Satz, der Grund steht am Datensatz und in * der Konsole, gelöscht wird nichts, und ein Mensch entscheidet — die Fristen @@ -84,13 +84,33 @@ class TearDownInstance implements ShouldBeUnique, ShouldQueue /** * Wie lange die Aktion auf den vzdump warten darf. * - * Zwanzig statt sechzig Minuten, und die Zahl kommt nicht aus einer + * Fünfzehn statt sechzig Minuten, und die Zahl kommt nicht aus einer * Schätzung über Plattengrössen, sondern von oben: `$timeout` minus dem - * Herunterfahren minus dem Zuschlag für Ablagenprüfung, Nachschau und die - * HTTP-Umläufe dazwischen. Was hier hineinpasst, ist das, was die - * Warteschlange trägt — siehe die Tabelle im Kopf. + * Herunterfahren, minus dem Warten auf die Zerstörung, minus dem Zuschlag + * für Ablagenprüfung, Nachschau und die HTTP-Umläufe dazwischen. Was hier + * hineinpasst, ist das, was die Warteschlange trägt — siehe die Tabelle im + * Kopf. + * + * Waren zwanzig Minuten, bevor das Warten auf `qmdestroy` dazukam. Das + * Sichern gibt die fünf Minuten ab und nicht das Zerstören sie sich, weil + * das Gesamtbudget von 1800 Sekunden die Uhr des Arbeiters unter sich + * halten muss — und weil ein zu kurz geratenes Sichern sichtbar + * stehenbleibt, ein nicht abgewartetes Zerstören dagegen still eine + * laufende Maschine als abgebaut verbuchte. */ - public const BACKUP_WAIT_SECONDS = 1200; + public const BACKUP_WAIT_SECONDS = 900; + + /** + * Wie lange die Aktion auf `qmdestroy` warten darf. + * + * Fünf Minuten. Die Zerstörung ist im Regelfall Sekunden — ein Thin-LVM + * gibt seine Blöcke sofort her —, und was länger braucht, hakt an etwas, + * das ein Mensch ansehen soll: eine VM, die eine fremde Sicherung sperrt, + * ein Volume in Benutzung. Läuft die Frist ab, bleibt die Instanz ohne + * `torn_down_at` stehen, der Grund steht in der Konsole, und die nächste + * Nacht sieht erneut nach. + */ + public const DELETE_WAIT_SECONDS = 300; /** * Kein zweiter Versuch. @@ -148,6 +168,7 @@ class TearDownInstance implements ShouldBeUnique, ShouldQueue $abbau->shutdownWaitSeconds = self::SHUTDOWN_WAIT_SECONDS; $abbau->backupWaitSeconds = self::BACKUP_WAIT_SECONDS; + $abbau->deleteWaitSeconds = self::DELETE_WAIT_SECONDS; // Der Rückgabewert wird bewusst nicht ausgewertet: die Aktion wirft nie // und schreibt ihren Grund selbst an den Datensatz, wo die Konsole ihn diff --git a/app/Provisioning/Steps/Customer/CloneVirtualMachine.php b/app/Provisioning/Steps/Customer/CloneVirtualMachine.php index feb05fb..2b3e203 100644 --- a/app/Provisioning/Steps/Customer/CloneVirtualMachine.php +++ b/app/Provisioning/Steps/Customer/CloneVirtualMachine.php @@ -57,6 +57,14 @@ class CloneVirtualMachine extends CustomerStep return StepResult::poll(10, 'finishing recovered clone'); } + // Die Auftragskennung von deleteVm() bleibt hier bewusst + // liegen, und das Verhalten dieses Schrittes ändert sich damit + // nicht: es wird nichts auf die Zerstörung gebucht. Der nächste + // Anlauf reserviert eine FRISCHE vmid (die Merkzeile ist gerade + // gelöscht worden), läuft also nicht auf die Leiche zu, falls + // `qmdestroy` scheitert. Anders als beim Abbau einer + // gekündigten Instanz hängt hier keine Buchung „ist fort" an + // der Antwort — dort wird der Auftrag deshalb abgewartet. $pve->deleteVm($node, $vmid); RunResource::query()->where('run_id', $run->id)->where('kind', 'vmid')->delete(); $run->forgetContext('vmid'); diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index 7aa0378..f2656f8 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -162,6 +162,31 @@ class FakeProxmoxClient implements ProxmoxClient */ public bool $shutdownThrows = false; + /** + * Maschinen, die es gibt und die laufen — der gewöhnliche Eingangszustand + * einer verkauften Cloud. + * + * Zwei Listen in einem Griff, weil sie zwei verschiedene Fragen + * beantworten: `clonedVmids` sagt „diese VM steht auf dem Knoten" + * (`vmExists()`), `runningVmids` sagt „und sie läuft gerade" + * (`vmStatus()`). Eine Prüfung, die nur die zweite füllt, beschreibt eine + * laufende Maschine, die es nicht gibt. + */ + public function running(int ...$vmids): static + { + foreach ($vmids as $vmid) { + if (! in_array($vmid, $this->clonedVmids, true)) { + $this->clonedVmids[] = $vmid; + } + + if (! in_array($vmid, $this->runningVmids, true)) { + $this->runningVmids[] = $vmid; + } + } + + return $this; + } + public function startVm(string $node, int $vmid): string { $this->runningVmids[] = $vmid; @@ -182,6 +207,19 @@ class FakeProxmoxClient implements ProxmoxClient throw new \RuntimeException('Proxmox nahm das Herunterfahren nicht an (Attrappe).'); } + // Eine stehende Maschine nimmt die Bitte NICHT klaglos an: Proxmox + // antwortet auf `status/shutdown` gegen einen gestoppten Gast mit einem + // Fehler, und der echte Client ruft auf jeder Antwort `->throw()`. + // Diese Attrappe hat das jahrelang verschwiegen — und genau deshalb + // konnte ein Aufrufer ohne Riegel davor durch jede Prüfung kommen. Der + // Fall ist der Regelbetrieb, nicht der Rand: eine wegen offener Zahlung + // gesperrte Cloud steht seit Wochen, und jeder Wiederholungslauf nach + // einem Fehlschlag trifft eine Maschine, die der vorige Lauf + // heruntergefahren hat. + if (! in_array($vmid, $this->runningVmids, true)) { + throw new \RuntimeException('Proxmox: VM '.$vmid.' is not running (Attrappe).'); + } + if (! $this->shutdownIgnored) { $this->runningVmids = array_values(array_diff($this->runningVmids, [$vmid])); unset($this->bootedConfig[$vmid]); @@ -262,15 +300,79 @@ class FakeProxmoxClient implements ProxmoxClient return in_array($vmid, $this->templateVmids, true); } - /** @var array */ + /** + * Jede angenommene Löschanfrage — der AUFTRAG, nicht sein Vollzug. + * + * @var array + */ public array $deletedVmids = []; - public function deleteVm(string $node, int $vmid): void + /** + * Die Maschinen, deren `qmdestroy` wirklich durchgelaufen ist — erst hier + * ist die Platte fort. + * + * @var array + */ + public array $destroyedVmids = []; + + /** + * Womit der `qmdestroy`-Auftrag endet, wenn er nicht mit dem allgemeinen + * `$taskExitStatus` enden soll — z. B. 'VM is locked (backup)'. Der + * gewöhnlichste Fehlschlag überhaupt: die nächtliche Sicherung eines + * anderen Kunden hält die Ablage, und die Zerstörung scheitert Sekunden + * NACH dem 200 auf die Anfrage. + */ + public ?string $destroyExitStatus = null; + + /** Der `qmdestroy`-Auftrag, der nicht fertig wird (hakende Ablage). */ + public bool $destroyHangs = false; + + /** + * Die Anfrage wird angenommen und liefert eine Kennung — mehr nicht. + * + * Bewusst NICHT der sofortige, untrügliche Vollzug, den diese Attrappe + * bisher vorspielte: Proxmox prüft hier synchron nur Schutzflagge und + * HA-Verwendung, forkt dann `qmdestroy` und antwortet mit 200. Solange der + * Auftrag nicht durchgelaufen ist, steht die Maschine und liegt die Platte + * — deshalb bleibt der VMID hier in `clonedVmids`/`runningVmids` stehen und + * verschwindet erst in `taskStatus()`. + */ + public function deleteVm(string $node, int $vmid): string { $this->deletedVmids[] = $vmid; - $this->clonedVmids = array_values(array_diff($this->clonedVmids, [$vmid])); - $this->runningVmids = array_values(array_diff($this->runningVmids, [$vmid])); - unset($this->vmConfig[$vmid], $this->bootedConfig[$vmid]); + + return 'UPID:pve:qmdestroy:'.$vmid; + } + + /** + * Der Vollzug, den die Attrappe an die Nachfrage nach dem Auftrag hängt. + * + * Ein Aufrufer, der die Kennung wegwirft, sieht die Maschine also nie + * verschwinden — das ist der Punkt: er weiss nicht, ob sie fort ist. + */ + private function destroyTaskStatus(string $upid): array + { + if ($this->destroyHangs) { + return ['status' => 'running']; + } + + $ende = $this->destroyExitStatus ?? $this->taskExitStatus; + + // Wie beim echten vzdump: "WARNINGS: n" ist ein fertiger Auftrag, kein + // Fehlschlag — die Maschine ist dann tatsächlich fort. + if ($ende === 'OK' || str_starts_with($ende, 'WARNINGS')) { + $vmid = (int) substr($upid, (int) strrpos($upid, ':') + 1); + + if (! in_array($vmid, $this->destroyedVmids, true)) { + $this->destroyedVmids[] = $vmid; + } + + $this->clonedVmids = array_values(array_diff($this->clonedVmids, [$vmid])); + $this->runningVmids = array_values(array_diff($this->runningVmids, [$vmid])); + unset($this->vmConfig[$vmid], $this->bootedConfig[$vmid]); + } + + return ['status' => 'stopped', 'exitstatus' => $ende]; } public function guestAgentPing(string $node, int $vmid): bool @@ -345,6 +447,12 @@ class FakeProxmoxClient implements ProxmoxClient public function taskStatus(string $node, string $upid): array { + // Der Zerstörungsauftrag hat einen eigenen Ausgang und einen eigenen + // Vollzug — siehe destroyTaskStatus(). + if (str_contains($upid, 'qmdestroy')) { + return $this->destroyTaskStatus($upid); + } + return ['status' => $this->forceTaskStatus ?? 'stopped', 'exitstatus' => $this->taskExitStatus]; } diff --git a/app/Services/Proxmox/HttpProxmoxClient.php b/app/Services/Proxmox/HttpProxmoxClient.php index 3541168..79384bb 100644 --- a/app/Services/Proxmox/HttpProxmoxClient.php +++ b/app/Services/Proxmox/HttpProxmoxClient.php @@ -166,9 +166,13 @@ class HttpProxmoxClient implements ProxmoxClient return (int) $this->http()->get("/nodes/{$node}/qemu/{$vmid}/config")->throw()->json('data.template', 0) === 1; } - public function deleteVm(string $node, int $vmid): void + public function deleteVm(string $node, int $vmid): string { - $this->http()->asForm()->delete("/nodes/{$node}/qemu/{$vmid}", ['purge' => 1])->throw(); + // Die Antwort ist die Auftragskennung und wurde bis hierher weggeworfen. + // Ein 200 heisst nur, dass Proxmox den Auftrag angenommen hat — siehe + // die Begründung an der Schnittstelle. + return (string) $this->http()->asForm() + ->delete("/nodes/{$node}/qemu/{$vmid}", ['purge' => 1])->throw()->json('data'); } public function guestAgentPing(string $node, int $vmid): bool diff --git a/app/Services/Proxmox/ProxmoxClient.php b/app/Services/Proxmox/ProxmoxClient.php index 09034a6..e9c3df2 100644 --- a/app/Services/Proxmox/ProxmoxClient.php +++ b/app/Services/Proxmox/ProxmoxClient.php @@ -94,7 +94,25 @@ interface ProxmoxClient */ public function isTemplate(string $node, int $vmid): bool; - public function deleteVm(string $node, int $vmid): void; + /** + * Die Zerstörung einer VM BEAUFTRAGEN und die Auftragskennung (UPID) + * liefern — kein Vollzug. + * + * Proxmox prüft auf diesem Aufruf synchron nur die Schutzflagge und die + * Verwendung in einer HA-Gruppe, forkt dann `qmdestroy` und antwortet mit + * 200 und einer Kennung. Die eigentliche Arbeit — Sperre setzen, + * `check_running`, jede Platte auf jeder Ablage wegräumen — läuft danach + * und kann dort scheitern: eine VM, die gerade gesichert wird + * (`VM is locked (backup)`), ein LVM-Volume in Benutzung, eine hakende + * Ablage. + * + * Wer das Ergebnis braucht, wartet den Auftrag über `taskStatus()` ab. Für + * `ArchiveAndTearDown` ist das keine Kür: dort ist dies die eine + * unumkehrbare Stufe, und ein 200 als Vollzug zu lesen hiesse, eine noch + * laufende Kundenmaschine als abgebaut zu verbuchen — danach sieht sie + * kein Lauf und kein Kasten je wieder an. + */ + public function deleteVm(string $node, int $vmid): string; public function guestAgentPing(string $node, int $vmid): bool; diff --git a/tests/Feature/Billing/DunningFixRoundTest.php b/tests/Feature/Billing/DunningFixRoundTest.php index 0b2a85c..6740047 100644 --- a/tests/Feature/Billing/DunningFixRoundTest.php +++ b/tests/Feature/Billing/DunningFixRoundTest.php @@ -63,7 +63,8 @@ it('shuts down only the cloud of the contract in arrears', function () { 'next_step_at' => Carbon::now()->subMinute(), 'fee_invoice_ids' => [], ]); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); app()->instance(StripeClient::class, new FakeStripeClient); test()->artisan('clupilot:advance-dunning')->assertSuccessful(); @@ -87,7 +88,8 @@ it('does not bring a cloud back while another case still has it stopped', functi ]); } - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); app(SuspendInstance::class)($w['schuldigeCloud']); app(SuspendInstance::class)($w['bezahlteCloud']); @@ -135,7 +137,8 @@ it('records the level before it asks Stripe for the fee', function () { } }; app()->instance(StripeClient::class, $stripe); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); test()->artisan('clupilot:advance-dunning'); @@ -164,7 +167,8 @@ it('sends an idempotency key with the fee so a same-day retry cannot double it', $stripe = new FakeStripeClient; app()->instance(StripeClient::class, $stripe); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); test()->artisan('clupilot:advance-dunning'); @@ -186,7 +190,8 @@ it('retries a notice that never made it out', function () { ]); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); Mail::fake(); test()->artisan('clupilot:advance-dunning')->assertSuccessful(); @@ -209,7 +214,8 @@ it('does not write the same level twice once it went out', function () { ]); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); Mail::fake(); test()->artisan('clupilot:advance-dunning'); @@ -233,7 +239,8 @@ it('sends nothing at all during a dry run', function () { ]); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); Mail::fake(); test()->artisan('clupilot:advance-dunning --dry-run')->assertSuccessful(); @@ -258,7 +265,8 @@ it('records the opening notice so it is not sent a second time', function () { expect($case->notified_levels)->toBe([0]); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); test()->artisan('clupilot:advance-dunning'); @@ -280,7 +288,8 @@ it('does not let one broken case stop the catch-up for the others', function () } app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Beide Clouds laufen, bevor eine davon gesperrt wird. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101, 102)); // Der erste Fall wirft beim Vermerken, der zweite muss trotzdem laufen. DunningCase::query()->orderBy('id')->first()->update(['stripe_invoice_id' => 'in_a']); diff --git a/tests/Feature/Billing/DunningMailTest.php b/tests/Feature/Billing/DunningMailTest.php index 746c1ed..0bf8cc8 100644 --- a/tests/Feature/Billing/DunningMailTest.php +++ b/tests/Feature/Billing/DunningMailTest.php @@ -99,7 +99,9 @@ it('sends a reminder with the fee spelled out separately', function () { $case = mailableCase(1); $case->update(['fee_invoice_ids' => []]); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Die Cloud läuft: die Attrappe weist eine Bitte zum Herunterfahren gegen + // eine stehende Maschine ab, genau wie Proxmox es tut. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101)); $this->artisan('clupilot:advance-dunning')->assertSuccessful(); @@ -112,7 +114,9 @@ it('names the shutdown date while it is still ahead', function () { Mail::fake(); mailableCase(0); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Die Cloud läuft: die Attrappe weist eine Bitte zum Herunterfahren gegen + // eine stehende Maschine ab, genau wie Proxmox es tut. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101)); $this->artisan('clupilot:advance-dunning'); @@ -124,7 +128,9 @@ it('sends the shutdown notice, not a fourth reminder', function () { Mail::fake(); mailableCase(DunningSchedule::SUSPENDED - 1); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Die Cloud läuft: die Attrappe weist eine Bitte zum Herunterfahren gegen + // eine stehende Maschine ab, genau wie Proxmox es tut. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101)); $this->artisan('clupilot:advance-dunning'); @@ -135,7 +141,9 @@ it('sends the shutdown notice, not a fourth reminder', function () { it('sends the all-clear only to somebody whose cloud actually stood', function () { Mail::fake(); $case = mailableCase(DunningSchedule::SUSPENDED); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Die Cloud läuft: die Attrappe weist eine Bitte zum Herunterfahren gegen + // eine stehende Maschine ab, genau wie Proxmox es tut. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101)); app()->instance(StripeClient::class, new FakeStripeClient); app(ApplyStripeBillingEvent::class)->invoicePaid([ @@ -151,7 +159,9 @@ it('stays quiet about a cloud that never stood', function () { // seine Cloud laufe wieder, wäre eine Nachricht über etwas, das nie war. Mail::fake(); mailableCase(1); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Die Cloud läuft: die Attrappe weist eine Bitte zum Herunterfahren gegen + // eine stehende Maschine ab, genau wie Proxmox es tut. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101)); app()->instance(StripeClient::class, new FakeStripeClient); app(ApplyStripeBillingEvent::class)->invoicePaid([ @@ -167,7 +177,9 @@ it('advances the case even when the mail cannot be sent', function () { // und die Sperre kommt nie. $case = mailableCase(0); app()->instance(StripeClient::class, new FakeStripeClient); - app()->instance(ProxmoxClient::class, new FakeProxmoxClient); + // Die Cloud läuft: die Attrappe weist eine Bitte zum Herunterfahren gegen + // eine stehende Maschine ab, genau wie Proxmox es tut. + app()->instance(ProxmoxClient::class, (new FakeProxmoxClient)->running(101)); Mail::shouldReceive('mailer')->andThrow(new RuntimeException('kein Mailserver')); diff --git a/tests/Feature/Billing/DunningSuspensionTest.php b/tests/Feature/Billing/DunningSuspensionTest.php index dcec51f..1a07f9d 100644 --- a/tests/Feature/Billing/DunningSuspensionTest.php +++ b/tests/Feature/Billing/DunningSuspensionTest.php @@ -41,7 +41,9 @@ function suspendableInstance(): Instance it('shuts the machine down without deleting anything', function () { $instance = suspendableInstance(); - $pve = new FakeProxmoxClient; + // Die Cloud läuft, bevor sie gesperrt wird — sonst weist die Attrappe die + // Bitte zum Herunterfahren ab, wie Proxmox es tut. + $pve = (new FakeProxmoxClient)->running(101); app()->instance(ProxmoxClient::class, $pve); app(SuspendInstance::class)($instance); @@ -62,7 +64,9 @@ it('does not shut a machine down twice', function () { // Ein zweiter Tageslauf, ein Neustart des Arbeiterprozesses: die Sperre // ist ein Zustand, kein Ereignis. $instance = suspendableInstance(); - $pve = new FakeProxmoxClient; + // Die Cloud läuft, bevor sie gesperrt wird — sonst weist die Attrappe die + // Bitte zum Herunterfahren ab, wie Proxmox es tut. + $pve = (new FakeProxmoxClient)->running(101); app()->instance(ProxmoxClient::class, $pve); app(SuspendInstance::class)($instance); @@ -75,7 +79,9 @@ it('does not shut a machine down twice', function () { it('starts the machine again and clears the mark', function () { $instance = suspendableInstance(); - $pve = new FakeProxmoxClient; + // Die Cloud läuft, bevor sie gesperrt wird — sonst weist die Attrappe die + // Bitte zum Herunterfahren ab, wie Proxmox es tut. + $pve = (new FakeProxmoxClient)->running(101); app()->instance(ProxmoxClient::class, $pve); app(SuspendInstance::class)($instance); @@ -111,7 +117,9 @@ function suspendedCase(): DunningCase it('closes the case and boots the machine when everything is paid', function () { $case = suspendedCase(); - $pve = new FakeProxmoxClient; + // Die Cloud läuft, bevor sie gesperrt wird — sonst weist die Attrappe die + // Bitte zum Herunterfahren ab, wie Proxmox es tut. + $pve = (new FakeProxmoxClient)->running(101); app()->instance(ProxmoxClient::class, $pve); app(SuspendInstance::class)(Instance::query()->sole()); @@ -133,7 +141,9 @@ it('keeps the case open while the fee is still unpaid', function () { // Rückstand zahlt und die Mahnspesen offen lässt, ist nicht fertig — und // seine Cloud bleibt aus. $case = suspendedCase(); - $pve = new FakeProxmoxClient; + // Die Cloud läuft, bevor sie gesperrt wird — sonst weist die Attrappe die + // Bitte zum Herunterfahren ab, wie Proxmox es tut. + $pve = (new FakeProxmoxClient)->running(101); app()->instance(ProxmoxClient::class, $pve); app(SuspendInstance::class)(Instance::query()->sole()); @@ -199,7 +209,9 @@ it('shuts the cloud down when the last level is reached', function () { 'fee_invoice_ids' => [], ]); - $pve = new FakeProxmoxClient; + // Die Cloud läuft, bevor sie gesperrt wird — sonst weist die Attrappe die + // Bitte zum Herunterfahren ab, wie Proxmox es tut. + $pve = (new FakeProxmoxClient)->running(101); app()->instance(ProxmoxClient::class, $pve); app()->instance(StripeClient::class, new FakeStripeClient); diff --git a/tests/Feature/Cancellation/ArchiveAndTearDownTest.php b/tests/Feature/Cancellation/ArchiveAndTearDownTest.php index c61fd3c..95fd9d7 100644 --- a/tests/Feature/Cancellation/ArchiveAndTearDownTest.php +++ b/tests/Feature/Cancellation/ArchiveAndTearDownTest.php @@ -31,6 +31,11 @@ function abgelaufeneInstanz(int $vmid, ?Carbon $endeteVor = null): Instance 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); @@ -41,7 +46,10 @@ it('faehrt herunter, archiviert, prueft nach, und loescht erst dann', function ( // Sichern vor dem Loeschen. expect($pve->shutdownCalls)->toHaveCount(1) ->and($pve->backupCalls)->toHaveCount(1) - ->and($pve->deletedVmids)->toBe([201]); + ->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(); @@ -64,6 +72,8 @@ it('LOESCHT NICHT, wenn nach dem Sichern kein Archiv daliegt', function () { 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); @@ -362,9 +372,13 @@ it('behält das Archiv am Datensatz, wenn das Löschen scheitert', function () { // 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): void + public function deleteVm(string $node, int $vmid): string { throw new RuntimeException('Proxmox: VM is locked (backup)'); } @@ -454,6 +468,179 @@ it('lässt keinen zweiten Lauf über dieselbe Instanz', function () { $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 diff --git a/tests/Feature/Cancellation/TeardownHasReadersTest.php b/tests/Feature/Cancellation/TeardownHasReadersTest.php index 9143cdf..8859d45 100644 --- a/tests/Feature/Cancellation/TeardownHasReadersTest.php +++ b/tests/Feature/Cancellation/TeardownHasReadersTest.php @@ -110,6 +110,20 @@ it('räumt die Zeile ab, sobald der Abbau durchgelaufen ist', function () { ->assertDontSee(__('admin.teardown.stuck_title')); }); +it('nimmt eine wiederbelebte Instanz aus dem roten Kasten', function () { + // Der Kasten verspricht im Untertitel, dass eine Zeile von selbst + // verschwindet, sobald der Abbau durchläuft. Für eine wiederbelebte Instanz + // läuft er nie wieder durch: `isDue()` verlangt `ended`, also fasst die + // Aktion sie nicht mehr an, der alte Grund bleibt stehen — und die Zeile + // stünde für immer da, für eine Maschine, die fröhlich läuft. Sie gehört + // damit auch nicht mehr in einen Kasten über hängende Abbauten. + $instanz = haengenderAbbau('nc-wiederbelebt', 'beim letzten Mal gescheitert'); + $instanz->update(['status' => 'active']); + + Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class) + ->assertDontSee(__('admin.teardown.stuck_title')); +}); + it('zeigt gar keinen Kasten, wenn nichts hängt', function () { // Ein dauerhaft leerer Kasten wird nach der zweiten Woche übersehen — und // mit ihm der erste Eintrag, der wirklich einmal darin steht. diff --git a/tests/Feature/Console/TearDownEndedInstancesTest.php b/tests/Feature/Console/TearDownEndedInstancesTest.php index ae8efe8..5552c8b 100644 --- a/tests/Feature/Console/TearDownEndedInstancesTest.php +++ b/tests/Feature/Console/TearDownEndedInstancesTest.php @@ -52,6 +52,12 @@ function attrappeMitArchiv(int ...$vmids): FakeProxmoxClient $pve = new FakeProxmoxClient; foreach ($vmids as $vmid) { + // Die Maschine gibt es, und sie läuft: der gewöhnliche Eingangszustand + // einer gekündigten Cloud. Die Attrappe weist die Bitte zum + // Herunterfahren gegen eine stehende Maschine ab, wie Proxmox es tut. + $pve->clonedVmids[] = $vmid; + $pve->runningVmids[] = $vmid; + $pve->backups[(string) $vmid] = [[ 'volid' => 'local:backup/vzdump-qemu-'.$vmid.'.vma.zst', 'ctime' => now()->timestamp, @@ -266,7 +272,12 @@ it('gibt der Aktion Fristen, die vor dem Arbeiter und vor der Warteschlange abla // nichts; `retry_after` startet einen ZWEITEN Abbau gegen eine Maschine // mitten im vzdump. Dreht jemand eine der Zahlen, dreht sich die // Reihenfolge — und zwar still. - $aktion = TearDownInstance::SHUTDOWN_WAIT_SECONDS + TearDownInstance::BACKUP_WAIT_SECONDS; + // Alle drei Wartezeiten der Aktion, nicht nur zwei: seit das Zerstören + // abgewartet wird, ist es die dritte Frist, die sich unter der Uhr des + // Arbeiters ausgehen muss. + $aktion = TearDownInstance::SHUTDOWN_WAIT_SECONDS + + TearDownInstance::BACKUP_WAIT_SECONDS + + TearDownInstance::DELETE_WAIT_SECONDS; $auftrag = (new TearDownInstance('egal'))->timeout; // Aus der Datei und nicht aus config(): Tests\TestCase::setUp() ersetzt @@ -294,6 +305,7 @@ it('gibt die Fristen wirklich an die Aktion weiter', function () { expect($aktion->shutdownWaitSeconds)->toBe(TearDownInstance::SHUTDOWN_WAIT_SECONDS) ->and($aktion->backupWaitSeconds)->toBe(TearDownInstance::BACKUP_WAIT_SECONDS) + ->and($aktion->deleteWaitSeconds)->toBe(TearDownInstance::DELETE_WAIT_SECONDS) // Und die Frist landet auch wirklich am Knoten, nicht nur im Objekt. ->and($pve->shutdownCalls[0]['timeout'])->toBe(TearDownInstance::SHUTDOWN_WAIT_SECONDS); });