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]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### 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)
|
- 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)
|
- 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)
|
- 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
|
### Geändert
|
||||||
|
|
||||||
### Behoben
|
### 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)
|
- 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)
|
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
# #021 Template-Bau ohne SSH (Bau-Adresse)
|
||||||
**Status:** offen
|
**Status:** umgesetzt, Abnahme auf pve-demo ausstehend
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature, proxmox
|
**Labels:** feature, proxmox
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Templates auch auf Servern ohne SSH zum Node bauen. Der Guest-Agent wird dann nicht per Snippet,
|
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.
|
sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Server-Einstellung `bauNetz`: Bridge, VLAN, statische IP/CIDR, Gateway, DNS
|
- [x] Server-Einstellung `bauNetz`: statische IP/CIDR, Gateway, DNS (Bridge/VLAN wie beim Template-Bau gewählt)
|
||||||
- [ ] Vorbereitungsstart mit cloud-init `ipconfig0` = Bau-Adresse und ProxDeploys Bereitstellungsschlüssel in `sshkeys`
|
- [x] 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
|
- [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
|
||||||
- [ ] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
- [x] Fähigkeitserkennung: „Template-Bau per API ohne SSH möglich, wenn Bau-Adresse gesetzt“
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
|
- [ ] Template-Bau auf pve-demo mit abgeschaltetem SSH
|
||||||
@@ -21,4 +21,7 @@ sondern über SSH **in die Vorbereitungs-VM** eingebaut.
|
|||||||
#007
|
#007
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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.
|
- 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 | – |
|
| [#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 | – |
|
| [#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 | – |
|
| [#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 |
|
| [#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;
|
templateVmidVon: string;
|
||||||
templateVmidBis: string;
|
templateVmidBis: string;
|
||||||
gastVmidVon: string;
|
gastVmidVon: string;
|
||||||
|
bauIp: string;
|
||||||
|
bauGateway: string;
|
||||||
|
bauDns: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function anfangswerte(server?: ServerSicht): Werte {
|
function anfangswerte(server?: ServerSicht): Werte {
|
||||||
@@ -60,6 +63,9 @@ function anfangswerte(server?: ServerSicht): Werte {
|
|||||||
templateVmidVon: String(server?.templateVmidVon ?? 9000),
|
templateVmidVon: String(server?.templateVmidVon ?? 9000),
|
||||||
templateVmidBis: String(server?.templateVmidBis ?? 9999),
|
templateVmidBis: String(server?.templateVmidBis ?? 9999),
|
||||||
gastVmidVon: String(server?.gastVmidVon ?? 100),
|
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);
|
setSpeichert(true);
|
||||||
setFelder({});
|
setFelder({});
|
||||||
setMeldung(null);
|
setMeldung(null);
|
||||||
|
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
|
||||||
const rumpf = {
|
const rumpf = {
|
||||||
...werte,
|
...ohneBau,
|
||||||
sshPort: Number(werte.sshPort),
|
sshPort: Number(werte.sshPort),
|
||||||
templateVmidVon: Number(werte.templateVmidVon),
|
templateVmidVon: Number(werte.templateVmidVon),
|
||||||
templateVmidBis: Number(werte.templateVmidBis),
|
templateVmidBis: Number(werte.templateVmidBis),
|
||||||
gastVmidVon: Number(werte.gastVmidVon),
|
gastVmidVon: Number(werte.gastVmidVon),
|
||||||
|
bauNetz: bauIp.trim()
|
||||||
|
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const gespeichert = bearbeiten
|
const gespeichert = bearbeiten
|
||||||
@@ -361,6 +371,44 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
|||||||
</Feld>
|
</Feld>
|
||||||
</div>
|
</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
|
<Schalter
|
||||||
id="sshAktiv"
|
id="sshAktiv"
|
||||||
label="SSH zu den Nodes nutzen"
|
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 { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
import { mindestens, versionLesen } from "@/engine/proxmox/faehigkeiten";
|
||||||
import { q } from "@/engine/shell";
|
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 { adapterFuer } from "@/engine/adapter";
|
||||||
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
import { profileRendern } from "@/engine/profile/rendern";
|
import { profileRendern } from "@/engine/profile/rendern";
|
||||||
@@ -36,6 +39,14 @@ export type TemplateBauEingabe = {
|
|||||||
distribution: Distribution;
|
distribution: Distribution;
|
||||||
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
|
/** Eingebackene Profile (#022), aufgelöst mit Standardwerten; leer = nur Guest-Agent. */
|
||||||
profile?: AufgeloestesProfil[];
|
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;
|
knoten: string;
|
||||||
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
|
/** Ziel für Disk, EFI- und cloud-init-Laufwerk (Inhaltstyp images). */
|
||||||
storage: string;
|
storage: string;
|
||||||
@@ -56,6 +67,8 @@ export type TemplateBauEingabe = {
|
|||||||
|
|
||||||
export type TemplateBauStand = {
|
export type TemplateBauStand = {
|
||||||
vmid?: number;
|
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";
|
weg?: "api" | "ssh";
|
||||||
importStorage?: string;
|
importStorage?: string;
|
||||||
snippetStorage?: 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[] {
|
export function templateBauSchritte(e: TemplateBauEingabe, stand: TemplateBauStand = {}): PhasenSchritt[] {
|
||||||
const { client, distribution: d, knoten } = e;
|
const { client, distribution: d, knoten } = e;
|
||||||
const vm = d.vm;
|
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.",
|
: "SSH zum Node einrichten oder auf PVE 8.4+ aktualisieren.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!vm.guest_agent_preinstalled && !(e.ssh && stand.snippetStorage)) {
|
if (!vm.guest_agent_preinstalled) {
|
||||||
throw new AblaufFehler(
|
if (e.ssh && stand.snippetStorage) stand.agentWeg = "snippet";
|
||||||
"Der Guest-Agent muss ins Template eingebaut werden – dafür braucht ProxDeploy SSH zum Node und einen Storage mit Inhaltstyp „Snippets“.",
|
else if (e.bauNetz && e.bereitstellung) stand.agentWeg = "baunetz";
|
||||||
e.ssh
|
else {
|
||||||
? "Einem Dir-Storage den Inhaltstyp „Snippets“ geben."
|
throw new AblaufFehler(
|
||||||
: "SSH in den Server-Einstellungen einrichten.",
|
"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 }>>(
|
const netze = await client.get<Array<{ iface: string; type: string }>>(
|
||||||
pfad`/nodes/${knoten}/network`,
|
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" };
|
if (vm.guest_agent_preinstalled) return { uebersprungen: true, detail: "im Image enthalten" };
|
||||||
const vmid = stand.vmid!;
|
const vmid = stand.vmid!;
|
||||||
const vmPfad = pfad`/nodes/${knoten}/qemu/${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}`;
|
const snippet = `${stand.snippetStorage}:snippets/${snippetDatei}`;
|
||||||
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
|
await ssh(`set -e; ziel="$(pvesm path ${q(snippet)})"; install -D -m 0644 /dev/stdin "$ziel"`, k, {
|
||||||
eingabe: vorbereitungsSnippet(),
|
eingabe: vorbereitungsSnippet(),
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Pro
|
|||||||
|
|
||||||
export async function erkunden(
|
export async function erkunden(
|
||||||
client: ProxmoxClient,
|
client: ProxmoxClient,
|
||||||
optionen: { serverHost: string; ssh: SshZugang | null; jetzt?: () => Date },
|
optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
|
||||||
): Promise<VerbindungsBericht> {
|
): Promise<VerbindungsBericht> {
|
||||||
const warnungen: string[] = [];
|
const warnungen: string[] = [];
|
||||||
const version = await client.get<{ version: string; release: string }>("/version");
|
const version = await client.get<{ version: string; release: string }>("/version");
|
||||||
@@ -313,6 +313,7 @@ export async function erkunden(
|
|||||||
storages: alleStorages,
|
storages: alleStorages,
|
||||||
ssh: sshGesamt,
|
ssh: sshGesamt,
|
||||||
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
|
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
|
||||||
|
bauNetz: optionen.bauNetz,
|
||||||
}),
|
}),
|
||||||
warnungen,
|
warnungen,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ export function faehigkeitenAbleiten(eingabe: {
|
|||||||
storages: StorageInfo[];
|
storages: StorageInfo[];
|
||||||
ssh: SshZustand;
|
ssh: SshZustand;
|
||||||
rechteFehlen: boolean;
|
rechteFehlen: boolean;
|
||||||
|
/** Bau-Adresse gesetzt (#021) – Guest-Agent einbauen geht dann auch ohne SSH zum Node. */
|
||||||
|
bauNetz?: boolean;
|
||||||
}): Faehigkeiten {
|
}): Faehigkeiten {
|
||||||
const { version, storages, ssh } = eingabe;
|
const { version, storages, ssh } = eingabe;
|
||||||
const sshOk = ssh === "ok";
|
const sshOk = ssh === "ok";
|
||||||
@@ -237,10 +239,19 @@ export function faehigkeitenAbleiten(eingabe: {
|
|||||||
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
|
const snippetSpeicher = storages.filter((s) => s.aktiv && s.inhalte.includes("snippets"));
|
||||||
|
|
||||||
let templateBau: Strategie;
|
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 = {
|
templateBau = {
|
||||||
weg: "api",
|
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) {
|
} else if (sshOk) {
|
||||||
templateBau = {
|
templateBau = {
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ export const ServerUrl = z
|
|||||||
return `${url.protocol}//${url.host}`;
|
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 Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
|
||||||
|
|
||||||
const Basis = z.object({
|
const Basis = z.object({
|
||||||
@@ -52,6 +81,7 @@ const Basis = z.object({
|
|||||||
templateVmidVon: Vmid.default(9000),
|
templateVmidVon: Vmid.default(9000),
|
||||||
templateVmidBis: Vmid.default(9999),
|
templateVmidBis: Vmid.default(9999),
|
||||||
gastVmidVon: Vmid.default(100),
|
gastVmidVon: Vmid.default(100),
|
||||||
|
bauNetz: BauNetz,
|
||||||
});
|
});
|
||||||
|
|
||||||
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
|
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
|
||||||
@@ -112,6 +142,7 @@ export type ServerSicht = {
|
|||||||
templateVmidVon: number;
|
templateVmidVon: number;
|
||||||
templateVmidBis: number;
|
templateVmidBis: number;
|
||||||
gastVmidVon: number;
|
gastVmidVon: number;
|
||||||
|
bauNetz: BauNetz | null;
|
||||||
erreichbar: boolean | null;
|
erreichbar: boolean | null;
|
||||||
pveVersion: string | null;
|
pveVersion: string | null;
|
||||||
geprueftAm: 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 { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
|
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
|
||||||
|
import type { BauNetz } from "@/lib/schemas/server";
|
||||||
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
|
import { vorlageAnwenden } from "@/lib/schemas/vorlage";
|
||||||
import { vorlageHolen } from "@/server/services/vorlagen";
|
import { vorlageHolen } from "@/server/services/vorlagen";
|
||||||
import type { Aufrufer } from "@/server/auth/zugang";
|
import type { Aufrufer } from "@/server/auth/zugang";
|
||||||
@@ -162,7 +163,13 @@ export function ausrollSchritte(
|
|||||||
profile: v.profile,
|
profile: v.profile,
|
||||||
vmidReservieren: o.vmidReservieren,
|
vmidReservieren: o.vmidReservieren,
|
||||||
templateBau: v.anfrage.templateBauen
|
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,
|
: undefined,
|
||||||
angelegt: o.angelegt,
|
angelegt: o.angelegt,
|
||||||
auftragKurzId: o.auftragKurzId,
|
auftragKurzId: o.auftragKurzId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ssh2 from "ssh2";
|
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 { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
|
||||||
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
|
||||||
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ServerAendern,
|
ServerAendern,
|
||||||
ServerAnlegen,
|
ServerAnlegen,
|
||||||
ServerPruefen,
|
ServerPruefen,
|
||||||
|
type BauNetz,
|
||||||
type ServerSicht,
|
type ServerSicht,
|
||||||
} from "@/lib/schemas/server";
|
} from "@/lib/schemas/server";
|
||||||
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
|
||||||
@@ -32,6 +33,7 @@ export function serverSicht(server: Server): ServerSicht {
|
|||||||
templateVmidVon: server.templateVmidVon,
|
templateVmidVon: server.templateVmidVon,
|
||||||
templateVmidBis: server.templateVmidBis,
|
templateVmidBis: server.templateVmidBis,
|
||||||
gastVmidVon: server.gastVmidVon,
|
gastVmidVon: server.gastVmidVon,
|
||||||
|
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
|
||||||
erreichbar: server.erreichbar,
|
erreichbar: server.erreichbar,
|
||||||
pveVersion: server.pveVersion,
|
pveVersion: server.pveVersion,
|
||||||
geprueftAm: server.geprueftAm?.toISOString() ?? null,
|
geprueftAm: server.geprueftAm?.toISOString() ?? null,
|
||||||
@@ -83,6 +85,7 @@ export async function serverAnlegen(
|
|||||||
const angelegt = await tx.server.create({
|
const angelegt = await tx.server.create({
|
||||||
data: {
|
data: {
|
||||||
...eingabe,
|
...eingabe,
|
||||||
|
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
|
||||||
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||||
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
|
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
|
||||||
},
|
},
|
||||||
@@ -117,12 +120,19 @@ export async function serverAendern(
|
|||||||
const { tokenGeheimnis, ...rest } = eingabe;
|
const { tokenGeheimnis, ...rest } = eingabe;
|
||||||
const daten = {
|
const daten = {
|
||||||
...rest,
|
...rest,
|
||||||
|
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
|
||||||
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
|
||||||
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
|
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
|
||||||
};
|
};
|
||||||
// Welche Felder sich geändert haben, steht im Protokoll – die Werte von Geheimnissen nie.
|
// 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)
|
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);
|
.map(([feld]) => feld);
|
||||||
|
|
||||||
const server = await prisma.$transaction(async (tx) => {
|
const server = await prisma.$transaction(async (tx) => {
|
||||||
@@ -287,6 +297,7 @@ export async function serverTesten(
|
|||||||
try {
|
try {
|
||||||
const bericht = await erkunden(client, {
|
const bericht = await erkunden(client, {
|
||||||
serverHost: new URL(server.url).hostname,
|
serverHost: new URL(server.url).hostname,
|
||||||
|
bauNetz: Boolean(server.bauNetz),
|
||||||
ssh: server.sshAktiv
|
ssh: server.sshAktiv
|
||||||
? {
|
? {
|
||||||
benutzer: server.sshBenutzer,
|
benutzer: server.sshBenutzer,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { templateBauSchritte, templateName, type TemplateBauStand } from "@/engi
|
|||||||
import type { Distribution } from "@/engine/katalog/schema";
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { BauNetz } from "@/lib/schemas/server";
|
||||||
import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
import { TemplateBauAnfrage } from "@/lib/schemas/template";
|
||||||
|
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
||||||
import { melden } from "@/server/ereignisse/senke";
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
import { verbindungAus } from "@/server/services/server";
|
import { verbindungAus } from "@/server/services/server";
|
||||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
@@ -43,6 +45,8 @@ ablaufRegistrieren("TEMPLATE_BAUEN", async ({ auftrag, senke, abbruchGewuenscht,
|
|||||||
bridge: anfrage.bridge,
|
bridge: anfrage.bridge,
|
||||||
vlan: anfrage.vlan,
|
vlan: anfrage.vlan,
|
||||||
ssh: nodeBefehlFuer(server, anfrage.knoten),
|
ssh: nodeBefehlFuer(server, anfrage.knoten),
|
||||||
|
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
|
||||||
|
bereitstellung: bereitstellungsschluessel,
|
||||||
auftragKurzId: auftrag.kurzId,
|
auftragKurzId: auftrag.kurzId,
|
||||||
vmidReservieren: async (belegt) => {
|
vmidReservieren: async (belegt) => {
|
||||||
const vmid = await vmidReservieren({
|
const vmid = await vmidReservieren({
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ async function mitSzenario(szenario: Szenario) {
|
|||||||
describe("Verbindungstest (Erkundung)", () => {
|
describe("Verbindungstest (Erkundung)", () => {
|
||||||
it("PVE 9.2 Einzelhost: Nodes, Storages, Bridges, keine fehlenden Rechte, Template-Bau per API", async () => {
|
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 { 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.version).toBe("9.2.1");
|
||||||
expect(bericht.cluster).toBeNull();
|
expect(bericht.cluster).toBeNull();
|
||||||
expect(bericht.knoten).toHaveLength(1);
|
expect(bericht.knoten).toHaveLength(1);
|
||||||
|
|||||||
@@ -101,17 +101,27 @@ describe("Strategien", () => {
|
|||||||
const mitImport = [storage("local", ["iso", "import", "snippets"])];
|
const mitImport = [storage("local", ["iso", "import", "snippets"])];
|
||||||
const ohneImport = [storage("local", ["iso", "snippets"])];
|
const ohneImport = [storage("local", ["iso", "snippets"])];
|
||||||
|
|
||||||
it("PVE 9.2 mit import-Storage: Template-Bau per API, auch ohne SSH", () => {
|
it("PVE 9.2 mit import-Storage ohne SSH: Image ginge per API, der Guest-Agent aber nicht – außer mit Bau-Adresse", () => {
|
||||||
const f = faehigkeitenAbleiten({
|
const ohne = faehigkeitenAbleiten({
|
||||||
version: v("9.2.1"),
|
version: v("9.2.1"),
|
||||||
storages: mitImport,
|
storages: mitImport,
|
||||||
ssh: "aus",
|
ssh: "aus",
|
||||||
rechteFehlen: false,
|
rechteFehlen: false,
|
||||||
});
|
});
|
||||||
expect(f.templateBau.weg).toBe("api");
|
expect(ohne.templateBau.weg).toBe("nicht_moeglich");
|
||||||
expect(f.vmProvisionierung.weg).toBe("api");
|
expect(ohne.templateBau.grund).toMatch(/Bau-Adresse/);
|
||||||
expect(f.lxcProvisionierung).toMatchObject({ weg: "ssh", ueber: "gast" });
|
const mit = faehigkeitenAbleiten({
|
||||||
expect(f.snippets.weg).toBe("nicht_moeglich");
|
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", () => {
|
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