feat(katalog): Distributionen und Profile als YAML-Daten

Fünf Distributionen (Debian 12/13, Ubuntu 22.04/24.04/26.04) und drei
Profile liegen unter catalog/ und werden beim Start abgeglichen. Die
Datei ist maßgeblich für den Inhalt, die Datenbank nur für „aktiv“;
verschwundene Einträge werden markiert, nicht gelöscht.

Unbekannte Schlüssel sind Fehler, damit Tippfehler nicht still
verschwinden; eine kaputte Datei wird mit Pfad gemeldet und
übersprungen, statt den Start zu verhindern. Prüfsummen-Parser kennt
das Debian-, Ubuntu- und BSD-Format; bei mehreren LXC-Templates gewinnt
die höchste Version.

Closes #004

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:11:14 +02:00
co-authored by Claude Opus 5.5
parent 69cf8eb784
commit acae1bc2a4
32 changed files with 1359 additions and 15 deletions
+211
View File
@@ -0,0 +1,211 @@
import { join } from "node:path";
import type { Prisma } from "@/generated/prisma/client";
import { katalogLaden, type KatalogFehler } from "@/engine/katalog/laden";
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 { 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,
);
});
}