import { AblaufFehler } from "@/engine/ablauf/fehler"; import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf"; import type { Distribution } from "@/engine/katalog/schema"; import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme"; import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten"; import { q } from "@/engine/shell"; import { adapterFuer } from "@/engine/adapter"; import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { profileRendern } from "@/engine/profile/rendern"; import { agentAbwarten, skriptAusfuehren } from "./gastagent"; /** * Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent * einbauen, als Template markieren. Geprüft gegen die Proxmox-API (docs/architektur.md, Abschnitt 3): * * - **per API** ab PVE 8.4 mit einem Storage vom Inhaltstyp `import`: `download-url` (mit * Prüfsumme, Proxmox prüft selbst) und `import-from=:import/` * - **per SSH** davor oder ohne import-Storage: Download und Prüfung auf dem Node, * `qm set --scsi0 …,import-from=` als root * * Der Guest-Agent ist in keinem der mitgelieferten Images enthalten. Er wird mit einem einmaligen * Vorbereitungsstart eingebaut (cloud-init vendor-data als Snippet, braucht SSH), damit jeder Klon * sofort einen Agent hat – ohne ihn gäbe es bei DHCP weder IP noch Provisionierung. */ /** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */ export type NodeBefehl = ( befehl: string, optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void }, ) => Promise<{ code: number | null; stdout: string; stderr: string }>; export type TemplateBauEingabe = { client: ProxmoxClient; distribution: Distribution; /** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */ profile?: AufgeloestesProfil[]; knoten: string; /** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */ storage: string; bridge: string; vlan?: number; /** null, wenn SSH nicht eingerichtet oder der Host-Key nicht bestätigt ist. */ ssh: NodeBefehl | null; /** VMID im Template-Bereich reservieren (nach Prüfung, wer schon was belegt). */ vmidReservieren: (belegt: Set) => Promise; /** Prüfsummen-Datei holen – im Test ersetzbar. */ holen?: (url: string) => Promise; auftragKurzId: string; jetzt?: () => Date; /** Zeitlimit für den Vorbereitungsstart (Paketinstallation im Gast). */ vorbereitungMs?: number; warten?: (ms: number) => Promise; }; export type TemplateBauStand = { vmid?: number; weg?: "api" | "ssh"; importStorage?: string; snippetStorage?: string; imageDatei?: string; pruefsumme?: string; volid?: string; nodePfad?: string; }; type StorageEintrag = { storage: string; type: string; content: string; active?: number; enabled?: number; shared?: number; }; const schlafen = (ms: number) => new Promise((erfuellen) => setTimeout(erfuellen, ms)); async function standardHolen(url: string): Promise { const antwort = await fetch(url, { signal: AbortSignal.timeout(30_000) }); if (!antwort.ok) throw new AblaufFehler( `Prüfsummen-Datei nicht abrufbar (HTTP ${antwort.status}): ${url}`, "Hat ProxDeploy Internetzugang? Stimmt die URL im Katalog?", ); return await antwort.text(); } export function templateName(distribution: Distribution, profile: string[] = []): string { return ["tpl", distribution.id, ...profile.filter((p) => p !== "base")] .join("-") .replace(/[^a-z0-9-]/g, "-"); } /** * Aufräumen vor dem Template, wenn Profile eingebacken wurden – alles, was je Klon eindeutig sein * muss oder nur Platz kostet. SSH-Host-Keys erzeugt cloud-init bei jedem Klon neu. */ export const TEMPLATE_AUFRAEUMEN = [ "set -e", "truncate -s 0 /etc/machine-id", "rm -f /var/lib/dbus/machine-id", "rm -f /etc/ssh/ssh_host_*", "rm -f /var/lib/docker/engine-id", "apt-get clean || true", "rm -f /var/log/proxdeploy-provisionierung.log", "cloud-init clean --logs", "sync", ].join("; "); /** * vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst * teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten. * SSH-Host-Keys erzeugt cloud-init bei jedem Klon ohnehin neu (neue instance-id). */ export function vorbereitungsSnippet(): string { return `#cloud-config # Von ProxDeploy: einmaliger Vorbereitungsstart beim Template-Bau package_update: true packages: - qemu-guest-agent runcmd: - [systemctl, enable, qemu-guest-agent] - [sh, -c, "truncate -s 0 /etc/machine-id && rm -f /var/lib/dbus/machine-id && cloud-init clean --logs"] power_state: mode: poweroff delay: now message: ProxDeploy-Vorbereitung abgeschlossen timeout: 300 condition: true `; } export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] { const { client, distribution: d, knoten } = e; const vm = d.vm; if (!vm) throw new AblaufFehler( `${d.name} bietet kein VM-Image.`, "Im Katalog vm: ergänzen oder eine andere Distribution wählen.", ); const holen = e.holen ?? standardHolen; const warten = e.warten ?? schlafen; const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`; const profilIds = (e.profile ?? []).map((p) => p.profil.id); const name = templateName(d, profilIds); const ssh = async ( befehl: string, k: PhasenKontext, optionen?: { eingabe?: string; zeitlimitMs?: number }, ) => { if (!e.ssh) throw new AblaufFehler( "Dafür ist SSH zum Node nötig.", "SSH in den Server-Einstellungen einrichten und den Host-Key bestätigen.", ); if (client.trocken) { await k.log(`[ssh ${knoten}] ${befehl}`, "info"); return { code: 0, stdout: "", stderr: "" }; } await k.log(`[ssh ${knoten}] ${befehl.split("\n")[0]}`, "debug"); const ergebnis = await e.ssh(befehl, optionen); if (ergebnis.code !== 0) { throw new AblaufFehler( `Befehl auf ${knoten} fehlgeschlagen (Code ${ergebnis.code}): ${(ergebnis.stderr || ergebnis.stdout).trim().split("\n").slice(-3).join(" | ")}`, "Detail-Log prüfen.", ); } return ergebnis; }; return [ { schluessel: "pruefungen", titel: "Prüfungen", ausfuehren: async () => { const version = await client.get<{ version: string }>(pfad`/nodes/${knoten}/version`).catch(() => { throw new AblaufFehler( `Node ${knoten} ist nicht erreichbar oder unbekannt.`, "Node-Namen und Cluster-Status prüfen.", ); }); const pve = versionLesen(version.version)!; const storages = await client.get(pfad`/nodes/${knoten}/storage`); const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0; const ziel = storages.find((s) => s.storage === e.storage); if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("images")) { throw new AblaufFehler( `Storage ${e.storage} ist auf ${knoten} nicht aktiv oder nimmt keine VM-Disks auf.`, `Geeignet sind: ${ storages .filter((s) => aktiv(s) && s.content.includes("images")) .map((s) => s.storage) .join(", ") || "keiner" }.`, ); } const importSpeicher = storages.find((s) => aktiv(s) && s.content.split(",").includes("import")); stand.snippetStorage = storages.find( (s) => aktiv(s) && s.content.split(",").includes("snippets"), )?.storage; if (mindestens(pve, 8, 4) && importSpeicher) { stand.weg = "api"; stand.importStorage = importSpeicher.storage; } else if (e.ssh) { stand.weg = "ssh"; } else { throw new AblaufFehler( mindestens(pve, 8, 4) ? `Auf ${knoten} gibt es keinen Storage mit Inhaltstyp „Import“.` : `PVE ${pve.text} kann Disk-Images erst ab 8.4 per API laden.`, mindestens(pve, 8, 4) ? "Einem Dir-Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten." : "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.", ); } if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) { throw new AblaufFehler( "Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.", e.ssh ? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben." : "SSH in den Server-Einstellungen einrichten.", ); } const netze = await client.get>( pfad`/nodes/${knoten}/network`, ); if (!netze.some((n) => n.iface === e.bridge && (n.type === "bridge" || n.type === "OVSBridge"))) { throw new AblaufFehler( `Bridge ${e.bridge} gibt es auf ${knoten} nicht.`, "Bridge in den Server-Standards oder im Formular korrigieren.", ); } const belegt = new Set( (await client.get>("/cluster/resources", { type: "vm" })) .map((r) => r.vmid!) .filter(Boolean), ); stand.vmid = await e.vmidReservieren(belegt); return { detail: `VMID ${stand.vmid} frei · ${e.storage} · ${stand.weg === "api" ? `Import über ${stand.importStorage}` : "Import per SSH"} · ${e.bridge}${e.vlan ? ` VLAN ${e.vlan}` : ""}`, }; }, }, { schluessel: "image", titel: "Image laden", ausfuehren: async (k) => { const quelle = dateiAusUrl(vm.image_url); await k.detail("Prüfsumme holen …"); const summen = await holen(vm.checksum_url); const hash = pruefsummeFinden(summen, quelle, vm.checksum_type); if (!hash) { throw new AblaufFehler( `${quelle} steht nicht in ${vm.checksum_url}.`, "Image- und Prüfsummen-URL im Katalog prüfen – der Anbieter hat die Dateien vielleicht umbenannt.", ); } stand.pruefsumme = hash; // Die Prüfsumme im Dateinamen: ein neues „latest“-Image wird neu geladen, ein gleiches nicht. stand.imageDatei = `proxdeploy-${d.id}-${hash.slice(0, 12)}.${vm.format}`; await k.log(`${vm.checksum_type} ${hash} für ${quelle}`); if (stand.weg === "api") { const volid = `${stand.importStorage}:import/${stand.imageDatei}`; stand.volid = volid; const vorhanden = await client.get>( pfad`/nodes/${knoten}/storage/${stand.importStorage!}/content`, { content: "import" }, ); if (vorhanden.some((v) => v.volid === volid)) return { detail: `${stand.imageDatei} schon vorhanden` }; const upid = await client.post( pfad`/nodes/${knoten}/storage/${stand.importStorage!}/download-url`, { content: "import", filename: stand.imageDatei, url: vm.image_url, checksum: hash, "checksum-algorithm": vm.checksum_type, "verify-certificates": true, }, ); await aufgabeAbwarten(client, upid, { zeitlimitMs: 60 * 60 * 1000, intervallMs: 2000, beiLog: (zeilen) => { const prozent = zeilen .map((z) => /(\d+(?:\.\d+)?)%/.exec(z)?.[1]) .filter(Boolean) .at(-1); if (prozent) void k.detail(`${quelle} · ${Math.round(Number(prozent))} %`); for (const z of zeilen) void k.log(z, "debug"); }, }); return { detail: `${quelle} geladen und geprüft (${vm.checksum_type})` }; } // SSH: auf den Node laden und dort prüfen; vorhandene gültige Datei wird wiederverwendet. const ordner = "/var/tmp/proxdeploy"; stand.nodePfad = `${ordner}/${stand.imageDatei}`; const pruefen = `echo ${q(`${hash} ${stand.nodePfad}`)} | ${vm.checksum_type}sum -c --status`; await k.detail(`${quelle} wird auf ${knoten} geladen …`); await ssh( `set -e; mkdir -p ${ordner}; if ! ${pruefen} 2>/dev/null; then curl -fsSL --retry 3 -o ${q(stand.nodePfad + ".teil")} ${q(vm.image_url)} && mv ${q(stand.nodePfad + ".teil")} ${q(stand.nodePfad)}; fi; ${pruefen}`, k, { zeitlimitMs: 60 * 60 * 1000 }, ); return { detail: `${quelle} auf ${knoten} geladen und geprüft (${vm.checksum_type})` }; }, }, { schluessel: "vm", titel: "VM anlegen", ausfuehren: async (k) => { const vmid = stand.vmid!; const jetzt = (e.jetzt?.() ?? new Date()).toISOString(); const importVon = stand.weg === "api" ? stand.volid! : stand.nodePfad!; const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`; const parameter = { vmid, name, ostype: "l26", machine: "q35", bios: vm.bios, ...(vm.bios === "ovmf" ? { efidisk0: `${e.storage}:1,efitype=4m,pre-enrolled-keys=1` } : {}), cpu: "host", cores: 2, memory: 2048, scsihw: "virtio-scsi-single", net0: `virtio,bridge=${e.bridge}${e.vlan ? `,tag=${e.vlan}` : ""}`, serial0: "socket", vga: "serial0", agent: "enabled=1,fstrim_cloned_disks=1", ide2: `${e.storage}:cloudinit`, ipconfig0: "ip=dhcp", ciupgrade: false, tags: ["proxdeploy-template", d.id, ...profilIds.map((p) => `profil-${p}`)].join(";"), description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}${ profilIds.length > 0 ? `\nEingebackene Profile: ${profilIds.join(", ")}` : "" }\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`, }; if (stand.weg === "api") { await aufgabeAbwarten( client, await client.post(pfad`/nodes/${knoten}/qemu`, { ...parameter, scsi0: disk, boot: "order=scsi0", }), { zeitlimitMs: 30 * 60 * 1000, }, ); } else { // Ein absoluter Pfad in import-from ist per API nur root@pam erlaubt – daher qm über SSH. await aufgabeAbwarten(client, await client.post(pfad`/nodes/${knoten}/qemu`, parameter), { zeitlimitMs: 10 * 60 * 1000, }); await k.detail("Disk wird importiert …"); await ssh(`qm set ${vmid} --scsi0 ${q(disk)} --boot order=scsi0`, k, { zeitlimitMs: 30 * 60 * 1000, }); } return { detail: `VM ${vmid} (${name}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`, }; }, }, { schluessel: "agent", titel: "Guest-Agent", ausfuehren: async (k) => { if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" }; const vmid = stand.vmid!; const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`; const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`; await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, { eingabe: vorbereitungsSnippet(), }); try { await client.put(`${vmPfad}/config`, { cicustom: `vendor=${snippet}` }); await aufgabeAbwarten(client, await client.post(`${vmPfad}/status/start`), { zeitlimitMs: 5 * 60 * 1000, }); if (!client.trocken) { const beginn = Date.now(); const grenze = e.vorbereitungMs ?? 20 * 60 * 1000; for (;;) { await k.pruefeAbbruch(); const status = await client.get<{ status: string }>(`${vmPfad}/status/current`); if (status.status === "stopped") break; if (Date.now() - beginn > grenze) { throw new AblaufFehler( `Die Vorbereitung von VM ${vmid} ist nach ${Math.round(grenze / 60000)} min nicht fertig.`, "Hat die VM per DHCP eine Adresse und Internetzugang (für qemu-guest-agent)? Konsole der VM ansehen.", ); } await k.detail( `Vorbereitungsstart: qemu-guest-agent wird installiert … ${Math.round((Date.now() - beginn) / 1000)}s`, ); await warten(5000); } } } finally { // Auch nach einem Fehler: das Snippet soll nicht im Template hängen bleiben. await client.put(`${vmPfad}/config`, { delete: "cicustom" }).catch(() => undefined); await ssh(`rm -f "$(pvesm path ${q(snippet)})"`, k).catch(() => undefined); } return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" }; }, }, { schluessel: "profile", titel: "Profile", ausfuehren: async (k) => { if (profilIds.length === 0) return { uebersprungen: true, detail: "keine eingebacken" }; 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 vmPfad = pfad`/nodes/${knoten}/qemu/${stand.vmid!}`; const { skript } = profileRendern(e.profile!, adapter, { os: d.os, gast: { name, benutzer: vm.default_user, typ: "vm" }, }); if (client.trocken) { await k.log(skript, "info"); return { detail: `${profilIds.join(", ")} würden eingebacken` }; } await aufgabeAbwarten(client, await client.post(`${vmPfad}/status/start`), { zeitlimitMs: 5 * 60 * 1000, }); await agentAbwarten(client, vmPfad, k, { grenzeMs: 5 * 60 * 1000, warten }); await skriptAusfuehren(client, vmPfad, skript, k, { grenzeMs: 60 * 60 * 1000, warten }); await k.detail("aufräumen und herunterfahren …"); // Über denselben Weg wie die Profile: wartet aufs Ende, statt eine Pause zu raten. await skriptAusfuehren(client, vmPfad, `#!/bin/bash\n${TEMPLATE_AUFRAEUMEN}\n`, k, { grenzeMs: 5 * 60 * 1000, warten, }); await aufgabeAbwarten( client, await client.post(`${vmPfad}/status/shutdown`, { timeout: 180 }), { zeitlimitMs: 5 * 60 * 1000 }, ); for (let i = 0; i < 60; i += 1) { if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break; await warten(3000); } return { detail: `${profilIds.join(", ")} eingebacken · Maschinen-ID und Host-Keys entfernt` }; }, }, { schluessel: "template", titel: "Template", ausfuehren: async () => { await aufgabeAbwarten( client, await client.post(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`), { zeitlimitMs: 10 * 60 * 1000 }, ); return { detail: `VM ${stand.vmid} ist jetzt Template ${name}` }; }, }, ]; }