Kuendigung B1, Tasks 4+5: der Zeitplan-Griff und die Sichtbarkeit
Gekuendigte Kundenmaschinen liefen bisher fuer immer weiter. Task 3 hat den Abbau gebaut; hier kommen der Griff, der ihn faehrt, und der Ort, an dem man sieht, was passiert ist. Die Wartezeit-Frage, entschieden: ein Auftrag je Instanz auf der provisioning-Warteschlange. Das ist keine Abwaegung — nur queue-provisioning steht im Netz-Namensraum des vpn-hub, der scheduler-Container nicht. Ein Befehl, der selbst mit Proxmox spraeche, haette gar keine Route zu einem Host. Die Fristen stehen ausdruecklich ineinander: die Aktion bekommt 600+1200 = 1800 s, der Auftrag hat $timeout 2100 s, retry_after der Verbindung ist 2400 s. Nur die unterste Uhr hinterlaesst einen lesbaren Grund am Datensatz; die mittlere toetet den Arbeiterprozess stumm, die oberste startet einen ZWEITEN Abbau gegen eine Maschine mitten im vzdump. $tries=1, weil ein sofortiger zweiter Versuch am Herunterfahren einer gesperrten VM scheitern und den richtigen Grund ueberschreiben wuerde. Die Staffelung ist als Pruefung festgenagelt. Der Preis — zwanzig statt sechzig Minuten fuers Sichern — steht im Kopfkommentar ausgeschrieben. Dazu zwei Entscheidungen, nach denen niemand gefragt hat: eine Obergrenze je Lauf, weil ueber dieselbe serielle Warteschlange bezahlte Bestellungen laufen; und eine Reihenfolge, die einen Dauerfall die uebrigen nicht aushungern laesst. Zeitplan taeglich um 05:30 — der Abbau hat keinen Moment, auf den es ankommt, aber er darf nicht ins naechtliche vzdump-Fenster um 02:00 fallen. Sichtbarkeit: zwei Kaesten in der Konsole. „Abbau haengt" (rot, ganz oben) — eine Instanz mit gefuelltem teardown_error steht unbegrenzt und belegt weiter einen Platz. Und „Archiviert und abgebaut" (unter der Liste) mit archive_volid im Klartext. Beide sortieren absteigend und beide haben ein Ende: der Fehler raeumt sich beim naechsten erfolgreichen Lauf selbst ab, das Archiv faellt nach zwoelf Monaten heraus. Der Folgepunkt vom Export-Kasten also nicht noch einmal. Suite 2929 gruen. 25 neue Pruefungen, vier Mutationsproben rot gesehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/versandtakt
parent
51ec6ebed2
commit
df73e558e9
|
|
@ -0,0 +1,168 @@
|
|||
<?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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,26 @@ class Instances extends Component
|
|||
{
|
||||
use WithPagination;
|
||||
|
||||
/**
|
||||
* Wie lange ein Archiv im Abschnitt „Archiviert und abgebaut" steht.
|
||||
*
|
||||
* Zwölf Monate, weil das die Aufbewahrungsfrist ist, an deren Ende der Dump
|
||||
* selbst gelöscht wird (siehe die Wanderung von `archived_at` in der
|
||||
* Migration). Damit hat dieser Abschnitt ein Ende, das ihm jemand anderes
|
||||
* setzt: Was hier steht, ist genau das, was noch auf einer Ablage liegt und
|
||||
* sich zurückholen lässt. Ein Abschnitt, der jede Löschung seit Anbeginn
|
||||
* aufführt, ist nach dem zweiten Jahr eine Liste, die niemand mehr liest —
|
||||
* derselbe Fehler wie beim Kasten „Datenexport bestellt" darunter, der sich
|
||||
* bis heute nicht abräumen lässt.
|
||||
*
|
||||
* Der Lauf, der die Frist wirklich durchsetzt, ist noch nicht gebaut (er
|
||||
* gehört zu dem Zeitpunkt gebaut, an dem das erste Archiv alt genug wird).
|
||||
* Bis dahin blendet dieser Abschnitt aus, was ohnehin nicht mehr da sein
|
||||
* soll — und die Angabe steht weiter am Datensatz, für den Tag, an dem der
|
||||
* Aufräumlauf sie braucht.
|
||||
*/
|
||||
private const ARCHIVE_MONTHS = 12;
|
||||
|
||||
/**
|
||||
* Restart one instance, once ConfirmRestartInstance has said so.
|
||||
*
|
||||
|
|
@ -72,6 +92,79 @@ class Instances extends Component
|
|||
|
||||
return view('livewire.admin.instances', [
|
||||
'instances' => $instances,
|
||||
// Der wichtigste Kasten der Seite: Abbauten, die hängengeblieben
|
||||
// sind.
|
||||
//
|
||||
// `teardown_error` ist der Grund, den ArchiveAndTearDown an den
|
||||
// Datensatz schreibt, statt zu werfen. Ohne Leser wäre er dasselbe
|
||||
// gewesen wie `export_wish` vor Teil A — ein Zustand in einer
|
||||
// Spalte, den niemand zu Gesicht bekommt. Nur hätte es hier mehr
|
||||
// gekostet als eine unbeantwortete Frage: eine Instanz mit gefülltem
|
||||
// Grund steht UNBEGRENZT und belegt weiter einen Platz auf ihrem
|
||||
// Host. Sieht sie niemand, war der ganze Abbau umsonst.
|
||||
//
|
||||
// Ganz oben und in Rot, nicht als Plakette in der Zeile: die Tabelle
|
||||
// darunter ist nach id absteigend seitenweise geblättert, eine vor
|
||||
// Monaten gescheiterte Instanz sässe also auf Seite acht. Ein
|
||||
// Fehler, den man finden könnte, ist kein Fehler, der auffällt.
|
||||
//
|
||||
// Er räumt sich selbst ab, und das ist der Unterschied zum Kasten
|
||||
// darunter: der nächtliche Lauf versucht es erneut, und ein
|
||||
// erfolgreicher Abbau setzt `teardown_error` auf null und
|
||||
// `torn_down_at`. Die Zeile verschwindet dann, ohne dass jemand
|
||||
// Buch führt.
|
||||
//
|
||||
// Nach dem letzten Versuch absteigend: was heute Nacht gescheitert
|
||||
// ist, steht oben. Aufsteigend zu sortieren — wie es der Kasten
|
||||
// „Datenexport bestellt" tut — hiesse, dass der älteste Dauerfall
|
||||
// den Kopf der Liste für immer besetzt und die frischen Fehler nach
|
||||
// unten wandern.
|
||||
'stuck' => Instance::query()
|
||||
->with(['customer', 'host'])
|
||||
->whereNotNull('teardown_error')
|
||||
->whereNull('torn_down_at')
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->map(fn (Instance $i) => [
|
||||
'customer' => $i->customer?->name ?? '—',
|
||||
'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain,
|
||||
'machine' => ($i->host?->name ?? '—').' · '.($i->vmid ?? '—'),
|
||||
'reason' => $i->teardown_error,
|
||||
// `updated_at` ist der beste Zeitpunkt, den es gibt: der
|
||||
// Fehlschlag hat keine eigene Spalte, und an einer Instanz
|
||||
// in diesem Zustand schreibt nichts anderes mehr.
|
||||
// R19: die Wanduhr des Betreibers, nicht die Speicherzone.
|
||||
'attempted' => $i->updated_at?->local()->isoFormat('LLL') ?? '—',
|
||||
// Der Zwischenstand, den es wirklich gibt: gesichert, aber
|
||||
// nicht gelöscht. Wer ihn von Hand zu Ende bringt, braucht
|
||||
// genau diese Kennung — sonst sichert er ein zweites Mal.
|
||||
'archive' => $i->archive_volid,
|
||||
])
|
||||
->all(),
|
||||
// Was abgebaut wurde, und wo das Archiv liegt.
|
||||
//
|
||||
// Ohne diese Angabe ist eine Wiederherstellung eine Suche über
|
||||
// fremde Ablagen. `archive_volid` steht deshalb im Klartext da und
|
||||
// nicht hinter einem Hinweistext.
|
||||
//
|
||||
// Absteigend nach dem Zeitpunkt der Archivierung, und begrenzt auf
|
||||
// die Aufbewahrungsfrist — siehe ARCHIVE_MONTHS.
|
||||
'teardowns' => Instance::query()
|
||||
->with('customer')
|
||||
->whereNotNull('torn_down_at')
|
||||
->whereNotNull('archive_volid')
|
||||
->whereNotNull('archived_at')
|
||||
->where('archived_at', '>=', now()->subMonths(self::ARCHIVE_MONTHS))
|
||||
->orderByDesc('archived_at')
|
||||
->get()
|
||||
->map(fn (Instance $i) => [
|
||||
'customer' => $i->customer?->name ?? '—',
|
||||
'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain,
|
||||
'archive' => $i->archive_volid,
|
||||
// R19: die Wanduhr des Betreibers, nicht die Speicherzone.
|
||||
'archived_at' => $i->archived_at?->local()->isoFormat('LLL') ?? '—',
|
||||
])
|
||||
->all(),
|
||||
// Die bestellten Datenexporte, ueber der Liste.
|
||||
//
|
||||
// `export_wish` stand bis hierher in einer Spalte, die kein Mensch
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Actions\ArchiveAndTearDown;
|
||||
use App\Models\Instance;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Eine gekündigte Instanz archivieren und abbauen — dort, wo es überhaupt geht.
|
||||
*
|
||||
* ## Warum ein Auftrag und nicht der Befehl selbst
|
||||
*
|
||||
* Das ist keine Abwägung, sondern eine Tatsache über diese Installation: nur
|
||||
* `queue-provisioning` steigt in den Netz-Namensraum des `vpn-hub` ein
|
||||
* (`network_mode: "service:vpn-hub"` in der docker-compose.yml), und dort steht
|
||||
* wg0. Der `scheduler`-Container, der `schedule:work` fährt, hat diesen Eintrag
|
||||
* nicht. Ein `clupilot:tear-down-ended-instances`, der `ProxmoxClient` selbst
|
||||
* aufriefe, hätte also gar keine Route zur Management-Adresse eines Hosts —
|
||||
* jeder Abbau endete in einer Zeitüberschreitung, und zwar erst nach der vollen
|
||||
* HTTP-Frist. Dieselbe Regel, dieselbe Warteschlange und derselbe Grund wie bei
|
||||
* `CollectHostLoad` und `PingHosts` nebenan.
|
||||
*
|
||||
* Ein Auftrag **je Instanz** und nicht einer für alle: die Warteschlange wird
|
||||
* von genau einem Arbeiter seriell abgearbeitet, und ein Abbau, der die Frist
|
||||
* reisst, soll die übrigen weder aufhalten noch mitnehmen. Ausserdem ist der
|
||||
* einzelne Auftrag die Einheit, die ein Betreiber in `failed_jobs` wiederfindet.
|
||||
*
|
||||
* ## Die Fristen, und warum sie ineinander stehen müssen
|
||||
*
|
||||
* `ArchiveAndTearDown` wartet blockierend (`Sleep::for`), mit seinen Vorgaben
|
||||
* bis zu 600 + 3600 = 4200 Sekunden je Instanz. Auf einer Warteschlange gibt es
|
||||
* darüber zwei weitere Uhren, und wessen Uhr zuerst klingelt, entscheidet, was
|
||||
* hinterher dasteht:
|
||||
*
|
||||
* | Uhr | Wert | was passiert, wenn sie zuerst klingelt |
|
||||
* |----------------------------------|--------|----------------------------------------|
|
||||
* | die Aktion selbst | 1800 s | wirft, `teardown_error` wird geschrieben, die Konsole zeigt den Grund |
|
||||
* | `$timeout` dieses Auftrags | 2100 s | SIGALRM, der Arbeiterprozess stirbt — **kein** Grund am Datensatz |
|
||||
* | `retry_after` der Verbindung | 2400 s | der Auftrag gilt als verloren und wird **ein zweites Mal** angefasst |
|
||||
*
|
||||
* Deshalb bekommt die Aktion hier ausdrücklich kleinere Fristen mitgegeben, als
|
||||
* sie selbst vorgibt. Die unterste Uhr ist die einzige, die einen lesbaren
|
||||
* Grund hinterlässt; die mittlere hinterlässt einen toten Arbeiter und eine
|
||||
* Instanz, die aussieht wie nie angefasst; die oberste startet einen ZWEITEN
|
||||
* Abbau gegen eine Maschine, die gerade mitten im vzdump steckt — genau der
|
||||
* Fall, den die Prüfung von Aufgabe 3 benannt hat.
|
||||
*
|
||||
* Die Werte sind Vorgaben, keine Naturgesetze: `PROVISIONING_QUEUE_RETRY_AFTER`
|
||||
* lässt sich anheben. Wer das tut, hebt `$timeout` und `BACKUP_WAIT_SECONDS`
|
||||
* mit an — in dieser Reihenfolge und mit dem Abstand, den die Tabelle zeigt.
|
||||
* `TearDownEndedInstancesTest` nagelt die Staffelung fest, damit eine
|
||||
* verschobene Zahl nicht still die Reihenfolge dreht.
|
||||
*
|
||||
* ## Der Preis dieser Entscheidung, ausgesprochen
|
||||
*
|
||||
* Zwanzig Minuten fürs Sichern statt der Stunde, die die Aktion allein
|
||||
* zuliesse. Eine sehr grosse Kundenplatte kann darin nicht fertig werden. Dann
|
||||
* wirft die Aktion mit genau diesem Satz, der Grund steht am Datensatz und in
|
||||
* der Konsole, gelöscht wird nichts, und ein Mensch entscheidet — die Fristen
|
||||
* anheben oder von Hand archivieren. Das ist der Ausgang, den ein Abbau haben
|
||||
* soll: sichtbar stehenbleiben. Ein Auftrag, der stumm vom Arbeiter erschlagen
|
||||
* wird, während der vzdump auf dem Knoten weiterläuft, ist es nicht.
|
||||
*/
|
||||
class TearDownInstance implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Wie lange die Aktion auf das Herunterfahren warten darf.
|
||||
*
|
||||
* Unverändert die zehn Minuten, die `ShutDownVirtualMachine` demselben
|
||||
* Vorgang gibt. Es ist die Frist, in der eine Nextcloud sauber stoppt; sie
|
||||
* zu kürzen hiesse, ein Archiv mit halb geschriebenen Tabellen aufzubewahren
|
||||
* oder gar nichts.
|
||||
*/
|
||||
public const SHUTDOWN_WAIT_SECONDS = 600;
|
||||
|
||||
/**
|
||||
* Wie lange die Aktion auf den vzdump warten darf.
|
||||
*
|
||||
* Zwanzig statt sechzig Minuten, und die Zahl kommt nicht aus einer
|
||||
* Schätzung über Plattengrössen, sondern von oben: `$timeout` minus dem
|
||||
* Herunterfahren minus dem Zuschlag für Ablagenprüfung, Nachschau und die
|
||||
* HTTP-Umläufe dazwischen. Was hier hineinpasst, ist das, was die
|
||||
* Warteschlange trägt — siehe die Tabelle im Kopf.
|
||||
*/
|
||||
public const BACKUP_WAIT_SECONDS = 1200;
|
||||
|
||||
/**
|
||||
* Kein zweiter Versuch.
|
||||
*
|
||||
* Ein Wiederholungslauf träfe die Maschine an, die der erste Versuch gerade
|
||||
* gesichert hat: Proxmox hält eine VM während des vzdump gesperrt, das
|
||||
* Herunterfahren schlüge fehl, und der neue, irreführende Grund
|
||||
* überschriebe den alten, richtigen. Wiederholt wird hier nicht in
|
||||
* Sekunden, sondern in der nächsten Nacht — dann ist der Dump fertig, und
|
||||
* die Ausgangslage ist wieder sauber.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Muss über den Fristen der Aktion und unter `retry_after` liegen.
|
||||
*
|
||||
* Derselbe Wert wie bei `AdvanceRunJob` und wie `--timeout` im
|
||||
* Arbeiterskript, damit hier keine dritte Zahl entsteht, die jemand
|
||||
* getrennt pflegen müsste.
|
||||
*/
|
||||
public int $timeout = 2100;
|
||||
|
||||
/**
|
||||
* Ein Abbau je Instanz, warteschlangenweit.
|
||||
*
|
||||
* Die Sperre in `ArchiveAndTearDown` hält zwei gleichzeitige LÄUFE
|
||||
* auseinander. Sie hält aber keinen zweiten Auftrag davon ab, sich hinter
|
||||
* dem ersten in die Warteschlange zu stellen — der liefe dann los, sobald
|
||||
* der erste fertig ist, und sicherte dieselbe Maschine ein zweites Mal auf
|
||||
* dieselbe Ablage. Das ist kein erfundener Fall: der nächtliche Lauf und
|
||||
* ein Betreiber, der den Befehl von Hand anwirft, sind zwei Einreicher.
|
||||
*/
|
||||
public int $uniqueFor = 2400;
|
||||
|
||||
public function __construct(public string $uuid)
|
||||
{
|
||||
$this->onConnection('provisioning');
|
||||
$this->onQueue('provisioning');
|
||||
}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function handle(ArchiveAndTearDown $abbau): void
|
||||
{
|
||||
$instance = Instance::query()->with('host')->where('uuid', $this->uuid)->first();
|
||||
|
||||
if ($instance === null) {
|
||||
// Zwischen Einreihen und Ausführen können Stunden liegen. Eine
|
||||
// Instanz, die es nicht mehr gibt, ist kein Fehlschlag.
|
||||
return;
|
||||
}
|
||||
|
||||
$abbau->shutdownWaitSeconds = self::SHUTDOWN_WAIT_SECONDS;
|
||||
$abbau->backupWaitSeconds = self::BACKUP_WAIT_SECONDS;
|
||||
|
||||
// Der Rückgabewert wird bewusst nicht ausgewertet: die Aktion wirft nie
|
||||
// und schreibt ihren Grund selbst an den Datensatz, wo die Konsole ihn
|
||||
// liest. Ein `false` hier in eine Ausnahme zu übersetzen, machte aus
|
||||
// einem vermerkten Fehlschlag zusätzlich einen fehlgeschlagenen
|
||||
// Auftrag — dieselbe Nachricht an zwei Orten, von denen nur einer
|
||||
// gelesen wird.
|
||||
$abbau($instance);
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +165,23 @@ return [
|
|||
'col_ends' => 'Laufzeitende',
|
||||
'over' => 'fällig',
|
||||
],
|
||||
// Die beiden Kästen zum Abbau gekündigter Maschinen — siehe
|
||||
// App\Livewire\Admin\Instances::render(). Der obere ist der wichtigere:
|
||||
// eine Instanz mit gefülltem `teardown_error` steht unbegrenzt und belegt
|
||||
// weiter einen Platz auf ihrem Host. Beide Texte sagen ausdrücklich, wie
|
||||
// die Zeile wieder verschwindet — ein Kasten, den nichts je abräumen kann,
|
||||
// wird nach der zweiten Woche nicht mehr gelesen.
|
||||
'teardown' => [
|
||||
'stuck_title' => 'Abbau hängt',
|
||||
'stuck_sub' => 'Diese Maschinen sollten längst abgebaut sein und stehen noch — jede belegt weiter einen Platz auf ihrem Host. Der nächtliche Lauf versucht es erneut; eine Zeile verschwindet von selbst, sobald der Abbau durchläuft.',
|
||||
'archive_title' => 'Archiviert und abgebaut',
|
||||
'archive_sub' => 'Wo das Archiv liegt, für die zwölf Monate der Aufbewahrung. Danach ist der Dump fort und die Zeile mit ihm.',
|
||||
'col_machine' => 'Maschine',
|
||||
'col_attempt' => 'Letzter Versuch',
|
||||
'col_reason' => 'Grund',
|
||||
'col_archive' => 'Archiv',
|
||||
'col_archived_at' => 'Archiviert am',
|
||||
],
|
||||
'instances_label' => 'Instanzen',
|
||||
'hosts_sub' => 'Proxmox-Hosts und Kapazität.',
|
||||
'storage_used' => 'Speicher belegt',
|
||||
|
|
|
|||
|
|
@ -165,6 +165,23 @@ return [
|
|||
'col_ends' => 'Term ends',
|
||||
'over' => 'due',
|
||||
],
|
||||
// The two sections about tearing down cancelled machines — see
|
||||
// App\Livewire\Admin\Instances::render(). The first one matters more: an
|
||||
// instance with teardown_error filled in stands indefinitely and keeps
|
||||
// occupying a slot on its host. Both texts say out loud how a row goes
|
||||
// away again — a section nothing can ever clear stops being read after the
|
||||
// second week.
|
||||
'teardown' => [
|
||||
'stuck_title' => 'Teardown stuck',
|
||||
'stuck_sub' => 'These machines should have been torn down long ago and are still standing — each one still occupies a slot on its host. The nightly run tries again; a row disappears by itself as soon as the teardown gets through.',
|
||||
'archive_title' => 'Archived and torn down',
|
||||
'archive_sub' => 'Where the archive is, for the twelve months it is kept. After that the dump is gone and so is the row.',
|
||||
'col_machine' => 'Machine',
|
||||
'col_attempt' => 'Last attempt',
|
||||
'col_reason' => 'Reason',
|
||||
'col_archive' => 'Archive',
|
||||
'col_archived_at' => 'Archived on',
|
||||
],
|
||||
'instances_label' => 'instances',
|
||||
'hosts_sub' => 'Proxmox hosts and capacity.',
|
||||
'storage_used' => 'Storage used',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,54 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ __('admin.instances_sub') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Haengengebliebene Abbauten, ganz oben und in Rot.
|
||||
|
||||
Der wichtigste Kasten dieser Seite: jede Zeile hier ist eine Maschine,
|
||||
die laengst weg sein sollte und weiter einen Platz auf ihrem Host
|
||||
belegt. Siehe Instances::render() dazu, warum das nicht als Plakette
|
||||
in der Zeile stehen kann (die Tabelle darunter ist geblaettert) und
|
||||
warum die Liste sich von selbst abraeumt. --}}
|
||||
@if ($stuck !== [])
|
||||
<div class="overflow-hidden rounded-lg border border-danger-border bg-surface shadow-xs animate-rise [animation-delay:20ms]">
|
||||
<div class="border-b border-danger-border bg-danger-bg px-4 py-3">
|
||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-danger">
|
||||
<x-ui.icon name="alert-triangle" class="size-4" />{{ __('admin.teardown.stuck_title') }}
|
||||
<span class="font-mono text-xs font-normal">{{ count($stuck) }}</span>
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-danger">{{ __('admin.teardown.stuck_sub') }}</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.address') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.teardown.col_machine') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.teardown.col_attempt') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.teardown.col_reason') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($stuck as $s)
|
||||
<tr class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3 text-body">{{ $s['customer'] }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $s['address'] }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $s['machine'] }}</td>
|
||||
<td class="px-4 py-3 text-xs text-muted">{{ $s['attempted'] }}</td>
|
||||
<td class="px-4 py-3 text-xs text-body">
|
||||
{{ $s['reason'] }}
|
||||
@if ($s['archive'])
|
||||
<span class="mt-1 block font-mono text-xs text-muted">{{ __('admin.teardown.col_archive') }}: {{ $s['archive'] }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Bestellte Datenexporte. Nur vorhanden, wenn wirklich einer bestellt
|
||||
wurde — ein dauerhaft leerer Kasten wird nach der zweiten Woche nicht
|
||||
mehr gelesen. Siehe Instances::render(): das ist der einzige Ort in
|
||||
|
|
@ -105,4 +153,46 @@
|
|||
@if ($instances->hasPages())
|
||||
<div class="animate-rise [animation-delay:120ms]">{{ $instances->links() }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Was abgebaut wurde, und wo das Archiv liegt.
|
||||
|
||||
Unter der Liste und nicht darueber: das ist kein Alarm, sondern ein
|
||||
Nachweis. Wer ihn braucht, sucht ihn — wer ihn nicht braucht, soll
|
||||
nicht jeden Tag daran vorbeiscrollen, um zur Instanzliste zu kommen.
|
||||
|
||||
`archive_volid` steht im Klartext da, weil genau diese Zeichenkette
|
||||
eine Wiederherstellung von einer Suche unterscheidet. --}}
|
||||
@if ($teardowns !== [])
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:140ms]">
|
||||
<div class="border-b border-line bg-surface-2 px-4 py-3">
|
||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
<x-ui.icon name="box" class="size-4" />{{ __('admin.teardown.archive_title') }}
|
||||
<span class="font-mono text-xs font-normal text-muted">{{ count($teardowns) }}</span>
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('admin.teardown.archive_sub') }}</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.address') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.teardown.col_archived_at') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.teardown.col_archive') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($teardowns as $t)
|
||||
<tr class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3 text-body">{{ $t['customer'] }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $t['address'] }}</td>
|
||||
<td class="px-4 py-3 text-xs text-muted">{{ $t['archived_at'] }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-body">{{ $t['archive'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -251,6 +251,31 @@ Schedule::command('clupilot:end-due-services')
|
|||
->hourly()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Der Abbau: vierzehn Tage nach dem Laufzeitende wird die Maschine archiviert
|
||||
// und gelöscht.
|
||||
//
|
||||
// Der Lauf darüber nimmt einer gekündigten Instanz nur die Adresse weg und
|
||||
// lässt die virtuelle Maschine ausdrücklich stehen. Danach kam bislang nichts
|
||||
// mehr — sie lief für immer weiter und belegte einen Platz auf dem Host, den
|
||||
// niemand mehr verkaufen kann. Dies ist der Termin, der das beendet.
|
||||
//
|
||||
// Täglich, nicht stündlich: der Abbau hat keinen Moment, auf den es ankommt.
|
||||
// Beim Laufzeitende oben ist der Zeitpunkt selbst die Sache — eine Stunde
|
||||
// später heisst eine Stunde Dienst, die niemand zu geben beschlossen hat.
|
||||
// Hier ist der Zeitpunkt schon vierzehn Tage her; ob eine Maschine am
|
||||
// vierzehnten Tag um 05:30 oder um 17:30 verschwindet, ändert für niemanden
|
||||
// etwas. Stündlich hiesse nur: dreiundzwanzigmal am Tag dieselbe Auswahl
|
||||
// treffen und feststellen, dass sie schon eingereiht ist.
|
||||
//
|
||||
// 05:30, und die Uhrzeit ist die eigentliche Entscheidung: RegisterBackup legt
|
||||
// auf jedem Host einen nächtlichen vzdump um 02:00 an. Ein Abbau, der sein
|
||||
// eigenes Archiv in dieses Fenster schiebt, trifft eine Maschine, die Proxmox
|
||||
// gerade für die Sicherung gesperrt hält — das Herunterfahren scheitert, und
|
||||
// zwar jede Nacht aufs Neue an derselben Stelle.
|
||||
Schedule::command('clupilot:tear-down-ended-instances')
|
||||
->dailyAt('05:30')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Die Erinnerung, bevor der Lauf oben die Adresse einzieht.
|
||||
//
|
||||
// Ab dem Laufzeitende kommt der Kunde selbst nicht mehr an seine Nextcloud —
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
|
||||
// tests/Feature/Cancellation/TeardownHasReadersTest.php
|
||||
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Wer sieht, was der Abbau hinterlassen hat.
|
||||
*
|
||||
* Zwei Zustände standen bis hierher nur in der Datenbank, und der Fehler wäre
|
||||
* derselbe gewesen wie bei `export_wish` vor Teil A — nur teurer:
|
||||
*
|
||||
* - `teardown_error`: eine Instanz mit gefülltem Grund steht **unbegrenzt** und
|
||||
* belegt weiter einen Platz auf ihrem Host. Sieht sie niemand, war der ganze
|
||||
* Abbau umsonst.
|
||||
* - `archive_volid` / `archived_at`: ohne diese Angabe ist eine
|
||||
* Wiederherstellung eine Suche über fremde Ablagen.
|
||||
*
|
||||
* Und einen Fehler ausdrücklich nicht noch einmal: der Kasten „Datenexport
|
||||
* bestellt" lässt sich bis heute nicht abräumen und sortiert die ältesten nach
|
||||
* oben. Beide Kästen hier tun das Gegenteil, und beides ist unten festgenagelt.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Eine Instanz, deren Abbau hängengeblieben ist: Grund vermerkt, Maschine
|
||||
* steht noch. Eigener Name statt der Vorlagen aus den Nachbardateien — Pest
|
||||
* führt diese Funktionen in einem gemeinsamen Namensraum.
|
||||
*/
|
||||
function haengenderAbbau(string $subdomain, string $grund, ?string $kunde = null): Instance
|
||||
{
|
||||
$host = Host::factory()->active()->create(['node' => 'pve']);
|
||||
|
||||
return Instance::factory()->create([
|
||||
'customer_id' => Customer::factory()->create(['name' => $kunde ?? 'Kundin '.$subdomain]),
|
||||
'host_id' => $host->id,
|
||||
'vmid' => 900 + strlen($subdomain),
|
||||
'status' => 'ended',
|
||||
'subdomain' => $subdomain,
|
||||
'service_ends_at' => now()->subDays(30),
|
||||
'teardown_error' => $grund,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Was gehakt hat: der wichtigere der beiden Kästen ----
|
||||
|
||||
it('zeigt dem Betreiber einen hängengebliebenen Abbau samt Grund', function () {
|
||||
// Die Zusicherung, an der der ganze Plan hängt. Ohne diesen Kasten stünde
|
||||
// die Maschine für immer, und der Grund läge in einer Spalte, die in der
|
||||
// ganzen Konsole keinen Leser hat.
|
||||
haengenderAbbau('nc-haengt', 'Die Ablage "local" nimmt keine Sicherungen auf.', kunde: 'Hängenkundin GmbH');
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertSee(__('admin.teardown.stuck_title'))
|
||||
->assertSee('Hängenkundin GmbH')
|
||||
->assertSee('nc-haengt')
|
||||
->assertSee('Die Ablage "local" nimmt keine Sicherungen auf.');
|
||||
});
|
||||
|
||||
it('nennt beim hängenden Abbau das Archiv, wenn es schon liegt', function () {
|
||||
// Der Zwischenstand, den es wirklich gibt: gesichert, aber nicht gelöscht.
|
||||
// Wer ihn von Hand zu Ende bringt, braucht genau diese Kennung — sonst
|
||||
// sichert er ein zweites Mal und zahlt die Stunde noch einmal.
|
||||
$instanz = haengenderAbbau('nc-halbfertig', 'Das Löschen wurde abgewiesen.');
|
||||
$instanz->update([
|
||||
'archive_volid' => 'local:backup/vzdump-qemu-909.vma.zst',
|
||||
'archived_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertSee('local:backup/vzdump-qemu-909.vma.zst');
|
||||
});
|
||||
|
||||
it('stellt den zuletzt gescheiterten Abbau nach oben', function () {
|
||||
// Der Fehler des Nachbarkastens, ausdrücklich nicht wiederholt: „Datenexport
|
||||
// bestellt" sortiert aufsteigend, also besetzt der älteste Eintrag den Kopf
|
||||
// der Liste für immer und die frischen wandern nach unten. Ein Kasten, in
|
||||
// dem der Dauerfall oben klebt, wird nach der zweiten Woche nicht mehr
|
||||
// gelesen.
|
||||
$alt = haengenderAbbau('nc-alterfall', 'scheitert seit Wochen');
|
||||
// Über den Abfrageerzeuger und nicht über das Modell: `updated_at` steht
|
||||
// nicht in $fillable, ein update() darauf verschluckt den Wert stillschweigend.
|
||||
Instance::query()->whereKey($alt->id)->update(['updated_at' => now()->subMonth()]);
|
||||
haengenderAbbau('nc-neuerfall', 'heute Nacht gescheitert');
|
||||
|
||||
$seite = Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class);
|
||||
|
||||
$reihenfolge = collect($seite->viewData('stuck'))->pluck('address')->all();
|
||||
|
||||
expect($reihenfolge)->toBe(['nc-neuerfall', 'nc-alterfall']);
|
||||
});
|
||||
|
||||
it('räumt die Zeile ab, sobald der Abbau durchgelaufen ist', function () {
|
||||
// Der Unterschied zum Kasten „Datenexport bestellt", der sich nie abräumen
|
||||
// lässt: hier gibt es einen Mechanismus, der das ohne Buchführung tut. Der
|
||||
// nächtliche Lauf versucht es erneut, und ein erfolgreicher Abbau setzt
|
||||
// `teardown_error` auf null und `torn_down_at`.
|
||||
$instanz = haengenderAbbau('nc-geheilt', 'beim letzten Mal gescheitert');
|
||||
$instanz->update([
|
||||
'teardown_error' => null,
|
||||
'archive_volid' => 'local:backup/vzdump-qemu-911.vma.zst',
|
||||
'archived_at' => now(),
|
||||
'torn_down_at' => now(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertDontSee(__('admin.teardown.stuck_title'));
|
||||
});
|
||||
|
||||
it('zeigt gar keinen Kasten, wenn nichts hängt', function () {
|
||||
// Ein dauerhaft leerer Kasten wird nach der zweiten Woche übersehen — und
|
||||
// mit ihm der erste Eintrag, der wirklich einmal darin steht.
|
||||
Instance::factory()->create(['status' => 'active', 'subdomain' => 'nc-laeuft']);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertDontSee(__('admin.teardown.stuck_title'));
|
||||
});
|
||||
|
||||
// ---- Wo das Archiv liegt ----
|
||||
|
||||
it('sagt, was abgebaut wurde und wo das Archiv liegt', function () {
|
||||
$customer = Customer::factory()->create(['name' => 'Abgebautkundin GmbH']);
|
||||
$instanz = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'ended',
|
||||
'subdomain' => 'nc-abgebaut',
|
||||
'service_ends_at' => now()->subDays(30),
|
||||
'archive_volid' => 'local:backup/vzdump-qemu-777.vma.zst',
|
||||
'archived_at' => now()->subDays(2),
|
||||
'torn_down_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertSee(__('admin.teardown.archive_title'))
|
||||
->assertSee('Abgebautkundin GmbH')
|
||||
->assertSee('local:backup/vzdump-qemu-777.vma.zst')
|
||||
// R19: die Wanduhr des Betreibers, nicht die Speicherzone.
|
||||
->assertSee($instanz->archived_at->local()->isoFormat('LLL'));
|
||||
});
|
||||
|
||||
it('lässt ein Archiv aus der Liste fallen, sobald die Aufbewahrung um ist', function () {
|
||||
// Der Kasten hat damit ein Ende, das ihm jemand anderes setzt: was hier
|
||||
// steht, ist genau das, was noch auf einer Ablage liegt. Eine Liste jeder
|
||||
// Löschung seit Anbeginn wäre wieder der Kasten, den nichts abräumen kann.
|
||||
Instance::factory()->create([
|
||||
'status' => 'ended',
|
||||
'subdomain' => 'nc-uralt',
|
||||
'service_ends_at' => now()->subYears(2),
|
||||
'archive_volid' => 'local:backup/vzdump-qemu-666.vma.zst',
|
||||
'archived_at' => now()->subMonths(13),
|
||||
'torn_down_at' => now()->subMonths(13),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertDontSee('local:backup/vzdump-qemu-666.vma.zst');
|
||||
});
|
||||
|
||||
it('stellt den jüngsten Abbau nach oben', function () {
|
||||
Instance::factory()->create([
|
||||
'status' => 'ended', 'subdomain' => 'nc-frueher',
|
||||
'archive_volid' => 'local:backup/vzdump-qemu-111.vma.zst',
|
||||
'archived_at' => now()->subMonths(6), 'torn_down_at' => now()->subMonths(6),
|
||||
]);
|
||||
Instance::factory()->create([
|
||||
'status' => 'ended', 'subdomain' => 'nc-gestern',
|
||||
'archive_volid' => 'local:backup/vzdump-qemu-222.vma.zst',
|
||||
'archived_at' => now()->subDay(), 'torn_down_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$seite = Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class);
|
||||
|
||||
expect(collect($seite->viewData('teardowns'))->pluck('address')->all())
|
||||
->toBe(['nc-gestern', 'nc-frueher']);
|
||||
});
|
||||
|
||||
it('führt eine Instanz ohne nachgewiesenes Archiv nicht als abgebaut auf', function () {
|
||||
// `torn_down_at` ohne `archive_volid` darf es nach ArchiveAndTearDown gar
|
||||
// nicht geben — und wenn es das doch einmal gibt, ist die Zeile eine
|
||||
// Behauptung über ein Archiv, das niemand finden wird. Lieber gar kein
|
||||
// Eintrag als ein Fundort, den es nicht gibt.
|
||||
Instance::factory()->create([
|
||||
'status' => 'ended',
|
||||
'subdomain' => 'nc-ohnearchiv',
|
||||
'archived_at' => now()->subDay(),
|
||||
'torn_down_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertDontSee(__('admin.teardown.archive_title'));
|
||||
});
|
||||
|
||||
// ---- Beide Sprachdateien tragen dieselben Schlüssel ----
|
||||
|
||||
it('hat jeden Schlüssel in beiden Sprachen', function () {
|
||||
$de = __('admin.teardown', locale: 'de');
|
||||
$en = __('admin.teardown', locale: 'en');
|
||||
|
||||
expect(array_keys($de))->toBe(array_keys($en))
|
||||
->and($de)->not->toBe($en);
|
||||
});
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
<?php
|
||||
|
||||
// tests/Feature/Console/TearDownEndedInstancesTest.php
|
||||
|
||||
use App\Actions\ArchiveAndTearDown;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Provisioning\Jobs\TearDownInstance;
|
||||
use App\Services\Proxmox\FakeProxmoxClient;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* Der Zeitplan-Griff für den Abbau: wer ausgewählt wird, wer nicht, und was ein
|
||||
* Probelauf anfassen darf (nämlich nichts).
|
||||
*
|
||||
* Der Befehl fasst selbst keine Maschine an — er reiht `TearDownInstance` auf
|
||||
* der `provisioning`-Warteschlange ein, weil nur dieser Container im
|
||||
* WireGuard-Tunnel hängt. Ohne `Queue::fake()` läuft der Auftrag hier trotzdem
|
||||
* im selben Prozess ab: `Tests\TestCase::setUp()` stellt diese Verbindung für
|
||||
* die ganze Suite auf `sync`. Genau das ist hier erwünscht, denn dann zeichnet
|
||||
* die Attrappe des Proxmox-Clients jeden Aufruf auf — eine Prüfung, die nur
|
||||
* „ein Auftrag wurde eingereiht" behauptet, sagt über ein Löschen nichts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Eine Instanz im Zustand NACH dem Laufzeitende (status 'ended', Adresse weg),
|
||||
* mit Host und VMID — ohne beides gibt es nichts abzubauen.
|
||||
*
|
||||
* Eigener Name statt der Vorlage aus ArchiveAndTearDownTest: Pest führt diese
|
||||
* Funktionen in einem gemeinsamen Namensraum, zwei gleichnamige wären ein
|
||||
* fataler Fehler beim Laden.
|
||||
*/
|
||||
function faelligeInstanz(int $vmid, ?Carbon $endeteAm = null, ?string $subdomain = null): Instance
|
||||
{
|
||||
$host = Host::factory()->active()->create(['node' => 'pve']);
|
||||
|
||||
return Instance::factory()->create([
|
||||
'host_id' => $host->id,
|
||||
'vmid' => $vmid,
|
||||
'status' => 'ended',
|
||||
'subdomain' => $subdomain ?? 'nc-'.$vmid,
|
||||
'service_ends_at' => $endeteAm ?? now()->subDays(20),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Eine Attrappe, auf deren Ablage ein frisches Archiv liegt — der Erfolgsfall. */
|
||||
function attrappeMitArchiv(int ...$vmids): FakeProxmoxClient
|
||||
{
|
||||
$pve = new FakeProxmoxClient;
|
||||
|
||||
foreach ($vmids as $vmid) {
|
||||
$pve->backups[(string) $vmid] = [[
|
||||
'volid' => 'local:backup/vzdump-qemu-'.$vmid.'.vma.zst',
|
||||
'ctime' => now()->timestamp,
|
||||
]];
|
||||
}
|
||||
|
||||
app()->instance(ProxmoxClient::class, $pve);
|
||||
|
||||
return $pve;
|
||||
}
|
||||
|
||||
it('fasst unter --dry-run nachweislich nichts an', function () {
|
||||
// Die Zusicherung, die der ganze Schalter ist. Nicht „es wurde nichts
|
||||
// eingereiht" — sondern: auf dem Knoten wurde nichts heruntergefahren,
|
||||
// nichts gesichert und nichts gelöscht, und am Datensatz steht danach
|
||||
// dasselbe wie davor.
|
||||
$pve = attrappeMitArchiv(201);
|
||||
$instance = faelligeInstanz(vmid: 201);
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances --dry-run')->assertSuccessful();
|
||||
|
||||
expect($pve->shutdownCalls)->toBe([])
|
||||
->and($pve->backupCalls)->toBe([])
|
||||
->and($pve->deletedVmids)->toBe([])
|
||||
->and($instance->fresh()->torn_down_at)->toBeNull()
|
||||
->and($instance->fresh()->archive_volid)->toBeNull()
|
||||
->and($instance->fresh()->teardown_error)->toBeNull();
|
||||
});
|
||||
|
||||
it('nennt im Probelauf trotzdem, was abgebaut würde', function () {
|
||||
// Ein Probelauf, der schweigt, ist kein Probelauf. Er ist die einzige
|
||||
// Möglichkeit, vor dem ersten scharfen Lauf zu sehen, wen es trifft.
|
||||
attrappeMitArchiv(201);
|
||||
faelligeInstanz(vmid: 201, subdomain: 'nc-probelauf');
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances --dry-run')
|
||||
->expectsOutputToContain('nc-probelauf')
|
||||
->expectsOutputToContain('Nichts wurde geändert.')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('baut ab, was seit mehr als vierzehn Tagen fällig ist', function () {
|
||||
$pve = attrappeMitArchiv(201);
|
||||
$instance = faelligeInstanz(vmid: 201);
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
|
||||
|
||||
expect($pve->deletedVmids)->toBe([201])
|
||||
->and($instance->fresh()->torn_down_at)->not->toBeNull()
|
||||
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst');
|
||||
});
|
||||
|
||||
it('lässt eine Instanz innerhalb der vierzehn Tage stehen', function () {
|
||||
// Die Frist ist der Sinn der Sache: bis hierher ist die Maschine eines
|
||||
// Menschen unverändert da, der es sich vielleicht gerade anders überlegt.
|
||||
// Geprüft am Knoten und nicht an der Warteschlange — was eingereiht wird,
|
||||
// ist eine Zwischenstation, was heruntergefahren wird, ist die Tatsache.
|
||||
$pve = attrappeMitArchiv(201);
|
||||
$instance = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(3));
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
|
||||
|
||||
expect($pve->shutdownCalls)->toBe([])
|
||||
->and($pve->deletedVmids)->toBe([])
|
||||
->and($instance->fresh()->torn_down_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('überspringt eine Instanz ohne Host und sagt den Grund', function () {
|
||||
// Es gibt nichts abzubauen, und ein Auftrag dafür verbrauchte nur einen
|
||||
// Platz auf der Warteschlange, über die auch bezahlte Bestellungen laufen.
|
||||
Queue::fake();
|
||||
$instance = Instance::factory()->create([
|
||||
'host_id' => null,
|
||||
'vmid' => null,
|
||||
'status' => 'ended',
|
||||
'subdomain' => 'nc-ohnehost',
|
||||
'service_ends_at' => now()->subDays(20),
|
||||
]);
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances')
|
||||
// Kurzes Stück, weil die Konsole lange Zeilen umbricht und eine
|
||||
// Zusicherung auf einen Satz genau am Umbruch scheitern würde. Der
|
||||
// vollständige Grund wird unten am Datensatz geprüft.
|
||||
->expectsOutputToContain('nc-ohnehost: übersprungen')
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
// Und der Grund steht am Datensatz, nicht nur im Protokoll des Zeitplans:
|
||||
// dort liest ihn niemand, hier zeigt ihn die Konsole über der Instanzliste.
|
||||
expect($instance->fresh()->teardown_error)->toContain('keine erreichbare Maschine');
|
||||
});
|
||||
|
||||
it('vermerkt im Probelauf auch bei einer Instanz ohne Host nichts', function () {
|
||||
// Der Probelauf darf auch dort nicht schreiben, wo das Geschriebene nur
|
||||
// eine Notiz ist — sonst hiesse „ändert nichts" in Wahrheit „ändert nur
|
||||
// Kleinigkeiten".
|
||||
$instance = Instance::factory()->create([
|
||||
'host_id' => null,
|
||||
'vmid' => null,
|
||||
'status' => 'ended',
|
||||
'service_ends_at' => now()->subDays(20),
|
||||
]);
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances --dry-run')->assertSuccessful();
|
||||
|
||||
expect($instance->fresh()->teardown_error)->toBeNull();
|
||||
});
|
||||
|
||||
it('bricht den Lauf nicht ab, wenn eine Instanz scheitert', function () {
|
||||
// Der Fall, den ein Zeitplan über viele Instanzen aushalten muss. Die
|
||||
// Aktion wirft für die erste Instanz hart — härter, als sie es im Betrieb
|
||||
// je täte, denn sie fängt selbst alles. Wer die Ausnahme trotzdem nach
|
||||
// draussen liesse, verlöre alle folgenden Instanzen dieses Laufes.
|
||||
$pve = attrappeMitArchiv(202);
|
||||
$erste = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(40));
|
||||
$zweite = faelligeInstanz(vmid: 202, endeteAm: now()->subDays(20));
|
||||
|
||||
app()->bind(ArchiveAndTearDown::class, fn () => new class($erste->uuid, app(ProxmoxClient::class)) extends ArchiveAndTearDown
|
||||
{
|
||||
public function __construct(private string $sprengsatz, ProxmoxClient $pve)
|
||||
{
|
||||
parent::__construct($pve);
|
||||
}
|
||||
|
||||
public function __invoke(Instance $instance): bool
|
||||
{
|
||||
if ($instance->uuid === $this->sprengsatz) {
|
||||
throw new RuntimeException('Attrappe: dieser Abbau fliegt dem Aufrufer um die Ohren.');
|
||||
}
|
||||
|
||||
return parent::__invoke($instance);
|
||||
}
|
||||
});
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
|
||||
|
||||
// Die zweite wurde trotzdem abgebaut — das ist die ganze Zusicherung.
|
||||
expect($zweite->fresh()->torn_down_at)->not->toBeNull()
|
||||
->and($pve->deletedVmids)->toBe([202]);
|
||||
});
|
||||
|
||||
it('vermerkt einen Fehlschlag am Datensatz und macht mit der nächsten weiter', function () {
|
||||
// Derselbe Gedanke, aber mit einem echten Fehler statt einer Attrappe:
|
||||
// Proxmox nimmt das Herunterfahren nicht an. Beide Instanzen müssen
|
||||
// versucht worden sein — ein Lauf, der nach der ersten abbräche, liesse
|
||||
// die zweite ohne Grund und ohne Versuch zurück.
|
||||
$pve = attrappeMitArchiv(201, 202);
|
||||
$pve->shutdownThrows = true;
|
||||
$erste = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(40));
|
||||
$zweite = faelligeInstanz(vmid: 202, endeteAm: now()->subDays(20));
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
|
||||
|
||||
expect($erste->fresh()->teardown_error)->not->toBeNull()
|
||||
->and($zweite->fresh()->teardown_error)->not->toBeNull()
|
||||
->and($pve->deletedVmids)->toBe([]);
|
||||
});
|
||||
|
||||
it('reiht nie mehr ein, als die Obergrenze erlaubt', function () {
|
||||
// `provisioning` ist EINE Warteschlange mit EINEM Arbeiter, und dieselbe,
|
||||
// über die eine bezahlte Bestellung ihre Maschine bekommt. Ohne Grenze
|
||||
// schöbe der erste Lauf nach dem Ausrollen den ganzen Rückstand davor.
|
||||
Queue::fake();
|
||||
faelligeInstanz(vmid: 201, endeteAm: now()->subDays(40));
|
||||
faelligeInstanz(vmid: 202, endeteAm: now()->subDays(30));
|
||||
faelligeInstanz(vmid: 203, endeteAm: now()->subDays(20));
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances --limit=2')->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(TearDownInstance::class, 2);
|
||||
});
|
||||
|
||||
it('lässt einen Dauerfall die übrigen nicht aushungern', function () {
|
||||
// Die Falle in „am längsten fällig zuerst": eine Instanz, deren Abbau jede
|
||||
// Nacht an derselben Sache scheitert, ist am nächsten Tag wieder die am
|
||||
// längsten fällige. Bei einer Obergrenze von eins käme dahinter nie
|
||||
// jemand an die Reihe — der Rückstand stünde für immer.
|
||||
Queue::fake();
|
||||
$dauerfall = faelligeInstanz(vmid: 201, endeteAm: now()->subDays(90));
|
||||
$dauerfall->update(['teardown_error' => 'scheitert seit Wochen an derselben Stelle']);
|
||||
$frisch = faelligeInstanz(vmid: 202, endeteAm: now()->subDays(20));
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances --limit=1')->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(TearDownInstance::class, fn (TearDownInstance $job) => $job->uuid === $frisch->uuid);
|
||||
Queue::assertNotPushed(TearDownInstance::class, fn (TearDownInstance $job) => $job->uuid === $dauerfall->uuid);
|
||||
});
|
||||
|
||||
it('reiht den Abbau auf der Warteschlange ein, die den Tunnel hat', function () {
|
||||
// Keine Feinheit: nur `queue-provisioning` steigt in den Netz-Namensraum
|
||||
// des vpn-hub ein. Der scheduler-Container, in dem dieser Befehl läuft,
|
||||
// erreicht die Management-Adresse eines Hosts gar nicht — ein Abbau, der
|
||||
// von dort aus mit Proxmox spräche, liefe jedes Mal in eine
|
||||
// Zeitüberschreitung.
|
||||
Queue::fake();
|
||||
$instance = faelligeInstanz(vmid: 201);
|
||||
|
||||
$this->artisan('clupilot:tear-down-ended-instances')->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(TearDownInstance::class, fn (TearDownInstance $job) => $job->uuid === $instance->uuid
|
||||
&& $job->connection === 'provisioning'
|
||||
&& $job->queue === 'provisioning');
|
||||
});
|
||||
|
||||
it('gibt der Aktion Fristen, die vor dem Arbeiter und vor der Warteschlange ablaufen', function () {
|
||||
// Die bindende Auflage aus der Prüfung von Aufgabe 3, als Prüfung.
|
||||
//
|
||||
// Drei Uhren laufen übereinander, und wessen Uhr zuerst klingelt,
|
||||
// entscheidet, was hinterher dasteht: die Aktion wirft und hinterlässt
|
||||
// einen lesbaren Grund; der Arbeiter erschlägt den Prozess und hinterlässt
|
||||
// nichts; `retry_after` startet einen ZWEITEN Abbau gegen eine Maschine
|
||||
// mitten im vzdump. Dreht jemand eine der Zahlen, dreht sich die
|
||||
// Reihenfolge — und zwar still.
|
||||
$aktion = TearDownInstance::SHUTDOWN_WAIT_SECONDS + TearDownInstance::BACKUP_WAIT_SECONDS;
|
||||
$auftrag = (new TearDownInstance('egal'))->timeout;
|
||||
|
||||
// Aus der Datei und nicht aus config(): Tests\TestCase::setUp() ersetzt
|
||||
// diese Verbindung für die ganze Suite durch ein blosses ['driver' =>
|
||||
// 'sync'], damit kein Test Redis braucht. Zur Laufzeit ist `retry_after`
|
||||
// hier also gar nicht mehr da — gefragt werden muss der Wert, den ein
|
||||
// echter Arbeiter bekommt.
|
||||
$verbindung = (int) (require base_path('config/queue.php'))['connections']['provisioning']['retry_after'];
|
||||
|
||||
expect($aktion)->toBeLessThan($auftrag)
|
||||
->and($auftrag)->toBeLessThan($verbindung);
|
||||
});
|
||||
|
||||
it('gibt die Fristen wirklich an die Aktion weiter', function () {
|
||||
// Die Zahlen oben nützen nichts, solange sie nur Konstanten sind. Die
|
||||
// Aktion bringt ihre eigenen, grosszügigeren Vorgaben mit (eine Stunde
|
||||
// fürs Sichern); wer die Zuweisung im Auftrag entfernte, liefe wieder in
|
||||
// den Fall, den die Prüfung benannt hat.
|
||||
$pve = attrappeMitArchiv(201);
|
||||
$instance = faelligeInstanz(vmid: 201);
|
||||
$aktion = app(ArchiveAndTearDown::class);
|
||||
app()->instance(ArchiveAndTearDown::class, $aktion);
|
||||
|
||||
(new TearDownInstance($instance->uuid))->handle($aktion);
|
||||
|
||||
expect($aktion->shutdownWaitSeconds)->toBe(TearDownInstance::SHUTDOWN_WAIT_SECONDS)
|
||||
->and($aktion->backupWaitSeconds)->toBe(TearDownInstance::BACKUP_WAIT_SECONDS)
|
||||
// Und die Frist landet auch wirklich am Knoten, nicht nur im Objekt.
|
||||
->and($pve->shutdownCalls[0]['timeout'])->toBe(TearDownInstance::SHUTDOWN_WAIT_SECONDS);
|
||||
});
|
||||
|
||||
it('fällt nicht über eine Instanz, die es beim Ausführen nicht mehr gibt', function () {
|
||||
// Zwischen Einreihen und Ausführen können Stunden liegen — die
|
||||
// Warteschlange ist seriell und ein Abbau darf eine halbe Stunde dauern.
|
||||
attrappeMitArchiv(201);
|
||||
|
||||
expect(fn () => (new TearDownInstance('gibt-es-nicht'))->handle(app(ArchiveAndTearDown::class)))
|
||||
->not->toThrow(Throwable::class);
|
||||
});
|
||||
|
||||
it('läuft nach Zeitplan, oder er läuft überhaupt nicht', function () {
|
||||
// Täglich und nicht stündlich: der Abbau hat keinen Moment, auf den es
|
||||
// ankommt — der Zeitpunkt, um den es geht, ist vierzehn Tage her.
|
||||
// 05:30, weil RegisterBackup auf jedem Host einen nächtlichen vzdump um
|
||||
// 02:00 anlegt und eine dafür gesperrte Maschine sich nicht herunterfahren
|
||||
// lässt.
|
||||
expect(File::get(base_path('routes/console.php')))
|
||||
->toContain("Schedule::command('clupilot:tear-down-ended-instances')")
|
||||
->toContain("->dailyAt('05:30')");
|
||||
});
|
||||
Loading…
Reference in New Issue