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>
This commit is contained in:
2026-10-12 14:20:32 +02:00
co-authored by Claude Opus 5.5
parent ebcf63100a
commit 00712dc6ce
16 changed files with 541 additions and 154 deletions
+60
View File
@@ -300,3 +300,63 @@ describe("Template-Bau", () => {
]);
});
});
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",
]);
});
});
+48
View File
@@ -147,6 +147,7 @@ async function eingabe(client: ProxmoxClient, anfrageTeil: Record<string, unknow
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<Record<string, unknown>> = [];
@@ -292,3 +293,50 @@ describe("VM ausrollen", () => {
]);
});
});
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");
});
});