Vier Spalten zur Verfolgung von Archivierung und Abbau von Instanzen

archive_volid: speichert den Ort des Archivs, ohne den es nach 12 Monaten
nicht wiederzufinden ist.
archived_at: speichert den Zeitpunkt der Archivierung, daraus berechnet sich
die 12-Monats-Frist.
torn_down_at: speichert den Zeitpunkt der Maschinenlöschung, getrennt von
archived_at, damit der Zwischenstand sichtbar bleibt, wenn Archivierung
erfolgreich war aber Löschung noch nicht versucht oder gescheitert ist.
teardown_error: speichert die Fehlermeldung beim Löschen in Klartext, damit
ein Betreiber ohne Log-Suche reagieren kann.

Vier Tests zeigen, dass die Felder auf null stehen, in Carbon casten und
dass der Zwischenstand (archiviert, nicht abgebaut) ein gültiger Zustand ist.
Eine Mutation-Prüfung bestätigt, dass die Tests greifen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 10:55:44 +02:00
parent 6ab868c522
commit 91d3ce4064
3 changed files with 162 additions and 0 deletions

View File

@ -23,6 +23,7 @@ class Instance extends Model
'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at',
'route_written', 'routed_hostnames', 'routed_backend', 'cert_ok', 'status', 'suspended_at', 'cancel_requested_at', 'service_ends_at',
'export_wish', 'export_reminded_at',
'archive_volid', 'archived_at', 'torn_down_at', 'teardown_error',
'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures',
'security_log_offset',
];
@ -62,6 +63,8 @@ class Instance extends Model
// deshalb unterstellt eine Bestandsinstanz ohne Antwort kein "nein".
'export_wish' => 'boolean',
'export_reminded_at' => 'datetime',
'archived_at' => 'datetime',
'torn_down_at' => 'datetime',
'restart_required_since' => 'datetime',
];
}

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Vier Felder, um den Weg von der gekündigten Instanz zum Archiv und von da zur Löschung
* nachzuverfolgen Fragen, die man später wirklich stellt, wenn nichts mehr läuft.
*
* `archive_volid` antwortet auf: **Wo** liegt das Archiv? Ohne diese Angabe ist es nach
* zwölf Monaten nicht wiederzufinden und nicht aufzuräumen. Ein Archiv ohne Ort ist keins.
*
* `archived_at` antwortet auf: **Wann** wurde es gelagert? Daraus rechnet die
* Zwölf-Monats-Frist, nach der ein Archiv selbst gelöscht wird.
*
* `torn_down_at` antwortet auf: Ist die Maschine **wirklich weg**? Getrennt von
* `archived_at`, weil zwischen beiden etwas schiefgehen darf und dann sichtbar bleiben
* muss eine Instanz mit `archived_at` gesetzt und `torn_down_at` leer ist ein gültiger,
* aussagekräftiger Zwischenstand, der zeigt „Sicherung war erfolgreich, Löschung noch nicht
* versucht" oder „Sicherung erfolgreich, Löschung gescheitert". Ein einziges Feld würde
* den Fehlerfall unsichtbar machen.
*
* `teardown_error` antwortet auf: Was hat gehakt beim Löschen, in Klartext? Damit ein
* Betreiber ohne Schleife über Logs einen Fehler findet und Maßnahmen trifft, statt zu
* rätseln, ob eine Löschung überhaupt versucht wurde.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->string('archive_volid')->nullable()->after('service_ends_at');
$table->timestamp('archived_at')->nullable()->after('archive_volid');
$table->timestamp('torn_down_at')->nullable()->after('archived_at');
$table->text('teardown_error')->nullable()->after('torn_down_at');
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn(['archive_volid', 'archived_at', 'torn_down_at', 'teardown_error']);
});
}
};

View File

