Files
proxdeploy/tests/engine/vm-ausrollen.test.ts
T
florian.wildeandClaude Opus 5.5 ebcf63100a feat(ubuntu): Ubuntu als VM und LXC, Fortschritt beim Klonen
Ubuntu 24.04 auf pve-demo als VM (Template im selben Auftrag gebaut)
und als Container ausgerollt; 22.04 und 26.04 laufen im Containertest.
Der Voll-Klon brauchte auf dem Testhost neun Minuten – die Phase zeigt
jetzt den Fortschritt aus dem Task-Log.

Closes #010

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:44:37 +02:00

295 lines
12 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("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",
);
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",
]);
});
});