diff --git a/app/Models/Instance.php b/app/Models/Instance.php index 2b999ea..f8d35e0 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -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', ]; } diff --git a/database/migrations/2026_08_05_100000_die_maschine_wird_archiviert_und_abgebaut.php b/database/migrations/2026_08_05_100000_die_maschine_wird_archiviert_und_abgebaut.php new file mode 100644 index 0000000..7335a54 --- /dev/null +++ b/database/migrations/2026_08_05_100000_die_maschine_wird_archiviert_und_abgebaut.php @@ -0,0 +1,46 @@ +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']); + }); + } +}; diff --git a/tests/Feature/Cancellation/TeardownStateTest.php b/tests/Feature/Cancellation/TeardownStateTest.php new file mode 100644 index 0000000..88817f6 --- /dev/null +++ b/tests/Feature/Cancellation/TeardownStateTest.php @@ -0,0 +1,113 @@ +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(); +});