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
+36 -2
View File
@@ -1,4 +1,5 @@
import type { Distribution } from "@/engine/katalog/schema";
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { prisma } from "@/lib/db";
@@ -27,6 +28,7 @@ export async function templateBauStarten(
if (!(distribution.definition as Distribution).vm) {
throw new AnwendungsFehler(400, "ungueltig", `${distribution.name} bietet kein VM-Image.`);
}
await templateProfileAufloesen(anfrage.profile, (distribution.definition as Distribution).family);
return await auftragAnlegen(
{
art: "TEMPLATE_BAUEN",
@@ -42,6 +44,33 @@ export async function templateBauStarten(
);
}
/**
* Profile fürs Template: aufgelöst mit Standardwerten. Ein Profil mit Pflichtvariable (z. B. der
* Zabbix-Server) ist gerätespezifisch und gehört nicht ins Template.
*/
export async function templateProfileAufloesen(
auswahl: string[],
familie: string,
): Promise<AufgeloestesProfil[]> {
if (auswahl.length === 0) return [];
const verfuegbar = new Map(
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
p.id,
p.definition as Profil,
]),
);
const aufgeloest = profileAufloesen(auswahl, verfuegbar, familie);
if (!aufgeloest.ok) {
throw new AnwendungsFehler(
400,
"ungueltig",
aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`).join(" "),
"Profile mit Pflichtvariablen gehören nicht ins Template – sie laufen beim Ausrollen.",
);
}
return aufgeloest.profile;
}
export type TemplateSicht = {
vmid: number;
name: string;
@@ -49,6 +78,7 @@ export type TemplateSicht = {
distributionId: string | null;
gebautAm: string | null;
imageDatei: string | null;
profile: string[];
};
/**
@@ -74,9 +104,13 @@ export async function templatesListe(serverName: string): Promise<TemplateSicht[
vmid: r.vmid,
name: r.name ?? `VM ${r.vmid}`,
knoten: r.node,
distributionId: db?.distributionId ?? tags.find((t) => t !== "proxdeploy-template") ?? null,
distributionId:
db?.distributionId ??
tags.find((t) => t !== "proxdeploy-template" && !t.startsWith("profil-")) ??
null,
gebautAm: db?.gebautAm.toISOString() ?? null,
imageDatei: db?.imageDatei ?? null,
profile: tags.filter((t) => t.startsWith("profil-")).map((t) => t.slice("profil-".length)),
};
})
.sort((a, b) => a.vmid - b.vmid);