feat(auftraege): worker, Phasen und Live-Fortschritt per SSE
Aufträge laufen als pg-boss-Jobs im worker. Der Phasen-Runner der Engine kennt weder Proxmox noch Datenbank; eine Senke schreibt Phasen und Log in die DB und meldet per NOTIFY, die app streamt per SSE an Web und CLI (Last-Event-ID, Herzschlag, Polling-Fallback im Browser). VMIDs vergibt eine Advisory-Sperre je Server zusammen mit einer Reservierungstabelle; zehn parallele Reservierungen im Test bekommen zehn verschiedene IDs. Der Proxmox-Client hat einen Trockenmodus für den Dry-Run: Lesendes echt, Schreibendes nur aufgezeichnet. Live-Ansicht im Aufbau der CLI-Ausgabe. Im echten Betrieb aufgefallen: ssh2 und pg sind CommonJS und brauchen im worker Default-Importe. Closes #006 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||||
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
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 }>;
|
||||
|
||||
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
|
||||
const iso = (d: Date | null) => d?.toISOString() ?? null;
|
||||
return {
|
||||
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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
export async function auftraegeListe(
|
||||
filter: { serverId?: string; grenze?: number } = {},
|
||||
): Promise<AuftragSicht[]> {
|
||||
const zeilen = await prisma.auftrag.findMany({
|
||||
where: filter.serverId ? { serverId: filter.serverId } : {},
|
||||
include: MIT,
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: filter.grenze ?? 50,
|
||||
});
|
||||
return zeilen.map(auftragSicht);
|
||||
}
|
||||
|
||||
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. */
|
||||
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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { utils as sshUtils } from "ssh2";
|
||||
import ssh2 from "ssh2";
|
||||
import type { Server } from "@/generated/prisma/client";
|
||||
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||
@@ -170,7 +170,7 @@ export async function sshSchluesselErzeugen(
|
||||
ip: string | null,
|
||||
): Promise<ServerSicht> {
|
||||
const server = await serverHolen(name);
|
||||
const paar = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
||||
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
|
||||
const neu = await prisma.$transaction(async (tx) => {
|
||||
const aktualisiert = await tx.server.update({
|
||||
where: { id: server.id },
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
|
||||
/**
|
||||
* Vergibt VMIDs ohne Doppelungen zwischen parallelen Aufträgen: unter einer Advisory-Sperre je
|
||||
* Server wird die kleinste freie ID ≥ `von` gesucht, die weder in Proxmox belegt noch von einem
|
||||
* anderen laufenden Auftrag reserviert ist, und sofort reserviert. Die Sperre fällt mit der
|
||||
* Transaktion; die Reservierung bleibt bis zum Auftragsende (Sicherheitsnetz: Ablauf nach 4 h).
|
||||
*
|
||||
* Gegen Gäste, die gleichzeitig außerhalb von ProxDeploy entstehen, hilft das nicht – dort
|
||||
* scheitert `clone`/`create` mit „already exists“, und die Phase wiederholt einmal mit neuer ID.
|
||||
*/
|
||||
const RESERVIERUNG_STUNDEN = 4;
|
||||
|
||||
export async function vmidReservieren(eingabe: {
|
||||
serverId: string;
|
||||
auftragId: string;
|
||||
belegt: ReadonlySet<number>;
|
||||
von: number;
|
||||
bis?: number;
|
||||
/** Eine bestimmte ID statt der nächsten freien (vom Benutzer gewählt). */
|
||||
wunsch?: number;
|
||||
}): Promise<number> {
|
||||
const bis = eingabe.bis ?? 999_999_999;
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('proxdeploy:vmid'), hashtext(${eingabe.serverId}))`;
|
||||
await tx.vmidReservierung.deleteMany({ where: { ablaufAm: { lt: new Date() } } });
|
||||
const reserviert = new Set(
|
||||
(
|
||||
await tx.vmidReservierung.findMany({ where: { serverId: eingabe.serverId }, select: { vmid: true } })
|
||||
).map((r) => r.vmid),
|
||||
);
|
||||
const frei = (id: number) => !eingabe.belegt.has(id) && !reserviert.has(id);
|
||||
|
||||
let vmid: number | null = null;
|
||||
if (eingabe.wunsch !== undefined) {
|
||||
if (!frei(eingabe.wunsch)) {
|
||||
throw new AblaufFehler(
|
||||
`VMID ${eingabe.wunsch} ist bereits belegt oder reserviert.`,
|
||||
"Eine andere ID wählen oder das Feld leer lassen – dann nimmt ProxDeploy die nächste freie.",
|
||||
);
|
||||
}
|
||||
vmid = eingabe.wunsch;
|
||||
} else {
|
||||
for (let id = eingabe.von; id <= bis; id += 1) {
|
||||
if (frei(id)) {
|
||||
vmid = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vmid === null) {
|
||||
throw new AblaufFehler(
|
||||
`Im Bereich ${eingabe.von}–${bis} ist keine VMID frei.`,
|
||||
"Bereich in den Server-Einstellungen erweitern.",
|
||||
);
|
||||
}
|
||||
await tx.vmidReservierung.create({
|
||||
data: {
|
||||
serverId: eingabe.serverId,
|
||||
vmid,
|
||||
auftragId: eingabe.auftragId,
|
||||
ablaufAm: new Date(Date.now() + RESERVIERUNG_STUNDEN * 3600 * 1000),
|
||||
},
|
||||
});
|
||||
return vmid;
|
||||
});
|
||||
}
|
||||
|
||||
export async function reservierungenFreigeben(auftragId: string): Promise<void> {
|
||||
await prisma.vmidReservierung.deleteMany({ where: { auftragId } });
|
||||
}
|
||||
Reference in New Issue
Block a user