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,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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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