CluPilotCloud/app/Actions/ArchiveAndTearDown.php

523 lines
23 KiB
PHP

<?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;
}
$lock = null;
try {
// 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.
//
// Innerhalb des try, nicht davor: im Betrieb ist der Sperrspeicher
// Redis, und ein Redis, das gerade nicht da ist, wirft schon beim
// Anlegen der Sperre — vor dem try nähme dieser Wurf im Zeitplan
// alle übrigen Instanzen mit.
$lock = Cache::lock('instance-teardown:'.$instance->uuid, $this->lockSeconds());
if (! $lock->get()) {
return false;
}
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 {
try {
$lock?->release();
} catch (Throwable) {
// Dieselbe Verbindung wie oben und derselbe Weg nach draussen —
// diesmal sogar am Erfolgsfall vorbei. Eine Sperre, die sich
// nicht freigeben lässt, läuft von selbst ab; ein Wurf von hier
// machte aus einem fertigen Abbau einen gescheiterten Zeitplan.
}
}
}
/**
* 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.
*/
private function lockSeconds(): int
{
return $this->shutdownWaitSeconds + $this->backupWaitSeconds + 600;
}
/**
* 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;
// Zuerst die Ablage, und das aus zwei Gründen. Der offensichtliche: eine
// Instanz, deren Archiv nirgends hin kann, wird gar nicht erst
// angefasst. Der wichtigere: `nodeStorage()` ruft `->throw()` und ist
// damit das Lebenszeichen des Knotens, das der Nachtrags-Zweig
// darunter braucht.
$storage = $this->archiveStorage($pve, $node);
// 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.
//
// Zwei Bedingungen, weil `vmExists()` allein die Frage nicht beantwortet:
// es ist `->get(…)->successful()` OHNE `->throw()`, und Proxmox antwortet
// auf die Konfiguration einer nicht vorhandenen VM mit 500 — demselben
// Code, den ein hakender Knoten liefert (siehe `isTemplate()` im
// Client). „Gibt es nicht" und „ich konnte nicht fragen" sehen also
// gleich aus. Ein verbrieftes `archive_volid` schliesst die Maschine
// aus, die nie archiviert wurde; die Ablagenprüfung darüber schliesst
// den Knoten aus, der gerade gar nichts beantwortet. Ohne beides würde
// hier eine LAUFENDE Kundenmaschine als abgebaut verbucht und danach
// nie wieder angesehen — der Zustand entsteht im Regelbetrieb, sobald
// ein Lauf nach dem Sichern am Löschen scheitert.
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;
}
$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) {
// Ohne `ctime` ist das Alter eines Archivs nicht feststellbar. Ein
// EINZELNER solcher Eintrag wird 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.
//
// MEHRERE dagegen nicht. Auf `local` liegen neben dem frischen Dump
// die nächtlichen Sicherungen derselben VM; ohne Zeitpunkt lassen
// sie sich nicht ordnen, und vermerkt würde irgendeiner davon —
// womöglich der von vorgestern. Eine Reihe, die sich nicht sortieren
// lässt, ist keine Grundlage für ein Löschen.
if (count($eintraege) > 1) {
throw new RuntimeException(
'Auf "'.$storage.'" liegen '.count($eintraege).' Archive für VM '.$vmid
.', und keines nennt einen Zeitpunkt. Welches davon die Sicherung dieses Laufes '
.'ist, lässt sich nicht sagen; es wird nichts gelöscht.'
);
}
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.
*
* Beide Schritte einzeln abgesichert, und das ist kein Zierrat: diese
* Methode läuft IM `catch`. Ein Wurf von hier — eine Datenbank, die gerade
* nicht schreibt — ersetzte den gefangenen Fehlschlag und käme doch nach
* draussen, wo er im Zeitplan die übrigen Instanzen mitrisse.
*/
private function recordFailure(Instance $instance, Throwable $e): void
{
try {
$instance->update(['teardown_error' => $e->getMessage()]);
} catch (Throwable) {
// Der Grund liess sich nicht vermerken. Mehr als der Eintrag unten
// geht dann nicht — und gelöscht hat dieser Lauf ohnehin nichts.
}
try {
Log::error('Abbau einer gekündigten Instanz gestoppt.', [
'instance' => $instance->uuid,
'vmid' => $instance->vmid,
'reason' => $e->getMessage(),
]);
} catch (Throwable) {
// Auch das Protokoll ist nicht erreichbar. Dann schweigt dieser
// eine Lauf, statt den ganzen Zeitplan mitzunehmen.
}
}
}