feat(verlauf): Filter, Wiederholen und Aufräumen

Verlauf filterbar (URL, API, proxdeploy history); Wiederholen füllt den Assistenten aus einem alten Auftrag; Aufräumen löscht nach Fehlschlag oder Abbruch den halb angelegten Gast – nur, wenn dessen Beschreibung auf den Auftrag verweist. Deshalb setzt das Klonen die Beschreibung jetzt sofort. Auf pve-demo geprüft (CT 109).

Closes #014

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:39:58 +02:00
co-authored by Claude Opus 5.5
parent 8dca30e254
commit 7f6cf00a0e
32 changed files with 1488 additions and 53 deletions
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, describe, expect, it } from "vitest";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { aufraeumSchritte } from "@/engine/ablaeufe/aufraeumen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten } from "../proxmox-mock/szenario";
const UPID = (art: string) => `UPID:pve02:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`;
const aufraeumen: Array<() => Promise<void>> = [];
afterEach(async () => {
while (aufraeumen.length) await aufraeumen.pop()!();
});
type Gast = {
type: "qemu" | "lxc";
node: string;
status: string;
template?: number;
config: Record<string, unknown>;
};
async function aufbauen(gast: Gast | null) {
const mock = await new ProxmoxMock().starten();
szenarioEinrichten(mock, { version: "9.2.1" });
mock
.route("GET", "/cluster/resources", () => ({
data: [
{ vmid: 100, type: "qemu", node: "pve01", status: "running", name: "anderer" },
...(gast
? [
{
vmid: 120,
type: gast.type,
node: gast.node,
status: gast.status,
name: "web01",
template: gast.template ?? 0,
},
]
: []),
],
}))
.route("GET", /^\/nodes\/pve02\/(qemu|lxc)\/120\/config$/, () => ({ data: gast?.config ?? {} }))
.route("POST", /^\/nodes\/pve02\/(qemu|lxc)\/120\/status\/stop$/, () => ({ data: UPID("qmstop") }))
.route("DELETE", /^\/nodes\/pve02\/(qemu|lxc)\/120$/, () => ({ data: UPID("qmdestroy") }))
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }));
const client = new ProxmoxClient({
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
});
aufraeumen.push(async () => {
await client.schliessen();
await mock.stoppen();
});
const phasen: Record<string, { status: string; detail?: string; ursache?: string }> = {};
const senke: EreignisSenke = {
phasenAnlegen: async () => {},
phaseGestartet: async () => {},
phaseDetail: async () => {},
phaseBeendet: async (s, status, a) => {
phasen[s] = { status, detail: a.detail, ursache: a.fehler?.ursache };
},
log: async () => {},
};
const ausfuehren = (typ: "VM" | "LXC" = "VM") =>
phasenAusfuehren(
aufraeumSchritte({ client, typ, vmid: 120, knoten: "pve01", auftragKurzId: "d-abcde" }),
senke,
);
const schreibend = () =>
mock.anfragen.filter((a) => a.methode !== "GET").map((a) => `${a.methode} ${a.pfad}`);
return { mock, ausfuehren, phasen, schreibend };
}
const EIGEN = "Ausgerollt mit ProxDeploy (Auftrag d-abcde)\nDistribution: Debian 13";
describe("Aufräumen", () => {
it("stoppt und löscht den eigenen Gast – auch wenn er inzwischen auf einem anderen Node liegt", async () => {
const t = await aufbauen({
type: "qemu",
node: "pve02",
status: "running",
config: { name: "web01", description: EIGEN },
});
expect((await t.ausfuehren()).ok).toBe(true);
expect(t.schreibend()).toEqual([
"POST /nodes/pve02/qemu/120/status/stop",
"DELETE /nodes/pve02/qemu/120",
]);
const loeschen = t.mock.anfragen.find((a) => a.methode === "DELETE")!;
expect(loeschen.abfrage.get("destroy-unreferenced-disks")).toBe("1");
expect(t.phasen["loeschen"]!.detail).toBe("VM 120 mit Disks gelöscht");
});
it("Container, der nicht läuft: Stoppen übersprungen", async () => {
const t = await aufbauen({
type: "lxc",
node: "pve02",
status: "stopped",
config: { hostname: "ct01", description: EIGEN },
});
expect((await t.ausfuehren("LXC")).ok).toBe(true);
expect(t.phasen["stoppen"]!.status).toBe("UEBERSPRUNGEN");
expect(t.schreibend()).toEqual(["DELETE /nodes/pve02/lxc/120"]);
});
it("schon weg: Erfolg ohne Löschen", async () => {
const t = await aufbauen(null);
expect((await t.ausfuehren()).ok).toBe(true);
expect(t.phasen["pruefungen"]!.detail).toMatch(/gibt es nicht mehr/);
expect(t.schreibend()).toEqual([]);
});
it.each([
[
"fremde Beschreibung",
{ type: "qemu", node: "pve02", status: "running", config: { description: "Produktiv-DB" } },
/gehört nicht erkennbar/,
],
[
"anderer Auftrag",
{
type: "qemu",
node: "pve02",
status: "running",
config: { description: "Ausgerollt mit ProxDeploy (Auftrag d-zzzzz)" },
},
/gehört nicht erkennbar/,
],
[
"Template",
{
type: "qemu",
node: "pve02",
status: "stopped",
template: 1,
config: { description: EIGEN, template: 1 },
},
/ist ein Template/,
],
[
"gesperrt",
{ type: "qemu", node: "pve02", status: "stopped", config: { description: EIGEN, lock: "clone" } },
/gesperrt \(clone\)/,
],
[
"falscher Typ",
{ type: "lxc", node: "pve02", status: "stopped", config: { description: EIGEN } },
/kein VM/,
],
] as Array<[string, Gast, RegExp]>)("%s: bricht ab und fasst nichts an", async (_name, gast, meldung) => {
const t = await aufbauen(gast);
const ergebnis = await t.ausfuehren("VM");
expect(ergebnis.ok).toBe(false);
expect(t.phasen["pruefungen"]!.ursache).toMatch(meldung);
expect(t.schreibend()).toEqual([]);
});
});