Kuendigung B1, Fix-Welle: der Platz wird frei, und das Loeschen ist vollzogen

K1 — abgebaute Instanzen zaehlten weiter als belegend. `scopeOccupyingHost`
filtert jetzt `torn_down_at`: eine abgebaute Instanz behaelt `ended`, `vmid`
und `disk_gb` als Nachweis, `status != 'failed'` war fuer sie also wahr. Der
Knoten wurde physisch leer und die Buchhaltung blieb voll — die naechste
bezahlte Bestellung derselben Groesse waere geparkt und verworfen worden.

K2 — `deleteVm()` gibt die UPID zurueck, der Abbau wartet den qmdestroy-Auftrag
ab und schreibt `torn_down_at` erst danach. Ein 200 heisst nur, dass Proxmox
den Auftrag angenommen hat; scheitert die Zerstoerung danach, stand bisher eine
laufende Maschine mit einem Datensatz da, der "abgebaut" sagte — und niemand
sah sie je wieder an. Die Attrappe bildet die Asynchronitaet ab
(`destroyedVmids`, `destroyExitStatus`, `destroyHangs`). Die Fristen des
Auftrags wandern mit: Sichern 900 s, Zerstoeren 300 s, Summe unveraendert 1800 s
unter der Uhr des Arbeiters.

K3 — vor `shutdownVm()` steht derselbe `vmStatus()`-Riegel wie im
Nachbarschritt. Eine wegen offener Zahlung gesperrte Cloud und jeder
Wiederholungslauf nach einem Teilfehlschlag treffen einen gestoppten Gast; der
Wurf haette die Instanz unheilbar gemacht und den echten Grund am Datensatz
ueberschrieben. Die Attrappe weist eine Bitte gegen eine stehende Maschine
jetzt ab, und ein Test faehrt erstmals einen zweiten Lauf nach einem
Fehlschlag durch.

W2 — der rote Kasten "Abbau haengt" filtert `status = 'ended'`. Eine
wiederbelebte Instanz waere sonst fuer immer darin stehengeblieben.

Neun Pruefungen im Mahnwesen lassen ihre Cloud jetzt laufen, bevor sie gesperrt
wird — die geschaerfte Attrappe legt offen, dass `SuspendInstance` denselben
fehlenden Riegel hat (Folgepunkt im Bericht).

Zu jedem der vier Punkte eine Zusicherung, die ohne den Fix rot ist; die
Rotproben stehen im Bericht.
feat/versandtakt
nexxo 2026-08-04 12:39:06 +02:00
parent df73e558e9
commit 19a770e1eb
14 changed files with 552 additions and 67 deletions

View File

@ -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
{

View File

@ -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')

View File

@ -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<self> $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'));
}
/**

View File

@ -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

View File

@ -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');

View File

@ -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<int, int> */
/**
* Jede angenommene Löschanfrage der AUFTRAG, nicht sein Vollzug.
*
* @var array<int, int>
*/
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<int, int>
*/
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];
}

View File

@ -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

View File

@ -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;

View File

@ -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']);

View File

@ -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'));

View File

@ -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);

View File

@ -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

View File

@ -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.

View File

@ -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);
});