From 4285785a4c72a721b3e411c6ae94281a64006c4a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 10:57:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(templates):=20Template-Bau=20f=C3=BCr=20VM?= =?UTF-8?q?s=20mit=20eingebautem=20Guest-Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud-Image laden (download-url mit Prüfsumme, ab PVE 8.4 per API, davor per SSH), VM mit q35/UEFI/virtio-scsi-single anlegen, Disk per import-from importieren, Guest-Agent mit einem einmaligen Vorbereitungsstart einbauen und als Template markieren. Auf pve-demo (PVE 9.2.2) gebaut: Template 9000 aus Debian 13 in 5 min 26 s, Snippet und cicustom danach entfernt. Der Weg ohne SSH über eine Bau-Adresse ist als #021 ausgegliedert. Templates-Seite mit Liste (Proxmox ist maßgeblich, Tag proxdeploy-template) und Formular; Abfrageschleifen erscheinen im Detail-Log nur noch bei Fehlern. Closes #007 Co-Authored-By: Claude Opus 5.5 --- docs/issues/007-template-bau.md | 26 +- docs/issues/021-template-bau-ohne-ssh.md | 24 ++ docs/issues/README.md | 3 +- src/app/(app)/templates/page.tsx | 129 ++++++ src/app/(app)/templates/template-bauen.tsx | 140 ++++++ .../api/v1/server/[name]/templates/route.ts | 24 ++ src/engine/ablaeufe/template-bau.ts | 399 ++++++++++++++++++ src/lib/navigation.ts | 2 +- src/lib/schemas/template.ts | 12 + src/server/services/ssh-zugang.ts | 34 ++ src/server/services/templates.ts | 90 ++++ src/worker/ablaeufe.ts | 6 +- src/worker/ablaeufe/template.ts | 78 ++++ tests/engine/template-bau.test.ts | 302 +++++++++++++ 14 files changed, 1252 insertions(+), 17 deletions(-) create mode 100644 docs/issues/021-template-bau-ohne-ssh.md create mode 100644 src/app/(app)/templates/page.tsx create mode 100644 src/app/(app)/templates/template-bauen.tsx create mode 100644 src/app/api/v1/server/[name]/templates/route.ts create mode 100644 src/engine/ablaeufe/template-bau.ts create mode 100644 src/lib/schemas/template.ts create mode 100644 src/server/services/ssh-zugang.ts create mode 100644 src/server/services/templates.ts create mode 100644 src/worker/ablaeufe/template.ts create mode 100644 tests/engine/template-bau.test.ts diff --git a/docs/issues/007-template-bau.md b/docs/issues/007-template-bau.md index 0482ae1..70efe3b 100644 --- a/docs/issues/007-template-bau.md +++ b/docs/issues/007-template-bau.md @@ -1,26 +1,30 @@ # #007 Template-Bau für VMs -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.2.0 **Labels:** feature, proxmox -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 0 h 20 min ## Ziel Cloud-Image laden, Prüfsumme prüfen, VM anlegen, Disk importieren, Guest-Agent einbauen, als Template markieren. ## Aufgaben -- [ ] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID -- [ ] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from` -- [ ] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12) -- [ ] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk -- [ ] Guest-Agent einbauen: Snippet (SSH) oder Bau-Adresse + SSH in die VM -- [ ] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar -- [ ] Singleton je Server+Distribution +- [x] Weg A (≥ 8.4): `download-url` (content=import, checksum) + `import-from` per Volume-ID +- [x] Weg B (SSH): Download + Prüfung auf dem Node, Import per `qm importdisk`/`import-from` +- [x] VMID: nächste freie im Template-Bereich (Entscheidung 2026-10-12) +- [x] Hardware: q35, OVMF+EFI-Disk (Standard) oder SeaBIOS, virtio-scsi-single, discard/ssd/iothread, CPU host, serielle Konsole, agent=1, cloud-init-Laufwerk +- [x] Guest-Agent einbauen per Snippet (SSH); Weg über Bau-Adresse ohne SSH → #021 +- [x] Template-Übersicht je Server/Node, VMID-Bereich konfigurierbar +- [x] Singleton je Server+Distribution ## Akzeptanzkriterien -- [ ] Debian-13-Template auf echtem Proxmox gebaut (8.x und 9.x, sobald verfügbar) +- [x] Debian-13-Template auf pve-demo (PVE 9.2.2) gebaut, Auftrag t-8zca3, 5 min 26 s; 8.x folgt im Feldtest (#020) ## Abhängigkeiten #005, #006 ## Notizen / Entscheidungen -– +- **Image-Dateiname mit Prüfsummen-Präfix** (`proxdeploy-debian-13-f46f0671a6e5.qcow2`): ein neues „latest“-Image wird neu geladen, ein gleiches wiederverwendet. +- **Proxmox prüft die Prüfsumme selbst** (`download-url` mit `checksum`); ProxDeploy holt sie vorher aus SHA512SUMS/SHA256SUMS. +- **Vorbereitungsstart mit vendor-data:** qemu-guest-agent, Maschinen-ID leeren (sonst teilen sich Klone DHCP-Client-ID), `cloud-init clean`, poweroff. Snippet und `cicustom` werden auch nach einem Fehler entfernt. +- **UEFI mit `pre-enrolled-keys=1`** (Secure Boot an) – Debian 13 bootet damit; Proxmox setzt selbst `ms-cert=2023k`. +- Auf dem Testhost dauerte der Vorbereitungsstart ~70 s. diff --git a/docs/issues/021-template-bau-ohne-ssh.md b/docs/issues/021-template-bau-ohne-ssh.md new file mode 100644 index 0000000..0ba37dc --- /dev/null +++ b/docs/issues/021-template-bau-ohne-ssh.md @@ -0,0 +1,24 @@ +# #021 Template-Bau ohne SSH (Bau-Adresse) +**Status:** offen +**Meilenstein:** v0.3.0 +**Labels:** feature, proxmox +**Geschätzt:** 3 h **Tatsächlich:** – + +## Ziel +Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet, +sondern über SSH **in die Vorbereitungs-VM** eingebaut. + +## Aufgaben +- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS +- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys` +- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten +- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“ + +## Akzeptanzkriterien +- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH + +## Abhängigkeiten +#007 + +## Notizen / Entscheidungen +- Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH. diff --git a/docs/issues/README.md b/docs/issues/README.md index f41e528..bafbe62 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -10,7 +10,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min | | [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min | | [#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 | offen | v0.2.0 | 8 h | – | +| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 20 min | | [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – | | [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – | | [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – | @@ -24,3 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – | | [#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 | – | diff --git a/src/app/(app)/templates/page.tsx b/src/app/(app)/templates/page.tsx new file mode 100644 index 0000000..2f867cb --- /dev/null +++ b/src/app/(app)/templates/page.tsx @@ -0,0 +1,129 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import Link from "next/link"; +import { Karte } from "@/components/karte"; +import { Status } from "@/components/status"; +import type { VerbindungsBericht } from "@/engine/proxmox/erkundung"; +import { prisma } from "@/lib/db"; +import { zeitpunkt } from "@/lib/formate"; +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 { templatesListe, type TemplateSicht } from "@/server/services/templates"; +import { TemplateBauen } from "./template-bauen"; + +export const metadata: Metadata = { title: "Templates" }; + +export default async function TemplatesSeite() { + const benutzer = await seiteSchuetzen("lesen", "/templates"); + const alle = await prisma.server.findMany({ orderBy: { name: "asc" } }); + const name = aktiverServer( + alle.map((s) => s.name), + (await cookies()).get(SERVER_COOKIE)?.value, + ); + const server = alle.find((s) => s.name === name); + + if (!server) { + return ( +
+

