Kuendigung B1, Task 3: archivieren, nachsehen, und erst dann abbauen

Gekuendigte Kundenmaschinen wurden nie abgebaut. EndInstanceService nimmt der
Instanz am Laufzeitende die Adresse weg und laesst die Maschine ausdruecklich
stehen; danach kam nichts mehr, und sie belegte fuer immer einen Platz auf dem
Host. ArchiveAndTearDown ist der Schritt danach: vierzehn Tage nach dem Ende
wird die Maschine archiviert und geloescht.

Die eine Regel: niemals loeschen, bevor das Archiv nachweislich existiert. Ein
vzdump kann mit einer Auftragskennung enden und trotzdem nichts hinterlassen —
volle Ablage, abgebrochener Lauf, ein Fehler im Gast. Deshalb steht zwischen
Sichern und Loeschen eine echte Nachschau auf der Ablage (backupsFor), und der
juengste Eintrag muss nach dem Beginn dieses Laufes entstanden sein: eine
naechtliche Sicherung von gestern ist kein Archiv, dem der letzte Tag fehlen
darf.

Beide Proxmox-Aufrufe liefern nur eine Kennung, keine Zusage, also wird auf den
Auftrag gewartet und danach nachgesehen — beim Herunterfahren, ob der Gast
wirklich steht, beim Sichern, ob die Datei liegt. Unmittelbar vor dem Loeschen
wird der Datensatz neu gelesen: eine Instanz, die in der Zwischenzeit
wiederbelebt wurde, wird nicht geloescht. Keine Ausnahme entkommt — ein
Fehlschlag ist false und ein Grund in teardown_error, weil dieser Ablauf spaeter
im Zeitplan ueber viele Instanzen laeuft.

Die Ablage kommt aus einer Einstellung (provisioning.proxmox.archive_storage,
Vorgabe local wie bei den naechtlichen Sicherungen) UND wird gegen die Ablagen
des Knotens geprueft. Fehlt sie oder nimmt sie keine Sicherungen auf, bricht der
Abbau ab, bevor eine Kundenmaschine dafuer heruntergefahren wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 11:15:52 +02:00
parent e80aac8b4a
commit 24a0f64128
5 changed files with 888 additions and 0 deletions

View File

@ -115,6 +115,12 @@ CLUPILOT_DNS_ZONE=clupilot.cloud
# docker-compose.yml. Only change if that volume is mounted somewhere else.
CLUPILOT_DNS_HOSTS_DIR=/etc/clupilot/dns-hosts
# ── Archiv einer gekündigten Instanz ─────────────────────────────────────
# Die Proxmox-Ablage, auf die der Abbau (App\Actions\ArchiveAndTearDown) das
# Archiv schreibt, bevor die Maschine gelöscht wird. Muss es auf JEDEM Host
# geben und `backup` tragen — sonst bricht der Abbau ab und löscht nichts.
CLUPILOT_ARCHIVE_STORAGE=local
# ── Traefik (reverse proxy + TLS) ────────────────────────────────────────
# Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files.
TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic

View File

