Plan: Kuendigung Teil B1 — der Abbau

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.
feat/versandtakt
nexxo 2026-08-04 10:40:33 +02:00
parent 022279a93c
commit df64b17c77
1 changed files with 363 additions and 0 deletions

View File

@ -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
<?php // tests/Feature/Provisioning/OneOffBackupTest.php
use App\Services\Proxmox\FakeProxmoxClient;
it('faehrt einen einzelnen vzdump und liefert die Auftragskennung', function () {
$pve = new FakeProxmoxClient;
$upid = $pve->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 16:** 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
<?php // tests/Feature/Cancellation/ArchiveAndTearDownTest.php
use App\Actions\ArchiveAndTearDown;
use App\Models\Instance;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
it('faehrt herunter, archiviert, prueft nach, und loescht erst dann', function () {
$pve = new FakeProxmoxClient;
$pve->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.**