feat(templates): Template-Bau für VMs mit eingebautem Guest-Agent

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:57:11 +02:00
co-authored by Claude Opus 5.5
parent 76bc0e4269
commit 4285785a4c
14 changed files with 1252 additions and 17 deletions
+129
View File
@@ -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 (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
<Karte>
<Status art="offen">Noch kein Server angelegt.</Status>
</Karte>
</div>
);
}
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 (
<div className="flex flex-col gap-6">
<header>
<p className="text-muted font-mono text-sm">{server.name}</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Templates</h1>
<p className="text-muted text-sm">
VM-Templates mit eingebautem Guest-Agent, VMID-Bereich {server.templateVmidVon}–
{server.templateVmidBis}.
</p>
</header>
<Karte titel="Vorhanden">
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1 text-sm">{fehler.hinweis}</p> : null}
</div>
) : templates.length === 0 ? (
<Status art="offen">Noch kein ProxDeploy-Template auf diesem Server.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{templates.map((t) => (
<li
key={t.vmid}
className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:gap-4"
>
<span className="font-mono sm:w-16">{t.vmid}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{t.name}</span>
<span className="text-muted text-sm">
{" "}
· {t.knoten}
{t.distributionId ? ` · ${t.distributionId}` : ""}
</span>
</span>
<span className="text-muted text-sm">
{t.gebautAm ? `gebaut ${zeitpunkt(t.gebautAm)}` : "nicht von hier gebaut"}
</span>
</li>
))}
</ul>
)}
</Karte>
{darf(benutzer.rolle, "ausrollen") ? (
bericht ? (
<Karte titel="Template bauen">
<TemplateBauen
server={server.name}
distributionen={distributionen}
knoten={bericht.knoten
.filter((k) => 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}
/>
</Karte>
) : (
<Karte titel="Template bauen">
<Status art="offen">
Erst die Verbindung testen –{" "}
<Link href={`/server/${server.name}`} className="underline">
zur Serverseite
</Link>
.
</Status>
</Karte>
)
) : null}
</div>
);
}
+140
View File
@@ -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 (
<form onSubmit={bauen} className="flex flex-col gap-4">
<Status art={faehigkeit.weg === "api" ? "erledigt" : faehigkeit.weg === "ssh" ? "laeuft" : "fehler"}>
{faehigkeit.weg === "api" ? "Per API" : faehigkeit.weg === "ssh" ? "Per SSH" : "Nicht möglich"} –{" "}
{faehigkeit.grund}
</Status>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="distribution" label="Distribution">
<select
id="distribution"
className={EINGABE_KLASSE}
value={werte.distribution}
onChange={(e) => setWerte({ ...werte, distribution: e.target.value })}
>
{distributionen.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</Feld>
<Feld id="knoten" label="Node">
<select
id="knoten"
className={EINGABE_KLASSE}
value={werte.knoten}
onChange={(e) => {
const k = knoten.find((x) => x.name === e.target.value);
setWerte({
...werte,
knoten: e.target.value,
storage: k?.storages[0] ?? "",
bridge: k?.bridges[0] ?? "",
});
}}
>
{knoten.map((k) => (
<option key={k.name}>{k.name}</option>
))}
</select>
</Feld>
<Feld id="storage" label="Storage">
<select
id="storage"
className={EINGABE_KLASSE}
value={werte.storage}
onChange={(e) => setWerte({ ...werte, storage: e.target.value })}
>
{aktuell?.storages.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</Feld>
<Feld id="bridge" label="Bridge (Vorbereitungsstart)">
<select
id="bridge"
className={EINGABE_KLASSE}
value={werte.bridge}
onChange={(e) => setWerte({ ...werte, bridge: e.target.value })}
>
{aktuell?.bridges.map((b) => (
<option key={b}>{b}</option>
))}
</select>
</Feld>
</div>
<p className="text-muted text-sm">
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
installieren.
</p>
<div className="flex flex-wrap items-center gap-3">
<Knopf type="submit" disabled={sendet || faehigkeit.weg === "nicht_moeglich" || !werte.distribution}>
{sendet ? "Startet …" : "Template bauen"}
</Knopf>
<div aria-live="polite" className="text-sm">
{fehler ? (
<div>
<Status art="fehler">{fehler.text}</Status>
{fehler.hinweis ? <p className="text-muted">{fehler.hinweis}</p> : null}
</div>
) : null}
</div>
</div>
</form>
);
}
@@ -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/<kurzId>/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 });
});
+399
View File
@@ -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=<storage>:import/<datei>`
* - **per SSH** davor oder ohne import-Storage: Download und Prüfung auf dem Node,
* `qm set --scsi0 …,import-from=<pfad>` 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<number>) => Promise<number>;
/** Prüfsummen-Datei holen – im Test ersetzbar. */
holen?: (url: string) => Promise<string>;
auftragKurzId: string;
jetzt?: () => Date;
/** Zeitlimit für den Vorbereitungsstart (Paketinstallation im Gast). */
vorbereitungMs?: number;
warten?: (ms: number) => Promise<void>;
};
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<void>((erfuellen) => setTimeout(erfuellen, ms));
async function standardHolen(url: string): Promise<string> {
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<StorageEintrag[]>(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<Array<{ iface: string; type: string }>>(
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<Array<{ vmid?: number }>>("/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<Array<{ volid: string }>>(
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<string>(
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<string>(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<string>(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<string>(`${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<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
{ zeitlimitMs: 10 * 60 * 1000 },
);
return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` };
},
},
];
}
+1 -1
View File
@@ -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" },
+12
View File
@@ -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<typeof TemplateBauAnfrage>;
+34
View File
@@ -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<string, string> | 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 },
);
}
+90
View File
@@ -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<TemplateSicht[]> {
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();
}
}
+2 -4
View File
@@ -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";
+78
View File
@@ -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();
}
});