feat(ausrollen): VM ausrollen mit Assistent, Dry-Run und CLI-Befehl

Eine VM aus dem ProxDeploy-Template ausrollen, vollständig per API:
Klonen, Hardware, cloud-init, Start, Guest-Agent und Provisionierung der
Profile über den Agent – ohne SSH. Der Fortschritt kommt aus der
Logdatei im Gast, je Profil eine Marke.

Der Dry-Run läuft durch dieselben Phasen mit einem Trocken-Client und
zeigt jeden geplanten Proxmox-Aufruf und den passenden CLI-Befehl. Der
Assistent hat 9 Schritte, den Zustand in der URL und bietet an, ein
fehlendes Template im selben Auftrag zu bauen.

Auf pve-demo abgenommen: pd-test01 (base, docker) per API, pd-test02
(base, zabbix-agent) über den Assistenten, beide per SSH geprüft. Dabei
gefunden: file-write scheitert an Umlauten – Skripte gehen jetzt
base64-kodiert hinüber.

Closes #008

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:34:15 +02:00
co-authored by Claude Opus 5.5
parent 4285785a4c
commit 9310164bc2
24 changed files with 2465 additions and 27 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ export type NavigationsEintrag = {
export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
+156
View File
@@ -0,0 +1,156 @@
import { z } from "@/lib/validierung";
/**
* Die Anfrage „Ausrollen“ – eine Form für Web-Assistent, CLI (`proxdeploy vm …`), Batch und
* Vorlagen. Sie liegt in `src/lib`, weil Formular (Client) und API (Server) dieselben Regeln
* brauchen. Felder sind deutsch; die CLI übersetzt ihre Flags darauf (`--memory` → `speicherMb`).
*/
/** Hostname nach RFC 1123, ein Label: so heißt der Gast in Proxmox und im Netz. */
export const GAST_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
const Ipv4Cidr = z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.20.15/24")
.refine((wert) => {
const [ip, praefix] = wert.split("/");
return (
ip!.split(".").every((teil) => Number(teil) <= 255) && Number(praefix) >= 1 && Number(praefix) <= 32
);
}, "Ungültige Adresse oder Präfixlänge.");
const Ipv4 = z
.string()
.trim()
.regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.20.1")
.refine((ip) => ip.split(".").every((teil) => Number(teil) <= 255), "Ungültige Adresse.");
export const SSH_SCHLUESSEL =
/^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( [^\r\n]*)?$/;
export const Netz = z
.object({
bridge: z
.string()
.trim()
.min(1, "Bridge fehlt.")
.regex(/^[A-Za-z0-9_.-]+$/, "Ungültiger Bridge-Name."),
vlan: z.coerce.number().int().min(1).max(4094).optional(),
modus: z.enum(["dhcp", "statisch"]).default("dhcp"),
ip: Ipv4Cidr.optional(),
gateway: Ipv4.optional(),
dns: z.array(Ipv4).max(3).default([]),
suchdomain: z
.string()
.trim()
.regex(/^[a-z0-9.-]*$/i, "Ungültige Suchdomain.")
.optional(),
})
.refine((n) => n.modus === "dhcp" || Boolean(n.ip), {
message: "Bei statischer Adresse fehlt die IP.",
path: ["ip"],
});
export const Zugang = z.object({
benutzer: z
.string()
.trim()
.regex(/^[a-z_][a-z0-9_-]{0,31}$/, "Benutzername: Kleinbuchstaben, Ziffern, _ und -")
.refine((name) => name !== "root", "root ist nicht erlaubt – ProxDeploy legt einen sudo-Benutzer an."),
sshSchluessel: z
.array(z.string().trim().regex(SSH_SCHLUESSEL, "Kein gültiger öffentlicher SSH-Schlüssel."))
.min(1, "Mindestens ein SSH-Schlüssel – Passwörter vergibt ProxDeploy nicht."),
});
export const AusrollAnfrage = z
.object({
typ: z.enum(["vm", "lxc"]),
server: z.string().trim().min(1, "Server fehlt."),
/** Leer = ProxDeploy wählt: Node mit dem Template, sonst der mit dem meisten freien RAM. */
knoten: z.string().trim().optional(),
distribution: z.string().trim().min(1, "Distribution fehlt."),
name: z
.string()
.trim()
.toLowerCase()
.regex(GAST_NAME, "Name: Kleinbuchstaben, Ziffern und Bindestrich (Hostname)."),
/** Leer = nächste freie ID. */
vmid: z.coerce.number().int().min(100).max(999_999_999).optional(),
tags: z
.array(
z
.string()
.trim()
.regex(/^[a-z0-9_+.-]+$/i, "Tags: Buchstaben, Ziffern, _ + . -"),
)
.max(10)
.default([]),
cores: z.coerce.number().int().min(1).max(128).default(2),
speicherMb: z.coerce.number().int().min(256).max(1_048_576).default(2048),
diskGb: z.coerce.number().int().min(1).max(65_536).default(20),
storage: z.string().trim().min(1, "Storage fehlt."),
/** LXC */
swapMb: z.coerce.number().int().min(0).max(1_048_576).default(512),
unprivilegiert: z.boolean().default(true),
features: z
.object({ nesting: z.boolean().default(false), keyctl: z.boolean().default(false) })
.default({ nesting: false, keyctl: false }),
netz: Netz,
zugang: Zugang,
profile: z.array(z.string()).default(["base"]),
variablen: z
.record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])))
.default({}),
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
templateBauen: z.boolean().default(false),
startBeiBoot: z.boolean().default(true),
})
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
message: "Mindestens 4 GB für eine VM.",
path: ["diskGb"],
});
export type AusrollAnfrage = z.infer<typeof AusrollAnfrage>;
export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
/**
* Der passende CLI-Befehl zu einer Anfrage – im Assistenten zum Kopieren angezeigt. Nur Flags, die
* vom Standard abweichen, damit der Befehl lesbar bleibt.
*/
export function cliBefehl(a: AusrollAnfrage): string {
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
if (a.knoten) teile.push(`--node ${a.knoten}`);
teile.push(`--distro ${a.distribution}`);
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
teile.push(`--profile ${a.profile.join(",")}`);
if (a.netz.modus === "statisch" && a.netz.ip) {
teile.push(`--ip ${a.netz.ip}`);
if (a.netz.gateway) teile.push(`--gw ${a.netz.gateway}`);
}
if (a.netz.dns.length > 0) teile.push(`--dns ${a.netz.dns.join(",")}`);
if (a.netz.suchdomain) teile.push(`--search-domain ${a.netz.suchdomain}`);
if (a.netz.bridge !== "vmbr0") teile.push(`--bridge ${a.netz.bridge}`);
if (a.netz.vlan) teile.push(`--vlan ${a.netz.vlan}`);
if (a.vmid) teile.push(`--vmid ${a.vmid}`);
if (a.cores !== 2) teile.push(`--cores ${a.cores}`);
if (a.speicherMb !== 2048) teile.push(`--memory ${a.speicherMb}`);
if (a.diskGb !== 20) teile.push(`--disk ${a.diskGb}G`);
teile.push(`--storage ${a.storage}`);
teile.push(`--user ${a.zugang.benutzer}`);
if (a.tags.length > 0) teile.push(`--tags ${a.tags.join(",")}`);
for (const [profil, werte] of Object.entries(a.variablen)) {
for (const [name, wert] of Object.entries(werte)) {
const text = String(wert);
teile.push(
`--var ${profil}.${name}=${/^[\w.,:/@-]+$/.test(text) ? text : `'${text.replace(/'/g, `'"'"'`)}'`}`,
);
}
}
if (a.typ === "lxc") {
if (a.swapMb !== 512) teile.push(`--swap ${a.swapMb}`);
if (!a.unprivilegiert) teile.push("--privileged");
}
if (a.templateBauen) teile.push("--build-template");
return teile.join(" ");
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
*/
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0";
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.2.0";
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
export function versionText(): string {