CluPilotCloud/app/Actions/ArchiveAndTearDown.php

455 lines
20 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;
}
// 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(),
]);
}
}