Files
proxdeploy/tests/engine/template-bau.test.ts
T
florian.wildeandClaude Opus 5.5 00712dc6ce feat(templates): Profile ins Template einbacken
Beim Template-Bau lassen sich Profile wählen (nur solche ohne
Pflichtvariablen). Nach dem Vorbereitungsstart laufen sie über den
Guest-Agent; danach werden Maschinen-ID, SSH-Host-Keys, Docker-Engine-ID
und apt-Cache entfernt. Ausrollen nimmt das Template mit den meisten
passenden Profilen und überspringt, was schon eingebacken ist.

Auf pve-demo: Provisionierung mit base+docker 141 s ohne, ~30 s mit
Template. Dabei gefunden: docker nimmt den Benutzer per guest.user in
die Gruppe auf – Profile mit guest.* laufen deshalb beim Deploy noch
einmal (idempotent, schnell).

Closes #022

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

363 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
]);
});
});
describe("Profile einbacken (#022)", () => {
it("startet die VM, provisioniert über den Agent, räumt auf, fährt herunter – Name und Tags nennen die Profile", async () => {
const { mock, client } = await aufbauen({ version: "9.2.1" });
const dateien: string[] = [];
let exec = 0;
mock
.route("POST", /\/agent\/ping$/, () => ({ data: { result: {} } }))
.route("POST", /\/agent\/file-write$/, (a) => {
dateien.push(Buffer.from(a.rumpf.get("content")!, "base64").toString("utf8"));
return { data: null };
})
.route("POST", /\/agent\/exec$/, () => ({ data: { pid: ++exec } }))
.route("GET", /\/agent\/exec-status$/, () => ({ data: { exited: 1, exitcode: 0 } }))
.route("GET", /\/agent\/file-read$/, () => ({ data: { content: "" } }))
.route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") }));
const katalog = await katalogLaden("catalog");
const { profileAufloesen } = await import("@/engine/profile/aufloesen");
const aufgeloest = profileAufloesen(
["docker"],
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
"debian",
);
if (!aufgeloest.ok) throw new Error("Profile");
const ergebnis = await phasenAusfuehren(
templateBauSchritte({
client,
distribution: await debian13(),
profile: aufgeloest.profile,
knoten: "pve01",
storage: "local-zfs",
bridge: "vmbr0",
ssh: sshAufzeichnung().ssh,
auftragKurzId: "t",
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
vmidReservieren: async () => 9002,
warten: async () => {},
}),
stumm,
);
expect(ergebnis).toEqual({ ok: true });
const anlegen = Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/qemu")!.rumpf,
);
expect(anlegen).toMatchObject({
name: "tpl-debian-13-docker",
tags: "proxdeploy-template;debian-13;profil-base;profil-docker",
});
expect(dateien.join("")).toContain("docker-compose-plugin");
expect(dateien.join("")).toContain("truncate -s 0 /etc/machine-id");
expect(dateien.join("")).toContain("rm -f /etc/ssh/ssh_host_*");
const reihenfolge = schreibend(mock.anfragen).filter((z) => /status\/(start|shutdown)|template$/.test(z));
expect(reihenfolge).toEqual([
"POST /nodes/pve01/qemu/9002/status/start",
"POST /nodes/pve01/qemu/9002/status/start",
"POST /nodes/pve01/qemu/9002/status/shutdown",
"POST /nodes/pve01/qemu/9002/template",
]);
});
});