feat(engine): Adapter debian und Profil-Engine
Profile sind deklarative Schritte je Distributionsfamilie; der Adapter übersetzt sie in ein idempotentes Shell-Skript. Ein neuer Adapter (rhel, alpine) ist eine Datei und ein Eintrag in der Registry. Variablen werden typisiert geprüft, in run-Schritten jeder Wert für die Shell gequotet. SSH-Härtung läuft zuletzt, damit sich eine Provisionierung per SSH nicht selbst aussperrt. Geprüft mit shellcheck und echt in Containern (Debian 12/13, Ubuntu 22.04/24.04/26.04) inklusive zweitem Lauf. Dabei aufgefallen: frische Images haben keine Paketlisten – der Vorspann macht jetzt apt-get update. Closes #005 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { base64, q } from "@/engine/shell";
|
||||
import type { Benutzerangaben, FamilienAdapter, PaketQuelle, SshHaertung } from "./schnittstelle";
|
||||
|
||||
/**
|
||||
* Debian und Ubuntu: apt, deb822-Quellen, systemd. Geprüft gegen Debian 12/13 und Ubuntu
|
||||
* 22.04/24.04/26.04 (Cloud-Images und Proxmox-LXC-Templates).
|
||||
*/
|
||||
const APT = "apt_get";
|
||||
|
||||
/** Ungültige Eingaben fallen beim Rendern auf, nicht erst mitten im Gast. */
|
||||
export class AdapterFehler extends Error {
|
||||
constructor(meldung: string) {
|
||||
super(meldung);
|
||||
this.name = "AdapterFehler";
|
||||
}
|
||||
}
|
||||
|
||||
function pruefen(wert: string, muster: RegExp, was: string): string {
|
||||
if (!muster.test(wert)) throw new AdapterFehler(`${was} „${wert}“ ist ungültig.`);
|
||||
return wert;
|
||||
}
|
||||
|
||||
const ZEITZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+){0,2}$/;
|
||||
const LOCALE = /^[a-z]{2,3}_[A-Z]{2}(\.[A-Za-z0-9-]+)?(@[a-z]+)?$/;
|
||||
const LAYOUT = /^[a-z]{2,8}(,[a-z]{2,8})*$/;
|
||||
|
||||
export const debianAdapter: FamilienAdapter = {
|
||||
familie: "debian",
|
||||
|
||||
kopf: () => `set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive LC_ALL=C.UTF-8
|
||||
# apt wartet selbst bis zu 10 min auf die Paketsperre – cloud-init und unattended-upgrades laufen
|
||||
# beim ersten Start oft noch.
|
||||
${APT}() { apt-get -y -q -o DPkg::Lock::Timeout=600 -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@"; }
|
||||
if command -v cloud-init >/dev/null 2>&1; then cloud-init status --wait >/dev/null 2>&1 || true; fi
|
||||
# Frische Images und Container haben oft keine Paketlisten – ohne dieses update findet apt nichts.
|
||||
${APT} update
|
||||
`,
|
||||
|
||||
aktualisieren: () => `${APT} update
|
||||
${APT} dist-upgrade
|
||||
`,
|
||||
|
||||
paketeInstallieren: (pakete) => `${APT} install ${pakete.map(q).join(" ")}
|
||||
`,
|
||||
|
||||
dienstAktivieren: (name, { starten, neuStarten }) => {
|
||||
// In Containern ohne systemd (selten, aber möglich) nicht scheitern.
|
||||
const zeilen = [`systemctl enable ${q(name)}`];
|
||||
if (neuStarten) zeilen.push(`systemctl restart ${q(name)}`);
|
||||
else if (starten) zeilen.push(`systemctl start ${q(name)}`);
|
||||
return zeilen.join("\n") + "\n";
|
||||
},
|
||||
|
||||
repoHinzufuegen: (quelle: PaketQuelle) => {
|
||||
const schluessel = `/etc/apt/keyrings/${quelle.name}.asc`;
|
||||
const quellen = `/etc/apt/sources.list.d/${quelle.name}.sources`;
|
||||
return `${APT} install ca-certificates curl
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL ${q(quelle.schluesselUrl)} -o ${q(schluessel)}
|
||||
chmod a+r ${q(schluessel)}
|
||||
cat > ${q(quellen)} <<PROXDEPLOY_QUELLE
|
||||
Types: deb
|
||||
URIs: ${quelle.uris}
|
||||
Suites: ${quelle.suites}
|
||||
Components: ${quelle.komponenten}
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: ${schluessel}
|
||||
PROXDEPLOY_QUELLE
|
||||
${APT} update
|
||||
`;
|
||||
},
|
||||
|
||||
repoPaketInstallieren: (url) => `${APT} install ca-certificates curl
|
||||
pd_deb="$(mktemp --suffix=.deb)"
|
||||
curl -fsSL ${q(url)} -o "$pd_deb"
|
||||
${APT} install "$pd_deb"
|
||||
rm -f "$pd_deb"
|
||||
${APT} update
|
||||
`,
|
||||
|
||||
dateiSchreiben: (pfad, inhalt, modus) => `install -d -m 0755 "$(dirname ${q(pfad)})"
|
||||
printf '%s' ${q(base64(inhalt))} | base64 -d > ${q(pfad)}
|
||||
chmod ${q(modus)} ${q(pfad)}
|
||||
`,
|
||||
|
||||
zeitzoneSetzen: (eingabe) => {
|
||||
const zeitzone = pruefen(eingabe, ZEITZONE, "Zeitzone");
|
||||
return `${APT} install tzdata
|
||||
test -f /usr/share/zoneinfo/${q(zeitzone)} || { echo "Unbekannte Zeitzone: "${q(zeitzone)} >&2; exit 1; }
|
||||
ln -sf /usr/share/zoneinfo/${q(zeitzone)} /etc/localtime
|
||||
echo ${q(zeitzone)} > /etc/timezone
|
||||
`;
|
||||
},
|
||||
|
||||
localeSetzen: (eingabe) => {
|
||||
const locale = pruefen(eingabe, LOCALE, "Locale");
|
||||
const zeichensatz = locale.includes(".") ? locale.split(".")[1]! : "UTF-8";
|
||||
const zeile = `${locale} ${zeichensatz}`;
|
||||
// Debian erzeugt aus /etc/locale.gen; Ubuntus locale-gen nimmt die Locale zusätzlich als Argument.
|
||||
return `${APT} install locales
|
||||
touch /etc/locale.gen
|
||||
grep -qxF ${q(zeile)} /etc/locale.gen || echo ${q(zeile)} >> /etc/locale.gen
|
||||
if grep -qi ubuntu /etc/os-release; then locale-gen ${q(locale)}; else locale-gen; fi
|
||||
update-locale LANG=${q(locale)}
|
||||
`;
|
||||
},
|
||||
|
||||
tastaturSetzen: (eingabe) => {
|
||||
const layout = pruefen(eingabe, LAYOUT, "Tastaturbelegung");
|
||||
return `echo "keyboard-configuration keyboard-configuration/layoutcode string ${layout}" | debconf-set-selections
|
||||
${APT} install keyboard-configuration console-setup
|
||||
sed -i 's/^XKBLAYOUT=.*/XKBLAYOUT="${layout}"/' /etc/default/keyboard
|
||||
setupcon --save-only >/dev/null 2>&1 || true
|
||||
`;
|
||||
},
|
||||
|
||||
benutzerAnlegen: (b: Benutzerangaben) => {
|
||||
const name = q(b.name);
|
||||
const schluessel = b.sshSchluessel.join("\n") + "\n";
|
||||
const zeilen = [
|
||||
`id -u ${name} >/dev/null 2>&1 || useradd -m -s /bin/bash ${name}`,
|
||||
`pd_home="$(getent passwd ${name} | cut -d: -f6)"`,
|
||||
`install -d -m 0700 -o ${name} -g ${name} "$pd_home/.ssh"`,
|
||||
// Schlüssel ergänzen statt ersetzen: cloud-init hat sie bei VMs vielleicht schon eingetragen.
|
||||
`printf '%s' ${q(base64(schluessel))} | base64 -d | while IFS= read -r pd_key; do [ -n "$pd_key" ] && { grep -qxF "$pd_key" "$pd_home/.ssh/authorized_keys" 2>/dev/null || echo "$pd_key" >> "$pd_home/.ssh/authorized_keys"; }; done`,
|
||||
`touch "$pd_home/.ssh/authorized_keys" && chown ${name}:${name} "$pd_home/.ssh/authorized_keys" && chmod 0600 "$pd_home/.ssh/authorized_keys"`,
|
||||
];
|
||||
if (b.sudo) {
|
||||
const datei = `/etc/sudoers.d/90-proxdeploy-${b.name}`;
|
||||
zeilen.push(
|
||||
`${APT} install sudo`,
|
||||
`usermod -aG sudo ${name}`,
|
||||
// Ohne Passwort, weil der Benutzer keins hat (nur SSH-Schlüssel). visudo prüft vor dem Aktivieren.
|
||||
`echo ${q(`${b.name} ALL=(ALL) NOPASSWD:ALL`)} > ${q(datei)}.neu`,
|
||||
`visudo -cf ${q(datei)}.neu >/dev/null && mv ${q(datei)}.neu ${q(datei)} && chmod 0440 ${q(datei)}`,
|
||||
);
|
||||
}
|
||||
return zeilen.join("\n") + "\n";
|
||||
},
|
||||
|
||||
sshHaerten: (h: SshHaertung) => `install -d /etc/ssh/sshd_config.d
|
||||
cat > /etc/ssh/sshd_config.d/10-proxdeploy.conf <<'PROXDEPLOY_SSH'
|
||||
# Von ProxDeploy angelegt
|
||||
PasswordAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
||||
KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
||||
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
|
||||
PROXDEPLOY_SSH
|
||||
if command -v sshd >/dev/null 2>&1; then
|
||||
sshd -t
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
|
||||
fi
|
||||
`,
|
||||
|
||||
gastAgentInstallieren: () => `# Im Container gibt es keinen Guest-Agent.
|
||||
if ! systemd-detect-virt --container --quiet; then
|
||||
${APT} install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent 2>/dev/null || systemctl start qemu-guest-agent || true
|
||||
fi
|
||||
`,
|
||||
|
||||
automatischeUpdates: (an) => `${APT} install unattended-upgrades
|
||||
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'PROXDEPLOY_UPGRADES'
|
||||
APT::Periodic::Update-Package-Lists "${an ? 1 : 0}";
|
||||
APT::Periodic::Unattended-Upgrade "${an ? 1 : 0}";
|
||||
PROXDEPLOY_UPGRADES
|
||||
`,
|
||||
|
||||
fakten: () => `. /etc/os-release
|
||||
printf '{"id":"%s","version_id":"%s","codename":"%s"}\\n' "$ID" "$VERSION_ID" "\${VERSION_CODENAME:-}"
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { debianAdapter } from "./debian";
|
||||
import type { FamilienAdapter } from "./schnittstelle";
|
||||
|
||||
/** Alle bekannten Adapter. Eine neue Familie (rhel, alpine, suse) ist eine Zeile mehr. */
|
||||
const ADAPTER: Record<string, FamilienAdapter> = {
|
||||
[debianAdapter.familie]: debianAdapter,
|
||||
};
|
||||
|
||||
export function adapterFuer(familie: string): FamilienAdapter | null {
|
||||
return ADAPTER[familie] ?? null;
|
||||
}
|
||||
|
||||
export function bekannteFamilien(): string[] {
|
||||
return Object.keys(ADAPTER);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Ein Provisionierungs-Adapter übersetzt die deklarativen Schritte eines Profils in Shell für eine
|
||||
* Distributionsfamilie (debian: apt/systemd, später rhel: dnf, alpine: apk/openrc …).
|
||||
*
|
||||
* Jede Methode liefert ein **idempotentes** Skriptfragment und führt selbst nichts aus – so lässt
|
||||
* sich jeder Adapter ohne Gast testen und im Dry-Run anzeigen. Ein neuer Adapter ist eine neue
|
||||
* Datei, die dieses Interface umsetzt, und ein Eintrag in `adapter/index.ts`.
|
||||
*/
|
||||
export type Skript = string;
|
||||
|
||||
export type PaketQuelle = {
|
||||
name: string;
|
||||
schluesselUrl: string;
|
||||
uris: string;
|
||||
suites: string;
|
||||
komponenten: string;
|
||||
};
|
||||
|
||||
export type Benutzerangaben = {
|
||||
name: string;
|
||||
sshSchluessel: string[];
|
||||
sudo: boolean;
|
||||
};
|
||||
|
||||
export type SshHaertung = { passwortAnmeldung: boolean; rootAnmeldung: boolean };
|
||||
|
||||
export interface FamilienAdapter {
|
||||
readonly familie: string;
|
||||
/** Vorspann: strikte Shell, nicht interaktiv, Warten auf Paketsperren. */
|
||||
kopf(): Skript;
|
||||
aktualisieren(): Skript;
|
||||
paketeInstallieren(pakete: string[]): Skript;
|
||||
dienstAktivieren(name: string, optionen: { starten: boolean; neuStarten: boolean }): Skript;
|
||||
repoHinzufuegen(quelle: PaketQuelle): Skript;
|
||||
/** Ein Paket von einer URL, das selbst eine Paketquelle einrichtet (z. B. zabbix-release). */
|
||||
repoPaketInstallieren(url: string): Skript;
|
||||
dateiSchreiben(pfad: string, inhalt: string, modus: string): Skript;
|
||||
zeitzoneSetzen(zeitzone: string): Skript;
|
||||
localeSetzen(locale: string): Skript;
|
||||
tastaturSetzen(layout: string): Skript;
|
||||
benutzerAnlegen(benutzer: Benutzerangaben): Skript;
|
||||
sshHaerten(haertung: SshHaertung): Skript;
|
||||
gastAgentInstallieren(): Skript;
|
||||
automatischeUpdates(an: boolean): Skript;
|
||||
/** Gibt OS-Fakten als eine Zeile JSON aus: {"id","version_id","codename"}. */
|
||||
fakten(): Skript;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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)) };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { FamilienAdapter } from "@/engine/adapter/schnittstelle";
|
||||
import type { ProfilSchritt } from "@/engine/katalog/schema";
|
||||
import type { AufgeloestesProfil } from "./aufloesen";
|
||||
import { einsetzen, tiefEinsetzen, type VorlagenKontext } from "./vorlage";
|
||||
|
||||
/**
|
||||
* Baut aus aufgelösten Profilen ein Provisionierungsskript.
|
||||
*
|
||||
* Zwischen den Profilen steht eine Markenzeile (`::proxdeploy:: profil <id>`), an der die Engine
|
||||
* im Live-Log erkennt, welches Profil gerade läuft. SSH-Härtung wird ans Ende verschoben: Läuft die
|
||||
* Provisionierung selbst über SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst aus.
|
||||
*/
|
||||
export const MARKE = "::proxdeploy::";
|
||||
|
||||
export type RenderKontext = {
|
||||
os: { id: string; version_id: string; codename?: string };
|
||||
gast: { name: string; benutzer: string };
|
||||
};
|
||||
|
||||
export type Abschnitt = { profil: string; skript: string };
|
||||
|
||||
export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
|
||||
|
||||
function bedingungErfuellt(schritt: ProfilSchritt, werte: Record<string, unknown>): boolean {
|
||||
if (!schritt.if) return true;
|
||||
const verneint = schritt.if.startsWith("!");
|
||||
const wert = Boolean(werte[schritt.if.replace(/^!/, "")]);
|
||||
return verneint ? !wert : wert;
|
||||
}
|
||||
|
||||
function schrittRendern(schritt: ProfilSchritt, adapter: FamilienAdapter, kontext: VorlagenKontext): string {
|
||||
if ("update" in schritt) return adapter.aktualisieren();
|
||||
if ("packages" in schritt)
|
||||
return adapter.paketeInstallieren(schritt.packages.map((p) => einsetzen(p, kontext)));
|
||||
if ("service" in schritt) {
|
||||
return adapter.dienstAktivieren(einsetzen(schritt.service.name, kontext), {
|
||||
starten: schritt.service.start,
|
||||
neuStarten: schritt.service.restart,
|
||||
});
|
||||
}
|
||||
if ("repo" in schritt) {
|
||||
const r = tiefEinsetzen(schritt.repo, kontext);
|
||||
return adapter.repoHinzufuegen({
|
||||
name: r.name,
|
||||
schluesselUrl: r.key_url,
|
||||
uris: r.uris,
|
||||
suites: r.suites,
|
||||
komponenten: r.components,
|
||||
});
|
||||
}
|
||||
if ("repo_package" in schritt)
|
||||
return adapter.repoPaketInstallieren(einsetzen(schritt.repo_package, kontext));
|
||||
if ("file" in schritt) {
|
||||
const f = tiefEinsetzen(schritt.file, kontext);
|
||||
return adapter.dateiSchreiben(f.path, f.content, f.mode);
|
||||
}
|
||||
if ("timezone" in schritt) return adapter.zeitzoneSetzen(einsetzen(schritt.timezone, kontext));
|
||||
if ("locale" in schritt) return adapter.localeSetzen(einsetzen(schritt.locale, kontext));
|
||||
if ("keyboard" in schritt) return adapter.tastaturSetzen(einsetzen(schritt.keyboard, kontext));
|
||||
if ("guest_agent" in schritt) return adapter.gastAgentInstallieren();
|
||||
if ("unattended_upgrades" in schritt) return adapter.automatischeUpdates(schritt.unattended_upgrades);
|
||||
if ("run" in schritt) return einsetzen(schritt.run, kontext, "shell") + "\n";
|
||||
if ("ssh_harden" in schritt) {
|
||||
return adapter.sshHaerten({
|
||||
passwortAnmeldung: schritt.ssh_harden.password_auth,
|
||||
rootAnmeldung: schritt.ssh_harden.root_login,
|
||||
});
|
||||
}
|
||||
const unbekannt: never = schritt;
|
||||
throw new Error(`Unbekannter Schritt: ${JSON.stringify(unbekannt)}`);
|
||||
}
|
||||
|
||||
export function profileRendern(
|
||||
profile: AufgeloestesProfil[],
|
||||
adapter: FamilienAdapter,
|
||||
kontext: RenderKontext,
|
||||
vorspann: string[] = [],
|
||||
): Gerendert {
|
||||
const abschnitte: Abschnitt[] = [];
|
||||
const zumSchluss: string[] = [];
|
||||
|
||||
for (const { profil, werte } of profile) {
|
||||
const vorlagenKontext: VorlagenKontext = {
|
||||
var: werte,
|
||||
os: { id: kontext.os.id, version_id: kontext.os.version_id, codename: kontext.os.codename ?? "" },
|
||||
guest: { name: kontext.gast.name, user: kontext.gast.benutzer },
|
||||
};
|
||||
const teile: string[] = [];
|
||||
for (const schritt of profil.families[adapter.familie] ?? []) {
|
||||
if (!bedingungErfuellt(schritt, werte)) continue;
|
||||
const text = schrittRendern(schritt, adapter, vorlagenKontext);
|
||||
if ("ssh_harden" in schritt) zumSchluss.push(text);
|
||||
else teile.push(text);
|
||||
}
|
||||
abschnitte.push({ profil: profil.id, skript: teile.join("") });
|
||||
}
|
||||
|
||||
const skript = [
|
||||
"#!/bin/bash",
|
||||
"# Von ProxDeploy erzeugt",
|
||||
adapter.kopf(),
|
||||
...vorspann,
|
||||
...abschnitte.map((a) => `echo '${MARKE} profil ${a.profil}'\n${a.skript}`),
|
||||
...(zumSchluss.length > 0 ? [`echo '${MARKE} abschluss'\n${zumSchluss.join("")}`] : []),
|
||||
`echo '${MARKE} fertig'\n`,
|
||||
].join("\n");
|
||||
|
||||
return { skript, abschnitte };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { q } from "@/engine/shell";
|
||||
|
||||
/**
|
||||
* Platzhalter in Profilen: `{{ var.name }}`, `{{ os.id }}`, `{{ guest.user }}` und die Ausweichform
|
||||
* `{{ var.a ?? var.b }}` (b, wenn a leer ist). Bewusst keine Logik, keine Filter, keine Schleifen –
|
||||
* ein Profil soll lesbar bleiben.
|
||||
*
|
||||
* Im Modus `shell` (für `run`-Schritte) wird jeder eingesetzte Wert gequotet: eine Variable aus dem
|
||||
* Formular kann so nie Shell-Code werden. Alle anderen Felder gehen an den Adapter, der selbst quotet.
|
||||
*/
|
||||
export type VorlagenKontext = Record<
|
||||
"var" | "os" | "guest",
|
||||
Record<string, string | number | boolean | undefined>
|
||||
>;
|
||||
|
||||
export class VorlagenFehler extends Error {
|
||||
constructor(meldung: string) {
|
||||
super(meldung);
|
||||
this.name = "VorlagenFehler";
|
||||
}
|
||||
}
|
||||
|
||||
const PLATZHALTER = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
const VERWEIS = /^(var|os|guest)\.([a-z][a-z0-9_]*)$/;
|
||||
|
||||
function aufloesen(ausdruck: string, kontext: VorlagenKontext): string {
|
||||
for (const teil of ausdruck.split("??").map((t) => t.trim())) {
|
||||
const treffer = VERWEIS.exec(teil);
|
||||
if (!treffer) throw new VorlagenFehler(`Unbekannter Platzhalter „{{ ${ausdruck} }}“.`);
|
||||
const bereich = kontext[treffer[1] as keyof VorlagenKontext];
|
||||
if (!(treffer[2]! in bereich)) throw new VorlagenFehler(`„${teil}“ ist nicht definiert.`);
|
||||
const wert = bereich[treffer[2]!];
|
||||
if (wert !== undefined && wert !== "") return String(wert);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function einsetzen(text: string, kontext: VorlagenKontext, modus: "roh" | "shell" = "roh"): string {
|
||||
return text.replace(PLATZHALTER, (_, ausdruck: string) => {
|
||||
const wert = aufloesen(ausdruck, kontext);
|
||||
return modus === "shell" ? q(wert) : wert;
|
||||
});
|
||||
}
|
||||
|
||||
/** Ersetzt in allen Strings eines Objekts (rekursiv) – für Schritte wie `repo` oder `file`. */
|
||||
export function tiefEinsetzen<T>(wert: T, kontext: VorlagenKontext): T {
|
||||
if (typeof wert === "string") return einsetzen(wert, kontext) as T;
|
||||
if (Array.isArray(wert)) return wert.map((eintrag) => tiefEinsetzen(eintrag, kontext)) as T;
|
||||
if (wert && typeof wert === "object") {
|
||||
return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, tiefEinsetzen(v, kontext)])) as T;
|
||||
}
|
||||
return wert;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Shell-Quoting für alles, was in ein Provisionierungsskript wandert. Einfache Anführungszeichen
|
||||
* sind in POSIX-sh die einzige Form, in der nichts mehr ausgewertet wird – kein $, kein `, kein \.
|
||||
*/
|
||||
export function q(wert: string): string {
|
||||
if (wert === "") return "''";
|
||||
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(wert)) return wert;
|
||||
return `'${wert.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
/** Inhalt als base64, damit beliebige Bytes (auch Zeilenumbrüche, $, ') unverändert ankommen. */
|
||||
export function base64(text: string): string {
|
||||
return Buffer.from(text, "utf8").toString("base64");
|
||||
}
|
||||
Reference in New Issue
Block a user