q35 hängt IDE an den SATA-Controller; der Cloud-Kernel von Debian 12 kennt ihn nicht, cloud-init fand keine Datenquelle. Gefunden bei der Abnahme von #021 auf pve-demo: Template-Bau über die Bau-Adresse und eine Debian-12-VM daraus, beides ohne SSH zum Node. Closes #021 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
434 lines
16 KiB
TypeScript
434 lines
16 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",
|
||
scsi1: "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/);
|
||
});
|
||
});
|