import { AblaufFehler } from "@/engine/ablauf/fehler"; import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf"; import { adapterFuer } from "@/engine/adapter"; import { templateWaehlen } from "@/engine/katalog/lxc-template"; import type { Distribution } from "@/engine/katalog/schema"; import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { MARKE, profileRendern } from "@/engine/profile/rendern"; import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; import { q } from "@/engine/shell"; import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung"; import type { AusrollAnfrage } from "@/lib/schemas/ausrollen"; import type { NodeBefehl } from "./template-bau"; /** * Einen LXC-Container ausrollen. Anlegen, Netz und Start gehen per API; Befehle im Container * nicht – Proxmox hat dafür keine API (geprüft gegen das Schema von PVE 9.2). Provisioniert wird * deshalb per SSH, auf einem von zwei Wegen: * * - **Node**: `pct exec -- bash -s` über SSH zum Node. Braucht keinen Netzweg zum Container. * - **Gast**: SSH als root direkt in den Container mit dem ProxDeploy-Bereitstellungsschlüssel, den * das Anlegen per `ssh-public-keys` hinterlegt; am Ende wird er wieder entfernt. */ export type Bereitstellungsschluessel = { privat: string; oeffentlich: string }; export type LxcAusrollEingabe = { client: ProxmoxClient; anfrage: AusrollAnfrage; distribution: Distribution; profile: AufgeloestesProfil[]; /** Container-Features, die Profile verlangen (z. B. docker → nesting). */ profilFeatures: { nesting: boolean; keyctl: boolean }; vmidReservieren: (belegt: Set, wunsch?: number) => Promise; sshFuer: (knoten: string) => NodeBefehl | null; bereitstellung: () => Promise; angelegt: (eintrag: Record) => Promise; auftragKurzId: string; warten?: (ms: number) => Promise; netzMs?: number; /** Nur für Tests: SSH in den Gast ersetzen. */ gastSsh?: ( ziel: { host: string; schluessel: Bereitstellungsschluessel }, skript: string, beiAusgabe: (t: string) => void, warten: (ms: number) => Promise, ) => Promise; }; export type LxcAusrollStand = { knoten?: string; vmid?: number; ostemplate?: string; vztmplStorage?: string; ip?: string; weg?: "node" | "gast"; }; type Ressource = { vmid?: number; node?: string; type?: string; status?: string; maxmem?: number; mem?: number; }; type StorageEintrag = { storage: string; content: string; active?: number; enabled?: number }; type Aplinfo = { template: string; section?: string; package?: string }; const schlafen = (ms: number) => new Promise((erfuellen) => setTimeout(erfuellen, ms)); /** Markenzeilen der Provisionierung in Detail und Log übersetzen – für beide Wege gleich. */ export function ausgabeVerfolgen(k: PhasenKontext) { let rest = ""; let profil = ""; const beginn = Date.now(); return { get profil() { return profil; }, weiter(text: string) { rest += text; const zeilen = rest.split("\n"); rest = zeilen.pop() ?? ""; for (const zeile of zeilen) { const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile); if (marke) { profil = marke[1]!; const s = Math.round((Date.now() - beginn) / 1000); void k.detail( `Profil ${profil} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`, ); } void k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug"); } }, }; } export function lxcNetz(netz: AusrollAnfrage["netz"]): string { return [ "name=eth0", `bridge=${netz.bridge}`, ...(netz.vlan ? [`tag=${netz.vlan}`] : []), netz.modus === "dhcp" ? "ip=dhcp" : `ip=${netz.ip}`, ...(netz.modus === "statisch" && netz.gateway ? [`gw=${netz.gateway}`] : []), "type=veth", ].join(","); } export function lxcAusrollSchritte(e: LxcAusrollEingabe, stand: LxcAusrollStand = {}): PhasenSchritt[] { const { client, anfrage: a, distribution: d } = e; const warten = e.warten ?? schlafen; const lxc = d.lxc; const ctPfad = () => pfad`/nodes/${stand.knoten!}/lxc/${stand.vmid!}`; const features = { nesting: a.features.nesting || e.profilFeatures.nesting, keyctl: a.features.keyctl || e.profilFeatures.keyctl, }; let bereitstellung: Bereitstellungsschluessel | undefined; return [ { schluessel: "pruefungen", titel: "Prüfungen", ausfuehren: async () => { if (!lxc) throw new AblaufFehler( `${d.name} gibt es nicht als LXC-Container.`, "Eine andere Distribution wählen.", ); const ressourcen = await client.get("/cluster/resources"); const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online"); stand.knoten = a.knoten ?? [...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."); } const storages = await client.get(pfad`/nodes/${stand.knoten}/storage`); const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0; const ziel = storages.find((s) => s.storage === a.storage); if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("rootdir")) { throw new AblaufFehler( `Storage ${a.storage} nimmt auf ${stand.knoten} keine Container auf.`, `Geeignet sind: ${ storages .filter((s) => aktiv(s) && s.content.includes("rootdir")) .map((s) => s.storage) .join(", ") || "keiner" }.`, ); } stand.vztmplStorage = storages.find( (s) => aktiv(s) && s.content.split(",").includes("vztmpl"), )?.storage; if (!stand.vztmplStorage) throw new AblaufFehler( "Kein Storage mit Inhaltstyp „Container-Templates“.", "Einem Dir-Storage den Inhaltstyp vztmpl geben.", ); 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 aware“ an der Bridge einschalten oder ohne VLAN ausrollen.", ); } // Provisionierung braucht SSH: lieber zum Node (kein Netzweg zum Container nötig). stand.weg = e.sshFuer(stand.knoten) ? "node" : "gast"; if (features.keyctl && stand.weg !== "node") { throw new AblaufFehler( "Das Feature keyctl darf per API-Token nicht gesetzt werden.", "SSH zum Node einrichten (dann setzt ProxDeploy es per pct set) oder ohne keyctl ausrollen.", ); } 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: "LXC" }); return { detail: `CTID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""} · Provisionierung über ${stand.weg === "node" ? "pct exec" : "SSH in den Container"}`, }; }, }, { schluessel: "lxc-template", titel: "LXC-Template", ausfuehren: async () => { const vorhanden = await client.get>( pfad`/nodes/${stand.knoten!}/storage/${stand.vztmplStorage!}/content`, { content: "vztmpl", }, ); const dateien = vorhanden.map((v) => v.volid.split("/").pop()!); const lokal = templateWaehlen(lxc!.template_pattern, dateien); // Was es zum Herunterladen gibt – ist dort eine neuere Version, wird sie geladen. const verfuegbar = (await client.get(pfad`/nodes/${stand.knoten!}/aplinfo`)).map( (x) => x.template, ); const neueste = templateWaehlen(lxc!.template_pattern, verfuegbar); if (lokal && (!neueste || lokal === neueste)) { stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`; return { uebersprungen: true, detail: `${lokal} vorhanden` }; } if (!neueste) { if (lokal) { stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`; return { uebersprungen: true, detail: `${lokal} vorhanden` }; } throw new AblaufFehler( `Kein Container-Template passt zu „${lxc!.template_pattern}“.`, "Mit „pveam update“ die Liste auf dem Node auffrischen oder das Muster im Katalog prüfen.", ); } await aufgabeAbwarten( client, await client.post(pfad`/nodes/${stand.knoten!}/aplinfo`, { storage: stand.vztmplStorage!, template: neueste, }), { zeitlimitMs: 30 * 60 * 1000 }, ); stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${neueste}`; return { detail: `${neueste} geladen` }; }, }, { schluessel: "anlegen", titel: "Anlegen", ausfuehren: async () => { if (stand.weg === "gast") bereitstellung = await e.bereitstellung(); const featureListe = [features.nesting ? "nesting=1" : null].filter(Boolean).join(","); const upid = await client.post(pfad`/nodes/${stand.knoten!}/lxc`, { vmid: stand.vmid!, hostname: a.name, ostemplate: stand.ostemplate!, ostype: lxc!.ostype, rootfs: `${a.storage}:${a.diskGb}`, cores: a.cores, memory: a.speicherMb, swap: a.swapMb, unprivileged: a.unprivilegiert, ...(featureListe ? { features: featureListe } : {}), net0: lxcNetz(a.netz), ...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}), ...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}), // Nur der Bereitstellungsschlüssel kommt an root – der Benutzer bekommt seine eigenen Schlüssel. ...(bereitstellung ? { "ssh-public-keys": bereitstellung.oeffentlich } : {}), 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(", ") || "–"}`, }); // Sofort merken, nicht erst nach dem Warten: bricht das Warten ab, legt Proxmox den // Container trotzdem fertig an – „Aufräumen“ muss ihn dann kennen. await e.angelegt({ vmid: stand.vmid }); await aufgabeAbwarten(client, upid, { zeitlimitMs: 15 * 60 * 1000 }); if (features.keyctl) { const ssh = e.sshFuer(stand.knoten!)!; if (!client.trocken) { const ergebnis = await ssh( `pct set ${stand.vmid} --features ${q(`nesting=${features.nesting ? 1 : 0},keyctl=1`)}`, ); if (ergebnis.code !== 0) throw new AblaufFehler( `keyctl ließ sich nicht setzen: ${ergebnis.stderr.trim()}`, "Rechte des SSH-Benutzers prüfen.", ); } } return { detail: `${a.unprivilegiert ? "unprivilegiert" : "privilegiert"} · ${a.cores} CPU · ${a.speicherMb} MB · ${a.diskGb}G${ features.nesting || features.keyctl ? ` · ${[features.nesting && "nesting", features.keyctl && "keyctl"].filter(Boolean).join(", ")}` : "" }`, }; }, }, { schluessel: "start", titel: "Start", ausfuehren: async () => { await aufgabeAbwarten(client, await client.post(`${ctPfad()}/status/start`), { zeitlimitMs: 5 * 60 * 1000, }); return { detail: `CT ${stand.vmid} läuft` }; }, }, { schluessel: "netzwerk", titel: "Netzwerk", ausfuehren: async (k) => { if (a.netz.modus === "statisch") { stand.ip = a.netz.ip!.split("/")[0]; return { detail: `IP ${stand.ip}` }; } if (client.trocken) return { detail: "IP per DHCP" }; const beginn = Date.now(); const grenze = e.netzMs ?? 2 * 60 * 1000; while (!stand.ip) { await k.pruefeAbbruch(); const schnittstellen = await client.get>( `${ctPfad()}/interfaces`, ); stand.ip = schnittstellen.find((s) => s.name === "eth0" && s.inet)?.inet?.split("/")[0]; if (stand.ip) break; if (Date.now() - beginn > grenze) { throw new AblaufFehler( "Der Container hat per DHCP keine Adresse bekommen.", "DHCP auf der Bridge bzw. im VLAN prüfen oder eine statische Adresse wählen.", ); } await k.detail(`wartet auf DHCP … ${Math.round((Date.now() - beginn) / 1000)}s`); await warten(2000); } return { detail: `IP ${stand.ip} per DHCP` }; }, }, { 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 nachspann = stand.weg === "gast" ? [ `echo '${MARKE} bereitstellung-entfernen'\nsed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys\n`, ] : []; const { skript } = profileRendern( e.profile, adapter, { os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "lxc" } }, [ adapter.benutzerAnlegen({ name: a.zugang.benutzer, sshSchluessel: a.zugang.sshSchluessel, sudo: true, }), ], ); const gesamt = skript + nachspann.join(""); if (client.trocken) { await k.log(gesamt, "info"); return { detail: `über ${stand.weg === "node" ? "pct exec" : "SSH"}${e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""}`, }; } const verfolgen = ausgabeVerfolgen(k); let code: number | null; if (stand.weg === "node") { const ssh = e.sshFuer(stand.knoten!)!; code = ( await ssh(`pct exec ${stand.vmid} -- bash -s`, { eingabe: gesamt, zeitlimitMs: 60 * 60 * 1000, beiAusgabe: (t) => verfolgen.weiter(t), }) ).code; } else { const ziel = { host: stand.ip!, schluessel: bereitstellung! }; code = await (e.gastSsh ?? gastSshStandard)(ziel, gesamt, (t) => verfolgen.weiter(t), warten); } verfolgen.weiter("\n"); if (code !== 0) { throw new AblaufFehler( `Provisionierung ${verfolgen.profil ? `im Profil ${verfolgen.profil} ` : ""}mit Code ${code} abgebrochen.`, "Detail-Log ansehen.", ); } return { detail: e.profile.length > 0 ? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet` : "Benutzer eingerichtet", }; }, }, ]; } /** * SSH als root in den frischen Container. Seinen Host-Key kann ProxDeploy vorher nicht kennen – * er entsteht beim ersten Start. Vertraut wird deshalb dem ersten Schlüssel (TOFU), und das nur für * diese eine Verbindung; der Bereitstellungsschlüssel wird am Ende des Skripts wieder entfernt. */ async function gastSshStandard( ziel: { host: string; schluessel: Bereitstellungsschluessel }, skript: string, beiAusgabe: (t: string) => void, warten: (ms: number) => Promise = schlafen, ): Promise { const beginn = Date.now(); for (;;) { try { const ergebnis = await sshAusfuehren( { host: ziel.host, port: 22, benutzer: "root", privatSchluessel: ziel.schluessel.privat, hostSchluessel: null, }, "bash -s", { erkunden: true, eingabe: skript, zeitlimitMs: 60 * 60 * 1000, beiAusgabe }, ); return ergebnis.code; } catch (fehler) { // sshd braucht nach dem Start ein paar Sekunden. if (fehler instanceof SshFehler && fehler.art === "verbindung" && Date.now() - beginn < 90_000) { await warten(3000); continue; } if (fehler instanceof SshFehler) { throw new AblaufFehler( `SSH in den Container ${ziel.host} fehlgeschlagen: ${fehler.ursache}`, "Ist der Container aus dem Netz von ProxDeploy erreichbar? Sonst SSH zum Node einrichten (pct exec).", ); } throw fehler; } } }