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>
243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
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",
|
||
]);
|
||
});
|
||
});
|