@ -0,0 +1,454 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use RuntimeException;
use Throwable;
/**
* Die Maschine einer gekündigten Instanz archivieren und dann abbauen.
*
* Gekündigte Kundenmaschinen wurden bisher nie abgebaut. `EndInstanceService`
* nimmt der Instanz am Laufzeitende die Adresse weg Router, DNS-Eintrag,
* Zertifikatsflaggen und lässt die virtuelle Maschine ausdrücklich stehen,
* weil das Zerstören fremder Platten keine Entscheidung ist, die ein
* Adress-Abbau nebenbei trifft. Danach kam nichts mehr: die Maschine lief für
* immer weiter und belegte einen Platz auf dem Host, den niemand mehr verkaufen
* kann. Dies ist der Schritt danach.
*
* ## Die eine Regel
*
* **Niemals löschen, bevor das Archiv nachweislich existiert.** Ein `vzdump`
* kann mit einer Auftragskennung enden und trotzdem nichts hinterlassen volle
* Ablage, abgebrochener Lauf, ein Fehler im Gast. Wer der Kennung glaubt statt
* nachzusehen, löscht die Maschine eines Kunden, dem zwölf Monate Aufbewahrung
* zugesagt sind, und hat kein Archiv. Das ist durch keinen Wiederholungslauf zu
* heilen. Deshalb steht zwischen dem Sichern und dem Löschen eine echte
* Nachfrage auf der Ablage (`backupsFor()`), und jeder Riegel davor lässt den
* Lauf lieber stehen, als eine Stufe zu überspringen.
*
* ## 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.
* 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.
*
* `archive_volid`/`archived_at` werden VOR dem Löschen geschrieben, nicht
* danach: das Archiv existiert ab Stufe 4 wirklich, und ein Absturz zwischen
* vier und fünf soll den bereits bezahlten Dump nicht vergessen. Genau dafür
* sind es zwei Zeitstempel (siehe die Wanderung der Spalten in der Migration).
*
* ## Die Ablage
*
* Der Name kommt aus der Einstellung `provisioning.proxmox.archive_storage`
* (Vorgabe `local`, wie sie die nächtlichen Sicherungen von `RegisterBackup`
* schon benutzen) UND wird gegen die Ablagen des Knotens geprüft: sie muss es
* geben, und sie muss `backup` tragen. Beides zusammen, weil beide Hälften
* allein nicht reichen eine feste Zeichenkette wäre eine Annahme über die
* Einrichtung jedes Hosts, die erst beim ersten Abbau auffällt, und eine
* ungeprüfte Einstellung wäre dieselbe Annahme mit mehr Schritten. Fehlt die
* Ablage, bricht der Lauf VOR dem Herunterfahren ab, sagt im Klartext, welche
* Ablagen der Knoten stattdessen hat, und löscht nichts. Es wird ausdrücklich
* nicht geraten: eine Maschine auf eine Ablage zu sichern, die der Betreiber
* nicht gemeint hat, ist ein Archiv, das in zwölf Monaten niemand sucht.
*
* ## Was hier ausdrücklich NICHT passiert
*
* Nichts wirft. Dieser Ablauf läuft später in einem Zeitplan über viele
* Instanzen; eine geworfene Ausnahme risse die übrigen mit. Ein Fehlschlag ist
* `false` und ein Grund in `teardown_error` dort, wo ein Betreiber ihn ohne
* Logdurchsicht findet.
*
* Das Archiv selbst wird nicht verwaltet. Die zwölf Monate Aufbewahrung und das
* Löschen des Dumps danach hängen an `archived_at` und gehören einem eigenen
* Lauf; hier wird nur festgehalten, wo das Archiv liegt.
*
* Adresse, DNS und Router werden nicht angefasst das hat `EndInstanceService`
* beim Laufzeitende getan, und eine zweite Stelle, die dasselbe täte, würde
* beim ersten Auseinanderlaufen zur Frage, welche recht hat.
*/
class ArchiveAndTearDown
{
/**
* Wie lange eine gekündigte Instanz nach dem Laufzeitende stehen bleibt.
*
* Vierzehn Tage sind die Rückfahrkarte. Ein Mensch, dessen Paket gestern
* ablief, überlegt es sich vielleicht gerade anders, und bis hierher ist
* seine Maschine unverändert da. Ab hier ist sie es nicht mehr.
*/
public const GRACE_DAYS = 14;
/**
* Wie weit die Uhr des Hosts vorgehen darf, ohne dass ein frisches Archiv
* als altes gilt.
*
* `ctime` kommt von der Uhr des Proxmox-Knotens, die Startzeit dieses Laufs
* von der des Portals. Ohne Toleranz macht eine Sekunde Versatz aus dem
* gerade eben geschriebenen Dump ein „Archiv von gestern" und der Abbau
* bliebe für immer stehen. Fünf Minuten sind gross genug für jeden
* NTP-Versatz und klein genug, dass eine nächtliche Sicherung von gestern
* niemals hineinfällt.
*/
private const CLOCK_SKEW_SECONDS = 300;
/**
* Wie lange auf das Herunterfahren des Gastes gewartet wird.
*
* Zehn Minuten, dieselbe Frist wie in ShutDownVirtualMachine und aus
* demselben Grund: eine Nextcloud, die sauber stoppt, ist in Sekunden bis
* wenigen Minuten fertig; was danach noch läuft, tut etwas.
*/
public int $shutdownWaitSeconds = 600;
/**
* Wie lange auf den vzdump gewartet wird.
*
* Eine Stunde. Eine Kundenplatte von einigen hundert Gigabyte mit zstd
* gepackt liegt darunter; was länger braucht, hat ein Problem, das ein
* Betreiber ansehen soll. Öffentlich, damit ein Aufrufer (und eine Prüfung)
* die Grenze setzen kann, ohne die Uhr anzuhalten.
*/
public int $backupWaitSeconds = 3600;
/** Abstand zwischen zwei Nachfragen nach einem laufenden Auftrag. */
public int $pollSeconds = 15;
public function __construct(private ProxmoxClient $pve) {}
/**
* Ist diese Instanz wirklich fällig?
*
* Die Regel steht hier und nicht in der Abfrage des Zeitplans, damit die
* Auswahl und der Abbau dieselbe Frage stellen zwei Fassungen von „ist
* es soweit" sind der Weg, auf dem die eine irgendwann einen Tag früher
* antwortet als die andere.
*
* `status === 'ended'` und nicht `cancellation_scheduled`: erst
* `EndInstanceService` setzt diesen Zustand, und er setzt ihn genau dann,
* wenn die Adresse schon weg ist. Eine Instanz ohne `service_ends_at` ist
* ein kaputter und kein abgelaufener Datensatz ein geratenes Enddatum
* wäre genau der Zeitpunkt, den diese Frist schützen soll.
*/
public function isDue(Instance $instance, ?Carbon $at = null): bool
{
return $instance->status === 'ended'
&& $instance->torn_down_at === null
&& $instance->service_ends_at !== null
&& $instance->service_ends_at->lessThan(($at ?? now())->copy()->subDays(self::GRACE_DAYS));
}
/**
* Archivieren und abbauen, oder mit einem Grund stehen bleiben.
*
* @return bool ob diese Instanz jetzt abgebaut ist (auch dann, wenn ein
* früherer Lauf sie abgebaut hat)
*/
public function __invoke(Instance $instance): bool
{
// Wiederholbarkeit vor allem anderen: ein zweiter Lauf nach einem
// Absturz darf nicht versuchen, eine Maschine zu löschen, die es nicht
// mehr gibt.
if ($instance->torn_down_at !== null) {
return true;
}
if (! $this->isDue($instance)) {
return false;
}
// Zwei Läufe über dieselbe Instanz — der Zeitplan und ein Betreiber von
// Hand — würden zweimal sichern und zweimal löschen; der zweite fände
// dabei eine Maschine vor, die der erste gerade wegnimmt. Niemand
// wartet auf die Sperre: wer sie nicht bekommt, hat nichts
// hinzuzufügen, weil der Lauf, der sie hält, dasselbe tut. Die Frist
// deckt den ganzen Dump ab, sonst liefe sie mitten im Sichern aus.
$lock = Cache::lock('instance-teardown:'.$instance->uuid, $this->backupWaitSeconds + 600);
if (! $lock->get()) {
return false;
}
try {
return $this->tearDown($instance);
} catch (Throwable $e) {
// Keine Ausnahme entkommt: dieser Ablauf läuft im Zeitplan über
// viele Instanzen, und ein Wurf hier nähme die übrigen mit.
$this->recordFailure($instance, $e);
return false;
} finally {
$lock->release();
}
}
/**
* Der eigentliche Ablauf. Jeder Riegel wirft, der Aufrufer oben fängt und
* schreibt den Grund an den Datensatz ein Wurf ist hier Ablaufsteuerung
* und verlässt die Aktion nie.
*/
private function tearDown(Instance $instance): bool
{
$host = $instance->host;
$vmid = $instance->vmid;
if ($host === null || $vmid === null) {
throw new RuntimeException(
'Zu dieser Instanz gehört keine erreichbare Maschine mehr (Host oder VMID fehlt). '
.'Es wird nichts gesichert und nichts gelöscht.'
);
}
$node = (string) ($host->node ?? 'pve');
$pve = $this->pve->forHost($host);
$vmid = (int) $vmid;
// Der Weg zurück aus einem Absturz zwischen Löschen und Buchen: die
// Maschine ist fort, das Archiv steht aber schon am Datensatz. Ohne
// diesen Zweig versuchte jeder weitere Lauf, einen Geist
// herunterzufahren, und die Instanz bliebe für immer im Fehler stehen.
// Bewusst an `archive_volid` gebunden und nicht an `vmExists()` allein:
// die Frage beantwortet auch ein hakendes Proxmox mit „nein", und ohne
// ein verbrieftes Archiv wäre das „schon weg" eine Maschine, die
// niemand mehr anfasst.
if ($instance->archive_volid !== null && ! $pve->vmExists($node, $vmid)) {
$instance->update(['torn_down_at' => now(), 'teardown_error' => null]);
Log::warning('Abbau: die Maschine war schon fort, das Archiv stand bereits fest.', [
'instance' => $instance->uuid,
'vmid' => $vmid,
'archive_volid' => $instance->archive_volid,
]);
return true;
}
// Vor dem Herunterfahren: eine Instanz, deren Archiv nirgends hin
// kann, wird gar nicht erst angefasst.
$storage = $this->archiveStorage($pve, $node);
$shutdownUpid = $pve->shutdownVm($node, $vmid, $this->shutdownWaitSeconds);
$this->awaitTask($pve, $node, $shutdownUpid, $this->shutdownWaitSeconds, 'Das Herunterfahren');
// Die Auftragskennung ist keine Zusage. Ein Gast, der ACPI ignoriert,
// lässt den Auftrag auslaufen und läuft weiter — gesichert wird eine
// laufende Datenbank hier nicht, und gelöscht schon gar nicht.
if (($pve->vmStatus($node, $vmid)['status'] ?? '') === 'running') {
throw new RuntimeException(
'Der Gast läuft nach der Bitte zum Herunterfahren weiter. Es wird nichts gesichert '
.'und nichts gelöscht; die Maschine wird bewusst nicht hart ausgeschaltet.'
);
}
// Vor dem Dump abgelesen, nicht danach: die Zeit ist die Grenze, an der
// sich später „mein Archiv" von „das von gestern Nacht" unterscheidet.
$begonnen = now();
$backupUpid = $pve->backupNow($node, $vmid, $storage);
$this->awaitTask($pve, $node, $backupUpid, $this->backupWaitSeconds, 'Die Sicherung');
$volid = $this->verifiedArchive($pve, $node, $vmid, $storage, $begonnen);
// Das Archiv ist eine Tatsache, sobald es daliegt — festgehalten, bevor
// gelöscht wird.
$instance->update(['archive_volid' => $volid, 'archived_at' => now()]);
// Zwischen Sichern und Löschen liegen Minuten. Was in dieser Zeit an der
// Instanz passiert ist, entscheidet: eine wiederbelebte Instanz
// (`status` nicht mehr `ended`) ist keine, die gelöscht werden darf.
// Der Sperre ist dabei nicht zu trauen — sie hält nur andere LÄUFE ab,
// nicht einen Menschen in der Konsole.
$frisch = $instance->fresh();
if ($frisch === null || $frisch->torn_down_at !== null || $frisch->status !== 'ended') {
throw new RuntimeException(
'Die Instanz hat sich während der Sicherung geändert (Zustand "'
.($frisch?->status ?? 'gelöscht').'"). Das Archiv liegt und ist vermerkt, '
.'gelöscht wird die Maschine nicht.'
);
}
$pve->deleteVm($node, $vmid);
$instance->update(['torn_down_at' => now(), 'teardown_error' => null]);
Log::info('Gekündigte Instanz archiviert und abgebaut.', [
'instance' => $instance->uuid,
'vmid' => $vmid,
'node' => $node,
'archive_volid' => $volid,
'service_ended_at' => $instance->service_ends_at?->toIso8601String(),
]);
return true;
}
/**
* Welche Ablage das Archiv trägt als Name aus der Einstellung und als
* geprüfte Tatsache auf dem Knoten.
*/
private function archiveStorage(ProxmoxClient $pve, string $node): string
{
$name = (string) config('provisioning.proxmox.archive_storage', 'local');
$vorhanden = collect($pve->nodeStorage($node));
$treffer = $vorhanden->firstWhere('storage', $name);
if ($treffer === null) {
throw new RuntimeException(
'Die Ablage "'.$name.'" gibt es auf dem Knoten '.$node.' nicht (vorhanden: '
.($vorhanden->pluck('storage')->filter()->implode(', ') ?: 'keine')
.'). Ohne Ziel für das Archiv wird nichts gelöscht.'
);
}
// `content` zählt auf, was eine Ablage überhaupt aufnehmen darf. Eine
// Ablage ohne `backup` nimmt den Dump nicht an — das fiele sonst erst
// beim vzdump auf, mit einer heruntergefahrenen Kundenmaschine daneben.
if (! in_array('backup', explode(',', (string) ($treffer['content'] ?? '')), true)) {
throw new RuntimeException(
'Die Ablage "'.$name.'" auf dem Knoten '.$node.' nimmt keine Sicherungen auf (content: "'
.((string) ($treffer['content'] ?? '')).'"). Es wird nichts gelöscht.'
);
}
return $name;
}
/**
* Auf einen Proxmox-Auftrag warten, bis er wirklich fertig ist.
*
* Sowohl `shutdownVm()` als auch `backupNow()` liefern nur eine Kennung; die
* Arbeit läuft danach. Ein Aufrufer, der sofort weiterliest, sieht den
* Zustand von vorher beim Herunterfahren eine laufende Maschine, beim
* Sichern eine Ablage ohne das Archiv, das gerade geschrieben wird.
*/
private function awaitTask(ProxmoxClient $pve, string $node, string $upid, int $waitSeconds, string $was): void
{
$frist = now()->addSeconds($waitSeconds);
while (true) {
$status = $pve->taskStatus($node, $upid);
if (($status['status'] ?? '') !== 'running') {
$ende = (string) ($status['exitstatus'] ?? 'OK');
// Proxmox beendet einen vzdump auch dann mit "WARNINGS: n", wenn
// das Archiv sauber geschrieben wurde (eine übersprungene Platte,
// ein Hinweis aus dem Gast). Das als Fehlschlag zu lesen liesse
// den Abbau an einer Meldung hängen, die kein Archiv verhindert —
// und der Beweis, auf den es ankommt, ist ohnehin die Nachschau
// auf der Ablage, nicht diese Zeichenkette.
if ($ende !== 'OK' && ! str_starts_with($ende, 'WARNINGS')) {
throw new RuntimeException($was.' endete mit "'.$ende.'". Es wird nichts gelöscht.');
}
if ($ende !== 'OK') {
Log::warning('Abbau: Proxmox-Auftrag mit Hinweisen beendet.', [
'node' => $node,
'upid' => $upid,
'exitstatus' => $ende,
]);
}
return;
}
if (now()->greaterThanOrEqualTo($frist)) {
throw new RuntimeException(
$was.' lief nach '.$waitSeconds.' Sekunden noch. Es wird nichts gelöscht; '
.'der nächste Lauf sieht erneut nach.'
);
}
Sleep::for($this->pollSeconds)->seconds();
}
}
/**
* Nachsehen, was wirklich auf der Ablage liegt der Riegel, an dem alles
* hängt.
*
* @return string die Kennung des Archivs (volid)
*/
private function verifiedArchive(ProxmoxClient $pve, string $node, int $vmid, string $storage, Carbon $begonnen): string
{
$eintraege = $pve->backupsFor($node, $vmid, $storage);
if ($eintraege === []) {
throw new RuntimeException(
'Nach der Sicherung liegt auf der Ablage "'.$storage.'" kein Archiv für VM '.$vmid.'. '
.'Die Maschine bleibt stehen und wird nicht gelöscht.'
);
}
/** @var array<string, mixed> $juengstes */
$juengstes = collect($eintraege)->sortBy(fn (array $e) => (int) ($e['ctime'] ?? 0))->last();
$ctime = $juengstes['ctime'] ?? null;
if ($ctime === null) {
// Eine echte Ablage nennt zu jedem Eintrag ein `ctime`; fehlt es,
// ist das Alter des Archivs schlicht nicht feststellbar. Dann wird
// es angenommen statt abgelehnt — es LIEGT ein Archiv dieser VM da,
// und der Fall, den diese Aktion zu verhindern hat, ist das Löschen
// ohne jedes Archiv. Laut genug, dass es auffällt, wenn es je öfter
// vorkommt.
Log::warning('Abbau: das Archiv nennt keinen Zeitpunkt, sein Alter ist ungeprüft.', [
'vmid' => $vmid,
'storage' => $storage,
]);
} elseif ((int) $ctime < $begonnen->getTimestamp() - self::CLOCK_SKEW_SECONDS) {
// Der Fall, den die reine Ja/Nein-Frage „liegt da etwas?" durchlässt:
// die nächtliche Sicherung von gestern. Gegen sie zu löschen hiesse,
// ein Archiv aufzubewahren, dem der letzte Tag fehlt — und niemand
// würde es je merken.
throw new RuntimeException(
'Das jüngste Archiv für VM '.$vmid.' auf "'.$storage.'" stammt vom '
.Carbon::createFromTimestamp((int) $ctime)->toIso8601String()
.' und damit von vor diesem Lauf. Die Sicherung dieses Laufs ist nicht auffindbar; '
.'es wird nichts gelöscht.'
);
}
$volid = (string) ($juengstes['volid'] ?? '');
if ($volid === '') {
throw new RuntimeException(
'Das Archiv für VM '.$vmid.' auf "'.$storage.'" hat keine Kennung (volid). '
.'Ein Archiv, dessen Ort nicht notiert werden kann, ist in zwölf Monaten keins — '
.'es wird nichts gelöscht.'
);
}
return $volid;
}
/** Der Grund gehört an den Datensatz, wo ein Betreiber ihn ohne Logdurchsicht findet. */
private function recordFailure(Instance $instance, Throwable $e): void
{
$instance->update(['teardown_error' => $e->getMessage()]);
Log::error('Abbau einer gekündigten Instanz gestoppt.', [
'instance' => $instance->uuid,
'vmid' => $instance->vmid,
'reason' => $e->getMessage(),
]);
}
}

