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:
2026-10-12 14:20:32 +02:00
co-authored by Claude Opus 5.5
parent ebcf63100a
commit 00712dc6ce
16 changed files with 541 additions and 154 deletions
+78 -7
View File
@@ -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}` };
},
},
];