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
nexxo 2026-08-04 12:00:37 +02:00
parent 51ec6ebed2
commit df73e558e9
9 changed files with 1092 additions and 0 deletions

View File

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

View File

@ -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

View File

@ -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);
}
}

View File

@ -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',

View File

@ -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',

View File

@ -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>

View File

@ -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 —

View File

@ -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);
});

View File

@ -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')");
});