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,59 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
/** Aktiv/inaktiv als echter Schalter (role=switch) mit sichtbarem Zustandstext. */
|
||||
export function AktivSchalter({
|
||||
art,
|
||||
id,
|
||||
aktiv,
|
||||
name,
|
||||
}: {
|
||||
art: "distributionen" | "profile";
|
||||
id: string;
|
||||
aktiv: boolean;
|
||||
name: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [wert, setWert] = useState(aktiv);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
async function umschalten() {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
await apiAufruf("PATCH", `/api/v1/${art}/${id}`, { aktiv: !wert });
|
||||
setWert(!wert);
|
||||
router.refresh();
|
||||
} catch (ursache) {
|
||||
setFehler(ursache instanceof ApiFehler ? ursache.message : "Fehler");
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={wert}
|
||||
aria-label={`${name} ${wert ? "deaktivieren" : "aktivieren"}`}
|
||||
disabled={laeuft}
|
||||
onClick={umschalten}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
wert ? "bg-akzent border-akzent" : "bg-panel-hoch border-linie"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-4 w-4 rounded-full transition ${wert ? "bg-akzent-text translate-x-6" : "bg-text translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-muted w-14 text-sm">{fehler ?? (wert ? "aktiv" : "inaktiv")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { AktivSchalter } from "./aktiv-schalter";
|
||||
|
||||
export const metadata: Metadata = { title: "Katalog" };
|
||||
|
||||
export default async function KatalogSeite() {
|
||||
const benutzer = await seiteSchuetzen("lesen", "/katalog");
|
||||
const verwalten = darf(benutzer.rolle, "katalogVerwalten");
|
||||
const [distributionen, profile] = await Promise.all([
|
||||
distributionenListe({ nurAktive: !verwalten }),
|
||||
profileListe({ nurAktive: !verwalten }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Katalog</h1>
|
||||
<p className="text-muted text-sm">
|
||||
Distributionen und Profile kommen aus <span className="font-mono">catalog/*.yaml</span> und werden
|
||||
beim Start abgeglichen.{" "}
|
||||
{verwalten ? "Deaktivierte Einträge erscheinen nicht im Assistenten." : null}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Karte titel="Distributionen">
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{distributionen.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{d.name}</p>
|
||||
<p className="text-muted font-mono text-xs">
|
||||
{d.id} · Familie {d.familie}
|
||||
{d.definition.vm
|
||||
? ` · Image ${d.definition.vm.format}, Benutzer ${d.definition.vm.default_user}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Status art={d.vm ? "erledigt" : "offen"}>VM</Status>
|
||||
<Status art={d.lxc ? "erledigt" : "offen"}>LXC</Status>
|
||||
{verwalten ? (
|
||||
<AktivSchalter art="distributionen" id={d.id} aktiv={d.aktiv} name={d.name} />
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Profile">
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{profile.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{p.name}{" "}
|
||||
<span className="text-muted font-mono text-xs">
|
||||
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
|
||||
</span>
|
||||
</p>
|
||||
{p.beschreibung ? <p className="text-muted text-sm">{p.beschreibung}</p> : null}
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
Familien: <span className="font-mono">{p.familien.join(", ")}</span>
|
||||
{p.definition.requires.length > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· setzt voraus: <span className="font-mono">{p.definition.requires.join(", ")}</span>
|
||||
</>
|
||||
) : null}
|
||||
{p.definition.variables.length > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· Variablen:{" "}
|
||||
<span className="font-mono">
|
||||
{p.definition.variables.map((v) => v.name).join(", ")}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
{verwalten ? <AktivSchalter art="profile" id={p.id} aktiv={p.aktiv} name={p.name} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { distributionAktivSetzen } from "@/server/services/katalog";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Nur `aktiv` ist änderbar – der Inhalt gehört der YAML-Datei im Katalog. */
|
||||
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { aktiv } = z.object({ aktiv: z.boolean() }).parse(await jsonLesen(anfrage));
|
||||
await distributionAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { distributionenListe } from "@/server/services/katalog";
|
||||
|
||||
/**
|
||||
* `?typ=vm|lxc` filtert auf Distributionen, die den Typ können. Deaktivierte sehen nur Admins
|
||||
* (mit `?alle=1`), alle anderen bekommen nur, was ausgerollt werden darf.
|
||||
*/
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
const abfrage = new URL(anfrage.url).searchParams;
|
||||
const typ = abfrage.get("typ");
|
||||
const alle = abfrage.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return NextResponse.json(
|
||||
await distributionenListe({ typ: typ === "vm" || typ === "lxc" ? typ : undefined, nurAktive: !alle }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profilAktivSetzen } from "@/server/services/katalog";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { aktiv } = z.object({ aktiv: z.boolean() }).parse(await jsonLesen(anfrage));
|
||||
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profileListe } from "@/server/services/katalog";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
const alle =
|
||||
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return NextResponse.json(await profileListe({ nurAktive: !alle }));
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten
|
||||
* Administrator an – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
|
||||
* Administrator an, gleicht den Katalog ab – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
|
||||
*/
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
|
||||
const { schluesselPruefen } = await import("@/lib/geheimnisse");
|
||||
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
|
||||
const { logger } = await import("@/lib/logger");
|
||||
const { katalogSynchronisieren } = await import("@/server/services/katalog");
|
||||
try {
|
||||
schluesselPruefen();
|
||||
await ersterAdminFallsLeer();
|
||||
const sync = await katalogSynchronisieren();
|
||||
logger.info(
|
||||
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
|
||||
"Katalog abgeglichen",
|
||||
);
|
||||
} catch (fehler) {
|
||||
logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen");
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
|
||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||
{
|
||||
pfad: "/einstellungen",
|
||||
|
||||
@@ -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