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 (
+
+
+
+
+ {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 (
+
+ );
+}
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",
+ ]);
+ });
+});