Gespeicherte Kombinationen aus Distribution, Ressourcen und Profilen. Im Assistenten mit einem Klick vorbelegt oder dort gespeichert; die API nimmt „vorlage“ entgegen (Grundlage für --preset der CLI). Eine Vorlage liegt unter der Anfrage – was die Anfrage selbst sagt, gewinnt. Beim ersten Start wird das Beispiel „Docker-Host klein“ aus der Vorgabe angelegt, einmalig. Closes #012 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
8.6 KiB
TypeScript
251 lines
8.6 KiB
TypeScript
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<Vorbereitet> {
|
||
// 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<string, unknown>)
|
||
: 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<number>, wunsch?: number) => Promise<number>;
|
||
templateVmidReservieren: (belegt: Set<number>) => Promise<number>;
|
||
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||
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<Plan> {
|
||
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<Vorbereitet, "server" | "distribution" | "anfrage">,
|
||
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(" · ");
|
||
}
|