Beim Template-Bau lassen sich Profile wählen (nur solche ohne Pflichtvariablen). Nach dem Vorbereitungsstart laufen sie über den Guest-Agent; danach werden Maschinen-ID, SSH-Host-Keys, Docker-Engine-ID und apt-Cache entfernt. Ausrollen nimmt das Template mit den meisten passenden Profilen und überspringt, was schon eingebacken ist. Auf pve-demo: Provisionierung mit base+docker 141 s ohne, ~30 s mit Template. Dabei gefunden: docker nimmt den Benutzer per guest.user in die Gruppe auf – Profile mit guest.* laufen deshalb beim Deploy noch einmal (idempotent, schnell). Closes #022 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
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).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",
|
||
]);
|
||
});
|
||
});
|
||
|
||
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");
|
||
});
|
||
});
|