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:
@@ -8,6 +8,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
## [Unveröffentlicht]
|
||||
|
||||
### Hinzugefügt
|
||||
- Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022)
|
||||
- Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011)
|
||||
- Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012)
|
||||
- Ubuntu 22.04/24.04/26.04 als VM und LXC; Fortschritt beim Klonen aus dem Task-Log (#010)
|
||||
- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009)
|
||||
- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009)
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
# #022 Templates mit eingebackenen Profilen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 35 min
|
||||
|
||||
## Ziel
|
||||
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
|
||||
fertig sind. Wunsch von Florian am 2026-10-12.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
|
||||
- [ ] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
|
||||
- [ ] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
|
||||
- [ ] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
|
||||
- [ ] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
|
||||
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template (Standard bleibt: Schlüssel je Deployment, siehe #015)
|
||||
- [x] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
|
||||
- [x] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
|
||||
- [x] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
|
||||
- [x] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
|
||||
- [x] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
|
||||
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template – zurückgestellt; Standard-Schlüssel je Benutzer/Team kommen stattdessen (#015)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Template debian-13 + docker auf pve-demo; Deploy daraus mit docker deutlich schneller als ohne
|
||||
- [x] Template 9002 `tpl-debian-13-docker` auf pve-demo (8 min). Provisionierung beim Deploy: 141 s ohne, 29–32 s mit eingebackenem base+docker
|
||||
|
||||
## Abhängigkeiten
|
||||
#007, #008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
|
||||
- **Fund:** Profile mit `{{ guest.* }}` hängen vom einzelnen Gast ab (docker nimmt den Benutzer in die Gruppe auf). Sie laufen beim Deploy trotzdem noch einmal – idempotent und schnell. Übersprungen wird nur, was gastunabhängig ist und Standardwerte hat.
|
||||
- Vor dem Template: Maschinen-ID leeren, SSH-Host-Keys und Docker-engine-id löschen, apt-Cache leeren, `cloud-init clean`; geprüft: Klone haben eigene Maschinen-ID und eigene Host-Keys.
|
||||
- Ausrollen wählt das Template mit den meisten eingebackenen Profilen, deren Profile alle gewählt sind – nie eins mit ungewählter Software.
|
||||
- Gemeinsames Modul `gastagent.ts` für Provisionierung über den Guest-Agent (Deploy und Template).
|
||||
|
||||
@@ -25,4 +25,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
|
||||
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | offen | v0.3.0 | 4 h | – |
|
||||
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 35 min |
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "profile" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -271,6 +271,8 @@ model Template {
|
||||
storage String
|
||||
imageDatei String
|
||||
pruefsumme String
|
||||
/// Eingebackene Profile (#022), aufgelöst inkl. Voraussetzungen.
|
||||
profile String[] @default([])
|
||||
gebautVonAuftragId String?
|
||||
gebautAm DateTime @default(now())
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { AnwendungsFehler } from "@/server/fehler";
|
||||
import { distributionenListe } from "@/server/services/katalog";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { templatesListe, type TemplateSicht } from "@/server/services/templates";
|
||||
import { TemplateBauen } from "./template-bauen";
|
||||
|
||||
@@ -48,6 +48,7 @@ export default async function TemplatesSeite() {
|
||||
const distributionen = (await distributionenListe({ typ: "vm", nurAktive: true })).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
familie: d.familie,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -83,6 +84,7 @@ export default async function TemplatesSeite() {
|
||||
{" "}
|
||||
· {t.knoten}
|
||||
{t.distributionId ? ` · ${t.distributionId}` : ""}
|
||||
{t.profile.length > 0 ? ` · mit ${t.profile.join(", ")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted text-sm">
|
||||
@@ -110,6 +112,14 @@ export default async function TemplatesSeite() {
|
||||
bridges: k.bridges.map((b) => b.name),
|
||||
}))}
|
||||
faehigkeit={bericht.faehigkeiten.templateBau}
|
||||
profile={(await profileListe({ nurAktive: true }))
|
||||
.filter((p) => !p.definition.variables.some((v) => v.required && v.default === undefined))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
requires: p.definition.requires,
|
||||
familien: p.familien,
|
||||
}))}
|
||||
/>
|
||||
</Karte>
|
||||
) : (
|
||||
|
||||
@@ -14,9 +14,11 @@ export function TemplateBauen({
|
||||
distributionen,
|
||||
knoten,
|
||||
faehigkeit,
|
||||
profile,
|
||||
}: {
|
||||
server: string;
|
||||
distributionen: Array<{ id: string; name: string }>;
|
||||
distributionen: Array<{ id: string; name: string; familie: string }>;
|
||||
profile: Array<{ id: string; name: string; requires: string[]; familien: string[] }>;
|
||||
knoten: Knoten[];
|
||||
faehigkeit: { weg: "api" | "ssh" | "nicht_moeglich"; grund: string };
|
||||
}) {
|
||||
@@ -26,6 +28,7 @@ export function TemplateBauen({
|
||||
knoten: knoten[0]?.name ?? "",
|
||||
storage: knoten[0]?.storages[0] ?? "",
|
||||
bridge: knoten[0]?.bridges.includes("vmbr0") ? "vmbr0" : (knoten[0]?.bridges[0] ?? ""),
|
||||
profile: [] as string[],
|
||||
});
|
||||
const [fehler, setFehler] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||
const [sendet, setSendet] = useState(false);
|
||||
@@ -118,6 +121,43 @@ export function TemplateBauen({
|
||||
</select>
|
||||
</Feld>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Profile einbacken (optional)</legend>
|
||||
<p className="text-muted text-sm">
|
||||
Was hier gewählt ist, ist in jedem Klon schon installiert – Deployments mit diesen Profilen sind
|
||||
schneller fertig. Profile mit Pflichtangaben (z. B. Zabbix-Server) laufen beim Ausrollen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{profile
|
||||
.filter((p) =>
|
||||
p.familien.includes(distributionen.find((d) => d.id === werte.distribution)?.familie ?? ""),
|
||||
)
|
||||
.map((p) => {
|
||||
const benoetigt = profile.some(
|
||||
(x) => werte.profile.includes(x.id) && x.requires.includes(p.id),
|
||||
);
|
||||
return (
|
||||
<label key={p.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-akzent h-4 w-4"
|
||||
checked={werte.profile.includes(p.id) || benoetigt}
|
||||
disabled={benoetigt && !werte.profile.includes(p.id)}
|
||||
onChange={(e) =>
|
||||
setWerte({
|
||||
...werte,
|
||||
profile: e.target.checked
|
||||
? [...werte.profile, p.id]
|
||||
: werte.profile.filter((x) => x !== p.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{p.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
<p className="text-muted text-sm">
|
||||
Der Vorbereitungsstart braucht DHCP und Internetzugang auf der Bridge, um qemu-guest-agent zu
|
||||
installieren.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||
import type { PhasenKontext } from "@/engine/ablauf/ablauf";
|
||||
import { MARKE } from "@/engine/profile/rendern";
|
||||
import type { Parameter, ProxmoxClient } from "@/engine/proxmox/client";
|
||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||
|
||||
/**
|
||||
* Provisionierung über den QEMU-Guest-Agent – gemeinsam für Deployments (#008) und Templates mit
|
||||
* eingebackenen Profilen (#022). Kein SSH nötig.
|
||||
*
|
||||
* Die Agent-Endpunkte antworten unterschiedlich: exec → {pid}, exec-status und file-read direkt,
|
||||
* ping und network-get-interfaces → {result}.
|
||||
*/
|
||||
export const SKRIPT = "/root/proxdeploy-provisionierung.sh";
|
||||
export 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 ExecStatus = { exited: number | boolean; exitcode?: number };
|
||||
|
||||
export function agent(client: ProxmoxClient, vmPfad: string) {
|
||||
return async <T>(befehl: string, methode: "GET" | "POST" = "POST", parameter?: Parameter) =>
|
||||
await client.aufrufen<T>({ methode, pfad: `${vmPfad}/agent/${befehl}`, parameter });
|
||||
}
|
||||
|
||||
/** Wartet, bis der Agent antwortet; liefert die Wartezeit in ms. */
|
||||
export async function agentAbwarten(
|
||||
client: ProxmoxClient,
|
||||
vmPfad: string,
|
||||
k: PhasenKontext,
|
||||
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
|
||||
): Promise<number> {
|
||||
const rufen = agent(client, vmPfad);
|
||||
const beginn = Date.now();
|
||||
for (;;) {
|
||||
await k.pruefeAbbruch();
|
||||
try {
|
||||
await rufen("ping");
|
||||
return Date.now() - beginn;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ProxmoxFehler && fehler.art === "recht") throw fehler;
|
||||
if (Date.now() - beginn > optionen.grenzeMs) {
|
||||
throw new AblaufFehler(
|
||||
`Der Guest-Agent antwortet nach ${Math.round(optionen.grenzeMs / 1000)} s nicht.`,
|
||||
"Bootet die VM? Konsole ansehen. Wurde das Template von ProxDeploy gebaut (Agent eingebaut)?",
|
||||
);
|
||||
}
|
||||
}
|
||||
await k.detail(`wartet … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||
await optionen.warten(3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Überträgt ein Skript in Stücken (file-write ersetzt, hängt nicht an), führt es aus und zeigt den
|
||||
* Fortschritt aus der Logdatei im Gast (Marken `::proxdeploy:: profil <id>`).
|
||||
*/
|
||||
export async function skriptAusfuehren(
|
||||
client: ProxmoxClient,
|
||||
vmPfad: string,
|
||||
skript: string,
|
||||
k: PhasenKontext,
|
||||
optionen: { grenzeMs: number; warten: (ms: number) => Promise<void> },
|
||||
): Promise<void> {
|
||||
const rufen = agent(client, vmPfad);
|
||||
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 rufen("file-write", "POST", {
|
||||
file: `${SKRIPT}.${nr}`,
|
||||
content: inhalt.toString("base64"),
|
||||
encode: "0",
|
||||
});
|
||||
}
|
||||
const teile = stuecke.map((_, nr) => `${SKRIPT}.${nr}`).join(" ");
|
||||
const { pid } = await rufen<{ pid: number }>("exec", "POST", {
|
||||
command: [
|
||||
"/bin/bash",
|
||||
"-c",
|
||||
`cat ${teile} > ${SKRIPT} && rm -f ${teile} && chmod 700 ${SKRIPT} && ${SKRIPT} > ${LOG} 2>&1`,
|
||||
],
|
||||
});
|
||||
|
||||
const beginn = Date.now();
|
||||
let gelesen = 0;
|
||||
let profil = "";
|
||||
for (;;) {
|
||||
await optionen.warten(3000);
|
||||
const status = await rufen<ExecStatus>("exec-status", "GET", { pid: String(pid) });
|
||||
try {
|
||||
const datei = await rufen<{ content: string }>("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.
|
||||
}
|
||||
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 > optionen.grenzeMs) {
|
||||
throw new AblaufFehler(
|
||||
`Provisionierung läuft länger als ${Math.round(optionen.grenzeMs / 60000)} min.`,
|
||||
`${LOG} in der VM ansehen.`,
|
||||
);
|
||||
}
|
||||
const s = Math.round((Date.now() - beginn) / 1000);
|
||||
await k.detail(
|
||||
`${profil ? `Profil ${profil}` : "Vorbereitung"} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
|
||||
);
|
||||
}
|
||||
await rufen("exec", "POST", { command: ["rm", "-f", SKRIPT] }).catch(() => undefined);
|
||||
}
|
||||
@@ -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}` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,8 +2,9 @@ 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 { profileRendern } from "@/engine/profile/rendern";
|
||||
import { agent, agentAbwarten, skriptAusfuehren } from "./gastagent";
|
||||
import { gastunabhaengig, mitStandardwerten, 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";
|
||||
@@ -41,7 +42,7 @@ export type VmAusrollEingabe = {
|
||||
|
||||
export type VmAusrollStand = {
|
||||
knoten?: string;
|
||||
template?: { vmid: number; knoten: string };
|
||||
template?: { vmid: number; knoten: string; eingebacken: string[] };
|
||||
vmid?: number;
|
||||
ip?: string;
|
||||
agentNachMs?: number;
|
||||
@@ -81,17 +82,7 @@ export function ipconfig(netz: AusrollAnfrage["netz"]): string {
|
||||
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 }>;
|
||||
@@ -101,13 +92,6 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
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[] = [
|
||||
@@ -119,17 +103,31 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
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(
|
||||
const gewaehlt = new Set(e.profile.map((p) => p.profil.id));
|
||||
const tags = (r: Ressource) => (r.tags ?? "").split(/[;, ]/);
|
||||
// Nur Templates, deren eingebackene Profile alle gewählt sind (#022) – sonst hätte der Gast
|
||||
// Software, die niemand bestellt hat.
|
||||
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.
|
||||
tags(r).includes("proxdeploy-template") &&
|
||||
tags(r).includes(d.id),
|
||||
)
|
||||
.map((r) => ({
|
||||
r,
|
||||
eingebacken: tags(r)
|
||||
.filter((t) => t.startsWith("profil-"))
|
||||
.map((t) => t.slice(7)),
|
||||
}))
|
||||
.filter((t) => t.eingebacken.every((p) => gewaehlt.has(p)))
|
||||
// Am meisten eingebacken zuerst, bei Gleichstand die kleinere VMID.
|
||||
.sort((x, y) => y.eingebacken.length - x.eingebacken.length || (x.r.vmid ?? 0) - (y.r.vmid ?? 0));
|
||||
// Node: gewünscht, sonst der mit dem besten Template, sonst der mit dem meisten freien RAM.
|
||||
stand.knoten =
|
||||
a.knoten ??
|
||||
templates.find((t) => nodes.some((n) => n.node === t.node))?.node ??
|
||||
templates.find((t) => nodes.some((n) => n.node === t.r.node))?.r.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)) {
|
||||
@@ -138,8 +136,10 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
"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 besten = templates.find((t) => t.r.node === stand.knoten) ?? templates[0];
|
||||
const tpl = besten?.r;
|
||||
if (besten)
|
||||
stand.template = { vmid: besten.r.vmid!, knoten: besten.r.node!, eingebacken: besten.eingebacken };
|
||||
|
||||
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
|
||||
const ziel = storages.find((s) => s.storage === a.storage);
|
||||
@@ -193,8 +193,13 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
schluessel: "template",
|
||||
titel: "Template",
|
||||
ausfuehren: async (k) => {
|
||||
if (stand.template)
|
||||
return { uebersprungen: true, detail: `${stand.template.vmid} auf ${stand.template.knoten}` };
|
||||
if (stand.template) {
|
||||
const t = stand.template;
|
||||
return {
|
||||
uebersprungen: true,
|
||||
detail: `${t.vmid} auf ${t.knoten}${t.eingebacken.length > 0 ? ` · ${t.eingebacken.join(", ")} eingebacken` : ""}`,
|
||||
};
|
||||
}
|
||||
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(
|
||||
@@ -218,7 +223,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
});
|
||||
await k.log(`Template · ${schritt.titel}: ${ergebnis?.detail ?? "erledigt"}`);
|
||||
}
|
||||
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten! };
|
||||
stand.template = { vmid: templateStand.vmid!, knoten: stand.knoten!, eingebacken: [] };
|
||||
return { detail: `${templateStand.vmid} gebaut` };
|
||||
},
|
||||
},
|
||||
@@ -326,33 +331,20 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
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;
|
||||
stand.agentNachMs = await agentAbwarten(client, vmPfad(), k, {
|
||||
grenzeMs: e.agentMs ?? 5 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
// 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")
|
||||
await agent(client, vmPfad())<AgentAntwort<Netzschnittstelle[]>>(
|
||||
"network-get-interfaces",
|
||||
"GET",
|
||||
)
|
||||
).result;
|
||||
stand.ip = schnittstellen
|
||||
.filter((s) => s.name !== "lo")
|
||||
@@ -378,8 +370,16 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||
"Adapter ergänzen (docs/katalog.md).",
|
||||
);
|
||||
// Eingebacken und mit Standardwerten → schon im Template, nicht noch einmal (#022).
|
||||
const eingebacken = new Set(stand.template?.eingebacken ?? []);
|
||||
const uebersprungen = e.profile
|
||||
.filter((p) => eingebacken.has(p.profil.id) && mitStandardwerten(p) && gastunabhaengig(p.profil))
|
||||
.map((p) => p.profil.id);
|
||||
const auszufuehren = e.profile.filter((p) => !uebersprungen.includes(p.profil.id));
|
||||
if (uebersprungen.length > 0)
|
||||
await k.log(`Im Template eingebacken, wird übersprungen: ${uebersprungen.join(", ")}`);
|
||||
const { skript } = profileRendern(
|
||||
e.profile,
|
||||
auszufuehren,
|
||||
adapter,
|
||||
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } },
|
||||
[
|
||||
@@ -394,85 +394,17 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
`Skript: ${skript.split("\n").length} Zeilen, ${Math.round(skript.length / 1024)} KiB`,
|
||||
"debug",
|
||||
);
|
||||
const zusatz = uebersprungen.length > 0 ? ` (${uebersprungen.join(", ")} aus dem Template)` : "";
|
||||
const liste = auszufuehren.map((p) => p.profil.id).join(", ");
|
||||
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",
|
||||
};
|
||||
return { detail: `${liste ? `Profile ${liste}` : "nur Benutzer"}${zusatz}` };
|
||||
}
|
||||
|
||||
// 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",
|
||||
await skriptAusfuehren(client, vmPfad(), skript, k, {
|
||||
grenzeMs: e.provisionierungMs ?? 60 * 60 * 1000,
|
||||
warten,
|
||||
});
|
||||
}
|
||||
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 { detail: `${liste ? `Profile ${liste} angewendet` : "Benutzer eingerichtet"}${zusatz}` };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -68,6 +68,40 @@ export function profileAufloesen(
|
||||
return { ok: true, profile, automatisch: reihenfolge.filter((id) => !auswahl.includes(id)) };
|
||||
}
|
||||
|
||||
/** Werte eines Profils aus der Eingabe, mit Standardwerten und Typprüfung. */
|
||||
export function variablenWerte(
|
||||
profil: Profil,
|
||||
eingabe: Record<string, unknown>,
|
||||
): { werte: Record<string, string | number | boolean>; fehler: ProfilFehler[] } {
|
||||
const werte: Record<string, string | number | boolean> = {};
|
||||
const fehler: ProfilFehler[] = [];
|
||||
for (const variable of profil.variables) {
|
||||
const ergebnis = wertPruefen(variable, eingabe[variable.name]);
|
||||
if ("fehler" in ergebnis)
|
||||
fehler.push({ profil: profil.id, variable: variable.name, meldung: ergebnis.fehler });
|
||||
else werte[variable.name] = ergebnis.wert;
|
||||
}
|
||||
return { werte, fehler };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein ins Template eingebackenes Profil muss beim Deploy nur dann nicht noch einmal laufen, wenn
|
||||
* seine Variablen genau die Standardwerte haben – mit denen wurde es eingebacken.
|
||||
*/
|
||||
export function mitStandardwerten(eintrag: AufgeloestesProfil): boolean {
|
||||
const standard = variablenWerte(eintrag.profil, {}).werte;
|
||||
return JSON.stringify(standard) === JSON.stringify(eintrag.werte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verweist ein Profil auf `{{ guest.* }}` (Name, Benutzer), hängt es vom einzelnen Gast ab – im
|
||||
* Template stand dort etwas anderes. Solche Profile laufen beim Deploy trotzdem noch einmal; dank
|
||||
* Idempotenz ist das schnell (Fund: docker nahm sonst den Benutzer nicht in die Gruppe auf).
|
||||
*/
|
||||
export function gastunabhaengig(profil: Profil): boolean {
|
||||
return !/\{\{[^}]*\bguest\./.test(JSON.stringify(profil.families));
|
||||
}
|
||||
|
||||
function wertPruefen(
|
||||
variable: ProfilVariable,
|
||||
eingabe: unknown,
|
||||
|
||||
@@ -7,6 +7,8 @@ export const TemplateBauAnfrage = z.object({
|
||||
storage: z.string().min(1, "Storage fehlt."),
|
||||
bridge: z.string().min(1).default("vmbr0"),
|
||||
vlan: z.coerce.number().int().min(1).max(4094).optional(),
|
||||
/** Profile, die ins Template eingebacken werden (#022) – nur solche ohne Pflichtvariablen. */
|
||||
profile: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export type TemplateBauAnfrage = z.infer<typeof TemplateBauAnfrage>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
||||
import { melden } from "@/server/ereignisse/senke";
|
||||
import { verbindungAus } from "@/server/services/server";
|
||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||
import { templateProfileAufloesen } from "@/server/services/templates";
|
||||
import { vmidReservieren } from "@/server/services/vmid";
|
||||
import { ablaufRegistrieren } from "../ausfuehren";
|
||||
import { aufrufLogger } from "../aufruf-log";
|
||||
@@ -16,10 +17,15 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
|
||||
const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } });
|
||||
const zeile = await prisma.distribution.findUniqueOrThrow({ where: { id: anfrage.distribution } });
|
||||
const distribution = zeile.definition as Distribution;
|
||||
const profile = await templateProfileAufloesen(anfrage.profile, distribution.family);
|
||||
|
||||
await prisma.auftrag.update({
|
||||
where: { id: auftrag.id },
|
||||
data: { kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}` },
|
||||
data: {
|
||||
kopf: `Server ${server.name} · Node ${anfrage.knoten} · Template ${distribution.name}${
|
||||
anfrage.profile.length > 0 ? ` mit ${anfrage.profile.join(", ")}` : ""
|
||||
}`,
|
||||
},
|
||||
});
|
||||
await melden(auftrag.id);
|
||||
|
||||
@@ -31,6 +37,7 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
|
||||
{
|
||||
client,
|
||||
distribution,
|
||||
profile,
|
||||
knoten: anfrage.knoten,
|
||||
storage: anfrage.storage,
|
||||
bridge: anfrage.bridge,
|
||||
@@ -65,12 +72,20 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
|
||||
storage: anfrage.storage,
|
||||
imageDatei: stand.imageDatei!,
|
||||
pruefsumme: stand.pruefsumme!,
|
||||
profile: profile.map((p) => p.profil.id),
|
||||
gebautVonAuftragId: auftrag.id,
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
ergebnis: { vmid: stand.vmid, template: templateName(distribution), knoten: anfrage.knoten },
|
||||
ergebnis: {
|
||||
vmid: stand.vmid,
|
||||
template: templateName(
|
||||
distribution,
|
||||
profile.map((p) => p.profil.id),
|
||||
),
|
||||
knoten: anfrage.knoten,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await client.schliessen();
|
||||
|
||||
@@ -300,3 +300,63 @@ describe("Template-Bau", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Profile einbacken (#022)", () => {
|
||||
it("startet die VM, provisioniert über den Agent, räumt auf, fährt herunter – Name und Tags nennen die Profile", async () => {
|
||||
const { mock, client } = await aufbauen({ version: "9.2.1" });
|
||||
const dateien: string[] = [];
|
||||
let exec = 0;
|
||||
mock
|
||||
.route("POST", /\/agent\/ping$/, () => ({ data: { result: {} } }))
|
||||
.route("POST", /\/agent\/file-write$/, (a) => {
|
||||
dateien.push(Buffer.from(a.rumpf.get("content")!, "base64").toString("utf8"));
|
||||
return { data: null };
|
||||
})
|
||||
.route("POST", /\/agent\/exec$/, () => ({ data: { pid: ++exec } }))
|
||||
.route("GET", /\/agent\/exec-status$/, () => ({ data: { exited: 1, exitcode: 0 } }))
|
||||
.route("GET", /\/agent\/file-read$/, () => ({ data: { content: "" } }))
|
||||
.route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") }));
|
||||
const katalog = await katalogLaden("catalog");
|
||||
const { profileAufloesen } = await import("@/engine/profile/aufloesen");
|
||||
const aufgeloest = profileAufloesen(
|
||||
["docker"],
|
||||
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
|
||||
"debian",
|
||||
);
|
||||
if (!aufgeloest.ok) throw new Error("Profile");
|
||||
const ergebnis = await phasenAusfuehren(
|
||||
templateBauSchritte({
|
||||
client,
|
||||
distribution: await debian13(),
|
||||
profile: aufgeloest.profile,
|
||||
knoten: "pve01",
|
||||
storage: "local-zfs",
|
||||
bridge: "vmbr0",
|
||||
ssh: sshAufzeichnung().ssh,
|
||||
auftragKurzId: "t",
|
||||
holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`,
|
||||
vmidReservieren: async () => 9002,
|
||||
warten: async () => {},
|
||||
}),
|
||||
stumm,
|
||||
);
|
||||
expect(ergebnis).toEqual({ ok: true });
|
||||
const anlegen = Object.fromEntries(
|
||||
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/qemu")!.rumpf,
|
||||
);
|
||||
expect(anlegen).toMatchObject({
|
||||
name: "tpl-debian-13-docker",
|
||||
tags: "proxdeploy-template;debian-13;profil-base;profil-docker",
|
||||
});
|
||||
expect(dateien.join("")).toContain("docker-compose-plugin");
|
||||
expect(dateien.join("")).toContain("truncate -s 0 /etc/machine-id");
|
||||
expect(dateien.join("")).toContain("rm -f /etc/ssh/ssh_host_*");
|
||||
const reihenfolge = schreibend(mock.anfragen).filter((z) => /status\/(start|shutdown)|template$/.test(z));
|
||||
expect(reihenfolge).toEqual([
|
||||
"POST /nodes/pve01/qemu/9002/status/start",
|
||||
"POST /nodes/pve01/qemu/9002/status/start",
|
||||
"POST /nodes/pve01/qemu/9002/status/shutdown",
|
||||
"POST /nodes/pve01/qemu/9002/template",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +147,7 @@ async function eingabe(client: ProxmoxClient, anfrageTeil: Record<string, unknow
|
||||
anfrage.profile,
|
||||
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
|
||||
"debian",
|
||||
anfrage.variablen,
|
||||
);
|
||||
if (!aufgeloest.ok) throw new Error("Profile");
|
||||
const angelegt: Array<Record<string, unknown>> = [];
|
||||
@@ -292,3 +293,50 @@ describe("VM ausrollen", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Templates mit eingebackenen Profilen (#022)", () => {
|
||||
async function mitTemplates(templates: Array<{ vmid: number; tags: string }>) {
|
||||
const { mock, client, dateien } = await aufbauen();
|
||||
mock.route("GET", "/cluster/resources", () => ({
|
||||
data: [
|
||||
{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 8e9 },
|
||||
...templates.map((t) => ({ type: "qemu", vmid: t.vmid, node: "pve01", template: 1, tags: t.tags })),
|
||||
],
|
||||
}));
|
||||
mock.route("POST", /^\/nodes\/pve01\/qemu\/\d+\/clone$/, () => ({ data: UPID("qmclone") }));
|
||||
return { mock, client, dateien };
|
||||
}
|
||||
|
||||
it("wählt das Template mit den meisten passenden Profilen, nie eins mit ungewählten", async () => {
|
||||
const { client } = await mitTemplates([
|
||||
{ vmid: 9000, tags: "proxdeploy-template;debian-13" },
|
||||
{ vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" },
|
||||
{ vmid: 9003, tags: "proxdeploy-template;debian-13;profil-base;profil-docker;profil-nginx" },
|
||||
]);
|
||||
const { e } = await eingabe(client);
|
||||
const stand: VmAusrollStand = {};
|
||||
expect(await phasenAusfuehren(vmAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
|
||||
expect(stand.template).toEqual({ vmid: 9002, knoten: "pve01", eingebacken: ["base", "docker"] });
|
||||
});
|
||||
|
||||
it("überspringt gastunabhängige eingebackene Profile; docker (nutzt guest.user) läuft noch einmal", async () => {
|
||||
const { client, dateien } = await mitTemplates([
|
||||
{ vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base;profil-docker" },
|
||||
]);
|
||||
const { e } = await eingabe(client);
|
||||
await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||
const skript = [...dateien.values()].join("");
|
||||
expect(skript).not.toContain("::proxdeploy:: profil base");
|
||||
expect(skript).toContain("::proxdeploy:: profil docker");
|
||||
expect(skript).toContain("usermod -aG docker florian");
|
||||
});
|
||||
|
||||
it("weicht eine Variable vom Standard ab, läuft auch ein eingebackenes Profil", async () => {
|
||||
const { client, dateien } = await mitTemplates([
|
||||
{ vmid: 9002, tags: "proxdeploy-template;debian-13;profil-base" },
|
||||
]);
|
||||
const { e } = await eingabe(client, { profile: ["base"], variablen: { base: { zeitzone: "UTC" } } });
|
||||
await phasenAusfuehren(vmAusrollSchritte(e), stumm);
|
||||
expect([...dateien.values()].join("")).toContain("::proxdeploy:: profil base");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user