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>
128 lines
4.7 KiB
TypeScript
128 lines
4.7 KiB
TypeScript
import type { Auftrag, Prisma } from "@/generated/prisma/client";
|
||
import type { AuftragsArt } from "@/generated/prisma/enums";
|
||
import type { AblaufErgebnis, EreignisSenke } from "@/engine/ablauf/ablauf";
|
||
import { prisma } from "@/lib/db";
|
||
import { logger } from "@/lib/logger";
|
||
import { DatenbankSenke, melden } from "@/server/ereignisse/senke";
|
||
import { reservierungenFreigeben } from "@/server/services/vmid";
|
||
|
||
/**
|
||
* Führt einen Auftrag aus: Status setzen, den passenden Ablauf starten, Ergebnis festhalten.
|
||
* Die Abläufe selbst (Template bauen, VM/LXC ausrollen, aufräumen) melden sich hier an.
|
||
*/
|
||
export type AblaufKontext = {
|
||
auftrag: Auftrag;
|
||
senke: EreignisSenke;
|
||
abbruchGewuenscht: () => Promise<boolean>;
|
||
/** Merkt sich, was angelegt wurde – fürs Aufräumen, auch wenn später etwas scheitert. */
|
||
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||
};
|
||
|
||
export type AblaufAusgang = AblaufErgebnis & { ergebnis?: Record<string, unknown> };
|
||
|
||
export type AblaufFunktion = (kontext: AblaufKontext) => Promise<AblaufAusgang>;
|
||
|
||
const ABLAEUFE = new Map<AuftragsArt, AblaufFunktion>();
|
||
|
||
export function ablaufRegistrieren(art: AuftragsArt, funktion: AblaufFunktion): void {
|
||
ABLAEUFE.set(art, funktion);
|
||
}
|
||
|
||
export async function auftragAusfuehren(
|
||
auftragId: string,
|
||
senke: EreignisSenke = new DatenbankSenke(auftragId),
|
||
) {
|
||
// Nur einmal starten, auch wenn pg-boss einen Job doppelt liefern sollte.
|
||
const gestartet = await prisma.auftrag.updateMany({
|
||
where: { id: auftragId, status: "WARTEND" },
|
||
data: { status: "LAEUFT", gestartetAm: new Date() },
|
||
});
|
||
if (gestartet.count === 0) {
|
||
logger.warn({ auftragId }, "Auftrag ist nicht (mehr) wartend – übersprungen");
|
||
return;
|
||
}
|
||
const auftrag = await prisma.auftrag.findUniqueOrThrow({ where: { id: auftragId } });
|
||
await melden(auftragId);
|
||
|
||
let ausgang: AblaufAusgang;
|
||
const ablauf = ABLAEUFE.get(auftrag.art);
|
||
try {
|
||
if (!ablauf) throw new Error(`Für ${auftrag.art} ist kein Ablauf registriert.`);
|
||
ausgang = await ablauf({
|
||
auftrag,
|
||
senke,
|
||
abbruchGewuenscht: async () =>
|
||
(await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { abbrechen: true } }))
|
||
?.abbrechen ?? false,
|
||
angelegt: async (eintrag) => {
|
||
const aktuell = (
|
||
await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { angelegt: true } })
|
||
)?.angelegt as Record<string, unknown> | null;
|
||
await prisma.auftrag.update({
|
||
where: { id: auftragId },
|
||
data: { angelegt: { ...(aktuell ?? {}), ...eintrag } as Prisma.InputJsonValue },
|
||
});
|
||
},
|
||
});
|
||
} catch (fehler) {
|
||
// Fehler außerhalb einer Phase (Programmfehler) – trotzdem sauber beenden.
|
||
logger.error({ err: fehler, auftragId }, "Ablauf ist abgestürzt");
|
||
const text = fehler instanceof Error ? fehler.message : String(fehler);
|
||
ausgang = {
|
||
ok: false,
|
||
abgebrochen: false,
|
||
phase: "",
|
||
fehler: { ursache: `Unerwarteter Fehler: ${text}`, hinweis: "Detail-Log prüfen." },
|
||
};
|
||
await senke.log("error", `Unerwarteter Fehler: ${text}`);
|
||
}
|
||
|
||
await prisma.auftrag.update({
|
||
where: { id: auftragId },
|
||
data: {
|
||
status: ausgang.ok ? "ERFOLGREICH" : ausgang.abgebrochen ? "ABGEBROCHEN" : "FEHLGESCHLAGEN",
|
||
beendetAm: new Date(),
|
||
...(ausgang.ok
|
||
? { ergebnis: (ausgang.ergebnis ?? {}) as Prisma.InputJsonValue }
|
||
: { fehler: { phase: ausgang.phase, ...ausgang.fehler } }),
|
||
},
|
||
});
|
||
await reservierungenFreigeben(auftragId);
|
||
await melden(auftragId);
|
||
}
|
||
|
||
/**
|
||
* Nach einem Absturz oder Neustart des workers hängen Aufträge auf LAEUFT. Sie werden ehrlich als
|
||
* fehlgeschlagen markiert – fortsetzen wäre riskanter als neu starten.
|
||
*/
|
||
export async function verwaisteAuftraegeBeenden(): Promise<number> {
|
||
const verwaist = await prisma.auftrag.findMany({ where: { status: "LAEUFT" }, select: { id: true } });
|
||
for (const { id } of verwaist) {
|
||
await prisma.$transaction([
|
||
prisma.phase.updateMany({
|
||
where: { auftragId: id, status: "LAEUFT" },
|
||
data: {
|
||
status: "FEHLER",
|
||
beendetAm: new Date(),
|
||
ursache: "Der worker wurde neu gestartet.",
|
||
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
|
||
},
|
||
}),
|
||
prisma.auftrag.update({
|
||
where: { id },
|
||
data: {
|
||
status: "FEHLGESCHLAGEN",
|
||
beendetAm: new Date(),
|
||
fehler: {
|
||
ursache: "Der worker wurde während des Auftrags neu gestartet.",
|
||
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
|
||
},
|
||
},
|
||
}),
|
||
]);
|
||
await reservierungenFreigeben(id);
|
||
await melden(id);
|
||
}
|
||
return verwaist.length;
|
||
}
|