import { afterEach, describe, expect, it } from "vitest"; import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; import { lxcAusrollSchritte, lxcNetz, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen"; import type { NodeBefehl } from "@/engine/ablaeufe/template-bau"; 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}:130: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 BEREIT = { privat: "privat", oeffentlich: "ssh-ed25519 AAAAbereit proxdeploy-bereitstellung" }; const aufraeumen: Array<() => Promise> = []; afterEach(async () => { while (aufraeumen.length) await aufraeumen.pop()!(); }); async function aufbauen(o: { templateLokal?: string[]; dhcpNachAbfragen?: number; trocken?: boolean } = {}) { const mock = await new ProxmoxMock().starten(); szenarioEinrichten(mock, { version: "9.2.1" }); let interfaceAbfragen = 0; mock .route("GET", "/cluster/resources", () => ({ data: [{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 1e9 }, { vmid: 100 }], })) .route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({ data: (o.templateLokal ?? []).map((t) => ({ volid: `local:vztmpl/${t}` })), })) .route("GET", "/nodes/pve01/aplinfo", () => ({ data: [ { template: "debian-13-standard_13.1-2_amd64.tar.zst" }, { template: "debian-13-standard_13.6-1_amd64.tar.zst" }, { template: "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" }, ], })) .route("POST", "/nodes/pve01/aplinfo", () => ({ data: UPID("download") })) .route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } })) .route("POST", "/nodes/pve01/lxc", () => ({ data: UPID("vzcreate") })) .route("POST", /^\/nodes\/pve01\/lxc\/\d+\/status\/start$/, () => ({ data: UPID("vzstart") })) .route("GET", /^\/nodes\/pve01\/lxc\/\d+\/interfaces$/, () => ({ data: [ { name: "lo", inet: "127.0.0.1/8" }, { name: "eth0", ...(++interfaceAbfragen > (o.dhcpNachAbfragen ?? 0) ? { inet: "10.0.10.77/24" } : {}), }, ], })); 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 }; } async function eingabe( client: ProxmoxClient, teil: Record = {}, ssh: NodeBefehl | null = null, ) { const katalog = await katalogLaden("catalog"); const anfrage = AusrollAnfrage.parse({ typ: "lxc", server: "rz1", distribution: "debian-13", name: "ct01", storage: "local-zfs", diskGb: 8, netz: { bridge: "vmbr0", modus: "dhcp" }, zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] }, profile: ["docker"], ...teil, }); const distribution = katalog.distributionen.find((d) => d.eintrag.id === anfrage.distribution)!.eintrag; const aufgeloest = profileAufloesen( anfrage.profile, new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])), "debian", ); if (!aufgeloest.ok) throw new Error("Profile"); const gast: Array<{ host: string; skript: string }> = []; return { gast, e: { client, anfrage, distribution, profile: aufgeloest.profile, profilFeatures: { nesting: aufgeloest.profile.some((p) => p.profil.lxc.features?.nesting), keyctl: false, }, vmidReservieren: async () => 130, sshFuer: () => ssh, bereitstellung: async () => BEREIT, angelegt: async () => {}, auftragKurzId: "d-ct", warten: async () => {}, gastSsh: async (ziel: { host: string }, skript: string, beiAusgabe: (t: string) => void) => { gast.push({ host: ziel.host, skript }); beiAusgabe("::proxdeploy:: profil base\n::proxdeploy:: fertig\n"); return 0; }, }, }; } describe("LXC ausrollen", () => { it("mit Node-SSH: neuestes Template laden, anlegen mit nesting (docker), DHCP-IP, pct exec mit Skript auf stdin", async () => { const { mock, client } = await aufbauen({ dhcpNachAbfragen: 2 }); const befehle: Array<{ befehl: string; eingabe?: string }> = []; const ssh: NodeBefehl = async (befehl, o) => { befehle.push({ befehl, eingabe: o?.eingabe }); o?.beiAusgabe?.("::proxdeploy:: profil base\n"); return { code: 0, stdout: "", stderr: "" }; }; const { e } = await eingabe(client, {}, ssh); const stand: LxcAusrollStand = {}; expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); expect(stand).toMatchObject({ weg: "node", vmid: 130, ip: "10.0.10.77", ostemplate: "local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst", }); expect( Object.fromEntries( mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")!.rumpf, ), ).toEqual({ storage: "local", template: "debian-13-standard_13.6-1_amd64.tar.zst", }); const anlegen = Object.fromEntries( mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf, ); expect(anlegen).toMatchObject({ vmid: "130", hostname: "ct01", rootfs: "local-zfs:8", unprivileged: "1", features: "nesting=1", net0: "name=eth0,bridge=vmbr0,ip=dhcp,type=veth", ostype: "debian", }); expect(anlegen).not.toHaveProperty("ssh-public-keys"); expect(befehle).toHaveLength(1); expect(befehle[0]!.befehl).toBe("pct exec 130 -- bash -s"); expect(befehle[0]!.eingabe).toContain("useradd -m -s /bin/bash florian"); // Zeitsync nur in VMs. expect(befehle[0]!.eingabe).not.toContain("chrony"); }); it("vorhandenes neuestes Template wird nicht erneut geladen", async () => { const { mock, client } = await aufbauen({ templateLokal: ["debian-13-standard_13.6-1_amd64.tar.zst"] }); const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" })); await phasenAusfuehren(lxcAusrollSchritte(e), stumm); expect(mock.anfragen.some((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")).toBe(false); }); it("ohne Node-SSH: Bereitstellungsschlüssel an root, SSH in den Container, Schlüssel am Ende entfernen", async () => { const { mock, client } = await aufbauen(); const { e, gast } = await eingabe(client, { netz: { bridge: "vmbr0", modus: "statisch", ip: "10.0.20.5/24", gateway: "10.0.20.1" }, }); const stand: LxcAusrollStand = {}; expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); expect(stand.weg).toBe("gast"); const anlegen = Object.fromEntries( mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf, ); expect(anlegen["ssh-public-keys"]).toBe(BEREIT.oeffentlich); expect(anlegen["net0"]).toBe("name=eth0,bridge=vmbr0,ip=10.0.20.5/24,gw=10.0.20.1,type=veth"); expect(gast[0]!.host).toBe("10.0.20.5"); expect( gast[0]!.skript.trimEnd().endsWith("sed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys"), ).toBe(true); }); it("keyctl ohne Node-SSH wird vor dem Anlegen abgelehnt", async () => { const { mock, client } = await aufbauen(); const { e } = await eingabe(client, { features: { nesting: true, keyctl: true } }); const ergebnis = await phasenAusfuehren(lxcAusrollSchritte(e), stumm); expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/keyctl/); expect(mock.anfragen.some((a) => a.pfad === "/nodes/pve01/lxc")).toBe(false); }); it("Netzkonfiguration mit VLAN", () => { expect(lxcNetz({ bridge: "vmbr1", vlan: 30, modus: "dhcp", dns: [] })).toBe( "name=eth0,bridge=vmbr1,tag=30,ip=dhcp,type=veth", ); }); it("Trockenlauf: Plan ohne Schreibzugriffe", async () => { const { mock, client } = await aufbauen({ trocken: true }); const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" })); expect(await phasenAusfuehren(lxcAusrollSchritte(e), stumm)).toEqual({ ok: true }); expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]); expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([ "POST /nodes/pve01/aplinfo", "POST /nodes/pve01/lxc", "POST /nodes/pve01/lxc/130/status/start", ]); }); });