Files
proxdeploy/tests/engine/lxc-ausrollen.test.ts
florian.wildeandClaude Opus 5.5 ca9d6de446 fix(ablauf): angelegte ID merken, bevor auf Proxmox gewartet wird
Brach das Warten auf Klonen oder Anlegen ab, legte Proxmox den Gast trotzdem fertig an – und „Aufräumen“ wusste nichts von ihm (CT 114 im Feldtest).

Refs #020

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:02:57 +02:00

243 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it } from "vitest";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { lxcAusrollSchritte, lxcNetz, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen";
import type { NodeBefehl } from "@/engine/ablaeufe/template-bau";
import { katalogLaden } from "@/engine/katalog/laden";
import { profileAufloesen } from "@/engine/profile/aufloesen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten } from "../proxmox-mock/szenario";
const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`;
const SCHLUESSEL =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
const stumm: EreignisSenke = {
phasenAnlegen: async () => {},
phaseGestartet: async () => {},
phaseDetail: async () => {},
phaseBeendet: async () => {},
log: async () => {},
};
const BEREIT = { privat: "privat", oeffentlich: "ssh-ed25519 AAAAbereit proxdeploy-bereitstellung" };
const aufraeumen: Array<() => Promise<void>> = [];
afterEach(async () => {
while (aufraeumen.length) await aufraeumen.pop()!();
});
async function aufbauen(o: { templateLokal?: string[]; dhcpNachAbfragen?: number; trocken?: boolean } = {}) {
const mock = await new ProxmoxMock().starten();
szenarioEinrichten(mock, { version: "9.2.1" });
let interfaceAbfragen = 0;
mock
.route("GET", "/cluster/resources", () => ({
data: [{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 1e9 }, { vmid: 100 }],
}))
.route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({
data: (o.templateLokal ?? []).map((t) => ({ volid: `local:vztmpl/${t}` })),
}))
.route("GET", "/nodes/pve01/aplinfo", () => ({
data: [
{ template: "debian-13-standard_13.1-2_amd64.tar.zst" },
{ template: "debian-13-standard_13.6-1_amd64.tar.zst" },
{ template: "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" },
],
}))
.route("POST", "/nodes/pve01/aplinfo", () => ({ data: UPID("download") }))
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
.route("POST", "/nodes/pve01/lxc", () => ({ data: UPID("vzcreate") }))
.route("POST", /^\/nodes\/pve01\/lxc\/\d+\/status\/start$/, () => ({ data: UPID("vzstart") }))
.route("GET", /^\/nodes\/pve01\/lxc\/\d+\/interfaces$/, () => ({
data: [
{ name: "lo", inet: "127.0.0.1/8" },
{
name: "eth0",
...(++interfaceAbfragen > (o.dhcpNachAbfragen ?? 0) ? { inet: "10.0.10.77/24" } : {}),
},
],
}));
const client = new ProxmoxClient(
{
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
},
{ trocken: o.trocken },
);
aufraeumen.push(async () => {
await client.schliessen();
await mock.stoppen();
});
return { mock, client };
}
async function eingabe(
client: ProxmoxClient,
teil: Record<string, unknown> = {},
ssh: NodeBefehl | null = null,
) {
const katalog = await katalogLaden("catalog");
const anfrage = AusrollAnfrage.parse({
typ: "lxc",
server: "rz1",
distribution: "debian-13",
name: "ct01",
storage: "local-zfs",
diskGb: 8,
netz: { bridge: "vmbr0", modus: "dhcp" },
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
profile: ["docker"],
...teil,
});
const distribution = katalog.distributionen.find((d) => d.eintrag.id === anfrage.distribution)!.eintrag;
const aufgeloest = profileAufloesen(
anfrage.profile,
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
"debian",
);
if (!aufgeloest.ok) throw new Error("Profile");
const gast: Array<{ host: string; skript: string }> = [];
return {
gast,
e: {
client,
anfrage,
distribution,
profile: aufgeloest.profile,
profilFeatures: {
nesting: aufgeloest.profile.some((p) => p.profil.lxc.features?.nesting),
keyctl: false,
},
vmidReservieren: async () => 130,
sshFuer: () => ssh,
bereitstellung: async () => BEREIT,
angelegt: async () => {},
auftragKurzId: "d-ct",
warten: async () => {},
gastSsh: async (ziel: { host: string }, skript: string, beiAusgabe: (t: string) => void) => {
gast.push({ host: ziel.host, skript });
beiAusgabe("::proxdeploy:: profil base\n::proxdeploy:: fertig\n");
return 0;
},
},
};
}
describe("LXC ausrollen", () => {
it("merkt sich die CTID schon nach dem Absenden – auch wenn die Anlage danach scheitert", async () => {
const { mock, client } = await aufbauen({ templateLokal: ["debian-13-standard_13.6-1_amd64.tar.zst"] });
mock.route("GET", /\/tasks\/[^/]+vzcreate[^/]*\/status$/, () => ({
data: { status: "stopped", exitstatus: "unable to create CT 130 - storage full" },
}));
const { e } = await eingabe(client);
const gemerkt: Array<Record<string, unknown>> = [];
const ergebnis = await phasenAusfuehren(
lxcAusrollSchritte({ ...e, angelegt: async (x) => void gemerkt.push(x) }),
stumm,
);
expect(ergebnis.ok).toBe(false);
expect(gemerkt).toContainEqual({ vmid: 130 });
});
it("mit Node-SSH: neuestes Template laden, anlegen mit nesting (docker), DHCP-IP, pct exec mit Skript auf stdin", async () => {
const { mock, client } = await aufbauen({ dhcpNachAbfragen: 2 });
const befehle: Array<{ befehl: string; eingabe?: string }> = [];
const ssh: NodeBefehl = async (befehl, o) => {
befehle.push({ befehl, eingabe: o?.eingabe });
o?.beiAusgabe?.("::proxdeploy:: profil base\n");
return { code: 0, stdout: "", stderr: "" };
};
const { e } = await eingabe(client, {}, ssh);
const stand: LxcAusrollStand = {};
expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
expect(stand).toMatchObject({
weg: "node",
vmid: 130,
ip: "10.0.10.77",
ostemplate: "local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst",
});
expect(
Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")!.rumpf,
),
).toEqual({
storage: "local",
template: "debian-13-standard_13.6-1_amd64.tar.zst",
});
const anlegen = Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf,
);
expect(anlegen).toMatchObject({
vmid: "130",
hostname: "ct01",
rootfs: "local-zfs:8",
unprivileged: "1",
features: "nesting=1",
net0: "name=eth0,bridge=vmbr0,ip=dhcp,type=veth",
ostype: "debian",
});
expect(anlegen).not.toHaveProperty("ssh-public-keys");
expect(befehle).toHaveLength(1);
expect(befehle[0]!.befehl).toBe("pct exec 130 -- bash -s");
expect(befehle[0]!.eingabe).toContain("useradd -m -s /bin/bash florian");
// Zeitsync nur in VMs.
expect(befehle[0]!.eingabe).not.toContain("chrony");
});
it("vorhandenes neuestes Template wird nicht erneut geladen", async () => {
const { mock, client } = await aufbauen({ templateLokal: ["debian-13-standard_13.6-1_amd64.tar.zst"] });
const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" }));
await phasenAusfuehren(lxcAusrollSchritte(e), stumm);
expect(mock.anfragen.some((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")).toBe(false);
});
it("ohne Node-SSH: Bereitstellungsschlüssel an root, SSH in den Container, Schlüssel am Ende entfernen", async () => {
const { mock, client } = await aufbauen();
const { e, gast } = await eingabe(client, {
netz: { bridge: "vmbr0", modus: "statisch", ip: "10.0.20.5/24", gateway: "10.0.20.1" },
});
const stand: LxcAusrollStand = {};
expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
expect(stand.weg).toBe("gast");
const anlegen = Object.fromEntries(
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf,
);
expect(anlegen["ssh-public-keys"]).toBe(BEREIT.oeffentlich);
expect(anlegen["net0"]).toBe("name=eth0,bridge=vmbr0,ip=10.0.20.5/24,gw=10.0.20.1,type=veth");
expect(gast[0]!.host).toBe("10.0.20.5");
expect(
gast[0]!.skript.trimEnd().endsWith("sed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys"),
).toBe(true);
});
it("keyctl ohne Node-SSH wird vor dem Anlegen abgelehnt", async () => {
const { mock, client } = await aufbauen();
const { e } = await eingabe(client, { features: { nesting: true, keyctl: true } });
const ergebnis = await phasenAusfuehren(lxcAusrollSchritte(e), stumm);
expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/keyctl/);
expect(mock.anfragen.some((a) => a.pfad === "/nodes/pve01/lxc")).toBe(false);
});
it("Netzkonfiguration mit VLAN", () => {
expect(lxcNetz({ bridge: "vmbr1", vlan: 30, modus: "dhcp", dns: [] })).toBe(
"name=eth0,bridge=vmbr1,tag=30,ip=dhcp,type=veth",
);
});
it("Trockenlauf: Plan ohne Schreibzugriffe", async () => {
const { mock, client } = await aufbauen({ trocken: true });
const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" }));
expect(await phasenAusfuehren(lxcAusrollSchritte(e), stumm)).toEqual({ ok: true });
expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]);
expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([
"POST /nodes/pve01/aplinfo",
"POST /nodes/pve01/lxc",
"POST /nodes/pve01/lxc/130/status/start",
]);
});
});