169 lines
6.9 KiB
PHP
169 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Actions\ArchiveAndTearDown;
|
|
use App\Models\Instance;
|
|
use App\Provisioning\Jobs\TearDownInstance;
|
|
use Illuminate\Console\Command;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Die Maschinen abbauen, deren Laufzeit vor mehr als vierzehn Tagen endete.
|
|
*
|
|
* Der Schritt nach `EndDueServices`. Der nimmt einer gekündigten Instanz am
|
|
* Laufzeitende die Adresse weg und lässt die virtuelle Maschine ausdrücklich
|
|
* stehen; danach kam nichts mehr. Die Maschine lief für immer weiter und
|
|
* belegte einen Platz auf dem Host, den niemand mehr verkaufen kann. Dieser
|
|
* Befehl ist der Termin, der das beendet.
|
|
*
|
|
* ## Was hier NICHT passiert
|
|
*
|
|
* Der Abbau selbst. Dieser Befehl fasst keine Maschine an — er wählt aus und
|
|
* reiht ein. Der Grund steht im Kopf von `TearDownInstance` und ist keine
|
|
* Stilfrage: der `scheduler`-Container, in dem dieser Befehl läuft, hängt nicht
|
|
* im WireGuard-Tunnel und erreicht die Management-Adresse eines Hosts gar
|
|
* nicht. Was von hier aus mit Proxmox spräche, liefe in eine
|
|
* Zeitüberschreitung, jedes Mal.
|
|
*
|
|
* ## Warum es eine Obergrenze gibt
|
|
*
|
|
* `provisioning` ist EINE Warteschlange mit EINEM Arbeiter, und dieselbe, über
|
|
* die eine bezahlte Bestellung ihre Maschine bekommt. Ein Abbau darf bis zu
|
|
* fünfunddreissig Minuten belegen. Ohne Grenze reihte der erste Lauf nach dem
|
|
* Ausrollen den ganzen Rückstand auf einmal ein — und ein Kunde, der heute
|
|
* bezahlt, wartete einen Tag auf seine Nextcloud, weil vor ihm vierzig
|
|
* Kündigungen stehen. Fünf am Tag räumen jeden realistischen Rückstand in
|
|
* anderthalb Wochen ab und lassen die Warteschlange dazwischen frei.
|
|
*
|
|
* ## Die Reihenfolge, und warum sie nicht die naheliegende ist
|
|
*
|
|
* „Am längsten fällig zuerst" allein ist eine Aushungerungsfalle: eine Instanz,
|
|
* deren Abbau jede Nacht an derselben Sache scheitert, ist am nächsten Tag
|
|
* wieder die am längsten fällige. Fünf davon, und keine sechste käme je an die
|
|
* Reihe. Deshalb kommen zuerst die, an denen noch kein Grund klebt, und erst
|
|
* darunter wird nach Fälligkeit sortiert.
|
|
*/
|
|
class TearDownEndedInstances extends Command
|
|
{
|
|
protected $signature = 'clupilot:tear-down-ended-instances
|
|
{--dry-run : nennen, was abgebaut würde, und nichts anfassen}
|
|
{--limit=5 : wie viele Instanzen ein Lauf höchstens einreiht}';
|
|
|
|
protected $description = 'Gekündigte Maschinen vierzehn Tage nach dem Laufzeitende archivieren und abbauen';
|
|
|
|
public function handle(ArchiveAndTearDown $abbau): int
|
|
{
|
|
$probelauf = (bool) $this->option('dry-run');
|
|
$obergrenze = max(1, (int) $this->option('limit'));
|
|
|
|
$eingereiht = 0;
|
|
$uebersprungen = 0;
|
|
$warten = 0;
|
|
|
|
// In der Abfrage eingegrenzt für die Datenbank, entschieden von
|
|
// isDue() für die Regel — dieselbe Arbeitsteilung wie beim Nachbarn
|
|
// EndDueServices. Die Abfrage ist ein Index, keine zweite Meinung: es
|
|
// gibt genau eine Stelle, die sagt, wann eine Instanz fällig ist, und
|
|
// die steht in der Aktion.
|
|
$kandidaten = Instance::query()
|
|
->with('host')
|
|
->where('status', 'ended')
|
|
->whereNull('torn_down_at')
|
|
->whereNotNull('service_ends_at')
|
|
->where('service_ends_at', '<', now()->subDays(ArchiveAndTearDown::GRACE_DAYS))
|
|
// Siehe Kopf: noch nie gescheiterte zuerst, damit ein Dauerfall die
|
|
// Obergrenze nicht Nacht für Nacht allein belegt.
|
|
->orderByRaw('(teardown_error is null) desc')
|
|
->orderBy('service_ends_at')
|
|
->cursor();
|
|
|
|
foreach ($kandidaten as $instance) {
|
|
if (! $abbau->isDue($instance)) {
|
|
continue;
|
|
}
|
|
|
|
if ($instance->host === null || $instance->vmid === null) {
|
|
$uebersprungen++;
|
|
$this->uebersprungen($instance, $probelauf);
|
|
|
|
continue;
|
|
}
|
|
|
|
// Erst hier, nicht in der Abfrage: eine übersprungene Instanz kostet
|
|
// die Warteschlange nichts und soll deshalb auch keinen der fünf
|
|
// Plätze verbrauchen.
|
|
if ($eingereiht >= $obergrenze) {
|
|
$warten++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$this->line(($probelauf ? '[Probelauf] ' : '')
|
|
."{$instance->subdomain}: Laufzeit endete am "
|
|
.$instance->service_ends_at->local()->isoFormat('LL')
|
|
.' — Abbau auf '.($instance->host->name ?? '?').' (VMID '.$instance->vmid.')');
|
|
|
|
if ($probelauf) {
|
|
$eingereiht++;
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
TearDownInstance::dispatch($instance->uuid);
|
|
} catch (Throwable $e) {
|
|
// Ein Redis, das gerade nicht antwortet, darf nicht die
|
|
// restlichen Instanzen dieses Laufes mitnehmen — dieselbe
|
|
// Regel, nach der die Aktion selbst nie wirft.
|
|
$uebersprungen++;
|
|
$this->error("{$instance->subdomain}: liess sich nicht einreihen — ".$e->getMessage());
|
|
|
|
continue;
|
|
}
|
|
|
|
$eingereiht++;
|
|
}
|
|
|
|
$this->info($probelauf
|
|
? "Probelauf: {$eingereiht} Instanz(en) würden abgebaut, {$uebersprungen} übersprungen, "
|
|
."{$warten} warten auf den nächsten Lauf. Nichts wurde geändert."
|
|
: "{$eingereiht} Instanz(en) zum Abbau eingereiht, {$uebersprungen} übersprungen, "
|
|
."{$warten} warten auf den nächsten Lauf.");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Eine Instanz, zu der keine erreichbare Maschine mehr gehört.
|
|
*
|
|
* Der Grund wird nicht nur gesagt, sondern vermerkt — ausser im Probelauf.
|
|
* Eine Zeile im Protokoll des Zeitplans liest niemand; `teardown_error` ist
|
|
* das Feld, das die Konsole über der Instanzliste anzeigt. Ohne den Vermerk
|
|
* bliebe diese Instanz für immer in der Auswahl stehen, würde jede Nacht
|
|
* erneut übersprungen, und niemand erführe davon.
|
|
*
|
|
* Wortgleich mit dem, was `ArchiveAndTearDown` in derselben Lage wirft: es
|
|
* ist derselbe Sachverhalt, und zwei Formulierungen dafür wären zwei
|
|
* Sachverhalte, sobald jemand nach dem Text sucht.
|
|
*/
|
|
private function uebersprungen(Instance $instance, bool $probelauf): void
|
|
{
|
|
$grund = 'Zu dieser Instanz gehört keine erreichbare Maschine mehr (Host oder VMID fehlt). '
|
|
.'Es wird nichts gesichert und nichts gelöscht.';
|
|
|
|
$this->warn(($probelauf ? '[Probelauf] ' : '')."{$instance->subdomain}: übersprungen — ".$grund);
|
|
|
|
if ($probelauf) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$instance->update(['teardown_error' => $grund]);
|
|
} catch (Throwable) {
|
|
// Der Vermerk liess sich nicht schreiben. Gesagt ist er trotzdem,
|
|
// und angefasst hat dieser Zweig ohnehin keine Maschine.
|
|
}
|
|
}
|
|
}
|