Files
proxdeploy/src/server/services/katalog.ts
T
florian.wildeandClaude Opus 5.5 e4306a6a53 feat(katalog): Profil-Editor mit Live-Prüfung und Skriptvorschau
Admins legen eigene Profile als YAML an; geprüft wird beim Tippen gegen
dasselbe Schema wie der Katalog, dazu eine Vorschau des erzeugten
Skripts. Katalogprofile bleiben schreibgeschützt und lassen sich als
Kopie bearbeiten. Ein Profil, das ein anderes voraussetzt, lässt sich
nicht löschen.

Closes #011

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:44:28 +02:00

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { join } from "node:path";
import type { Prisma } from "@/generated/prisma/client";
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
import { katalogLaden, yamlPruefen, type KatalogFehler } from "@/engine/katalog/laden";
import { ProfilSchema } from "@/engine/katalog/schema";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/** Wo der Katalog liegt: im Repo und im Image unter ./catalog, überschreibbar für Tests. */
export function katalogWurzel(): string {
return process.env["PROXDEPLOY_KATALOG"] ?? join(process.cwd(), "catalog");
}
export type SyncErgebnis = {
distributionen: { neu: number; geaendert: number; entfernt: number };
profile: { neu: number; geaendert: number; entfernt: number };
fehler: KatalogFehler[];
};
const json = (wert: unknown) => wert as Prisma.InputJsonValue;
/**
* Gleicht catalog/ mit der Datenbank ab. Die Datei ist maßgeblich für den Inhalt, die Datenbank
* für `aktiv`. Verschwundene Einträge werden markiert statt gelöscht – Aufträge verweisen darauf.
* Idempotent: app und worker dürfen es beide beim Start aufrufen.
*/
export async function katalogSynchronisieren(wurzel = katalogWurzel()): Promise<SyncErgebnis> {
const katalog = await katalogLaden(wurzel);
const jetzt = new Date();
const ergebnis: SyncErgebnis = {
distributionen: { neu: 0, geaendert: 0, entfernt: 0 },
profile: { neu: 0, geaendert: 0, entfernt: 0 },
fehler: [...katalog.fehler],
};
await prisma.$transaction(async (tx) => {
// Gleichzeitiger Start von app und worker: nur einer gleicht ab, der andere wartet.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('proxdeploy:katalog'))`;
const bisherD = new Map((await tx.distribution.findMany()).map((d) => [d.id, d]));
for (const { eintrag, quelltext, hash } of katalog.distributionen) {
const alt = bisherD.get(eintrag.id);
const daten = {
name: eintrag.name,
familie: eintrag.family,
definition: json(eintrag),
quelltext,
dateiHash: hash,
entferntAm: null,
synchronisiertAm: jetzt,
};
if (!alt) {
await tx.distribution.create({ data: { id: eintrag.id, ...daten } });
ergebnis.distributionen.neu += 1;
} else if (alt.dateiHash !== hash || alt.entferntAm) {
await tx.distribution.update({ where: { id: eintrag.id }, data: daten });
ergebnis.distributionen.geaendert += 1;
}
bisherD.delete(eintrag.id);
}
for (const verschwunden of bisherD.values()) {
if (verschwunden.entferntAm) continue;
await tx.distribution.update({ where: { id: verschwunden.id }, data: { entferntAm: jetzt } });
ergebnis.distributionen.entfernt += 1;
}
const bisherP = new Map((await tx.profil.findMany()).map((p) => [p.id, p]));
for (const { eintrag, quelltext, hash, datei } of katalog.profile) {
const alt = bisherP.get(eintrag.id);
if (alt?.herkunft === "EIGEN") {
ergebnis.fehler.push({
datei,
meldung: `Ein eigenes Profil „${eintrag.id}“ gibt es schon – das Katalogprofil wird nicht übernommen.`,
});
bisherP.delete(eintrag.id);
continue;
}
const daten = {
name: eintrag.name,
beschreibung: eintrag.description ?? null,
familien: Object.keys(eintrag.families),
herkunft: "KATALOG" as const,
definition: json(eintrag),
quelltext,
dateiHash: hash,
entferntAm: null,
synchronisiertAm: jetzt,
};
if (!alt) {
await tx.profil.create({ data: { id: eintrag.id, ...daten } });
ergebnis.profile.neu += 1;
} else if (alt.dateiHash !== hash || alt.entferntAm) {
await tx.profil.update({ where: { id: eintrag.id }, data: daten });
ergebnis.profile.geaendert += 1;
}
bisherP.delete(eintrag.id);
}
for (const verschwunden of bisherP.values()) {
if (verschwunden.herkunft !== "KATALOG" || verschwunden.entferntAm) continue;
await tx.profil.update({ where: { id: verschwunden.id }, data: { entferntAm: jetzt } });
ergebnis.profile.entfernt += 1;
}
});
for (const f of ergebnis.fehler) logger.warn({ datei: f.datei }, `Katalog: ${f.meldung}`);
return ergebnis;
}
// ── Lesen ───────────────────────────────────────────────────────────────────────────────────────
export type DistributionSicht = {
id: string;
name: string;
familie: string;
aktiv: boolean;
vm: boolean;
lxc: boolean;
definition: Distribution;
};
export async function distributionenListe(filter: { typ?: "vm" | "lxc"; nurAktive?: boolean } = {}) {
const zeilen = await prisma.distribution.findMany({
where: { entferntAm: null, ...(filter.nurAktive ? { aktiv: true } : {}) },
orderBy: { id: "asc" },
});
return zeilen
.map((d): DistributionSicht => {
const definition = d.definition as Distribution;
return {
id: d.id,
name: d.name,
familie: d.familie,
aktiv: d.aktiv,
vm: Boolean(definition.vm),
lxc: Boolean(definition.lxc),
definition,
};
})
.filter((d) => !filter.typ || d[filter.typ]);
}
export type ProfilSicht = {
id: string;
name: string;
beschreibung: string | null;
familien: string[];
herkunft: "KATALOG" | "EIGEN";
aktiv: boolean;
definition: Profil;
quelltext: string;
};
export async function profileListe(filter: { nurAktive?: boolean } = {}): Promise<ProfilSicht[]> {
const zeilen = await prisma.profil.findMany({
where: { entferntAm: null, ...(filter.nurAktive ? { aktiv: true } : {}) },
orderBy: { id: "asc" },
});
return zeilen.map((p) => ({
id: p.id,
name: p.name,
beschreibung: p.beschreibung,
familien: p.familien,
herkunft: p.herkunft,
aktiv: p.aktiv,
definition: p.definition as Profil,
quelltext: p.quelltext,
}));
}
export async function distributionAktivSetzen(
id: string,
aktiv: boolean,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const vorhanden = await prisma.distribution.findUnique({ where: { id } });
if (!vorhanden || vorhanden.entferntAm) throw nichtGefunden(`Die Distribution „${id}“`);
await prisma.$transaction(async (tx) => {
await tx.distribution.update({ where: { id }, data: { aktiv, geaendertVonId: aufrufer.benutzer.id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: aktiv ? "Distribution aktiviert" : "Distribution deaktiviert",
ziel: `distribution:${id}`,
ip,
},
tx,
);
});
}
export async function profilAktivSetzen(id: string, aktiv: boolean, aufrufer: Aufrufer, ip: string | null) {
const vorhanden = await prisma.profil.findUnique({ where: { id } });
if (!vorhanden || vorhanden.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
await prisma.$transaction(async (tx) => {
await tx.profil.update({ where: { id }, data: { aktiv, geaendertVonId: aufrufer.benutzer.id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: aktiv ? "Profil aktiviert" : "Profil deaktiviert",
ziel: `profil:${id}`,
ip,
},
tx,
);
});
}
// ── Eigene Profile (#011) ───────────────────────────────────────────────────────────────────────
export type ProfilPruefung =
{ ok: true; profil: Profil; vorschau: string | null } | { ok: false; fehler: string[] };
/**
* Prüft ein Profil aus dem Editor: YAML, Schema, Voraussetzungen. Mit Vorschau des erzeugten
* Skripts für die erste Distribution der Familie, damit man sieht, was im Gast ankommt.
*/
export async function profilPruefen(quelltext: string): Promise<ProfilPruefung> {
const geprueft = yamlPruefen(quelltext, ProfilSchema);
if (!geprueft.ok) return { ok: false, fehler: geprueft.fehler };
const profil = geprueft.eintrag;
const alle = new Map(
(await prisma.profil.findMany({ where: { entferntAm: null } })).map((p) => [
p.id,
p.definition as Profil,
]),
);
alle.set(profil.id, profil);
const fehler: string[] = [];
for (const familie of Object.keys(profil.families)) {
if (!adapterFuer(familie))
fehler.push(
`families.${familie}: Für diese Familie gibt es keinen Adapter (bekannt: ${bekannteFamilien().join(", ")}).`,
);
}
const familie = Object.keys(profil.families).find((f) => adapterFuer(f));
let vorschau: string | null = null;
if (familie) {
const aufgeloest = profileAufloesen([profil.id], alle, familie, {
// Für die Vorschau Pflichtvariablen mit einem Platzhalter füllen.
[profil.id]: Object.fromEntries(
profil.variables.filter((v) => v.required).map((v) => [v.name, `<${v.name}>`]),
),
});
if (!aufgeloest.ok) {
fehler.push(...aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`));
} else {
const distro = (await distributionenListe({ nurAktive: true })).find((d) => d.familie === familie);
try {
vorschau =
profileRendern(
aufgeloest.profile.filter((p) => p.profil.id === profil.id),
adapterFuer(familie)!,
{
os: distro?.definition.os ?? { id: familie, version_id: "0" },
gast: { name: "beispiel", benutzer: "benutzer", typ: "vm" },
},
).abschnitte[0]?.skript ?? null;
} catch (ursache) {
fehler.push(ursache instanceof Error ? ursache.message : String(ursache));
}
}
}
return fehler.length > 0 ? { ok: false, fehler } : { ok: true, profil, vorschau };
}
export async function profilSpeichern(
quelltext: string,
aufrufer: Aufrufer,
ip: string | null,
bisherigeId?: string,
): Promise<ProfilSicht> {
const pruefung = await profilPruefen(quelltext);
if (!pruefung.ok)
throw new AnwendungsFehler(
400,
"ungueltig",
pruefung.fehler.join(" "),
"Fehlerstellen im Editor beheben.",
);
const { profil } = pruefung;
const vorhanden = await prisma.profil.findUnique({ where: { id: profil.id } });
if (bisherigeId && bisherigeId !== profil.id) {
throw new AnwendungsFehler(
400,
"ungueltig",
"Die id eines Profils lässt sich nicht ändern.",
"Als neues Profil mit anderer id speichern.",
);
}
if (vorhanden && (vorhanden.herkunft === "KATALOG" || !bisherigeId) && !vorhanden.entferntAm) {
throw new AnwendungsFehler(
409,
"vergeben",
vorhanden.herkunft === "KATALOG"
? `„${profil.id}“ ist ein Katalogprofil.`
: `Ein Profil „${profil.id}“ gibt es schon.`,
vorhanden.herkunft === "KATALOG"
? "Für eine eigene Fassung eine andere id wählen, z. B. docker-eigen."
: undefined,
);
}
const daten = {
name: profil.name,
beschreibung: profil.description ?? null,
familien: Object.keys(profil.families),
herkunft: "EIGEN" as const,
definition: profil as unknown as Prisma.InputJsonValue,
quelltext,
entferntAm: null,
geaendertVonId: aufrufer.benutzer.id,
};
await prisma.$transaction(async (tx) => {
await tx.profil.upsert({ where: { id: profil.id }, create: { id: profil.id, ...daten }, update: daten });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: bisherigeId ? "Profil geändert" : "Profil angelegt",
ziel: `profil:${profil.id}`,
ip,
},
tx,
);
});
return (await profileListe()).find((p) => p.id === profil.id)!;
}
export async function profilLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
const profil = await prisma.profil.findUnique({ where: { id } });
if (!profil || profil.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
if (profil.herkunft === "KATALOG") {
throw new AnwendungsFehler(
409,
"katalog",
"Katalogprofile lassen sich nicht löschen.",
"Deaktivieren oder die YAML-Datei aus catalog/profiles entfernen.",
);
}
const abhaengig = (await prisma.profil.findMany({ where: { entferntAm: null } })).filter((p) =>
(p.definition as Profil).requires?.includes(id),
);
if (abhaengig.length > 0) {
throw new AnwendungsFehler(
409,
"benutzt",
`${abhaengig.map((p) => p.id).join(", ")} setzt „${id}“ voraus.`,
);
}
await prisma.$transaction(async (tx) => {
// Aufträge nennen Profile nur beim Namen – löschen ist sicher, der Verlauf bleibt lesbar.
await tx.profil.delete({ where: { id } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Profil gelöscht",
ziel: `profil:${id}`,
ip,
},
tx,
);
});
}