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:
2026-10-12 10:53:38 +02:00
co-authored by Claude Opus 5.5
parent acae1bc2a4
commit 01d22aac3a
15 changed files with 999 additions and 10 deletions
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Läuft im Container: systemctl-Stub, Skript ausführen, Ergebnis prüfen.
printf '#!/bin/sh\necho "[systemctl] $*" >> /tmp/systemctl.log\n' > /usr/local/bin/systemctl && chmod +x /usr/local/bin/systemctl
bash "$1" > /tmp/lauf.log 2>&1; code=$?
grep '::proxdeploy::' /tmp/lauf.log
echo "exit=$code"
[ $code -ne 0 ] && tail -25 /tmp/lauf.log
echo "--- Prüfung"
docker --version; docker compose version
zabbix_agent2 -V | head -1; cat /etc/zabbix/zabbix_agent2.d/proxdeploy.conf
cat /etc/timezone; readlink /etc/localtime; grep LANG /etc/default/locale; locale -a | grep -i de_de
grep XKBLAYOUT /etc/default/keyboard
id florian; cat /etc/sudoers.d/90-proxdeploy-florian; cat ~florian/.ssh/authorized_keys
cat /etc/apt/apt.conf.d/20auto-upgrades; dpkg -l fail2ban chrony qemu-guest-agent | grep ^ii | awk '{print $2}'
cat /tmp/systemctl.log | sort -u | tr '\n' ' '; echo
echo "--- Zweiter Lauf (Idempotenz)"; bash "$1" > /tmp/lauf2.log 2>&1; echo "exit2=$?"; grep -c florian ~florian/.ssh/authorized_keys
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Führt die Profile base, docker und zabbix-agent wirklich aus: je ein frischer Container pro
# Distribution, danach Prüfung des Ergebnisses und ein zweiter Lauf (Idempotenz).
# Dauert einige Minuten und braucht Docker und Netz – deshalb nicht im Commit-Tor.
# pnpm test:container # alle
# pnpm test:container debian-13 # eine
set -euo pipefail
cd "$(dirname "$0")/../.."
export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
declare -A IMAGES=(
[debian-12]=debian:bookworm-slim [debian-13]=debian:trixie-slim
[ubuntu-22.04]=ubuntu:22.04 [ubuntu-24.04]=ubuntu:24.04 [ubuntu-26.04]=ubuntu:26.04
)
ziele=("${@:-debian-12 debian-13 ubuntu-22.04 ubuntu-24.04 ubuntu-26.04}")
arbeit="$(mktemp -d)"; trap 'rm -rf "$arbeit"' EXIT
cp tests/container/im-gast-pruefen.sh "$arbeit/"
fehler=0
for distro in ${ziele[*]}; do
pnpm -s exec tsx scripts/profil-rendern.ts "$distro" docker,zabbix-agent server=zbx.example fail2ban=true > "$arbeit/$distro.sh"
echo "=== $distro (${IMAGES[$distro]})"
if docker run --rm -v "$arbeit:/s:ro" "${IMAGES[$distro]}" bash /s/im-gast-pruefen.sh "/s/$distro.sh" > "$arbeit/$distro.log" 2>&1 \
&& grep -q '^exit=0$' "$arbeit/$distro.log" && grep -q '^exit2=0$' "$arbeit/$distro.log"; then
echo "✓ $distro"
else
echo "✗ $distro"; tail -30 "$arbeit/$distro.log"; fehler=1
fi
done
exit $fehler
+206
View File
@@ -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"');
});
});
+55
View File
@@ -0,0 +1,55 @@
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 { debianAdapter } from "@/engine/adapter/debian";
import { katalogLaden } from "@/engine/katalog/laden";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { profileRendern } from "@/engine/profile/rendern";
/** shellcheck über Docker – läuft nur, wenn Docker und das Image da sind, und sagt das sonst. */
function shellcheckVerfuegbar(): boolean {
try {
execFileSync("docker", ["image", "inspect", "koalaman/shellcheck:stable"], { stdio: "ignore" });
return true;
} catch {
console.warn("shellcheck übersprungen: docker oder koalaman/shellcheck:stable fehlt");
return false;
}
}
describe.runIf(shellcheckVerfuegbar())("shellcheck", () => {
it("findet in den gerenderten Katalogprofilen nichts", async () => {
const katalog = await katalogLaden("catalog");
const profile = new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag]));
const ordner = mkdtempSync(join(tmpdir(), "pd-sc-"));
for (const { eintrag: distro } of katalog.distributionen) {
const aufgeloest = profileAufloesen(["docker", "zabbix-agent"], profile, "debian", {
base: { fail2ban: true },
"zabbix-agent": { server: "zbx" },
});
if (!aufgeloest.ok) throw new Error("Auflösung fehlgeschlagen");
const { skript } = profileRendern(
aufgeloest.profile,
debianAdapter,
{ os: distro.os, gast: { name: "g", benutzer: "u" } },
[debianAdapter.benutzerAnlegen({ name: "u", sshSchluessel: ["ssh-ed25519 AAAA u"], sudo: true })],
);
writeFileSync(join(ordner, `${distro.id}.sh`), skript);
}
const ausgabe = (() => {
try {
execFileSync(
"bash",
["-c", `docker run --rm -v ${ordner}:/s:ro koalaman/shellcheck:stable -s bash /s/*.sh`],
{ stdio: "pipe" },
);
return "";
} catch (fehler) {
return String((fehler as { stdout?: Buffer }).stdout ?? fehler);
}
})();
expect(ausgabe).toBe("");
}, 60_000);
});