feat(ausrollen): VM ausrollen mit Assistent, Dry-Run und CLI-Befehl
Eine VM aus dem ProxDeploy-Template ausrollen, vollständig per API: Klonen, Hardware, cloud-init, Start, Guest-Agent und Provisionierung der Profile über den Agent – ohne SSH. Der Fortschritt kommt aus der Logdatei im Gast, je Profil eine Marke. Der Dry-Run läuft durch dieselben Phasen mit einem Trocken-Client und zeigt jeden geplanten Proxmox-Aufruf und den passenden CLI-Befehl. Der Assistent hat 9 Schritte, den Zustand in der URL und bietet an, ein fehlendes Template im selben Auftrag zu bauen. Auf pve-demo abgenommen: pd-test01 (base, docker) per API, pd-test02 (base, zabbix-agent) über den Assistenten, beide per SSH geprüft. Dabei gefunden: file-write scheitert an Umlauten – Skripte gehen jetzt base64-kodiert hinüber. Closes #008 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import type { Server } from "@/generated/prisma/client";
|
||||
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
||||
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 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 { 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> {
|
||||
const anfrage = AusrollAnfrage.parse(roh);
|
||||
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"}.`,
|
||||
);
|
||||
}
|
||||
if (anfrage.typ === "lxc") {
|
||||
throw new AnwendungsFehler(
|
||||
400,
|
||||
"nicht_umgesetzt",
|
||||
"LXC-Container folgen mit #009.",
|
||||
"Vorerst als VM ausrollen.",
|
||||
);
|
||||
}
|
||||
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 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: VmAusrollStand = {};
|
||||
try {
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
vmAusrollSchritte(
|
||||
{
|
||||
client,
|
||||
anfrage: v.anfrage,
|
||||
distribution: v.distribution,
|
||||
profile: v.profile,
|
||||
vmidReservieren: (belegt, wunsch) =>
|
||||
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
|
||||
templateBau: v.anfrage.templateBauen
|
||||
? {
|
||||
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
||||
auftragKurzId: "dry-run",
|
||||
vmidReservieren: (belegt) =>
|
||||
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
|
||||
}
|
||||
: undefined,
|
||||
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">, stand: VmAusrollStand): string {
|
||||
return [
|
||||
`Server ${v.server.name}`,
|
||||
stand.knoten ? `Node ${stand.knoten}` : null,
|
||||
`${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
Reference in New Issue
Block a user