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:
@@ -0,0 +1,473 @@
|
||||
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: 30 * 60 * 1000 },
|
||||
);
|
||||
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 } },
|
||||
[
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user