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>
486 lines
20 KiB
TypeScript
486 lines
20 KiB
TypeScript
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;
|
||
}
|