CluPilotCloud/tests/Feature/Cancellation/ArchiveAndTearDownTest.php

392 lines
18 KiB
PHP

<?php // tests/Feature/Cancellation/ArchiveAndTearDownTest.php
use App\Actions\ArchiveAndTearDown;
use App\Models\Host;
use App\Models\Instance;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Carbon;
/**
* Eine Instanz im Zustand NACH dem Laufzeitende, so wie ihn
* EndInstanceService hinterlässt (status 'ended', Adresse weg), und mit der
* Frist von vierzehn Tagen längst um — nach dem Muster von
* gekuendigteInstanz() aus DashboardCountdownTest.
*
* Anders als dort mit Host und VMID, denn hier geht es um die Maschine selbst:
* ohne beides gibt es nichts abzubauen.
*/
function abgelaufeneInstanz(int $vmid, ?Carbon $endeteVor = null): Instance
{
$host = Host::factory()->active()->create(['node' => 'pve']);
return Instance::factory()->create([
'host_id' => $host->id,
'vmid' => $vmid,
'status' => 'ended',
'service_ends_at' => $endeteVor ?? now()->subDays(20),
]);
}
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([]);
});
it('rührt eine Instanz nicht an, solange der vierzehnte Tag noch läuft', function () {
// Der Rand der Frist, fünf Minuten davor. Er gehört noch dem Kunden: eine
// Frist, die einen Tag zu früh zuschlägt, nimmt jedem Menschen einen ganzen
// Tag seiner Rückfahrkarte weg — und niemandem fiele es auf.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(14)->addMinutes(5));
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('baut ab, sobald der vierzehnte Tag um ist', function () {
// Die andere Seite desselben Randes, fünf Minuten danach. Beide zusammen
// halten die Frist fest: verschöbe sie jemand um einen Tag — in welche
// Richtung auch immer —, wird eine der beiden Prüfungen rot.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201, endeteVor: now()->subDays(14)->subMinutes(5));
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->deletedVmids)->toBe([201]);
});
it('rührt eine Instanz nicht an, deren Dienst gar nicht beendet ist', function () {
// Eine laufende Instanz mit einem alten service_ends_at ist kein Abbaufall:
// solange EndInstanceService sie nicht auf 'ended' gesetzt hat, ist die
// Adresse noch da und jemand arbeitet damit. Nur der eine Zustand zählt,
// nicht das Datum allein.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update(['status' => 'active']);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('fährt gar nichts herunter, wenn es die eingestellte Ablage auf dem Knoten nicht gibt', function () {
// Die Ablage ist eine Annahme über die Einrichtung der Hosts, kein Wissen.
// Ein Name, den es dort nicht gibt, muss laut scheitern — und zwar bevor
// eine Kundenmaschine dafür heruntergefahren wurde.
$pve = new FakeProxmoxClient;
$pve->storage = [['storage' => 'local-lvm', 'type' => 'lvmthin', 'content' => 'images,rootdir']];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->toContain('local');
});
it('fährt gar nichts herunter, wenn die Ablage keine Sicherungen aufnimmt', function () {
// Es gibt sie, sie heißt richtig — und sie darf keine Sicherungen tragen.
// Ohne diese Prüfung fiele das erst beim vzdump auf, mit einer bereits
// heruntergefahrenen Kundenmaschine daneben.
$pve = new FakeProxmoxClient;
$pve->storage = [['storage' => 'local', 'type' => 'dir', 'content' => 'iso,vztmpl']];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([]);
});
it('sichert nicht, wenn der Gast nach dem Herunterfahren weiterläuft', function () {
// shutdownVm() liefert eine Auftragskennung, keine Zusage. Ein Gast, der
// ACPI ignoriert, läuft weiter — und eine laufende Datenbank zu sichern
// und dann zu löschen hieße, ein Archiv mit halb geschriebenen Tabellen
// aufzubewahren.
$pve = new FakeProxmoxClient;
$pve->shutdownIgnored = true;
$pve->runningVmids = [201];
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toHaveCount(1)
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('löscht nicht gegen ein Archiv von gestern', function () {
// Der zweite Weg, an dem die reine Ja/Nein-Frage „liegt da etwas?"
// vorbeigeht: die nächtliche Sicherung liegt auf derselben Ablage. Gegen
// sie zu löschen hieße, ein Archiv aufzubewahren, dem der letzte Tag
// fehlt — und es fiele erst auf, wenn es zu spät ist.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [[
'volid' => 'local:backup/vzdump-qemu-201-gestern.vma.zst',
'ctime' => now()->subDay()->timestamp,
]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->backupCalls)->toHaveCount(1)
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->archive_volid)->toBeNull();
});
it('merkt sich das frische Archiv, nicht das danebenliegende alte', function () {
// Der Normalfall auf einer echten Ablage: die nächtliche Sicherung von
// gestern liegt neben dem Dump von gerade eben. Vermerkt werden muss der
// frische — ein Kunde, der in zwölf Monaten nach seinen Daten fragt, soll
// nicht den Stand von vorgestern bekommen.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [
['volid' => 'local:backup/vzdump-qemu-201-gestern.vma.zst', 'ctime' => now()->subDay()->timestamp],
['volid' => 'local:backup/vzdump-qemu-201-jetzt.vma.zst', 'ctime' => now()->timestamp],
];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->deletedVmids)->toBe([201])
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201-jetzt.vma.zst')
->and($instance->fresh()->archived_at)->not->toBeNull();
});
it('löscht nicht, wenn das Archiv keine Kennung trägt', function () {
// Ein Archiv, dessen Ort nicht notiert werden kann, ist in zwölf Monaten
// keins: niemand findet es wieder, und niemand räumt es je auf.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [['size' => 4096, 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('löscht nicht, wenn der Sicherungsauftrag mit einem Fehler endet', function () {
// Die volle Ablage, wie Proxmox sie meldet. Dass daneben ein älteres
// Archiv derselben VM liegt, macht den Fehlschlag nicht zum Erfolg.
$pve = new FakeProxmoxClient;
$pve->taskExitStatus = 'no space left on device';
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->toContain('no space left on device');
});
it('lässt sich von Hinweisen im Sicherungsauftrag nicht aufhalten', function () {
// Proxmox beendet einen vzdump auch mit „WARNINGS: n", wenn das Archiv
// sauber geschrieben wurde. Das als Fehlschlag zu lesen hieße, den Abbau an
// einer Meldung hängen zu lassen, die kein Archiv verhindert — der Beweis
// ist die Nachschau auf der Ablage, nicht diese Zeichenkette.
$pve = new FakeProxmoxClient;
$pve->taskExitStatus = 'WARNINGS: 1';
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->deletedVmids)->toBe([201]);
});
it('löscht nicht, solange der Sicherungsauftrag noch läuft', function () {
// Ein vzdump liefert seine Kennung sofort und arbeitet danach minutenlang
// weiter. Wer nicht abwartet, fragt die Ablage, bevor das Archiv darauf
// liegt. Läuft er über die Frist hinaus, bleibt die Maschine stehen — der
// nächste Lauf sieht erneut nach.
$pve = new class extends FakeProxmoxClient
{
public function taskStatus(string $node, string $upid): array
{
// Nur der vzdump hängt. Das Herunterfahren davor war fertig —
// sonst käme diese Prüfung nie bis zum Sichern.
return str_contains($upid, 'vzdump')
? ['status' => 'running']
: parent::taskStatus($node, $upid);
}
};
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
// Ohne Wartezeit, damit die Prüfung nicht wirklich eine Stunde wartet.
$abbau = app(ArchiveAndTearDown::class);
$abbau->backupWaitSeconds = 0;
expect($abbau($instance))->toBeFalse()
->and($pve->backupCalls)->toHaveCount(1)
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('löscht nicht, wenn die Instanz zwischen Sichern und Löschen wiederbelebt wird', function () {
// Zwischen dem Dump und dem Löschen liegen Minuten. Wird die Instanz in
// dieser Zeit von jemandem angefasst, ist sie keine, die gelöscht werden
// darf — die gemerkte Kopie im Speicher weiß davon nichts, also wird der
// Datensatz unmittelbar davor neu gelesen.
$pve = new class extends FakeProxmoxClient
{
public ?Instance $wiederbelebt = null;
public function backupsFor(string $node, int $vmid, string $storage): array
{
$this->wiederbelebt?->update(['status' => 'active']);
return parent::backupsFor($node, $vmid, $storage);
}
};
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
// Eine ZWEITE Kopie desselben Datensatzes, so wie sie ein anderer Vorgang
// hätte — die Aktion darf ihrer eigenen nicht glauben.
$pve->wiederbelebt = Instance::find($instance->id);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
// Das Archiv existiert wirklich und bleibt vermerkt: der Dump ist
// gelaufen, und ihn zu vergessen hieße, ihn beim nächsten Lauf noch
// einmal zu bezahlen.
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst')
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
it('behält das Archiv am Datensatz, wenn das Löschen scheitert', function () {
// Der Zwischenstand, für den es zwei Zeitstempel gibt: gesichert ja,
// gelöscht nein. Ginge archive_volid dabei verloren, liefe der nächste Lauf
// gegen dieselbe Maschine noch einen Dump — und der erste läge unauffindbar
// auf der Ablage.
$pve = new class extends FakeProxmoxClient
{
public function deleteVm(string $node, int $vmid): void
{
throw new RuntimeException('Proxmox: VM is locked (backup)');
}
};
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst')
->and($instance->fresh()->archived_at)->not->toBeNull()
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->toContain('locked');
});
it('trägt den Abbau nach, wenn die Maschine schon fort ist und das Archiv feststeht', function () {
// Der Absturz im Millisekundenfenster zwischen dem Löschen und dem
// Vermerk. Ohne diesen Weg zurück versuchte jeder weitere Lauf, einen
// Geist herunterzufahren, und die Instanz bliebe für immer im Fehler
// stehen. Es wird dabei nichts gelöscht — es gibt nichts mehr.
$pve = new FakeProxmoxClient; // kennt vmid 201 nicht, vmExists() ist falsch
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update([
'archive_volid' => 'local:backup/vzdump-qemu-201.vma.zst',
'archived_at' => now()->subMinutes(5),
]);
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue()
->and($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->not->toBeNull();
});
it('rührt eine Instanz ohne Maschine nicht an', function () {
// Ein Datensatz ohne VMID hat nichts, was sich sichern oder löschen ließe.
// Er wird nicht stillschweigend als abgebaut gebucht: das wäre ein Haken an
// einer Maschine, die vielleicht doch irgendwo läuft.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update(['vmid' => null]);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->not->toBeNull();
});