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:
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user