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
+102
View File
@@ -0,0 +1,102 @@
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { parse, YAMLParseError } from "yaml";
import type { ZodType } from "@/lib/validierung";
import { DistributionSchema, ProfilSchema, type Distribution, type Profil } from "./schema";
/**
* Liest `catalog/distros` und `catalog/profiles`. Eine fehlerhafte Datei wird mit Dateiname und
* Pfad gemeldet und übersprungen – ein Tippfehler in einem Profil soll nicht den Start verhindern.
*/
export type KatalogFehler = { datei: string; meldung: string };
export type Geladen<T> = { eintrag: T; datei: string; quelltext: string; hash: string };
export type Katalog = {
distributionen: Geladen<Distribution>[];
profile: Geladen<Profil>[];
fehler: KatalogFehler[];
};
/** Prüft YAML-Text gegen ein Schema; liefert Eintrag oder lesbare Fehlerliste. */
export function yamlPruefen<T>(
quelltext: string,
schema: ZodType<T>,
): { ok: true; eintrag: T } | { ok: false; fehler: string[] } {
let roh: unknown;
try {
roh = parse(quelltext, { prettyErrors: true });
} catch (fehler) {
const text = fehler instanceof YAMLParseError ? fehler.message.split("\n")[0]! : String(fehler);
return { ok: false, fehler: [`YAML: ${text}`] };
}
const ergebnis = schema.safeParse(roh);
if (ergebnis.success) return { ok: true, eintrag: ergebnis.data };
return {
ok: false,
fehler: ergebnis.error.issues.map(
(p) => `${p.path.length > 0 ? p.path.join(".") : "(Wurzel)"}: ${p.message}`,
),
};
}
async function verzeichnisLesen<T extends { id: string }>(
verzeichnis: string,
schema: ZodType<T>,
fehler: KatalogFehler[],
): Promise<Geladen<T>[]> {
let dateien: string[];
try {
dateien = (await readdir(verzeichnis)).filter((d) => /\.ya?ml$/.test(d)).sort();
} catch {
fehler.push({ datei: verzeichnis, meldung: "Verzeichnis fehlt." });
return [];
}
const ergebnis: Geladen<T>[] = [];
const gesehen = new Set<string>();
for (const datei of dateien) {
const quelltext = await readFile(join(verzeichnis, datei), "utf8");
const geprueft = yamlPruefen(quelltext, schema);
if (!geprueft.ok) {
fehler.push({ datei, meldung: geprueft.fehler.join("; ") });
continue;
}
// Dateiname und id sollen übereinstimmen – sonst sucht man ewig, woher ein Eintrag kommt.
if (datei.replace(/\.ya?ml$/, "") !== geprueft.eintrag.id) {
fehler.push({ datei, meldung: `id „${geprueft.eintrag.id}“ passt nicht zum Dateinamen.` });
continue;
}
if (gesehen.has(geprueft.eintrag.id)) {
fehler.push({ datei, meldung: `id „${geprueft.eintrag.id}“ gibt es doppelt.` });
continue;
}
gesehen.add(geprueft.eintrag.id);
ergebnis.push({
eintrag: geprueft.eintrag,
datei,
quelltext,
hash: createHash("sha256").update(quelltext).digest("hex"),
});
}
return ergebnis;
}
export async function katalogLaden(wurzel: string): Promise<Katalog> {
const fehler: KatalogFehler[] = [];
const distributionen = await verzeichnisLesen(join(wurzel, "distros"), DistributionSchema, fehler);
const profile = await verzeichnisLesen(join(wurzel, "profiles"), ProfilSchema, fehler);
// Verweise prüfen: requires auf unbekannte Profile ist ein Fehler des verweisenden Profils.
const bekannt = new Set(profile.map((p) => p.eintrag.id));
const gueltig = profile.filter((p) => {
const fehlt = p.eintrag.requires.filter((r) => !bekannt.has(r));
if (fehlt.length > 0)
fehler.push({
datei: p.datei,
meldung: `requires verweist auf unbekannte Profile: ${fehlt.join(", ")}`,
});
return fehlt.length === 0;
});
return { distributionen, profile: gueltig, fehler };
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Wählt aus der Liste verfügbarer Container-Templates (pveam available bzw. GET aplinfo) das
* passende zum Muster des Katalogs. Bei mehreren Treffern gewinnt die höchste Version – für
* Debian 13 gab es am 2026-10-12 sowohl 13.1-2 als auch 13.6-1.
*/
export function musterZuRegex(muster: string): RegExp {
const maskiert = muster.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
return new RegExp(`^${maskiert}$`);
}
/** Vergleicht wie `sort -V`: Zahlenfolgen numerisch, Rest lexikalisch. */
export function versionVergleichen(a: string, b: string): number {
const teile = (s: string) => s.match(/\d+|\D+/g) ?? [];
const x = teile(a);
const y = teile(b);
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
const p = x[i];
const q = y[i];
if (p === undefined) return -1;
if (q === undefined) return 1;
const zahlen = /^\d+$/.test(p) && /^\d+$/.test(q);
const unterschied = zahlen ? Number(p) - Number(q) : p.localeCompare(q);
if (unterschied !== 0) return unterschied;
}
return 0;
}
export function templateWaehlen(muster: string, verfuegbar: readonly string[]): string | null {
const regex = musterZuRegex(muster);
const treffer = verfuegbar.filter((name) => regex.test(name));
if (treffer.length === 0) return null;
return [...treffer].sort(versionVergleichen).at(-1)!;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Prüfsummen-Dateien der Cloud-Images. Zwei Zeilenformate kommen vor (geprüft 2026-10-12):
* Debian `<hash>␣␣<datei>`, Ubuntu `<hash>␣*<datei>` (Binärmodus). Dazu das BSD-Format
* `SHA256 (<datei>) = <hash>`, das manche Spiegel liefern.
*/
export function pruefsummeFinden(inhalt: string, datei: string, typ: "sha256" | "sha512"): string | null {
const laenge = typ === "sha256" ? 64 : 128;
for (const zeile of inhalt.split(/\r?\n/)) {
const gnu = /^([0-9a-fA-F]+)\s+\*?(.+)$/.exec(zeile.trim());
if (gnu && gnu[2] === datei && gnu[1]!.length === laenge) return gnu[1]!.toLowerCase();
const bsd = /^(SHA256|SHA512) \((.+)\) = ([0-9a-fA-F]+)$/.exec(zeile.trim());
if (bsd && bsd[2] === datei && bsd[3]!.length === laenge) return bsd[3]!.toLowerCase();
}
return null;
}
/** Der Dateiname am Ende einer Image-URL. */
export function dateiAusUrl(url: string): string {
return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
}
+174
View File
@@ -0,0 +1,174 @@
import { z } from "@/lib/validierung";
/**
* Der Katalog ist Daten, kein Code: jede Distribution und jedes Profil ist eine YAML-Datei unter
* `catalog/`. Die Schlüssel bleiben englisch wie in der Vorgabe – es ist ein Austauschformat.
* Unbekannte Schlüssel sind ein Fehler (`strict`), damit ein Tippfehler nicht still ignoriert wird.
*/
const KENNUNG = /^[a-z0-9][a-z0-9.-]{0,47}$/;
const Kennung = z.string().regex(KENNUNG, "Nur Kleinbuchstaben, Ziffern, Punkt und Bindestrich.");
// ── Distributionen ──────────────────────────────────────────────────────────────────────────────
export const DistributionSchema = z
.object({
id: Kennung,
name: z.string().min(1),
family: z.string().regex(/^[a-z][a-z0-9]*$/, "Familie: Kleinbuchstaben, z. B. debian, rhel, alpine."),
os: z
.object({
id: z.string().min(1),
version_id: z.string().min(1),
codename: z.string().optional(),
})
.strict(),
arch: z.enum(["amd64", "arm64"]).default("amd64"),
vm: z
.object({
image_url: z.url({ protocol: /^https$/, message: "Nur HTTPS-Quellen." }),
checksum_url: z.url({ protocol: /^https$/, message: "Nur HTTPS-Quellen." }),
checksum_type: z.enum(["sha256", "sha512"]),
format: z.enum(["qcow2", "raw", "vmdk"]),
default_user: z.string().min(1),
guest_agent_preinstalled: z.boolean().default(false),
bios: z.enum(["ovmf", "seabios"]).default("ovmf"),
min_disk_gb: z.number().int().min(1).default(4),
})
.strict()
.optional(),
lxc: z
.object({
template_pattern: z
.string()
.regex(/^[\w.*-]+\.tar\.(zst|gz|xz)$/, "Muster wie debian-13-standard_*_amd64.tar.zst"),
ostype: z.string().min(1),
})
.strict()
.optional(),
})
.strict()
.refine((d) => d.vm || d.lxc, { message: "Mindestens vm oder lxc angeben." });
export type Distribution = z.infer<typeof DistributionSchema>;
// ── Profile ─────────────────────────────────────────────────────────────────────────────────────
export const VariableSchema = z
.object({
name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."),
label: z.string().min(1),
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
required: z.boolean().default(false),
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
options: z.array(z.string()).optional(),
help: z.string().optional(),
})
.strict()
.refine((v) => v.type !== "select" || (v.options && v.options.length > 0), {
message: "Eine Auswahl braucht options.",
});
export type ProfilVariable = z.infer<typeof VariableSchema>;
/** Optional an jedem Schritt: nur ausführen, wenn die Variable wahr ist (`if: fail2ban`). */
const Bedingung = {
if: z
.string()
.regex(/^!?[a-z][a-z0-9_]*$/)
.optional(),
};
/**
* Ein Schritt ist deklarativ; der Adapter der Familie übersetzt ihn in Shell. Genau ein
* Aktionsschlüssel je Schritt.
*/
export const SchrittSchema = z.union([
z.object({ update: z.literal(true), ...Bedingung }).strict(),
z.object({ packages: z.array(z.string().min(1)).min(1), ...Bedingung }).strict(),
z
.object({
service: z
.object({
name: z.string().min(1),
enable: z.boolean().default(true),
start: z.boolean().default(true),
restart: z.boolean().default(false),
})
.strict(),
...Bedingung,
})
.strict(),
z
.object({
repo: z
.object({
name: z.string().regex(/^[a-z0-9-]+$/),
key_url: z.url({ protocol: /^https$/ }),
uris: z.string().min(1),
suites: z.string().min(1),
components: z.string().min(1),
})
.strict(),
...Bedingung,
})
.strict(),
z.object({ repo_package: z.string().min(1), ...Bedingung }).strict(),
z
.object({
file: z
.object({
path: z.string().regex(/^\/[^\0]+$/, "Absoluter Pfad."),
content: z.string(),
mode: z
.string()
.regex(/^[0-7]{3,4}$/)
.default("0644"),
})
.strict(),
...Bedingung,
})
.strict(),
z.object({ timezone: z.string().min(1), ...Bedingung }).strict(),
z.object({ locale: z.string().min(1), ...Bedingung }).strict(),
z.object({ keyboard: z.string().min(1), ...Bedingung }).strict(),
z
.object({
ssh_harden: z
.object({ password_auth: z.boolean().default(false), root_login: z.boolean().default(false) })
.strict(),
...Bedingung,
})
.strict(),
z.object({ guest_agent: z.literal(true), ...Bedingung }).strict(),
z.object({ unattended_upgrades: z.boolean(), ...Bedingung }).strict(),
z.object({ run: z.string().min(1), ...Bedingung }).strict(),
]);
export type ProfilSchritt = z.infer<typeof SchrittSchema>;
export const ProfilSchema = z
.object({
id: Kennung,
name: z.string().min(1),
description: z.string().optional(),
requires: z.array(Kennung).default([]),
variables: z.array(VariableSchema).default([]),
lxc: z
.object({
features: z
.object({ nesting: z.boolean().optional(), keyctl: z.boolean().optional() })
.strict()
.optional(),
})
.strict()
.default({}),
families: z.record(z.string().regex(/^[a-z][a-z0-9]*$/), z.array(SchrittSchema).min(1)),
})
.strict()
.refine((p) => new Set(p.variables.map((v) => v.name)).size === p.variables.length, {
message: "Variablennamen müssen eindeutig sein.",
path: ["variables"],
});
export type Profil = z.infer<typeof ProfilSchema>;