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
parent
6ab868c522
commit
91d3ce4064
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue