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
parent
022279a93c
commit
df64b17c77
|
|
@ -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 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
|
||||
<?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.**
|
||||
Loading…
Reference in New Issue