Files
proxdeploy/tests/engine/vm-ausrollen.test.ts
florian.wildeandClaude Opus 5.5 7f6cf00a0e feat(verlauf): Filter, Wiederholen und Aufräumen
Verlauf filterbar (URL, API, proxdeploy history); Wiederholen füllt den Assistenten aus einem alten Auftrag; Aufräumen löscht nach Fehlschlag oder Abbruch den halb angelegten Gast – nur, wenn dessen Beschreibung auf den Auftrag verweist. Deshalb setzt das Klonen die Beschreibung jetzt sofort. Auf pve-demo geprüft (CT 109).

Closes #014

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:39:58 +02:00

351 lines
14 KiB
TypeScript
Raw Permalink 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("GET", /\/tasks\/[^/]+\/log$/, () => ({
data: [{ n: 1, t: "transferred 1.2 GiB of 3.5 GiB (35.16%)" }],
}))
.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",
anfrage.variablen,
);
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).toMatchObject({
newid: "101",
name: "web01",
full: "1",
target: "pve01",
storage: "local-zfs",
});
// Schon beim Klonen erkennbar als Gast dieses Auftrags – davon hängt „Aufräumen“ ab.
expect(klon["description"]).toMatch(/^Ausgerollt mit ProxDeploy \(Auftrag d-test1\)/);
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",
]);
});
});
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");
});
});