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:
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden, keinQuerscrollen } from "./helfer";
|
||||
|
||||
test("Verlauf: Filter stehen in der URL und überleben das Neuladen", async ({ page }) => {
|
||||
await anmelden(page, "/auftraege");
|
||||
await expect(page.getByRole("heading", { name: "Verlauf" })).toBeVisible();
|
||||
await page.getByLabel("Typ").selectOption("vm");
|
||||
await expect(page).toHaveURL(/typ=vm/);
|
||||
await page.getByLabel("Status").selectOption("ERFOLGREICH");
|
||||
await expect(page).toHaveURL(/typ=vm.*status=ERFOLGREICH|status=ERFOLGREICH.*typ=vm/);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Typ")).toHaveValue("vm");
|
||||
await expect(page.getByLabel("Status")).toHaveValue("ERFOLGREICH");
|
||||
await keinQuerscrollen(page);
|
||||
await page.getByRole("button", { name: "Filter zurücksetzen" }).click();
|
||||
await expect(page).toHaveURL(/\/auftraege$/);
|
||||
});
|
||||
|
||||
test("Wiederholen füllt den Assistenten mit den alten Werten, Name leer", async ({ page }) => {
|
||||
await anmelden(page, "/auftraege?typ=vm&status=ERFOLGREICH");
|
||||
const erster = page.locator("main ul li a").first();
|
||||
if ((await erster.count()) === 0) test.skip(true, "Kein erfolgreicher VM-Auftrag vorhanden");
|
||||
await erster.click();
|
||||
await page.getByRole("link", { name: "Wiederholen" }).click();
|
||||
await expect(page).toHaveURL(/\/ausrollen\?wiederholen=/);
|
||||
await expect(page.getByText(/Wiederholung von d-/)).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
|
||||
await expect(page.getByLabel("Name (Hostname)")).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Weiter" })).toBeDisabled();
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,15 @@ describe("VM ausrollen", () => {
|
||||
expect(angelegt).toContainEqual({ vmid: 101 });
|
||||
|
||||
const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf);
|
||||
expect(klon).toEqual({ newid: "101", name: "web01", full: "1", target: "pve01", storage: "local-zfs" });
|
||||
expect(klon).toMatchObject({
|
||||
newid: "101",
|
||||
name: "web01",
|
||||
full: "1",
|
||||
target: "pve01",
|
||||
storage: "local-zfs",
|
||||
});
|
||||
// Schon beim Klonen erkennbar als Gast dieses Auftrags – davon hängt „Aufräumen“ ab.
|
||||
expect(klon["description"]).toMatch(/^Ausgerollt mit ProxDeploy \(Auftrag d-test1\)/);
|
||||
|
||||
const configs = mock.anfragen
|
||||
.filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config"))
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import * as liste from "@/app/api/v1/auftraege/route";
|
||||
import * as aufraeumenRoute from "@/app/api/v1/auftraege/[id]/aufraeumen/route";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { auftragHolen, verlaufFilterAus, VERLAUF_PRO_SEITE } from "@/server/services/auftraege";
|
||||
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||
|
||||
describe("Verlaufsfilter aus der URL", () => {
|
||||
it("übernimmt Gültiges und lässt Unsinn still fallen", () => {
|
||||
expect(verlaufFilterAus({ typ: "lxc", status: "FEHLGESCHLAGEN", seite: "3", server: "rz1" })).toEqual({
|
||||
typ: "lxc",
|
||||
status: "FEHLGESCHLAGEN",
|
||||
seite: 3,
|
||||
server: "rz1",
|
||||
});
|
||||
expect(verlaufFilterAus({ typ: "quatsch", status: "x", seite: "-4", distribution: "" })).toEqual({
|
||||
seite: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Verlauf und Aufräumen", () => {
|
||||
let cookie: string;
|
||||
let benutzerId: string;
|
||||
let serverIds: Record<string, string>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
await katalogSynchronisieren();
|
||||
const op = await angemeldet("OPERATOR");
|
||||
cookie = op.cookie;
|
||||
benutzerId = op.benutzer.id;
|
||||
serverIds = {};
|
||||
for (const name of ["rz1", "rz2"]) {
|
||||
serverIds[name] = (
|
||||
await testPrisma.server.create({
|
||||
data: {
|
||||
name,
|
||||
titel: name,
|
||||
url: "https://127.0.0.1:8006",
|
||||
tokenId: "a@pve!b",
|
||||
tokenGeheimnis: verschluesseln("x"),
|
||||
fingerabdruck: "AA",
|
||||
},
|
||||
})
|
||||
).id;
|
||||
}
|
||||
});
|
||||
|
||||
let nr = 0;
|
||||
const auftrag = async (daten: Record<string, unknown>) =>
|
||||
await testPrisma.auftrag.create({
|
||||
data: {
|
||||
kurzId: `d-t${String(nr++).padStart(4, "0")}`,
|
||||
art: "AUSROLLEN",
|
||||
status: "ERFOLGREICH",
|
||||
quelle: "WEB",
|
||||
serverId: serverIds["rz1"]!,
|
||||
erstelltVonId: benutzerId,
|
||||
anfrage: {},
|
||||
...daten,
|
||||
} as never,
|
||||
});
|
||||
|
||||
it("filtert nach Server, Typ, Distribution und Status und blättert", async () => {
|
||||
await auftrag({ gastTyp: "VM", distributionId: "debian-13" });
|
||||
await auftrag({ gastTyp: "LXC", distributionId: "ubuntu-24.04", status: "FEHLGESCHLAGEN" });
|
||||
await auftrag({ gastTyp: "LXC", distributionId: "debian-13", serverId: serverIds["rz2"] });
|
||||
await auftrag({ art: "TEMPLATE_BAUEN", gastTyp: "VM", distributionId: "debian-13" });
|
||||
|
||||
const holen = async (abfrage: string) =>
|
||||
(await (await liste.GET(anfrage("GET", `/api/v1/auftraege?${abfrage}`, cookie))).json()) as {
|
||||
eintraege: Array<{ kurzId: string; art: string; gastTyp: string }>;
|
||||
gesamt: number;
|
||||
seiten: number;
|
||||
};
|
||||
expect((await holen("")).gesamt).toBe(4);
|
||||
expect((await holen("typ=lxc")).gesamt).toBe(2);
|
||||
expect((await holen("typ=vm")).gesamt).toBe(1);
|
||||
expect((await holen("typ=template")).eintraege[0]!.art).toBe("TEMPLATE_BAUEN");
|
||||
expect((await holen("server=rz2")).gesamt).toBe(1);
|
||||
expect((await holen("distribution=debian-13&typ=lxc")).gesamt).toBe(1);
|
||||
expect((await holen("status=FEHLGESCHLAGEN")).gesamt).toBe(1);
|
||||
|
||||
for (let i = 0; i < VERLAUF_PRO_SEITE; i++) await auftrag({ gastTyp: "VM" });
|
||||
const erste = await holen("");
|
||||
const zweite = await holen("seite=2");
|
||||
expect(erste.seiten).toBe(2);
|
||||
expect(erste.eintraege).toHaveLength(VERLAUF_PRO_SEITE);
|
||||
expect(zweite.eintraege).toHaveLength(4);
|
||||
expect(new Set([...erste.eintraege, ...zweite.eintraege].map((e) => e.kurzId)).size).toBe(
|
||||
VERLAUF_PRO_SEITE + 4,
|
||||
);
|
||||
});
|
||||
|
||||
const starten = async (kurzId: string) =>
|
||||
await aufraeumenRoute.POST(
|
||||
anfrage("POST", `/api/v1/auftraege/${kurzId}/aufraeumen`, cookie),
|
||||
kontext({ id: kurzId }),
|
||||
);
|
||||
|
||||
it("Aufräumen nur bei Fehlschlag/Abbruch mit angelegtem Gast, nie doppelt", async () => {
|
||||
const ok = await auftrag({ gastTyp: "VM", angelegt: { vmid: 120, knoten: "pve01", typ: "VM" } });
|
||||
const nichts = await auftrag({ gastTyp: "VM", status: "FEHLGESCHLAGEN", angelegt: { knoten: "pve01" } });
|
||||
const kaputt = await auftrag({
|
||||
gastTyp: "LXC",
|
||||
name: "ct01",
|
||||
status: "ABGEBROCHEN",
|
||||
angelegt: { vmid: 121, knoten: "pve01", typ: "LXC" },
|
||||
});
|
||||
|
||||
expect((await auftragHolen(ok.kurzId)).aufraeumbar).toBeNull();
|
||||
expect((await auftragHolen(nichts.kurzId)).aufraeumbar).toBeNull();
|
||||
expect((await auftragHolen(kaputt.kurzId)).aufraeumbar).toEqual({
|
||||
typ: "LXC",
|
||||
vmid: 121,
|
||||
knoten: "pve01",
|
||||
});
|
||||
|
||||
expect((await starten(ok.kurzId)).status).toBe(409);
|
||||
expect((await starten(nichts.kurzId)).status).toBe(409);
|
||||
const gestartet = await starten(kaputt.kurzId);
|
||||
expect(gestartet.status).toBe(202);
|
||||
const { kurzId } = (await gestartet.json()) as { kurzId: string };
|
||||
expect(kurzId).toMatch(/^c-/);
|
||||
const neu = await testPrisma.auftrag.findUniqueOrThrow({ where: { kurzId } });
|
||||
expect(neu).toMatchObject({ art: "AUFRAEUMEN", bezugId: kaputt.id, gastTyp: "LXC", name: "ct01" });
|
||||
expect(neu.anfrage).toEqual({ auftrag: kaputt.kurzId, typ: "LXC", vmid: 121, knoten: "pve01" });
|
||||
|
||||
const doppelt = await starten(kaputt.kurzId);
|
||||
expect(doppelt.status).toBe(409);
|
||||
expect((await doppelt.json()).fehler.nachricht).toContain(kurzId);
|
||||
expect(await testPrisma.protokoll.count({ where: { aktion: "Aufräumen gestartet" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user