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 { 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 { 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 { 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 { 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 { 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 { 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, ); }); }