@ -0,0 +1,113 @@
<?php // tests/Feature/Cancellation/TeardownStateTest.php
use App\Models\Instance;
use Illuminate\Support\Carbon;
it('eine frische Instanz weiss noch gar nichts über Archivierung oder Abbau', function () {
// Vier Felder, vier Zustände. Eine Instanz, die zum ersten Mal sichtbar wird, hat noch
// kein Archiv (archive_volid null), wurde nicht archiviert (archived_at null), wurde
// nicht abgebaut (torn_down_at null) und kein Fehler hat gehakt (teardown_error null).
// Das ist der gültige Normalzustand: noch am Leben.
$instance = Instance::factory()->create();
expect($instance->archive_volid)->toBeNull()
->and($instance->archived_at)->toBeNull()
->and($instance->torn_down_at)->toBeNull()
->and($instance->teardown_error)->toBeNull();
});
it('merkt sich, wo das Archiv liegt', function () {
$volid = '/mnt/archiv-2026-08-04/instance-abc123';
$instance = Instance::factory()->create(['archive_volid' => $volid]);
expect($instance->fresh()->archive_volid)->toEqual($volid);
});
it('speichert und gibt archived_at als Carbon zurück', function () {
// archived_at kommt als Datenbankzeichenkette an und muss als Carbon-Instanz wieder
// herauskommen — ein Objekt, nicht eine Zeichenkette, sonst kann ein Aufrufer
// Vergleiche, Arithmetik und Formatierungen nicht durchführen.
$now = now();
$instance = Instance::factory()->create(['archived_at' => $now]);
expect($instance->fresh()->archived_at)
->toBeInstanceOf(Carbon::class)
->and($instance->fresh()->archived_at->timestamp)->toBe($now->timestamp);
});
it('speichert und gibt torn_down_at als Carbon zurück', function () {
// torn_down_at kommt als Datenbankzeichenkette an und muss als Carbon-Instanz wieder
// herauskommen — ein Objekt, nicht eine Zeichenkette, sonst kann ein Aufrufer
// Vergleiche, Arithmetik und Formatierungen nicht durchführen.
$now = now();
$instance = Instance::factory()->create(['torn_down_at' => $now]);
expect($instance->fresh()->torn_down_at)
->toBeInstanceOf(Carbon::class)
->and($instance->fresh()->torn_down_at->timestamp)->toBe($now->timestamp);
});
it('speichert die Fehlermeldung beim Abbau', function () {
// teardown_error hält den Fehler fest, der beim Löschen gehakt hat — in Klartext,
// damit ein Betreiber ihn findet, ohne erst auf die Logs zu schauen.
$error = 'SSH-Verbindung zum Host timeout nach 30 Sekunden';
$instance = Instance::factory()->create(['teardown_error' => $error]);
expect($instance->fresh()->teardown_error)->toEqual($error);
});
it('ein gültiger Zwischenstand: archiviert aber nicht abgebaut', function () {
// Das ist der entscheidende Zwischenstand, der erklärt, warum es zwei Felder sind statt
// einem einzigen. Eine Instanz mit `archived_at` gesetzt und `torn_down_at` leer
// bedeutet: „Sicherung war erfolgreich, Löschung noch nicht versucht" oder „Sicherung
// erfolgreich, Löschung gescheitert — sieh teardown_error". Ein einziges Feld würde
// den Unterschied zwischen „noch nicht archiviert" und „archiviert aber noch nicht
// gelöscht" unsichtbar machen.
$archived = now()->subDays(5);
$instance = Instance::factory()->create([
'archive_volid' => '/mnt/archiv/instance-123',
'archived_at' => $archived,
'torn_down_at' => null,
'teardown_error' => null,
]);
expect($instance->fresh()->archived_at->timestamp)->toBe($archived->timestamp)
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->toBeNull();
});
it('ein fehlgeschlagener Abbau ist sichtbar', function () {
// Eine Instanz mit `archived_at` und `torn_down_at` beide gesetzt bedeutet: Archivierung
// UND Löschung waren erfolgreich. Eine mit `archived_at` gesetzt und `torn_down_at`
// leer aber `teardown_error` gefüllt bedeutet: Löschung hat gehakt. Der Fehler ist
// dokumentiert und der Betreiber kann reagieren.
$archived = now()->subDays(5);
$error = 'Löschen des Snapshots gescheitert: Volume ist noch in Verwendung';
$instance = Instance::factory()->create([
'archive_volid' => '/mnt/archiv/instance-123',
'archived_at' => $archived,
'torn_down_at' => null,
'teardown_error' => $error,
]);
expect($instance->fresh()->archived_at->timestamp)->toBe($archived->timestamp)
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->toEqual($error);
});
it('ein vollständiger Durchlauf: archiviert und abgebaut', function () {
// Die „glückliche" Sequenz: Archivierung erfolgreich, Löschung erfolgreich. Beide
// Zeitstempel sind gesetzt, kein Fehler.
$archived = now()->subDays(10);
$tornDown = now()->subDays(5);
$instance = Instance::factory()->create([
'archive_volid' => '/mnt/archiv/instance-123',
'archived_at' => $archived,
'torn_down_at' => $tornDown,
'teardown_error' => null,
]);
expect($instance->fresh()->archived_at->timestamp)->toBe($archived->timestamp)
->and($instance->fresh()->torn_down_at->timestamp)->toBe($tornDown->timestamp)
->and($instance->fresh()->teardown_error)->toBeNull();
});