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 $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(), ]); } }