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>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
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");
|
||
});
|
||
});
|