Files
proxdeploy/tests/engine/template-bau.test.ts
T
florian.wildeandClaude Opus 5.5 21edff2141 feat(templates): Template-Bau ohne SSH über eine Bau-Adresse
Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste
Bau-Adresse ins Template: cloud-init setzt Adresse und den
Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet
sich per SSH in der VM an, installiert den Agent und entfernt danach
Schlüssel, Adresse und cloud-init-Angaben.

Dabei korrigiert: der Verbindungstest meldete „Template-Bau per API“
auch ohne Weg, den Agent einzubauen. Im Protokoll erscheinen
unveränderte JSON-Felder nicht mehr als geändert.

Refs #021

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

434 lines
16 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",
]);
});
});
describe("Guest-Agent über die Bau-Adresse (#021)", () => {
it("ohne Node-SSH: feste Adresse und Bereitstellungsschlüssel per cloud-init, SSH in die VM, danach zurückgesetzt", async () => {
const { mock, client } = await aufbauen({ version: "9.2.1" });
mock.route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") }));
const gast: Array<{ host: string; benutzer: string; skript: string }> = [];
const ergebnis = await phasenAusfuehren(
templateBauSchritte({
client,
distribution: await debian13(),
knoten: "pve01",
storage: "local-zfs",
bridge: "vmbr0",
ssh: null,
auftragKurzId: "t",
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
vmidReservieren: async () => 9000,
warten: async () => {},
bauNetz: { ip: "10.0.10.250/24", gateway: "10.0.10.1", dns: ["10.0.10.1"] },
bereitstellung: async () => ({
privat: "p",
oeffentlich: "ssh-ed25519 AAAAbau proxdeploy-bereitstellung",
}),
gastSsh: async (ziel, skript) => {
gast.push({ host: ziel.host, benutzer: ziel.benutzer, skript });
return 0;
},
}),
stumm,
);
expect(ergebnis).toEqual({ ok: true });
expect(gast).toEqual([
{
host: "10.0.10.250",
benutzer: "debian",
skript: expect.stringContaining("apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent"),
},
]);
expect(gast[0]!.skript).toContain("rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys");
const configs = mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf));
expect(configs[0]).toMatchObject({
ciuser: "debian",
ipconfig0: "ip=10.0.10.250/24,gw=10.0.10.1",
nameserver: "10.0.10.1",
});
expect(configs[0]!["sshkeys"]).toBe("ssh-ed25519%20AAAAbau%20proxdeploy-bereitstellung");
expect(configs.at(-1)).toEqual({ ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" });
// Kein Snippet ohne Node-SSH.
expect(mock.anfragen.some((a) => a.rumpf.get("cicustom"))).toBe(false);
});
it("ohne Node-SSH und ohne Bau-Adresse sagt schon die Prüfung, was fehlt", 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: null,
auftragKurzId: "t",
holen: async () => "",
vmidReservieren: async () => 9000,
}),
stumm,
);
expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" });
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Bau-Adresse/);
});
});