Verlauf filterbar (URL, API, proxdeploy history); Wiederholen füllt den Assistenten aus einem alten Auftrag; Aufräumen löscht nach Fehlschlag oder Abbruch den halb angelegten Gast – nur, wenn dessen Beschreibung auf den Auftrag verweist. Deshalb setzt das Klonen die Beschreibung jetzt sofort. Auf pve-demo geprüft (CT 109). Closes #014 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
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<AuftragsArt, string> = { 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<void> = auftragEinstellen,
|
||
): Promise<Auftrag> {
|
||
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<Auftrag, "art" | "status" | "angelegt" | "gastTyp" | "knoten">,
|
||
): 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<AuftragSicht, "abschluss" | "aufraeumbar"> = {
|
||
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<AuftragSicht> {
|
||
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<typeof VerlaufFilter>;
|
||
|
||
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<string, string | string[] | undefined>): VerlaufFilter {
|
||
const roh: Record<string, string> = {};
|
||
for (const [k, v] of Object.entries(parameter)) {
|
||
const wert = Array.isArray(v) ? v[0] : v;
|
||
if (wert) roh[k] = wert;
|
||
}
|
||
const geprueft: Record<string, unknown> = {};
|
||
for (const schluessel of Object.keys(VerlaufFilter.shape) as Array<keyof VerlaufFilter>) {
|
||
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<LogZeile[]> {
|
||
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<Auftrag> {
|
||
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<void> {
|
||
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);
|
||
}
|