import { afterEach, describe, expect, it } from "vitest"; import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; import { ipconfig, sshkeysKodieren, vmAusrollSchritte, type VmAusrollStand, } from "@/engine/ablaeufe/vm-ausrollen"; import { katalogLaden } from "@/engine/katalog/laden"; import { profileAufloesen } from "@/engine/profile/aufloesen"; import { ProxmoxClient } from "@/engine/proxmox/client"; import { AusrollAnfrage } from "@/lib/schemas/ausrollen"; import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock"; import { szenarioEinrichten } from "../proxmox-mock/szenario"; const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:120:proxdeploy@pve!test:`; const SCHLUESSEL = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@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()!(); }); type MockOptionen = { template?: boolean; cloneBelegt?: boolean; provisionierungCode?: number; agentStumm?: boolean; vlanBridge?: boolean; trocken?: boolean; }; async function aufbauen(o: MockOptionen = {}) { const mock = await new ProxmoxMock().starten(); szenarioEinrichten(mock, { version: "9.2.1", importStorage: true }); let klonVersuche = 0; const dateien = new Map(); let execStatusAbfragen = 0; mock .route("GET", "/cluster/resources", () => ({ data: [ { type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 }, ...(o.template === false ? [] : [ { type: "qemu", vmid: 9000, node: "pve01", template: 1, tags: "proxdeploy-template;debian-13" }, ]), { type: "qemu", vmid: 100, node: "pve01" }, ], })) .route("GET", /^\/nodes\/pve01\/network$/, () => ({ data: [{ iface: "vmbr0", type: "bridge", bridge_vlan_aware: o.vlanBridge === false ? 0 : 1 }], })) .route("POST", "/nodes/pve01/qemu/9000/clone", () => o.cloneBelegt && ++klonVersuche === 1 ? { status: 500, statusText: "unable to create VM 101 - VM 101 already exists on node 'pve02'" } : { data: UPID("qmclone") }, ) .route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } })) .route("GET", /\/tasks\/[^/]+\/log$/, () => ({ data: [{ n: 1, t: "transferred 1.2 GiB of 3.5 GiB (35.16%)" }], })) .route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/(config|resize)$/, () => ({ data: null })) .route("GET", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({ data: { scsi0: "local-zfs:vm-101-disk-1,size=3G" }, })) .route("POST", /^\/nodes\/pve01\/qemu\/\d+\/status\/start$/, () => ({ data: UPID("qmstart") })) .route("POST", /\/agent\/ping$/, () => o.agentStumm ? { status: 500, statusText: "QEMU guest agent is not running" } : { data: { result: {} } }, ) .route("GET", /\/agent\/network-get-interfaces$/, () => ({ data: { result: [ { name: "lo", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "127.0.0.1" }] }, { name: "eth0", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "10.0.10.50" }] }, ], }, })) .route("POST", /\/agent\/file-write$/, (a) => { dateien.set( a.rumpf.get("file")!, Buffer.from(a.rumpf.get("content")!, a.rumpf.get("encode") === "0" ? "base64" : "utf8").toString( "utf8", ), ); return { data: null }; }) .route("POST", /\/agent\/exec$/, () => ({ data: { pid: 4711 } })) .route("GET", /\/agent\/exec-status$/, () => ({ data: ++execStatusAbfragen < 2 ? { exited: 0 } : { exited: 1, exitcode: o.provisionierungCode ?? 0 }, })) .route("GET", /\/agent\/file-read$/, () => ({ data: { content: "::proxdeploy:: profil base\nReading package lists...\n::proxdeploy:: profil docker\n", }, })); const client = new ProxmoxClient( { url: mock.url, tokenId: MOCK_TOKEN_ID, tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, tlsPruefen: false, fingerabdruck: mock.fingerabdruck, }, { trocken: o.trocken }, ); aufraeumen.push(async () => { await client.schliessen(); await mock.stoppen(); }); return { mock, client, dateien }; } async function eingabe(client: ProxmoxClient, anfrageTeil: Record = {}) { const katalog = await katalogLaden("catalog"); const distribution = katalog.distributionen.find((d) => d.eintrag.id === "debian-13")!.eintrag; const anfrage = AusrollAnfrage.parse({ typ: "vm", server: "rz1", distribution: "debian-13", name: "web01", storage: "local-zfs", diskGb: 40, netz: { bridge: "vmbr0", vlan: 20, modus: "statisch", ip: "10.0.20.15/24", gateway: "10.0.20.1", dns: ["10.0.20.1"], }, zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] }, profile: ["docker"], ...anfrageTeil, }); const aufgeloest = profileAufloesen( anfrage.profile, new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])), "debian", anfrage.variablen, ); if (!aufgeloest.ok) throw new Error("Profile"); const angelegt: Array> = []; return { angelegt, e: { client, anfrage, distribution, profile: aufgeloest.profile, vmidReservieren: async (belegt: Set, wunsch?: number) => wunsch ?? (belegt.has(101) ? 102 : 101), angelegt: async (x: Record) => void angelegt.push(x), auftragKurzId: "d-test1", warten: async () => {}, agentMs: 50, }, }; } describe("VM ausrollen", () => { it("klont, konfiguriert, setzt cloud-init, startet, wartet auf den Agent und provisioniert", async () => { const { mock, client, dateien } = await aufbauen(); const { e, angelegt } = await eingabe(client); const stand: VmAusrollStand = {}; expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); expect(stand).toMatchObject({ knoten: "pve01", template: { vmid: 9000, knoten: "pve01" }, vmid: 101, ip: "10.0.20.15", }); 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" }); const configs = mock.anfragen .filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config")) .map((a) => Object.fromEntries(a.rumpf)); expect(configs[0]).toMatchObject({ cores: "2", memory: "2048", cpu: "host", net0: "virtio,bridge=vmbr0,tag=20", tags: "proxdeploy;debian-13", }); expect(configs[1]).toMatchObject({ ciuser: "florian", ipconfig0: "ip=10.0.20.15/24,gw=10.0.20.1", nameserver: "10.0.20.1", ciupgrade: "0", sshkeys: sshkeysKodieren([SCHLUESSEL]), }); expect(Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/resize"))!.rumpf)).toEqual({ disk: "scsi0", size: "40G", }); // Das Skript kommt base64-kodiert an (Umlaute!) und enthält Benutzer und beide Profile. const skript = [...dateien.values()].join(""); expect(skript).toContain("::proxdeploy:: profil base"); expect(skript).toContain("::proxdeploy:: profil docker"); expect(skript).toContain("useradd -m -s /bin/bash florian"); expect(skript).toContain("Frische Images"); const exec = mock.anfragen.find((a) => a.pfad.endsWith("/agent/exec"))!.rumpf.getAll("command"); expect(exec[0]).toBe("/bin/bash"); expect(exec[2]).toMatch(/> \/var\/log\/proxdeploy-provisionierung\.log 2>&1$/); }); it("kodiert SSH-Schlüssel so, wie Proxmox sie erwartet (auch / + und Leerzeichen)", () => { expect(sshkeysKodieren(["ssh-ed25519 AA/B+C= x (y)"])).toBe("ssh-ed25519%20AA%2FB%2BC%3D%20x%20%28y%29"); expect(ipconfig({ bridge: "vmbr0", modus: "dhcp", dns: [] })).toBe("ip=dhcp"); }); it("ohne Template: klare Meldung – mit templateBauen würde gebaut", async () => { const { client } = await aufbauen({ template: false }); const { e } = await eingabe(client); const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm); expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" }); if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Template vorher bauen/); }); it("VLAN auf einer nicht VLAN-fähigen Bridge wird vor dem Klonen abgelehnt", async () => { const { mock, client } = await aufbauen({ vlanBridge: false }); const { e } = await eingabe(client); const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm); expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/nicht VLAN-fähig/); expect(mock.anfragen.some((a) => a.pfad.endsWith("/clone"))).toBe(false); }); it("ist die VMID beim Klonen inzwischen belegt, wird einmal mit neuer ID geklont", async () => { const { mock, client } = await aufbauen({ cloneBelegt: true }); const { e } = await eingabe(client); const stand: VmAusrollStand = {}; let aufrufe = 0; e.vmidReservieren = async () => (++aufrufe === 1 ? 101 : 102); expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); expect(stand.vmid).toBe(102); expect(mock.anfragen.filter((a) => a.pfad.endsWith("/clone")).map((a) => a.rumpf.get("newid"))).toEqual([ "101", "102", ]); }); it("ein scheiternder Profillauf nennt das Profil und den Exit-Code", async () => { const { client } = await aufbauen({ provisionierungCode: 100 }); const { e } = await eingabe(client); const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm); expect(ergebnis).toMatchObject({ ok: false, phase: "provisionierung" }); if (!ergebnis.ok) expect(ergebnis.fehler.ursache).toBe("Provisionierung im Profil docker mit Code 100 abgebrochen."); }); it("antwortet der Agent nicht, sagt die Phase, woran es liegen kann", async () => { const { client } = await aufbauen({ agentStumm: true }); const { e } = await eingabe(client); const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm); expect(ergebnis).toMatchObject({ ok: false, phase: "agent" }); if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Konsole/); }); it("DHCP: die IP kommt vom Guest-Agent (ohne Loopback)", async () => { const { client } = await aufbauen(); const { e } = await eingabe(client, { netz: { bridge: "vmbr0", modus: "dhcp" } }); const stand: VmAusrollStand = {}; await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm); expect(stand.ip).toBe("10.0.10.50"); }); it("Trockenlauf: nichts Schreibendes erreicht Proxmox, kein Agent-Aufruf", async () => { const { mock, client } = await aufbauen({ trocken: true }); const { e } = await eingabe(client); expect(await phasenAusfuehren(vmAusrollSchritte(e), stumm)).toEqual({ ok: true }); expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]); expect(mock.anfragen.some((a) => a.pfad.includes("/agent/"))).toBe(false); expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([ "POST /nodes/pve01/qemu/9000/clone", "PUT /nodes/pve01/qemu/101/config", "PUT /nodes/pve01/qemu/101/resize", "PUT /nodes/pve01/qemu/101/config", "POST /nodes/pve01/qemu/101/status/start", ]); }); }); describe("Templates mit eingebackenen Profilen (#022)", () => { async function mitTemplates(templates: Array<{ vmid: number; tags: string }>) { const { mock, client, dateien } = await aufbauen(); mock.route("GET", "/cluster/resources", () => ({ data: [ { type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 }, ...templates.map((t) => ({ type: "qemu", vmid: t.vmid, node: "pve01", template: 1, tags: t.tags })), ], })); mock.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/clone$/, () => ({ data: UPID("qmclone") })); return { mock, client, dateien }; } it("wählt das Template mit den meisten passenden Profilen, nie eins mit ungewählten", async () => { const { client } = await mitTemplates([ { vmid: 9000, tags: "proxdeploy-template;debian-13" }, { vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" }, { vmid: 9003, tags: "proxdeploy-template;debian-13;profil-base;profil-docker;profil-nginx" }, ]); const { e } = await eingabe(client); const stand: VmAusrollStand = {}; expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); expect(stand.template).toEqual({ vmid: 9002, knoten: "pve01", eingebacken: ["base", "docker"] }); }); it("überspringt gastunabhängige eingebackene Profile; docker (nutzt guest.user) läuft noch einmal", async () => { const { client, dateien } = await mitTemplates([ { vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" }, ]); const { e } = await eingabe(client); await phasenAusfuehren(vmAusrollSchritte(e), stumm); const skript = [...dateien.values()].join(""); expect(skript).not.toContain("::proxdeploy:: profil base"); expect(skript).toContain("::proxdeploy:: profil docker"); expect(skript).toContain("usermod -aG docker florian"); }); it("weicht eine Variable vom Standard ab, läuft auch ein eingebackenes Profil", async () => { const { client, dateien } = await mitTemplates([ { vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base" }, ]); const { e } = await eingabe(client, { profile: ["base"], variablen: { base: { zeitzone: "UTC" } } }); await phasenAusfuehren(vmAusrollSchritte(e), stumm); expect([...dateien.values()].join("")).toContain("::proxdeploy:: profil base"); }); });