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,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
|
||||
/** Eine Senke, die mitschreibt – so sieht ein Test genau, was Web und CLI zu sehen bekämen. */
|
||||
function mitschreiber() {
|
||||
const ereignisse: string[] = [];
|
||||
const senke: EreignisSenke = {
|
||||
phasenAnlegen: async (p) => void ereignisse.push(`anlegen ${p.map((x) => x.schluessel).join(",")}`),
|
||||
phaseGestartet: async (s) => void ereignisse.push(`start ${s}`),
|
||||
phaseDetail: async (s, d) => void ereignisse.push(`detail ${s}: ${d}`),
|
||||
phaseBeendet: async (s, status, a) =>
|
||||
void ereignisse.push(
|
||||
`ende ${s} ${status}${a.detail ? `: ${a.detail}` : ""}${a.fehler ? ` – ${a.fehler.ursache} → ${a.fehler.hinweis}` : ""}`,
|
||||
),
|
||||
log: async (stufe, n) => void ereignisse.push(`log ${stufe} ${n}`),
|
||||
};
|
||||
return { senke, ereignisse };
|
||||
}
|
||||
|
||||
describe("Phasen-Runner", () => {
|
||||
it("führt Phasen der Reihe nach aus und meldet Detail, Überspringen und Erfolg", async () => {
|
||||
const { senke, ereignisse } = mitschreiber();
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
[
|
||||
{ schluessel: "pruefen", titel: "Prüfungen", ausfuehren: async () => ({ detail: "VMID 120 frei" }) },
|
||||
{
|
||||
schluessel: "template",
|
||||
titel: "Template",
|
||||
ausfuehren: async () => ({ uebersprungen: true, detail: "9013 vorhanden" }),
|
||||
},
|
||||
{
|
||||
schluessel: "klonen",
|
||||
titel: "Klonen",
|
||||
ausfuehren: async (k) => {
|
||||
await k.detail("läuft …");
|
||||
await k.log("clone gestartet");
|
||||
return { detail: "full clone 9013 → 120" };
|
||||
},
|
||||
},
|
||||
],
|
||||
senke,
|
||||
);
|
||||
expect(ergebnis).toEqual({ ok: true });
|
||||
expect(ereignisse).toEqual([
|
||||
"anlegen pruefen,template,klonen",
|
||||
"start pruefen",
|
||||
"ende pruefen ERLEDIGT: VMID 120 frei",
|
||||
"start template",
|
||||
"ende template UEBERSPRUNGEN: 9013 vorhanden",
|
||||
"start klonen",
|
||||
"detail klonen: läuft …",
|
||||
"log info clone gestartet",
|
||||
"ende klonen ERLEDIGT: full clone 9013 → 120",
|
||||
]);
|
||||
});
|
||||
|
||||
it("bricht bei einem Fehler ab: Ursache und Hinweis, folgende Phasen bleiben offen", async () => {
|
||||
const { senke, ereignisse } = mitschreiber();
|
||||
let danach = false;
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
[
|
||||
{
|
||||
schluessel: "klonen",
|
||||
titel: "Klonen",
|
||||
ausfuehren: async () => {
|
||||
throw new ProxmoxFehler(
|
||||
"recht",
|
||||
"Permission check failed (/vms/120, VM.Clone)",
|
||||
"Der Rolle fehlt VM.Clone auf /vms/120.",
|
||||
);
|
||||
},
|
||||
},
|
||||
{ schluessel: "start", titel: "Start", ausfuehren: async () => void (danach = true) },
|
||||
],
|
||||
senke,
|
||||
);
|
||||
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: false, phase: "klonen" });
|
||||
expect(danach).toBe(false);
|
||||
expect(ereignisse).toContain(
|
||||
"ende klonen FEHLER – Permission check failed (/vms/120, VM.Clone) → Der Rolle fehlt VM.Clone auf /vms/120.",
|
||||
);
|
||||
expect(ereignisse.some((e) => e.startsWith("start start"))).toBe(false);
|
||||
});
|
||||
|
||||
it("prüft vor jeder Phase auf Abbruch", async () => {
|
||||
const { senke } = mitschreiber();
|
||||
let abbrechen = false;
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
[
|
||||
{ schluessel: "a", titel: "A", ausfuehren: async () => void (abbrechen = true) },
|
||||
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
|
||||
],
|
||||
senke,
|
||||
{ abbruchGewuenscht: async () => abbrechen },
|
||||
);
|
||||
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: true, phase: "b" });
|
||||
});
|
||||
|
||||
it("benennt unerwartete Fehler ehrlich", async () => {
|
||||
const { senke } = mitschreiber();
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
[
|
||||
{
|
||||
schluessel: "x",
|
||||
titel: "X",
|
||||
ausfuehren: async () => {
|
||||
throw new TypeError("kaputt");
|
||||
},
|
||||
},
|
||||
],
|
||||
senke,
|
||||
);
|
||||
expect(ergebnis.ok || ergebnis.fehler.ursache).toBe("Unerwarteter Fehler: kaputt");
|
||||
const bewusst = await phasenAusfuehren(
|
||||
[
|
||||
{
|
||||
schluessel: "y",
|
||||
titel: "Y",
|
||||
ausfuehren: async () => {
|
||||
throw new AblaufFehler("U", "H");
|
||||
},
|
||||
},
|
||||
],
|
||||
senke,
|
||||
);
|
||||
expect(bewusst.ok || bewusst.fehler).toEqual({ ursache: "U", hinweis: "H" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user