From d25ec54ef9bbc2f9de32e25c223c1553bd354b33 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 13:19:13 +0200 Subject: [PATCH] feat(lxc): LXC-Container ausrollen, mit oder ohne Node-SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container aus dem Proxmox-Template (neueste passende Version aus aplinfo), unprivilegiert, nesting automatisch für docker. Weil Proxmox keine Exec-API für Container hat, läuft die Provisionierung per SSH: bevorzugt pct exec über den Node, sonst direkt in den Container mit einem Bereitstellungsschlüssel, der am Ende wieder entfernt wird. Auf pve-demo beide Wege geprüft: Debian 13 mit docker per pct exec (hello-world läuft mit nesting allein) und Ubuntu 24.04 per SSH in den Container. Dabei gefunden: chrony scheitert ohne CAP_SYS_TIME (jetzt if: vm), sshd -t auf Ubuntu 24.04 ohne /run/sshd. Closes #009 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + catalog/profiles/base.yaml | 7 +- docs/issues/009-lxc.md | 17 +- docs/issues/README.md | 3 +- docs/katalog.md | 3 +- src/app/(app)/ausrollen/assistent.tsx | 37 ++- src/engine/ablaeufe/lxc-ausrollen.ts | 444 ++++++++++++++++++++++++++ src/engine/ablaeufe/template-bau.ts | 2 +- src/engine/ablaeufe/vm-ausrollen.ts | 2 +- src/engine/adapter/debian.ts | 2 + src/engine/katalog/schema.ts | 5 +- src/engine/profile/rendern.ts | 17 +- src/engine/ssh/verbindung.ts | 20 +- src/server/services/ausrollen.ts | 98 ++++-- src/server/services/bereitstellung.ts | 30 ++ src/server/services/ssh-zugang.ts | 2 +- src/worker/ablaeufe/ausrollen.ts | 40 ++- tests/engine/lxc-ausrollen.test.ts | 227 +++++++++++++ 18 files changed, 893 insertions(+), 67 deletions(-) create mode 100644 src/engine/ablaeufe/lxc-ausrollen.ts create mode 100644 src/server/services/bereitstellung.ts create mode 100644 tests/engine/lxc-ausrollen.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a754f..0c88859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt +- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009) +- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009) ### Geändert ### Behoben +- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009) +- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009) ### Entfernt diff --git a/catalog/profiles/base.yaml b/catalog/profiles/base.yaml index 0a4f358..efb28b4 100644 --- a/catalog/profiles/base.yaml +++ b/catalog/profiles/base.yaml @@ -1,6 +1,7 @@ # Grundausstattung für jeden Gast. Platzhalter: {{ var. }} aus den Variablen unten, # {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution, # {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist. +# Bedingungen: `if: `, dazu fest `if: vm` / `if: lxc`. id: base name: Basis description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung @@ -16,8 +17,12 @@ families: - timezone: "{{ var.zeitzone }}" - locale: "{{ var.locale }}" - keyboard: "{{ var.tastatur }}" - - packages: [chrony, sudo, curl, ca-certificates] + - packages: [sudo, curl, ca-certificates] + # Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts. + - packages: [chrony] + if: vm - service: { name: chrony, enable: true, start: true } + if: vm - unattended_upgrades: true - ssh_harden: { password_auth: false, root_login: false } - packages: [fail2ban] diff --git a/docs/issues/009-lxc.md b/docs/issues/009-lxc.md index cb9a706..f86b3fd 100644 --- a/docs/issues/009-lxc.md +++ b/docs/issues/009-lxc.md @@ -1,21 +1,26 @@ # #009 LXC-Container ausrollen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.3.0 **Labels:** feature, proxmox -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min ## Ziel LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container. ## Aufgaben -- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH) -- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen) +- [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH) +- [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen) ## Akzeptanzkriterien -- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH +- [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt) ## Abhängigkeiten #008 ## Notizen / Entscheidungen -– +- **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs. +- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an. +- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13). +- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab. +- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root. +- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt. diff --git a/docs/issues/README.md b/docs/issues/README.md index a12fb46..aea1ffd 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -12,7 +12,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min | | [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min | | [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min | -| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – | +| [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min | | [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – | | [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – | | [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – | @@ -25,3 +25,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – | | [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – | | [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – | +| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | offen | v0.3.0 | 4 h | – | diff --git a/docs/katalog.md b/docs/katalog.md index 9c19d51..d0258bb 100644 --- a/docs/katalog.md +++ b/docs/katalog.md @@ -99,7 +99,8 @@ families: | `run: ` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt | Jeder Schritt kann `if: ` (oder `if: "!"`) tragen und läuft dann nur, wenn die -Variable wahr bzw. falsch ist. +Variable wahr bzw. falsch ist. Fest eingebaut sind `if: vm` und `if: lxc` – etwa für Zeitsync, die +in einem unprivilegierten Container nicht laufen kann. ### Platzhalter diff --git a/src/app/(app)/ausrollen/assistent.tsx b/src/app/(app)/ausrollen/assistent.tsx index 7599f3e..1590218 100644 --- a/src/app/(app)/ausrollen/assistent.tsx +++ b/src/app/(app)/ausrollen/assistent.tsx @@ -465,7 +465,6 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus className={clsx( "border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4", werte.typ === wert && "border-akzent", - wert === "lxc" && "cursor-not-allowed opacity-50", )} > @@ -555,7 +554,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus autoFocus /> - + + {werte.typ === "lxc" ? ( +
+ + setze({ swapMb: e.target.value })} + /> + + setze({ unprivilegiert: an })} + /> + setze({ nesting: an })} + /> +
+ ) : null} ) : null} diff --git a/src/engine/ablaeufe/lxc-ausrollen.ts b/src/engine/ablaeufe/lxc-ausrollen.ts new file mode 100644 index 0000000..93dddb6 --- /dev/null +++ b/src/engine/ablaeufe/lxc-ausrollen.ts @@ -0,0 +1,444 @@ +import { AblaufFehler } from "@/engine/ablauf/fehler"; +import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf"; +import { adapterFuer } from "@/engine/adapter"; +import { templateWaehlen } from "@/engine/katalog/lxc-template"; +import type { Distribution } from "@/engine/katalog/schema"; +import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; +import { MARKE, profileRendern } from "@/engine/profile/rendern"; +import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; +import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; +import { q } from "@/engine/shell"; +import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung"; +import type { AusrollAnfrage } from "@/lib/schemas/ausrollen"; +import type { NodeBefehl } from "./template-bau"; + +/** + * Einen LXC-Container ausrollen. Anlegen, Netz und Start gehen per API; Befehle im Container + * nicht – Proxmox hat dafür keine API (geprüft gegen das Schema von PVE 9.2). Provisioniert wird + * deshalb per SSH, auf einem von zwei Wegen: + * + * - **Node**: `pct exec -- bash -s` über SSH zum Node. Braucht keinen Netzweg zum Container. + * - **Gast**: SSH als root direkt in den Container mit dem ProxDeploy-Bereitstellungsschlüssel, den + * das Anlegen per `ssh-public-keys` hinterlegt; am Ende wird er wieder entfernt. + */ +export type Bereitstellungsschluessel = { privat: string; oeffentlich: string }; + +export type LxcAusrollEingabe = { + client: ProxmoxClient; + anfrage: AusrollAnfrage; + distribution: Distribution; + profile: AufgeloestesProfil[]; + /** Container-Features, die Profile verlangen (z. B. docker → nesting). */ + profilFeatures: { nesting: boolean; keyctl: boolean }; + vmidReservieren: (belegt: Set, wunsch?: number) => Promise; + sshFuer: (knoten: string) => NodeBefehl | null; + bereitstellung: () => Promise; + angelegt: (eintrag: Record) => Promise; + auftragKurzId: string; + warten?: (ms: number) => Promise; + netzMs?: number; + /** Nur für Tests: SSH in den Gast ersetzen. */ + gastSsh?: ( + ziel: { host: string; schluessel: Bereitstellungsschluessel }, + skript: string, + beiAusgabe: (t: string) => void, + warten: (ms: number) => Promise, + ) => Promise; +}; + +export type LxcAusrollStand = { + knoten?: string; + vmid?: number; + ostemplate?: string; + vztmplStorage?: string; + ip?: string; + weg?: "node" | "gast"; +}; + +type Ressource = { + vmid?: number; + node?: string; + type?: string; + status?: string; + maxmem?: number; + mem?: number; +}; +type StorageEintrag = { storage: string; content: string; active?: number; enabled?: number }; +type Aplinfo = { template: string; section?: string; package?: string }; + +const schlafen = (ms: number) => new Promise((erfuellen) => setTimeout(erfuellen, ms)); + +/** Markenzeilen der Provisionierung in Detail und Log übersetzen – für beide Wege gleich. */ +export function ausgabeVerfolgen(k: PhasenKontext) { + let rest = ""; + let profil = ""; + const beginn = Date.now(); + return { + get profil() { + return profil; + }, + weiter(text: string) { + rest += text; + const zeilen = rest.split("\n"); + rest = zeilen.pop() ?? ""; + for (const zeile of zeilen) { + const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile); + if (marke) { + profil = marke[1]!; + const s = Math.round((Date.now() - beginn) / 1000); + void k.detail( + `Profil ${profil} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`, + ); + } + void k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug"); + } + }, + }; +} + +export function lxcNetz(netz: AusrollAnfrage["netz"]): string { + return [ + "name=eth0", + `bridge=${netz.bridge}`, + ...(netz.vlan ? [`tag=${netz.vlan}`] : []), + netz.modus === "dhcp" ? "ip=dhcp" : `ip=${netz.ip}`, + ...(netz.modus === "statisch" && netz.gateway ? [`gw=${netz.gateway}`] : []), + "type=veth", + ].join(","); +} + +export function lxcAusrollSchritte(e: LxcAusrollEingabe, stand: LxcAusrollStand = {}): PhasenSchritt[] { + const { client, anfrage: a, distribution: d } = e; + const warten = e.warten ?? schlafen; + const lxc = d.lxc; + const ctPfad = () => pfad`/nodes/${stand.knoten!}/lxc/${stand.vmid!}`; + const features = { + nesting: a.features.nesting || e.profilFeatures.nesting, + keyctl: a.features.keyctl || e.profilFeatures.keyctl, + }; + let bereitstellung: Bereitstellungsschluessel | undefined; + + return [ + { + schluessel: "pruefungen", + titel: "Prüfungen", + ausfuehren: async () => { + if (!lxc) + throw new AblaufFehler( + `${d.name} gibt es nicht als LXC-Container.`, + "Eine andere Distribution wählen.", + ); + const ressourcen = await client.get("/cluster/resources"); + const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online"); + stand.knoten = + a.knoten ?? + [...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0] + ?.node; + if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) { + throw new AblaufFehler(`Node ${stand.knoten ?? "?"} ist nicht online.`, "Anderen Node wählen."); + } + const storages = await client.get(pfad`/nodes/${stand.knoten}/storage`); + const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0; + const ziel = storages.find((s) => s.storage === a.storage); + if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("rootdir")) { + throw new AblaufFehler( + `Storage ${a.storage} nimmt auf ${stand.knoten} keine Container auf.`, + `Geeignet sind: ${ + storages + .filter((s) => aktiv(s) && s.content.includes("rootdir")) + .map((s) => s.storage) + .join(", ") || "keiner" + }.`, + ); + } + stand.vztmplStorage = storages.find( + (s) => aktiv(s) && s.content.split(",").includes("vztmpl"), + )?.storage; + if (!stand.vztmplStorage) + throw new AblaufFehler( + "Kein Storage mit Inhaltstyp „Container-Templates“.", + "Einem Dir-Storage den Inhaltstyp vztmpl geben.", + ); + + const netze = await client.get>( + pfad`/nodes/${stand.knoten}/network`, + ); + const bridge = netze.find( + (n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"), + ); + if (!bridge) + throw new AblaufFehler( + `Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`, + "Andere Bridge wählen.", + ); + if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) { + throw new AblaufFehler( + `Bridge ${a.netz.bridge} ist nicht VLAN-fähig.`, + "„VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.", + ); + } + + // Provisionierung braucht SSH: lieber zum Node (kein Netzweg zum Container nötig). + stand.weg = e.sshFuer(stand.knoten) ? "node" : "gast"; + if (features.keyctl && stand.weg !== "node") { + throw new AblaufFehler( + "Das Feature keyctl darf per API-Token nicht gesetzt werden.", + "SSH zum Node einrichten (dann setzt ProxDeploy es per pct set) oder ohne keyctl ausrollen.", + ); + } + const belegt = new Set( + ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"), + ); + stand.vmid = await e.vmidReservieren(belegt, a.vmid); + await e.angelegt({ knoten: stand.knoten, typ: "LXC" }); + return { + detail: `CTID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""} · Provisionierung über ${stand.weg === "node" ? "pct exec" : "SSH in den Container"}`, + }; + }, + }, + { + schluessel: "lxc-template", + titel: "LXC-Template", + ausfuehren: async () => { + const vorhanden = await client.get>( + pfad`/nodes/${stand.knoten!}/storage/${stand.vztmplStorage!}/content`, + { + content: "vztmpl", + }, + ); + const dateien = vorhanden.map((v) => v.volid.split("/").pop()!); + const lokal = templateWaehlen(lxc!.template_pattern, dateien); + // Was es zum Herunterladen gibt – ist dort eine neuere Version, wird sie geladen. + const verfuegbar = (await client.get(pfad`/nodes/${stand.knoten!}/aplinfo`)).map( + (x) => x.template, + ); + const neueste = templateWaehlen(lxc!.template_pattern, verfuegbar); + if (lokal && (!neueste || lokal === neueste)) { + stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`; + return { uebersprungen: true, detail: `${lokal} vorhanden` }; + } + if (!neueste) { + if (lokal) { + stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`; + return { uebersprungen: true, detail: `${lokal} vorhanden` }; + } + throw new AblaufFehler( + `Kein Container-Template passt zu „${lxc!.template_pattern}“.`, + "Mit „pveam update“ die Liste auf dem Node auffrischen oder das Muster im Katalog prüfen.", + ); + } + await aufgabeAbwarten( + client, + await client.post(pfad`/nodes/${stand.knoten!}/aplinfo`, { + storage: stand.vztmplStorage!, + template: neueste, + }), + { zeitlimitMs: 30 * 60 * 1000 }, + ); + stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${neueste}`; + return { detail: `${neueste} geladen` }; + }, + }, + { + schluessel: "anlegen", + titel: "Anlegen", + ausfuehren: async () => { + if (stand.weg === "gast") bereitstellung = await e.bereitstellung(); + const featureListe = [features.nesting ? "nesting=1" : null].filter(Boolean).join(","); + await aufgabeAbwarten( + client, + await client.post(pfad`/nodes/${stand.knoten!}/lxc`, { + vmid: stand.vmid!, + hostname: a.name, + ostemplate: stand.ostemplate!, + ostype: lxc!.ostype, + rootfs: `${a.storage}:${a.diskGb}`, + cores: a.cores, + memory: a.speicherMb, + swap: a.swapMb, + unprivileged: a.unprivilegiert, + ...(featureListe ? { features: featureListe } : {}), + net0: lxcNetz(a.netz), + ...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}), + ...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}), + // Nur der Bereitstellungsschlüssel kommt an root – der Benutzer bekommt seine eigenen Schlüssel. + ...(bereitstellung ? { "ssh-public-keys": bereitstellung.oeffentlich } : {}), + onboot: a.startBeiBoot, + tags: ["proxdeploy", d.id, ...a.tags].join(";"), + description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`, + }), + { zeitlimitMs: 15 * 60 * 1000 }, + ); + await e.angelegt({ vmid: stand.vmid }); + if (features.keyctl) { + const ssh = e.sshFuer(stand.knoten!)!; + if (!client.trocken) { + const ergebnis = await ssh( + `pct set ${stand.vmid} --features ${q(`nesting=${features.nesting ? 1 : 0},keyctl=1`)}`, + ); + if (ergebnis.code !== 0) + throw new AblaufFehler( + `keyctl ließ sich nicht setzen: ${ergebnis.stderr.trim()}`, + "Rechte des SSH-Benutzers prüfen.", + ); + } + } + return { + detail: `${a.unprivilegiert ? "unprivilegiert" : "privilegiert"} · ${a.cores} CPU · ${a.speicherMb} MB · ${a.diskGb}G${ + features.nesting || features.keyctl + ? ` · ${[features.nesting && "nesting", features.keyctl && "keyctl"].filter(Boolean).join(", ")}` + : "" + }`, + }; + }, + }, + { + schluessel: "start", + titel: "Start", + ausfuehren: async () => { + await aufgabeAbwarten(client, await client.post(`${ctPfad()}/status/start`), { + zeitlimitMs: 5 * 60 * 1000, + }); + return { detail: `CT ${stand.vmid} läuft` }; + }, + }, + { + schluessel: "netzwerk", + titel: "Netzwerk", + ausfuehren: async (k) => { + if (a.netz.modus === "statisch") { + stand.ip = a.netz.ip!.split("/")[0]; + return { detail: `IP ${stand.ip}` }; + } + if (client.trocken) return { detail: "IP per DHCP" }; + const beginn = Date.now(); + const grenze = e.netzMs ?? 2 * 60 * 1000; + while (!stand.ip) { + await k.pruefeAbbruch(); + const schnittstellen = await client.get>( + `${ctPfad()}/interfaces`, + ); + stand.ip = schnittstellen.find((s) => s.name === "eth0" && s.inet)?.inet?.split("/")[0]; + if (stand.ip) break; + if (Date.now() - beginn > grenze) { + throw new AblaufFehler( + "Der Container hat per DHCP keine Adresse bekommen.", + "DHCP auf der Bridge bzw. im VLAN prüfen oder eine statische Adresse wählen.", + ); + } + await k.detail(`wartet auf DHCP … ${Math.round((Date.now() - beginn) / 1000)}s`); + await warten(2000); + } + return { detail: `IP ${stand.ip} per DHCP` }; + }, + }, + { + schluessel: "provisionierung", + titel: "Provisionierung", + ausfuehren: async (k) => { + const adapter = adapterFuer(d.family); + if (!adapter) + throw new AblaufFehler( + `Für die Familie „${d.family}“ gibt es keinen Adapter.`, + "Adapter ergänzen (docs/katalog.md).", + ); + const nachspann = + stand.weg === "gast" + ? [ + `echo '${MARKE} bereitstellung-entfernen'\nsed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys\n`, + ] + : []; + const { skript } = profileRendern( + e.profile, + adapter, + { os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "lxc" } }, + [ + adapter.benutzerAnlegen({ + name: a.zugang.benutzer, + sshSchluessel: a.zugang.sshSchluessel, + sudo: true, + }), + ], + ); + const gesamt = skript + nachspann.join(""); + if (client.trocken) { + await k.log(gesamt, "info"); + return { + detail: `über ${stand.weg === "node" ? "pct exec" : "SSH"}${e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""}`, + }; + } + const verfolgen = ausgabeVerfolgen(k); + let code: number | null; + if (stand.weg === "node") { + const ssh = e.sshFuer(stand.knoten!)!; + code = ( + await ssh(`pct exec ${stand.vmid} -- bash -s`, { + eingabe: gesamt, + zeitlimitMs: 60 * 60 * 1000, + beiAusgabe: (t) => verfolgen.weiter(t), + }) + ).code; + } else { + const ziel = { host: stand.ip!, schluessel: bereitstellung! }; + code = await (e.gastSsh ?? gastSshStandard)(ziel, gesamt, (t) => verfolgen.weiter(t), warten); + } + verfolgen.weiter("\n"); + if (code !== 0) { + throw new AblaufFehler( + `Provisionierung ${verfolgen.profil ? `im Profil ${verfolgen.profil} ` : ""}mit Code ${code} abgebrochen.`, + "Detail-Log ansehen.", + ); + } + return { + detail: + e.profile.length > 0 + ? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet` + : "Benutzer eingerichtet", + }; + }, + }, + ]; +} + +/** + * SSH als root in den frischen Container. Seinen Host-Key kann ProxDeploy vorher nicht kennen – + * er entsteht beim ersten Start. Vertraut wird deshalb dem ersten Schlüssel (TOFU), und das nur für + * diese eine Verbindung; der Bereitstellungsschlüssel wird am Ende des Skripts wieder entfernt. + */ +async function gastSshStandard( + ziel: { host: string; schluessel: Bereitstellungsschluessel }, + skript: string, + beiAusgabe: (t: string) => void, + warten: (ms: number) => Promise = schlafen, +): Promise { + const beginn = Date.now(); + for (;;) { + try { + const ergebnis = await sshAusfuehren( + { + host: ziel.host, + port: 22, + benutzer: "root", + privatSchluessel: ziel.schluessel.privat, + hostSchluessel: null, + }, + "bash -s", + { erkunden: true, eingabe: skript, zeitlimitMs: 60 * 60 * 1000, beiAusgabe }, + ); + return ergebnis.code; + } catch (fehler) { + // sshd braucht nach dem Start ein paar Sekunden. + if (fehler instanceof SshFehler && fehler.art === "verbindung" && Date.now() - beginn < 90_000) { + await warten(3000); + continue; + } + if (fehler instanceof SshFehler) { + throw new AblaufFehler( + `SSH in den Container ${ziel.host} fehlgeschlagen: ${fehler.ursache}`, + "Ist der Container aus dem Netz von ProxDeploy erreichbar? Sonst SSH zum Node einrichten (pct exec).", + ); + } + throw fehler; + } + } +} diff --git a/src/engine/ablaeufe/template-bau.ts b/src/engine/ablaeufe/template-bau.ts index da4b362..de8da10 100644 --- a/src/engine/ablaeufe/template-bau.ts +++ b/src/engine/ablaeufe/template-bau.ts @@ -24,7 +24,7 @@ import { q } from "@/engine/shell"; /** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */ export type NodeBefehl = ( befehl: string, - optionen?: { eingabe?: string; zeitlimitMs?: number }, + optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void }, ) => Promise<{ code: number | null; stdout: string; stderr: string }>; export type TemplateBauEingabe = { diff --git a/src/engine/ablaeufe/vm-ausrollen.ts b/src/engine/ablaeufe/vm-ausrollen.ts index f8eeade..9808f0f 100644 --- a/src/engine/ablaeufe/vm-ausrollen.ts +++ b/src/engine/ablaeufe/vm-ausrollen.ts @@ -369,7 +369,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { const { skript } = profileRendern( e.profile, adapter, - { os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer } }, + { os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } }, [ adapter.benutzerAnlegen({ name: a.zugang.benutzer, diff --git a/src/engine/adapter/debian.ts b/src/engine/adapter/debian.ts index 888c448..70a812f 100644 --- a/src/engine/adapter/debian.ts +++ b/src/engine/adapter/debian.ts @@ -147,6 +147,8 @@ KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"} PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"} PROXDEPLOY_SSH if command -v sshd >/dev/null 2>&1; then + # Ubuntu 24.04 startet sshd per Socket – bis zur ersten Verbindung fehlt /run/sshd, und sshd -t bricht ab. + install -d -m 0755 /run/sshd sshd -t systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true fi diff --git a/src/engine/katalog/schema.ts b/src/engine/katalog/schema.ts index 0be73ae..fd55abe 100644 --- a/src/engine/katalog/schema.ts +++ b/src/engine/katalog/schema.ts @@ -56,7 +56,10 @@ export type Distribution = z.infer; export const VariableSchema = z .object({ - name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."), + name: z + .string() + .regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich.") + .refine((n) => n !== "vm" && n !== "lxc", "vm und lxc sind feste Bedingungen, keine Variablennamen."), label: z.string().min(1), type: z.enum(["string", "number", "boolean", "select"]).default("string"), required: z.boolean().default(false), diff --git a/src/engine/profile/rendern.ts b/src/engine/profile/rendern.ts index 0f307b0..dbf8733 100644 --- a/src/engine/profile/rendern.ts +++ b/src/engine/profile/rendern.ts @@ -14,17 +14,26 @@ export const MARKE = "::proxdeploy::"; export type RenderKontext = { os: { id: string; version_id: string; codename?: string }; - gast: { name: string; benutzer: string }; + gast: { name: string; benutzer: string; typ?: "vm" | "lxc" }; }; +/** Feste Bedingungen neben den Profil-Variablen: `if: vm` / `if: lxc` (bzw. `!vm`, `!lxc`). */ +export const FESTE_BEDINGUNGEN = ["vm", "lxc"] as const; + export type Abschnitt = { profil: string; skript: string }; export type Gerendert = { skript: string; abschnitte: Abschnitt[] }; -function bedingungErfuellt(schritt: ProfilSchritt, werte: Record): boolean { +function bedingungErfuellt( + schritt: ProfilSchritt, + werte: Record, + typ: "vm" | "lxc" | undefined, +): boolean { if (!schritt.if) return true; const verneint = schritt.if.startsWith("!"); - const wert = Boolean(werte[schritt.if.replace(/^!/, "")]); + const name = schritt.if.replace(/^!/, ""); + // Ohne bekannten Typ (Rendern zum Ansehen) laufen typabhängige Schritte mit. + const wert = name === "vm" || name === "lxc" ? (typ ? typ === name : !verneint) : Boolean(werte[name]); return verneint ? !wert : wert; } @@ -87,7 +96,7 @@ export function profileRendern( }; const teile: string[] = []; for (const schritt of profil.families[adapter.familie] ?? []) { - if (!bedingungErfuellt(schritt, werte)) continue; + if (!bedingungErfuellt(schritt, werte, kontext.gast.typ)) continue; const text = schrittRendern(schritt, adapter, vorlagenKontext); if ("ssh_harden" in schritt) zumSchluss.push(text); else teile.push(text); diff --git a/src/engine/ssh/verbindung.ts b/src/engine/ssh/verbindung.ts index 1c111c1..e2f5575 100644 --- a/src/engine/ssh/verbindung.ts +++ b/src/engine/ssh/verbindung.ts @@ -40,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string { export function sshAusfuehren( ziel: SshZiel, befehl: string, - optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {}, + optionen: { + zeitlimitMs?: number; + eingabe?: string; + erkunden?: boolean; + /** Laufende Ausgabe (stdout und stderr), z. B. für Live-Fortschritt einer Provisionierung. */ + beiAusgabe?: (text: string) => void; + } = {}, ): Promise { if (!ziel.hostSchluessel && !optionen.erkunden) { return Promise.reject( @@ -95,8 +101,16 @@ export function sshAusfuehren( let stdout = ""; let stderr = ""; let code: number | null = null; - kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8"))); - kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8"))); + kanal.on("data", (teil: Buffer) => { + const text = teil.toString("utf8"); + stdout += text; + optionen.beiAusgabe?.(text); + }); + kanal.stderr.on("data", (teil: Buffer) => { + const text = teil.toString("utf8"); + stderr += text; + optionen.beiAusgabe?.(text); + }); kanal.on("exit", (exitCode: number | null) => (code = exitCode)); kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen })); if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe); diff --git a/src/server/services/ausrollen.ts b/src/server/services/ausrollen.ts index 3912d72..407b3af 100644 --- a/src/server/services/ausrollen.ts +++ b/src/server/services/ausrollen.ts @@ -1,6 +1,8 @@ import type { Server } from "@/generated/prisma/client"; import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; +import { lxcAusrollSchritte, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen"; import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen"; +import type { PhasenSchritt } from "@/engine/ablauf/ablauf"; import type { Distribution, Profil } from "@/engine/katalog/schema"; import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client"; @@ -11,6 +13,7 @@ import { AnwendungsFehler } from "@/server/fehler"; import { auftragAnlegen } from "@/server/services/auftraege"; import { serverHolen, verbindungAus } from "@/server/services/server"; import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; +import { bereitstellungsschluessel } from "@/server/services/bereitstellung"; import { vmidVorschlagen } from "@/server/services/vmid"; /** @@ -44,14 +47,6 @@ export async function ausrollenVorbereiten(roh: unknown): Promise { `${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`, ); } - if (anfrage.typ === "lxc") { - throw new AnwendungsFehler( - 400, - "nicht_umgesetzt", - "LXC-Container folgen mit #009.", - "Vorerst als VM ausrollen.", - ); - } const verfuegbar = new Map( (await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [ p.id, @@ -92,6 +87,66 @@ export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: str ); } +export type AusrollStand = VmAusrollStand & LxcAusrollStand; + +/** Container-Features, die die gewählten Profile verlangen (z. B. docker → nesting). */ +export function profilFeatures(profile: AufgeloestesProfil[]): { nesting: boolean; keyctl: boolean } { + return { + nesting: profile.some((p) => p.profil.lxc.features?.nesting), + keyctl: profile.some((p) => p.profil.lxc.features?.keyctl), + }; +} + +/** + * Die Phasen für VM oder LXC – dieselben im Trockenlauf und im worker. Was sich unterscheidet + * (reservieren oder nur vorschlagen, Auftrags-ID), kommt von außen. + */ +export function ausrollSchritte( + v: Vorbereitet, + o: { + client: ProxmoxClient; + vmidReservieren: (belegt: Set, wunsch?: number) => Promise; + templateVmidReservieren: (belegt: Set) => Promise; + angelegt: (eintrag: Record) => Promise; + auftragKurzId: string; + }, + stand: AusrollStand, +): PhasenSchritt[] { + const sshFuer = (knoten: string) => nodeBefehlFuer(v.server, knoten); + if (v.anfrage.typ === "lxc") { + return lxcAusrollSchritte( + { + client: o.client, + anfrage: v.anfrage, + distribution: v.distribution, + profile: v.profile, + profilFeatures: profilFeatures(v.profile), + vmidReservieren: o.vmidReservieren, + sshFuer, + bereitstellung: bereitstellungsschluessel, + angelegt: o.angelegt, + auftragKurzId: o.auftragKurzId, + }, + stand, + ); + } + return vmAusrollSchritte( + { + client: o.client, + anfrage: v.anfrage, + distribution: v.distribution, + profile: v.profile, + vmidReservieren: o.vmidReservieren, + templateBau: v.anfrage.templateBauen + ? { sshFuer, auftragKurzId: o.auftragKurzId, vmidReservieren: o.templateVmidReservieren } + : undefined, + angelegt: o.angelegt, + auftragKurzId: o.auftragKurzId, + }, + stand, + ); +} + export type Plan = { kopf: string; phasen: Array<{ @@ -138,25 +193,17 @@ export async function ausrollenPlanen(roh: unknown): Promise { if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht; }, }; - const stand: VmAusrollStand = {}; + const stand: AusrollStand = {}; try { const ergebnis = await phasenAusfuehren( - vmAusrollSchritte( + ausrollSchritte( + v, { client, - anfrage: v.anfrage, - distribution: v.distribution, - profile: v.profile, vmidReservieren: (belegt, wunsch) => vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }), - templateBau: v.anfrage.templateBauen - ? { - sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten), - auftragKurzId: "dry-run", - vmidReservieren: (belegt) => - vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }), - } - : undefined, + templateVmidReservieren: (belegt) => + vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }), angelegt: async () => {}, auftragKurzId: "dry-run", }, @@ -178,11 +225,16 @@ export async function ausrollenPlanen(roh: unknown): Promise { } } -export function kopfzeile(v: Pick, stand: VmAusrollStand): string { +export function kopfzeile( + v: Pick, + stand: AusrollStand, +): string { return [ `Server ${v.server.name}`, stand.knoten ? `Node ${stand.knoten}` : null, - `${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`, + v.anfrage.typ === "lxc" + ? `${v.distribution.name} (LXC)` + : `${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`, ] .filter(Boolean) .join(" · "); diff --git a/src/server/services/bereitstellung.ts b/src/server/services/bereitstellung.ts new file mode 100644 index 0000000..1fab88f --- /dev/null +++ b/src/server/services/bereitstellung.ts @@ -0,0 +1,30 @@ +import ssh2 from "ssh2"; +import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen"; +import { prisma } from "@/lib/db"; +import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; + +/** + * Der Bereitstellungsschlüssel: damit meldet sich ProxDeploy als root in frisch angelegten + * Containern an, wenn es keinen SSH-Zugang zum Node hat. Er steht nur während der + * Provisionierung in /root/.ssh/authorized_keys und wird am Ende des Skripts entfernt (am + * Kommentar `proxdeploy-bereitstellung` erkennbar). Einmal erzeugt, privat nur verschlüsselt. + */ +const SCHLUESSEL = "bereitstellungsschluessel"; +export const KOMMENTAR = "proxdeploy-bereitstellung"; + +export async function bereitstellungsschluessel(): Promise { + const vorhanden = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } }); + if (vorhanden) { + const wert = vorhanden.wert as { privat: string; oeffentlich: string }; + return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich }; + } + const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR }); + const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() }; + // Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel. + await prisma.einstellung.upsert({ + where: { schluessel: SCHLUESSEL }, + create: { schluessel: SCHLUESSEL, wert: neu }, + update: {}, + }); + return await bereitstellungsschluessel(); +} diff --git a/src/server/services/ssh-zugang.ts b/src/server/services/ssh-zugang.ts index 0ba62e2..8d303d3 100644 --- a/src/server/services/ssh-zugang.ts +++ b/src/server/services/ssh-zugang.ts @@ -29,6 +29,6 @@ export function nodeBefehlFuer(server: Server, knoten: string): NodeBefehl | nul hostSchluessel: pin, }, befehl, - { eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs }, + { eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs, beiAusgabe: optionen.beiAusgabe }, ); } diff --git a/src/worker/ablaeufe/ausrollen.ts b/src/worker/ablaeufe/ausrollen.ts index c45d077..85740fa 100644 --- a/src/worker/ablaeufe/ausrollen.ts +++ b/src/worker/ablaeufe/ausrollen.ts @@ -1,11 +1,15 @@ import { phasenAusfuehren } from "@/engine/ablauf/ablauf"; -import { sshBefehl, vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen"; +import { sshBefehl } from "@/engine/ablaeufe/vm-ausrollen"; import { ProxmoxClient } from "@/engine/proxmox/client"; import { prisma } from "@/lib/db"; import { melden } from "@/server/ereignisse/senke"; -import { ausrollenVorbereiten, kopfzeile } from "@/server/services/ausrollen"; +import { + ausrollenVorbereiten, + ausrollSchritte, + kopfzeile, + type AusrollStand, +} from "@/server/services/ausrollen"; import { verbindungAus } from "@/server/services/server"; -import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; import { vmidReservieren } from "@/server/services/vmid"; import { ablaufRegistrieren } from "../ausfuehren"; import { aufrufLogger } from "../aufruf-log"; @@ -13,7 +17,7 @@ import { aufrufLogger } from "../aufruf-log"; ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => { // Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben. const v = await ausrollenVorbereiten(auftrag.anfrage); - const stand: VmAusrollStand = {}; + const stand: AusrollStand = {}; const kopfSetzen = async () => { await prisma.auftrag.update({ where: { id: auftrag.id }, @@ -26,12 +30,10 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) }); try { const ergebnis = await phasenAusfuehren( - vmAusrollSchritte( + ausrollSchritte( + v, { client, - anfrage: v.anfrage, - distribution: v.distribution, - profile: v.profile, vmidReservieren: async (belegt, wunsch) => { const vmid = await vmidReservieren({ serverId: v.server.id, @@ -44,20 +46,14 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange await kopfSetzen(); return vmid; }, - templateBau: v.anfrage.templateBauen - ? { - sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten), - auftragKurzId: auftrag.kurzId, - vmidReservieren: (belegt) => - vmidReservieren({ - serverId: v.server.id, - auftragId: auftrag.id, - belegt, - von: v.server.templateVmidVon, - bis: v.server.templateVmidBis, - }), - } - : undefined, + templateVmidReservieren: (belegt) => + vmidReservieren({ + serverId: v.server.id, + auftragId: auftrag.id, + belegt, + von: v.server.templateVmidVon, + bis: v.server.templateVmidBis, + }), angelegt, auftragKurzId: auftrag.kurzId, }, diff --git a/tests/engine/lxc-ausrollen.test.ts b/tests/engine/lxc-ausrollen.test.ts new file mode 100644 index 0000000..846f2ac --- /dev/null +++ b/tests/engine/lxc-ausrollen.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; +import { lxcAusrollSchritte, lxcNetz, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen"; +import type { NodeBefehl } from "@/engine/ablaeufe/template-bau"; +import { katalogLaden } from "@/engine/katalog/laden"; +import { profileAufloesen } from "@/engine/profile/aufloesen"; +import { ProxmoxClient } from "@/engine/proxmox/client"; +import { AusrollAnfrage } from "@/lib/schemas/ausrollen"; +import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock"; +import { szenarioEinrichten } from "../proxmox-mock/szenario"; + +const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`; +const SCHLUESSEL = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test"; +const stumm: EreignisSenke = { + phasenAnlegen: async () => {}, + phaseGestartet: async () => {}, + phaseDetail: async () => {}, + phaseBeendet: async () => {}, + log: async () => {}, +}; +const BEREIT = { privat: "privat", oeffentlich: "ssh-ed25519 AAAAbereit proxdeploy-bereitstellung" }; + +const aufraeumen: Array<() => Promise> = []; +afterEach(async () => { + while (aufraeumen.length) await aufraeumen.pop()!(); +}); + +async function aufbauen(o: { templateLokal?: string[]; dhcpNachAbfragen?: number; trocken?: boolean } = {}) { + const mock = await new ProxmoxMock().starten(); + szenarioEinrichten(mock, { version: "9.2.1" }); + let interfaceAbfragen = 0; + mock + .route("GET", "/cluster/resources", () => ({ + data: [{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 1e9 }, { vmid: 100 }], + })) + .route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({ + data: (o.templateLokal ?? []).map((t) => ({ volid: `local:vztmpl/${t}` })), + })) + .route("GET", "/nodes/pve01/aplinfo", () => ({ + data: [ + { template: "debian-13-standard_13.1-2_amd64.tar.zst" }, + { template: "debian-13-standard_13.6-1_amd64.tar.zst" }, + { template: "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" }, + ], + })) + .route("POST", "/nodes/pve01/aplinfo", () => ({ data: UPID("download") })) + .route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } })) + .route("POST", "/nodes/pve01/lxc", () => ({ data: UPID("vzcreate") })) + .route("POST", /^\/nodes\/pve01\/lxc\/\d+\/status\/start$/, () => ({ data: UPID("vzstart") })) + .route("GET", /^\/nodes\/pve01\/lxc\/\d+\/interfaces$/, () => ({ + data: [ + { name: "lo", inet: "127.0.0.1/8" }, + { + name: "eth0", + ...(++interfaceAbfragen > (o.dhcpNachAbfragen ?? 0) ? { inet: "10.0.10.77/24" } : {}), + }, + ], + })); + const client = new ProxmoxClient( + { + url: mock.url, + tokenId: MOCK_TOKEN_ID, + tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, + tlsPruefen: false, + fingerabdruck: mock.fingerabdruck, + }, + { trocken: o.trocken }, + ); + aufraeumen.push(async () => { + await client.schliessen(); + await mock.stoppen(); + }); + return { mock, client }; +} + +async function eingabe( + client: ProxmoxClient, + teil: Record = {}, + ssh: NodeBefehl | null = null, +) { + const katalog = await katalogLaden("catalog"); + const anfrage = AusrollAnfrage.parse({ + typ: "lxc", + server: "rz1", + distribution: "debian-13", + name: "ct01", + storage: "local-zfs", + diskGb: 8, + netz: { bridge: "vmbr0", modus: "dhcp" }, + zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] }, + profile: ["docker"], + ...teil, + }); + const distribution = katalog.distributionen.find((d) => d.eintrag.id === anfrage.distribution)!.eintrag; + const aufgeloest = profileAufloesen( + anfrage.profile, + new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])), + "debian", + ); + if (!aufgeloest.ok) throw new Error("Profile"); + const gast: Array<{ host: string; skript: string }> = []; + return { + gast, + e: { + client, + anfrage, + distribution, + profile: aufgeloest.profile, + profilFeatures: { + nesting: aufgeloest.profile.some((p) => p.profil.lxc.features?.nesting), + keyctl: false, + }, + vmidReservieren: async () => 130, + sshFuer: () => ssh, + bereitstellung: async () => BEREIT, + angelegt: async () => {}, + auftragKurzId: "d-ct", + warten: async () => {}, + gastSsh: async (ziel: { host: string }, skript: string, beiAusgabe: (t: string) => void) => { + gast.push({ host: ziel.host, skript }); + beiAusgabe("::proxdeploy:: profil base\n::proxdeploy:: fertig\n"); + return 0; + }, + }, + }; +} + +describe("LXC ausrollen", () => { + it("mit Node-SSH: neuestes Template laden, anlegen mit nesting (docker), DHCP-IP, pct exec mit Skript auf stdin", async () => { + const { mock, client } = await aufbauen({ dhcpNachAbfragen: 2 }); + const befehle: Array<{ befehl: string; eingabe?: string }> = []; + const ssh: NodeBefehl = async (befehl, o) => { + befehle.push({ befehl, eingabe: o?.eingabe }); + o?.beiAusgabe?.("::proxdeploy:: profil base\n"); + return { code: 0, stdout: "", stderr: "" }; + }; + const { e } = await eingabe(client, {}, ssh); + const stand: LxcAusrollStand = {}; + expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); + expect(stand).toMatchObject({ + weg: "node", + vmid: 130, + ip: "10.0.10.77", + ostemplate: "local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst", + }); + + expect( + Object.fromEntries( + mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")!.rumpf, + ), + ).toEqual({ + storage: "local", + template: "debian-13-standard_13.6-1_amd64.tar.zst", + }); + const anlegen = Object.fromEntries( + mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf, + ); + expect(anlegen).toMatchObject({ + vmid: "130", + hostname: "ct01", + rootfs: "local-zfs:8", + unprivileged: "1", + features: "nesting=1", + net0: "name=eth0,bridge=vmbr0,ip=dhcp,type=veth", + ostype: "debian", + }); + expect(anlegen).not.toHaveProperty("ssh-public-keys"); + expect(befehle).toHaveLength(1); + expect(befehle[0]!.befehl).toBe("pct exec 130 -- bash -s"); + expect(befehle[0]!.eingabe).toContain("useradd -m -s /bin/bash florian"); + // Zeitsync nur in VMs. + expect(befehle[0]!.eingabe).not.toContain("chrony"); + }); + + it("vorhandenes neuestes Template wird nicht erneut geladen", async () => { + const { mock, client } = await aufbauen({ templateLokal: ["debian-13-standard_13.6-1_amd64.tar.zst"] }); + const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" })); + await phasenAusfuehren(lxcAusrollSchritte(e), stumm); + expect(mock.anfragen.some((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")).toBe(false); + }); + + it("ohne Node-SSH: Bereitstellungsschlüssel an root, SSH in den Container, Schlüssel am Ende entfernen", async () => { + const { mock, client } = await aufbauen(); + const { e, gast } = await eingabe(client, { + netz: { bridge: "vmbr0", modus: "statisch", ip: "10.0.20.5/24", gateway: "10.0.20.1" }, + }); + const stand: LxcAusrollStand = {}; + expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); + expect(stand.weg).toBe("gast"); + const anlegen = Object.fromEntries( + mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf, + ); + expect(anlegen["ssh-public-keys"]).toBe(BEREIT.oeffentlich); + expect(anlegen["net0"]).toBe("name=eth0,bridge=vmbr0,ip=10.0.20.5/24,gw=10.0.20.1,type=veth"); + expect(gast[0]!.host).toBe("10.0.20.5"); + expect( + gast[0]!.skript.trimEnd().endsWith("sed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys"), + ).toBe(true); + }); + + it("keyctl ohne Node-SSH wird vor dem Anlegen abgelehnt", async () => { + const { mock, client } = await aufbauen(); + const { e } = await eingabe(client, { features: { nesting: true, keyctl: true } }); + const ergebnis = await phasenAusfuehren(lxcAusrollSchritte(e), stumm); + expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/keyctl/); + expect(mock.anfragen.some((a) => a.pfad === "/nodes/pve01/lxc")).toBe(false); + }); + + it("Netzkonfiguration mit VLAN", () => { + expect(lxcNetz({ bridge: "vmbr1", vlan: 30, modus: "dhcp", dns: [] })).toBe( + "name=eth0,bridge=vmbr1,tag=30,ip=dhcp,type=veth", + ); + }); + + it("Trockenlauf: Plan ohne Schreibzugriffe", async () => { + const { mock, client } = await aufbauen({ trocken: true }); + const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" })); + expect(await phasenAusfuehren(lxcAusrollSchritte(e), stumm)).toEqual({ ok: true }); + expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]); + expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([ + "POST /nodes/pve01/aplinfo", + "POST /nodes/pve01/lxc", + "POST /nodes/pve01/lxc/130/status/start", + ]); + }); +});