import type { Server } from "@/generated/prisma/client"; import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; import { lxcAusrollSchritte, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen"; import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen"; import type { PhasenSchritt } from "@/engine/ablauf/ablauf"; import type { Distribution, Profil } from "@/engine/katalog/schema"; import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client"; import { prisma } from "@/lib/db"; import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen"; import { vorlageAnwenden } from "@/lib/schemas/vorlage"; import { vorlageHolen } from "@/server/services/vorlagen"; import type { Aufrufer } from "@/server/auth/zugang"; import { AnwendungsFehler } from "@/server/fehler"; import { auftragAnlegen } from "@/server/services/auftraege"; import { serverHolen, verbindungAus } from "@/server/services/server"; import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; import { bereitstellungsschluessel } from "@/server/services/bereitstellung"; import { vmidVorschlagen } from "@/server/services/vmid"; /** * Ausrollen vorbereiten: Anfrage prüfen, Distribution und Profile auflösen. Fehler kommen als * Feldfehler zurück, damit der Assistent sie am richtigen Feld zeigt. */ export type Vorbereitet = { anfrage: AusrollAnfrage; server: Server; distribution: Distribution; profile: AufgeloestesProfil[]; automatisch: string[]; }; export async function ausrollenVorbereiten(roh: unknown): Promise { // Eine Vorlage (Preset) liegt unter der Anfrage: was die Anfrage selbst sagt, gewinnt. const vorlageName = roh && typeof roh === "object" && "vorlage" in roh ? (roh as { vorlage?: unknown }).vorlage : undefined; const mitVorlage = typeof vorlageName === "string" && vorlageName ? vorlageAnwenden((await vorlageHolen(vorlageName)).parameter, roh as Record) : roh; const anfrage = AusrollAnfrage.parse(mitVorlage); const server = await serverHolen(anfrage.server); const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } }); if (!zeile || zeile.entferntAm || !zeile.aktiv) { throw new AnwendungsFehler( 400, "ungueltig", `Die Distribution „${anfrage.distribution}“ ist nicht verfügbar.`, ); } const distribution = zeile.definition as Distribution; if (!distribution[anfrage.typ]) { throw new AnwendungsFehler( 400, "ungueltig", `${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`, ); } const verfuegbar = new Map( (await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [ p.id, p.definition as Profil, ]), ); const aufloesung = profileAufloesen(anfrage.profile, verfuegbar, distribution.family, anfrage.variablen); if (!aufloesung.ok) { const felder = Object.fromEntries( aufloesung.fehler.map((f) => [`variablen.${f.profil}${f.variable ? `.${f.variable}` : ""}`, f.meldung]), ); throw new AnwendungsFehler( 400, "ungueltig", aufloesung.fehler.map((f) => f.meldung).join(" "), undefined, felder, ); } return { anfrage, server, distribution, profile: aufloesung.profile, automatisch: aufloesung.automatisch }; } export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: string | null) { const v = await ausrollenVorbereiten(roh); return await auftragAnlegen( { art: "AUSROLLEN", serverId: v.server.id, knoten: v.anfrage.knoten, gastTyp: v.anfrage.typ === "vm" ? "VM" : "LXC", name: v.anfrage.name, distributionId: v.distribution.id, profile: v.profile.map((p) => p.profil.id), anfrage: v.anfrage, }, aufrufer, ip, ); } export type AusrollStand = VmAusrollStand & LxcAusrollStand; /** Container-Features, die die gewählten Profile verlangen (z. B. docker → nesting). */ export function profilFeatures(profile: AufgeloestesProfil[]): { nesting: boolean; keyctl: boolean } { return { nesting: profile.some((p) => p.profil.lxc.features?.nesting), keyctl: profile.some((p) => p.profil.lxc.features?.keyctl), }; } /** * Die Phasen für VM oder LXC – dieselben im Trockenlauf und im worker. Was sich unterscheidet * (reservieren oder nur vorschlagen, Auftrags-ID), kommt von außen. */ export function ausrollSchritte( v: Vorbereitet, o: { client: ProxmoxClient; vmidReservieren: (belegt: Set, wunsch?: number) => Promise; templateVmidReservieren: (belegt: Set) => Promise; angelegt: (eintrag: Record) => Promise; auftragKurzId: string; }, stand: AusrollStand, ): PhasenSchritt[] { const sshFuer = (knoten: string) => nodeBefehlFuer(v.server, knoten); if (v.anfrage.typ === "lxc") { return lxcAusrollSchritte( { client: o.client, anfrage: v.anfrage, distribution: v.distribution, profile: v.profile, profilFeatures: profilFeatures(v.profile), vmidReservieren: o.vmidReservieren, sshFuer, bereitstellung: bereitstellungsschluessel, angelegt: o.angelegt, auftragKurzId: o.auftragKurzId, }, stand, ); } return vmAusrollSchritte( { client: o.client, anfrage: v.anfrage, distribution: v.distribution, profile: v.profile, vmidReservieren: o.vmidReservieren, templateBau: v.anfrage.templateBauen ? { sshFuer, auftragKurzId: o.auftragKurzId, vmidReservieren: o.templateVmidReservieren } : undefined, angelegt: o.angelegt, auftragKurzId: o.auftragKurzId, }, stand, ); } export type Plan = { kopf: string; phasen: Array<{ schluessel: string; titel: string; status: string; detail: string | null; ursache: string | null; hinweis: string | null; }>; aufrufe: Aufruf[]; skript: string | null; cliBefehl: string; automatisch: string[]; ok: boolean; }; /** * Dry-Run: dieselben Phasen wie im Ernstfall, mit einem Proxmox-Client im Trockenmodus. Lesendes * geht wirklich an Proxmox (VMID frei? Template da? Bridge VLAN-fähig?), Schreibendes wird nur * aufgezeichnet. Was hier steht, ist also genau das, was „Ausrollen“ tun würde. */ export async function ausrollenPlanen(roh: unknown): Promise { const v = await ausrollenVorbereiten(roh); const client = new ProxmoxClient(verbindungAus(v.server), { trocken: true, zeitlimitMs: 15_000 }); const phasen: Plan["phasen"] = []; let skript: string | null = null; const finde = (s: string) => phasen.find((p) => p.schluessel === s)!; const senke: EreignisSenke = { phasenAnlegen: async (liste) => { for (const p of liste) phasen.push({ ...p, status: "OFFEN", detail: null, ursache: null, hinweis: null }); }, phaseGestartet: async (s) => void (finde(s).status = "LAEUFT"), phaseDetail: async (s, d) => void (finde(s).detail = d), phaseBeendet: async (s, status, a) => { Object.assign(finde(s), { status, ...(a.detail !== undefined ? { detail: a.detail } : {}), ...(a.fehler ?? {}), }); }, log: async (_stufe, nachricht, angaben) => { if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht; }, }; const stand: AusrollStand = {}; try { const ergebnis = await phasenAusfuehren( ausrollSchritte( v, { client, vmidReservieren: (belegt, wunsch) => vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }), templateVmidReservieren: (belegt) => vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }), angelegt: async () => {}, auftragKurzId: "dry-run", }, stand, ), senke, ); return { kopf: kopfzeile(v, stand), phasen, aufrufe: client.geplant, skript, cliBefehl: cliBefehl(v.anfrage), automatisch: v.automatisch, ok: ergebnis.ok, }; } finally { await client.schliessen(); } } export function kopfzeile( v: Pick, stand: AusrollStand, ): string { return [ `Server ${v.server.name}`, stand.knoten ? `Node ${stand.knoten}` : null, v.anfrage.typ === "lxc" ? `${v.distribution.name} (LXC)` : `${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`, ] .filter(Boolean) .join(" · "); }