Beim Template-Bau lassen sich Profile wählen (nur solche ohne Pflichtvariablen). Nach dem Vorbereitungsstart laufen sie über den Guest-Agent; danach werden Maschinen-ID, SSH-Host-Keys, Docker-Engine-ID und apt-Cache entfernt. Ausrollen nimmt das Template mit den meisten passenden Profilen und überspringt, was schon eingebacken ist. Auf pve-demo: Provisionierung mit base+docker 141 s ohne, ~30 s mit Template. Dabei gefunden: docker nimmt den Benutzer per guest.user in die Gruppe auf – Profile mit guest.* laufen deshalb beim Deploy noch einmal (idempotent, schnell). Closes #022 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
import type { Profil, ProfilVariable } from "@/engine/katalog/schema";
|
||
|
||
/**
|
||
* Aus der Auswahl im Assistenten wird eine geordnete Liste: Voraussetzungen zuerst (`requires`,
|
||
* transitiv), sonst die Reihenfolge der Auswahl. Dazu die Variablen mit Standardwerten und
|
||
* Typprüfung. Fehler werden gesammelt, damit das Formular alle auf einmal zeigen kann.
|
||
*/
|
||
export type AufgeloestesProfil = { profil: Profil; werte: Record<string, string | number | boolean> };
|
||
|
||
export type ProfilFehler = { profil: string; variable?: string; meldung: string };
|
||
|
||
export type Aufloesung =
|
||
{ ok: true; profile: AufgeloestesProfil[]; automatisch: string[] } | { ok: false; fehler: ProfilFehler[] };
|
||
|
||
export function profileAufloesen(
|
||
auswahl: string[],
|
||
verfuegbar: ReadonlyMap<string, Profil>,
|
||
familie: string,
|
||
eingaben: Record<string, Record<string, unknown>> = {},
|
||
): Aufloesung {
|
||
const fehler: ProfilFehler[] = [];
|
||
const reihenfolge: string[] = [];
|
||
const besucht = new Set<string>();
|
||
const inArbeit = new Set<string>();
|
||
|
||
const besuchen = (id: string, von: string | null) => {
|
||
if (besucht.has(id)) return;
|
||
if (inArbeit.has(id)) {
|
||
fehler.push({ profil: id, meldung: `Zyklische Voraussetzung über „${von}“.` });
|
||
return;
|
||
}
|
||
const profil = verfuegbar.get(id);
|
||
if (!profil) {
|
||
fehler.push({
|
||
profil: id,
|
||
meldung: von
|
||
? `Wird von „${von}“ vorausgesetzt, ist aber nicht verfügbar.`
|
||
: "Unbekanntes oder deaktiviertes Profil.",
|
||
});
|
||
return;
|
||
}
|
||
inArbeit.add(id);
|
||
for (const voraussetzung of profil.requires) besuchen(voraussetzung, id);
|
||
inArbeit.delete(id);
|
||
besucht.add(id);
|
||
reihenfolge.push(id);
|
||
};
|
||
for (const id of auswahl) besuchen(id, null);
|
||
|
||
const profile: AufgeloestesProfil[] = [];
|
||
for (const id of reihenfolge) {
|
||
const profil = verfuegbar.get(id)!;
|
||
if (!profil.families[familie]) {
|
||
fehler.push({ profil: id, meldung: `Keine Umsetzung für die Familie „${familie}“.` });
|
||
continue;
|
||
}
|
||
const werte: Record<string, string | number | boolean> = {};
|
||
for (const variable of profil.variables) {
|
||
const ergebnis = wertPruefen(variable, eingaben[id]?.[variable.name]);
|
||
if ("fehler" in ergebnis)
|
||
fehler.push({ profil: id, variable: variable.name, meldung: ergebnis.fehler });
|
||
else werte[variable.name] = ergebnis.wert;
|
||
}
|
||
profile.push({ profil, werte });
|
||
}
|
||
|
||
if (fehler.length > 0) return { ok: false, fehler };
|
||
return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) };
|
||
}
|
||
|
||
/** Werte eines Profils aus der Eingabe, mit Standardwerten und Typprüfung. */
|
||
export function variablenWerte(
|
||
profil: Profil,
|
||
eingabe: Record<string, unknown>,
|
||
): { werte: Record<string, string | number | boolean>; fehler: ProfilFehler[] } {
|
||
const werte: Record<string, string | number | boolean> = {};
|
||
const fehler: ProfilFehler[] = [];
|
||
for (const variable of profil.variables) {
|
||
const ergebnis = wertPruefen(variable, eingabe[variable.name]);
|
||
if ("fehler" in ergebnis)
|
||
fehler.push({ profil: profil.id, variable: variable.name, meldung: ergebnis.fehler });
|
||
else werte[variable.name] = ergebnis.wert;
|
||
}
|
||
return { werte, fehler };
|
||
}
|
||
|
||
/**
|
||
* Ein ins Template eingebackenes Profil muss beim Deploy nur dann nicht noch einmal laufen, wenn
|
||
* seine Variablen genau die Standardwerte haben – mit denen wurde es eingebacken.
|
||
*/
|
||
export function mitStandardwerten(eintrag: AufgeloestesProfil): boolean {
|
||
const standard = variablenWerte(eintrag.profil, {}).werte;
|
||
return JSON.stringify(standard) === JSON.stringify(eintrag.werte);
|
||
}
|
||
|
||
/**
|
||
* Verweist ein Profil auf `{{ guest.* }}` (Name, Benutzer), hängt es vom einzelnen Gast ab – im
|
||
* Template stand dort etwas anderes. Solche Profile laufen beim Deploy trotzdem noch einmal; dank
|
||
* Idempotenz ist das schnell (Fund: docker nahm sonst den Benutzer nicht in die Gruppe auf).
|
||
*/
|
||
export function gastunabhaengig(profil: Profil): boolean {
|
||
return !/\{\{[^}]*\bguest\./.test(JSON.stringify(profil.families));
|
||
}
|
||
|
||
function wertPruefen(
|
||
variable: ProfilVariable,
|
||
eingabe: unknown,
|
||
): { wert: string | number | boolean } | { fehler: string } {
|
||
const roh = eingabe === undefined || eingabe === null || eingabe === "" ? variable.default : eingabe;
|
||
if (roh === undefined || roh === "") {
|
||
if (variable.required) return { fehler: `${variable.label} fehlt.` };
|
||
return { wert: variable.type === "boolean" ? false : "" };
|
||
}
|
||
switch (variable.type) {
|
||
case "boolean":
|
||
if (typeof roh === "boolean") return { wert: roh };
|
||
if (roh === "true" || roh === "1") return { wert: true };
|
||
if (roh === "false" || roh === "0") return { wert: false };
|
||
return { fehler: `${variable.label}: ja oder nein erwartet.` };
|
||
case "number": {
|
||
const zahl = typeof roh === "number" ? roh : Number(roh);
|
||
return Number.isFinite(zahl) ? { wert: zahl } : { fehler: `${variable.label}: Zahl erwartet.` };
|
||
}
|
||
case "select":
|
||
return variable.options!.includes(String(roh))
|
||
? { wert: String(roh) }
|
||
: { fehler: `${variable.label}: erlaubt sind ${variable.options!.join(", ")}.` };
|
||
default: {
|
||
const text = String(roh);
|
||
// Ein Zeilenumbruch in einer Variable ist fast immer ein Kopierfehler – und in Konfigurationsdateien gefährlich.
|
||
if (/[\r\n\0]/.test(text)) return { fehler: `${variable.label}: keine Zeilenumbrüche.` };
|
||
return { wert: text };
|
||
}
|
||
}
|
||
}
|