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>
This commit is contained in:
2026-10-12 11:34:15 +02:00
co-authored by Claude Opus 5.5
parent 4285785a4c
commit 9310164bc2
24 changed files with 2465 additions and 27 deletions
+291
View File
@@ -0,0 +1,291 @@
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",
]);
});
});