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',
|
'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',
|
'route_written', 'routed_hostnames', 'routed_backend', 'cert_ok', 'status', 'suspended_at', 'cancel_requested_at', 'service_ends_at',
|
||||||
'export_wish', 'export_reminded_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',
|
'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures',
|
||||||
'security_log_offset',
|
'security_log_offset',
|
||||||
];
|
];
|
||||||
|
|
@ -62,6 +63,8 @@ class Instance extends Model
|
||||||
// deshalb unterstellt eine Bestandsinstanz ohne Antwort kein "nein".
|
// deshalb unterstellt eine Bestandsinstanz ohne Antwort kein "nein".
|
||||||
'export_wish' => 'boolean',
|
'export_wish' => 'boolean',
|
||||||
'export_reminded_at' => 'datetime',
|
'export_reminded_at' => 'datetime',
|
||||||
|
'archived_at' => 'datetime',
|
||||||
|
'torn_down_at' => 'datetime',
|
||||||
'restart_required_since' => '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