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,206 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
|
||||
import { AdapterFehler, debianAdapter } from "@/engine/adapter/debian";
|
||||
import { katalogLaden } from "@/engine/katalog/laden";
|
||||
import { ProfilSchema, type Profil } from "@/engine/katalog/schema";
|
||||
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||
import { MARKE, profileRendern } from "@/engine/profile/rendern";
|
||||
import { einsetzen, VorlagenFehler } from "@/engine/profile/vorlage";
|
||||
import { q } from "@/engine/shell";
|
||||
|
||||
const kontext = {
|
||||
var: { a: "eins", leer: "" },
|
||||
os: { id: "debian", version_id: "13", codename: "trixie" },
|
||||
guest: { name: "web01", user: "florian" },
|
||||
};
|
||||
|
||||
async function katalogProfile(): Promise<Map<string, Profil>> {
|
||||
const k = await katalogLaden("catalog");
|
||||
return new Map(k.profile.map((p) => [p.eintrag.id, p.eintrag]));
|
||||
}
|
||||
|
||||
const profil = (roh: unknown) => ProfilSchema.parse(roh);
|
||||
|
||||
describe("Shell-Quoting", () => {
|
||||
it("lässt Harmloses stehen und packt alles andere in einfache Anführungszeichen", () => {
|
||||
expect(q("docker-ce")).toBe("docker-ce");
|
||||
expect(q("a b")).toBe("'a b'");
|
||||
expect(q("$(rm -rf /)")).toBe("'$(rm -rf /)'");
|
||||
expect(q("it's")).toBe(`'it'"'"'s'`);
|
||||
expect(q("")).toBe("''");
|
||||
});
|
||||
|
||||
it("ein gequoteter Wert kommt in bash unverändert an", () => {
|
||||
for (const wert of ["$(id)", "`id`", "a'b\"c", "x; rm -rf /", "ä ö ü", "\\n"]) {
|
||||
expect(execFileSync("bash", ["-c", `printf '%s' ${q(wert)}`]).toString()).toBe(wert);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Platzhalter", () => {
|
||||
it("setzt var, os und guest ein; ?? nimmt den ersten nicht leeren Wert", () => {
|
||||
expect(einsetzen("{{ os.id }}{{ os.version_id }}-{{ guest.name }}", kontext)).toBe("debian13-web01");
|
||||
expect(einsetzen("{{ var.leer ?? var.a }}", kontext)).toBe("eins");
|
||||
});
|
||||
|
||||
it("wirft bei unbekannten Platzhaltern statt sie leer zu lassen", () => {
|
||||
expect(() => einsetzen("{{ var.gibtsnicht }}", kontext)).toThrow(VorlagenFehler);
|
||||
expect(() => einsetzen("{{ env.HOME }}", kontext)).toThrow(/Unbekannter Platzhalter/);
|
||||
});
|
||||
|
||||
it("im Shell-Modus kann eine Variable nie Code werden", () => {
|
||||
const boese = { ...kontext, var: { name: "x; touch /tmp/gehackt" } };
|
||||
const befehl = einsetzen("echo {{ var.name }}", boese, "shell");
|
||||
expect(befehl).toBe("echo 'x; touch /tmp/gehackt'");
|
||||
expect(execFileSync("bash", ["-c", befehl]).toString().trim()).toBe("x; touch /tmp/gehackt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Profile auflösen", () => {
|
||||
it("zieht Voraussetzungen nach vorne und meldet sie als automatisch", async () => {
|
||||
const ergebnis = profileAufloesen(["docker", "zabbix-agent"], await katalogProfile(), "debian", {
|
||||
"zabbix-agent": { server: "zbx" },
|
||||
});
|
||||
expect(ergebnis.ok).toBe(true);
|
||||
if (!ergebnis.ok) return;
|
||||
expect(ergebnis.profile.map((p) => p.profil.id)).toEqual(["base", "docker", "zabbix-agent"]);
|
||||
expect(ergebnis.automatisch).toEqual(["base"]);
|
||||
});
|
||||
|
||||
it("setzt Standardwerte und prüft Pflichtvariablen", async () => {
|
||||
const fehlt = profileAufloesen(["zabbix-agent"], await katalogProfile(), "debian");
|
||||
expect(fehlt).toEqual({
|
||||
ok: false,
|
||||
fehler: [{ profil: "zabbix-agent", variable: "server", meldung: "Zabbix-Server fehlt." }],
|
||||
});
|
||||
|
||||
const ok = profileAufloesen(["base"], await katalogProfile(), "debian");
|
||||
expect(ok.ok && ok.profile[0]!.werte).toEqual({
|
||||
zeitzone: "Europe/Berlin",
|
||||
locale: "de_DE.UTF-8",
|
||||
tastatur: "de",
|
||||
fail2ban: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt Zeilenumbrüche, falsche Typen, Zyklen und fehlende Familien ab", () => {
|
||||
const verfuegbar = new Map<string, Profil>([
|
||||
["a", profil({ id: "a", name: "A", requires: ["b"], families: { debian: [{ update: true }] } })],
|
||||
["b", profil({ id: "b", name: "B", requires: ["a"], families: { debian: [{ update: true }] } })],
|
||||
["nur-rhel", profil({ id: "nur-rhel", name: "R", families: { rhel: [{ update: true }] } })],
|
||||
[
|
||||
"v",
|
||||
profil({
|
||||
id: "v",
|
||||
name: "V",
|
||||
variables: [
|
||||
{ name: "text", label: "Text" },
|
||||
{ name: "zahl", label: "Zahl", type: "number" },
|
||||
{ name: "wahl", label: "Wahl", type: "select", options: ["x", "y"] },
|
||||
],
|
||||
families: { debian: [{ update: true }] },
|
||||
}),
|
||||
],
|
||||
]);
|
||||
const ergebnis = profileAufloesen(["a", "nur-rhel", "v"], verfuegbar, "debian", {
|
||||
v: { text: "a\nb", zahl: "abc", wahl: "z" },
|
||||
});
|
||||
expect(ergebnis.ok).toBe(false);
|
||||
if (ergebnis.ok) return;
|
||||
const meldungen = ergebnis.fehler.map(
|
||||
(f) => `${f.profil}${f.variable ? "." + f.variable : ""}: ${f.meldung}`,
|
||||
);
|
||||
expect(meldungen).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/Zyklische Voraussetzung/),
|
||||
"nur-rhel: Keine Umsetzung für die Familie „debian“.",
|
||||
"v.text: Text: keine Zeilenumbrüche.",
|
||||
"v.zahl: Zahl: Zahl erwartet.",
|
||||
"v.wahl: Wahl: erlaubt sind x, y.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rendern", () => {
|
||||
const renderKontext = {
|
||||
os: { id: "debian", version_id: "13", codename: "trixie" },
|
||||
gast: { name: "web01", benutzer: "florian" },
|
||||
};
|
||||
|
||||
async function rendern(auswahl: string[], eingaben = {}) {
|
||||
const ergebnis = profileAufloesen(auswahl, await katalogProfile(), "debian", eingaben);
|
||||
if (!ergebnis.ok) throw new Error(JSON.stringify(ergebnis.fehler));
|
||||
return profileRendern(ergebnis.profile, debianAdapter, renderKontext);
|
||||
}
|
||||
|
||||
it("setzt Marken je Profil und die SSH-Härtung ganz ans Ende", async () => {
|
||||
const { skript } = await rendern(["docker"]);
|
||||
const marken = skript.split("\n").filter((z) => z.includes(MARKE));
|
||||
expect(marken).toEqual([
|
||||
`echo '${MARKE} profil base'`,
|
||||
`echo '${MARKE} profil docker'`,
|
||||
`echo '${MARKE} abschluss'`,
|
||||
`echo '${MARKE} fertig'`,
|
||||
]);
|
||||
expect(skript.indexOf("10-proxdeploy.conf")).toBeGreaterThan(skript.indexOf("docker-compose-plugin"));
|
||||
});
|
||||
|
||||
it("setzt Distribution und Gast ein (Docker-Repo, Zabbix-Paket, Hostname)", async () => {
|
||||
const { skript } = await rendern(["docker", "zabbix-agent"], {
|
||||
"zabbix-agent": { server: "zbx.example" },
|
||||
});
|
||||
expect(skript).toContain("https://download.docker.com/linux/debian/gpg");
|
||||
expect(skript).toContain("Suites: trixie");
|
||||
expect(skript).toContain("zabbix-release_latest_7.0+debian13_all.deb");
|
||||
expect(skript).toContain("usermod -aG docker florian");
|
||||
// Der Inhalt der Konfigurationsdatei steht base64-kodiert im Skript.
|
||||
const b64 = /printf '%s' (\S+) \| base64 -d > \/etc\/zabbix/.exec(skript)![1]!;
|
||||
expect(Buffer.from(b64, "base64").toString()).toContain("Hostname=web01\n");
|
||||
});
|
||||
|
||||
it("überspringt Schritte, deren Bedingung nicht erfüllt ist", async () => {
|
||||
expect((await rendern(["base"])).skript).not.toContain("fail2ban");
|
||||
expect((await rendern(["base"], { base: { fail2ban: true } })).skript).toContain("fail2ban");
|
||||
});
|
||||
|
||||
it("weist ungültige Zeitzone, Locale und Tastatur schon beim Rendern ab", async () => {
|
||||
await expect(rendern(["base"], { base: { zeitzone: "Europe/Berlin; rm" } })).rejects.toThrow(
|
||||
AdapterFehler,
|
||||
);
|
||||
await expect(rendern(["base"], { base: { locale: "de" } })).rejects.toThrow(/Locale/);
|
||||
await expect(rendern(["base"], { base: { tastatur: "de'" } })).rejects.toThrow(/Tastatur/);
|
||||
});
|
||||
|
||||
it("ist syntaktisch gültiges bash", async () => {
|
||||
const { skript } = await rendern(["docker", "zabbix-agent"], {
|
||||
"zabbix-agent": { server: "zbx" },
|
||||
base: { fail2ban: true },
|
||||
});
|
||||
const datei = join(mkdtempSync(join(tmpdir(), "pd-skript-")), "s.sh");
|
||||
writeFileSync(datei, skript);
|
||||
expect(() => execFileSync("bash", ["-n", datei])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Adapter-Registry", () => {
|
||||
it("kennt debian; eine unbekannte Familie ergibt null statt Absturz", () => {
|
||||
expect(bekannteFamilien()).toEqual(["debian"]);
|
||||
expect(adapterFuer("debian")).toBe(debianAdapter);
|
||||
expect(adapterFuer("rhel")).toBeNull();
|
||||
});
|
||||
|
||||
it("Benutzer: sudo-Datei wird vor dem Aktivieren mit visudo geprüft, Schlüssel ergänzt statt ersetzt", () => {
|
||||
const skript = debianAdapter.benutzerAnlegen({
|
||||
name: "florian",
|
||||
sshSchluessel: ["ssh-ed25519 AAAA x"],
|
||||
sudo: true,
|
||||
});
|
||||
expect(skript).toMatch(/visudo -cf .*\.neu >\/dev\/null && mv/);
|
||||
expect(skript).toContain('>> "$pd_home/.ssh/authorized_keys"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user