import { randomInt } from "node:crypto"; import type { Auftrag, Prisma } from "@/generated/prisma/client"; import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums"; import { abschlussAus } from "@/lib/auftrag-text"; import { prisma } from "@/lib/db"; import { z } from "@/lib/validierung"; import type { Aufrufer } from "@/server/auth/zugang"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; import { melden } from "@/server/ereignisse/senke"; import { protokollieren } from "@/server/services/protokoll"; import { auftragEinstellen } from "@/server/warteschlange"; import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag"; export type { AuftragSicht, LogZeile }; /** Kurz-IDs ohne Verwechsler (kein 0/o, 1/l/i): „d-7f3k2“ für Ausrollen, „t-…“ für Templates. */ const ZEICHEN = "23456789abcdefghjkmnpqrstuvwxyz"; const PRAEFIX: Record = { AUSROLLEN: "d", TEMPLATE_BAUEN: "t", AUFRAEUMEN: "c" }; export function kurzIdErzeugen(art: AuftragsArt): string { let id = ""; for (let i = 0; i < 5; i += 1) id += ZEICHEN[randomInt(ZEICHEN.length)]; return `${PRAEFIX[art]}-${id}`; } export type NeuerAuftrag = { art: AuftragsArt; serverId: string; knoten?: string; gastTyp?: GastTyp; name?: string; distributionId?: string; profile?: string[]; anfrage: Prisma.InputJsonValue; plan?: Prisma.InputJsonValue; bezugId?: string; wiederholtVonId?: string; }; export async function auftragAnlegen( neu: NeuerAuftrag, aufrufer: Aufrufer, ip: string | null, einstellen: (id: string) => Promise = auftragEinstellen, ): Promise { let auftrag: Auftrag | undefined; for (let versuch = 0; versuch < 5 && !auftrag; versuch += 1) { try { auftrag = await prisma.$transaction(async (tx) => { const angelegt = await tx.auftrag.create({ data: { ...neu, profile: neu.profile ?? [], kurzId: kurzIdErzeugen(neu.art), quelle: aufrufer.quelle as Quelle, erstelltVonId: aufrufer.benutzer.id, }, }); await protokollieren( { benutzerId: aufrufer.benutzer.id, quelle: aufrufer.quelle, aktion: neu.art === "AUSROLLEN" ? "Ausrollen gestartet" : neu.art === "TEMPLATE_BAUEN" ? "Template-Bau gestartet" : "Aufräumen gestartet", ziel: `auftrag:${angelegt.kurzId}`, details: { name: neu.name, distribution: neu.distributionId }, ip, }, tx, ); return angelegt; }); } catch (fehler) { // Kurz-ID zufällig doppelt – selten, dann einfach neu würfeln. if ((fehler as { code?: string }).code !== "P2002") throw fehler; } } if (!auftrag) throw new AnwendungsFehler(500, "intern", "Konnte keine freie Auftrags-ID erzeugen."); await einstellen(auftrag.id); return auftrag; } const MIT = { server: { select: { name: true, titel: true } }, erstelltVon: { select: { benutzername: true } }, phasen: { orderBy: { reihenfolge: "asc" as const } }, }; type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>; /** Was „Aufräumen“ löschen würde – nur bei Fehlschlag oder Abbruch und nur, wenn etwas angelegt wurde. */ function aufraeumbarAus( a: Pick, ): AuftragSicht["aufraeumbar"] { if (a.art === "AUFRAEUMEN" || (a.status !== "FEHLGESCHLAGEN" && a.status !== "ABGEBROCHEN")) return null; const angelegt = (a.angelegt ?? {}) as { vmid?: unknown; knoten?: unknown; typ?: unknown }; if (typeof angelegt.vmid !== "number") return null; const typ = angelegt.typ === "LXC" || a.gastTyp === "LXC" ? "LXC" : "VM"; const knoten = typeof angelegt.knoten === "string" ? angelegt.knoten : a.knoten; return { typ, vmid: angelegt.vmid, knoten }; } export function auftragSicht(a: MitBeziehungen): AuftragSicht { const iso = (d: Date | null) => d?.toISOString() ?? null; const sicht: Omit = { id: a.id, kurzId: a.kurzId, art: a.art, status: a.status, kopf: a.kopf ?? [`Server ${a.server.name}`, a.knoten ? `Node ${a.knoten}` : null].filter(Boolean).join(" · "), server: a.server, knoten: a.knoten, gastTyp: a.gastTyp, vmid: a.vmid, name: a.name, distributionId: a.distributionId, profile: a.profile, ergebnis: a.ergebnis, fehler: a.fehler, erstelltVon: a.erstelltVon.benutzername, erstelltAm: a.erstelltAm.toISOString(), gestartetAm: iso(a.gestartetAm), beendetAm: iso(a.beendetAm), phasen: a.phasen.map((p) => ({ schluessel: p.schluessel, titel: p.titel, status: p.status, detail: p.detail, ursache: p.ursache, hinweis: p.hinweis, gestartetAm: iso(p.gestartetAm), beendetAm: iso(p.beendetAm), })), }; return { ...sicht, abschluss: abschlussAus(sicht), aufraeumbar: aufraeumbarAus(a) }; } /** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */ export async function auftragHolen(kennung: string): Promise { const auftrag = await prisma.auftrag.findFirst({ where: { OR: [{ kurzId: kennung }, { id: kennung }] }, include: MIT, }); if (!auftrag) throw nichtGefunden(`Den Auftrag „${kennung}“`); return auftragSicht(auftrag); } /** Filter des Verlaufs – dieselben in Web (`/auftraege?…`) und API (`GET /api/v1/auftraege?…`). */ export const VerlaufFilter = z.object({ server: z.string().trim().optional(), typ: z.enum(["vm", "lxc", "template", "aufraeumen"]).optional(), distribution: z.string().trim().optional(), status: z .enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]) .optional(), seite: z.coerce.number().int().min(1).max(10_000).default(1), }); export type VerlaufFilter = z.infer; export const VERLAUF_PRO_SEITE = 25; /** Liest Filter aus Suchparametern; Unbekanntes und Leeres fällt still weg statt zu stören. */ export function verlaufFilterAus(parameter: Record): VerlaufFilter { const roh: Record = {}; for (const [k, v] of Object.entries(parameter)) { const wert = Array.isArray(v) ? v[0] : v; if (wert) roh[k] = wert; } const geprueft: Record = {}; for (const schluessel of Object.keys(VerlaufFilter.shape) as Array) { const feld = VerlaufFilter.shape[schluessel].safeParse(roh[schluessel]); if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data; } return VerlaufFilter.parse(geprueft); } export async function auftraegeSuchen(filter: VerlaufFilter): Promise<{ eintraege: AuftragSicht[]; gesamt: number; seite: number; seiten: number; }> { const where: Prisma.AuftragWhereInput = { ...(filter.server ? { server: { name: filter.server } } : {}), ...(filter.distribution ? { distributionId: filter.distribution } : {}), ...(filter.status ? { status: filter.status } : {}), ...(filter.typ === "vm" ? { art: "AUSROLLEN", gastTyp: "VM" } : {}), ...(filter.typ === "lxc" ? { art: "AUSROLLEN", gastTyp: "LXC" } : {}), ...(filter.typ === "template" ? { art: "TEMPLATE_BAUEN" } : {}), ...(filter.typ === "aufraeumen" ? { art: "AUFRAEUMEN" } : {}), }; const [gesamt, zeilen] = await prisma.$transaction([ prisma.auftrag.count({ where }), prisma.auftrag.findMany({ where, include: MIT, orderBy: { erstelltAm: "desc" }, skip: (filter.seite - 1) * VERLAUF_PRO_SEITE, take: VERLAUF_PRO_SEITE, }), ]); return { eintraege: zeilen.map(auftragSicht), gesamt, seite: filter.seite, seiten: Math.max(1, Math.ceil(gesamt / VERLAUF_PRO_SEITE)), }; } export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise { const zeilen = await prisma.logeintrag.findMany({ where: { auftragId, id: { gt: abId } }, orderBy: { id: "asc" }, take: grenze, }); return zeilen.map((z) => ({ id: z.id.toString(), zeit: z.zeit.toISOString(), stufe: z.stufe, phase: z.phase, nachricht: z.nachricht, })); } export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]); /** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */ /** * Aufräumen starten: eigener Auftrag, der den halb angelegten Gast eines fehlgeschlagenen bzw. * abgebrochenen Auftrags löscht. Was genau gelöscht wird, prüft der worker gegen Proxmox. */ export async function aufraeumenStarten( kennung: string, aufrufer: Aufrufer, ip: string | null, ): Promise { const sicht = await auftragHolen(kennung); if (!sicht.aufraeumbar) { throw new AnwendungsFehler( 409, "nicht_aufraeumbar", sicht.status === "AUFGERAEUMT" ? "Dieser Auftrag ist schon aufgeräumt." : "Hier gibt es nichts aufzuräumen – nur fehlgeschlagene oder abgebrochene Aufträge, die etwas angelegt haben.", ); } const laufend = await prisma.auftrag.findFirst({ where: { art: "AUFRAEUMEN", bezugId: sicht.id, status: { in: ["WARTEND", "LAEUFT"] } }, select: { kurzId: true }, }); if (laufend) { throw new AnwendungsFehler(409, "laeuft_schon", `Das Aufräumen läuft schon (${laufend.kurzId}).`); } const original = await prisma.auftrag.findUniqueOrThrow({ where: { id: sicht.id } }); const { typ, vmid, knoten } = sicht.aufraeumbar; return await auftragAnlegen( { art: "AUFRAEUMEN", serverId: original.serverId, ...(knoten ? { knoten } : {}), gastTyp: typ, ...(original.name ? { name: original.name } : {}), ...(original.distributionId ? { distributionId: original.distributionId } : {}), bezugId: original.id, anfrage: { auftrag: original.kurzId, typ, vmid, knoten }, }, aufrufer, ip, ); } export async function auftragAbbrechen( kennung: string, aufrufer: Aufrufer, ip: string | null, ): Promise { const auftrag = await auftragHolen(kennung); if (ENDZUSTAENDE.has(auftrag.status)) { throw new AnwendungsFehler(409, "beendet", "Der Auftrag ist bereits beendet."); } await prisma.$transaction(async (tx) => { await tx.auftrag.update({ where: { id: auftrag.id }, data: { abbrechen: true } }); await protokollieren( { benutzerId: aufrufer.benutzer.id, quelle: aufrufer.quelle, aktion: "Abbruch angefordert", ziel: `auftrag:${auftrag.kurzId}`, ip, }, tx, ); }); await melden(auftrag.id); }