Beim Template-Bau lassen sich Profile wählen (nur solche ohne Pflichtvariablen). Nach dem Vorbereitungsstart laufen sie über den Guest-Agent; danach werden Maschinen-ID, SSH-Host-Keys, Docker-Engine-ID und apt-Cache entfernt. Ausrollen nimmt das Template mit den meisten passenden Profilen und überspringt, was schon eingebacken ist. Auf pve-demo: Provisionierung mit base+docker 141 s ohne, ~30 s mit Template. Dabei gefunden: docker nimmt den Benutzer per guest.user in die Gruppe auf – Profile mit guest.* laufen deshalb beim Deploy noch einmal (idempotent, schnell). Closes #022 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
471 lines
20 KiB
TypeScript
471 lines
20 KiB
TypeScript
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||
import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||
import type { Distribution } from "@/engine/katalog/schema";
|
||
import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme";
|
||
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
||
import { q } from "@/engine/shell";
|
||
import { adapterFuer } from "@/engine/adapter";
|
||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||
import { profileRendern } from "@/engine/profile/rendern";
|
||
import { agentAbwarten, skriptAusfuehren } from "./gastagent";
|
||
|
||
/**
|
||
* Template-Bau für VMs: Cloud-Image laden und prüfen, VM anlegen, Disk importieren, Guest-Agent
|
||
* einbauen, als Template markieren. Geprüft gegen die Proxmox-API (docs/architektur.md, Abschnitt 3):
|
||
*
|
||
* - **per API** ab PVE 8.4 mit einem Storage vom Inhaltstyp `import`: `download-url` (mit
|
||
* Prüfsumme, Proxmox prüft selbst) und `import-from=<storage>:import/<datei>`
|
||
* - **per SSH** davor oder ohne import-Storage: Download und Prüfung auf dem Node,
|
||
* `qm set --scsi0 …,import-from=<pfad>` als root
|
||
*
|
||
* Der Guest-Agent ist in keinem der mitgelieferten Images enthalten. Er wird mit einem einmaligen
|
||
* Vorbereitungsstart eingebaut (cloud-init vendor-data als Snippet, braucht SSH), damit jeder Klon
|
||
* sofort einen Agent hat – ohne ihn gäbe es bei DHCP weder IP noch Provisionierung.
|
||
*/
|
||
|
||
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
|
||
export type NodeBefehl = (
|
||
befehl: string,
|
||
optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void },
|
||
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
|
||
|
||
export type TemplateBauEingabe = {
|
||
client: ProxmoxClient;
|
||
distribution: Distribution;
|
||
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
|
||
profile?: AufgeloestesProfil[];
|
||
knoten: string;
|
||
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
|
||
storage: string;
|
||
bridge: string;
|
||
vlan?: number;
|
||
/** null, wenn SSH nicht eingerichtet oder der Host-Key nicht bestätigt ist. */
|
||
ssh: NodeBefehl | null;
|
||
/** VMID im Template-Bereich reservieren (nach Prüfung, wer schon was belegt). */
|
||
vmidReservieren: (belegt: Set<number>) => Promise<number>;
|
||
/** Prüfsummen-Datei holen – im Test ersetzbar. */
|
||
holen?: (url: string) => Promise<string>;
|
||
auftragKurzId: string;
|
||
jetzt?: () => Date;
|
||
/** Zeitlimit für den Vorbereitungsstart (Paketinstallation im Gast). */
|
||
vorbereitungMs?: number;
|
||
warten?: (ms: number) => Promise<void>;
|
||
};
|
||
|
||
export type TemplateBauStand = {
|
||
vmid?: number;
|
||
weg?: "api" | "ssh";
|
||
importStorage?: string;
|
||
snippetStorage?: string;
|
||
imageDatei?: string;
|
||
pruefsumme?: string;
|
||
volid?: string;
|
||
nodePfad?: string;
|
||
};
|
||
|
||
type StorageEintrag = {
|
||
storage: string;
|
||
type: string;
|
||
content: string;
|
||
active?: number;
|
||
enabled?: number;
|
||
shared?: number;
|
||
};
|
||
|
||
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||
|
||
async function standardHolen(url: string): Promise<string> {
|
||
const antwort = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
||
if (!antwort.ok)
|
||
throw new AblaufFehler(
|
||
`Prüfsummen-Datei nicht abrufbar (HTTP ${antwort.status}): ${url}`,
|
||
"Hat ProxDeploy Internetzugang? Stimmt die URL im Katalog?",
|
||
);
|
||
return await antwort.text();
|
||
}
|
||
|
||
export function templateName(distribution: Distribution, profile: string[] = []): string {
|
||
return ["tpl", distribution.id, ...profile.filter((p) => p !== "base")]
|
||
.join("-")
|
||
.replace(/[^a-z0-9-]/g, "-");
|
||
}
|
||
|
||
/**
|
||
* Aufräumen vor dem Template, wenn Profile eingebacken wurden – alles, was je Klon eindeutig sein
|
||
* muss oder nur Platz kostet. SSH-Host-Keys erzeugt cloud-init bei jedem Klon neu.
|
||
*/
|
||
export const TEMPLATE_AUFRAEUMEN = [
|
||
"set -e",
|
||
"truncate -s 0 /etc/machine-id",
|
||
"rm -f /var/lib/dbus/machine-id",
|
||
"rm -f /etc/ssh/ssh_host_*",
|
||
"rm -f /var/lib/docker/engine-id",
|
||
"apt-get clean || true",
|
||
"rm -f /var/log/proxdeploy-provisionierung.log",
|
||
"cloud-init clean --logs",
|
||
"sync",
|
||
].join("; ");
|
||
|
||
/**
|
||
* vendor-data für den Vorbereitungsstart: Guest-Agent installieren, Maschinen-ID leeren (sonst
|
||
* teilen sich alle Klone DHCP-Client-ID und journald-ID), cloud-init zurücksetzen, ausschalten.
|
||
* SSH-Host-Keys erzeugt cloud-init bei jedem Klon ohnehin neu (neue instance-id).
|
||
*/
|
||
export function vorbereitungsSnippet(): string {
|
||
return `#cloud-config
|
||
# Von ProxDeploy: einmaliger Vorbereitungsstart beim Template-Bau
|
||
package_update: true
|
||
packages:
|
||
- qemu-guest-agent
|
||
runcmd:
|
||
- [systemctl, enable, qemu-guest-agent]
|
||
- [sh, -c, "truncate -s 0 /etc/machine-id && rm -f /var/lib/dbus/machine-id && cloud-init clean --logs"]
|
||
power_state:
|
||
mode: poweroff
|
||
delay: now
|
||
message: ProxDeploy-Vorbereitung abgeschlossen
|
||
timeout: 300
|
||
condition: true
|
||
`;
|
||
}
|
||
|
||
export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
|
||
const { client, distribution: d, knoten } = e;
|
||
const vm = d.vm;
|
||
if (!vm)
|
||
throw new AblaufFehler(
|
||
`${d.name} bietet kein VM-Image.`,
|
||
"Im Katalog vm: ergänzen oder eine andere Distribution wählen.",
|
||
);
|
||
const holen = e.holen ?? standardHolen;
|
||
const warten = e.warten ?? schlafen;
|
||
const snippetDatei = `proxdeploy-vorbereitung-${e.auftragKurzId}.yaml`;
|
||
const profilIds = (e.profile ?? []).map((p) => p.profil.id);
|
||
const name = templateName(d, profilIds);
|
||
|
||
const ssh = async (
|
||
befehl: string,
|
||
k: PhasenKontext,
|
||
optionen?: { eingabe?: string; zeitlimitMs?: number },
|
||
) => {
|
||
if (!e.ssh)
|
||
throw new AblaufFehler(
|
||
"Dafür ist SSH zum Node nötig.",
|
||
"SSH in den Server-Einstellungen einrichten und den Host-Key bestätigen.",
|
||
);
|
||
if (client.trocken) {
|
||
await k.log(`[ssh ${knoten}] ${befehl}`, "info");
|
||
return { code: 0, stdout: "", stderr: "" };
|
||
}
|
||
await k.log(`[ssh ${knoten}] ${befehl.split("\n")[0]}`, "debug");
|
||
const ergebnis = await e.ssh(befehl, optionen);
|
||
if (ergebnis.code !== 0) {
|
||
throw new AblaufFehler(
|
||
`Befehl auf ${knoten} fehlgeschlagen (Code ${ergebnis.code}): ${(ergebnis.stderr || ergebnis.stdout).trim().split("\n").slice(-3).join(" | ")}`,
|
||
"Detail-Log prüfen.",
|
||
);
|
||
}
|
||
return ergebnis;
|
||
};
|
||
|
||
return [
|
||
{
|
||
schluessel: "pruefungen",
|
||
titel: "Prüfungen",
|
||
ausfuehren: async () => {
|
||
const version = await client.get<{ version: string }>(pfad`/nodes/${knoten}/version`).catch(() => {
|
||
throw new AblaufFehler(
|
||
`Node ${knoten} ist nicht erreichbar oder unbekannt.`,
|
||
"Node-Namen und Cluster-Status prüfen.",
|
||
);
|
||
});
|
||
const pve = versionLesen(version.version)!;
|
||
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${knoten}/storage`);
|
||
const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0;
|
||
const ziel = storages.find((s) => s.storage === e.storage);
|
||
if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("images")) {
|
||
throw new AblaufFehler(
|
||
`Storage ${e.storage} ist auf ${knoten} nicht aktiv oder nimmt keine VM-Disks auf.`,
|
||
`Geeignet sind: ${
|
||
storages
|
||
.filter((s) => aktiv(s) && s.content.includes("images"))
|
||
.map((s) => s.storage)
|
||
.join(", ") || "keiner"
|
||
}.`,
|
||
);
|
||
}
|
||
const importSpeicher = storages.find((s) => aktiv(s) && s.content.split(",").includes("import"));
|
||
stand.snippetStorage = storages.find(
|
||
(s) => aktiv(s) && s.content.split(",").includes("snippets"),
|
||
)?.storage;
|
||
if (mindestens(pve, 8, 4) && importSpeicher) {
|
||
stand.weg = "api";
|
||
stand.importStorage = importSpeicher.storage;
|
||
} else if (e.ssh) {
|
||
stand.weg = "ssh";
|
||
} else {
|
||
throw new AblaufFehler(
|
||
mindestens(pve, 8, 4)
|
||
? `Auf ${knoten} gibt es keinen Storage mit Inhaltstyp „Import“.`
|
||
: `PVE ${pve.text} kann Disk-Images erst ab 8.4 per API laden.`,
|
||
mindestens(pve, 8, 4)
|
||
? "Einem Dir-Storage den Inhaltstyp „Import“ geben (Datacenter → Storage) oder SSH einrichten."
|
||
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
|
||
);
|
||
}
|
||
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) {
|
||
throw new AblaufFehler(
|
||
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.",
|
||
e.ssh
|
||
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben."
|
||
: "SSH in den Server-Einstellungen einrichten.",
|
||
);
|
||
}
|
||
const netze = await client.get<Array<{ iface: string; type: string }>>(
|
||
pfad`/nodes/${knoten}/network`,
|
||
);
|
||
if (!netze.some((n) => n.iface === e.bridge && (n.type === "bridge" || n.type === "OVSBridge"))) {
|
||
throw new AblaufFehler(
|
||
`Bridge ${e.bridge} gibt es auf ${knoten} nicht.`,
|
||
"Bridge in den Server-Standards oder im Formular korrigieren.",
|
||
);
|
||
}
|
||
const belegt = new Set(
|
||
(await client.get<Array<{ vmid?: number }>>("/cluster/resources", { type: "vm" }))
|
||
.map((r) => r.vmid!)
|
||
.filter(Boolean),
|
||
);
|
||
stand.vmid = await e.vmidReservieren(belegt);
|
||
return {
|
||
detail: `VMID ${stand.vmid} frei · ${e.storage} · ${stand.weg === "api" ? `Import über ${stand.importStorage}` : "Import per SSH"} · ${e.bridge}${e.vlan ? ` VLAN ${e.vlan}` : ""}`,
|
||
};
|
||
},
|
||
},
|
||
{
|
||
schluessel: "image",
|
||
titel: "Image laden",
|
||
ausfuehren: async (k) => {
|
||
const quelle = dateiAusUrl(vm.image_url);
|
||
await k.detail("Prüfsumme holen …");
|
||
const summen = await holen(vm.checksum_url);
|
||
const hash = pruefsummeFinden(summen, quelle, vm.checksum_type);
|
||
if (!hash) {
|
||
throw new AblaufFehler(
|
||
`${quelle} steht nicht in ${vm.checksum_url}.`,
|
||
"Image- und Prüfsummen-URL im Katalog prüfen – der Anbieter hat die Dateien vielleicht umbenannt.",
|
||
);
|
||
}
|
||
stand.pruefsumme = hash;
|
||
// Die Prüfsumme im Dateinamen: ein neues „latest“-Image wird neu geladen, ein gleiches nicht.
|
||
stand.imageDatei = `proxdeploy-${d.id}-${hash.slice(0, 12)}.${vm.format}`;
|
||
await k.log(`${vm.checksum_type} ${hash} für ${quelle}`);
|
||
|
||
if (stand.weg === "api") {
|
||
const volid = `${stand.importStorage}:import/${stand.imageDatei}`;
|
||
stand.volid = volid;
|
||
const vorhanden = await client.get<Array<{ volid: string }>>(
|
||
pfad`/nodes/${knoten}/storage/${stand.importStorage!}/content`,
|
||
{ content: "import" },
|
||
);
|
||
if (vorhanden.some((v) => v.volid === volid))
|
||
return { detail: `${stand.imageDatei} schon vorhanden` };
|
||
const upid = await client.post<string>(
|
||
pfad`/nodes/${knoten}/storage/${stand.importStorage!}/download-url`,
|
||
{
|
||
content: "import",
|
||
filename: stand.imageDatei,
|
||
url: vm.image_url,
|
||
checksum: hash,
|
||
"checksum-algorithm": vm.checksum_type,
|
||
"verify-certificates": true,
|
||
},
|
||
);
|
||
await aufgabeAbwarten(client, upid, {
|
||
zeitlimitMs: 60 * 60 * 1000,
|
||
intervallMs: 2000,
|
||
beiLog: (zeilen) => {
|
||
const prozent = zeilen
|
||
.map((z) => /(\d+(?:\.\d+)?)%/.exec(z)?.[1])
|
||
.filter(Boolean)
|
||
.at(-1);
|
||
if (prozent) void k.detail(`${quelle} · ${Math.round(Number(prozent))} %`);
|
||
for (const z of zeilen) void k.log(z, "debug");
|
||
},
|
||
});
|
||
return { detail: `${quelle} geladen und geprüft (${vm.checksum_type})` };
|
||
}
|
||
|
||
// SSH: auf den Node laden und dort prüfen; vorhandene gültige Datei wird wiederverwendet.
|
||
const ordner = "/var/tmp/proxdeploy";
|
||
stand.nodePfad = `${ordner}/${stand.imageDatei}`;
|
||
const pruefen = `echo ${q(`${hash} ${stand.nodePfad}`)} | ${vm.checksum_type}sum -c --status`;
|
||
await k.detail(`${quelle} wird auf ${knoten} geladen …`);
|
||
await ssh(
|
||
`set -e; mkdir -p ${ordner}; if ! ${pruefen} 2>/dev/null; then curl -fsSL --retry 3 -o ${q(stand.nodePfad + ".teil")} ${q(vm.image_url)} && mv ${q(stand.nodePfad + ".teil")} ${q(stand.nodePfad)}; fi; ${pruefen}`,
|
||
k,
|
||
{ zeitlimitMs: 60 * 60 * 1000 },
|
||
);
|
||
return { detail: `${quelle} auf ${knoten} geladen und geprüft (${vm.checksum_type})` };
|
||
},
|
||
},
|
||
{
|
||
schluessel: "vm",
|
||
titel: "VM anlegen",
|
||
ausfuehren: async (k) => {
|
||
const vmid = stand.vmid!;
|
||
const jetzt = (e.jetzt?.() ?? new Date()).toISOString();
|
||
const importVon = stand.weg === "api" ? stand.volid! : stand.nodePfad!;
|
||
const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`;
|
||
const parameter = {
|
||
vmid,
|
||
name,
|
||
ostype: "l26",
|
||
machine: "q35",
|
||
bios: vm.bios,
|
||
...(vm.bios === "ovmf" ? { efidisk0: `${e.storage}:1,efitype=4m,pre-enrolled-keys=1` } : {}),
|
||
cpu: "host",
|
||
cores: 2,
|
||
memory: 2048,
|
||
scsihw: "virtio-scsi-single",
|
||
net0: `virtio,bridge=${e.bridge}${e.vlan ? `,tag=${e.vlan}` : ""}`,
|
||
serial0: "socket",
|
||
vga: "serial0",
|
||
agent: "enabled=1,fstrim_cloned_disks=1",
|
||
ide2: `${e.storage}:cloudinit`,
|
||
ipconfig0: "ip=dhcp",
|
||
ciupgrade: false,
|
||
tags: ["proxdeploy-template", d.id, ...profilIds.map((p) => `profil-${p}`)].join(";"),
|
||
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}${
|
||
profilIds.length > 0 ? `\nEingebackene Profile: ${profilIds.join(", ")}` : ""
|
||
}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
|
||
};
|
||
if (stand.weg === "api") {
|
||
await aufgabeAbwarten(
|
||
client,
|
||
await client.post<string>(pfad`/nodes/${knoten}/qemu`, {
|
||
...parameter,
|
||
scsi0: disk,
|
||
boot: "order=scsi0",
|
||
}),
|
||
{
|
||
zeitlimitMs: 30 * 60 * 1000,
|
||
},
|
||
);
|
||
} else {
|
||
// Ein absoluter Pfad in import-from ist per API nur root@pam erlaubt – daher qm über SSH.
|
||
await aufgabeAbwarten(client, await client.post<string>(pfad`/nodes/${knoten}/qemu`, parameter), {
|
||
zeitlimitMs: 10 * 60 * 1000,
|
||
});
|
||
await k.detail("Disk wird importiert …");
|
||
await ssh(`qm set ${vmid} --scsi0 ${q(disk)} --boot order=scsi0`, k, {
|
||
zeitlimitMs: 30 * 60 * 1000,
|
||
});
|
||
}
|
||
return {
|
||
detail: `VM ${vmid} (${name}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
|
||
};
|
||
},
|
||
},
|
||
{
|
||
schluessel: "agent",
|
||
titel: "Guest-Agent",
|
||
ausfuehren: async (k) => {
|
||
if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
|
||
const vmid = stand.vmid!;
|
||
const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`;
|
||
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
|
||
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
|
||
eingabe: vorbereitungsSnippet(),
|
||
});
|
||
try {
|
||
await client.put(`${vmPfad}/config`, { cicustom: `vendor=${snippet}` });
|
||
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
|
||
zeitlimitMs: 5 * 60 * 1000,
|
||
});
|
||
if (!client.trocken) {
|
||
const beginn = Date.now();
|
||
const grenze = e.vorbereitungMs ?? 20 * 60 * 1000;
|
||
for (;;) {
|
||
await k.pruefeAbbruch();
|
||
const status = await client.get<{ status: string }>(`${vmPfad}/status/current`);
|
||
if (status.status === "stopped") break;
|
||
if (Date.now() - beginn > grenze) {
|
||
throw new AblaufFehler(
|
||
`Die Vorbereitung von VM ${vmid} ist nach ${Math.round(grenze / 60000)} min nicht fertig.`,
|
||
"Hat die VM per DHCP eine Adresse und Internetzugang (für qemu-guest-agent)? Konsole der VM ansehen.",
|
||
);
|
||
}
|
||
await k.detail(
|
||
`Vorbereitungsstart: qemu-guest-agent wird installiert … ${Math.round((Date.now() - beginn) / 1000)}s`,
|
||
);
|
||
await warten(5000);
|
||
}
|
||
}
|
||
} finally {
|
||
// Auch nach einem Fehler: das Snippet soll nicht im Template hängen bleiben.
|
||
await client.put(`${vmPfad}/config`, { delete: "cicustom" }).catch(() => undefined);
|
||
await ssh(`rm -f "$(pvesm path ${q(snippet)})"`, k).catch(() => undefined);
|
||
}
|
||
return { detail: "qemu-guest-agent installiert · Maschinen-ID geleert" };
|
||
},
|
||
},
|
||
{
|
||
schluessel: "profile",
|
||
titel: "Profile",
|
||
ausfuehren: async (k) => {
|
||
if (profilIds.length === 0) return { uebersprungen: true, detail: "keine eingebacken" };
|
||
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 vmPfad = pfad`/nodes/${knoten}/qemu/${stand.vmid!}`;
|
||
const { skript } = profileRendern(e.profile!, adapter, {
|
||
os: d.os,
|
||
gast: { name, benutzer: vm.default_user, typ: "vm" },
|
||
});
|
||
if (client.trocken) {
|
||
await k.log(skript, "info");
|
||
return { detail: `${profilIds.join(", ")} würden eingebacken` };
|
||
}
|
||
await aufgabeAbwarten(client, await client.post<string>(`${vmPfad}/status/start`), {
|
||
zeitlimitMs: 5 * 60 * 1000,
|
||
});
|
||
await agentAbwarten(client, vmPfad, k, { grenzeMs: 5 * 60 * 1000, warten });
|
||
await skriptAusfuehren(client, vmPfad, skript, k, { grenzeMs: 60 * 60 * 1000, warten });
|
||
await k.detail("aufräumen und herunterfahren …");
|
||
// Über denselben Weg wie die Profile: wartet aufs Ende, statt eine Pause zu raten.
|
||
await skriptAusfuehren(client, vmPfad, `#!/bin/bash\n${TEMPLATE_AUFRAEUMEN}\n`, k, {
|
||
grenzeMs: 5 * 60 * 1000,
|
||
warten,
|
||
});
|
||
await aufgabeAbwarten(
|
||
client,
|
||
await client.post<string>(`${vmPfad}/status/shutdown`, { timeout: 180 }),
|
||
{ zeitlimitMs: 5 * 60 * 1000 },
|
||
);
|
||
for (let i = 0; i < 60; i += 1) {
|
||
if ((await client.get<{ status: string }>(`${vmPfad}/status/current`)).status === "stopped") break;
|
||
await warten(3000);
|
||
}
|
||
return { detail: `${profilIds.join(", ")} eingebacken · Maschinen-ID und Host-Keys entfernt` };
|
||
},
|
||
},
|
||
{
|
||
schluessel: "template",
|
||
titel: "Template",
|
||
ausfuehren: async () => {
|
||
await aufgabeAbwarten(
|
||
client,
|
||
await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
|
||
{ zeitlimitMs: 10 * 60 * 1000 },
|
||
);
|
||
return { detail: `VM ${stand.vmid} ist jetzt Template ${name}` };
|
||
},
|
||
},
|
||
];
|
||
}
|