Files
proxdeploy/tests/engine/proxmox-client.test.ts
T
florian.wildeandClaude Opus 5.5 bc13b94c7e fix(proxmox): Task-Status unter Last weiter abfragen, nesting an
Im Feldtest wurde ein LXC fertig angelegt, während ProxDeploy nach drei gescheiterten Statusabfragen aufgab (Verbindungsaufbau > 10 s bei 76 % I/O-Last). Statusabfragen werden jetzt bis zum Task-Zeitlimit wiederholt, der Verbindungsaufbau darf 30 s dauern. nesting ist für Container standardmäßig an wie in der Proxmox-Oberfläche – Proxmox warnt sonst bei systemd 249/252.

Refs #020

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

306 lines
12 KiB
TypeScript
Raw 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 { createServer as createNetServer, type AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { aufgabeAbwarten, nodeAusUpid } from "@/engine/proxmox/aufgaben";
import { parameterKodieren, pfad, ProxmoxClient } from "@/engine/proxmox/client";
import { ProxmoxFehler, rechtHinweis } from "@/engine/proxmox/fehler";
import { fingerabdruckNormalisieren, zertifikatHolen } from "@/engine/proxmox/tls";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
const mock = new ProxmoxMock();
function client(
ueberschreiben: Partial<ConstructorParameters<typeof ProxmoxClient>[0]> = {},
optionen: ConstructorParameters<typeof ProxmoxClient>[1] = {},
) {
return new ProxmoxClient(
{
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
...ueberschreiben,
},
// Ohne Pausen: Wiederholungen sollen Tests nicht ausbremsen.
{ pauseMs: () => 0, ...optionen },
);
}
async function fehlerVon(versprechen: Promise<unknown>): Promise<ProxmoxFehler> {
try {
await versprechen;
} catch (fehler) {
if (fehler instanceof ProxmoxFehler) return fehler;
throw fehler;
}
throw new Error("Es wurde ein Fehler erwartet.");
}
beforeAll(async () => {
await mock.starten();
mock
.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2", repoid: "abc" } }))
.route("GET", "/nodes/pve01/qemu/120/config", () => ({
status: 403,
statusText: "Permission check failed (/vms/120, VM.Audit)",
}))
.route("POST", "/nodes/pve01/qemu", () => ({
status: 400,
statusText: "Parameter verification failed.",
errors: { memory: "value must have a minimum value of 16" },
}))
.route("POST", "/echo", (anfrage) => ({ data: Object.fromEntries(anfrage.rumpf) }));
});
afterAll(() => mock.stoppen());
describe("Fingerprint", () => {
it("normalisiert übliche Schreibweisen", () => {
const hex = "ab".repeat(32);
const erwartet = "AB:".repeat(31) + "AB";
expect(fingerabdruckNormalisieren(hex)).toBe(erwartet);
expect(fingerabdruckNormalisieren(`SHA256: ${erwartet.toLowerCase()}`)).toBe(erwartet);
expect(fingerabdruckNormalisieren("zu-kurz")).toBeNull();
});
it("holt das Zertifikat des Servers mit Fingerprint und Namen", async () => {
const info = await zertifikatHolen(mock.url);
expect(info.fingerabdruck).toBe(mock.fingerabdruck);
expect(info.subjekt).toContain("CN=pve01.test");
expect(info.vertraut).toBe(false);
expect(info.alternativeNamen).toContain("DNS:pve01.test");
});
});
describe("ProxmoxClient", () => {
it("spricht mit gepinntem Fingerprint und API-Token", async () => {
expect(await client().get("/version")).toMatchObject({ version: "9.2.1" });
expect(mock.anfragen.at(-1)?.autorisierung).toBe(`PVEAPIToken=${MOCK_TOKEN_ID}=${MOCK_TOKEN_GEHEIMNIS}`);
});
it("trennt bei geändertem Fingerprint, bevor das Token gesendet wird", async () => {
const vorher = mock.anfragen.length;
const fehler = await fehlerVon(
client({ fingerabdruck: ProxmoxMock.fremderFingerabdruck() }).get("/version"),
);
expect(fehler.art).toBe("fingerabdruck");
expect(fehler.hinweis).toMatch(/erneuert/);
expect(mock.anfragen.length).toBe(vorher);
});
it("meldet ein selbst signiertes Zertifikat bei TLS-Prüfung ohne Pin verständlich", async () => {
const fehler = await fehlerVon(client({ tlsPruefen: true, fingerabdruck: null }).get("/version"));
expect(fehler.art).toBe("tls");
expect(fehler.hinweis).toMatch(/Fingerprint holen/);
});
it("verweigert eine Verbindung ganz ohne Prüfung", () => {
expect(() => client({ tlsPruefen: false, fingerabdruck: null })).toThrow(/blind/);
});
it("übersetzt 401 in einen Hinweis zum Token", async () => {
const fehler = await fehlerVon(client({ tokenGeheimnis: "falsch" }).get("/version"));
expect(fehler.art).toBe("anmeldung");
expect(fehler.hinweis).toMatch(/Token-ID/);
});
it("nennt bei 403 das fehlende Recht und den Pfad", async () => {
const fehler = await fehlerVon(client().get(pfad`/nodes/${"pve01"}/qemu/${120}/config`));
expect(fehler.art).toBe("recht");
expect(fehler.hinweis).toContain("VM.Audit auf /vms/120");
});
it("gibt abgelehnte Parameter einzeln zurück", async () => {
const fehler = await fehlerVon(client().post("/nodes/pve01/qemu", { memory: 1 }));
expect(fehler.art).toBe("parameter");
expect(fehler.ursache).toContain("memory: value must have a minimum value of 16");
});
it("meldet unbekannte Endpunkte als nicht unterstützt (alte PVE-Version)", async () => {
const fehler = await fehlerVon(client().get("/gibt/es/nicht"));
expect(fehler.art).toBe("nicht_gefunden");
});
it("meldet einen nicht erreichbaren Host", async () => {
// Einen freien Port belegen und sofort wieder freigeben – dort lauscht garantiert niemand.
const frei = await new Promise<number>((erfuellen) => {
const server = createNetServer().listen(0, "127.0.0.1", () => {
const port = (server.address() as AddressInfo).port;
server.close(() => erfuellen(port));
});
});
const fehler = await fehlerVon(client({ url: `https://127.0.0.1:${frei}` }).get("/version"));
expect(fehler.art).toBe("verbindung");
expect(fehler.hinweis).toMatch(/8006/);
});
it("kodiert Parameter wie pvesh: 1/0, Listen wiederholt, undefined weggelassen", async () => {
expect(parameterKodieren({ a: true, b: false, c: ["x", "y"], d: undefined, e: 5 }).toString()).toBe(
"a=1&b=0&c=x&c=y&e=5",
);
expect(await client().post("/echo", { name: "web 01", full: true })).toEqual({
name: "web 01",
full: "1",
});
});
it("setzt Pfadteile kodiert ein", () => {
expect(pfad`/nodes/${"pve 01"}/storage/${"a/b"}`).toBe("/nodes/pve%2001/storage/a%2Fb");
});
it("prüft das Format der Token-ID", () => {
try {
client({ tokenId: "root@pam" });
expect.unreachable();
} catch (fehler) {
expect((fehler as ProxmoxFehler).hinweis).toMatch(/benutzer@realm!tokenname/);
}
});
it("meldet jeden Aufruf an einen Beobachter (für Log und Dry-Run)", async () => {
const gesehen: string[] = [];
const beobachtet = new ProxmoxClient(
{
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
},
{
beobachter: (aufruf, ergebnis) =>
gesehen.push(`${aufruf.methode} ${aufruf.pfad} ${ergebnis.fehler?.art ?? "ok"}`),
},
);
await beobachtet.get("/version");
await beobachtet.get("/gibt/es/nicht").catch(() => undefined);
expect(gesehen).toEqual(["GET /version ok", "GET /gibt/es/nicht nicht_gefunden"]);
});
it("macht aus einem unbekannten Fehlertext einen allgemeinen Hinweis", () => {
expect(rechtHinweis("irgendwas")).toMatch(/proxmox-rechte\.md/);
});
});
describe("Vorübergehende Störungen", () => {
it("wiederholt GET nach „Broken pipe“ (596) und getrennter Verbindung und meldet es dem Beobachter", async () => {
let n = 0;
mock.route("GET", "/nodes/pve01/storage", () => {
n += 1;
if (n === 1) return { status: 596, statusText: "Broken pipe" };
if (n === 2) return { trennen: true };
return { data: [{ storage: "local" }] };
});
const gesehen: Array<{ fehler?: string; wiederholung?: number }> = [];
const c = client(
{},
{ beobachter: (_a, e) => gesehen.push({ fehler: e.fehler?.ursache, wiederholung: e.wiederholung }) },
);
expect(await c.get("/nodes/pve01/storage")).toEqual([{ storage: "local" }]);
expect(n).toBe(3);
expect(gesehen.map((g) => g.wiederholung)).toEqual([1, 2, undefined]);
expect(gesehen[0]!.fehler).toBe("Broken pipe");
});
it("gibt nach drei Versuchen auf", async () => {
let n = 0;
mock.route("GET", "/nodes/pve02/storage", () => {
n += 1;
return { status: 596, statusText: "Broken pipe" };
});
const fehler = await fehlerVon(client().get("/nodes/pve02/storage"));
expect(fehler.ursache).toBe("Broken pipe");
expect(n).toBe(3);
});
it("wiederholt Schreibendes nicht, wenn die Anfrage den Node erreicht haben kann", async () => {
let n = 0;
mock.route("POST", "/nodes/pve01/qemu", () => {
n += 1;
return n === 1 ? { trennen: true } : { data: "UPID:pve01:1:2:3:qmcreate:100:x:" };
});
await expect(client().post("/nodes/pve01/qemu", { vmid: 100 })).rejects.toThrow();
expect(n).toBe(1);
});
it("wiederholt keine fachlichen Fehler (500 mit Proxmox-Meldung, 403, 501)", async () => {
let n = 0;
mock.route("GET", "/nodes/pve03/storage", () => {
n += 1;
return { status: 500, statusText: "storage 'x' does not exist" };
});
await fehlerVon(client().get("/nodes/pve03/storage"));
expect(n).toBe(1);
});
});
describe("Proxmox-Tasks (UPID)", () => {
const UPID = "UPID:pve01:0000A1B2:00C3D4E5:6710F00D:qmclone:9013:proxdeploy@pve!test:";
it("liest den Node aus der UPID", () => {
expect(nodeAusUpid(UPID)).toBe("pve01");
expect(() => nodeAusUpid("kaputt")).toThrow(ProxmoxFehler);
});
it("wartet, bis der Task fertig ist, und reicht Log-Zeilen weiter", async () => {
let abfragen = 0;
mock
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => {
abfragen += 1;
return { data: abfragen < 3 ? { status: "running" } : { status: "stopped", exitstatus: "OK" } };
})
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, (anfrage) => {
const start = Number(anfrage.abfrage.get("start"));
return {
data:
start < abfragen ? [{ n: abfragen, t: `Zeile ${abfragen}` }] : [{ n: start, t: "no content" }],
};
});
const zeilen: string[] = [];
await aufgabeAbwarten(client(), UPID, { warten: async () => {}, beiLog: (neu) => zeilen.push(...neu) });
expect(abfragen).toBe(3);
expect(zeilen).toEqual(["Zeile 1", "Zeile 2", "Zeile 3"]);
});
it("wirft mit dem Exitstatus und den letzten Log-Zeilen, wenn der Task scheitert", async () => {
mock
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => ({
data: { status: "stopped", exitstatus: "unable to create VM 120 - already exists" },
}))
.route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, () => ({
data: [{ n: 1, t: "TASK ERROR: already exists" }],
}));
const fehler = await fehlerVon(aufgabeAbwarten(client(), UPID, { warten: async () => {} }));
expect(fehler.art).toBe("aufgabe");
expect(fehler.ursache).toContain("already exists");
expect(fehler.hinweis).toContain("TASK ERROR");
});
});
describe("Task abwarten unter Last", () => {
it("fragt nach vorübergehenden Fehlern der Statusabfrage weiter, bis der Task fertig ist", async () => {
let n = 0;
mock.route("GET", /\/nodes\/pve09\/tasks\/[^/]+\/status$/, () => {
n += 1;
if (n <= 4) return { status: 596, statusText: "Broken pipe" };
return { data: { status: "stopped", exitstatus: "WARNINGS: 1" } };
});
await aufgabeAbwarten(client(), "UPID:pve09:00000001:00000002:00000003:vzcreate:114:root@pam:", {
warten: async () => {},
});
expect(n).toBe(5);
});
it("bricht bei fachlichen Fehlern sofort ab", async () => {
mock.route("GET", /\/nodes\/pve08\/tasks\/[^/]+\/status$/, () => ({
status: 403,
statusText: "Permission check failed (/nodes/pve08, Sys.Audit)",
}));
const fehler = await fehlerVon(
aufgabeAbwarten(client(), "UPID:pve08:00000001:00000002:00000003:vzcreate:114:root@pam:", {
warten: async () => {},
}),
);
expect(fehler.art).toBe("recht");
});
});