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,86 @@
|
||||
import { AbbruchFehler, fehlerBeschreiben, type FehlerBeschreibung } from "./fehler";
|
||||
|
||||
/**
|
||||
* Der Phasen-Runner. Er kennt weder Proxmox noch die Datenbank: Phasen sind Funktionen, Ereignisse
|
||||
* gehen an eine Senke. Der worker hängt eine Senke an, die in die Datenbank schreibt und per
|
||||
* NOTIFY meldet; Tests hängen eine an, die mitschreibt.
|
||||
*
|
||||
* Die Texte (Titel, Detail, Ursache, Hinweis) entstehen nur hier und in den Phasen – Web und CLI
|
||||
* zeigen sie unverändert an.
|
||||
*/
|
||||
export type PhasenStatusWert = "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
|
||||
|
||||
export type LogStufe = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface EreignisSenke {
|
||||
phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void>;
|
||||
phaseGestartet(schluessel: string, detail?: string): Promise<void>;
|
||||
/** Zwischenstand, z. B. „Download 43 %“ oder „cloud-init läuft …“. */
|
||||
phaseDetail(schluessel: string, detail: string): Promise<void>;
|
||||
phaseBeendet(
|
||||
schluessel: string,
|
||||
status: Exclude<PhasenStatusWert, "OFFEN" | "LAEUFT">,
|
||||
angaben: { detail?: string; fehler?: FehlerBeschreibung },
|
||||
): Promise<void>;
|
||||
log(stufe: LogStufe, nachricht: string, angaben?: { phase?: string; daten?: unknown }): Promise<void>;
|
||||
}
|
||||
|
||||
export type PhasenKontext = {
|
||||
/** Detailtext der laufenden Phase aktualisieren. */
|
||||
detail(text: string): Promise<void>;
|
||||
log(nachricht: string, stufe?: LogStufe, daten?: unknown): Promise<void>;
|
||||
/** Wirft AbbruchFehler, wenn der Auftrag abgebrochen werden soll. */
|
||||
pruefeAbbruch(): Promise<void>;
|
||||
};
|
||||
|
||||
export type PhasenErgebnis = { detail?: string; uebersprungen?: boolean };
|
||||
|
||||
export type PhasenSchritt = {
|
||||
schluessel: string;
|
||||
titel: string;
|
||||
ausfuehren: (kontext: PhasenKontext) => Promise<PhasenErgebnis | void>;
|
||||
};
|
||||
|
||||
export type AblaufErgebnis =
|
||||
{ ok: true } | { ok: false; abgebrochen: boolean; phase: string; fehler: FehlerBeschreibung };
|
||||
|
||||
export async function phasenAusfuehren(
|
||||
schritte: PhasenSchritt[],
|
||||
senke: EreignisSenke,
|
||||
optionen: { abbruchGewuenscht?: () => Promise<boolean> } = {},
|
||||
): Promise<AblaufErgebnis> {
|
||||
await senke.phasenAnlegen(schritte.map(({ schluessel, titel }) => ({ schluessel, titel })));
|
||||
|
||||
for (const schritt of schritte) {
|
||||
const kontext: PhasenKontext = {
|
||||
detail: (text) => senke.phaseDetail(schritt.schluessel, text),
|
||||
log: (nachricht, stufe = "info", daten) =>
|
||||
senke.log(stufe, nachricht, { phase: schritt.schluessel, daten }),
|
||||
pruefeAbbruch: async () => {
|
||||
if (await optionen.abbruchGewuenscht?.()) throw new AbbruchFehler();
|
||||
},
|
||||
};
|
||||
try {
|
||||
await kontext.pruefeAbbruch();
|
||||
await senke.phaseGestartet(schritt.schluessel);
|
||||
const ergebnis = (await schritt.ausfuehren(kontext)) ?? {};
|
||||
await senke.phaseBeendet(schritt.schluessel, ergebnis.uebersprungen ? "UEBERSPRUNGEN" : "ERLEDIGT", {
|
||||
detail: ergebnis.detail,
|
||||
});
|
||||
} catch (fehler) {
|
||||
const beschreibung = fehlerBeschreiben(fehler);
|
||||
await senke.phaseBeendet(schritt.schluessel, "FEHLER", { fehler: beschreibung });
|
||||
await senke.log("error", `${schritt.titel}: ${beschreibung.ursache}`, {
|
||||
phase: schritt.schluessel,
|
||||
daten: fehler instanceof Error ? { name: fehler.name, stack: fehler.stack } : undefined,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
abgebrochen: fehler instanceof AbbruchFehler,
|
||||
phase: schritt.schluessel,
|
||||
fehler: beschreibung,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AdapterFehler } from "@/engine/adapter/debian";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
import { VorlagenFehler } from "@/engine/profile/vorlage";
|
||||
import { SshFehler } from "@/engine/ssh/verbindung";
|
||||
|
||||
/**
|
||||
* Ein Fehler, den eine Phase bewusst wirft – mit Ursache und Lösungshinweis, wie sie unter der
|
||||
* Phase in Web und CLI stehen.
|
||||
*/
|
||||
export class AblaufFehler extends Error {
|
||||
constructor(
|
||||
readonly ursache: string,
|
||||
readonly hinweis: string,
|
||||
) {
|
||||
super(ursache);
|
||||
this.name = "AblaufFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Auftrag wurde abgebrochen – kein Fehler im eigentlichen Sinn. */
|
||||
export class AbbruchFehler extends Error {
|
||||
constructor() {
|
||||
super("Abgebrochen");
|
||||
this.name = "AbbruchFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type FehlerBeschreibung = { ursache: string; hinweis: string };
|
||||
|
||||
/** Macht aus jedem Fehler Ursache und Hinweis; Unbekanntes wird ehrlich als solches benannt. */
|
||||
export function fehlerBeschreiben(fehler: unknown): FehlerBeschreibung {
|
||||
if (fehler instanceof AblaufFehler || fehler instanceof ProxmoxFehler || fehler instanceof SshFehler) {
|
||||
return { ursache: fehler.ursache, hinweis: fehler.hinweis };
|
||||
}
|
||||
if (fehler instanceof AdapterFehler || fehler instanceof VorlagenFehler) {
|
||||
return { ursache: fehler.message, hinweis: "Eingaben bzw. Profil-Variablen prüfen." };
|
||||
}
|
||||
if (fehler instanceof AbbruchFehler) {
|
||||
return { ursache: "Abgebrochen.", hinweis: "Bereits Angelegtes lässt sich über „Aufräumen“ entfernen." };
|
||||
}
|
||||
const text = fehler instanceof Error ? fehler.message : String(fehler);
|
||||
return {
|
||||
ursache: `Unerwarteter Fehler: ${text}`,
|
||||
hinweis: "Detail-Log prüfen; das ist vermutlich ein Fehler in ProxDeploy.",
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pfad, type ProxmoxClient } from "./client";
|
||||
import { istTrockenUpid, pfad, type ProxmoxClient } from "./client";
|
||||
import { ProxmoxFehler } from "./fehler";
|
||||
|
||||
/**
|
||||
@@ -10,6 +10,7 @@ type LogZeile = { n: number; t: string };
|
||||
|
||||
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
|
||||
export function nodeAusUpid(upid: string): string {
|
||||
if (istTrockenUpid(upid)) return upid.split(":")[2] ?? "node";
|
||||
const teile = upid.split(":");
|
||||
if (teile[0] !== "UPID" || !teile[1]) {
|
||||
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
|
||||
@@ -32,6 +33,7 @@ export async function aufgabeAbwarten(
|
||||
upid: string,
|
||||
optionen: AbwartenOptionen = {},
|
||||
): Promise<void> {
|
||||
if (istTrockenUpid(upid)) return;
|
||||
const node = nodeAusUpid(upid);
|
||||
const intervall = optionen.intervallMs ?? 1000;
|
||||
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
|
||||
|
||||
@@ -26,6 +26,17 @@ export type AufrufBeobachter = (
|
||||
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Trockenlauf (Dry-Run): lesende Aufrufe gehen wirklich an Proxmox, schreibende werden nur
|
||||
* aufgezeichnet und mit einer Platzhalter-UPID beantwortet. Dieselben Phasen laufen so im
|
||||
* Trockenlauf und im Ernstfall – der angezeigte Plan kann nicht vom tatsächlichen Ablauf abweichen.
|
||||
*/
|
||||
export const TROCKEN_UPID_PRAEFIX = "UPID:trocken:";
|
||||
|
||||
export function istTrockenUpid(upid: unknown): boolean {
|
||||
return typeof upid === "string" && upid.startsWith(TROCKEN_UPID_PRAEFIX);
|
||||
}
|
||||
|
||||
const STANDARD_ZEITLIMIT_MS = 30_000;
|
||||
|
||||
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
||||
@@ -60,7 +71,11 @@ export class ProxmoxClient {
|
||||
|
||||
constructor(
|
||||
private readonly verbindung: ProxmoxVerbindung,
|
||||
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {},
|
||||
private readonly optionen: {
|
||||
zeitlimitMs?: number;
|
||||
beobachter?: AufrufBeobachter;
|
||||
trocken?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
||||
throw new ProxmoxFehler(
|
||||
@@ -90,7 +105,20 @@ export class ProxmoxClient {
|
||||
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
|
||||
}
|
||||
|
||||
/** Im Trockenlauf aufgezeichnete schreibende Aufrufe, in Reihenfolge. */
|
||||
readonly geplant: Aufruf[] = [];
|
||||
|
||||
get trocken(): boolean {
|
||||
return Boolean(this.optionen.trocken);
|
||||
}
|
||||
|
||||
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
|
||||
if (this.optionen.trocken && aufruf.methode !== "GET") {
|
||||
this.geplant.push(aufruf);
|
||||
this.optionen.beobachter?.(aufruf, { dauerMs: 0 });
|
||||
const knoten = /^\/nodes\/([^/]+)/.exec(aufruf.pfad)?.[1] ?? "node";
|
||||
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
|
||||
}
|
||||
const beginn = Date.now();
|
||||
try {
|
||||
const ergebnis = await this.senden<T>(aufruf);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Client } from "ssh2";
|
||||
// ssh2 ist CommonJS: unter reinem Node-ESM (worker) gibt es keine benannten Exporte.
|
||||
import ssh2 from "ssh2";
|
||||
|
||||
const { Client } = ssh2;
|
||||
|
||||
/**
|
||||
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
|
||||
|
||||
Reference in New Issue
Block a user