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:
@@ -1,23 +1,31 @@
|
|||||||
# #005 Adapter debian und Profil-Engine
|
# #005 Adapter debian und Profil-Engine
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** feature, engine
|
**Labels:** feature, engine
|
||||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
**Geschätzt:** 6 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Interface `FamilienAdapter`, Umsetzung `debian`, Profil `base` wird zu einem idempotenten Skript.
|
Interface `FamilienAdapter`, Umsetzung `debian`, Profile werden zu einem idempotenten Skript.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Interface + Registry; `debian`-Adapter (apt, systemd, deb822, locale, keyboard, sshd_config.d)
|
- [x] Interface + Registry; `debian`-Adapter (apt mit Lock-Timeout, deb822, systemd, locale, keyboard, sshd_config.d, sudoers mit visudo-Prüfung)
|
||||||
- [ ] Profilauflösung (requires, Reihenfolge, Variablen, Shell-Quoting)
|
- [x] Profilauflösung (requires transitiv, Zyklen, Variablen mit Typen und Standardwerten, Familien-Prüfung)
|
||||||
- [ ] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, sudo-User, SSH-Härtung, chrony, fail2ban optional)
|
- [x] Platzhalter `{{ var.x }}`, `{{ os.* }}`, `{{ guest.* }}`, `a ?? b`; in `run` jeder Wert shell-gequotet
|
||||||
- [ ] Snapshot-Tests der erzeugten Skripte; shellcheck auf die Ausgabe
|
- [x] Profil `base` (Updates, Guest-Agent, Europe/Berlin, de_DE.UTF-8, Tastatur de, unattended-upgrades, chrony, SSH-Härtung, fail2ban optional)
|
||||||
|
- [x] Tests der erzeugten Skripte; shellcheck auf die Ausgabe aller Distributionen
|
||||||
|
- [x] `pnpm test:container`: Skripte wirklich ausführen (Debian 12/13, Ubuntu 22.04/24.04/26.04), Ergebnis prüfen, zweiter Lauf auf Idempotenz
|
||||||
|
- [x] `docs/katalog.md`: neue Distribution, neuer Adapter, eigenes Profil
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
|
- [x] Neues Profil/neuer Adapter braucht keine Änderung an Engine-Code
|
||||||
|
- [x] base + docker + zabbix-agent laufen auf allen fünf Distributionen durch und sind idempotent (Container, 2026-10-12)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#004
|
#004
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Fund im Container:** frische Images haben keine Paketlisten – der Vorspann macht jetzt immer `apt-get update`, sonst scheiterte schon die Benutzeranlage an „Unable to locate package sudo“.
|
||||||
|
- **Zeitzone, Locale und Tastatur werden gegen strenge Muster geprüft**, bevor sie ins Skript kommen (shellcheck fand verschachteltes Quoting im sed-Ausdruck).
|
||||||
|
- **SSH-Härtung läuft immer zuletzt:** läuft die Provisionierung per SSH als root (LXC ohne Node-SSH), schlösse sie sich sonst selbst aus.
|
||||||
|
- **Marken `::proxdeploy:: profil <id>`** im Skript zeigen im Live-Log, welches Profil gerade läuft.
|
||||||
|
- Dateiinhalte gehen base64-kodiert ins Skript – beliebige Zeichen kommen unverändert an.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
|
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
|
||||||
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
|
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
|
||||||
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
|
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
|
||||||
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
|
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
|
||||||
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
|
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
|
||||||
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – |
|
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – |
|
||||||
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – |
|
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – |
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
# Katalog: Distributionen und Profile
|
||||||
|
|
||||||
|
Distributionen und Profile sind Daten, kein Code. Jede liegt als YAML-Datei unter `catalog/` und wird
|
||||||
|
beim Start von ProxDeploy in die Datenbank abgeglichen. Die Datei ist maßgeblich für den Inhalt; in der
|
||||||
|
Oberfläche (*Katalog*) lassen sich Einträge nur aktivieren und deaktivieren. Eine fehlerhafte Datei
|
||||||
|
wird mit Dateiname und Fehlerstelle im Log gemeldet und übersprungen.
|
||||||
|
|
||||||
|
Regeln für beide Arten:
|
||||||
|
|
||||||
|
- Dateiname = `id` + `.yaml` (`catalog/distros/debian-13.yaml` → `id: debian-13`)
|
||||||
|
- unbekannte Schlüssel sind ein Fehler – ein Tippfehler fällt so sofort auf
|
||||||
|
- nur HTTPS-Quellen
|
||||||
|
|
||||||
|
## Neue Distribution hinzufügen
|
||||||
|
|
||||||
|
Beispiel Debian 13:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: debian-13
|
||||||
|
name: Debian 13 „Trixie“
|
||||||
|
family: debian # wählt den Provisionierungs-Adapter
|
||||||
|
os:
|
||||||
|
id: debian # für Platzhalter in Profilen: {{ os.id }}
|
||||||
|
version_id: "13" # {{ os.version_id }} – in Anführungszeichen, sonst wird 22.04 zur Zahl
|
||||||
|
codename: trixie # {{ os.codename }}
|
||||||
|
arch: amd64
|
||||||
|
vm:
|
||||||
|
image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
|
||||||
|
checksum_url: https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
|
||||||
|
checksum_type: sha512 # sha256 | sha512
|
||||||
|
format: qcow2 # qcow2 | raw | vmdk
|
||||||
|
default_user: debian
|
||||||
|
guest_agent_preinstalled: false # sonst baut ProxDeploy ihn beim Template-Bau ein
|
||||||
|
bios: ovmf # ovmf (UEFI, Standard) | seabios
|
||||||
|
min_disk_gb: 4
|
||||||
|
lxc:
|
||||||
|
template_pattern: "debian-13-standard_*_amd64.tar.zst" # wie in `pveam available`
|
||||||
|
ostype: debian
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm` oder `lxc` darf fehlen, wenn die Distribution nur eins davon kann. Bei mehreren passenden
|
||||||
|
LXC-Templates gewinnt die höchste Version. Die Prüfsummen-Datei darf im GNU-Format (`<hash> <datei>`
|
||||||
|
oder `<hash> *<datei>`) oder im BSD-Format (`SHA256 (<datei>) = <hash>`) vorliegen.
|
||||||
|
|
||||||
|
Gehört die Distribution zu einer Familie, für die es noch keinen Adapter gibt (etwa `rhel` für Rocky
|
||||||
|
oder Alma, `alpine`), braucht es zusätzlich einen Adapter:
|
||||||
|
|
||||||
|
1. `src/engine/adapter/<familie>.ts` anlegen, das Interface `FamilienAdapter` aus
|
||||||
|
`src/engine/adapter/schnittstelle.ts` umsetzen (Pakete, Dienste, Paketquellen, Zeitzone, Locale,
|
||||||
|
Tastatur, Benutzer, SSH-Härtung, Guest-Agent, automatische Updates)
|
||||||
|
2. in `src/engine/adapter/index.ts` eintragen
|
||||||
|
3. Tests nach dem Muster von `tests/engine/profile.test.ts`, dazu ein Image in
|
||||||
|
`tests/container/profile-pruefen.sh`
|
||||||
|
4. in den Profilen unter `families:` eine Umsetzung für die neue Familie ergänzen – Profile ohne
|
||||||
|
Umsetzung erscheinen im Assistenten ausgegraut
|
||||||
|
|
||||||
|
## Eigenes Profil anlegen
|
||||||
|
|
||||||
|
Ein Profil ist eine Liste von Schritten je Distributionsfamilie. Der Adapter übersetzt sie in ein
|
||||||
|
Shell-Skript, das bei VMs über den Guest-Agent und bei Containern per SSH läuft.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: nginx
|
||||||
|
name: nginx
|
||||||
|
description: Webserver mit Startseite
|
||||||
|
requires: [base] # wird automatisch davor ausgeführt
|
||||||
|
variables:
|
||||||
|
- { name: titel, label: Seitentitel, default: Willkommen }
|
||||||
|
- { name: tls, label: Zertifikat per certbot, type: boolean, default: false }
|
||||||
|
lxc:
|
||||||
|
features: { nesting: false } # Container-Features, die das Profil braucht
|
||||||
|
families:
|
||||||
|
debian:
|
||||||
|
- packages: [nginx]
|
||||||
|
- file:
|
||||||
|
path: /var/www/html/index.html
|
||||||
|
content: |
|
||||||
|
<h1>{{ var.titel }}</h1>
|
||||||
|
<p>{{ guest.name }} · {{ os.id }} {{ os.version_id }}</p>
|
||||||
|
- service: { name: nginx, enable: true, start: true }
|
||||||
|
- packages: [certbot, python3-certbot-nginx]
|
||||||
|
if: tls
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritte
|
||||||
|
|
||||||
|
| Schritt | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `update: true` | Paketlisten aktualisieren und alle Pakete aktualisieren |
|
||||||
|
| `packages: [a, b]` | Pakete installieren |
|
||||||
|
| `service: { name, enable, start, restart }` | Dienst aktivieren, starten oder neu starten |
|
||||||
|
| `repo: { name, key_url, uris, suites, components }` | Paketquelle mit Schlüssel einrichten (deb822) |
|
||||||
|
| `repo_package: <url>` | ein Paket installieren, das selbst eine Quelle einrichtet (z. B. zabbix-release) |
|
||||||
|
| `file: { path, content, mode }` | Datei schreiben (Inhalt wird unverändert übertragen) |
|
||||||
|
| `timezone`, `locale`, `keyboard` | Zeitzone, Sprache, Tastaturbelegung |
|
||||||
|
| `ssh_harden: { password_auth, root_login }` | SSH absichern – läuft immer ganz am Ende |
|
||||||
|
| `guest_agent: true` | QEMU-Guest-Agent (in Containern übersprungen) |
|
||||||
|
| `unattended_upgrades: true` | automatische Sicherheitsupdates |
|
||||||
|
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
||||||
|
|
||||||
|
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
||||||
|
Variable wahr bzw. falsch ist.
|
||||||
|
|
||||||
|
### Platzhalter
|
||||||
|
|
||||||
|
`{{ var.<name> }}` (Profil-Variable), `{{ os.id }}`, `{{ os.version_id }}`, `{{ os.codename }}`,
|
||||||
|
`{{ guest.name }}`, `{{ guest.user }}`. `{{ var.a ?? var.b }}` nimmt `b`, wenn `a` leer ist. Ein
|
||||||
|
unbekannter Platzhalter ist ein Fehler, kein leerer Text.
|
||||||
|
|
||||||
|
In `run`-Schritten wird jeder eingesetzte Wert für die Shell gequotet – eine Eingabe im Formular kann
|
||||||
|
so nie zu Code werden. Variablen dürfen keine Zeilenumbrüche enthalten.
|
||||||
|
|
||||||
|
### Variablentypen
|
||||||
|
|
||||||
|
`string` (Standard), `number`, `boolean`, `select` (mit `options: [...]`). `required: true` macht
|
||||||
|
das Feld im Assistenten zur Pflicht; `default` füllt es vor; `help` erscheint darunter.
|
||||||
|
|
||||||
|
### Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec tsx scripts/profil-rendern.ts debian-13 nginx titel=Hallo # Skript ansehen
|
||||||
|
pnpm test:container debian-13 # wirklich ausführen
|
||||||
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Rendert Profile für eine Distribution auf stdout – zum Ansehen und für die Integrationsprüfung
|
||||||
|
* in einem Container: `pnpm exec tsx scripts/profil-rendern.ts debian-13 base,docker server=zbx.local`
|
||||||
|
*/
|
||||||
|
import { adapterFuer } from "@/engine/adapter";
|
||||||
|
import { katalogLaden } from "@/engine/katalog/laden";
|
||||||
|
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||||
|
import { profileRendern } from "@/engine/profile/rendern";
|
||||||
|
|
||||||
|
const [distroId = "debian-13", auswahl = "base", ...zuweisungen] = process.argv.slice(2);
|
||||||
|
const katalog = await katalogLaden("catalog");
|
||||||
|
const distro = katalog.distributionen.find((d) => d.eintrag.id === distroId)?.eintrag;
|
||||||
|
if (!distro) throw new Error(`Unbekannte Distribution ${distroId}`);
|
||||||
|
const profile = new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag]));
|
||||||
|
const eingaben: Record<string, Record<string, string>> = {};
|
||||||
|
for (const z of zuweisungen) {
|
||||||
|
const [name, wert] = z.split("=");
|
||||||
|
for (const p of profile.values())
|
||||||
|
if (p.variables.some((v) => v.name === name)) (eingaben[p.id] ??= {})[name!] = wert!;
|
||||||
|
}
|
||||||
|
const aufloesung = profileAufloesen(auswahl.split(","), profile, distro.family, eingaben);
|
||||||
|
if (!aufloesung.ok) {
|
||||||
|
console.error(aufloesung.fehler);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const adapter = adapterFuer(distro.family)!;
|
||||||
|
const { skript } = profileRendern(
|
||||||
|
aufloesung.profile,
|
||||||
|
adapter,
|
||||||
|
{
|
||||||
|
os: distro.os,
|
||||||
|
gast: { name: "testgast", benutzer: "florian" },
|
||||||
|
},
|
||||||
|
[
|
||||||
|
adapter.benutzerAnlegen({
|
||||||
|
name: "florian",
|
||||||
|
sshSchluessel: ["ssh-ed25519 AAAAtest florian@test"],
|
||||||
|
sudo: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
process.stdout.write(skript);
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+28
@@ -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
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user