diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c88859..7843276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt +- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022) +- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011) +- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012) +- Ubuntu 22.04/24.04/26.04 als VM und LXC; Fortschritt beim Klonen aus dem Task-Log (#010) - 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) diff --git a/docs/issues/022-template-profile.md b/docs/issues/022-template-profile.md index 62d1996..2be4b74 100644 --- a/docs/issues/022-template-profile.md +++ b/docs/issues/022-template-profile.md @@ -1,26 +1,30 @@ # #022 Templates mit eingebackenen Profilen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.3.0 **Labels:** feature, proxmox -**Geschätzt:** 4 h **Tatsächlich:** – +**Geschätzt:** 4 h **Tatsächlich:** 0 h 35 min ## Ziel Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller fertig sind. Wunsch von Florian am 2026-10-12. ## Aufgaben -- [ ] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten) -- [ ] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment) -- [ ] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen -- [ ] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker` -- [ ] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen -- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template (Standard bleibt: Schlüssel je Deployment, siehe #015) +- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten) +- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment) +- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen +- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker` +- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen +- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015) ## Akzeptanzkriterien -- [ ] Template debian-13 + docker auf pve-demo; Deploy daraus mit docker deutlich schneller als ohne +- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker ## Abhängigkeiten #007, #008 ## Notizen / Entscheidungen - SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen). +- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat. +- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys. +- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software. +- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template). diff --git a/docs/issues/README.md b/docs/issues/README.md index 6bf3d60..ef8fadf 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -25,4 +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 | – | +| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 35 min | diff --git a/prisma/migrations/20261012114619_template_profile/migration.sql b/prisma/migrations/20261012114619_template_profile/migration.sql new file mode 100644 index 0000000..d031613 --- /dev/null +++ b/prisma/migrations/20261012114619_template_profile/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "profile" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a774e0e..e8eb052 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -271,6 +271,8 @@ model Template { storage String imageDatei String pruefsumme String + /// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen. + profile String[] @default([]) gebautVonAuftragId String? gebautAm DateTime @default(now()) diff --git a/src/app/(app)/templates/page.tsx b/src/app/(app)/templates/page.tsx index 2f867cb..1a0e5b3 100644 --- a/src/app/(app)/templates/page.tsx +++ b/src/app/(app)/templates/page.tsx @@ -10,7 +10,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl"; import { darf } from "@/server/auth/rechte"; import { seiteSchuetzen } from "@/server/auth/zugang"; import { AnwendungsFehler } from "@/server/fehler"; -import { distributionenListe } from "@/server/services/katalog"; +import { distributionenListe, profileListe } from "@/server/services/katalog"; import { templatesListe, type TemplateSicht } from "@/server/services/templates"; import { TemplateBauen } from "./template-bauen"; @@ -48,6 +48,7 @@ export default async function TemplatesSeite() { const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({ id: d.id, name: d.name, + familie: d.familie, })); return ( @@ -83,6 +84,7 @@ export default async function TemplatesSeite() { {" "} · {t.knoten} {t.distributionId ? ` · ${t.distributionId}` : ""} + {t.profile.length > 0 ? ` · mit ${t.profile.join(", ")}` : ""} @@ -110,6 +112,14 @@ export default async function TemplatesSeite() { bridges: k.bridges.map((b) => b.name), }))} faehigkeit={bericht.faehigkeiten.templateBau} + profile={(await profileListe({ nurAktive: true })) + .filter((p) => !p.definition.variables.some((v) => v.required && v.default === undefined)) + .map((p) => ({ + id: p.id, + name: p.name, + requires: p.definition.requires, + familien: p.familien, + }))} /> ) : ( diff --git a/src/app/(app)/templates/template-bauen.tsx b/src/app/(app)/templates/template-bauen.tsx index 65934c7..4516634 100644 --- a/src/app/(app)/templates/template-bauen.tsx +++ b/src/app/(app)/templates/template-bauen.tsx @@ -14,9 +14,11 @@ export function TemplateBauen({ distributionen, knoten, faehigkeit, + profile, }: { server: string; - distributionen: Array<{ id: string; name: string }>; + distributionen: Array<{ id: string; name: string; familie: string }>; + profile: Array<{ id: string; name: string; requires: string[]; familien: string[] }>; knoten: Knoten[]; faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string }; }) { @@ -26,6 +28,7 @@ export function TemplateBauen({ knoten: knoten[0]?.name ?? "", storage: knoten[0]?.storages[0] ?? "", bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""), + profile: [] as string[], }); const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null); const [sendet, setSendet] = useState(false); @@ -118,6 +121,43 @@ export function TemplateBauen({ +
+ Profile einbacken (optional) +

+ Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind + schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen. +

+
+ {profile + .filter((p) => + p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""), + ) + .map((p) => { + const benoetigt = profile.some( + (x) => werte.profile.includes(x.id) && x.requires.includes(p.id), + ); + return ( + + ); + })} +
+

Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu installieren. diff --git a/src/engine/ablaeufe/gastagent.ts b/src/engine/ablaeufe/gastagent.ts new file mode 100644 index 0000000..18718ff --- /dev/null +++ b/src/engine/ablaeufe/gastagent.ts @@ -0,0 +1,129 @@ +import { AblaufFehler } from "@/engine/ablauf/fehler"; +import type { PhasenKontext } from "@/engine/ablauf/ablauf"; +import { MARKE } from "@/engine/profile/rendern"; +import type { Parameter, ProxmoxClient } from "@/engine/proxmox/client"; +import { ProxmoxFehler } from "@/engine/proxmox/fehler"; + +/** + * Provisionierung über den QEMU-Guest-Agent – gemeinsam für Deployments (#008) und Templates mit + * eingebackenen Profilen (#022). Kein SSH nötig. + * + * Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt, + * ping und network-get-interfaces → {result}. + */ +export const SKRIPT = "/root/proxdeploy-provisionierung.sh"; +export const LOG = "/var/log/proxdeploy-provisionierung.log"; + +/** + * file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst + * als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in + * subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64. + */ +const STUECK = 40 * 1024; + +type ExecStatus = { exited: number | boolean; exitcode?: number }; + +export function agent(client: ProxmoxClient, vmPfad: string) { + return async (befehl: string, methode: "GET" | "POST" = "POST", parameter?: Parameter) => + await client.aufrufen({ methode, pfad: `${vmPfad}/agent/${befehl}`, parameter }); +} + +/** Wartet, bis der Agent antwortet; liefert die Wartezeit in ms. */ +export async function agentAbwarten( + client: ProxmoxClient, + vmPfad: string, + k: PhasenKontext, + optionen: { grenzeMs: number; warten: (ms: number) => Promise }, +): Promise { + const rufen = agent(client, vmPfad); + const beginn = Date.now(); + for (;;) { + await k.pruefeAbbruch(); + try { + await rufen("ping"); + return Date.now() - beginn; + } catch (fehler) { + if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler; + if (Date.now() - beginn > optionen.grenzeMs) { + throw new AblaufFehler( + `Der Guest-Agent antwortet nach ${Math.round(optionen.grenzeMs / 1000)} s nicht.`, + "Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?", + ); + } + } + await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`); + await optionen.warten(3000); + } +} + +/** + * Überträgt ein Skript in Stücken (file-write ersetzt, hängt nicht an), führt es aus und zeigt den + * Fortschritt aus der Logdatei im Gast (Marken `::proxdeploy:: profil `). + */ +export async function skriptAusfuehren( + client: ProxmoxClient, + vmPfad: string, + skript: string, + k: PhasenKontext, + optionen: { grenzeMs: number; warten: (ms: number) => Promise }, +): Promise { + const rufen = agent(client, vmPfad); + const bytes = Buffer.from(skript, "utf8"); + const stuecke: Buffer[] = []; + for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK)); + for (const [nr, inhalt] of stuecke.entries()) { + await rufen("file-write", "POST", { + file: `${SKRIPT}.${nr}`, + content: inhalt.toString("base64"), + encode: "0", + }); + } + const teile = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" "); + const { pid } = await rufen<{ pid: number }>("exec", "POST", { + command: [ + "/bin/bash", + "-c", + `cat ${teile} > ${SKRIPT} && rm -f ${teile} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`, + ], + }); + + const beginn = Date.now(); + let gelesen = 0; + let profil = ""; + for (;;) { + await optionen.warten(3000); + const status = await rufen("exec-status", "GET", { pid: String(pid) }); + try { + const datei = await rufen<{ content: string }>("file-read", "GET", { file: LOG }); + const zeilen = datei.content.split("\n"); + for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) { + const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile); + if (marke) profil = marke[1]!; + await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug"); + } + gelesen = Math.max(gelesen, zeilen.length - 1); + } catch { + // Logdatei noch nicht da – beim nächsten Mal. + } + if (status.exited) { + if (status.exitcode !== 0) { + throw new AblaufFehler( + `Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`, + `Detail-Log oder ${LOG} in der VM ansehen.`, + ); + } + break; + } + if (Date.now() - beginn > optionen.grenzeMs) { + throw new AblaufFehler( + `Provisionierung läuft länger als ${Math.round(optionen.grenzeMs / 60000)} min.`, + `${LOG} in der VM ansehen.`, + ); + } + const s = Math.round((Date.now() - beginn) / 1000); + await k.detail( + `${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`, + ); + } + await rufen("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined); +} diff --git a/src/engine/ablaeufe/template-bau.ts b/src/engine/ablaeufe/template-bau.ts index de8da10..2fddc90 100644 --- a/src/engine/ablaeufe/template-bau.ts +++ b/src/engine/ablaeufe/template-bau.ts @@ -6,6 +6,10 @@ import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten"; import { q } from "@/engine/shell"; +import { adapterFuer } from "@/engine/adapter"; +import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; +import { profileRendern } from "@/engine/profile/rendern"; +import { agentAbwarten, skriptAusfuehren } from "./gastagent"; /** * Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent @@ -30,6 +34,8 @@ export type NodeBefehl = ( export type TemplateBauEingabe = { client: ProxmoxClient; distribution: Distribution; + /** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */ + profile?: AufgeloestesProfil[]; knoten: string; /** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */ storage: string; @@ -80,10 +86,28 @@ async function standardHolen(url: string): Promise { return await antwort.text(); } -export function templateName(distribution: Distribution): string { - return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`; +export function templateName(distribution: Distribution, profile: string[] = []): string { + return ["tpl", distribution.id, ...profile.filter((p) => p !== "base")] + .join("-") + .replace(/[^a-z0-9-]/g, "-"); } +/** + * Aufräumen vor dem Template, wenn Profile eingebacken wurden – alles, was je Klon eindeutig sein + * muss oder nur Platz kostet. SSH-Host-Keys erzeugt cloud-init bei jedem Klon neu. + */ +export const TEMPLATE_AUFRAEUMEN = [ + "set -e", + "truncate -s 0 /etc/machine-id", + "rm -f /var/lib/dbus/machine-id", + "rm -f /etc/ssh/ssh_host_*", + "rm -f /var/lib/docker/engine-id", + "apt-get clean || true", + "rm -f /var/log/proxdeploy-provisionierung.log", + "cloud-init clean --logs", + "sync", +].join("; "); + /** * vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst * teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten. @@ -118,6 +142,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta const holen = e.holen ?? standardHolen; const warten = e.warten ?? schlafen; const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`; + const profilIds = (e.profile ?? []).map((p) => p.profil.id); + const name = templateName(d, profilIds); const ssh = async ( befehl: string, @@ -294,7 +320,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`; const parameter = { vmid, - name: templateName(d), + name, ostype: "l26", machine: "q35", bios: vm.bios, @@ -310,8 +336,10 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta ide2: `${e.storage}:cloudinit`, ipconfig0: "ip=dhcp", ciupgrade: false, - tags: `proxdeploy-template;${d.id}`, - description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`, + tags: ["proxdeploy-template", d.id, ...profilIds.map((p) => `profil-${p}`)].join(";"), + description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}${ + profilIds.length > 0 ? `\nEingebackene Profile: ${profilIds.join(", ")}` : "" + }\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`, }; if (stand.weg === "api") { await aufgabeAbwarten( @@ -336,7 +364,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta }); } return { - detail: `VM ${vmid} (${templateName(d)}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`, + detail: `VM ${vmid} (${name}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`, }; }, }, @@ -383,6 +411,49 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" }; }, }, + { + schluessel: "profile", + titel: "Profile", + ausfuehren: async (k) => { + if (profilIds.length === 0) return { uebersprungen: true, detail: "keine eingebacken" }; + 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 vmPfad = pfad`/nodes/${knoten}/qemu/${stand.vmid!}`; + const { skript } = profileRendern(e.profile!, adapter, { + os: d.os, + gast: { name, benutzer: vm.default_user, typ: "vm" }, + }); + if (client.trocken) { + await k.log(skript, "info"); + return { detail: `${profilIds.join(", ")} würden eingebacken` }; + } + await aufgabeAbwarten(client, await client.post(`${vmPfad}/status/start`), { + zeitlimitMs: 5 * 60 * 1000, + }); + await agentAbwarten(client, vmPfad, k, { grenzeMs: 5 * 60 * 1000, warten }); + await skriptAusfuehren(client, vmPfad, skript, k, { grenzeMs: 60 * 60 * 1000, warten }); + await k.detail("aufräumen und herunterfahren …"); + // Über denselben Weg wie die Profile: wartet aufs Ende, statt eine Pause zu raten. + await skriptAusfuehren(client, vmPfad, `#!/bin/bash\n${TEMPLATE_AUFRAEUMEN}\n`, k, { + grenzeMs: 5 * 60 * 1000, + warten, + }); + await aufgabeAbwarten( + client, + await client.post(`${vmPfad}/status/shutdown`, { timeout: 180 }), + { zeitlimitMs: 5 * 60 * 1000 }, + ); + for (let i = 0; i < 60; i += 1) { + if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break; + await warten(3000); + } + return { detail: `${profilIds.join(", ")} eingebacken · Maschinen-ID und Host-Keys entfernt` }; + }, + }, { schluessel: "template", titel: "Template", @@ -392,7 +463,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta await client.post(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`), { zeitlimitMs: 10 * 60 * 1000 }, ); - return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` }; + return { detail: `VM ${stand.vmid} ist jetzt Template ${name}` }; }, }, ]; diff --git a/src/engine/ablaeufe/vm-ausrollen.ts b/src/engine/ablaeufe/vm-ausrollen.ts index c631526..346eb13 100644 --- a/src/engine/ablaeufe/vm-ausrollen.ts +++ b/src/engine/ablaeufe/vm-ausrollen.ts @@ -2,8 +2,9 @@ import { AblaufFehler } from "@/engine/ablauf/fehler"; import type { PhasenSchritt } from "@/engine/ablauf/ablauf"; import { adapterFuer } from "@/engine/adapter"; import type { Distribution } from "@/engine/katalog/schema"; -import { profileRendern, MARKE } from "@/engine/profile/rendern"; -import type { AufgeloestesProfil } from "@/engine/profile/aufloesen"; +import { profileRendern } from "@/engine/profile/rendern"; +import { agent, agentAbwarten, skriptAusfuehren } from "./gastagent"; +import { gastunabhaengig, mitStandardwerten, type AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben"; import { pfad, type ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxFehler } from "@/engine/proxmox/fehler"; @@ -41,7 +42,7 @@ export type VmAusrollEingabe = { export type VmAusrollStand = { knoten?: string; - template?: { vmid: number; knoten: string }; + template?: { vmid: number; knoten: string; eingebacken: string[] }; vmid?: number; ip?: string; agentNachMs?: number; @@ -81,17 +82,7 @@ export function ipconfig(netz: AusrollAnfrage["netz"]): string { return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`; } -const SKRIPT = "/root/proxdeploy-provisionierung.sh"; -const LOG = "/var/log/proxdeploy-provisionierung.log"; -/** - * file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst - * als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in - * subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64. - */ -const STUECK = 40 * 1024; - type AgentAntwort = { result: T }; -type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string }; type Netzschnittstelle = { name: string; "ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>; @@ -101,13 +92,6 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { const { client, anfrage: a, distribution: d } = e; const warten = e.warten ?? schlafen; const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`; - // Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt, - // ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten. - const agent = async ( - befehl: string, - methode: "GET" | "POST" = "POST", - parameter?: Record, - ) => await client.aufrufen({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter }); const templateStand: TemplateBauStand = {}; const schritte: PhasenSchritt[] = [ @@ -119,17 +103,31 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen."); const ressourcen = await client.get("/cluster/resources"); const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online"); - const templates = ressourcen.filter( - (r) => - r.type === "qemu" && - r.template === 1 && - (r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") && - (r.tags ?? "").split(/[;, ]/).includes(d.id), - ); - // Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM. + const gewaehlt = new Set(e.profile.map((p) => p.profil.id)); + const tags = (r: Ressource) => (r.tags ?? "").split(/[;, ]/); + // Nur Templates, deren eingebackene Profile alle gewählt sind (#022) – sonst hätte der Gast + // Software, die niemand bestellt hat. + const templates = ressourcen + .filter( + (r) => + r.type === "qemu" && + r.template === 1 && + tags(r).includes("proxdeploy-template") && + tags(r).includes(d.id), + ) + .map((r) => ({ + r, + eingebacken: tags(r) + .filter((t) => t.startsWith("profil-")) + .map((t) => t.slice(7)), + })) + .filter((t) => t.eingebacken.every((p) => gewaehlt.has(p))) + // Am meisten eingebacken zuerst, bei Gleichstand die kleinere VMID. + .sort((x, y) => y.eingebacken.length - x.eingebacken.length || (x.r.vmid ?? 0) - (y.r.vmid ?? 0)); + // Node: gewünscht, sonst der mit dem besten Template, sonst der mit dem meisten freien RAM. stand.knoten = a.knoten ?? - templates.find((t) => nodes.some((n) => n.node === t.node))?.node ?? + templates.find((t) => nodes.some((n) => n.node === t.r.node))?.r.node ?? [...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)) { @@ -138,8 +136,10 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { "Anderen Node wählen oder Cluster-Status prüfen.", ); } - const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0]; - if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! }; + const besten = templates.find((t) => t.r.node === stand.knoten) ?? templates[0]; + const tpl = besten?.r; + if (besten) + stand.template = { vmid: besten.r.vmid!, knoten: besten.r.node!, eingebacken: besten.eingebacken }; const storages = await client.get(pfad`/nodes/${stand.knoten}/storage`); const ziel = storages.find((s) => s.storage === a.storage); @@ -193,8 +193,13 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { schluessel: "template", titel: "Template", ausfuehren: async (k) => { - if (stand.template) - return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` }; + if (stand.template) { + const t = stand.template; + return { + uebersprungen: true, + detail: `${t.vmid} auf ${t.knoten}${t.eingebacken.length > 0 ? ` · ${t.eingebacken.join(", ")} eingebacken` : ""}`, + }; + } if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen."); // Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail. const unter = templateBauSchritte( @@ -218,7 +223,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { }); await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`); } - stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! }; + stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten!, eingebacken: [] }; return { detail: `${templateStand.vmid} gebaut` }; }, }, @@ -326,33 +331,20 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { titel: "Guest-Agent", ausfuehren: async (k) => { if (client.trocken) return { detail: "wartet auf Antwort (ping)" }; - const beginn = Date.now(); - const grenze = e.agentMs ?? 5 * 60 * 1000; - for (;;) { - await k.pruefeAbbruch(); - try { - await agent("ping"); - break; - } catch (fehler) { - if (Date.now() - beginn > grenze) { - throw new AblaufFehler( - `Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`, - "Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?", - ); - } - if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler; - } - await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`); - await warten(3000); - } - stand.agentNachMs = Date.now() - beginn; + stand.agentNachMs = await agentAbwarten(client, vmPfad(), k, { + grenzeMs: e.agentMs ?? 5 * 60 * 1000, + warten, + }); // IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback). if (a.netz.modus === "statisch") { stand.ip = a.netz.ip!.split("/")[0]; } else { for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) { const schnittstellen = ( - await agent>("network-get-interfaces", "GET") + await agent(client, vmPfad())>( + "network-get-interfaces", + "GET", + ) ).result; stand.ip = schnittstellen .filter((s) => s.name !== "lo") @@ -378,8 +370,16 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { `Für die Familie „${d.family}“ gibt es keinen Adapter.`, "Adapter ergänzen (docs/katalog.md).", ); + // Eingebacken und mit Standardwerten → schon im Template, nicht noch einmal (#022). + const eingebacken = new Set(stand.template?.eingebacken ?? []); + const uebersprungen = e.profile + .filter((p) => eingebacken.has(p.profil.id) && mitStandardwerten(p) && gastunabhaengig(p.profil)) + .map((p) => p.profil.id); + const auszufuehren = e.profile.filter((p) => !uebersprungen.includes(p.profil.id)); + if (uebersprungen.length > 0) + await k.log(`Im Template eingebacken, wird übersprungen: ${uebersprungen.join(", ")}`); const { skript } = profileRendern( - e.profile, + auszufuehren, adapter, { os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } }, [ @@ -394,85 +394,17 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = { `Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`, "debug", ); + const zusatz = uebersprungen.length > 0 ? ` (${uebersprungen.join(", ")} aus dem Template)` : ""; + const liste = auszufuehren.map((p) => p.profil.id).join(", "); if (client.trocken) { await k.log(skript, "info"); - return { - detail: - e.profile.length > 0 - ? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}` - : "nur Benutzer", - }; + return { detail: `${liste ? `Profile ${liste}` : "nur Benutzer"}${zusatz}` }; } - - // Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen. - const bytes = Buffer.from(skript, "utf8"); - const stuecke: Buffer[] = []; - for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK)); - for (const [nr, inhalt] of stuecke.entries()) { - await agent("file-write", "POST", { - file: `${SKRIPT}.${nr}`, - content: inhalt.toString("base64"), - encode: "0", - }); - } - const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" "); - const { pid } = await agent<{ pid: number }>("exec", "POST", { - command: [ - "/bin/bash", - "-c", - `cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`, - ], + await skriptAusfuehren(client, vmPfad(), skript, k, { + grenzeMs: e.provisionierungMs ?? 60 * 60 * 1000, + warten, }); - - const beginn = Date.now(); - const grenze = e.provisionierungMs ?? 60 * 60 * 1000; - let gelesen = 0; - let profil = ""; - for (;;) { - await warten(3000); - const status = await agent("exec-status", "GET", { pid: String(pid) }); - // Fortschritt: neue Zeilen der Logdatei im Gast. - try { - const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", { - file: LOG, - }); - const zeilen = datei.content.split("\n"); - for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) { - const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile); - if (marke) profil = marke[1]!; - await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug"); - } - gelesen = Math.max(gelesen, zeilen.length - 1); - } catch { - // Logdatei noch nicht da – beim nächsten Mal. - } - const sekunden = Math.round((Date.now() - beginn) / 1000); - if (status.exited) { - if (status.exitcode !== 0) { - throw new AblaufFehler( - `Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`, - `Detail-Log oder ${LOG} in der VM ansehen.`, - ); - } - break; - } - if (Date.now() - beginn > grenze) { - throw new AblaufFehler( - `Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`, - `${LOG} in der VM ansehen.`, - ); - } - await k.detail( - `${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`, - ); - } - await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined); - return { - detail: - e.profile.length > 0 - ? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet` - : "Benutzer eingerichtet", - }; + return { detail: `${liste ? `Profile ${liste} angewendet` : "Benutzer eingerichtet"}${zusatz}` }; }, }, ]; diff --git a/src/engine/profile/aufloesen.ts b/src/engine/profile/aufloesen.ts index c857241..61125c0 100644 --- a/src/engine/profile/aufloesen.ts +++ b/src/engine/profile/aufloesen.ts @@ -68,6 +68,40 @@ export function profileAufloesen( return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) }; } +/** Werte eines Profils aus der Eingabe, mit Standardwerten und Typprüfung. */ +export function variablenWerte( + profil: Profil, + eingabe: Record, +): { werte: Record; fehler: ProfilFehler[] } { + const werte: Record = {}; + const fehler: ProfilFehler[] = []; + for (const variable of profil.variables) { + const ergebnis = wertPruefen(variable, eingabe[variable.name]); + if ("fehler" in ergebnis) + fehler.push({ profil: profil.id, variable: variable.name, meldung: ergebnis.fehler }); + else werte[variable.name] = ergebnis.wert; + } + return { werte, fehler }; +} + +/** + * Ein ins Template eingebackenes Profil muss beim Deploy nur dann nicht noch einmal laufen, wenn + * seine Variablen genau die Standardwerte haben – mit denen wurde es eingebacken. + */ +export function mitStandardwerten(eintrag: AufgeloestesProfil): boolean { + const standard = variablenWerte(eintrag.profil, {}).werte; + return JSON.stringify(standard) === JSON.stringify(eintrag.werte); +} + +/** + * Verweist ein Profil auf `{{ guest.* }}` (Name, Benutzer), hängt es vom einzelnen Gast ab – im + * Template stand dort etwas anderes. Solche Profile laufen beim Deploy trotzdem noch einmal; dank + * Idempotenz ist das schnell (Fund: docker nahm sonst den Benutzer nicht in die Gruppe auf). + */ +export function gastunabhaengig(profil: Profil): boolean { + return !/\{\{[^}]*\bguest\./.test(JSON.stringify(profil.families)); +} + function wertPruefen( variable: ProfilVariable, eingabe: unknown, diff --git a/src/lib/schemas/template.ts b/src/lib/schemas/template.ts index 3cb2b89..6d321ce 100644 --- a/src/lib/schemas/template.ts +++ b/src/lib/schemas/template.ts @@ -7,6 +7,8 @@ export const TemplateBauAnfrage = z.object({ storage: z.string().min(1, "Storage fehlt."), bridge: z.string().min(1).default("vmbr0"), vlan: z.coerce.number().int().min(1).max(4094).optional(), + /** Profile, die ins Template eingebacken werden (#022) – nur solche ohne Pflichtvariablen. */ + profile: z.array(z.string()).default([]), }); export type TemplateBauAnfrage = z.infer; diff --git a/src/server/services/templates.ts b/src/server/services/templates.ts index b8775c0..d4f91aa 100644 --- a/src/server/services/templates.ts +++ b/src/server/services/templates.ts @@ -1,4 +1,5 @@ -import type { Distribution } from "@/engine/katalog/schema"; +import type { Distribution, Profil } from "@/engine/katalog/schema"; +import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen"; import { ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxFehler } from "@/engine/proxmox/fehler"; import { prisma } from "@/lib/db"; @@ -27,6 +28,7 @@ export async function templateBauStarten( if (!(distribution.definition as Distribution).vm) { throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`); } + await templateProfileAufloesen(anfrage.profile, (distribution.definition as Distribution).family); return await auftragAnlegen( { art: "TEMPLATE_BAUEN", @@ -42,6 +44,33 @@ export async function templateBauStarten( ); } +/** + * Profile fürs Template: aufgelöst mit Standardwerten. Ein Profil mit Pflichtvariable (z. B. der + * Zabbix-Server) ist gerätespezifisch und gehört nicht ins Template. + */ +export async function templateProfileAufloesen( + auswahl: string[], + familie: string, +): Promise { + if (auswahl.length === 0) return []; + const verfuegbar = new Map( + (await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [ + p.id, + p.definition as Profil, + ]), + ); + const aufgeloest = profileAufloesen(auswahl, verfuegbar, familie); + if (!aufgeloest.ok) { + throw new AnwendungsFehler( + 400, + "ungueltig", + aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`).join(" "), + "Profile mit Pflichtvariablen gehören nicht ins Template – sie laufen beim Ausrollen.", + ); + } + return aufgeloest.profile; +} + export type TemplateSicht = { vmid: number; name: string; @@ -49,6 +78,7 @@ export type TemplateSicht = { distributionId: string | null; gebautAm: string | null; imageDatei: string | null; + profile: string[]; }; /** @@ -74,9 +104,13 @@ export async function templatesListe(serverName: string): Promise t !== "proxdeploy-template") ?? null, + distributionId: + db?.distributionId ?? + tags.find((t) => t !== "proxdeploy-template" && !t.startsWith("profil-")) ?? + null, gebautAm: db?.gebautAm.toISOString() ?? null, imageDatei: db?.imageDatei ?? null, + profile: tags.filter((t) => t.startsWith("profil-")).map((t) => t.slice("profil-".length)), }; }) .sort((a, b) => a.vmid - b.vmid); diff --git a/src/worker/ablaeufe/template.ts b/src/worker/ablaeufe/template.ts index 6efd58f..2ff1fe1 100644 --- a/src/worker/ablaeufe/template.ts +++ b/src/worker/ablaeufe/template.ts @@ -7,6 +7,7 @@ import { TemplateBauAnfrage } from "@/lib/schemas/template"; import { melden } from "@/server/ereignisse/senke"; import { verbindungAus } from "@/server/services/server"; import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; +import { templateProfileAufloesen } from "@/server/services/templates"; import { vmidReservieren } from "@/server/services/vmid"; import { ablaufRegistrieren } from "../ausfuehren"; import { aufrufLogger } from "../aufruf-log"; @@ -16,10 +17,15 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht, const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } }); const zeile = await prisma.distribution.findUniqueOrThrow({ where: { id: anfrage.distribution } }); const distribution = zeile.definition as Distribution; + const profile = await templateProfileAufloesen(anfrage.profile, distribution.family); await prisma.auftrag.update({ where: { id: auftrag.id }, - data: { kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}` }, + data: { + kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}${ + anfrage.profile.length > 0 ? ` mit ${anfrage.profile.join(", ")}` : "" + }`, + }, }); await melden(auftrag.id); @@ -31,6 +37,7 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht, { client, distribution, + profile, knoten: anfrage.knoten, storage: anfrage.storage, bridge: anfrage.bridge, @@ -65,12 +72,20 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht, storage: anfrage.storage, imageDatei: stand.imageDatei!, pruefsumme: stand.pruefsumme!, + profile: profile.map((p) => p.profil.id), gebautVonAuftragId: auftrag.id, }, }); return { ok: true, - ergebnis: { vmid: stand.vmid, template: templateName(distribution), knoten: anfrage.knoten }, + ergebnis: { + vmid: stand.vmid, + template: templateName( + distribution, + profile.map((p) => p.profil.id), + ), + knoten: anfrage.knoten, + }, }; } finally { await client.schliessen(); diff --git a/tests/engine/template-bau.test.ts b/tests/engine/template-bau.test.ts index c79b611..3d5a6c7 100644 --- a/tests/engine/template-bau.test.ts +++ b/tests/engine/template-bau.test.ts @@ -300,3 +300,63 @@ describe("Template-Bau", () => { ]); }); }); + +describe("Profile einbacken (#022)", () => { + it("startet die VM, provisioniert über den Agent, räumt auf, fährt herunter – Name und Tags nennen die Profile", async () => { + const { mock, client } = await aufbauen({ version: "9.2.1" }); + const dateien: string[] = []; + let exec = 0; + mock + .route("POST", /\/agent\/ping$/, () => ({ data: { result: {} } })) + .route("POST", /\/agent\/file-write$/, (a) => { + dateien.push(Buffer.from(a.rumpf.get("content")!, "base64").toString("utf8")); + return { data: null }; + }) + .route("POST", /\/agent\/exec$/, () => ({ data: { pid: ++exec } })) + .route("GET", /\/agent\/exec-status$/, () => ({ data: { exited: 1, exitcode: 0 } })) + .route("GET", /\/agent\/file-read$/, () => ({ data: { content: "" } })) + .route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") })); + const katalog = await katalogLaden("catalog"); + const { profileAufloesen } = await import("@/engine/profile/aufloesen"); + const aufgeloest = profileAufloesen( + ["docker"], + new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])), + "debian", + ); + if (!aufgeloest.ok) throw new Error("Profile"); + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + profile: aufgeloest.profile, + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh: sshAufzeichnung().ssh, + auftragKurzId: "t", + holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, + vmidReservieren: async () => 9002, + warten: async () => {}, + }), + stumm, + ); + expect(ergebnis).toEqual({ ok: true }); + const anlegen = Object.fromEntries( + mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/qemu")!.rumpf, + ); + expect(anlegen).toMatchObject({ + name: "tpl-debian-13-docker", + tags: "proxdeploy-template;debian-13;profil-base;profil-docker", + }); + expect(dateien.join("")).toContain("docker-compose-plugin"); + expect(dateien.join("")).toContain("truncate -s 0 /etc/machine-id"); + expect(dateien.join("")).toContain("rm -f /etc/ssh/ssh_host_*"); + const reihenfolge = schreibend(mock.anfragen).filter((z) => /status\/(start|shutdown)|template$/.test(z)); + expect(reihenfolge).toEqual([ + "POST /nodes/pve01/qemu/9002/status/start", + "POST /nodes/pve01/qemu/9002/status/start", + "POST /nodes/pve01/qemu/9002/status/shutdown", + "POST /nodes/pve01/qemu/9002/template", + ]); + }); +}); diff --git a/tests/engine/vm-ausrollen.test.ts b/tests/engine/vm-ausrollen.test.ts index 32fe6f2..0d2dcf3 100644 --- a/tests/engine/vm-ausrollen.test.ts +++ b/tests/engine/vm-ausrollen.test.ts @@ -147,6 +147,7 @@ async function eingabe(client: ProxmoxClient, anfrageTeil: Record [p.eintrag.id, p.eintrag])), "debian", + anfrage.variablen, ); if (!aufgeloest.ok) throw new Error("Profile"); const angelegt: Array> = []; @@ -292,3 +293,50 @@ describe("VM ausrollen", () => { ]); }); }); + +describe("Templates mit eingebackenen Profilen (#022)", () => { + async function mitTemplates(templates: Array<{ vmid: number; tags: string }>) { + const { mock, client, dateien } = await aufbauen(); + mock.route("GET", "/cluster/resources", () => ({ + data: [ + { type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 }, + ...templates.map((t) => ({ type: "qemu", vmid: t.vmid, node: "pve01", template: 1, tags: t.tags })), + ], + })); + mock.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/clone$/, () => ({ data: UPID("qmclone") })); + return { mock, client, dateien }; + } + + it("wählt das Template mit den meisten passenden Profilen, nie eins mit ungewählten", async () => { + const { client } = await mitTemplates([ + { vmid: 9000, tags: "proxdeploy-template;debian-13" }, + { vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" }, + { vmid: 9003, tags: "proxdeploy-template;debian-13;profil-base;profil-docker;profil-nginx" }, + ]); + const { e } = await eingabe(client); + const stand: VmAusrollStand = {}; + expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true }); + expect(stand.template).toEqual({ vmid: 9002, knoten: "pve01", eingebacken: ["base", "docker"] }); + }); + + it("überspringt gastunabhängige eingebackene Profile; docker (nutzt guest.user) läuft noch einmal", async () => { + const { client, dateien } = await mitTemplates([ + { vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" }, + ]); + const { e } = await eingabe(client); + await phasenAusfuehren(vmAusrollSchritte(e), stumm); + const skript = [...dateien.values()].join(""); + expect(skript).not.toContain("::proxdeploy:: profil base"); + expect(skript).toContain("::proxdeploy:: profil docker"); + expect(skript).toContain("usermod -aG docker florian"); + }); + + it("weicht eine Variable vom Standard ab, läuft auch ein eingebackenes Profil", async () => { + const { client, dateien } = await mitTemplates([ + { vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base" }, + ]); + const { e } = await eingabe(client, { profile: ["base"], variablen: { base: { zeitzone: "UTC" } } }); + await phasenAusfuehren(vmAusrollSchritte(e), stumm); + expect([...dateien.values()].join("")).toContain("::proxdeploy:: profil base"); + }); +});