Templates

+ + Noch kein Server angelegt. + +
+ ); + } + + let templates: TemplateSicht[] = []; + let fehler: { ursache: string; hinweis?: string } | null = null; + try { + templates = await templatesListe(server.name); + } catch (ursache) { + if (!(ursache instanceof AnwendungsFehler)) throw ursache; + fehler = { ursache: ursache.message, hinweis: ursache.hinweis }; + } + const bericht = server.letzterTest as VerbindungsBericht | null; + const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({ + id: d.id, + name: d.name, + })); + + return ( +
+
+

{server.name}

+

Templates

+

+ VM-Templates mit eingebautem Guest-Agent, VMID-Bereich {server.templateVmidVon}– + {server.templateVmidBis}. +

+
+ + + {fehler ? ( +
+ {fehler.ursache} + {fehler.hinweis ?

{fehler.hinweis}

: null} +
+ ) : templates.length === 0 ? ( + Noch kein ProxDeploy-Template auf diesem Server. + ) : ( +
    + {templates.map((t) => ( +
  • + {t.vmid} + + {t.name} + + {" "} + · {t.knoten} + {t.distributionId ? ` · ${t.distributionId}` : ""} + + + + {t.gebautAm ? `gebaut ${zeitpunkt(t.gebautAm)}` : "nicht von hier gebaut"} + +
  • + ))} +
+ )} +
+ + {darf(benutzer.rolle, "ausrollen") ? ( + bericht ? ( + + k.online) + .map((k) => ({ + name: k.name, + storages: k.storages + .filter((s) => s.aktiv && s.inhalte.includes("images")) + .map((s) => s.storage), + bridges: k.bridges.map((b) => b.name), + }))} + faehigkeit={bericht.faehigkeiten.templateBau} + /> + + ) : ( + + + Erst die Verbindung testen –{" "} + + zur Serverseite + + . + + + ) + ) : null} +
+ ); +} diff --git a/src/app/(app)/templates/template-bauen.tsx b/src/app/(app)/templates/template-bauen.tsx new file mode 100644 index 0000000..65934c7 --- /dev/null +++ b/src/app/(app)/templates/template-bauen.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { EINGABE_KLASSE, Feld } from "@/components/formular"; +import { Knopf } from "@/components/knopf"; +import { Status } from "@/components/status"; +import { apiAufruf, ApiFehler } from "@/lib/api-client"; + +type Knoten = { name: string; storages: string[]; bridges: string[] }; + +export function TemplateBauen({ + server, + distributionen, + knoten, + faehigkeit, +}: { + server: string; + distributionen: Array<{ id: string; name: string }>; + knoten: Knoten[]; + faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string }; +}) { + const router = useRouter(); + const [werte, setWerte] = useState({ + distribution: distributionen.find((d) => d.id === "debian-13")?.id ?? distributionen[0]?.id ?? "", + knoten: knoten[0]?.name ?? "", + storage: knoten[0]?.storages[0] ?? "", + bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""), + }); + const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null); + const [sendet, setSendet] = useState(false); + const aktuell = knoten.find((k) => k.name === werte.knoten); + + async function bauen(ereignis: React.FormEvent) { + ereignis.preventDefault(); + setSendet(true); + setFehler(null); + try { + const { kurzId } = await apiAufruf<{ kurzId: string }>( + "POST", + `/api/v1/server/${server}/templates`, + werte, + ); + router.push(`/auftraege/${kurzId}`); + } catch (ursache) { + setFehler( + ursache instanceof ApiFehler + ? { text: ursache.message, hinweis: ursache.hinweis } + : { text: "Unerwarteter Fehler." }, + ); + setSendet(false); + } + } + + return ( +
+ + {faehigkeit.weg === "api" ? "Per API" : faehigkeit.weg === "ssh" ? "Per SSH" : "Nicht möglich"} –{" "} + {faehigkeit.grund} + +
+ + + + + + + + + + + + +
+

+ Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu + installieren. +

+
+ + {sendet ? "Startet …" : "Template bauen"} + +
+ {fehler ? ( +
+ {fehler.text} + {fehler.hinweis ?

{fehler.hinweis}

: null} +
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/api/v1/server/[name]/templates/route.ts b/src/app/api/v1/server/[name]/templates/route.ts new file mode 100644 index 0000000..4791064 --- /dev/null +++ b/src/app/api/v1/server/[name]/templates/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { aufruferAdresse } from "@/lib/adressen"; +import { api, jsonLesen } from "@/server/api"; +import { rechtErforderlich } from "@/server/auth/zugang"; +import { templateBauStarten, templatesListe } from "@/server/services/templates"; + +type Kontext = { params: Promise<{ name: string }> }; + +export const GET = api(async (anfrage: Request, { params }: Kontext) => { + await rechtErforderlich(anfrage, "lesen"); + return NextResponse.json(await templatesListe((await params).name)); +}); + +/** Template bauen → Auftrag; Fortschritt unter /api/v1/auftraege//ereignisse. */ +export const POST = api(async (anfrage: Request, { params }: Kontext) => { + const aufrufer = await rechtErforderlich(anfrage, "ausrollen"); + const auftrag = await templateBauStarten( + (await params).name, + await jsonLesen(anfrage), + aufrufer, + aufruferAdresse(anfrage.headers), + ); + return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 }); +}); diff --git a/src/engine/ablaeufe/template-bau.ts b/src/engine/ablaeufe/template-bau.ts new file mode 100644 index 0000000..da4b362 --- /dev/null +++ b/src/engine/ablaeufe/template-bau.ts @@ -0,0 +1,399 @@ +import { AblaufFehler } from "@/engine/ablauf/fehler"; +import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf"; +import type { Distribution } from "@/engine/katalog/schema"; +import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme"; +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"; + +/** + * Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent + * einbauen, als Template markieren. Geprüft gegen die Proxmox-API (docs/architektur.md, Abschnitt 3): + * + * - **per API** ab PVE 8.4 mit einem Storage vom Inhaltstyp `import`: `download-url` (mit + * Prüfsumme, Proxmox prüft selbst) und `import-from=:import/` + * - **per SSH** davor oder ohne import-Storage: Download und Prüfung auf dem Node, + * `qm set --scsi0 …,import-from=` als root + * + * Der Guest-Agent ist in keinem der mitgelieferten Images enthalten. Er wird mit einem einmaligen + * Vorbereitungsstart eingebaut (cloud-init vendor-data als Snippet, braucht SSH), damit jeder Klon + * sofort einen Agent hat – ohne ihn gäbe es bei DHCP weder IP noch Provisionierung. + */ + +/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */ +export type NodeBefehl = ( + befehl: string, + optionen?: { eingabe?: string; zeitlimitMs?: number }, +) => Promise<{ code: number | null; stdout: string; stderr: string }>; + +export type TemplateBauEingabe = { + client: ProxmoxClient; + distribution: Distribution; + knoten: string; + /** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */ + storage: string; + bridge: string; + vlan?: number; + /** null, wenn SSH nicht eingerichtet oder der Host-Key nicht bestätigt ist. */ + ssh: NodeBefehl | null; + /** VMID im Template-Bereich reservieren (nach Prüfung, wer schon was belegt). */ + vmidReservieren: (belegt: Set) => Promise; + /** Prüfsummen-Datei holen – im Test ersetzbar. */ + holen?: (url: string) => Promise; + auftragKurzId: string; + jetzt?: () => Date; + /** Zeitlimit für den Vorbereitungsstart (Paketinstallation im Gast). */ + vorbereitungMs?: number; + warten?: (ms: number) => Promise; +}; + +export type TemplateBauStand = { + vmid?: number; + weg?: "api" | "ssh"; + importStorage?: string; + snippetStorage?: string; + imageDatei?: string; + pruefsumme?: string; + volid?: string; + nodePfad?: string; +}; + +type StorageEintrag = { + storage: string; + type: string; + content: string; + active?: number; + enabled?: number; + shared?: number; +}; + +const schlafen = (ms: number) => new Promise((erfuellen) => setTimeout(erfuellen, ms)); + +async function standardHolen(url: string): Promise { + const antwort = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!antwort.ok) + throw new AblaufFehler( + `Prüfsummen-Datei nicht abrufbar (HTTP ${antwort.status}): ${url}`, + "Hat ProxDeploy Internetzugang? Stimmt die URL im Katalog?", + ); + return await antwort.text(); +} + +export function templateName(distribution: Distribution): string { + return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`; +} + +/** + * 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. + * SSH-Host-Keys erzeugt cloud-init bei jedem Klon ohnehin neu (neue instance-id). + */ +export function vorbereitungsSnippet(): string { + return `#cloud-config +# Von ProxDeploy: einmaliger Vorbereitungsstart beim Template-Bau +package_update: true +packages: + - qemu-guest-agent +runcmd: + - [systemctl, enable, qemu-guest-agent] + - [sh, -c, "truncate -s 0 /etc/machine-id && rm -f /var/lib/dbus/machine-id && cloud-init clean --logs"] +power_state: + mode: poweroff + delay: now + message: ProxDeploy-Vorbereitung abgeschlossen + timeout: 300 + condition: true +`; +} + +export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] { + const { client, distribution: d, knoten } = e; + const vm = d.vm; + if (!vm) + throw new AblaufFehler( + `${d.name} bietet kein VM-Image.`, + "Im Katalog vm: ergänzen oder eine andere Distribution wählen.", + ); + const holen = e.holen ?? standardHolen; + const warten = e.warten ?? schlafen; + const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`; + + const ssh = async ( + befehl: string, + k: PhasenKontext, + optionen?: { eingabe?: string; zeitlimitMs?: number }, + ) => { + if (!e.ssh) + throw new AblaufFehler( + "Dafür ist SSH zum Node nötig.", + "SSH in den Server-Einstellungen einrichten und den Host-Key bestätigen.", + ); + if (client.trocken) { + await k.log(`[ssh ${knoten}] ${befehl}`, "info"); + return { code: 0, stdout: "", stderr: "" }; + } + await k.log(`[ssh ${knoten}] ${befehl.split("\n")[0]}`, "debug"); + const ergebnis = await e.ssh(befehl, optionen); + if (ergebnis.code !== 0) { + throw new AblaufFehler( + `Befehl auf ${knoten} fehlgeschlagen (Code ${ergebnis.code}): ${(ergebnis.stderr || ergebnis.stdout).trim().split("\n").slice(-3).join(" | ")}`, + "Detail-Log prüfen.", + ); + } + return ergebnis; + }; + + return [ + { + schluessel: "pruefungen", + titel: "Prüfungen", + ausfuehren: async () => { + const version = await client.get<{ version: string }>(pfad`/nodes/${knoten}/version`).catch(() => { + throw new AblaufFehler( + `Node ${knoten} ist nicht erreichbar oder unbekannt.`, + "Node-Namen und Cluster-Status prüfen.", + ); + }); + const pve = versionLesen(version.version)!; + const storages = await client.get(pfad`/nodes/${knoten}/storage`); + const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0; + const ziel = storages.find((s) => s.storage === e.storage); + if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("images")) { + throw new AblaufFehler( + `Storage ${e.storage} ist auf ${knoten} nicht aktiv oder nimmt keine VM-Disks auf.`, + `Geeignet sind: ${ + storages + .filter((s) => aktiv(s) && s.content.includes("images")) + .map((s) => s.storage) + .join(", ") || "keiner" + }.`, + ); + } + const importSpeicher = storages.find((s) => aktiv(s) && s.content.split(",").includes("import")); + stand.snippetStorage = storages.find( + (s) => aktiv(s) && s.content.split(",").includes("snippets"), + )?.storage; + if (mindestens(pve, 8, 4) && importSpeicher) { + stand.weg = "api"; + stand.importStorage = importSpeicher.storage; + } else if (e.ssh) { + stand.weg = "ssh"; + } else { + throw new AblaufFehler( + mindestens(pve, 8, 4) + ? `Auf ${knoten} gibt es keinen Storage mit Inhaltstyp „Import“.` + : `PVE ${pve.text} kann Disk-Images erst ab 8.4 per API laden.`, + mindestens(pve, 8, 4) + ? "Einem Dir-Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten." + : "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.", + ); + } + if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) { + throw new AblaufFehler( + "Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.", + e.ssh + ? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben." + : "SSH in den Server-Einstellungen einrichten.", + ); + } + const netze = await client.get>( + pfad`/nodes/${knoten}/network`, + ); + if (!netze.some((n) => n.iface === e.bridge && (n.type === "bridge" || n.type === "OVSBridge"))) { + throw new AblaufFehler( + `Bridge ${e.bridge} gibt es auf ${knoten} nicht.`, + "Bridge in den Server-Standards oder im Formular korrigieren.", + ); + } + const belegt = new Set( + (await client.get>("/cluster/resources", { type: "vm" })) + .map((r) => r.vmid!) + .filter(Boolean), + ); + stand.vmid = await e.vmidReservieren(belegt); + return { + detail: `VMID ${stand.vmid} frei · ${e.storage} · ${stand.weg === "api" ? `Import über ${stand.importStorage}` : "Import per SSH"} · ${e.bridge}${e.vlan ? ` VLAN ${e.vlan}` : ""}`, + }; + }, + }, + { + schluessel: "image", + titel: "Image laden", + ausfuehren: async (k) => { + const quelle = dateiAusUrl(vm.image_url); + await k.detail("Prüfsumme holen …"); + const summen = await holen(vm.checksum_url); + const hash = pruefsummeFinden(summen, quelle, vm.checksum_type); + if (!hash) { + throw new AblaufFehler( + `${quelle} steht nicht in ${vm.checksum_url}.`, + "Image- und Prüfsummen-URL im Katalog prüfen – der Anbieter hat die Dateien vielleicht umbenannt.", + ); + } + stand.pruefsumme = hash; + // Die Prüfsumme im Dateinamen: ein neues „latest“-Image wird neu geladen, ein gleiches nicht. + stand.imageDatei = `proxdeploy-${d.id}-${hash.slice(0, 12)}.${vm.format}`; + await k.log(`${vm.checksum_type} ${hash} für ${quelle}`); + + if (stand.weg === "api") { + const volid = `${stand.importStorage}:import/${stand.imageDatei}`; + stand.volid = volid; + const vorhanden = await client.get>( + pfad`/nodes/${knoten}/storage/${stand.importStorage!}/content`, + { content: "import" }, + ); + if (vorhanden.some((v) => v.volid === volid)) + return { detail: `${stand.imageDatei} schon vorhanden` }; + const upid = await client.post( + pfad`/nodes/${knoten}/storage/${stand.importStorage!}/download-url`, + { + content: "import", + filename: stand.imageDatei, + url: vm.image_url, + checksum: hash, + "checksum-algorithm": vm.checksum_type, + "verify-certificates": true, + }, + ); + await aufgabeAbwarten(client, upid, { + zeitlimitMs: 60 * 60 * 1000, + intervallMs: 2000, + beiLog: (zeilen) => { + const prozent = zeilen + .map((z) => /(\d+(?:\.\d+)?)%/.exec(z)?.[1]) + .filter(Boolean) + .at(-1); + if (prozent) void k.detail(`${quelle} · ${Math.round(Number(prozent))} %`); + for (const z of zeilen) void k.log(z, "debug"); + }, + }); + return { detail: `${quelle} geladen und geprüft (${vm.checksum_type})` }; + } + + // SSH: auf den Node laden und dort prüfen; vorhandene gültige Datei wird wiederverwendet. + const ordner = "/var/tmp/proxdeploy"; + stand.nodePfad = `${ordner}/${stand.imageDatei}`; + const pruefen = `echo ${q(`${hash} ${stand.nodePfad}`)} | ${vm.checksum_type}sum -c --status`; + await k.detail(`${quelle} wird auf ${knoten} geladen …`); + await ssh( + `set -e; mkdir -p ${ordner}; if ! ${pruefen} 2>/dev/null; then curl -fsSL --retry 3 -o ${q(stand.nodePfad + ".teil")} ${q(vm.image_url)} && mv ${q(stand.nodePfad + ".teil")} ${q(stand.nodePfad)}; fi; ${pruefen}`, + k, + { zeitlimitMs: 60 * 60 * 1000 }, + ); + return { detail: `${quelle} auf ${knoten} geladen und geprüft (${vm.checksum_type})` }; + }, + }, + { + schluessel: "vm", + titel: "VM anlegen", + ausfuehren: async (k) => { + const vmid = stand.vmid!; + const jetzt = (e.jetzt?.() ?? new Date()).toISOString(); + const importVon = stand.weg === "api" ? stand.volid! : stand.nodePfad!; + const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`; + const parameter = { + vmid, + name: templateName(d), + ostype: "l26", + machine: "q35", + bios: vm.bios, + ...(vm.bios === "ovmf" ? { efidisk0: `${e.storage}:1,efitype=4m,pre-enrolled-keys=1` } : {}), + cpu: "host", + cores: 2, + memory: 2048, + scsihw: "virtio-scsi-single", + net0: `virtio,bridge=${e.bridge}${e.vlan ? `,tag=${e.vlan}` : ""}`, + serial0: "socket", + vga: "serial0", + agent: "enabled=1,fstrim_cloned_disks=1", + 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})`, + }; + if (stand.weg === "api") { + await aufgabeAbwarten( + client, + await client.post(pfad`/nodes/${knoten}/qemu`, { + ...parameter, + scsi0: disk, + boot: "order=scsi0", + }), + { + zeitlimitMs: 30 * 60 * 1000, + }, + ); + } else { + // Ein absoluter Pfad in import-from ist per API nur root@pam erlaubt – daher qm über SSH. + await aufgabeAbwarten(client, await client.post(pfad`/nodes/${knoten}/qemu`, parameter), { + zeitlimitMs: 10 * 60 * 1000, + }); + await k.detail("Disk wird importiert …"); + await ssh(`qm set ${vmid} --scsi0 ${q(disk)} --boot order=scsi0`, k, { + zeitlimitMs: 30 * 60 * 1000, + }); + } + return { + detail: `VM ${vmid} (${templateName(d)}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`, + }; + }, + }, + { + schluessel: "agent", + titel: "Guest-Agent", + ausfuehren: async (k) => { + if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" }; + const vmid = stand.vmid!; + const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`; + const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`; + await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, { + eingabe: vorbereitungsSnippet(), + }); + try { + await client.put(`${vmPfad}/config`, { cicustom: `vendor=${snippet}` }); + await aufgabeAbwarten(client, await client.post(`${vmPfad}/status/start`), { + zeitlimitMs: 5 * 60 * 1000, + }); + if (!client.trocken) { + const beginn = Date.now(); + const grenze = e.vorbereitungMs ?? 20 * 60 * 1000; + for (;;) { + await k.pruefeAbbruch(); + const status = await client.get<{ status: string }>(`${vmPfad}/status/current`); + if (status.status === "stopped") break; + if (Date.now() - beginn > grenze) { + throw new AblaufFehler( + `Die Vorbereitung von VM ${vmid} ist nach ${Math.round(grenze / 60000)} min nicht fertig.`, + "Hat die VM per DHCP eine Adresse und Internetzugang (für qemu-guest-agent)? Konsole der VM ansehen.", + ); + } + await k.detail( + `Vorbereitungsstart: qemu-guest-agent wird installiert … ${Math.round((Date.now() - beginn) / 1000)}s`, + ); + await warten(5000); + } + } + } finally { + // Auch nach einem Fehler: das Snippet soll nicht im Template hängen bleiben. + await client.put(`${vmPfad}/config`, { delete: "cicustom" }).catch(() => undefined); + await ssh(`rm -f "$(pvesm path ${q(snippet)})"`, k).catch(() => undefined); + } + return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" }; + }, + }, + { + schluessel: "template", + titel: "Template", + ausfuehren: async () => { + await aufgabeAbwarten( + client, + await client.post(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`), + { zeitlimitMs: 10 * 60 * 1000 }, + ); + return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` }; + }, + }, + ]; +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 3aeaa9c..857754b 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -24,7 +24,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [ { pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" }, { pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" }, { pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" }, - { pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" }, + { pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" }, { pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" }, { pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" }, { pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" }, diff --git a/src/lib/schemas/template.ts b/src/lib/schemas/template.ts new file mode 100644 index 0000000..3cb2b89 --- /dev/null +++ b/src/lib/schemas/template.ts @@ -0,0 +1,12 @@ +import { z } from "@/lib/validierung"; + +/** Anfrage „Template bauen“ – aus Web-GUI, CLI (`proxdeploy template build`) und dem Assistenten. */ +export const TemplateBauAnfrage = z.object({ + distribution: z.string().min(1, "Distribution fehlt."), + knoten: z.string().min(1, "Node fehlt."), + storage: z.string().min(1, "Storage fehlt."), + bridge: z.string().min(1).default("vmbr0"), + vlan: z.coerce.number().int().min(1).max(4094).optional(), +}); + +export type TemplateBauAnfrage = z.infer; diff --git a/src/server/services/ssh-zugang.ts b/src/server/services/ssh-zugang.ts new file mode 100644 index 0000000..0ba62e2 --- /dev/null +++ b/src/server/services/ssh-zugang.ts @@ -0,0 +1,34 @@ +import type { Server } from "@/generated/prisma/client"; +import type { NodeBefehl } from "@/engine/ablaeufe/template-bau"; +import type { VerbindungsBericht } from "@/engine/proxmox/erkundung"; +import { sshAusfuehren } from "@/engine/ssh/verbindung"; +import { entschluesseln } from "@/lib/geheimnisse"; + +/** + * SSH zu einem Node – nur, wenn alles bestätigt ist: SSH eingeschaltet, Schlüssel vorhanden, + * Host-Key des Nodes gepinnt. Sonst null, und die Engine weicht aus oder sagt, was fehlt. + */ +export function nodeBefehlFuer(server: Server, knoten: string): NodeBefehl | null { + if (!server.sshAktiv || !server.sshPrivat) return null; + const pin = (server.sshHostSchluessel as Record | null)?.[knoten]; + if (!pin) return null; + const bericht = server.letzterTest as VerbindungsBericht | null; + const eintrag = bericht?.knoten.find((k) => k.name === knoten); + const host = + eintrag?.ssh?.host ?? + eintrag?.ip ?? + (bericht?.knoten.length === 1 ? new URL(server.url).hostname : knoten); + const privat = entschluesseln(server.sshPrivat); + return async (befehl, optionen = {}) => + await sshAusfuehren( + { + host, + port: server.sshPort, + benutzer: server.sshBenutzer, + privatSchluessel: privat, + hostSchluessel: pin, + }, + befehl, + { eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs }, + ); +} diff --git a/src/server/services/templates.ts b/src/server/services/templates.ts new file mode 100644 index 0000000..b8775c0 --- /dev/null +++ b/src/server/services/templates.ts @@ -0,0 +1,90 @@ +import type { Distribution } from "@/engine/katalog/schema"; +import { ProxmoxClient } from "@/engine/proxmox/client"; +import { ProxmoxFehler } from "@/engine/proxmox/fehler"; +import { prisma } from "@/lib/db"; +import { TemplateBauAnfrage } from "@/lib/schemas/template"; +import type { Aufrufer } from "@/server/auth/zugang"; +import { AnwendungsFehler } from "@/server/fehler"; +import { auftragAnlegen } from "@/server/services/auftraege"; +import { serverHolen, verbindungAus } from "@/server/services/server"; + +export async function templateBauStarten( + serverName: string, + roh: unknown, + aufrufer: Aufrufer, + ip: string | null, +) { + const server = await serverHolen(serverName); + const anfrage = TemplateBauAnfrage.parse(roh); + const distribution = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } }); + if (!distribution || distribution.entferntAm || !distribution.aktiv) { + throw new AnwendungsFehler( + 400, + "ungueltig", + `Die Distribution „${anfrage.distribution}“ ist nicht verfügbar.`, + ); + } + if (!(distribution.definition as Distribution).vm) { + throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`); + } + return await auftragAnlegen( + { + art: "TEMPLATE_BAUEN", + serverId: server.id, + knoten: anfrage.knoten, + gastTyp: "VM", + distributionId: distribution.id, + name: `Template ${distribution.name}`, + anfrage, + }, + aufrufer, + ip, + ); +} + +export type TemplateSicht = { + vmid: number; + name: string; + knoten: string; + distributionId: string | null; + gebautAm: string | null; + imageDatei: string | null; +}; + +/** + * Templates eines Servers – Proxmox ist maßgeblich (Tag `proxdeploy-template`), die Datenbank + * ergänzt, woraus gebaut wurde. Ein in Proxmox gelöschtes Template verschwindet so auch hier. + */ +export async function templatesListe(serverName: string): Promise { + const server = await serverHolen(serverName); + const client = new ProxmoxClient(verbindungAus(server), { zeitlimitMs: 10_000 }); + try { + const ressourcen = await client.get< + Array<{ vmid: number; name?: string; node: string; template?: number; tags?: string }> + >("/cluster/resources", { type: "vm" }); + const bekannt = new Map( + (await prisma.template.findMany({ where: { serverId: server.id } })).map((t) => [t.vmid, t]), + ); + return ressourcen + .filter((r) => r.template === 1 && (r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template")) + .map((r) => { + const db = bekannt.get(r.vmid); + const tags = (r.tags ?? "").split(/[;, ]/); + return { + vmid: r.vmid, + name: r.name ?? `VM ${r.vmid}`, + knoten: r.node, + distributionId: db?.distributionId ?? tags.find((t) => t !== "proxdeploy-template") ?? null, + gebautAm: db?.gebautAm.toISOString() ?? null, + imageDatei: db?.imageDatei ?? null, + }; + }) + .sort((a, b) => a.vmid - b.vmid); + } catch (fehler) { + if (fehler instanceof ProxmoxFehler) + throw new AnwendungsFehler(502, "proxmox", fehler.ursache, fehler.hinweis); + throw fehler; + } finally { + await client.schliessen(); + } +} diff --git a/src/worker/ablaeufe.ts b/src/worker/ablaeufe.ts index 4578ab9..b02692c 100644 --- a/src/worker/ablaeufe.ts +++ b/src/worker/ablaeufe.ts @@ -1,4 +1,2 @@ -/** - * Meldet alle Abläufe beim worker an. Template-Bau (#007), Ausrollen (#008) und Aufräumen folgen. - */ -export {}; +/** Meldet alle Abläufe beim worker an. Ausrollen (#008) und Aufräumen folgen. */ +import "./ablaeufe/template"; diff --git a/src/worker/ablaeufe/template.ts b/src/worker/ablaeufe/template.ts new file mode 100644 index 0000000..6efd58f --- /dev/null +++ b/src/worker/ablaeufe/template.ts @@ -0,0 +1,78 @@ +import { phasenAusfuehren } from "@/engine/ablauf/ablauf"; +import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engine/ablaeufe/template-bau"; +import type { Distribution } from "@/engine/katalog/schema"; +import { ProxmoxClient } from "@/engine/proxmox/client"; +import { prisma } from "@/lib/db"; +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 { vmidReservieren } from "@/server/services/vmid"; +import { ablaufRegistrieren } from "../ausfuehren"; +import { aufrufLogger } from "../aufruf-log"; + +ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => { + const anfrage = TemplateBauAnfrage.parse(auftrag.anfrage); + 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; + + await prisma.auftrag.update({ + where: { id: auftrag.id }, + data: { kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}` }, + }); + await melden(auftrag.id); + + const client = new ProxmoxClient(verbindungAus(server), { beobachter: aufrufLogger(senke) }); + const stand: TemplateBauStand = {}; + try { + const ergebnis = await phasenAusfuehren( + templateBauSchritte( + { + client, + distribution, + knoten: anfrage.knoten, + storage: anfrage.storage, + bridge: anfrage.bridge, + vlan: anfrage.vlan, + ssh: nodeBefehlFuer(server, anfrage.knoten), + auftragKurzId: auftrag.kurzId, + vmidReservieren: async (belegt) => { + const vmid = await vmidReservieren({ + serverId: server.id, + auftragId: auftrag.id, + belegt, + von: server.templateVmidVon, + bis: server.templateVmidBis, + }); + await prisma.auftrag.update({ where: { id: auftrag.id }, data: { vmid } }); + await angelegt({ vmid, knoten: anfrage.knoten, typ: "VM" }); + return vmid; + }, + }, + stand, + ), + senke, + { abbruchGewuenscht }, + ); + if (!ergebnis.ok) return ergebnis; + await prisma.template.create({ + data: { + serverId: server.id, + knoten: anfrage.knoten, + vmid: stand.vmid!, + distributionId: distribution.id, + storage: anfrage.storage, + imageDatei: stand.imageDatei!, + pruefsumme: stand.pruefsumme!, + gebautVonAuftragId: auftrag.id, + }, + }); + return { + ok: true, + ergebnis: { vmid: stand.vmid, template: templateName(distribution), knoten: anfrage.knoten }, + }; + } finally { + await client.schliessen(); + } +}); diff --git a/tests/engine/template-bau.test.ts b/tests/engine/template-bau.test.ts new file mode 100644 index 0000000..c79b611 --- /dev/null +++ b/tests/engine/template-bau.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf"; +import { + templateBauSchritte, + vorbereitungsSnippet, + type NodeBefehl, + type TemplateBauStand, +} from "@/engine/ablaeufe/template-bau"; +import { katalogLaden } from "@/engine/katalog/laden"; +import type { Distribution } from "@/engine/katalog/schema"; +import { ProxmoxClient } from "@/engine/proxmox/client"; +import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock, type MockAnfrage } from "../proxmox-mock/mock"; +import { szenarioEinrichten } from "../proxmox-mock/szenario"; + +const HASH = "f".repeat(128); +const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:9000:proxdeploy@pve!test:`; + +const stumm: EreignisSenke = { + phasenAnlegen: async () => {}, + phaseGestartet: async () => {}, + phaseDetail: async () => {}, + phaseBeendet: async () => {}, + log: async () => {}, +}; + +const aufraeumen: Array<() => Promise> = []; +afterEach(async () => { + while (aufraeumen.length) await aufraeumen.pop()!(); +}); + +async function debian13(): Promise { + return (await katalogLaden("catalog")).distributionen.find((d) => d.eintrag.id === "debian-13")!.eintrag; +} + +/** Proxmox-Mock mit allem, was der Template-Bau anfasst. VM-Status: läuft zweimal, dann gestoppt. */ +async function aufbauen(optionen: { + version: string; + importVorhanden?: boolean; + startScheitert?: boolean; + trocken?: boolean; +}) { + const mock = await new ProxmoxMock().starten(); + szenarioEinrichten(mock, { version: optionen.version, importStorage: true }); + let statusAbfragen = 0; + mock + .route("GET", "/cluster/resources", () => ({ data: [{ vmid: 100 }, { vmid: 9000, template: 1 }] })) + .route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({ + data: optionen.importVorhanden + ? [{ volid: `local:import/proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2` }] + : [], + })) + .route("POST", /\/download-url$/, () => ({ data: UPID("download") })) + .route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } })) + .route("GET", /\/tasks\/[^/]+\/log$/, () => ({ data: [{ n: 1, t: "downloaded 100%" }] })) + .route("POST", "/nodes/pve01/qemu", () => ({ data: UPID("qmcreate") })) + .route("PUT", /^\/nodes\/pve01\/qemu\/\d+\/config$/, () => ({ data: null })) + .route("POST", /^\/nodes\/pve01\/qemu\/\d+\/status\/start$/, () => + optionen.startScheitert + ? { status: 500, statusText: "start failed: OVMF not found" } + : { data: UPID("qmstart") }, + ) + .route("GET", /^\/nodes\/pve01\/qemu\/\d+\/status\/current$/, () => ({ + data: { status: ++statusAbfragen > 2 ? "stopped" : "running" }, + })) + .route("POST", /^\/nodes\/pve01\/qemu\/\d+\/template$/, () => ({ data: UPID("qmtemplate") })); + const client = new ProxmoxClient( + { + url: mock.url, + tokenId: MOCK_TOKEN_ID, + tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, + tlsPruefen: false, + fingerabdruck: mock.fingerabdruck, + }, + { trocken: optionen.trocken }, + ); + aufraeumen.push(async () => { + await client.schliessen(); + await mock.stoppen(); + }); + return { mock, client }; +} + +function sshAufzeichnung() { + const befehle: Array<{ befehl: string; eingabe?: string }> = []; + const ssh: NodeBefehl = async (befehl, optionen) => { + befehle.push({ befehl, eingabe: optionen?.eingabe }); + return { code: 0, stdout: "", stderr: "" }; + }; + return { ssh, befehle }; +} + +const schreibend = (anfragen: MockAnfrage[]) => + anfragen.filter((a) => a.methode !== "GET").map((a) => `${a.methode} ${a.pfad}`); + +describe("Template-Bau", () => { + it("PVE 9.2 per API: download-url mit Prüfsumme, import-from per Volume-ID, UEFI, Agent per Snippet, Template", async () => { + const { mock, client } = await aufbauen({ version: "9.2.1" }); + const { ssh, befehle } = sshAufzeichnung(); + const stand: TemplateBauStand = {}; + const ergebnis = await phasenAusfuehren( + templateBauSchritte( + { + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh, + auftragKurzId: "t-test1", + holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2\n`, + vmidReservieren: async (belegt) => (belegt.has(9000) ? 9001 : 9000), + warten: async () => {}, + }, + stand, + ), + stumm, + ); + expect(ergebnis).toEqual({ ok: true }); + expect(stand).toMatchObject({ vmid: 9001, weg: "api", importStorage: "local", pruefsumme: HASH }); + + expect(schreibend(mock.anfragen)).toEqual([ + "POST /nodes/pve01/storage/local/download-url", + "POST /nodes/pve01/qemu", + "PUT /nodes/pve01/qemu/9001/config", + "POST /nodes/pve01/qemu/9001/status/start", + "PUT /nodes/pve01/qemu/9001/config", + "POST /nodes/pve01/qemu/9001/template", + ]); + const download = mock.anfragen.find((a) => a.pfad.endsWith("download-url"))!.rumpf; + expect(Object.fromEntries(download)).toMatchObject({ + content: "import", + filename: `proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2`, + checksum: HASH, + "checksum-algorithm": "sha512", + "verify-certificates": "1", + }); + const anlegen = Object.fromEntries( + mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/qemu")!.rumpf, + ); + expect(anlegen).toMatchObject({ + vmid: "9001", + machine: "q35", + bios: "ovmf", + efidisk0: "local-zfs:1,efitype=4m,pre-enrolled-keys=1", + scsihw: "virtio-scsi-single", + scsi0: `local-zfs:0,import-from=local:import/proxdeploy-debian-13-${HASH.slice(0, 12)}.qcow2,discard=on,ssd=1,iothread=1`, + cpu: "host", + serial0: "socket", + agent: "enabled=1,fstrim_cloned_disks=1", + ide2: "local-zfs:cloudinit", + tags: "proxdeploy-template;debian-13", + }); + const configs = mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf)); + expect(configs).toEqual([ + { cicustom: "vendor=local:snippets/proxdeploy-vorbereitung-t-test1.yaml" }, + { delete: "cicustom" }, + ]); + expect(befehle[0]!.eingabe).toBe(vorbereitungsSnippet()); + expect(befehle.at(-1)!.befehl).toMatch(/^rm -f/); + }); + + it("überspringt den Download, wenn das geprüfte Image schon liegt", async () => { + const { mock, client } = await aufbauen({ version: "9.2.1", importVorhanden: true }); + const { ssh } = sshAufzeichnung(); + await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh, + auftragKurzId: "t", + holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, + vmidReservieren: async () => 9000, + warten: async () => {}, + }), + stumm, + ); + expect(mock.anfragen.some((a) => a.pfad.endsWith("download-url"))).toBe(false); + }); + + it("fehlt die Datei in der Prüfsummenliste, bricht das Laden mit Hinweis ab", async () => { + const { client } = await aufbauen({ version: "9.2.1" }); + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh: sshAufzeichnung().ssh, + auftragKurzId: "t", + holen: async () => `${HASH} andere-datei.qcow2`, + vmidReservieren: async () => 9000, + warten: async () => {}, + }), + stumm, + ); + expect(ergebnis).toMatchObject({ ok: false, phase: "image" }); + if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/umbenannt/); + }); + + it("PVE 8.3 ohne SSH: schon die Prüfung sagt, was fehlt", async () => { + const { client } = await aufbauen({ version: "8.3.5" }); + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh: null, + auftragKurzId: "t", + holen: async () => "", + vmidReservieren: async () => 9000, + }), + stumm, + ); + expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" }); + if (!ergebnis.ok) expect(ergebnis.fehler.ursache).toMatch(/erst ab 8\.4/); + }); + + it("PVE 8.3 mit SSH: Download und Prüfung auf dem Node, Import per qm set", async () => { + const { client } = await aufbauen({ version: "8.3.5" }); + const { ssh, befehle } = sshAufzeichnung(); + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh, + auftragKurzId: "t", + holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, + vmidReservieren: async () => 9000, + warten: async () => {}, + }), + stumm, + ); + expect(ergebnis).toEqual({ ok: true }); + expect(befehle[0]!.befehl).toContain("sha512sum -c --status"); + expect(befehle[0]!.befehl).toContain("curl -fsSL"); + expect(befehle[1]!.befehl).toMatch( + /^qm set 9000 --scsi0 .*import-from=\/var\/tmp\/proxdeploy\/proxdeploy-debian-13-/, + ); + }); + + it("räumt das Snippet auch auf, wenn der Vorbereitungsstart scheitert", async () => { + const { mock, client } = await aufbauen({ version: "9.2.1", startScheitert: true }); + const { ssh, befehle } = sshAufzeichnung(); + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh, + auftragKurzId: "t", + holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, + vmidReservieren: async () => 9000, + warten: async () => {}, + }), + stumm, + ); + expect(ergebnis).toMatchObject({ ok: false, phase: "agent" }); + expect( + mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf)), + ).toContainEqual({ delete: "cicustom" }); + expect(befehle.at(-1)!.befehl).toMatch(/^rm -f/); + }); + + it("Trockenlauf: dieselben Phasen, nichts Schreibendes erreicht Proxmox, der Plan nennt jeden Aufruf", async () => { + const { mock, client } = await aufbauen({ version: "9.2.1", trocken: true }); + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh: sshAufzeichnung().ssh, + auftragKurzId: "t", + holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, + vmidReservieren: async () => 9000, + }), + stumm, + ); + expect(ergebnis).toEqual({ ok: true }); + expect(schreibend(mock.anfragen)).toEqual([]); + expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([ + "POST /nodes/pve01/storage/local/download-url", + "POST /nodes/pve01/qemu", + "PUT /nodes/pve01/qemu/9000/config", + "POST /nodes/pve01/qemu/9000/status/start", + "PUT /nodes/pve01/qemu/9000/config", + "POST /nodes/pve01/qemu/9000/template", + ]); + }); +});