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:
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user