feat(templates): Profile ins Template einbacken
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>
This commit is contained in:
@@ -6,6 +6,10 @@ 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
|
||||
@@ -30,6 +34,8 @@ export type NodeBefehl = (
|
||||
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;
|
||||
@@ -80,10 +86,28 @@ async function standardHolen(url: string): Promise<string> {
|
||||
return await antwort.text();
|
||||
}
|
||||
|
||||
export function templateName(distribution: Distribution): string {
|
||||
return `tpl-${distribution.id.replace(/[^a-z0-9-]/g, "-")}`;
|
||||
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.
|
||||
@@ -118,6 +142,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
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,
|
||||
@@ -294,7 +320,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
const disk = `${e.storage}:0,import-from=${importVon},discard=on,ssd=1,iothread=1`;
|
||||
const parameter = {
|
||||
vmid,
|
||||
name: templateName(d),
|
||||
name,
|
||||
ostype: "l26",
|
||||
machine: "q35",
|
||||
bios: vm.bios,
|
||||
@@ -310,8 +336,10 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
ide2: `${e.storage}:cloudinit`,
|
||||
ipconfig0: "ip=dhcp",
|
||||
ciupgrade: false,
|
||||
tags: `proxdeploy-template;${d.id}`,
|
||||
description: `ProxDeploy-Template ${d.name}\nImage: ${dateiAusUrl(vm.image_url)}\n${vm.checksum_type}: ${stand.pruefsumme}\nGebaut: ${jetzt} (Auftrag ${e.auftragKurzId})`,
|
||||
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(
|
||||
@@ -336,7 +364,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
});
|
||||
}
|
||||
return {
|
||||
detail: `VM ${vmid} (${templateName(d)}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
|
||||
detail: `VM ${vmid} (${name}) · q35 · ${vm.bios === "ovmf" ? "UEFI" : "SeaBIOS"} · Disk importiert`,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -383,6 +411,49 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
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",
|
||||
@@ -392,7 +463,7 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta
|
||||
await client.post<string>(pfad`/nodes/${knoten}/qemu/${stand.vmid!}/template`),
|
||||
{ zeitlimitMs: 10 * 60 * 1000 },
|
||||
);
|
||||
return { detail: `VM ${stand.vmid} ist jetzt Template ${templateName(d)}` };
|
||||
return { detail: `VM ${stand.vmid} ist jetzt Template ${name}` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user