From 21edff2141c3fce443c06f131f885d378e2be62c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 14:36:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(templates):=20Template-Bau=20ohne=20SSH=20?= =?UTF-8?q?=C3=BCber=20eine=20Bau-Adresse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste Bau-Adresse ins Template: cloud-init setzt Adresse und den Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet sich per SSH in der VM an, installiert den Agent und entfernt danach Schlüssel, Adresse und cloud-init-Angaben. Dabei korrigiert: der Verbindungstest meldete „Template-Bau per API“ auch ohne Weg, den Agent einzubauen. Im Protokoll erscheinen unveränderte JSON-Felder nicht mehr als geändert. Refs #021 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + docs/issues/021-template-bau-ohne-ssh.md | 15 ++- docs/issues/README.md | 2 +- src/app/(app)/server/server-formular.tsx | 50 +++++++- src/engine/ablaeufe/template-bau.ts | 153 +++++++++++++++++++++-- src/engine/proxmox/erkundung.ts | 3 +- src/engine/proxmox/faehigkeiten.ts | 15 ++- src/lib/schemas/server.ts | 31 +++++ src/server/services/ausrollen.ts | 9 +- src/server/services/server.ts | 15 ++- src/worker/ablaeufe/template.ts | 4 + tests/engine/erkundung.test.ts | 2 +- tests/engine/faehigkeiten.test.ts | 22 +++- tests/engine/template-bau.test.ts | 71 +++++++++++ 14 files changed, 366 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a0a26..435645c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt +- Template-Bau ohne SSH zum Node: über eine Bau-Adresse installiert ProxDeploy den Guest-Agent per SSH in der Vorbereitungs-VM (#021) - SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015) - 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) @@ -19,6 +20,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Geändert ### Behoben +- Verbindungstest meldete „Template-Bau per API“ auch dann, wenn der Guest-Agent mangels SSH gar nicht ins Template käme (#021) - chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009) - SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009) diff --git a/docs/issues/021-template-bau-ohne-ssh.md b/docs/issues/021-template-bau-ohne-ssh.md index 0ba37dc..2815a85 100644 --- a/docs/issues/021-template-bau-ohne-ssh.md +++ b/docs/issues/021-template-bau-ohne-ssh.md @@ -1,18 +1,18 @@ # #021 Template-Bau ohne SSH (Bau-Adresse) -**Status:** offen +**Status:** umgesetzt, Abnahme auf pve-demo ausstehend **Meilenstein:** v0.3.0 **Labels:** feature, proxmox -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min ## Ziel Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet, sondern über SSH **in die Vorbereitungs-VM** eingebaut. ## Aufgaben -- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS -- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys` -- [ ] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten -- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“ +- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt) +- [x] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys` +- [x] SSH in die VM (Host-Key aus der Konsole nicht prüfbar → Schlüssel nur für diese eine Verbindung), qemu-guest-agent installieren, Maschinen-ID leeren, `cloud-init clean`, ausschalten +- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“ ## Akzeptanzkriterien - [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH @@ -21,4 +21,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut. #007 ## Notizen / Entscheidungen +- **Fähigkeitserkennung war zu optimistisch:** „Template-Bau per API“ stand auch ohne Node-SSH da, obwohl der Guest-Agent dann nicht hineinkam. Jetzt: per API nur mit Snippet-Weg oder Bau-Adresse. +- Der Bereitstellungsschlüssel kommt per cloud-init an den Standardbenutzer des Images; danach werden alle authorized_keys, ciuser, sshkeys und die feste Adresse entfernt. +- Host-Key der frischen VM ist vorher unbekannt (TOFU, nur für diese eine Verbindung). - Aus #007 ausgegliedert, damit Etappe 2 nicht wartet: der Testhost hat SSH, und VM-Deployments aus vorhandenen Templates laufen ohnehin ohne SSH. diff --git a/docs/issues/README.md b/docs/issues/README.md index ef8fadf..78c3b73 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -24,5 +24,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – | | [#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 | – | +| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 25 min | | [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 35 min | diff --git a/src/app/(app)/server/server-formular.tsx b/src/app/(app)/server/server-formular.tsx index e346929..f81576e 100644 --- a/src/app/(app)/server/server-formular.tsx +++ b/src/app/(app)/server/server-formular.tsx @@ -43,6 +43,9 @@ type Werte = { templateVmidVon: string; templateVmidBis: string; gastVmidVon: string; + bauIp: string; + bauGateway: string; + bauDns: string; }; function anfangswerte(server?: ServerSicht): Werte { @@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte { templateVmidVon: String(server?.templateVmidVon ?? 9000), templateVmidBis: String(server?.templateVmidBis ?? 9999), gastVmidVon: String(server?.gastVmidVon ?? 100), + bauIp: server?.bauNetz?.ip ?? "", + bauGateway: server?.bauNetz?.gateway ?? "", + bauDns: server?.bauNetz?.dns.join(", ") ?? "", }; } @@ -117,12 +123,16 @@ export function ServerFormular({ server }: { server?: ServerSicht }) { setSpeichert(true); setFelder({}); setMeldung(null); + const { bauIp, bauGateway, bauDns, ...ohneBau } = werte; const rumpf = { - ...werte, + ...ohneBau, sshPort: Number(werte.sshPort), templateVmidVon: Number(werte.templateVmidVon), templateVmidBis: Number(werte.templateVmidBis), gastVmidVon: Number(werte.gastVmidVon), + bauNetz: bauIp.trim() + ? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) } + : null, }; try { const gespeichert = bearbeiten @@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) { +
+ Bau-Adresse (optional) +

