Files
proxdeploy/tests/engine/template-bau.test.ts
T
florian.wildeandClaude Opus 5.5 4285785a4c feat(templates): Template-Bau für VMs mit eingebautem Guest-Agent
Cloud-Image laden (download-url mit Prüfsumme, ab PVE 8.4 per API, davor
per SSH), VM mit q35/UEFI/virtio-scsi-single anlegen, Disk per
import-from importieren, Guest-Agent mit einem einmaligen
Vorbereitungsstart einbauen und als Template markieren.

Auf pve-demo (PVE 9.2.2) gebaut: Template 9000 aus Debian 13 in
5 min 26 s, Snippet und cicustom danach entfernt. Der Weg ohne SSH über
eine Bau-Adresse ist als #021 ausgegliedert.

Templates-Seite mit Liste (Proxmox ist maßgeblich, Tag
proxdeploy-template) und Formular; Abfrageschleifen erscheinen im
Detail-Log nur noch bei Fehlern.

Closes #007

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:57:11 +02:00

303 lines
11 KiB
TypeScript

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<void>> = [];
afterEach(async () => {
while (aufraeumen.length) await aufraeumen.pop()!();
});
async function debian13(): Promise<Distribution> {
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",
]);
});
});