Files
proxdeploy/src/engine/ablaeufe/vm-ausrollen.ts
T
florian.wildeandClaude Opus 5.5 ebcf63100a feat(ubuntu): Ubuntu als VM und LXC, Fortschritt beim Klonen
Ubuntu 24.04 auf pve-demo als VM (Template im selben Auftrag gebaut)
und als Container ausgerollt; 22.04 und 26.04 laufen im Containertest.
Der Voll-Klon brauchte auf dem Testhost neun Minuten – die Phase zeigt
jetzt den Fortschritt aus dem Task-Log.

Closes #010

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:44:37 +02:00

486 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
import { adapterFuer } from "@/engine/adapter";
import type { Distribution } from "@/engine/katalog/schema";
import { profileRendern, MARKE } from "@/engine/profile/rendern";
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { templateBauSchritte, type TemplateBauEingabe, type TemplateBauStand } from "./template-bau";
/**
* Eine VM aus einem ProxDeploy-Template ausrollen – vollständig per API, ohne SSH:
* Klonen, Hardware, cloud-init (Benutzer, Schlüssel, Netz), Start, Guest-Agent, Provisionierung
* der Profile über den Guest-Agent (Skript per file-write, Ausführung per exec, Fortschritt aus der
* Logdatei im Gast).
*/
export type VmAusrollEingabe = {
client: ProxmoxClient;
anfrage: AusrollAnfrage;
distribution: Distribution;
profile: AufgeloestesProfil[];
/** Gast-VMID reservieren (nach Prüfung, was belegt ist); im Trockenlauf nur ermitteln. */
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
/** Für ein fehlendes Template (nur mit anfrage.templateBauen). */
templateBau?: Omit<
TemplateBauEingabe,
"client" | "distribution" | "knoten" | "storage" | "bridge" | "vlan" | "ssh"
> & {
/** SSH zum Node – erst bekannt, wenn die Prüfung den Node gewählt hat. */
sshFuer: (knoten: string) => TemplateBauEingabe["ssh"];
};
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
auftragKurzId: string;
warten?: (ms: number) => Promise<void>;
/** Zeitlimits – im Test kürzer. */
agentMs?: number;
provisionierungMs?: number;
};
export type VmAusrollStand = {
knoten?: string;
template?: { vmid: number; knoten: string };
vmid?: number;
ip?: string;
agentNachMs?: number;
};
type Ressource = {
vmid?: number;
node?: string;
template?: number;
tags?: string;
type?: string;
status?: string;
maxmem?: number;
mem?: number;
name?: string;
};
type StorageEintrag = {
storage: string;
content: string;
active?: number;
enabled?: number;
shared?: number;
};
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
/** Proxmox erwartet die Schlüssel URI-kodiert – strenger als encodeURIComponent (auch ! ' ( ) *). */
export function sshkeysKodieren(schluessel: string[]): string {
return encodeURIComponent(schluessel.join("\n")).replace(
/[!'()*]/g,
(z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export function ipconfig(netz: AusrollAnfrage["netz"]): string {
if (netz.modus === "dhcp") return "ip=dhcp";
return `ip=${netz.ip}${netz.gateway ? `,gw=${netz.gateway}` : ""}`;
}
const SKRIPT = "/root/proxdeploy-provisionierung.sh";
const LOG = "/var/log/proxdeploy-provisionierung.log";
/**
* file-write nimmt höchstens 60 KiB je Aufruf (API-Schema, Feld content). ProxDeploy kodiert selbst
* als base64 (encode=0): Proxmox' eigene Kodierung scheitert an Nicht-ASCII („Wide character in
* subroutine entry“, gefunden beim ersten echten Deploy). 40 KiB Rohdaten werden ~54 KiB base64.
*/
const STUECK = 40 * 1024;
type AgentAntwort<T> = { result: T };
type ExecStatus = { exited: number | boolean; exitcode?: number; "out-data"?: string; "err-data"?: string };
type Netzschnittstelle = {
name: string;
"ip-addresses"?: Array<{ "ip-address-type": string; "ip-address": string }>;
};
export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {}): PhasenSchritt[] {
const { client, anfrage: a, distribution: d } = e;
const warten = e.warten ?? schlafen;
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
// Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
// ping und network-get-interfaces → {result}. Deshalb liefert der Helfer die Rohdaten.
const agent = async <T>(
befehl: string,
methode: "GET" | "POST" = "POST",
parameter?: Record<string, string | readonly string[]>,
) => await client.aufrufen<T>({ methode, pfad: `${vmPfad()}/agent/${befehl}`, parameter });
const templateStand: TemplateBauStand = {};
const schritte: PhasenSchritt[] = [
{
schluessel: "pruefungen",
titel: "Prüfungen",
ausfuehren: async () => {
if (!d.vm)
throw new AblaufFehler(`${d.name} bietet kein VM-Image.`, "Eine andere Distribution wählen.");
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
const templates = ressourcen.filter(
(r) =>
r.type === "qemu" &&
r.template === 1 &&
(r.tags ?? "").split(/[;, ]/).includes("proxdeploy-template") &&
(r.tags ?? "").split(/[;, ]/).includes(d.id),
);
// Node: gewünscht, sonst der mit dem Template, sonst der mit dem meisten freien RAM.
stand.knoten =
a.knoten ??
templates.find((t) => nodes.some((n) => n.node === t.node))?.node ??
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
?.node;
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
throw new AblaufFehler(
`Node ${stand.knoten ?? "?"} ist nicht online.`,
"Anderen Node wählen oder Cluster-Status prüfen.",
);
}
const tpl = templates.find((t) => t.node === stand.knoten) ?? templates[0];
if (tpl) stand.template = { vmid: tpl.vmid!, knoten: tpl.node! };
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
const ziel = storages.find((s) => s.storage === a.storage);
if (!ziel || ziel.active !== 1 || !ziel.content.split(",").includes("images")) {
throw new AblaufFehler(
`Storage ${a.storage} nimmt auf ${stand.knoten} keine VM-Disks auf.`,
"Anderen Storage wählen.",
);
}
if (tpl && tpl.node !== stand.knoten && ziel.shared !== 1) {
// Ein Voll-Klon auf einen anderen Node geht nur, wenn das Template auf geteiltem Storage liegt.
throw new AblaufFehler(
`Das Template ${tpl.vmid} liegt auf ${tpl.node}, die VM soll auf ${stand.knoten}.`,
`Node ${tpl.node} wählen oder auf ${stand.knoten} ein eigenes Template bauen.`,
);
}
const netze = await client.get<Array<{ iface: string; type: string; bridge_vlan_aware?: number }>>(
pfad`/nodes/${stand.knoten}/network`,
);
const bridge = netze.find(
(n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"),
);
if (!bridge)
throw new AblaufFehler(
`Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`,
"Andere Bridge wählen.",
);
if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) {
throw new AblaufFehler(
`Bridge ${a.netz.bridge} ist nicht VLAN-fähig – VLAN ${a.netz.vlan} würde nicht ankommen.`,
"In Proxmox „VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
);
}
if (!tpl && !a.templateBauen) {
throw new AblaufFehler(
`Auf diesem Server gibt es kein ProxDeploy-Template für ${d.name}.`,
"Unter „Templates“ eins bauen oder beim Ausrollen „Template vorher bauen“ wählen.",
);
}
const belegt = new Set(
ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"),
);
stand.vmid = await e.vmidReservieren(belegt, a.vmid);
await e.angelegt({ knoten: stand.knoten, typ: "VM" });
return {
detail: `VMID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""}`,
};
},
},
{
schluessel: "template",
titel: "Template",
ausfuehren: async (k) => {
if (stand.template)
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` };
if (!e.templateBau) throw new AblaufFehler("Template fehlt.", "Template bauen.");
// Die Phasen des Template-Baus laufen hier als Unterschritte; ihr Fortschritt erscheint als Detail.
const unter = templateBauSchritte(
{
...e.templateBau,
ssh: e.templateBau.sshFuer(stand.knoten!),
client,
distribution: d,
knoten: stand.knoten!,
storage: a.storage,
bridge: a.netz.bridge,
vlan: a.netz.vlan,
},
templateStand,
);
for (const schritt of unter) {
await k.detail(`${schritt.titel} …`);
const ergebnis = await schritt.ausfuehren({
...k,
detail: (text) => k.detail(`${schritt.titel}: ${text}`),
});
await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`);
}
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! };
return { detail: `${templateStand.vmid} gebaut` };
},
},
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async (k) => {
const tpl = stand.template!;
const klonen = async (vmid: number) =>
await aufgabeAbwarten(
client,
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
newid: vmid,
name: a.name,
full: true,
target: stand.knoten!,
storage: a.storage,
}),
{
zeitlimitMs: 60 * 60 * 1000,
intervallMs: 2000,
// „transferred 1.2 GiB of 3.5 GiB (35.16%)“ – auf langsamen Storages dauert ein Voll-Klon.
beiLog: (zeilen) => {
const letzte = zeilen
.map((z) => /transferred (.+) of (.+) \((\d+(?:\.\d+)?)%\)/.exec(z))
.filter(Boolean)
.at(-1);
if (letzte)
void k.detail(`${letzte[1]} von ${letzte[2]} (${Math.round(Number(letzte[3]))} %)`);
},
},
);
try {
await klonen(stand.vmid!);
} catch (fehler) {
// Gleichzeitig außerhalb von ProxDeploy vergeben? Einmal mit neuer ID versuchen.
if (!(fehler instanceof ProxmoxFehler) || !/already exists/i.test(fehler.ursache) || a.vmid)
throw fehler;
const belegt = new Set(
(await client.get<Ressource[]>("/cluster/resources", { type: "vm" })).map((r) => r.vmid!),
);
stand.vmid = await e.vmidReservieren(belegt);
await k.log(`VMID war inzwischen belegt – neuer Versuch mit ${stand.vmid}`, "warn");
await klonen(stand.vmid);
}
await e.angelegt({ vmid: stand.vmid });
return { detail: `full clone ${tpl.vmid} → ${stand.vmid}` };
},
},
{
schluessel: "hardware",
titel: "Hardware",
ausfuehren: async () => {
await client.put(`${vmPfad()}/config`, {
cores: a.cores,
memory: a.speicherMb,
cpu: "host",
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
onboot: a.startBeiBoot,
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
});
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
const groesse = /size=(\d+)G/.exec(konfig["scsi0"] ?? "")?.[1];
if (client.trocken || !groesse || Number(groesse) < a.diskGb) {
await client.put(`${vmPfad()}/resize`, { disk: "scsi0", size: `${a.diskGb}G` });
}
return { detail: `${a.cores} CPU · ${a.speicherMb} MB · Disk ${a.diskGb}G` };
},
},
{
schluessel: "cloudinit",
titel: "cloud-init",
ausfuehren: async () => {
await client.put(`${vmPfad()}/config`, {
ciuser: a.zugang.benutzer,
sshkeys: sshkeysKodieren(a.zugang.sshSchluessel),
ipconfig0: ipconfig(a.netz),
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
// Updates macht das Profil base als sichtbare Phase, nicht stumm beim ersten Boot.
ciupgrade: false,
});
const schluessel = a.zugang.sshSchluessel.length;
return {
detail: `user ${a.zugang.benutzer} · ${schluessel} SSH-Key${schluessel === 1 ? "" : "s"} · ${a.netz.modus === "dhcp" ? "DHCP" : a.netz.ip}${
e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""
}`,
};
},
},
{
schluessel: "start",
titel: "Start",
ausfuehren: async () => {
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad()}/status/start`), {
zeitlimitMs: 5 * 60 * 1000,
});
return { detail: `VM ${stand.vmid} läuft` };
},
},
{
schluessel: "agent",
titel: "Guest-Agent",
ausfuehren: async (k) => {
if (client.trocken) return { detail: "wartet auf Antwort (ping)" };
const beginn = Date.now();
const grenze = e.agentMs ?? 5 * 60 * 1000;
for (;;) {
await k.pruefeAbbruch();
try {
await agent("ping");
break;
} catch (fehler) {
if (Date.now() - beginn > grenze) {
throw new AblaufFehler(
`Der Guest-Agent antwortet nach ${Math.round(grenze / 1000)} s nicht.`,
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
);
}
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
}
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
await warten(3000);
}
stand.agentNachMs = Date.now() - beginn;
// IP: bei statischer Adresse bekannt, sonst aus dem Agent (erste IPv4 außer Loopback).
if (a.netz.modus === "statisch") {
stand.ip = a.netz.ip!.split("/")[0];
} else {
for (let versuch = 0; versuch < 20 && !stand.ip; versuch += 1) {
const schnittstellen = (
await agent<AgentAntwort<Netzschnittstelle[]>>("network-get-interfaces", "GET")
).result;
stand.ip = schnittstellen
.filter((s) => s.name !== "lo")
.flatMap((s) => s["ip-addresses"] ?? [])
.find((ip) => ip["ip-address-type"] === "ipv4" && !ip["ip-address"].startsWith("169.254."))?.[
"ip-address"
];
if (!stand.ip) await warten(3000);
}
}
return {
detail: `antwortet nach ${Math.round(stand.agentNachMs / 1000)}s${stand.ip ? ` · IP ${stand.ip}` : ""}`,
};
},
},
{
schluessel: "provisionierung",
titel: "Provisionierung",
ausfuehren: async (k) => {
const adapter = adapterFuer(d.family);
if (!adapter)
throw new AblaufFehler(
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
"Adapter ergänzen (docs/katalog.md).",
);
const { skript } = profileRendern(
e.profile,
adapter,
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } },
[
adapter.benutzerAnlegen({
name: a.zugang.benutzer,
sshSchluessel: a.zugang.sshSchluessel,
sudo: true,
}),
],
);
await k.log(
`Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`,
"debug",
);
if (client.trocken) {
await k.log(skript, "info");
return {
detail:
e.profile.length > 0
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")}`
: "nur Benutzer",
};
}
// Hochladen in Stücken (file-write ersetzt die Datei, hängt nicht an) und zusammensetzen.
const bytes = Buffer.from(skript, "utf8");
const stuecke: Buffer[] = [];
for (let i = 0; i < bytes.length; i += STUECK) stuecke.push(bytes.subarray(i, i + STUECK));
for (const [nr, inhalt] of stuecke.entries()) {
await agent("file-write", "POST", {
file: `${SKRIPT}.${nr}`,
content: inhalt.toString("base64"),
encode: "0",
});
}
const zusammensetzen = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
const { pid } = await agent<{ pid: number }>("exec", "POST", {
command: [
"/bin/bash",
"-c",
`cat ${zusammensetzen} > ${SKRIPT} && rm -f ${zusammensetzen} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
],
});
const beginn = Date.now();
const grenze = e.provisionierungMs ?? 60 * 60 * 1000;
let gelesen = 0;
let profil = "";
for (;;) {
await warten(3000);
const status = await agent<ExecStatus>("exec-status", "GET", { pid: String(pid) });
// Fortschritt: neue Zeilen der Logdatei im Gast.
try {
const datei = await agent<{ content: string; truncated?: boolean }>("file-read", "GET", {
file: LOG,
});
const zeilen = datei.content.split("\n");
for (const zeile of zeilen.slice(gelesen, zeilen.length - 1)) {
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
if (marke) profil = marke[1]!;
await k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
}
gelesen = Math.max(gelesen, zeilen.length - 1);
} catch {
// Logdatei noch nicht da – beim nächsten Mal.
}
const sekunden = Math.round((Date.now() - beginn) / 1000);
if (status.exited) {
if (status.exitcode !== 0) {
throw new AblaufFehler(
`Provisionierung ${profil ? `im Profil ${profil} ` : ""}mit Code ${status.exitcode} abgebrochen.`,
`Detail-Log oder ${LOG} in der VM ansehen.`,
);
}
break;
}
if (Date.now() - beginn > grenze) {
throw new AblaufFehler(
`Provisionierung läuft länger als ${Math.round(grenze / 60000)} min.`,
`${LOG} in der VM ansehen.`,
);
}
await k.detail(
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(sekunden / 60)}m ${String(sekunden % 60).padStart(2, "0")}s`,
);
}
await agent("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
return {
detail:
e.profile.length > 0
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
: "Benutzer eingerichtet",
};
},
},
];
return schritte;
}
/** SSH-Befehl für die Ergebnis-Karte. */
export function sshBefehl(benutzer: string, ip: string | undefined): string | undefined {
return ip ? `ssh ${benutzer}@${ip}` : undefined;
}