diff --git a/app/Console/Commands/TearDownEndedInstances.php b/app/Console/Commands/TearDownEndedInstances.php new file mode 100644 index 0000000..dfc73dc --- /dev/null +++ b/app/Console/Commands/TearDownEndedInstances.php @@ -0,0 +1,168 @@ +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. + } + } +} diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php index 38d324a..de1b841 100644 --- a/app/Livewire/Admin/Instances.php +++ b/app/Livewire/Admin/Instances.php @@ -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 diff --git a/app/Provisioning/Jobs/TearDownInstance.php b/app/Provisioning/Jobs/TearDownInstance.php new file mode 100644 index 0000000..1121b4c --- /dev/null +++ b/app/Provisioning/Jobs/TearDownInstance.php @@ -0,0 +1,160 @@ +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); + } +} diff --git a/lang/de/admin.php b/lang/de/admin.php index 72f9ab0..223de4a 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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', diff --git a/lang/en/admin.php b/lang/en/admin.php index 235d72d..0bbf567 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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', diff --git a/resources/views/livewire/admin/instances.blade.php b/resources/views/livewire/admin/instances.blade.php index cf38a55..1de8f0a 100644 --- a/resources/views/livewire/admin/instances.blade.php +++ b/resources/views/livewire/admin/instances.blade.php @@ -4,6 +4,54 @@

{{ __('admin.instances_sub') }}

+ {{-- 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 !== []) +
+
+

+ {{ __('admin.teardown.stuck_title') }} + {{ count($stuck) }} +

+

{{ __('admin.teardown.stuck_sub') }}

+
+
+ + + + + + + + + + + + @foreach ($stuck as $s) + + + + + + + + @endforeach + +
{{ __('admin.col.customer') }}{{ __('admin.col.address') }}{{ __('admin.teardown.col_machine') }}{{ __('admin.teardown.col_attempt') }}{{ __('admin.teardown.col_reason') }}
{{ $s['customer'] }}{{ $s['address'] }}{{ $s['machine'] }}{{ $s['attempted'] }} + {{ $s['reason'] }} + @if ($s['archive']) + {{ __('admin.teardown.col_archive') }}: {{ $s['archive'] }} + @endif +
+
+
+ @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())
{{ $instances->links() }}
@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 !== []) +
+
+

+ {{ __('admin.teardown.archive_title') }} + {{ count($teardowns) }} +

+

{{ __('admin.teardown.archive_sub') }}

+
+
+ + + + + + + + + + + @foreach ($teardowns as $t) + + + + + + + @endforeach + +
{{ __('admin.col.customer') }}{{ __('admin.col.address') }}{{ __('admin.teardown.col_archived_at') }}{{ __('admin.teardown.col_archive') }}
{{ $t['customer'] }}{{ $t['address'] }}{{ $t['archived_at'] }}{{ $t['archive'] }}
+
+
+ @endif diff --git a/routes/console.php b/routes/console.php index 6c5ce0f..cd029d5 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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 — diff --git a/tests/Feature/Cancellation/TeardownHasReadersTest.php b/tests/Feature/Cancellation/TeardownHasReadersTest.php new file mode 100644 index 0000000..9143cdf --- /dev/null +++ b/tests/Feature/Cancellation/TeardownHasReadersTest.php @@ -0,0 +1,203 @@ +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); +}); diff --git a/tests/Feature/Console/TearDownEndedInstancesTest.php b/tests/Feature/Console/TearDownEndedInstancesTest.php new file mode 100644 index 0000000..ae8efe8 --- /dev/null +++ b/tests/Feature/Console/TearDownEndedInstancesTest.php @@ -0,0 +1,319 @@ +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')"); +});