import { afterEach, describe, expect, it } from "vitest"; import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; import { templateBauSchritte, vorbereitungsSnippet, type NodeBefehl, type TemplateBauStand, } from "@/engine/ablaeufe/template-bau"; import { katalogLaden } from "@/engine/katalog/laden"; import type { Distribution } from "@/engine/katalog/schema"; import { ProxmoxClient } from "@/engine/proxmox/client"; import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock, type MockAnfrage } from "../proxmox-mock/mock"; import { szenarioEinrichten } from "../proxmox-mock/szenario"; const HASH = "f".repeat(128); const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:9000:proxdeploy@pve!test:`; const stumm: EreignisSenke = { phasenAnlegen: async () => {}, phaseGestartet: async () => {}, phaseDetail: async () => {}, phaseBeendet: async () => {}, log: async () => {}, }; const aufraeumen: Array<() => Promise> = []; afterEach(async () => { while (aufraeumen.length) await aufraeumen.pop()!(); }); async function debian13(): Promise { return (await katalogLaden("catalog")).distributionen.find((d) => d.eintrag.id === "debian-13")!.eintrag; } /** Proxmox-Mock mit allem, was der Template-Bau anfasst. VM-Status: läuft zweimal, dann gestoppt. */ async function aufbauen(optionen: { version: string; importVorhanden?: boolean; startScheitert?: boolean; trocken?: boolean; }) { const mock = await new ProxmoxMock().starten(); szenarioEinrichten(mock, { version: optionen.version, importStorage: true }); let statusAbfragen = 0; mock .route("GET", "/cluster/resources", () => ({ data: [{ vmid: 100 }, { vmid: 9000, template: 1 }] })) .route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({ data: optionen.importVorhanden ? [{ volid: `local:import/proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2` }] : [], })) .route("POST", /\/download-url$/, () => ({ data: UPID("download") })) .route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } })) .route("GET", /\/tasks\/[^/]+\/log$/, () => ({ data: [{ n: 1, t: "downloaded 100%" }] })) .route("POST", "/nodes/pve01/qemu", () => ({ data: UPID("qmcreate") })) .route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({ data: null })) .route("POST", /^\/nodes\/pve01\/qemu\/\d+\/status\/start$/, () => optionen.startScheitert ? { status: 500, statusText: "start failed: OVMF not found" } : { data: UPID("qmstart") }, ) .route("GET", /^\/nodes\/pve01\/qemu\/\d+\/status\/current$/, () => ({ data: { status: ++statusAbfragen > 2 ? "stopped" : "running" }, })) .route("POST", /^\/nodes\/pve01\/qemu\/\d+\/template$/, () => ({ data: UPID("qmtemplate") })); const client = new ProxmoxClient( { url: mock.url, tokenId: MOCK_TOKEN_ID, tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, tlsPruefen: false, fingerabdruck: mock.fingerabdruck, }, { trocken: optionen.trocken }, ); aufraeumen.push(async () => { await client.schliessen(); await mock.stoppen(); }); return { mock, client }; } function sshAufzeichnung() { const befehle: Array<{ befehl: string; eingabe?: string }> = []; const ssh: NodeBefehl = async (befehl, optionen) => { befehle.push({ befehl, eingabe: optionen?.eingabe }); return { code: 0, stdout: "", stderr: "" }; }; return { ssh, befehle }; } const schreibend = (anfragen: MockAnfrage[]) => anfragen.filter((a) => a.methode !== "GET").map((a) => `${a.methode} ${a.pfad}`); describe("Template-Bau", () => { it("PVE 9.2 per API: download-url mit Prüfsumme, import-from per Volume-ID, UEFI, Agent per Snippet, Template", async () => { const { mock, client } = await aufbauen({ version: "9.2.1" }); const { ssh, befehle } = sshAufzeichnung(); const stand: TemplateBauStand = {}; const ergebnis = await phasenAusfuehren( templateBauSchritte( { client, distribution: await debian13(), knoten: "pve01", storage: "local-zfs", bridge: "vmbr0", ssh, auftragKurzId: "t-test1", holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2\n`, vmidReservieren: async (belegt) => (belegt.has(9000) ? 9001 : 9000), warten: async () => {}, }, stand, ), stumm, ); expect(ergebnis).toEqual({ ok: true }); expect(stand).toMatchObject({ vmid: 9001, weg: "api", importStorage: "local", pruefsumme: HASH }); expect(schreibend(mock.anfragen)).toEqual([ "POST /nodes/pve01/storage/local/download-url", "POST /nodes/pve01/qemu", "PUT /nodes/pve01/qemu/9001/config", "POST /nodes/pve01/qemu/9001/status/start", "PUT /nodes/pve01/qemu/9001/config", "POST /nodes/pve01/qemu/9001/template", ]); const download = mock.anfragen.find((a) => a.pfad.endsWith("download-url"))!.rumpf; expect(Object.fromEntries(download)).toMatchObject({ content: "import", filename: `proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2`, checksum: HASH, "checksum-algorithm": "sha512", "verify-certificates": "1", }); const anlegen = Object.fromEntries( mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/qemu")!.rumpf, ); expect(anlegen).toMatchObject({ vmid: "9001", machine: "q35", bios: "ovmf", efidisk0: "local-zfs:1,efitype=4m,pre-enrolled-keys=1", scsihw: "virtio-scsi-single", scsi0: `local-zfs:0,import-from=local:import/proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2,discard=on,ssd=1,iothread=1`, cpu: "host", serial0: "socket", agent: "enabled=1,fstrim_cloned_disks=1", ide2: "local-zfs:cloudinit", tags: "proxdeploy-template;debian-13", }); const configs = mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf)); expect(configs).toEqual([ { cicustom: "vendor=local:snippets/proxdeploy-vorbereitung-t-test1.yaml" }, { delete: "cicustom" }, ]); expect(befehle[0]!.eingabe).toBe(vorbereitungsSnippet()); expect(befehle.at(-1)!.befehl).toMatch(/^rm -f/); }); it("überspringt den Download, wenn das geprüfte Image schon liegt", async () => { const { mock, client } = await aufbauen({ version: "9.2.1", importVorhanden: true }); const { ssh } = sshAufzeichnung(); await phasenAusfuehren( templateBauSchritte({ client, distribution: await debian13(), knoten: "pve01", storage: "local-zfs", bridge: "vmbr0", ssh, auftragKurzId: "t", holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, vmidReservieren: async () => 9000, warten: async () => {}, }), stumm, ); expect(mock.anfragen.some((a) => a.pfad.endsWith("download-url"))).toBe(false); }); it("fehlt die Datei in der Prüfsummenliste, bricht das Laden mit Hinweis ab", async () => { const { client } = await aufbauen({ version: "9.2.1" }); const ergebnis = await phasenAusfuehren( templateBauSchritte({ client, distribution: await debian13(), knoten: "pve01", storage: "local-zfs", bridge: "vmbr0", ssh: sshAufzeichnung().ssh, auftragKurzId: "t", holen: async () => `${HASH} andere-datei.qcow2`, vmidReservieren: async () => 9000, warten: async () => {}, }), stumm, ); expect(ergebnis).toMatchObject({ ok: false, phase: "image" }); if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/umbenannt/); }); it("PVE 8.3 ohne SSH: schon die Prüfung sagt, was fehlt", async () => { const { client } = await aufbauen({ version: "8.3.5" }); const ergebnis = await phasenAusfuehren( templateBauSchritte({ client, distribution: await debian13(), knoten: "pve01", storage: "local-zfs", bridge: "vmbr0", ssh: null, auftragKurzId: "t", holen: async () => "", vmidReservieren: async () => 9000, }), stumm, ); expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" }); if (!ergebnis.ok) expect(ergebnis.fehler.ursache).toMatch(/erst ab 8\.4/); }); it("PVE 8.3 mit SSH: Download und Prüfung auf dem Node, Import per qm set", async () => { const { client } = await aufbauen({ version: "8.3.5" }); const { ssh, befehle } = sshAufzeichnung(); const ergebnis = await phasenAusfuehren( templateBauSchritte({ client, distribution: await debian13(), knoten: "pve01", storage: "local-zfs", bridge: "vmbr0", ssh, auftragKurzId: "t", holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, vmidReservieren: async () => 9000, warten: async () => {}, }), stumm, ); expect(ergebnis).toEqual({ ok: true }); expect(befehle[0]!.befehl).toContain("sha512sum -c --status"); expect(befehle[0]!.befehl).toContain("curl -fsSL"); expect(befehle[1]!.befehl).toMatch( /^qm set 9000 --scsi0 .*import-from=\/var\/tmp\/proxdeploy\/proxdeploy-debian-13-/, ); }); it("räumt das Snippet auch auf, wenn der Vorbereitungsstart scheitert", async () => { const { mock, client } = await aufbauen({ version: "9.2.1", startScheitert: true }); const { ssh, befehle } = sshAufzeichnung(); const ergebnis = await phasenAusfuehren( templateBauSchritte({ client, distribution: await debian13(), knoten: "pve01", storage: "local-zfs", bridge: "vmbr0", ssh, auftragKurzId: "t", holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, vmidReservieren: async () => 9000, warten: async () => {}, }), stumm, ); expect(ergebnis).toMatchObject({ ok: false, phase: "agent" }); expect( mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf)), ).toContainEqual({ delete: "cicustom" }); expect(befehle.at(-1)!.befehl).toMatch(/^rm -f/); }); it("Trockenlauf: dieselben Phasen, nichts Schreibendes erreicht Proxmox, der Plan nennt jeden Aufruf", async () => { const { mock, client } = await aufbauen({ version: "9.2.1", trocken: true }); const ergebnis = await phasenAusfuehren( templateBauSchritte({ client, distribution: await debian13(), knoten: "pve01", storage: "local-zfs", bridge: "vmbr0", ssh: sshAufzeichnung().ssh, auftragKurzId: "t", holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, vmidReservieren: async () => 9000, }), stumm, ); expect(ergebnis).toEqual({ ok: true }); expect(schreibend(mock.anfragen)).toEqual([]); expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([ "POST /nodes/pve01/storage/local/download-url", "POST /nodes/pve01/qemu", "PUT /nodes/pve01/qemu/9000/config", "POST /nodes/pve01/qemu/9000/status/start", "PUT /nodes/pve01/qemu/9000/config", "POST /nodes/pve01/qemu/9000/template", ]); }); });