Files
proxdeploy/tests/engine/vm-ausrollen.test.ts
T
florian.wildeandClaude Opus 5.5 9310164bc2 feat(ausrollen): VM ausrollen mit Assistent, Dry-Run und CLI-Befehl
Eine VM aus dem ProxDeploy-Template ausrollen, vollständig per API:
Klonen, Hardware, cloud-init, Start, Guest-Agent und Provisionierung der
Profile über den Agent – ohne SSH. Der Fortschritt kommt aus der
Logdatei im Gast, je Profil eine Marke.

Der Dry-Run läuft durch dieselben Phasen mit einem Trocken-Client und
zeigt jeden geplanten Proxmox-Aufruf und den passenden CLI-Befehl. Der
Assistent hat 9 Schritte, den Zustand in der URL und bietet an, ein
fehlendes Template im selben Auftrag zu bauen.

Auf pve-demo abgenommen: pd-test01 (base, docker) per API, pd-test02
(base, zabbix-agent) über den Assistenten, beide per SSH geprüft. Dabei
gefunden: file-write scheitert an Umlauten – Skripte gehen jetzt
base64-kodiert hinüber.

Closes #008

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

292 lines
11 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 {
ipconfig,
sshkeysKodieren,
vmAusrollSchritte,
type VmAusrollStand,
} from "@/engine/ablaeufe/vm-ausrollen";
import { katalogLaden } from "@/engine/katalog/laden";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten } from "../proxmox-mock/szenario";
const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:120:proxdeploy@pve!test:`;
const SCHLUESSEL =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@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()!();
});
type MockOptionen = {
template?: boolean;
cloneBelegt?: boolean;
provisionierungCode?: number;
agentStumm?: boolean;
vlanBridge?: boolean;
trocken?: boolean;
};
async function aufbauen(o: MockOptionen = {}) {
const mock = await new ProxmoxMock().starten();
szenarioEinrichten(mock, { version: "9.2.1", importStorage: true });
let klonVersuche = 0;
const dateien = new Map<string, string>();
let execStatusAbfragen = 0;
mock
.route("GET", "/cluster/resources", () => ({
data: [
{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 },
...(o.template === false
? []
: [
{ type: "qemu", vmid: 9000, node: "pve01", template: 1, tags: "proxdeploy-template;debian-13" },
]),
{ type: "qemu", vmid: 100, node: "pve01" },
],
}))
.route("GET", /^\/nodes\/pve01\/network$/, () => ({
data: [{ iface: "vmbr0", type: "bridge", bridge_vlan_aware: o.vlanBridge === false ? 0 : 1 }],
}))
.route("POST", "/nodes/pve01/qemu/9000/clone", () =>
o.cloneBelegt && ++klonVersuche === 1
? { status: 500, statusText: "unable to create VM 101 - VM 101 already exists on node 'pve02'" }
: { data: UPID("qmclone") },
)
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
.route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/(config|resize)$/, () => ({ data: null }))
.route("GET", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({
data: { scsi0: "local-zfs:vm-101-disk-1,size=3G" },
}))
.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/status\/start$/, () => ({ data: UPID("qmstart") }))
.route("POST", /\/agent\/ping$/, () =>
o.agentStumm
? { status: 500, statusText: "QEMU guest agent is not running" }
: { data: { result: {} } },
)
.route("GET", /\/agent\/network-get-interfaces$/, () => ({
data: {
result: [
{ name: "lo", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "127.0.0.1" }] },
{ name: "eth0", "ip-addresses": [{ "ip-address-type": "ipv4", "ip-address": "10.0.10.50" }] },
],
},
}))
.route("POST", /\/agent\/file-write$/, (a) => {
dateien.set(
a.rumpf.get("file")!,
Buffer.from(a.rumpf.get("content")!, a.rumpf.get("encode") === "0" ? "base64" : "utf8").toString(
"utf8",
),
);
return { data: null };
})
.route("POST", /\/agent\/exec$/, () => ({ data: { pid: 4711 } }))
.route("GET", /\/agent\/exec-status$/, () => ({
data: ++execStatusAbfragen < 2 ? { exited: 0 } : { exited: 1, exitcode: o.provisionierungCode ?? 0 },
}))
.route("GET", /\/agent\/file-read$/, () => ({
data: {
content: "::proxdeploy:: profil base\nReading package lists...\n::proxdeploy:: profil docker\n",
},
}));
const client = new ProxmoxClient(
{
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
},
{ trocken: o.trocken },
);
aufraeumen.push(async () => {
await client.schliessen();
await mock.stoppen();
});
return { mock, client, dateien };
}
async function eingabe(client: ProxmoxClient, anfrageTeil: Record<string, unknown> = {}) {
const katalog = await katalogLaden("catalog");
const distribution = katalog.distributionen.find((d) => d.eintrag.id === "debian-13")!.eintrag;
const anfrage = AusrollAnfrage.parse({
typ: "vm",
server: "rz1",
distribution: "debian-13",
name: "web01",
storage: "local-zfs",
diskGb: 40,
netz: {
bridge: "vmbr0",
vlan: 20,
modus: "statisch",
ip: "10.0.20.15/24",
gateway: "10.0.20.1",
dns: ["10.0.20.1"],
},
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
profile: ["docker"],
...anfrageTeil,
});
const aufgeloest = profileAufloesen(
anfrage.profile,
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
"debian",
);
if (!aufgeloest.ok) throw new Error("Profile");
const angelegt: Array<Record<string, unknown>> = [];
return {
angelegt,
e: {
client,
anfrage,
distribution,
profile: aufgeloest.profile,
vmidReservieren: async (belegt: Set<number>, wunsch?: number) =>
wunsch ?? (belegt.has(101) ? 102 : 101),
angelegt: async (x: Record<string, unknown>) => void angelegt.push(x),
auftragKurzId: "d-test1",
warten: async () => {},
agentMs: 50,
},
};
}
describe("VM ausrollen", () => {
it("klont, konfiguriert, setzt cloud-init, startet, wartet auf den Agent und provisioniert", async () => {
const { mock, client, dateien } = await aufbauen();
const { e, angelegt } = await eingabe(client);
const stand: VmAusrollStand = {};
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
expect(stand).toMatchObject({
knoten: "pve01",
template: { vmid: 9000, knoten: "pve01" },
vmid: 101,
ip: "10.0.20.15",
});
expect(angelegt).toContainEqual({ vmid: 101 });
const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf);
expect(klon).toEqual({ newid: "101", name: "web01", full: "1", target: "pve01", storage: "local-zfs" });
const configs = mock.anfragen
.filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config"))
.map((a) => Object.fromEntries(a.rumpf));
expect(configs[0]).toMatchObject({
cores: "2",
memory: "2048",
cpu: "host",
net0: "virtio,bridge=vmbr0,tag=20",
tags: "proxdeploy;debian-13",
});
expect(configs[1]).toMatchObject({
ciuser: "florian",
ipconfig0: "ip=10.0.20.15/24,gw=10.0.20.1",
nameserver: "10.0.20.1",
ciupgrade: "0",
sshkeys: sshkeysKodieren([SCHLUESSEL]),
});
expect(Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/resize"))!.rumpf)).toEqual({
disk: "scsi0",
size: "40G",
});
// Das Skript kommt base64-kodiert an (Umlaute!) und enthält Benutzer und beide Profile.
const skript = [...dateien.values()].join("");
expect(skript).toContain("::proxdeploy:: profil base");
expect(skript).toContain("::proxdeploy:: profil docker");
expect(skript).toContain("useradd -m -s /bin/bash florian");
expect(skript).toContain("Frische Images");
const exec = mock.anfragen.find((a) => a.pfad.endsWith("/agent/exec"))!.rumpf.getAll("command");
expect(exec[0]).toBe("/bin/bash");
expect(exec[2]).toMatch(/> \/var\/log\/proxdeploy-provisionierung\.log 2>&1$/);
});
it("kodiert SSH-Schlüssel so, wie Proxmox sie erwartet (auch / + und Leerzeichen)", () => {
expect(sshkeysKodieren(["ssh-ed25519 AA/B+C= x (y)"])).toBe("ssh-ed25519%20AA%2FB%2BC%3D%20x%20%28y%29");
expect(ipconfig({ bridge: "vmbr0", modus: "dhcp", dns: [] })).toBe("ip=dhcp");
});
it("ohne Template: klare Meldung – mit templateBauen würde gebaut", async () => {
const { client } = await aufbauen({ template: false });
const { e } = await eingabe(client);
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" });
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Template vorher bauen/);
});
it("VLAN auf einer nicht VLAN-fähigen Bridge wird vor dem Klonen abgelehnt", async () => {
const { mock, client } = await aufbauen({ vlanBridge: false });
const { e } = await eingabe(client);
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/nicht VLAN-fähig/);
expect(mock.anfragen.some((a) => a.pfad.endsWith("/clone"))).toBe(false);
});
it("ist die VMID beim Klonen inzwischen belegt, wird einmal mit neuer ID geklont", async () => {
const { mock, client } = await aufbauen({ cloneBelegt: true });
const { e } = await eingabe(client);
const stand: VmAusrollStand = {};
let aufrufe = 0;
e.vmidReservieren = async () => (++aufrufe === 1 ? 101 : 102);
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
expect(stand.vmid).toBe(102);
expect(mock.anfragen.filter((a) => a.pfad.endsWith("/clone")).map((a) => a.rumpf.get("newid"))).toEqual([
"101",
"102",
]);
});
it("ein scheiternder Profillauf nennt das Profil und den Exit-Code", async () => {
const { client } = await aufbauen({ provisionierungCode: 100 });
const { e } = await eingabe(client);
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
expect(ergebnis).toMatchObject({ ok: false, phase: "provisionierung" });
if (!ergebnis.ok)
expect(ergebnis.fehler.ursache).toBe("Provisionierung im Profil docker mit Code 100 abgebrochen.");
});
it("antwortet der Agent nicht, sagt die Phase, woran es liegen kann", async () => {
const { client } = await aufbauen({ agentStumm: true });
const { e } = await eingabe(client);
const ergebnis = await phasenAusfuehren(vmAusrollSchritte(e), stumm);
expect(ergebnis).toMatchObject({ ok: false, phase: "agent" });
if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Konsole/);
});
it("DHCP: die IP kommt vom Guest-Agent (ohne Loopback)", async () => {
const { client } = await aufbauen();
const { e } = await eingabe(client, { netz: { bridge: "vmbr0", modus: "dhcp" } });
const stand: VmAusrollStand = {};
await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm);
expect(stand.ip).toBe("10.0.10.50");
});
it("Trockenlauf: nichts Schreibendes erreicht Proxmox, kein Agent-Aufruf", async () => {
const { mock, client } = await aufbauen({ trocken: true });
const { e } = await eingabe(client);
expect(await phasenAusfuehren(vmAusrollSchritte(e), stumm)).toEqual({ ok: true });
expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]);
expect(mock.anfragen.some((a) => a.pfad.includes("/agent/"))).toBe(false);
expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([
"POST /nodes/pve01/qemu/9000/clone",
"PUT /nodes/pve01/qemu/101/config",
"PUT /nodes/pve01/qemu/101/resize",
"PUT /nodes/pve01/qemu/101/config",
"POST /nodes/pve01/qemu/101/status/start",
]);
});
});