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,4 @@
|
||||
/**
|
||||
* Meldet alle Abläufe beim worker an. Template-Bau (#007), Ausrollen (#008) und Aufräumen folgen.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||
import type { AufrufBeobachter } from "@/engine/proxmox/client";
|
||||
|
||||
/**
|
||||
* Schreibt Proxmox-Aufrufe ins Detail-Log. Die Abfrageschleifen (Task-Status, Task-Log,
|
||||
* VM-Status) erscheinen nur bei Fehlern – sonst stünden bei jedem Download Hunderte gleicher
|
||||
* Zeilen da, und das Wichtige ginge unter.
|
||||
*/
|
||||
const SCHLEIFE = /\/tasks\/[^/]+\/(status|log)$|\/status\/current$/;
|
||||
|
||||
export function aufrufLogger(senke: EreignisSenke): AufrufBeobachter {
|
||||
return (aufruf, ergebnis) => {
|
||||
const pfad = decodeURIComponent(aufruf.pfad);
|
||||
if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return;
|
||||
void senke.log(
|
||||
ergebnis.fehler ? "warn" : "debug",
|
||||
`${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}`,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Der worker: arbeitet die Auftragswarteschlange ab. Derselbe Codestand wie die app, eigener
|
||||
* Prozess – ein langer Template-Bau blockiert so nie eine Web-Anfrage.
|
||||
*/
|
||||
import "./umgebung";
|
||||
import { schluesselPruefen } from "@/lib/geheimnisse";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||
import { boss, WARTESCHLANGE, type AuftragsJob } from "@/server/warteschlange";
|
||||
import { auftragAusfuehren, verwaisteAuftraegeBeenden } from "./ausfuehren";
|
||||
import "./ablaeufe";
|
||||
|
||||
async function starten(): Promise<void> {
|
||||
schluesselPruefen();
|
||||
const sync = await katalogSynchronisieren();
|
||||
logger.info({ distributionen: sync.distributionen, profile: sync.profile }, "Katalog abgeglichen");
|
||||
const verwaist = await verwaisteAuftraegeBeenden();
|
||||
if (verwaist > 0) logger.warn({ anzahl: verwaist }, "Verwaiste Aufträge als fehlgeschlagen markiert");
|
||||
|
||||
const parallel = Math.max(1, Number(process.env["WORKER_PARALLEL"] ?? 4));
|
||||
const b = await boss("worker");
|
||||
await b.work<AuftragsJob>(WARTESCHLANGE, { batchSize: 1, localConcurrency: parallel }, async ([job]) => {
|
||||
if (job) await auftragAusfuehren(job.data.auftragId);
|
||||
});
|
||||
logger.info({ parallel }, "worker bereit");
|
||||
|
||||
const beenden = async (signal: string) => {
|
||||
logger.info({ signal }, "worker fährt herunter");
|
||||
await b.stop({ graceful: true, timeout: 30_000 });
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => void beenden("SIGTERM"));
|
||||
process.on("SIGINT", () => void beenden("SIGINT"));
|
||||
}
|
||||
|
||||
starten().catch((fehler) => {
|
||||
logger.fatal({ err: fehler }, "worker konnte nicht starten");
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Muss das erste Modul des workers sein: der Logger liest LOG_LEVEL und den Dienstnamen beim
|
||||
* Import. Next lädt `.env` von selbst, der worker nicht – im Container kommt die Umgebung von
|
||||
* außen, dann gibt es keine Datei und nichts passiert.
|
||||
*/
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
} catch {
|
||||
// keine .env – Umgebung kommt von außen
|
||||
}
|
||||
process.env["PROXDEPLOY_DIENST"] = "worker";
|
||||
Reference in New Issue
Block a user