CluPilotCloud/tests/Feature/Cancellation/ArchiveAndTearDownTest.php

658 lines
31 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;
use Illuminate\Support\Facades\Cache;
/**
* 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;
// Die Maschine gibt es, und sie läuft — der gewöhnliche Eingangszustand.
// Ohne das fragt die Attrappe zu Recht zurück, warum ein stehender Gast
// heruntergefahren werden soll.
$pve->clonedVmids = [201];
$pve->runningVmids = [201];
$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])
// Und der Abbau ist wirklich vollzogen, nicht nur beauftragt: der
// qmdestroy-Auftrag ist durchgelaufen.
->and($pve->destroyedVmids)->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->clonedVmids = [201];
$pve->runningVmids = [201];
$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([])
->and($instance->fresh()->teardown_error)->not->toBeNull();
});
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([])
->and($instance->fresh()->teardown_error)->toContain('local');
});
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 gegen mehrere Archive, von denen keines einen Zeitpunkt nennt', function () {
// Auf `local` liegen neben dem frischen Dump die nächtlichen Sicherungen
// derselben VM. Fehlt allen der Zeitpunkt, lassen sie sich nicht ordnen —
// vermerkt würde irgendeines, womöglich das von vorgestern. Eine Reihe, die
// sich nicht sortieren lässt, ist keine Grundlage für ein Löschen. Ein
// EINZELNES Archiv ohne Zeitpunkt bleibt dagegen gültig; das ist der Fall
// der ersten Prüfung dieser Datei.
$pve = new FakeProxmoxClient;
$pve->backups['201'] = [
['volid' => 'local:backup/vzdump-qemu-201-a.vma.zst', 'size' => 4096],
['volid' => 'local:backup/vzdump-qemu-201-b.vma.zst', 'size' => 4096],
];
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()->archive_volid)->toBeNull()
->and($instance->fresh()->teardown_error)->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.
//
// Hier wird schon die ANFRAGE abgewiesen (Proxmox antwortet nicht mit 200).
// Der andere Weg — Anfrage angenommen, Auftrag scheitert danach — steht
// weiter unten und war der stille von beiden.
$pve = new class extends FakeProxmoxClient
{
public function deleteVm(string $node, int $vmid): string
{
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('bucht keine Maschine als abgebaut, wenn der Knoten gar nicht antwortet', function () {
// Die Eingangslage entsteht im Regelbetrieb: ein Lauf hat gesichert und ist
// am Löschen gescheitert, weil der Knoten hängt. Am nächsten Tag steht
// `archive_volid` am Datensatz, und `vmExists()` — ein GET ohne `->throw()` —
// antwortet wegen desselben hakenden Knotens mit „nein". Ohne ein
// Lebenszeichen davor würde hier eine LAUFENDE Kundenmaschine als abgebaut
// verbucht und danach nie wieder angesehen.
$pve = new class extends FakeProxmoxClient
{
public function nodeStorage(string $node): array
{
throw new RuntimeException('Proxmox: 595 Connection refused');
}
};
app()->instance(ProxmoxClient::class, $pve);
$instance = abgelaufeneInstanz(vmid: 201);
$instance->update([
'archive_volid' => 'local:backup/vzdump-qemu-201.vma.zst',
'archived_at' => now()->subDay(),
'teardown_error' => 'Proxmox: VM is locked (backup)',
]);
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->toContain('Connection refused');
});
it('lässt keinen zweiten Lauf über dieselbe Instanz', function () {
// Zwei Läufe nebeneinander — der Zeitplan und ein Betreiber von Hand —
// würden zweimal sichern und zweimal löschen; der zweite fände dabei eine
// Maschine vor, die der erste gerade wegnimmt. Hier hält bereits jemand die
// Sperre, und der Lauf hat nichts hinzuzufügen.
$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);
$fremdeSperre = Cache::lock('instance-teardown:'.$instance->uuid, 60);
expect($fremdeSperre->get())->toBeTrue();
expect(app(ArchiveAndTearDown::class)($instance))->toBeFalse()
->and($pve->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toBe([])
->and($pve->deletedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull();
// Und die fremde Sperre steht noch: der abgewiesene Lauf gibt im `finally`
// nicht frei, was ihm nie gehört hat.
expect(Cache::lock('instance-teardown:'.$instance->uuid, 60)->get())->toBeFalse();
$fremdeSperre->release();
});
// ---- Der Platz wird wieder verkaufbar --------------------------------------
it('gibt den Platz auf dem Host wirklich wieder frei', function () {
// Der Zweck des ganzen Vorhabens, und die Prüfung, die gefehlt hat.
//
// Eine abgebaute Instanz behält `status = 'ended'`, ihre `vmid` und ihr
// `disk_gb` — der Datensatz bleibt als Nachweis stehen, wo das Archiv
// liegt. Zählte sie deshalb weiter als belegend, wäre der Knoten physisch
// leer und die Buchhaltung voll: die nächste bezahlte Bestellung derselben
// Grösse fände keinen Platz, würde geparkt und verworfen, und das Paket
// verschwände vom Angebot der Landingpage. Der Abbau löschte Kundenplatten
// und brächte nichts ein.
$pve = new FakeProxmoxClient;
$pve->clonedVmids = [201];
$pve->runningVmids = [201];
$pve->backups['201'] = [['volid' => 'local:backup/vzdump-qemu-201.vma.zst', 'ctime' => now()->timestamp]];
app()->instance(ProxmoxClient::class, $pve);
$host = Host::factory()->active()->create(['node' => 'pve', 'datacenter' => 'fsn']);
$instance = Instance::factory()->create([
'host_id' => $host->id,
'vmid' => 201,
'disk_gb' => 540,
'status' => 'ended',
'service_ends_at' => now()->subDays(20),
]);
// Vorher: die Platte ist gebucht, und ein Paket derselben Grösse passt
// nicht mehr auf diesen Host.
expect($host->fresh()->committedGb())->toBe(540)
->and(Host::placeableIn('fsn', 540))->toBeNull();
expect(app(ArchiveAndTearDown::class)($instance))->toBeTrue();
// Nachher: nichts mehr gebucht, der ganze freie Platz wieder zu haben, und
// dieselbe Bestellung findet ihren Host.
expect($host->fresh()->committedGb())->toBe(0)
->and($host->fresh()->availableGb())->toBe($host->fresh()->freeGb())
->and(Host::placeableIn('fsn', 540)?->id)->toBe($host->id);
});
it('zählt eine abgebaute Instanz nicht mehr als belegend', function () {
// Dieselbe Regel eine Ebene tiefer, ohne Proxmox: es ist der Zeitstempel
// `torn_down_at`, der die Zeile aus der Summe nimmt, und nicht ein
// geleertes `vmid`. Wer statt seiner die VMID nullte, liesse
// `status != 'failed'` allein die Zeile in der Summe halten — der Platz
// bliebe belegt, und niemandem fiele es auf.
$host = Host::factory()->active()->create();
$abgebaut = Instance::factory()->create([
'host_id' => $host->id, 'vmid' => 201, 'disk_gb' => 300,
'status' => 'ended', 'archive_volid' => 'local:backup/vzdump-qemu-201.vma.zst',
'archived_at' => now(), 'torn_down_at' => now(),
]);
Instance::factory()->create([
'host_id' => $host->id, 'vmid' => 202, 'disk_gb' => 40, 'status' => 'active',
]);
expect($host->fresh()->committedGb())->toBe(40);
// Und die Gegenprobe: solange sie nicht abgebaut ist, zählt genau dieselbe
// gekündigte Instanz mit — die Frist von vierzehn Tagen ist bezahlter Platz.
$abgebaut->update(['torn_down_at' => null]);
expect($host->fresh()->committedGb())->toBe(340);
});
// ---- Das Löschen ist ein Auftrag, kein Vollzug -----------------------------
it('bucht keinen Abbau, wenn der Zerstörungsauftrag hinterher scheitert', function () {
// `DELETE /nodes/…/qemu/…` prüft synchron nur Schutzflagge und
// HA-Verwendung, forkt dann `qmdestroy` und antwortet mit 200. Scheitert
// die Zerstörung dreissig Sekunden später — eine fremde Sicherung hält die
// Ablage —, steht die Maschine, liegt die Platte, und ein auf das 200
// gebuchtes `torn_down_at` nähme sie für immer aus jeder Auswahl und jedem
// Kasten. Der teuerste stille Fehlschlag dieses Ablaufs.
$pve = new FakeProxmoxClient;
$pve->clonedVmids = [201];
$pve->runningVmids = [201];
$pve->destroyExitStatus = '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()
// Beauftragt ja, vollzogen nein — und genau das ist der Unterschied.
->and($pve->deletedVmids)->toBe([201])
->and($pve->destroyedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->toContain('locked')
// Das Archiv bleibt vermerkt: der Dump ist gelaufen und soll nicht ein
// zweites Mal bezahlt werden.
->and($instance->fresh()->archive_volid)->toBe('local:backup/vzdump-qemu-201.vma.zst');
});
it('bucht keinen Abbau, solange der Zerstörungsauftrag noch läuft', function () {
// Dieselbe Lage in langsam: der Auftrag hängt an einer trägen Ablage. Wer
// nicht abwartet, schreibt „abgebaut" über eine Maschine, deren Platten
// gerade erst weggeräumt werden — und erfährt nie, ob es geklappt hat.
$pve = new FakeProxmoxClient;
$pve->clonedVmids = [201];
$pve->runningVmids = [201];
$pve->destroyHangs = true;
$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 zehn Minuten wartet.
$abbau = app(ArchiveAndTearDown::class);
$abbau->deleteWaitSeconds = 0;
expect($abbau($instance))->toBeFalse()
->and($pve->destroyedVmids)->toBe([])
->and($instance->fresh()->torn_down_at)->toBeNull()
->and($instance->fresh()->teardown_error)->toContain('Das Löschen');
});
// ---- Der stehende Gast -----------------------------------------------------
it('bittet einen bereits stehenden Gast nicht ums Herunterfahren', function () {
// Der Riegel, den der Nachbarschritt ShutDownVirtualMachine seit jeher hat.
// `shutdownVm()` ruft im echten Client `->throw()`, und eine stehende
// Maschine ist hier kein Rand: eine wegen offener Zahlung gesperrte Cloud
// steht seit Wochen still, wenn ihr Kunde kündigt. Ohne den Riegel wäre
// ihr Abbau unheilbar — jede Nacht derselbe Wurf.
$pve = new FakeProxmoxClient;
$pve->clonedVmids = [201]; // die Maschine gibt es …
$pve->runningVmids = []; // … sie läuft nur nicht
$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->shutdownCalls)->toBe([])
->and($pve->backupCalls)->toHaveCount(1)
->and($pve->destroyedVmids)->toBe([201])
->and($instance->fresh()->torn_down_at)->not->toBeNull()
->and($instance->fresh()->teardown_error)->toBeNull();
});
it('kommt im zweiten Lauf nach einem Teilfehlschlag durch', function () {
// Der Satz „der nächste Lauf sieht erneut nach" steht an fünf Stellen im
// Code und war nirgends belegt — kein Test fuhr je einen zweiten Lauf.
//
// Die Lage entsteht im Regelbetrieb: der erste Lauf fährt herunter,
// sichert, und scheitert am Zerstören. Die Maschine bleibt STEHEN —
// niemand startet sie wieder. In der nächsten Nacht ist das
// Herunterfahren die erste Stufe und trifft einen gestoppten Gast.
$pve = new FakeProxmoxClient;
$pve->clonedVmids = [201];
$pve->runningVmids = [201];
$pve->destroyExitStatus = '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()->teardown_error)->toContain('locked')
// Und sie steht seither: `mode=stop` startet nichts wieder.
->and($pve->runningVmids)->toBe([]);
// Die nächste Nacht. Die fremde Sicherung ist inzwischen durch.
$pve->destroyExitStatus = null;
expect(app(ArchiveAndTearDown::class)($instance->fresh()))->toBeTrue()
// Kein zweites Herunterfahren — und vor allem kein Wurf dabei.
->and($pve->shutdownCalls)->toHaveCount(1)
->and($pve->destroyedVmids)->toBe([201])
->and($instance->fresh()->torn_down_at)->not->toBeNull()
// Der alte Grund ist fort, nicht überschrieben: der Abbau ist durch.
->and($instance->fresh()->teardown_error)->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();
});