import { AblaufFehler } from "@/engine/ablauf/fehler"; import type { PhasenSchritt } from "@/engine/ablauf/ablauf"; import { adapterFuer } from "@/engine/adapter"; import type { Distribution } from "@/engine/katalog/schema"; import { profileRendern, MARKE } from "@/engine/profile/rendern"; import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxFehler } from "@/engine/proxmox/fehler"; import type { AusrollAnfrage } from "@/lib/schemas/ausrollen"; import { templateBauSchritte, type TemplateBauEingabe, type TemplateBauStand } from "./template-bau"; /** * Eine VM aus einem ProxDeploy-Template ausrollen – vollständig per API, ohne SSH: * Klonen, Hardware, cloud-init (Benutzer, Schlüssel, Netz), Start, Guest-Agent, Provisionierung * der Profile über den Guest-Agent (Skript per file-write, Ausführung per exec, Fortschritt aus der * Logdatei im Gast). */ export type VmAusrollEingabe = { client: ProxmoxClient; anfrage: AusrollAnfrage; distribution: Distribution; profile: AufgeloestesProfil[]; /** Gast-VMID reservieren (nach Prüfung, was belegt ist); im Trockenlauf nur ermitteln. */ vmidReservieren: (belegt: Set, wunsch?: number) => Promise; /** Für ein fehlendes Template (nur mit anfrage.templateBauen). */ templateBau?: Omit< TemplateBauEingabe, "client" | "distribution" | "knoten" | "storage" | "bridge" | "vlan" | "ssh" > & { /** SSH zum Node – erst bekannt, wenn die Prüfung den Node gewählt hat. */ sshFuer: (knoten: string) => TemplateBauEingabe["ssh"]; }; angelegt: (eintrag: Record) => Promise; auftragKurzId: string; warten?: (ms: number) => Promise; /** Zeitlimits – im Test kürzer. */ agentMs?: number; provisionierungMs?: number; }; export type VmAusrollStand = { knoten?: string; template?: { vmid: number; knoten: string }; vmid?: number; ip?: string; agentNachMs?: number; }; type Ressource = { vmid?: number; node?: string; template?: number; tags?: string; type?: string; status?: string; maxmem?: number; mem?: number; name?: string; }; type StorageEintrag = { storage: string; content: string; active?: number; enabled?: number; shared?: number; }; const schlafen = (ms: number) => new Promise((erfuellen) => setTimeout(erfuellen, ms)); /** Proxmox erwartet die Schlüssel URI-kodiert – strenger als encodeURIComponent (auch ! ' ( ) *). */ export function sshkeysKodieren(schluessel: string[]): string { return encodeURIComponent(schluessel.join("\n")).replace( /[!'()*]/g, (z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`, ); } export function ipconfig(netz: AusrollAnfrage["netz"]): string { if (netz.modus === "dhcp") return "ip=dhcp"; return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`; } const SKRIPT = "/root/proxdeploy-provisionierung.sh"; const LOG = "/var/log/proxdeploy-provisionierung.log"; /** * file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst * als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in * subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64. */ const STUECK = 40 * 1024; type AgentAntwort = { result: T }; type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string }; type Netzschnittstelle = { name: string; "ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>; }; export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {}): PhasenSchritt[] { const { client, anfrage: a, distribution: d } = e; const warten = e.warten ?? schlafen; const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`; // Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt, // ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten. const agent = async ( befehl: string, methode: "GET" | "POST" = "POST", parameter?: Record, ) => await client.aufrufen({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter }); const templateStand: TemplateBauStand = {}; const schritte: PhasenSchritt[] = [ { schluessel: "pruefungen", titel: "Prüfungen", ausfuehren: async () => { if (!d.vm) throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen."); const ressourcen = await client.get("/cluster/resources"); const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online"); const templates = ressourcen.filter( (r) => r.type === "qemu" && r.template === 1 && (r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") && (r.tags ?? "").split(/[;, ]/).includes(d.id), ); // Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM. stand.knoten = a.knoten ?? templates.find((t) => nodes.some((n) => n.node === t.node))?.node ?? [...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0] ?.node; if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) { throw new AblaufFehler( `Node ${stand.knoten ?? "?"} ist nicht online.`, "Anderen Node wählen oder Cluster-Status prüfen.", ); } const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0]; if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! }; const storages = await client.get(pfad`/nodes/${stand.knoten}/storage`); const ziel = storages.find((s) => s.storage === a.storage); if (!ziel || ziel.active !== 1 || !ziel.content.split(",").includes("images")) { throw new AblaufFehler( `Storage ${a.storage} nimmt auf ${stand.knoten} keine VM-Disks auf.`, "Anderen Storage wählen.", ); } if (tpl && tpl.node !== stand.knoten && ziel.shared !== 1) { // Ein Voll-Klon auf einen anderen Node geht nur, wenn das Template auf geteiltem Storage liegt. throw new AblaufFehler( `Das Template ${tpl.vmid} liegt auf ${tpl.node}, die VM soll auf ${stand.knoten}.`, `Node ${tpl.node} wählen oder auf ${stand.knoten} ein eigenes Template bauen.`, ); } const netze = await client.get>( pfad`/nodes/${stand.knoten}/network`, ); const bridge = netze.find( (n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"), ); if (!bridge) throw new AblaufFehler( `Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`, "Andere Bridge wählen.", ); if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) { throw new AblaufFehler( `Bridge ${a.netz.bridge} ist nicht VLAN-fähig – VLAN ${a.netz.vlan} würde nicht ankommen.`, "In Proxmox „VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.", ); } if (!tpl && !a.templateBauen) { throw new AblaufFehler( `Auf diesem Server gibt es kein ProxDeploy-Template für ${d.name}.`, "Unter „Templates“ eins bauen oder beim Ausrollen „Template vorher bauen“ wählen.", ); } const belegt = new Set( ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"), ); stand.vmid = await e.vmidReservieren(belegt, a.vmid); await e.angelegt({ knoten: stand.knoten, typ: "VM" }); return { detail: `VMID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""}`, }; }, }, { schluessel: "template", titel: "Template", ausfuehren: async (k) => { if (stand.template) return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` }; if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen."); // Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail. const unter = templateBauSchritte( { ...e.templateBau, ssh: e.templateBau.sshFuer(stand.knoten!), client, distribution: d, knoten: stand.knoten!, storage: a.storage, bridge: a.netz.bridge, vlan: a.netz.vlan, }, templateStand, ); for (const schritt of unter) { await k.detail(`${schritt.titel} …`); const ergebnis = await schritt.ausfuehren({ ...k, detail: (text) => k.detail(`${schritt.titel}: ${text}`), }); await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`); } stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! }; return { detail: `${templateStand.vmid} gebaut` }; }, }, { schluessel: "klonen", titel: "Klonen", ausfuehren: async (k) => { const tpl = stand.template!; const klonen = async (vmid: number) => await aufgabeAbwarten( client, await client.post(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, { newid: vmid, name: a.name, full: true, target: stand.knoten!, storage: a.storage, }), { zeitlimitMs: 60 * 60 * 1000, intervallMs: 2000, // „transferred 1.2 GiB of 3.5 GiB (35.16%)“ – auf langsamen Storages dauert ein Voll-Klon. beiLog: (zeilen) => { const letzte = zeilen .map((z) => /transferred (.+) of (.+) \((\d+(?:\.\d+)?)%\)/.exec(z)) .filter(Boolean) .at(-1); if (letzte) void k.detail(`${letzte[1]} von ${letzte[2]} (${Math.round(Number(letzte[3]))} %)`); }, }, ); try { await klonen(stand.vmid!); } catch (fehler) { // Gleichzeitig außerhalb von ProxDeploy vergeben? Einmal mit neuer ID versuchen. if (!(fehler instanceof ProxmoxFehler) || !/already exists/i.test(fehler.ursache) || a.vmid) throw fehler; const belegt = new Set( (await client.get("/cluster/resources", { type: "vm" })).map((r) => r.vmid!), ); stand.vmid = await e.vmidReservieren(belegt); await k.log(`VMID war inzwischen belegt – neuer Versuch mit ${stand.vmid}`, "warn"); await klonen(stand.vmid); } await e.angelegt({ vmid: stand.vmid }); return { detail: `full clone ${tpl.vmid} → ${stand.vmid}` }; }, }, { schluessel: "hardware", titel: "Hardware", ausfuehren: async () => { await client.put(`${vmPfad()}/config`, { cores: a.cores, memory: a.speicherMb, cpu: "host", net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`, onboot: a.startBeiBoot, tags: ["proxdeploy", d.id, ...a.tags].join(";"), description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`, }); // Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern. const konfig = client.trocken ? {} : await client.get>(`${vmPfad()}/config`); const groesse = /size=(\d+)G/.exec(konfig["scsi0"] ?? "")?.[1]; if (client.trocken || !groesse || Number(groesse) < a.diskGb) { await client.put(`${vmPfad()}/resize`, { disk: "scsi0", size: `${a.diskGb}G` }); } return { detail: `${a.cores} CPU · ${a.speicherMb} MB · Disk ${a.diskGb}G` }; }, }, { schluessel: "cloudinit", titel: "cloud-init", ausfuehren: async () => { await client.put(`${vmPfad()}/config`, { ciuser: a.zugang.benutzer, sshkeys: sshkeysKodieren(a.zugang.sshSchluessel), ipconfig0: ipconfig(a.netz), ...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}), ...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}), // Updates macht das Profil base als sichtbare Phase, nicht stumm beim ersten Boot. ciupgrade: false, }); const schluessel = a.zugang.sshSchluessel.length; return { detail: `user ${a.zugang.benutzer} · ${schluessel} SSH-Key${schluessel === 1 ? "" : "s"} · ${a.netz.modus === "dhcp" ? "DHCP" : a.netz.ip}${ e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : "" }`, }; }, }, { schluessel: "start", titel: "Start", ausfuehren: async () => { await aufgabeAbwarten(client, await client.post(`${vmPfad()}/status/start`), { zeitlimitMs: 5 * 60 * 1000, }); return { detail: `VM ${stand.vmid} läuft` }; }, }, { schluessel: "agent", titel: "Guest-Agent", ausfuehren: async (k) => { if (client.trocken) return { detail: "wartet auf Antwort (ping)" }; const beginn = Date.now(); const grenze = e.agentMs ?? 5 * 60 * 1000; for (;;) { await k.pruefeAbbruch(); try { await agent("ping"); break; } catch (fehler) { if (Date.now() - beginn > grenze) { throw new AblaufFehler( `Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`, "Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?", ); } if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler; } await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`); await warten(3000); } stand.agentNachMs = Date.now() - beginn; // IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback). if (a.netz.modus === "statisch") { stand.ip = a.netz.ip!.split("/")[0]; } else { for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) { const schnittstellen = ( await agent>("network-get-interfaces", "GET") ).result; stand.ip = schnittstellen .filter((s) => s.name !== "lo") .flatMap((s) => s["ip-addresses"] ?? []) .find((ip) => ip["ip-address-type"] === "ipv4" && !ip["ip-address"].startsWith("169.254."))?.[ "ip-address" ]; if (!stand.ip) await warten(3000); } } return { detail: `antwortet nach ${Math.round(stand.agentNachMs / 1000)}s${stand.ip ? ` · IP ${stand.ip}` : ""}`, }; }, }, { schluessel: "provisionierung", titel: "Provisionierung", ausfuehren: async (k) => { const adapter = adapterFuer(d.family); if (!adapter) throw new AblaufFehler( `Für die Familie „${d.family}“ gibt es keinen Adapter.`, "Adapter ergänzen (docs/katalog.md).", ); const { skript } = profileRendern( e.profile, adapter, { os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } }, [ adapter.benutzerAnlegen({ name: a.zugang.benutzer, sshSchluessel: a.zugang.sshSchluessel, sudo: true, }), ], ); await k.log( `Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`, "debug", ); if (client.trocken) { await k.log(skript, "info"); return { detail: e.profile.length > 0 ? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : "nur Benutzer", }; } // Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen. const bytes = Buffer.from(skript, "utf8"); const stuecke: Buffer[] = []; for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK)); for (const [nr, inhalt] of stuecke.entries()) { await agent("file-write", "POST", { file: `${SKRIPT}.${nr}`, content: inhalt.toString("base64"), encode: "0", }); } const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" "); const { pid } = await agent<{ pid: number }>("exec", "POST", { command: [ "/bin/bash", "-c", `cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`, ], }); const beginn = Date.now(); const grenze = e.provisionierungMs ?? 60 * 60 * 1000; let gelesen = 0; let profil = ""; for (;;) { await warten(3000); const status = await agent("exec-status", "GET", { pid: String(pid) }); // Fortschritt: neue Zeilen der Logdatei im Gast. try { const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", { file: LOG, }); const zeilen = datei.content.split("\n"); for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) { const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile); if (marke) profil = marke[1]!; await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug"); } gelesen = Math.max(gelesen, zeilen.length - 1); } catch { // Logdatei noch nicht da – beim nächsten Mal. } const sekunden = Math.round((Date.now() - beginn) / 1000); if (status.exited) { if (status.exitcode !== 0) { throw new AblaufFehler( `Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`, `Detail-Log oder ${LOG} in der VM ansehen.`, ); } break; } if (Date.now() - beginn > grenze) { throw new AblaufFehler( `Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`, `${LOG} in der VM ansehen.`, ); } await k.detail( `${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`, ); } await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined); return { detail: e.profile.length > 0 ? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet` : "Benutzer eingerichtet", }; }, }, ]; return schritte; } /** SSH-Befehl für die Ergebnis-Karte. */ export function sshBefehl(benutzer: string, ip: string | undefined): string | undefined { return ip ? `ssh ${benutzer}@${ip}` : undefined; }