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
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
import { anmelden, keinQuerscrollen } from "./helfer";
test("Katalog: Distributionen und Profile sehen, Distribution ab- und wieder einschalten", async ({
page,
}) => {
await anmelden(page, "/katalog");
await expect(page.getByRole("heading", { name: "Katalog", level: 1 })).toBeVisible();
await expect(page.getByText("Debian 13 „Trixie“")).toBeVisible();
await expect(page.getByText("Docker CE")).toBeVisible();
await keinQuerscrollen(page);
const schalter = page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ });
await expect(schalter).toHaveAttribute("aria-checked", "true");
await schalter.click();
await expect(schalter).toHaveAttribute("aria-checked", "false");
await page.reload();
await expect(page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ })).toHaveAttribute(
"aria-checked",
"false",
);
await page.screenshot({ path: `test-results/katalog-${test.info().project.name}.png`, fullPage: true });
await page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ }).click();
await expect(page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ })).toHaveAttribute(
"aria-checked",
"true",
);
});
+177
View File
@@ -0,0 +1,177 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { katalogLaden, yamlPruefen } from "@/engine/katalog/laden";
import { templateWaehlen, versionVergleichen } from "@/engine/katalog/lxc-template";
import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme";
import { DistributionSchema, ProfilSchema } from "@/engine/katalog/schema";
describe("Mitgelieferter Katalog", () => {
it("lädt ohne Fehler: fünf Distributionen, drei Profile", async () => {
const katalog = await katalogLaden("catalog");
expect(katalog.fehler).toEqual([]);
expect(katalog.distributionen.map((d) => d.eintrag.id)).toEqual([
"debian-12",
"debian-13",
"ubuntu-22.04",
"ubuntu-24.04",
"ubuntu-26.04",
]);
expect(katalog.profile.map((p) => p.eintrag.id)).toEqual(["base", "docker", "zabbix-agent"]);
});
it("jede Distribution kann VM und LXC und gehört zur Familie debian", async () => {
const katalog = await katalogLaden("catalog");
for (const { eintrag } of katalog.distributionen) {
expect(eintrag.family).toBe("debian");
expect(eintrag.vm?.guest_agent_preinstalled).toBe(false);
expect(eintrag.vm?.bios).toBe("ovmf");
expect(dateiAusUrl(eintrag.vm!.image_url)).toMatch(/\.(qcow2|img)$/);
expect(eintrag.lxc?.template_pattern).toContain(eintrag.os.version_id);
}
});
it("jedes Profil hat eine Umsetzung für debian", async () => {
const katalog = await katalogLaden("catalog");
for (const { eintrag } of katalog.profile) expect(Object.keys(eintrag.families)).toContain("debian");
});
});
describe("Schema", () => {
const debian = {
id: "debian-13",
name: "Debian 13",
family: "debian",
os: { id: "debian", version_id: "13" },
lxc: { template_pattern: "debian-13-standard_*_amd64.tar.zst", ostype: "debian" },
};
it("verlangt vm oder lxc", () => {
expect(DistributionSchema.safeParse({ ...debian, lxc: undefined }).success).toBe(false);
});
it("lehnt unbekannte Schlüssel und HTTP-Quellen ab", () => {
expect(DistributionSchema.safeParse({ ...debian, imgae_url: "x" }).success).toBe(false);
const http = yamlPruefen(
`id: x\nname: X\nfamily: debian\nos: { id: debian, version_id: "1" }\nvm: { image_url: "http://a/b.qcow2", checksum_url: "https://a/S", checksum_type: sha256, format: qcow2, default_user: u }`,
DistributionSchema,
);
expect(http.ok).toBe(false);
if (!http.ok) expect(http.fehler.join()).toContain("vm.image_url");
});
it("meldet kaputtes YAML mit Zeile", () => {
const ergebnis = yamlPruefen("id: [kaputt", DistributionSchema);
expect(ergebnis.ok).toBe(false);
if (!ergebnis.ok) expect(ergebnis.fehler[0]).toMatch(/^YAML:/);
});
it("Profil: genau eine Aktion je Schritt, Bedingungen, eindeutige Variablen", () => {
const basis = { id: "p", name: "P", families: { debian: [{ packages: ["htop"] }] } };
expect(ProfilSchema.safeParse(basis).success).toBe(true);
expect(
ProfilSchema.safeParse({ ...basis, families: { debian: [{ packages: ["a"], update: true }] } }).success,
).toBe(false);
expect(
ProfilSchema.safeParse({ ...basis, families: { debian: [{ packages: ["a"], if: "x" }] } }).success,
).toBe(true);
expect(
ProfilSchema.safeParse({
...basis,
variables: [
{ name: "a", label: "A" },
{ name: "a", label: "B" },
],
}).success,
).toBe(false);
expect(
ProfilSchema.safeParse({ ...basis, variables: [{ name: "a", label: "A", type: "select" }] }).success,
).toBe(false);
});
});
describe("Katalog laden: Fehler einzeln", () => {
function katalog(dateien: Record<string, string>): string {
const wurzel = mkdtempSync(join(tmpdir(), "pd-katalog-"));
mkdirSync(join(wurzel, "distros"));
mkdirSync(join(wurzel, "profiles"));
for (const [pfad, inhalt] of Object.entries(dateien)) writeFileSync(join(wurzel, pfad), inhalt);
return wurzel;
}
it("überspringt fehlerhafte Dateien, lädt den Rest und nennt Dateiname und Pfad", async () => {
const wurzel = katalog({
"profiles/gut.yaml": "id: gut\nname: Gut\nfamilies:\n debian:\n - update: true\n",
"profiles/kaputt.yaml": "id: kaputt\nname: K\nfamilies:\n debian:\n - pakete: [a]\n",
"profiles/anders.yaml": "id: falsch\nname: F\nfamilies:\n debian:\n - update: true\n",
"profiles/haengt.yaml":
"id: haengt\nname: H\nrequires: [gibtsnicht]\nfamilies:\n debian:\n - update: true\n",
});
const ergebnis = await katalogLaden(wurzel);
expect(ergebnis.profile.map((p) => p.eintrag.id)).toEqual(["gut"]);
expect(ergebnis.fehler.map((f) => f.datei).sort()).toEqual(["anders.yaml", "haengt.yaml", "kaputt.yaml"]);
expect(ergebnis.fehler.find((f) => f.datei === "anders.yaml")!.meldung).toMatch(/Dateinamen/);
});
});
describe("Prüfsummen", () => {
const sha512 = "a".repeat(128);
const sha256 = "b".repeat(64);
it("liest das Debian-Format (zwei Leerzeichen)", () => {
expect(
pruefsummeFinden(
`${sha512} debian-13-genericcloud-amd64.qcow2\n${"c".repeat(128)} andere.raw`,
"debian-13-genericcloud-amd64.qcow2",
"sha512",
),
).toBe(sha512);
});
it("liest das Ubuntu-Format (Binärmodus mit *)", () => {
expect(
pruefsummeFinden(
`${sha256} *noble-server-cloudimg-amd64.img`,
"noble-server-cloudimg-amd64.img",
"sha256",
),
).toBe(sha256);
});
it("liest das BSD-Format und ignoriert falsche Längen", () => {
expect(pruefsummeFinden(`SHA256 (x.img) = ${sha256.toUpperCase()}`, "x.img", "sha256")).toBe(sha256);
expect(pruefsummeFinden(`${sha256} x.img`, "x.img", "sha512")).toBeNull();
expect(pruefsummeFinden(`${sha256} y.img`, "x.img", "sha256")).toBeNull();
});
it("nimmt den Dateinamen aus der URL", () => {
expect(
dateiAusUrl("https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"),
).toBe("debian-13-genericcloud-amd64.qcow2");
});
});
describe("LXC-Template wählen", () => {
const verfuegbar = [
"debian-13-standard_13.1-2_amd64.tar.zst",
"debian-13-standard_13.6-1_amd64.tar.zst",
"debian-13-standard_13.6-1_arm64.tar.zst",
"debian-12-standard_12.12-1_amd64.tar.zst",
"debian-12-standard_12.2-1_amd64.tar.zst",
];
it("nimmt bei mehreren Treffern die höchste Version (wie sort -V)", () => {
expect(templateWaehlen("debian-13-standard_*_amd64.tar.zst", verfuegbar)).toBe(
"debian-13-standard_13.6-1_amd64.tar.zst",
);
expect(templateWaehlen("debian-12-standard_*_amd64.tar.zst", verfuegbar)).toBe(
"debian-12-standard_12.12-1_amd64.tar.zst",
);
});
it("findet nichts Falsches", () => {
expect(templateWaehlen("ubuntu-24.04-standard_*_amd64.tar.zst", verfuegbar)).toBeNull();
expect(versionVergleichen("12.2-1", "12.12-1")).toBeLessThan(0);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { GET as distributionenGet } from "@/app/api/v1/distributionen/route";
import { PATCH as distributionPatch } from "@/app/api/v1/distributionen/[id]/route";
import { GET as profileGet } from "@/app/api/v1/profile/route";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
describe.runIf(datenbankVerfuegbar)("Katalog-Abgleich und API", () => {
let wurzel: string;
beforeEach(async () => {
await datenbankLeeren();
wurzel = mkdtempSync(join(tmpdir(), "pd-sync-"));
cpSync("catalog", wurzel, { recursive: true });
});
it("legt an, erkennt Änderungen über den Hash und ist idempotent", async () => {
const erst = await katalogSynchronisieren(wurzel);
expect(erst.distributionen).toEqual({ neu: 5, geaendert: 0, entfernt: 0 });
expect(erst.profile).toEqual({ neu: 3, geaendert: 0, entfernt: 0 });
const nochmal = await katalogSynchronisieren(wurzel);
expect(nochmal.distributionen).toEqual({ neu: 0, geaendert: 0, entfernt: 0 });
writeFileSync(
join(wurzel, "distros/debian-12.yaml"),
(await import("node:fs"))
.readFileSync(join(wurzel, "distros/debian-12.yaml"), "utf8")
.replace("Bookworm", "Bookworm (alt)"),
);
const geaendert = await katalogSynchronisieren(wurzel);
expect(geaendert.distributionen.geaendert).toBe(1);
expect((await testPrisma.distribution.findUniqueOrThrow({ where: { id: "debian-12" } })).name).toContain(
"(alt)",
);
});
it("behält „aktiv“ über Änderungen hinweg und markiert entfernte Dateien statt zu löschen", async () => {
await katalogSynchronisieren(wurzel);
await testPrisma.distribution.update({ where: { id: "ubuntu-22.04" }, data: { aktiv: false } });
rmSync(join(wurzel, "distros/ubuntu-22.04.yaml"));
const ergebnis = await katalogSynchronisieren(wurzel);
expect(ergebnis.distributionen.entfernt).toBe(1);
const zeile = await testPrisma.distribution.findUniqueOrThrow({ where: { id: "ubuntu-22.04" } });
expect(zeile.entferntAm).not.toBeNull();
expect(zeile.aktiv).toBe(false);
});
it("überschreibt kein eigenes Profil mit gleicher id", async () => {
await testPrisma.profil.create({
data: {
id: "docker",
name: "Mein Docker",
familien: ["debian"],
herkunft: "EIGEN",
definition: {},
quelltext: "x",
},
});
const ergebnis = await katalogSynchronisieren(wurzel);
expect(ergebnis.fehler.map((f) => f.meldung).join()).toMatch(/eigenes Profil „docker“/);
expect((await testPrisma.profil.findUniqueOrThrow({ where: { id: "docker" } })).name).toBe("Mein Docker");
});
it("API: Operator sieht nur aktive; Admin schaltet ab und sieht mit ?alle=1 alles", async () => {
await katalogSynchronisieren(wurzel);
const admin = (await angemeldet("ADMIN")).cookie;
const operator = (await angemeldet("OPERATOR")).cookie;
expect(
(
await distributionPatch(
anfrage("PATCH", "/api/v1/distributionen/debian-12", operator, { aktiv: false }),
kontext({ id: "debian-12" }),
)
).status,
).toBe(403);
expect(
(
await distributionPatch(
anfrage("PATCH", "/api/v1/distributionen/debian-12", admin, { aktiv: false }),
kontext({ id: "debian-12" }),
)
).status,
).toBe(200);
const fuerOperator = await (
await distributionenGet(anfrage("GET", "/api/v1/distributionen?typ=vm&alle=1", operator))
).json();
expect(fuerOperator.map((d: { id: string }) => d.id)).not.toContain("debian-12");
const fuerAdmin = await (
await distributionenGet(anfrage("GET", "/api/v1/distributionen?alle=1", admin))
).json();
expect(fuerAdmin.find((d: { id: string }) => d.id === "debian-12").aktiv).toBe(false);
const profile = await (await profileGet(anfrage("GET", "/api/v1/profile", operator))).json();
expect(profile.map((p: { id: string }) => p.id)).toEqual(["base", "docker", "zabbix-agent"]);
expect(await testPrisma.protokoll.count({ where: { aktion: "Distribution deaktiviert" } })).toBe(1);
});
});