feat(templates): Template-Bau ohne SSH über eine Bau-Adresse
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 }) {
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="text-sm font-medium">Bau-Adresse (optional)</legend>
|
||||
<p className="text-muted text-sm">
|
||||
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.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Feld id="bauIp" label="IP/Präfix" fehler={felder["bauNetz.ip"]}>
|
||||
<input
|
||||
id="bauIp"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauIp}
|
||||
onChange={(e) => setze("bauIp", e.target.value)}
|
||||
placeholder="10.0.10.250/24"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="bauGateway" label="Gateway" fehler={felder["bauNetz.gateway"]}>
|
||||
<input
|
||||
id="bauGateway"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauGateway}
|
||||
onChange={(e) => setze("bauGateway", e.target.value)}
|
||||
placeholder="10.0.10.1"
|
||||
/>
|
||||
</Feld>
|
||||
<Feld id="bauDns" label="DNS" fehler={felder["bauNetz.dns"]}>
|
||||
<input
|
||||
id="bauDns"
|
||||
className={`${EINGABE_KLASSE} font-mono`}
|
||||
value={werte.bauDns}
|
||||
onChange={(e) => setze("bauDns", e.target.value)}
|
||||
placeholder="10.0.10.1"
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Schalter
|
||||
id="sshAktiv"
|
||||
label="SSH zu den Nodes nutzen"
|
||||
|
||||
@@ -6,6 +6,9 @@ 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 { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
|
||||
import type { BauNetz } from "@/lib/schemas/server";
|
||||
import type { Bereitstellungsschluessel } from "./lxc-ausrollen";
|
||||
import { adapterFuer } from "@/engine/adapter";
|
||||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||
import { profileRendern } from "@/engine/profile/rendern";
|
||||
@@ -36,6 +39,14 @@ export type TemplateBauEingabe = {
|
||||
distribution: Distribution;
|
||||
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
|
||||
profile?: AufgeloestesProfil[];
|
||||
/** Bau-Adresse (#021): Guest-Agent per SSH in die VM einbauen, wenn es kein Node-SSH gibt. */
|
||||
bauNetz?: BauNetz | null;
|
||||
bereitstellung?: () => Promise<Bereitstellungsschluessel>;
|
||||
/** Nur für Tests: SSH in die Vorbereitungs-VM ersetzen. */
|
||||
gastSsh?: (
|
||||
ziel: { host: string; benutzer: string; schluessel: Bereitstellungsschluessel },
|
||||
skript: string,
|
||||
) => Promise<number | null>;
|
||||
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<void>,
|
||||
): 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<string>(`${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<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);
|
||||
}
|
||||
} 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<void>,
|
||||
beiZeile: (z: string) => void,
|
||||
): Promise<number | null> {
|
||||
// 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<Array<{ iface: string; type: string }>>(
|
||||
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(),
|
||||
|
||||
@@ -119,7 +119,7 @@ async function versuchen<T>(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<VerbindungsBericht> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<z.infer<typeof BauNetz>>;
|
||||
|
||||
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<T extends { templateVmidVon: number; templateVmidBis: number }>(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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>)[feld] !== wert)
|
||||
.filter(
|
||||
([feld, wert]) =>
|
||||
feld === "tokenGeheimnis" ||
|
||||
vergleichbar((bisher as Record<string, unknown>)[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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user