From df64b17c77cf1bb3bab0079af3515f6aea5e055c Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 10:40:33 +0200 Subject: [PATCH] =?UTF-8?q?Plan:=20Kuendigung=20Teil=20B1=20=E2=80=94=20de?= =?UTF-8?q?r=20Abbau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Ausarbeiten hat sich der Entwurf als zu vorsichtig erwiesen. Zwei Funde: Die Hosts machen LAENGST Momentaufnahmen — RegisterBackup legt fuer jede Kundeninstanz einen naechtlichen vzdump an, mode: snapshot, storage: local. Die Frage, die im Entwurf als riskanteste Voraussetzung stand ("friert Proxmox eine laufende Maschine brauchbar ein?"), ist im Betrieb seit Monaten beantwortet. Und der Ablageort ist da: dieselbe lokale Ablage. Der Speicherserver ist der ZWEITE Ort, nicht der erste. Damit ist die Haelfte von Teil B baubar, die Geld kostet: heute laeuft jede gekuendigte Maschine fuer immer weiter und bindet einen Platz, den niemand mehr verkaufen kann. Der Kunden-Export (B2) wartet weiter — ein zusaetzlicher Tarball von 175 GB neben dem Archiv braucht ein Ziel ausserhalb des Hosts. Die tragende Regel des Plans steht ueber allem: niemals loeschen, bevor das Archiv NACHWEISLICH existiert. Ein vzdump kann mit einer Auftragskennung enden und trotzdem nichts hinterlassen — volle Ablage, abgebrochener Lauf. Wer sich auf die Kennung verlaesst statt nachzusehen, loescht eine Maschine, deren Archiv es nicht gibt. --- .../2026-08-04-kuendigung-teil-b1-abbau.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-kuendigung-teil-b1-abbau.md diff --git a/docs/superpowers/plans/2026-08-04-kuendigung-teil-b1-abbau.md b/docs/superpowers/plans/2026-08-04-kuendigung-teil-b1-abbau.md new file mode 100644 index 0000000..b81f51f --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-kuendigung-teil-b1-abbau.md @@ -0,0 +1,363 @@ +# Kündigung, Teil B1: der Abbau — Umsetzungsplan + +> **Für agentische Arbeiter:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development`. Schritte benutzen Checkbox-Syntax (`- [ ]`). + +**Ziel:** Vierzehn Tage nach dem Laufzeitende wird die Maschine eines gekündigten Kunden archiviert und gelöscht — und der Platz ist wieder verkaufbar. + +**Entwurf:** `docs/superpowers/specs/2026-08-04-kuendigung-export-abbau-design.md` +**Teil A** (fragen, warnen, Streifen) ist gebaut und in v1.7.1 veröffentlicht. + +--- + +## Warum das jetzt geht, obwohl der Speicherserver fehlt + +Beim Ausarbeiten hat sich der Entwurf an einer Stelle als zu vorsichtig +erwiesen. Zwei Funde: + +**Die Hosts machen längst Momentaufnahmen.** `RegisterBackup` legt für **jede** +Kundeninstanz einen nächtlichen `vzdump` an — `mode: snapshot`, `storage: +local`. Die Frage, die im Entwurf als riskanteste Voraussetzung stand („friert +Proxmox eine laufende Maschine brauchbar ein?"), ist im Betrieb seit Monaten +beantwortet. + +**Der Ablageort ist da.** Das Archiv geht auf dieselbe lokale Ablage, auf der +die nächtlichen Sicherungen ohnehin liegen. Der Speicherserver ist der +**zweite** Ort, nicht der erste — und dorthin gehört das Archiv später +gespiegelt, zusammen mit dem Modul „zweiter Sicherungsort", das heute verkauft +wird und nichts tut. + +**Was weiterhin wartet:** der Kunden-Export (B2). Ein Archiv von 175 GB auf der +Hostplatte ist vertretbar, weil im selben Zug eine Maschine derselben Grösse +verschwindet. Ein **zusätzlicher** Export-Tarball daneben ist es nicht — der +braucht ein Ziel ausserhalb des Hosts. + +**B1 löst das, was Geld kostet:** heute läuft jede gekündigte Maschine für +immer weiter und bindet einen Platz, den niemand mehr verkaufen kann. + +--- + +## Global Constraints + +- **Niemals löschen, bevor das Archiv nachweislich existiert.** Das ist die + tragende Regel dieses Plans. Ein Abbau, der die Maschine entfernt und das + Archiv verfehlt, ist unwiederbringlicher Datenverlust bei einem Kunden, dem + wir zwölf Monate Aufbewahrung zugesagt haben. +- **Nichts endet früher.** `EndInstanceService::hasEnded()` bleibt unangetastet. + Der Abbau setzt **vierzehn Tage nach** dem Laufzeitende an, nicht davor. +- **Ein Fehlschlag lässt die Maschine stehen.** Im Zweifel bleibt eine Maschine + eine Woche zu lang, statt eine Sekunde zu früh zu verschwinden. +- Kommentare erklären das WARUM, auf Deutsch, **mit echten Umlauten**. +- Sprachdateien immer **beide**, dieselben Schlüssel. +- R19: jede Zeit, die ein Mensch liest, geht durch `->local()`. +- Tests: `docker compose exec -u 1000:1000 -T app php artisan test`. + +--- + +## Dateiaufstellung + +| Datei | Verantwortung | +|---|---| +| `app/Services/Proxmox/ProxmoxClient.php` + `HttpProxmoxClient.php` + `FakeProxmoxClient.php` | **ändern** — ein einzelner `vzdump`, und Archive auflisten | +| `database/migrations/…_die_maschine_wird_archiviert_und_abgebaut.php` | **neu** — Zustand und Fundort des Archivs | +| `app/Models/Instance.php` | **ändern** | +| `app/Actions/ArchiveAndTearDown.php` | **neu** — der eigentliche Ablauf | +| `app/Console/Commands/TearDownEndedInstances.php` | **neu** — der Zeitplan-Griff | +| `routes/console.php` | **ändern** — täglich | +| `app/Livewire/Admin/Instances.php` + Ansicht | **ändern** — Archive sichtbar machen | +| `lang/{de,en}/…` | **ändern** | + +--- + +## Task 1: Ein einzelner vzdump, und die Frage, ob er wirklich da ist + +**Files:** +- Modify: `app/Services/Proxmox/ProxmoxClient.php`, `HttpProxmoxClient.php`, `FakeProxmoxClient.php` +- Test: `tests/Feature/Provisioning/OneOffBackupTest.php` + +**Interfaces:** +- Produces: `backupNow(string $node, int $vmid, string $storage): string` (liefert die UPID) +- Produces: `backupsFor(string $node, int $vmid, string $storage): array` (was wirklich auf der Ablage liegt) + +Der Client kann heute nur einen **Zeitplan** anlegen (`createBackupJob`). Für +den Abbau braucht es einen einzelnen Lauf — und, wichtiger, eine Möglichkeit +**nachzusehen, ob er etwas hinterlassen hat**. + +- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben** + +```php +backupNow('pve', 201, 'local'); + + expect($upid)->not->toBe('') + ->and($pve->backupCalls)->toHaveCount(1) + ->and($pve->backupCalls[0])->toMatchArray(['node' => 'pve', 'vmid' => 201, 'storage' => 'local']); +}); + +it('sagt, was auf der Ablage wirklich liegt', function () { + // Der Kern: nicht "der Auftrag lief", sondern "da liegt eine Datei". + // Ein vzdump, der mit einem Fehler endet, hinterlaesst auch eine + // Auftragskennung — nur eben kein Archiv. + $pve = new FakeProxmoxClient; + $pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201-2026_08_04.vma.zst', 'size' => 4096]]; + + expect($pve->backupsFor('pve', 201, 'local'))->toHaveCount(1); +}); + +it('liefert eine leere Liste, wenn nichts da ist', function () { + expect((new FakeProxmoxClient)->backupsFor('pve', 999, 'local'))->toBe([]); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +- [ ] **Schritt 3: Die beiden Methoden bauen** + +`HttpProxmoxClient::backupNow()` ruft `POST /nodes/{node}/vzdump` mit `vmid`, +`storage`, `mode` und `compress`. **`mode` ist `stop`**, nicht `snapshot`: der +Abbau fährt die Maschine vorher ohnehin herunter, und eine angehaltene Maschine +liefert das saubere Abbild ohne jede Einfrier-Frage. + +`backupsFor()` liest `GET /nodes/{node}/storage/{storage}/content` mit +`content=backup` und filtert auf die VMID. **Das ist die Methode, an der +alles hängt** — der Abbau fragt sie, bevor er löscht. + +Prüf im Bestand, wie `HttpProxmoxClient` seine Aufrufe baut (`->throw()`, +`json('data')`), und füg dich ein. Der `FakeProxmoxClient` bekommt +`public array $backupCalls` und `public array $backups` nach dem Muster seiner +übrigen Felder. + +- [ ] **Schritt 4: Laufen lassen, grün. Schritt 5: Festschreiben.** + +--- + +## Task 2: Der Datensatz merkt sich, wo das Archiv liegt + +**Files:** +- Create: `database/migrations/2026_08_05_100000_die_maschine_wird_archiviert_und_abgebaut.php` +- Modify: `app/Models/Instance.php` +- Test: `tests/Feature/Cancellation/TeardownStateTest.php` + +**Interfaces:** +- Produces: `instances.archive_volid` (`?string`), `instances.archived_at` (`?Carbon`), `instances.torn_down_at` (`?Carbon`), `instances.teardown_error` (`?string`) + +Vier Felder, und jedes beantwortet eine Frage, die man später wirklich stellt: + +- `archive_volid` — **wo** liegt es? Ohne diese Angabe ist ein Archiv nach zwölf + Monaten nicht wiederzufinden und nicht aufzuräumen. +- `archived_at` — **wann**? Daraus rechnet die Zwölf-Monats-Frist. +- `torn_down_at` — ist die Maschine wirklich weg? Getrennt von `archived_at`, + weil zwischen beiden etwas schiefgehen darf und dann sichtbar bleibt. +- `teardown_error` — was hat gehakt, in Klartext. + +- [ ] **Schritt 1–6:** Prüfung schreiben (die vier Felder existieren, sind + `null` bei einer frischen Instanz, `archived_at`/`torn_down_at` casten zu + Carbon), fehlschlagen sehen, Wanderung und Modell bauen, grün sehen, + festschreiben. Muster: die Wanderung aus Teil A + (`2026_08_04_140000_der_kunde_sagt_ob_er_einen_export_will.php`). + +--- + +## Task 3: Archivieren und abbauen — in dieser Reihenfolge, mit einem Riegel dazwischen + +**Files:** +- Create: `app/Actions/ArchiveAndTearDown.php` +- Test: `tests/Feature/Cancellation/ArchiveAndTearDownTest.php` + +**Interfaces:** +- Consumes: `backupNow()`, `backupsFor()`, `shutdownVm()`, `deleteVm()` +- Produces: `__invoke(Instance $instance): bool` + +**Das ist die Aufgabe, in der ein Fehler unwiederbringlich wäre.** Sie hat +genau eine Regel, und die steht über allem: **niemals löschen, bevor das Archiv +nachweislich existiert.** + +- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben** + +```php +backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'size' => 4096]]; + app()->instance(ProxmoxClient::class, $pve); + $instance = abgelaufeneInstanz(vmid: 201); + + expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue(); + + // Die Reihenfolge IST die Zusicherung: Herunterfahren vor dem Sichern, + // Sichern vor dem Loeschen. + expect($pve->shutdownCalls)->toHaveCount(1) + ->and($pve->backupCalls)->toHaveCount(1) + ->and($pve->deletedVmids)->toBe([201]); + + expect($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst') + ->and($instance->fresh()->torn_down_at)->not->toBeNull(); +}); + +it('LOESCHT NICHT, wenn nach dem Sichern kein Archiv daliegt', function () { + // Der Fall, der alles traegt. Ein vzdump kann mit einer Auftragskennung + // enden und trotzdem nichts hinterlassen — volle Ablage, abgebrochener + // Lauf, ein Fehler im Gast. Wer sich auf die Kennung verlaesst statt + // nachzusehen, loescht eine Maschine, deren Archiv es nicht gibt. + $pve = new FakeProxmoxClient; // backups bleibt leer + app()->instance(ProxmoxClient::class, $pve); + $instance = abgelaufeneInstanz(vmid: 201); + + expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() + ->and($pve->deletedVmids)->toBe([]) + ->and($instance->fresh()->torn_down_at)->toBeNull() + ->and($instance->fresh()->teardown_error)->not->toBeNull(); +}); + +it('loescht auch dann nicht, wenn das Herunterfahren scheitert', function () { + $pve = new FakeProxmoxClient; + $pve->shutdownThrows = true; + app()->instance(ProxmoxClient::class, $pve); + $instance = abgelaufeneInstanz(vmid: 201); + + expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() + ->and($pve->backupCalls)->toBe([]) + ->and($pve->deletedVmids)->toBe([]); +}); + +it('faellt nicht ueber eine Instanz, die schon abgebaut ist', function () { + // Wiederholbarkeit: ein zweiter Lauf nach einem Absturz darf nicht + // versuchen, eine Maschine zu loeschen, die es nicht mehr gibt. + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = abgelaufeneInstanz(vmid: 201); + $instance->update(['torn_down_at' => now()->subDay()]); + + expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue() + ->and($pve->deletedVmids)->toBe([]); +}); + +it('ruehrt eine Instanz nicht an, deren Frist noch nicht um ist', function () { + // Die vierzehn Tage sind der Sinn der Sache. Eine Maschine, die gestern + // ablief, ist die Rueckfahrkarte eines Menschen, der es sich vielleicht + // gerade anders ueberlegt. + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(3)); + + expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse() + ->and($pve->shutdownCalls)->toBe([]) + ->and($pve->deletedVmids)->toBe([]); +}); +``` + +Den Helfer `abgelaufeneInstanz()` baust du nach dem Muster der Prüfungen aus +Teil A (`tests/Feature/Cancellation/`). + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +- [ ] **Schritt 3: Die Aktion bauen** + +Der Ablauf, und jeder Schritt hat einen Riegel: + +1. **Fällig?** `status === 'ended'`, `service_ends_at` liegt mehr als vierzehn + Tage zurück, `torn_down_at` ist leer. Sonst: nichts tun. +2. **Herunterfahren** mit Zeitgrenze. Scheitert es, ist Schluss — eine Maschine, + die sich nicht sauber beenden lässt, wird nicht im Lauf gesichert. +3. **Sichern** über `backupNow()`. +4. **Nachsehen** über `backupsFor()`. **Findet sich nichts, wird nicht + gelöscht** — `teardown_error` bekommt den Grund, die Maschine bleibt stehen, + und der nächste Lauf versucht es erneut. +5. **Löschen**, `archive_volid`, `archived_at` und `torn_down_at` schreiben. + +Keine Ausnahme darf entkommen: ein Fehlschlag ist `false` plus ein Grund am +Datensatz, kein Wurf. Vorbild für Form und Ton: `app/Actions/EndInstanceService.php`. + +- [ ] **Schritt 4: Laufen lassen, grün. Schritt 5: Festschreiben.** + +--- + +## Task 4: Der Zeitplan-Griff + +**Files:** +- Create: `app/Console/Commands/TearDownEndedInstances.php` +- Modify: `routes/console.php` +- Test: `tests/Feature/Console/TearDownEndedInstancesTest.php` + +Vorbild ist `app/Console/Commands/EndDueServices.php` — er macht den Schritt +davor. Gleiche Form: `--dry-run`, Auswahl in der Abfrage, Entscheidung in der +Aktion, Zusammenfassung am Ende, und sagen, was übersprungen wurde und warum. + +- [ ] **Prüfungen:** unter `--dry-run` wird nachweislich nichts angefasst; eine + Instanz innerhalb der vierzehn Tage bleibt stehen; eine ohne Host wird mit + Grund übersprungen; ein Fehlschlag bei einer Instanz bricht den Lauf nicht ab. + +- [ ] **Zeitplan:** täglich. Der Abbau hat keinen Moment, auf den es ankommt — + anders als das Laufzeitende, das stündlich geprüft wird. Begründung in den + Kommentar, wie beim Nachbarn. + +--- + +## Task 5: Der Betreiber sieht, was archiviert wurde + +**Files:** +- Modify: `app/Livewire/Admin/Instances.php` und die Ansicht +- Modify: `lang/{de,en}/admin.php` +- Test: erweitern + +Teil A hat gezeigt, was passiert, wenn ein Zustand nur in der Datenbank steht: +die Frage nach dem Export war ein Formular ohne Empfänger, bis ein Kasten in +der Konsole dazukam. + +Dasselbe gilt hier, und zwar für **zwei** Fragen: + +- **Was ist abgebaut worden, und wo liegt das Archiv?** Ohne diese Angabe ist + eine Wiederherstellung eine Suche. +- **Was hat gehakt?** Eine Instanz mit gefülltem `teardown_error` steht + unbegrenzt und bindet weiter einen Platz. Wenn niemand sie sieht, war der + ganze Plan umsonst. + +Der zweite Punkt ist der wichtigere. Bau ihn so, dass ein hängender Abbau +**auffällt**, nicht so, dass man ihn finden könnte. + +--- + +## Selbstdurchsicht + +| Anforderung aus dem Entwurf | Aufgabe | +|---|---| +| Vierzehn Tage nach Laufzeitende | 3, 4 | +| Herunterfahren, archivieren, löschen | 3 | +| Niemals löschen ohne nachgewiesenes Archiv | 3 | +| Archiv auffindbar (wo, wann) | 2, 5 | +| Fehlschlag sichtbar | 2, 5 | + +**Was B1 ausdrücklich nicht tut** + +- **Kein Kunden-Export.** Der braucht ein Ziel ausserhalb des Hosts (B2). +- **Kein Aufräumen nach zwölf Monaten.** Das ist ein eigener, kleiner Zeitplan + und gehört zu dem Zeitpunkt gebaut, an dem das erste Archiv alt genug wird — + vorher ist es Code ohne Gegenstück. **Aber `archived_at` wird jetzt schon + geschrieben**, damit die Frist später überhaupt rechenbar ist. +- **Keine Spiegelung auf den Speicherserver.** Das Archiv liegt zunächst dort, + wo die nächtlichen Sicherungen liegen. Der zweite Ort kommt mit dem Modul, + das ihn ohnehin verkauft. + +--- + +## Eine Frage, die vor Task 3 beantwortet gehört + +**Auf welche Ablage geht das Archiv?** Der Plan sagt `local`, weil die +nächtlichen Sicherungen dorthin gehen (`RegisterBackup`). Das ist eine +Annahme über die Einrichtung der Hosts, keine gemessene Tatsache. + +Der Umsetzer von Task 1 soll `nodeStorage()` gegen einen echten Host fahren — +oder, wenn das nicht geht, den Namen als Einstellung führen mit `local` als +Vorgabe. **Ein fest verdrahteter Ablagename, den es auf einem Host nicht gibt, +lässt jeden Abbau scheitern, und zwar erst dann, wenn er gebraucht wird.**