View File

@ -150,6 +150,18 @@ class FakeProxmoxClient implements ProxmoxClient
*/
public bool $shutdownIgnored = false;
/**
* Ein Herunterfahren, das gar nicht erst angenommen wird der Knoten
* antwortet nicht, das Token darf es nicht, die VMID gibt es dort nicht
* mehr. Der echte Client ruft auf jeder Antwort `->throw()`, das ist also
* kein erfundener Fall.
*
* Nicht dasselbe wie $shutdownIgnored: dort kommt die Bitte an und der Gast
* befolgt sie nur nicht. Beide Fälle nebeneinander, weil ein Aufrufer, der
* nur den einen kennt, den anderen für unmöglich hält.
*/
public bool $shutdownThrows = false;
public function startVm(string $node, int $vmid): string
{
$this->runningVmids[] = $vmid;
@ -162,8 +174,14 @@ class FakeProxmoxClient implements ProxmoxClient
public function shutdownVm(string $node, int $vmid, int $timeoutSeconds): string
{
// Der Versuch wird auch dann vermerkt, wenn er scheitert: gefragt wurde,
// und eine Prüfung soll das sehen können.
$this->shutdownCalls[] = ['vmid' => $vmid, 'timeout' => $timeoutSeconds];
if ($this->shutdownThrows) {
throw new \RuntimeException('Proxmox nahm das Herunterfahren nicht an (Attrappe).');
}
if (! $this->shutdownIgnored) {
$this->runningVmids = array_values(array_diff($this->runningVmids, [$vmid]));
unset($this->bootedConfig[$vmid]);

View File

@ -585,5 +585,24 @@ return [
'role_privs' => 'VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.PowerMgmt,VM.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit,Sys.Modify',
'user' => 'automation@pve',
'token_name' => 'clupilot',
/*
| Die Ablage, auf die der Abbau einer gekündigten Instanz ihr Archiv
| schreibt (App\Actions\ArchiveAndTearDown).
|
| `local` als Vorgabe, weil dort schon die nächtlichen Sicherungen
| landen HttpProxmoxClient::createBackupJob() hat den Namen fest
| verdrahtet, und ein Archiv woanders hinzuschreiben als die Sicherungen
| wäre ein zweiter Ort, an dem in zwölf Monaten jemand suchen muss.
|
| Einstellbar und nicht fest, weil es eine Annahme über die Einrichtung
| JEDES Hosts ist: eine Flotte, deren Sicherungen auf einer PBS-Ablage
| oder einem NFS-Ziel liegen, hat diesen Namen nicht, und eine feste
| Zeichenkette fiele erst beim ersten Abbau auf also genau dann, wenn
| er gebraucht wird. Der Name wird zusätzlich gegen die Ablagen des
| Knotens geprüft; fehlt sie oder nimmt sie keine Sicherungen auf, bricht
| der Abbau ab und löscht nichts.
*/
'archive_storage' => env('CLUPILOT_ARCHIVE_STORAGE', 'local'),
],
];

View File

@ -0,0 +1,391 @@
<?php // tests/Feature/Cancellation/ArchiveAndTearDownTest.php
use App\Actions\ArchiveAndTearDown;
use App\Models\Host;
use App\Models\Instance;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Carbon;
/**
* Eine Instanz im Zustand NACH dem Laufzeitende, so wie ihn
* EndInstanceService hinterlässt (status 'ended', Adresse weg), und mit der
* Frist von vierzehn Tagen längst um nach dem Muster von
* gekuendigteInstanz() aus DashboardCountdownTest.
*
* Anders als dort mit Host und VMID, denn hier geht es um die Maschine selbst:
* ohne beides gibt es nichts abzubauen.
*/
function abgelaufeneInstanz(int $vmid, ?Carbon $endeteVor = null): Instance
{
$host = Host::factory()->active()->create(['node' => 'pve']);
return Instance::factory()->create([
'host_id' => $host->id,
'vmid' => $vmid,
'status' => 'ended',
'service_ends_at' => $endeteVor ?? now()->subDays(20),
]);
}
it('faehrt herunter, archiviert, prueft nach, und loescht erst dann', function () {
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'size' => 4096]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue();
// Die Reihenfolge IST die Zusicherung: Herunterfahren vor dem Sichern,
// Sichern vor dem Loeschen.
expect($pve->shutdownCalls)->toHaveCount(1)
->and($pve->backupCalls)->toHaveCount(1)
->and($pve->deletedVmids)->toBe([201]);
expect($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst')
->and($instance->fresh()->torn_down_at)->not->toBeNull();
});
it('LOESCHT NICHT, wenn nach dem Sichern kein Archiv daliegt', function () {
// Der Fall, der alles traegt. Ein vzdump kann mit einer Auftragskennung
// enden und trotzdem nichts hinterlassen — volle Ablage, abgebrochener
// Lauf, ein Fehler im Gast. Wer sich auf die Kennung verlaesst statt
// nachzusehen, loescht eine Maschine, deren Archiv es nicht gibt.
$pve = new FakeProxmoxClient; // backups bleibt leer
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('loescht auch dann nicht, wenn das Herunterfahren scheitert', function () {
$pve = new FakeProxmoxClient;
$pve->shutdownThrows = true;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('faellt nicht ueber eine Instanz, die schon abgebaut ist', function () {
// Wiederholbarkeit: ein zweiter Lauf nach einem Absturz darf nicht
// versuchen, eine Maschine zu loeschen, die es nicht mehr gibt.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update(['torn_down_at' => now()->subDay()]);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->deletedVmids)->toBe([]);
});
it('ruehrt eine Instanz nicht an, deren Frist noch nicht um ist', function () {
// Die vierzehn Tage sind der Sinn der Sache. Eine Maschine, die gestern
// ablief, ist die Rueckfahrkarte eines Menschen, der es sich vielleicht
// gerade anders ueberlegt.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(3));
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('rührt eine Instanz nicht an, solange der vierzehnte Tag noch läuft', function () {
// Der Rand der Frist, fünf Minuten davor. Er gehört noch dem Kunden: eine
// Frist, die einen Tag zu früh zuschlägt, nimmt jedem Menschen einen ganzen
// Tag seiner Rückfahrkarte weg — und niemandem fiele es auf.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(14)->addMinutes(5));
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('baut ab, sobald der vierzehnte Tag um ist', function () {
// Die andere Seite desselben Randes, fünf Minuten danach. Beide zusammen
// halten die Frist fest: verschöbe sie jemand um einen Tag — in welche
// Richtung auch immer —, wird eine der beiden Prüfungen rot.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(14)->subMinutes(5));
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->deletedVmids)->toBe([201]);
});
it('rührt eine Instanz nicht an, deren Dienst gar nicht beendet ist', function () {
// Eine laufende Instanz mit einem alten service_ends_at ist kein Abbaufall:
// solange EndInstanceService sie nicht auf 'ended' gesetzt hat, ist die
// Adresse noch da und jemand arbeitet damit. Nur der eine Zustand zählt,
// nicht das Datum allein.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update(['status' => 'active']);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('fährt gar nichts herunter, wenn es die eingestellte Ablage auf dem Knoten nicht gibt', function () {
// Die Ablage ist eine Annahme über die Einrichtung der Hosts, kein Wissen.
// Ein Name, den es dort nicht gibt, muss laut scheitern — und zwar bevor
// eine Kundenmaschine dafür heruntergefahren wurde.
$pve = new FakeProxmoxClient;
$pve->storage = [['storage' => 'local-lvm', 'type' => 'lvmthin', 'content' => 'images,rootdir']];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->toContain('local');
});
it('fährt gar nichts herunter, wenn die Ablage keine Sicherungen aufnimmt', function () {
// Es gibt sie, sie heißt richtig — und sie darf keine Sicherungen tragen.
// Ohne diese Prüfung fiele das erst beim vzdump auf, mit einer bereits
// heruntergefahrenen Kundenmaschine daneben.
$pve = new FakeProxmoxClient;
$pve->storage = [['storage' => 'local', 'type' => 'dir', 'content' => 'iso,vztmpl']];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('sichert nicht, wenn der Gast nach dem Herunterfahren weiterläuft', function () {
// shutdownVm() liefert eine Auftragskennung, keine Zusage. Ein Gast, der
// ACPI ignoriert, läuft weiter — und eine laufende Datenbank zu sichern
// und dann zu löschen hieße, ein Archiv mit halb geschriebenen Tabellen
// aufzubewahren.
$pve = new FakeProxmoxClient;
$pve->shutdownIgnored = true;
$pve->runningVmids = [201];
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toHaveCount(1)
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('löscht nicht gegen ein Archiv von gestern', function () {
// Der zweite Weg, an dem die reine Ja/Nein-Frage „liegt da etwas?"
// vorbeigeht: die nächtliche Sicherung liegt auf derselben Ablage. Gegen
// sie zu löschen hieße, ein Archiv aufzubewahren, dem der letzte Tag
// fehlt — und es fiele erst auf, wenn es zu spät ist.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [[
'volid' => 'local:backup/vzdump-qemu-201-gestern.vma.zst',
'ctime' => now()->subDay()->timestamp,
]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->backupCalls)->toHaveCount(1)
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->archive_volid)->toBeNull();
});
it('merkt sich das frische Archiv, nicht das danebenliegende alte', function () {
// Der Normalfall auf einer echten Ablage: die nächtliche Sicherung von
// gestern liegt neben dem Dump von gerade eben. Vermerkt werden muss der
// frische — ein Kunde, der in zwölf Monaten nach seinen Daten fragt, soll
// nicht den Stand von vorgestern bekommen.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [
['volid' => 'local:backup/vzdump-qemu-201-gestern.vma.zst', 'ctime' => now()->subDay()->timestamp],
['volid' => 'local:backup/vzdump-qemu-201-jetzt.vma.zst', 'ctime' => now()->timestamp],
];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->deletedVmids)->toBe([201])
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201-jetzt.vma.zst')
->and($instance->fresh()->archived_at)->not->toBeNull();
});
it('löscht nicht, wenn das Archiv keine Kennung trägt', function () {
// Ein Archiv, dessen Ort nicht notiert werden kann, ist in zwölf Monaten
// keins: niemand findet es wieder, und niemand räumt es je auf.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [['size' => 4096, 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('löscht nicht, wenn der Sicherungsauftrag mit einem Fehler endet', function () {
// Die volle Ablage, wie Proxmox sie meldet. Dass daneben ein älteres
// Archiv derselben VM liegt, macht den Fehlschlag nicht zum Erfolg.
$pve = new FakeProxmoxClient;
$pve->taskExitStatus = 'no space left on device';
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->toContain('no space left on device');
});
it('lässt sich von Hinweisen im Sicherungsauftrag nicht aufhalten', function () {
// Proxmox beendet einen vzdump auch mit „WARNINGS: n", wenn das Archiv
// sauber geschrieben wurde. Das als Fehlschlag zu lesen hieße, den Abbau an
// einer Meldung hängen zu lassen, die kein Archiv verhindert — der Beweis
// ist die Nachschau auf der Ablage, nicht diese Zeichenkette.
$pve = new FakeProxmoxClient;
$pve->taskExitStatus = 'WARNINGS: 1';
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->deletedVmids)->toBe([201]);
});
it('löscht nicht, solange der Sicherungsauftrag noch läuft', function () {
// Ein vzdump liefert seine Kennung sofort und arbeitet danach minutenlang
// weiter. Wer nicht abwartet, fragt die Ablage, bevor das Archiv darauf
// liegt. Läuft er über die Frist hinaus, bleibt die Maschine stehen — der
// nächste Lauf sieht erneut nach.
$pve = new class extends FakeProxmoxClient
{
public function taskStatus(string $node, string $upid): array
{
// Nur der vzdump hängt. Das Herunterfahren davor war fertig —
// sonst käme diese Prüfung nie bis zum Sichern.
return str_contains($upid, 'vzdump')
? ['status' => 'running']
: parent::taskStatus($node, $upid);
}
};
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
// Ohne Wartezeit, damit die Prüfung nicht wirklich eine Stunde wartet.
$abbau = app(ArchiveAndTearDown::class);
$abbau->backupWaitSeconds = 0;
expect($abbau($instance))->toBeFalse()
->and($pve->backupCalls)->toHaveCount(1)
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('löscht nicht, wenn die Instanz zwischen Sichern und Löschen wiederbelebt wird', function () {
// Zwischen dem Dump und dem Löschen liegen Minuten. Wird die Instanz in
// dieser Zeit von jemandem angefasst, ist sie keine, die gelöscht werden
// darf — die gemerkte Kopie im Speicher weiß davon nichts, also wird der
// Datensatz unmittelbar davor neu gelesen.
$pve = new class extends FakeProxmoxClient
{
public ?Instance $wiederbelebt = null;
public function backupsFor(string $node, int $vmid, string $storage): array
{
$this->wiederbelebt?->update(['status' => 'active']);
return parent::backupsFor($node, $vmid, $storage);
}
};
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
// Eine ZWEITE Kopie desselben Datensatzes, so wie sie ein anderer Vorgang
// hätte — die Aktion darf ihrer eigenen nicht glauben.
$pve->wiederbelebt = Instance::find($instance->id);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
// Das Archiv existiert wirklich und bleibt vermerkt: der Dump ist
// gelaufen, und ihn zu vergessen hieße, ihn beim nächsten Lauf noch
// einmal zu bezahlen.
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst')
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('behält das Archiv am Datensatz, wenn das Löschen scheitert', function () {
// Der Zwischenstand, für den es zwei Zeitstempel gibt: gesichert ja,
// gelöscht nein. Ginge archive_volid dabei verloren, liefe der nächste Lauf
// gegen dieselbe Maschine noch einen Dump — und der erste läge unauffindbar
// auf der Ablage.
$pve = new class extends FakeProxmoxClient
{
public function deleteVm(string $node, int $vmid): void
{
throw new RuntimeException('Proxmox: VM is locked (backup)');
}
};
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst')
->and($instance->fresh()->archived_at)->not->toBeNull()
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->toContain('locked');
});
it('trägt den Abbau nach, wenn die Maschine schon fort ist und das Archiv feststeht', function () {
// Der Absturz im Millisekundenfenster zwischen dem Löschen und dem
// Vermerk. Ohne diesen Weg zurück versuchte jeder weitere Lauf, einen
// Geist herunterzufahren, und die Instanz bliebe für immer im Fehler
// stehen. Es wird dabei nichts gelöscht — es gibt nichts mehr.
$pve = new FakeProxmoxClient; // kennt vmid 201 nicht, vmExists() ist falsch
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update([
'archive_volid' => 'local:backup/vzdump-qemu-201.vma.zst',
'archived_at' => now()->subMinutes(5),
]);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->not->toBeNull();
});
it('rührt eine Instanz ohne Maschine nicht an', function () {
// Ein Datensatz ohne VMID hat nichts, was sich sichern oder löschen ließe.
// Er wird nicht stillschweigend als abgebaut gebucht: das wäre ein Haken an
// einer Maschine, die vielleicht doch irgendwo läuft.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update(['vmid' => null]);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->not->toBeNull();
});