Files
proxdeploy/src/server/services/ausrollen.ts
T
florian.wildeandClaude Opus 5.5 1b1ee4b876 feat(vorlagen): Vorlagen (Presets) für Assistent, API und CLI
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>
2026-10-12 13:44:33 +02:00

251 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(" · ");
}