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:
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { POST as ausrollenPost } from "@/app/api/v1/auftraege/route";
|
||||
import { verschluesseln } from "@/lib/geheimnisse";
|
||||
import { cliBefehl, AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
import { angemeldet, anfrage } from "./helpers/anfragen";
|
||||
|
||||
const SCHLUESSEL =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
|
||||
const gueltig = {
|
||||
typ: "vm",
|
||||
server: "rz1",
|
||||
distribution: "debian-13",
|
||||
name: "web01",
|
||||
storage: "local-zfs",
|
||||
netz: { bridge: "vmbr0" },
|
||||
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
|
||||
profile: ["docker"],
|
||||
};
|
||||
|
||||
describe("CLI-Befehl aus der Anfrage", () => {
|
||||
it("nennt nur Abweichungen vom Standard und entspricht der Vorgabe", () => {
|
||||
const a = AusrollAnfrage.parse({
|
||||
...gueltig,
|
||||
profile: ["base", "docker"],
|
||||
netz: { bridge: "vmbr0", vlan: 20, modus: "statisch", ip: "10.0.20.15/24", gateway: "10.0.20.1" },
|
||||
cores: 2,
|
||||
speicherMb: 4096,
|
||||
diskGb: 40,
|
||||
});
|
||||
expect(cliBefehl(a)).toBe(
|
||||
"proxdeploy vm web01 --server rz1 --distro debian-13 --profile base,docker --ip 10.0.20.15/24 --gw 10.0.20.1 --vlan 20 --memory 4096 --disk 40G --storage local-zfs --user florian",
|
||||
);
|
||||
});
|
||||
|
||||
it("quotet Variablenwerte mit Leerzeichen", () => {
|
||||
const a = AusrollAnfrage.parse({ ...gueltig, variablen: { "zabbix-agent": { server: "a b" } } });
|
||||
expect(cliBefehl(a)).toContain("--var zabbix-agent.server='a b'");
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("POST /api/v1/auftraege", () => {
|
||||
let cookie: string;
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
await katalogSynchronisieren();
|
||||
cookie = (await angemeldet("OPERATOR")).cookie;
|
||||
await testPrisma.server.create({
|
||||
data: {
|
||||
name: "rz1",
|
||||
titel: "RZ 1",
|
||||
url: "https://127.0.0.1:8006",
|
||||
tokenId: "a@pve!b",
|
||||
tokenGeheimnis: verschluesseln("x"),
|
||||
fingerabdruck: "AA",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const senden = async (rumpf: unknown) => {
|
||||
const antwort = await ausrollenPost(anfrage("POST", "/api/v1/auftraege", cookie, rumpf));
|
||||
return { status: antwort.status, inhalt: await antwort.json() };
|
||||
};
|
||||
|
||||
it("meldet Feldfehler einzeln: Name, Schlüssel, root", async () => {
|
||||
const { status, inhalt } = await senden({
|
||||
...gueltig,
|
||||
name: "Web_01",
|
||||
zugang: { benutzer: "root", sshSchluessel: ["kein schlüssel"] },
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(Object.keys(inhalt.fehler.felder)).toEqual(
|
||||
expect.arrayContaining(["name", "zugang.benutzer", "zugang.sshSchluessel.0"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("fehlende Profil-Variable landet am richtigen Feld", async () => {
|
||||
const { status, inhalt } = await senden({ ...gueltig, profile: ["zabbix-agent"] });
|
||||
expect(status).toBe(400);
|
||||
expect(inhalt.fehler.felder).toEqual({ "variablen.zabbix-agent.server": "Zabbix-Server fehlt." });
|
||||
});
|
||||
|
||||
it("deaktivierte Distribution und unbekannter Server werden abgewiesen", async () => {
|
||||
await testPrisma.distribution.update({ where: { id: "debian-13" }, data: { aktiv: false } });
|
||||
expect((await senden(gueltig)).status).toBe(400);
|
||||
expect((await senden({ ...gueltig, server: "gibtsnicht" })).status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user