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:
2026-10-12 14:36:38 +02:00
co-authored by Claude Opus 5.5
parent 426d3ec95a
commit 21edff2141
14 changed files with 366 additions and 28 deletions
+1 -1
View File
@@ -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);
+16 -6
View File
@@ -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", () => {
+71
View File
@@ -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/);
});
});