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