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:
2026-10-12 10:54:41 +02:00
co-authored by Claude Opus 5.5
parent 01d22aac3a
commit 76bc0e4269
38 changed files with 2274 additions and 22 deletions
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Code } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { TerminalKarte, type TerminalErgebnis } from "@/components/terminal-karte";
import { apiAufruf } from "@/lib/api-client";
import { dauer } from "@/lib/formate";
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
function ergebnisAus(a: AuftragSicht): TerminalErgebnis | null {
if (!ENDE.has(a.status)) return null;
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
const gesamt =
a.gestartetAm && a.beendetAm
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
: null;
const name = a.name ?? a.kurzId;
if (a.status !== "ERFOLGREICH") {
return {
ok: false,
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : "fehlgeschlagen"}`,
dauer: gesamt,
zeilen: [],
};
}
const zeilen: Array<[string, string]> = [];
if (a.vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(a.vmid)]);
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
}
/**
* Hängt sich per SSE an den Auftrag. Fällt SSE aus (z. B. ein Proxy, der Streams puffert), wird
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
*/
export function LiveAnsicht({
anfang,
anfangsLog,
version,
abbrechenErlaubt,
}: {
anfang: AuftragSicht;
anfangsLog: LogZeile[];
version: string;
abbrechenErlaubt: boolean;
}) {
const [auftrag, setAuftrag] = useState(anfang);
const [log, setLog] = useState(anfangsLog);
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
ENDE.has(anfang.status) ? "beendet" : "live",
);
const logBis = useRef(anfangsLog.at(-1)?.id ?? "0");
useEffect(() => {
if (ENDE.has(anfang.status)) return;
let fehlschlaege = 0;
let abfrage: ReturnType<typeof setInterval> | undefined;
const quelle = new EventSource(`/api/v1/auftraege/${anfang.kurzId}/ereignisse?ab=${logBis.current}`);
const zeilenAnhaengen = (neu: LogZeile[]) => {
const bekannt = BigInt(logBis.current);
const frisch = neu.filter((z) => BigInt(z.id) > bekannt);
if (frisch.length === 0) return;
logBis.current = frisch.at(-1)!.id;
setLog((alt) => [...alt, ...frisch]);
};
quelle.addEventListener("stand", (e) => {
fehlschlaege = 0;
setAuftrag(JSON.parse((e as MessageEvent).data));
});
quelle.addEventListener("log", (e) => zeilenAnhaengen(JSON.parse((e as MessageEvent).data)));
quelle.addEventListener("ende", () => {
quelle.close();
setVerbindung("beendet");
});
quelle.onerror = () => {
fehlschlaege += 1;
if (fehlschlaege < 3 || abfrage) return;
quelle.close();
setVerbindung("abfrage");
abfrage = setInterval(async () => {
try {
const stand = await apiAufruf<AuftragSicht>("GET", `/api/v1/auftraege/${anfang.kurzId}`);
setAuftrag(stand);
zeilenAnhaengen(
await apiAufruf<LogZeile[]>("GET", `/api/v1/auftraege/${anfang.kurzId}/log?ab=${logBis.current}`),
);
if (ENDE.has(stand.status)) {
clearInterval(abfrage);
setVerbindung("beendet");
}
} catch {
// nächster Versuch in 3 s
}
}, 3000);
};
return () => {
quelle.close();
if (abfrage) clearInterval(abfrage);
};
}, [anfang.kurzId, anfang.status]);
const [art, text] =
auftrag.status === "WARTEND"
? (["offen", "wartet auf den worker"] as const)
: STATUS_ANZEIGE[auftrag.status];
const ergebnis = ergebnisAus(auftrag);
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<p className="text-muted font-mono text-sm">
{auftrag.kurzId} · gestartet von {auftrag.erstelltVon}
</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{auftrag.art === "TEMPLATE_BAUEN"
? "Template bauen"
: auftrag.art === "AUFRAEUMEN"
? "Aufräumen"
: (auftrag.name ?? "Ausrollen")}
</h1>
</div>
<div className="flex items-center gap-4">
<Status art={art}>{text}</Status>
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
<Knopf
variante="gefahr"
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
>
Abbrechen
</Knopf>
) : null}
</div>
</header>
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
{typeof ssh === "string" ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted text-sm">SSH-Befehl</span>
<Code>{ssh}</Code>
</div>
) : null}
<details
className="bg-panel border-linie rounded-karte border"
open={auftrag.status === "FEHLGESCHLAGEN"}
>
<summary className="cursor-pointer px-5 py-3.5 font-semibold">
Detail-Log <span className="text-muted font-normal">({log.length} Zeilen)</span>
</summary>
<ol className="border-linie max-h-[60vh] overflow-auto border-t px-5 py-3 font-mono text-xs leading-5">
{log.map((zeile) => (
<li key={zeile.id} className="flex gap-3">
<time className="text-muted shrink-0">{new Date(zeile.zeit).toLocaleTimeString("de-DE")}</time>
<span
className={
zeile.stufe === "error"
? "text-fehler"
: zeile.stufe === "warn"
? "text-laeuft"
: zeile.stufe === "debug"
? "text-muted"
: "text-text"
}
>
{zeile.phase ? <span className="text-muted">[{zeile.phase}] </span> : null}
<span className="break-all whitespace-pre-wrap">{zeile.nachricht}</span>
</span>
</li>
))}
</ol>
</details>
<p className="text-muted text-xs" aria-live="polite">
{verbindung === "live"
? "Live verbunden."
: verbindung === "abfrage"
? "Live-Verbindung nicht möglich – Aktualisierung alle 3 s."
: null}
</p>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { versionText } from "@/lib/version";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragHolen, logAb } from "@/server/services/auftraege";
import { LiveAnsicht } from "./live-ansicht";
export async function generateMetadata({
params,
}: {
params: Promise<{ kurzId: string }>;
}): Promise<Metadata> {
return { title: (await params).kurzId };
}
export default async function AuftragSeite({ params }: { params: Promise<{ kurzId: string }> }) {
const { kurzId } = await params;
const benutzer = await seiteSchuetzen("lesen", `/auftraege/${kurzId}`);
const auftrag = await auftragHolen(kurzId).catch((fehler) => {
if (fehler instanceof AnwendungsFehler && fehler.status === 404) notFound();
throw fehler;
});
const log = await logAb(auftrag.id, BigInt(0), 2000);
return (
<LiveAnsicht
anfang={auftrag}
anfangsLog={log}
version={versionText()}
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { zeitpunkt } from "@/lib/formate";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
export const metadata: Metadata = { title: "Verlauf" };
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
export default async function Verlauf() {
await seiteSchuetzen("lesen", "/auftraege");
const auftraege = await auftraegeListe({ grenze: 100 });
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
<Karte>
{auftraege.length === 0 ? (
<Status art="offen">Noch keine Aufträge.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{auftraege.map((a) => {
const [art, text] = STATUS_ANZEIGE[a.status];
return (
<li key={a.id}>
<Link
href={`/auftraege/${a.kurzId}`}
className="hover:bg-panel-hoch/60 -mx-2 flex flex-col gap-1 rounded-lg px-2 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<span className="font-mono text-sm sm:w-20">{a.kurzId}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
<span className="text-muted text-sm">
{" "}
· {ART_TEXT[a.art]} · {a.server.name}
{a.distributionId ? ` · ${a.distributionId}` : ""}
{a.vmid ? ` · ${a.vmid}` : ""}
</span>
</span>
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
<Status art={art} className="text-sm sm:w-32">
{text}
</Status>
</Link>
</li>
);
})}
</ul>
)}
</Karte>
</div>
);
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragAbbrechen } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
await auftragAbbrechen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true });
});
@@ -0,0 +1,101 @@
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { abonnieren } from "@/server/ereignisse/hoeren";
import { auftragHolen, ENDZUSTAENDE, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const dynamic = "force-dynamic";
/**
* Live-Fortschritt als Server-Sent Events – für Web-GUI und CLI derselbe Strom.
*
* - `stand`: der vollständige Auftrag mit allen Phasen (klein, deshalb immer ganz)
* - `log`: neue Detail-Log-Zeilen; ihre ID ist die Event-ID, damit ein Wiederverbinden mit
* `Last-Event-ID` (oder `?ab=`) genau dort weitermacht
* - `ende`: der Auftrag ist fertig, der Strom schließt
*/
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const anfangsStand = await auftragHolen((await params).id);
const auftragId = anfangsStand.id;
const letzte = anfrage.headers.get("last-event-id") ?? new URL(anfrage.url).searchParams.get("ab") ?? "0";
let logBis = BigInt(/^\d+$/.test(letzte) ? letzte : "0");
const kodierer = new TextEncoder();
let aufraeumen = () => {};
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
const senden = (text: string) => {
if (offen) steuerung.enqueue(kodierer.encode(text));
};
const ereignis = (art: string, daten: unknown, id?: string) =>
senden(`${id ? `id: ${id}\n` : ""}event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`);
let laeuft = false;
let nochmal = false;
const aktualisieren = async () => {
// Signale können dicht kommen; zusammenfassen statt parallel abzufragen.
if (laeuft) {
nochmal = true;
return;
}
laeuft = true;
try {
do {
nochmal = false;
const stand = await auftragHolen(auftragId);
const zeilen = await logAb(auftragId, logBis);
if (zeilen.length > 0) {
logBis = BigInt(zeilen.at(-1)!.id);
ereignis("log", zeilen, zeilen.at(-1)!.id);
}
ereignis("stand", stand);
if (ENDZUSTAENDE.has(stand.status)) {
ereignis("ende", { status: stand.status });
schliessen();
return;
}
} while (nochmal);
} finally {
laeuft = false;
}
};
const abmelden = await abonnieren(auftragId, () => void aktualisieren());
// Herzschlag: hält Proxys davon ab, die Verbindung als tot zu schließen.
const herz = setInterval(() => senden(": ping\n\n"), 15_000);
const schliessen = () => {
if (!offen) return;
offen = false;
clearInterval(herz);
abmelden();
try {
steuerung.close();
} catch {
// schon geschlossen
}
};
aufraeumen = schliessen;
anfrage.signal.addEventListener("abort", schliessen);
senden("retry: 3000\n\n");
await aktualisieren();
},
cancel() {
aufraeumen();
},
});
return new Response(strom, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
// nginx puffert sonst und der Fortschritt käme in Brocken an.
"x-accel-buffering": "no",
},
});
});
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragHolen, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
/** Detail-Log ab einer Zeilen-ID (`?ab=`), für Polling und `proxdeploy logs`. */
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const auftrag = await auftragHolen((await params).id);
const ab = new URL(anfrage.url).searchParams.get("ab");
return NextResponse.json(await logAb(auftrag.id, BigInt(/^\d+$/.test(ab ?? "") ? ab! : "0")));
});
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragHolen } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await auftragHolen((await params).id));
});
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await auftraegeListe());
});
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { StatusSymbol, type StatusArt } from "@/components/status";
import { dauer } from "@/lib/formate";
/**
* Die Live-Ansicht im Aufbau der CLI-Ausgabe: Kopfzeile, eine Zeile je Phase (Symbol, Name in
* fester Breite, Detail grau), am Ende die Ergebnis-Box. Alle Texte kommen aus der Engine.
*/
export type TerminalPhase = {
schluessel: string;
titel: string;
status: "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
detail: string | null;
ursache: string | null;
hinweis: string | null;
gestartetAm: string | null;
};
export type TerminalErgebnis = {
ok: boolean;
titel: string;
dauer: string | null;
zeilen: Array<[string, string]>;
};
const ART: Record<TerminalPhase["status"], StatusArt> = {
OFFEN: "offen",
LAEUFT: "laeuft",
ERLEDIGT: "erledigt",
FEHLER: "fehler",
UEBERSPRUNGEN: "offen",
};
const FARBE: Record<StatusArt, string> = {
erledigt: "text-akzent",
laeuft: "text-laeuft",
fehler: "text-fehler",
offen: "text-muted",
};
function Laufzeit({ seit }: { seit: string }) {
const [jetzt, setJetzt] = useState(() => Date.now());
useEffect(() => {
const uhr = setInterval(() => setJetzt(Date.now()), 1000);
return () => clearInterval(uhr);
}, []);
return <span>{dauer(jetzt - new Date(seit).getTime())}</span>;
}
export function TerminalKarte({
version,
kopf,
trockenlauf = false,
phasen,
ergebnis,
}: {
version: string;
kopf: string;
trockenlauf?: boolean;
phasen: TerminalPhase[];
ergebnis: TerminalErgebnis | null;
}) {
const breite = Math.max(12, ...phasen.map((p) => p.titel.length)) + 2;
return (
<div className="bg-panel border-linie rounded-karte overflow-hidden border font-mono text-[13px] leading-6 sm:text-sm">
<div
className="flex items-center gap-1.5 border-b border-[var(--farbe-linie)] px-4 py-2"
aria-hidden="true"
>
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
</div>
<div className="flex flex-col gap-4 p-4 sm:p-5">
<p className="break-words">
<span className="text-muted">⬡ </span>
<span className="text-text">Prox</span>
<span className="text-akzent font-bold">Deploy</span>
<span className="text-muted">
{" "}
{version} · {kopf}
</span>
{trockenlauf ? <span className="text-laeuft font-bold"> · DRY-RUN</span> : null}
</p>
<ol className="flex flex-col" aria-label="Phasen">
{phasen.map((phase) => {
const art = ART[phase.status];
return (
<li key={phase.schluessel} className="flex flex-col sm:flex-row sm:gap-2">
<span
className={clsx("inline-flex shrink-0 items-baseline gap-2", FARBE[art])}
style={{ minWidth: `${breite + 2}ch` }}
>
<StatusSymbol
art={art}
className={clsx(
"translate-y-[0.15em]",
phase.status === "LAEUFT" && "motion-safe:animate-spin",
)}
/>
<span
className={
phase.status === "OFFEN" || phase.status === "UEBERSPRUNGEN"
? "text-muted"
: "text-text"
}
>
{phase.titel}
</span>
<span className="sr-only">
{" "}
–{" "}
{
{
OFFEN: "offen",
LAEUFT: "läuft",
ERLEDIGT: "erledigt",
FEHLER: "Fehler",
UEBERSPRUNGEN: "übersprungen",
}[phase.status]
}
</span>
</span>
<span className="text-muted min-w-0 pl-6 break-words sm:pl-0">
{phase.status === "FEHLER" ? (
<>
<span className="text-fehler">{phase.ursache}</span>
{phase.hinweis ? <span className="block">→ {phase.hinweis}</span> : null}
</>
) : (
<>
{phase.detail}
{phase.status === "LAEUFT" && phase.gestartetAm ? (
<>
{phase.detail ? " … " : ""}
<Laufzeit seit={phase.gestartetAm} />
</>
) : null}
</>
)}
</span>
</li>
);
})}
</ol>
{ergebnis ? (
<div
className={clsx(
"max-w-xl rounded-lg border px-4 py-3",
ergebnis.ok ? "border-akzent" : "border-fehler",
)}
>
<p className={ergebnis.ok ? "text-akzent" : "text-fehler"}>
{ergebnis.ok ? "● " : "✗ "}
<span className="text-text font-bold">{ergebnis.titel}</span>
{ergebnis.dauer ? <span className="text-muted"> · {ergebnis.dauer}</span> : null}
</p>
{ergebnis.zeilen.length > 0 ? (
<dl className="mt-1 grid grid-cols-[9ch_1fr] gap-x-2">
{ergebnis.zeilen.map(([name, wert]) => (
<div key={name} className="contents">
<dt className="text-muted">{name}</dt>
<dd className="text-text break-all">{wert}</dd>
</div>
))}
</dl>
) : null}
</div>
) : null}
</div>
</div>
);
}
+86
View File
@@ -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 };
}
+46
View File
@@ -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.",
};
}
+3 -1
View File
@@ -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;
+29 -1
View File
@@ -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);
+4 -1
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
import type { StatusArt } from "@/components/status";
import type { AuftragSicht } from "@/lib/schemas/auftrag";
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
WARTEND: ["offen", "wartet"],
LAEUFT: ["laeuft", "läuft"],
ERFOLGREICH: ["erledigt", "erfolgreich"],
FEHLGESCHLAGEN: ["fehler", "fehlgeschlagen"],
ABGEBROCHEN: ["fehler", "abgebrochen"],
AUFGERAEUMT: ["offen", "aufgeräumt"],
};
export const ART_TEXT: Record<AuftragSicht["art"], string> = {
AUSROLLEN: "Ausrollen",
TEMPLATE_BAUEN: "Template",
AUFRAEUMEN: "Aufräumen",
};
+1 -1
View File
@@ -23,7 +23,7 @@ export type NavigationsEintrag = {
export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
+35
View File
@@ -0,0 +1,35 @@
import type { AuftragsArt, AuftragsStatus, GastTyp, PhasenStatus } from "@/generated/prisma/enums";
/** Was die API über einen Auftrag herausgibt – geteilt von Server, Web-GUI und (als JSON) CLI. */
export type AuftragSicht = {
id: string;
kurzId: string;
art: AuftragsArt;
status: AuftragsStatus;
kopf: string;
server: { name: string; titel: string };
knoten: string | null;
gastTyp: GastTyp | null;
vmid: number | null;
name: string | null;
distributionId: string | null;
profile: string[];
ergebnis: unknown;
fehler: unknown;
erstelltVon: string;
erstelltAm: string;
gestartetAm: string | null;
beendetAm: string | null;
phasen: Array<{
schluessel: string;
titel: string;
status: PhasenStatus;
detail: string | null;
ursache: string | null;
hinweis: string | null;
gestartetAm: string | null;
beendetAm: string | null;
}>;
};
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };
+49
View File
@@ -0,0 +1,49 @@
// pg ist CommonJS – Default-Import, damit es auch im worker (reines Node-ESM) lädt.
import pg from "pg";
import { logger } from "@/lib/logger";
import { KANAL } from "./senke";
/**
* Eine LISTEN-Verbindung je Prozess, an der beliebig viele SSE-Ströme hängen. Bricht sie ab, wird
* sie neu aufgebaut; die Ströme holen sich beim nächsten Signal ohnehin den vollen Stand.
*/
type Hoerer = (auftragId: string) => void;
const hoerer = new Set<Hoerer>();
let verbindung: Promise<pg.Client> | undefined;
async function verbinden(): Promise<pg.Client> {
const client = new pg.Client({
connectionString: process.env["DATABASE_URL"],
application_name: "proxdeploy-listen",
});
client.on("notification", (meldung) => {
if (meldung.channel !== KANAL || !meldung.payload) return;
for (const h of hoerer) h(meldung.payload);
});
client.on("error", (fehler) => {
logger.warn({ err: fehler }, "LISTEN-Verbindung verloren, baue neu auf");
verbindung = undefined;
setTimeout(() => void sicherstellen(), 1000);
});
await client.connect();
await client.query(`LISTEN ${KANAL}`);
return client;
}
function sicherstellen(): Promise<pg.Client> {
verbindung ??= verbinden().catch((fehler) => {
verbindung = undefined;
throw fehler;
});
return verbindung;
}
export async function abonnieren(auftragId: string, rueckruf: () => void): Promise<() => void> {
await sicherstellen();
const h: Hoerer = (id) => {
if (id === auftragId) rueckruf();
};
hoerer.add(h);
return () => hoerer.delete(h);
}
+83
View File
@@ -0,0 +1,83 @@
import type { EreignisSenke, LogStufe } from "@/engine/ablauf/ablauf";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
/**
* Schreibt die Ereignisse eines Auftrags in die Datenbank und meldet sie per NOTIFY. Die app hört
* mit (LISTEN) und schiebt sie an alle offenen SSE-Verbindungen – Web und CLI sehen dasselbe.
*/
export const KANAL = "proxdeploy_auftrag";
export async function melden(auftragId: string): Promise<void> {
await prisma.$executeRaw`SELECT pg_notify(${KANAL}, ${auftragId})`;
}
export class DatenbankSenke implements EreignisSenke {
constructor(private readonly auftragId: string) {}
async phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void> {
await prisma.$transaction([
prisma.phase.deleteMany({ where: { auftragId: this.auftragId } }),
prisma.phase.createMany({
data: phasen.map((p, reihenfolge) => ({ auftragId: this.auftragId, reihenfolge, ...p })),
}),
]);
await melden(this.auftragId);
}
async phaseGestartet(schluessel: string, detail?: string): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: { status: "LAEUFT", gestartetAm: new Date(), ...(detail ? { detail } : {}) },
});
await melden(this.auftragId);
}
async phaseDetail(schluessel: string, detail: string): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: { detail },
});
await melden(this.auftragId);
}
async phaseBeendet(
schluessel: string,
status: "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN",
angaben: { detail?: string; fehler?: { ursache: string; hinweis: string } },
): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: {
status,
beendetAm: new Date(),
...(angaben.detail !== undefined ? { detail: angaben.detail } : {}),
...(angaben.fehler ? { ursache: angaben.fehler.ursache, hinweis: angaben.fehler.hinweis } : {}),
},
});
await melden(this.auftragId);
}
async log(
stufe: LogStufe,
nachricht: string,
angaben: { phase?: string; daten?: unknown } = {},
): Promise<void> {
await prisma.logeintrag.create({
data: {
auftragId: this.auftragId,
stufe,
nachricht,
phase: angaben.phase ?? null,
daten: (angaben.daten ?? undefined) as Prisma.InputJsonValue | undefined,
},
});
// Ins Betriebslog (JSON) geht jede Zeile mit Auftrags-ID – auch ohne GUI nachvollziehbar.
logger[stufe === "debug" ? "debug" : stufe](
{ auftragId: this.auftragId, phase: angaben.phase },
nachricht,
);
await melden(this.auftragId);
}
}
+191
View File
@@ -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);
}
+2 -2
View File
@@ -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 },
+72
View File
@@ -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 } });
}
+43
View File
@@ -0,0 +1,43 @@
import { PgBoss } from "pg-boss";
import { logger } from "@/lib/logger";
/**
* Die Auftragswarteschlange (pg-boss auf derselben PostgreSQL, Schema `pgboss`). Die app stellt
* nur ein, der worker arbeitet ab. Ein Auftrag wird nie automatisch wiederholt: halb angelegte
* Gäste soll ein Mensch ansehen, nicht eine Schleife.
*/
export const WARTESCHLANGE = "auftrag";
/** Template-Bau kann bei langsamem Download dauern; danach gilt ein Job als verwaist. */
const LAUFZEIT_SEKUNDEN = 4 * 3600;
export type AuftragsJob = { auftragId: string };
let instanz: Promise<PgBoss> | undefined;
export function boss(rolle: "app" | "worker"): Promise<PgBoss> {
instanz ??= (async () => {
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt.");
const b = new PgBoss({
connectionString: url,
application_name: `proxdeploy-${rolle}`,
// Wartung und Zeitpläne macht nur der worker; die app stellt nur ein.
supervise: rolle === "worker",
schedule: false,
});
b.on("error", (fehler) => logger.error({ err: fehler }, "pg-boss"));
await b.start();
await b.createQueue(WARTESCHLANGE, { retryLimit: 0, expireInSeconds: LAUFZEIT_SEKUNDEN });
return b;
})();
return instanz;
}
export async function auftragEinstellen(auftragId: string): Promise<void> {
const b = await boss("app");
await b.send(WARTESCHLANGE, { auftragId } satisfies AuftragsJob, {
retryLimit: 0,
expireInSeconds: LAUFZEIT_SEKUNDEN,
});
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Meldet alle Abläufe beim worker an. Template-Bau (#007), Ausrollen (#008) und Aufräumen folgen.
*/
export {};
+20
View File
@@ -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}` : ""}`,
);
};
}
+127
View File
@@ -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;
}
+39
View File
@@ -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);
});
+11
View File
@@ -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";