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