578 lines
27 KiB
PHP
578 lines
27 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**, 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** — 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
|
|
* 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;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
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, 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 + $this->deleteWaitSeconds + 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;
|
|
}
|
|
|
|
// 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') {
|
|
$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.'
|
|
);
|
|
}
|
|
|
|
// 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]);
|
|
|
|
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.
|
|
*
|
|
* `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
|
|
{
|
|
$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.
|
|
}
|
|
}
|
|
}
|