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 $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. } } }