+ Feste Adresse für den Vorbereitungsstart beim Template-Bau. Nur nötig ohne SSH zum Node: + ProxDeploy meldet sich darüber kurz in der VM an und installiert den Guest-Agent. Braucht + Internetzugang. +

+
+ + setze("bauIp", e.target.value)} + placeholder="10.0.10.250/24" + /> + + + setze("bauGateway", e.target.value)} + placeholder="10.0.10.1" + /> + + + setze("bauDns", e.target.value)} + placeholder="10.0.10.1" + /> + +
+
+ Promise; + /** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */ + gastSsh?: ( + ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel }, + skript: string, + ) => Promise; knoten: string; /** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */ storage: string; @@ -56,6 +67,8 @@ export type TemplateBauEingabe = { export type TemplateBauStand = { vmid?: number; + /** Wie der Guest-Agent hineinkommt: Snippet (Node-SSH) oder SSH in die VM über die Bau-Adresse. */ + agentWeg?: "snippet" | "baunetz"; weg?: "api" | "ssh"; importStorage?: string; snippetStorage?: string; @@ -131,6 +144,126 @@ power_state: `; } +/** + * Guest-Agent ohne Node-SSH (#021): Vorbereitungsstart mit fester Bau-Adresse und dem + * Bereitstellungsschlüssel für den Standardbenutzer des Images, dann SSH in die VM. Danach wird + * der Schlüssel entfernt und die cloud-init-Angaben zurückgesetzt – im Template bleibt nichts davon. + */ +export const BAUNETZ_SKRIPT = `set -e +export DEBIAN_FRONTEND=noninteractive +cloud-init status --wait >/dev/null 2>&1 || true +apt-get -q update +apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent +systemctl enable qemu-guest-agent +rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys +truncate -s 0 /etc/machine-id +rm -f /var/lib/dbus/machine-id +cloud-init clean --logs +sync +`; + +async function agentUeberBauNetz( + e: TemplateBauEingabe, + vmPfad: string, + benutzer: string, + k: PhasenKontext, + warten: (ms: number) => Promise, +): Promise<{ detail: string }> { + const { client } = e; + const netz = e.bauNetz!; + const schluessel = await e.bereitstellung!(); + const host = netz.ip.split("/")[0]!; + await client.put(`${vmPfad}/config`, { + ciuser: benutzer, + sshkeys: encodeURIComponent(schluessel.oeffentlich).replace( + /[!'()*]/g, + (z) => `%${z.charCodeAt(0).toString(16).toUpperCase()}`, + ), + ipconfig0: `ip=${netz.ip},gw=${netz.gateway}`, + ...(netz.dns.length > 0 ? { nameserver: netz.dns.join(" ") } : {}), + }); + try { + await aufgabeAbwarten(client, await client.post(`${vmPfad}/status/start`), { + zeitlimitMs: 5 * 60 * 1000, + }); + if (client.trocken) return { detail: `über Bau-Adresse ${host}` }; + await k.detail(`Vorbereitungsstart über Bau-Adresse ${host} …`); + const code = await (e.gastSsh ?? gastSshBau)( + { host, benutzer, schluessel }, + BAUNETZ_SKRIPT, + warten, + (z) => void k.log(z, "debug"), + ); + if (code !== 0) { + throw new AblaufFehler( + `Einbau des Guest-Agents über ${host} mit Code ${code} abgebrochen.`, + "Hat die Bau-Adresse Internetzugang (Gateway, DNS)? Detail-Log ansehen.", + ); + } + await aufgabeAbwarten(client, await client.post(`${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); + } + } finally { + // Bau-Adresse und Schlüssel gehören nicht ins Template. + await client + .put(`${vmPfad}/config`, { ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" }) + .catch(() => undefined); + } + return { detail: `qemu-guest-agent über Bau-Adresse ${host} installiert · Maschinen-ID geleert` }; +} + +async function gastSshBau( + ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel }, + skript: string, + warten: (ms: number) => Promise, + beiZeile: (z: string) => void, +): Promise { + // Den Host-Key der frischen VM kann niemand vorher kennen (TOFU, nur für diese Verbindung). + const beginn = Date.now(); + for (;;) { + try { + const ergebnis = await sshAusfuehren( + { + host: ziel.host, + port: 22, + benutzer: ziel.benutzer, + privatSchluessel: ziel.schluessel.privat, + hostSchluessel: null, + }, + "sudo -n bash -s", + { + erkunden: true, + eingabe: skript, + zeitlimitMs: 20 * 60 * 1000, + beiAusgabe: (t) => t.split("\n").filter(Boolean).forEach(beiZeile), + }, + ); + return ergebnis.code; + } catch (fehler) { + if ( + fehler instanceof SshFehler && + (fehler.art === "verbindung" || fehler.art === "anmeldung" || fehler.art === "zeitueberschreitung") && + Date.now() - beginn < 4 * 60 * 1000 + ) { + // Bootet noch oder cloud-init hat den Schlüssel noch nicht eingetragen. + await warten(5000); + continue; + } + if (fehler instanceof SshFehler) { + throw new AblaufFehler( + `SSH in die Vorbereitungs-VM (${ziel.host}) fehlgeschlagen: ${fehler.ursache}`, + "Ist die Bau-Adresse aus dem Netz von ProxDeploy erreichbar? Stimmen Bridge/VLAN?", + ); + } + throw fehler; + } + } +} + export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] { const { client, distribution: d, knoten } = e; const vm = d.vm; @@ -215,13 +348,17 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta : "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.", - ); + if (!vm.guest_agent_preinstalled) { + if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet"; + else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz"; + else { + throw new AblaufFehler( + "Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node (mit einem Snippets-Storage) oder eine Bau-Adresse.", + e.ssh + ? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben oder in den Server-Einstellungen eine Bau-Adresse eintragen." + : "SSH in den Server-Einstellungen einrichten oder dort eine Bau-Adresse (feste IP für die Vorbereitungs-VM) eintragen.", + ); + } } const netze = await client.get>( pfad`/nodes/${knoten}/network`, @@ -375,6 +512,8 @@ export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauSta if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" }; const vmid = stand.vmid!; const vmPfad = pfad`/nodes/${knoten}/qemu/${vmid}`; + if (stand.agentWeg === "baunetz") + return await agentUeberBauNetz(e, vmPfad, vm.default_user, k, warten); 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(), diff --git a/src/engine/proxmox/erkundung.ts b/src/engine/proxmox/erkundung.ts index 5457b8a..8e5ef81 100644 --- a/src/engine/proxmox/erkundung.ts +++ b/src/engine/proxmox/erkundung.ts @@ -119,7 +119,7 @@ async function versuchen(warnungen: string[], text: string, aufruf: () => Pro export async function erkunden( client: ProxmoxClient, - optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date }, + optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date }, ): Promise { const warnungen: string[] = []; const version = await client.get<{ version: string; release: string }>("/version"); @@ -313,6 +313,7 @@ export async function erkunden( storages: alleStorages, ssh: sshGesamt, rechteFehlen: fehlendeRechte.some((r) => r.pflicht), + bauNetz: optionen.bauNetz, }), warnungen, }; diff --git a/src/engine/proxmox/faehigkeiten.ts b/src/engine/proxmox/faehigkeiten.ts index 41e7622..f6a922b 100644 --- a/src/engine/proxmox/faehigkeiten.ts +++ b/src/engine/proxmox/faehigkeiten.ts @@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: { storages: StorageInfo[]; ssh: SshZustand; rechteFehlen: boolean; + /** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */ + bauNetz?: boolean; }): Faehigkeiten { const { version, storages, ssh } = eingabe; const sshOk = ssh === "ok"; @@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: { const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets")); let templateBau: Strategie; - if (mindestens(version, 8, 4) && importSpeicher.length > 0) { + const agentEinbauen = (sshOk && snippetSpeicher.length > 0) || Boolean(eingabe.bauNetz); + if (mindestens(version, 8, 4) && importSpeicher.length > 0 && !agentEinbauen) { + templateBau = { + weg: "nicht_moeglich", + grund: + "Image-Import ginge per API, aber der Guest-Agent muss ins Template: SSH zum Node (mit Snippets-Storage) oder eine Bau-Adresse in den Server-Einstellungen.", + }; + } else if (mindestens(version, 8, 4) && importSpeicher.length > 0) { templateBau = { weg: "api", - grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}`, + grund: `download-url und import-from über Storage ${importSpeicher.map((s) => s.storage).join(", ")}${ + sshOk && snippetSpeicher.length > 0 ? "" : "; Guest-Agent über die Bau-Adresse" + }`, }; } else if (sshOk) { templateBau = { diff --git a/src/lib/schemas/server.ts b/src/lib/schemas/server.ts index 140ea33..407b4e8 100644 --- a/src/lib/schemas/server.ts +++ b/src/lib/schemas/server.ts @@ -30,6 +30,35 @@ export const ServerUrl = z return `${url.protocol}//${url.host}`; }); +/** + * Bau-Adresse (#021): eine feste Adresse für den Vorbereitungsstart beim Template-Bau, wenn es + * kein SSH zum Node gibt. ProxDeploy meldet sich darüber per SSH in der VM an. + */ +export const BauNetz = z + .object({ + ip: z + .string() + .trim() + .regex(/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, "Format: 10.0.10.250/24"), + gateway: z + .string() + .trim() + .regex(/^(\d{1,3}\.){3}\d{1,3}$/, "Format: 10.0.10.1"), + dns: z + .array( + z + .string() + .trim() + .regex(/^(\d{1,3}\.){3}\d{1,3}$/), + ) + .max(3) + .default([]), + }) + .nullable() + .default(null); + +export type BauNetz = NonNullable>; + const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999); const Basis = z.object({ @@ -52,6 +81,7 @@ const Basis = z.object({ templateVmidVon: Vmid.default(9000), templateVmidBis: Vmid.default(9999), gastVmidVon: Vmid.default(100), + bauNetz: BauNetz, }); function bereicheGueltig(wert: T) { @@ -112,6 +142,7 @@ export type ServerSicht = { templateVmidVon: number; templateVmidBis: number; gastVmidVon: number; + bauNetz: BauNetz | null; erreichbar: boolean | null; pveVersion: string | null; geprueftAm: string | null; diff --git a/src/server/services/ausrollen.ts b/src/server/services/ausrollen.ts index b30c55b..41ee1e7 100644 --- a/src/server/services/ausrollen.ts +++ b/src/server/services/ausrollen.ts @@ -8,6 +8,7 @@ import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufl import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client"; import { prisma } from "@/lib/db"; import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen"; +import type { BauNetz } from "@/lib/schemas/server"; import { vorlageAnwenden } from "@/lib/schemas/vorlage"; import { vorlageHolen } from "@/server/services/vorlagen"; import type { Aufrufer } from "@/server/auth/zugang"; @@ -162,7 +163,13 @@ export function ausrollSchritte( profile: v.profile, vmidReservieren: o.vmidReservieren, templateBau: v.anfrage.templateBauen - ? { sshFuer, auftragKurzId: o.auftragKurzId, vmidReservieren: o.templateVmidReservieren } + ? { + sshFuer, + auftragKurzId: o.auftragKurzId, + vmidReservieren: o.templateVmidReservieren, + bauNetz: (v.server.bauNetz as BauNetz | null) ?? null, + bereitstellung: bereitstellungsschluessel, + } : undefined, angelegt: o.angelegt, auftragKurzId: o.auftragKurzId, diff --git a/src/server/services/server.ts b/src/server/services/server.ts index 6ec1cd6..76ad02a 100644 --- a/src/server/services/server.ts +++ b/src/server/services/server.ts @@ -1,5 +1,5 @@ import ssh2 from "ssh2"; -import type { Server } from "@/generated/prisma/client"; +import { Prisma, type Server } from "@/generated/prisma/client"; import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client"; import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung"; import { ProxmoxFehler } from "@/engine/proxmox/fehler"; @@ -11,6 +11,7 @@ import { ServerAendern, ServerAnlegen, ServerPruefen, + type BauNetz, type ServerSicht, } from "@/lib/schemas/server"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; @@ -32,6 +33,7 @@ export function serverSicht(server: Server): ServerSicht { templateVmidVon: server.templateVmidVon, templateVmidBis: server.templateVmidBis, gastVmidVon: server.gastVmidVon, + bauNetz: (server.bauNetz as BauNetz | null) ?? null, erreichbar: server.erreichbar, pveVersion: server.pveVersion, geprueftAm: server.geprueftAm?.toISOString() ?? null, @@ -83,6 +85,7 @@ export async function serverAnlegen( const angelegt = await tx.server.create({ data: { ...eingabe, + bauNetz: eingabe.bauNetz ?? Prisma.JsonNull, fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis), }, @@ -117,12 +120,19 @@ export async function serverAendern( const { tokenGeheimnis, ...rest } = eingabe; const daten = { ...rest, + bauNetz: rest.bauNetz ?? Prisma.JsonNull, fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), ...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}), }; // Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie. + // JSON-Felder (bauNetz) vergleichen nach Inhalt; Prisma.JsonNull steht für „leer“ wie null. + const vergleichbar = (wert: unknown) => JSON.stringify(wert === Prisma.JsonNull ? null : (wert ?? null)); const geaendert = Object.entries(daten) - .filter(([feld, wert]) => feld === "tokenGeheimnis" || (bisher as Record)[feld] !== wert) + .filter( + ([feld, wert]) => + feld === "tokenGeheimnis" || + vergleichbar((bisher as Record)[feld]) !== vergleichbar(wert), + ) .map(([feld]) => feld); const server = await prisma.$transaction(async (tx) => { @@ -287,6 +297,7 @@ export async function serverTesten( try { const bericht = await erkunden(client, { serverHost: new URL(server.url).hostname, + bauNetz: Boolean(server.bauNetz), ssh: server.sshAktiv ? { benutzer: server.sshBenutzer, diff --git a/src/worker/ablaeufe/template.ts b/src/worker/ablaeufe/template.ts index 2ff1fe1..83f4976 100644 --- a/src/worker/ablaeufe/template.ts +++ b/src/worker/ablaeufe/template.ts @@ -3,7 +3,9 @@ import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engi import type { Distribution } from "@/engine/katalog/schema"; import { ProxmoxClient } from "@/engine/proxmox/client"; import { prisma } from "@/lib/db"; +import type { BauNetz } from "@/lib/schemas/server"; import { TemplateBauAnfrage } from "@/lib/schemas/template"; +import { bereitstellungsschluessel } from "@/server/services/bereitstellung"; import { melden } from "@/server/ereignisse/senke"; import { verbindungAus } from "@/server/services/server"; import { nodeBefehlFuer } from "@/server/services/ssh-zugang"; @@ -43,6 +45,8 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht, bridge: anfrage.bridge, vlan: anfrage.vlan, ssh: nodeBefehlFuer(server, anfrage.knoten), + bauNetz: (server.bauNetz as BauNetz | null) ?? null, + bereitstellung: bereitstellungsschluessel, auftragKurzId: auftrag.kurzId, vmidReservieren: async (belegt) => { const vmid = await vmidReservieren({ diff --git a/tests/engine/erkundung.test.ts b/tests/engine/erkundung.test.ts index 1394e8f..2e7b1ce 100644 --- a/tests/engine/erkundung.test.ts +++ b/tests/engine/erkundung.test.ts @@ -32,7 +32,7 @@ async function mitSzenario(szenario: Szenario) { describe("Verbindungstest (Erkundung)", () => { it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => { const { client } = await mitSzenario({ version: "9.2.1", importStorage: true }); - const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null }); + const bericht = await erkunden(client, { serverHost: "127.0.0.1", ssh: null, bauNetz: true }); expect(bericht.version).toBe("9.2.1"); expect(bericht.cluster).toBeNull(); expect(bericht.knoten).toHaveLength(1); diff --git a/tests/engine/faehigkeiten.test.ts b/tests/engine/faehigkeiten.test.ts index fcbb404..a25cdcc 100644 --- a/tests/engine/faehigkeiten.test.ts +++ b/tests/engine/faehigkeiten.test.ts @@ -101,17 +101,27 @@ describe("Strategien", () => { const mitImport = [storage("local", ["iso", "import", "snippets"])]; const ohneImport = [storage("local", ["iso", "snippets"])]; - it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => { - const f = faehigkeitenAbleiten({ + it("PVE 9.2 mit import-Storage ohne SSH: Image ginge per API, der Guest-Agent aber nicht – außer mit Bau-Adresse", () => { + const ohne = faehigkeitenAbleiten({ version: v("9.2.1"), storages: mitImport, ssh: "aus", rechteFehlen: false, }); - expect(f.templateBau.weg).toBe("api"); - expect(f.vmProvisionierung.weg).toBe("api"); - expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" }); - expect(f.snippets.weg).toBe("nicht_moeglich"); + expect(ohne.templateBau.weg).toBe("nicht_moeglich"); + expect(ohne.templateBau.grund).toMatch(/Bau-Adresse/); + const mit = faehigkeitenAbleiten({ + version: v("9.2.1"), + storages: mitImport, + ssh: "aus", + rechteFehlen: false, + bauNetz: true, + }); + expect(mit.templateBau).toMatchObject({ weg: "api" }); + expect(mit.templateBau.grund).toMatch(/Bau-Adresse/); + expect(mit.vmProvisionierung.weg).toBe("api"); + expect(mit.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" }); + expect(mit.snippets.weg).toBe("nicht_moeglich"); }); it("PVE 8.3: Template-Bau nur per SSH; ohne SSH mit Begründung unmöglich", () => { diff --git a/tests/engine/template-bau.test.ts b/tests/engine/template-bau.test.ts index 3d5a6c7..46d8af9 100644 --- a/tests/engine/template-bau.test.ts +++ b/tests/engine/template-bau.test.ts @@ -360,3 +360,74 @@ describe("Profile einbacken (#022)", () => { ]); }); }); + +describe("Guest-Agent über die Bau-Adresse (#021)", () => { + it("ohne Node-SSH: feste Adresse und Bereitstellungsschlüssel per cloud-init, SSH in die VM, danach zurückgesetzt", async () => { + const { mock, client } = await aufbauen({ version: "9.2.1" }); + mock.route("POST", /\/status\/shutdown$/, () => ({ data: UPID("qmshutdown") })); + const gast: Array<{ host: string; benutzer: string; skript: string }> = []; + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh: null, + auftragKurzId: "t", + holen: async () => `${HASH} debian-13-genericcloud-amd64.qcow2`, + vmidReservieren: async () => 9000, + warten: async () => {}, + bauNetz: { ip: "10.0.10.250/24", gateway: "10.0.10.1", dns: ["10.0.10.1"] }, + bereitstellung: async () => ({ + privat: "p", + oeffentlich: "ssh-ed25519 AAAAbau proxdeploy-bereitstellung", + }), + gastSsh: async (ziel, skript) => { + gast.push({ host: ziel.host, benutzer: ziel.benutzer, skript }); + return 0; + }, + }), + stumm, + ); + expect(ergebnis).toEqual({ ok: true }); + expect(gast).toEqual([ + { + host: "10.0.10.250", + benutzer: "debian", + skript: expect.stringContaining("apt-get -y -q -o DPkg::Lock::Timeout=600 install qemu-guest-agent"), + }, + ]); + expect(gast[0]!.skript).toContain("rm -f /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys"); + const configs = mock.anfragen.filter((a) => a.methode === "PUT").map((a) => Object.fromEntries(a.rumpf)); + expect(configs[0]).toMatchObject({ + ciuser: "debian", + ipconfig0: "ip=10.0.10.250/24,gw=10.0.10.1", + nameserver: "10.0.10.1", + }); + expect(configs[0]!["sshkeys"]).toBe("ssh-ed25519%20AAAAbau%20proxdeploy-bereitstellung"); + expect(configs.at(-1)).toEqual({ ipconfig0: "ip=dhcp", delete: "sshkeys,ciuser,nameserver" }); + // Kein Snippet ohne Node-SSH. + expect(mock.anfragen.some((a) => a.rumpf.get("cicustom"))).toBe(false); + }); + + it("ohne Node-SSH und ohne Bau-Adresse sagt schon die Prüfung, was fehlt", async () => { + const { client } = await aufbauen({ version: "9.2.1" }); + const ergebnis = await phasenAusfuehren( + templateBauSchritte({ + client, + distribution: await debian13(), + knoten: "pve01", + storage: "local-zfs", + bridge: "vmbr0", + ssh: null, + auftragKurzId: "t", + holen: async () => "", + vmidReservieren: async () => 9000, + }), + stumm, + ); + expect(ergebnis).toMatchObject({ ok: false, phase: "pruefungen" }); + if (!ergebnis.ok) expect(ergebnis.fehler.hinweis).toMatch(/Bau-Adresse/); + }); +});