From 998aba9ef3d12efafe929d1727d5740e2702b09b Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 23 Sep 2026 10:28:25 +0200 Subject: [PATCH] feat(260923-dhh): Proxmox-Modul Aufgabe 3 - PBS und PMG auswerten - proxmox-normalize.ts: nachsichtige Leser (readNumber/readText/readBool/ readList) und normalizePve/normalizePbs/normalizePmg als reine Funktionen, nie ein Wurf bei unerwarteter Form - PVE ergaenzt um je Speicherort Belegung (storages) - PBS: Belegung je Datenspeicher plus letzte Sicherung/Pruefergebnis aus bis zu 10 Folgeabfragen je Durchlauf (Deckel in proxmox.service.ts) - PMG: Tageszahlen eingehend/ausgehend/Spam/Viren - Feldnamen je Produkt als benannte Konstante (Annahmen A3/A5 der Recherche), mehrere plausible Namen je Feld moeglich - proxmox.service.ts: produktabhaengige Abfragefolge, Ticket-Erneuerung jetzt je Durchlauf statt je Aufruf (PBS-Mehrfachabfragen loggen nicht mehrfach neu ein) - proxmox-nur-lesen.spec.ts: Riegel erkennt jetzt auch den Umschlag getWithRetry als zulaessige Aufrufform Tore: api 1293/1293 (>=1240), type-check 4/4. Co-Authored-By: Claude Sonnet 5 --- .../api/src/proxmox/proxmox-normalize.spec.ts | 240 ++++++++++++++++ apps/api/src/proxmox/proxmox-normalize.ts | 269 ++++++++++++++++++ .../api/src/proxmox/proxmox-nur-lesen.spec.ts | 18 +- apps/api/src/proxmox/proxmox.service.spec.ts | 83 ++++++ apps/api/src/proxmox/proxmox.service.ts | 229 +++++++++------ apps/api/src/proxmox/proxmox.types.ts | 8 + 6 files changed, 750 insertions(+), 97 deletions(-) create mode 100644 apps/api/src/proxmox/proxmox-normalize.spec.ts create mode 100644 apps/api/src/proxmox/proxmox-normalize.ts diff --git a/apps/api/src/proxmox/proxmox-normalize.spec.ts b/apps/api/src/proxmox/proxmox-normalize.spec.ts new file mode 100644 index 0000000..2749250 --- /dev/null +++ b/apps/api/src/proxmox/proxmox-normalize.spec.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; +import { + listPbsDatastoreNames, + normalizePbs, + normalizePmg, + normalizePve, + readBool, + readList, + readNumber, + readText, +} from './proxmox-normalize'; + +describe('nachsichtige Leser (Aufgabe 3, )', () => { + it('readNumber: Zahl, umwandelbare Zeichenkette, sonst null', () => { + expect(readNumber(42)).toBe(42); + expect(readNumber('42')).toBe(42); + expect(readNumber('0.37')).toBe(0.37); + expect(readNumber('nicht-umwandelbar')).toBeNull(); + expect(readNumber(undefined)).toBeNull(); + expect(readNumber(null)).toBeNull(); + expect(readNumber(Number.NaN)).toBeNull(); + }); + + it('readText: nichtleere Zeichenkette oder Zahl, sonst null', () => { + expect(readText('hallo')).toBe('hallo'); + expect(readText(42)).toBe('42'); + expect(readText('')).toBeNull(); + expect(readText(null)).toBeNull(); + expect(readText(undefined)).toBeNull(); + }); + + it('readBool: boolesch oder gaengige Wahr/Falsch-Formen, sonst null', () => { + expect(readBool(true)).toBe(true); + expect(readBool('true')).toBe(true); + expect(readBool(1)).toBe(true); + expect(readBool(false)).toBe(false); + expect(readBool('false')).toBe(false); + expect(readBool('irgendwas')).toBeNull(); + }); + + it('readList: alles, was kein Array ist, wird eine leere Liste', () => { + expect(readList([1, 2])).toEqual([1, 2]); + expect(readList('kein-array')).toEqual([]); + expect(readList(null)).toEqual([]); + expect(readList(undefined)).toEqual([]); + expect(readList({})).toEqual([]); + }); +}); + +describe('normalizePve (Aufgabe 3, )', () => { + it('Knotenzahl, laufende/gestoppte Gaeste, je Knoten Prozessorlast/Speicher, je Speicherort Belegung', () => { + const body = { + data: [ + { type: 'node', node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 }, + { type: 'node', node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 }, + { type: 'qemu', node: 'pve1', status: 'running' }, + { type: 'qemu', node: 'pve1', status: 'stopped' }, + { type: 'lxc', node: 'pve2', status: 'running' }, + { type: 'storage', node: 'pve1', storage: 'local-lvm', disk: 100, maxdisk: 500 }, + ], + }; + + const { metrics, errorKind } = normalizePve(body); + + expect(errorKind).toBeNull(); + expect(metrics.nodeCount).toBe(2); + expect(metrics.guestsRunning).toBe(2); + expect(metrics.guestsStopped).toBe(1); + expect(metrics.nodes).toEqual([ + { node: 'pve1', cpu: 0.25, maxcpu: 8, mem: 4_000_000_000, maxmem: 16_000_000_000 }, + { node: 'pve2', cpu: 0.1, maxcpu: 4, mem: 1_000_000_000, maxmem: 8_000_000_000 }, + ]); + expect(metrics.storages).toEqual([ + { storage: 'local-lvm', node: 'pve1', disk: 100, maxdisk: 500 }, + ]); + }); + + it('Feld fehlt -> null, nie 0/Wurf', () => { + const body = { data: [{ type: 'node', node: 'pve1' }] }; + expect(() => normalizePve(body)).not.toThrow(); + const { metrics } = normalizePve(body); + expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: null, maxcpu: null, mem: null, maxmem: null }); + }); + + it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => { + const body = { data: [{ type: 'node', node: 'pve1', cpu: '0.5', maxcpu: '4', mem: '100', maxmem: '200' }] }; + const { metrics } = normalizePve(body); + expect(metrics.nodes[0]).toEqual({ node: 'pve1', cpu: 0.5, maxcpu: 4, mem: 100, maxmem: 200 }); + }); + + it('Antwort ist HTML statt JSON-Objekt (hier: eine Zeichenkette) -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => { + expect(() => normalizePve('Anmeldeseite')).not.toThrow(); + const { metrics, errorKind } = normalizePve('Anmeldeseite'); + expect(errorKind).toBe('antwortform'); + expect(metrics).toEqual({ + productType: 'pve', + nodeCount: 0, + guestsRunning: 0, + guestsStopped: 0, + nodes: [], + storages: [], + }); + }); + + it('Antwort ist ein Array statt eines Objekts -> antwortform, kein Wurf', () => { + const { errorKind } = normalizePve([1, 2, 3]); + expect(errorKind).toBe('antwortform'); + }); + + it('Antwort ist null -> antwortform, kein Wurf', () => { + const { errorKind } = normalizePve(null); + expect(errorKind).toBe('antwortform'); + }); +}); + +describe('normalizePbs (Aufgabe 3, )', () => { + it('je Datenspeicher Gesamt/Belegt/Frei, letzter Sicherungszeitpunkt und letztes Pruefergebnis', () => { + const usage = { + data: [{ store: 'backup-store', total: 1000, used: 400, avail: 600 }], + }; + const snapshotsByStore = { + 'backup-store': { + data: [ + { 'backup-time': 1000, verification: { state: 'ok' } }, + { 'backup-time': 2000, verification: { state: 'failed' } }, + ], + }, + }; + + const { metrics, errorKind } = normalizePbs(usage, snapshotsByStore); + + expect(errorKind).toBeNull(); + expect(metrics.datastores).toEqual([ + { + name: 'backup-store', + total: 1000, + used: 400, + free: 600, + lastBackupAt: 2000, + lastVerifyState: 'failed', + }, + ]); + }); + + it('ein Datenspeicher ohne Sicherungen ergibt null (Frontend zeigt "noch keine Sicherung") und keinen Fehler', () => { + const usage = { data: [{ store: 'leer', total: 10, used: 0, avail: 10 }] }; + const { metrics, errorKind } = normalizePbs(usage, { leer: { data: [] } }); + expect(errorKind).toBeNull(); + expect(metrics.datastores[0]).toMatchObject({ lastBackupAt: null, lastVerifyState: null }); + }); + + it('Feld fehlt -> null, nie 0/Wurf', () => { + const usage = { data: [{ store: 'x' }] }; + expect(() => normalizePbs(usage, {})).not.toThrow(); + const { metrics } = normalizePbs(usage, {}); + expect(metrics.datastores[0]).toEqual({ + name: 'x', + total: null, + used: null, + free: null, + lastBackupAt: null, + lastVerifyState: null, + }); + }); + + it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => { + const usage = { data: [{ store: 'x', total: '1000', used: '400', avail: '600' }] }; + const { metrics } = normalizePbs(usage, {}); + expect(metrics.datastores[0]).toMatchObject({ total: 1000, used: 400, free: 600 }); + }); + + it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => { + expect(() => normalizePbs('', {})).not.toThrow(); + const { metrics, errorKind } = normalizePbs('', {}); + expect(errorKind).toBe('antwortform'); + expect(metrics.datastores).toEqual([]); + }); + + it('ein PBS-Server mit vielen Datenspeichern: listPbsDatastoreNames liefert alle Namen (Deckel lebt in proxmox.service.ts)', () => { + const usage = { data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}` })) }; + expect(listPbsDatastoreNames(usage)).toHaveLength(15); + }); +}); + +describe('normalizePmg (Aufgabe 3, )', () => { + it('Tageszahlen eingehend, ausgehend, Spam, Viren', () => { + const body = { + data: { + count_in: 100, + count_out: 50, + spamcount_in: 10, + spamcount_out: 2, + viruscount_in: 1, + viruscount_out: 0, + }, + }; + const { metrics, errorKind } = normalizePmg(body); + expect(errorKind).toBeNull(); + expect(metrics).toEqual({ + productType: 'pmg', + countIn: 100, + countOut: 50, + spamCount: 12, + virusCount: 1, + }); + }); + + it('Feld fehlt -> null, nie 0/Wurf', () => { + const body = { data: {} }; + expect(() => normalizePmg(body)).not.toThrow(); + const { metrics } = normalizePmg(body); + expect(metrics).toEqual({ + productType: 'pmg', + countIn: null, + countOut: null, + spamCount: null, + virusCount: null, + }); + }); + + it('Zahl kommt als Zeichenkette -> wird als Zahl gelesen', () => { + const body = { data: { count_in: '100', count_out: '50' } }; + const { metrics } = normalizePmg(body); + expect(metrics.countIn).toBe(100); + expect(metrics.countOut).toBe(50); + }); + + it('Antwort ist HTML statt JSON -> leeres Messwertobjekt, errorKind antwortform, kein Wurf', () => { + expect(() => normalizePmg('')).not.toThrow(); + const { metrics, errorKind } = normalizePmg(''); + expect(errorKind).toBe('antwortform'); + expect(metrics).toEqual({ + productType: 'pmg', + countIn: null, + countOut: null, + spamCount: null, + virusCount: null, + }); + }); +}); diff --git a/apps/api/src/proxmox/proxmox-normalize.ts b/apps/api/src/proxmox/proxmox-normalize.ts new file mode 100644 index 0000000..a35222d --- /dev/null +++ b/apps/api/src/proxmox/proxmox-normalize.ts @@ -0,0 +1,269 @@ +import type { + ProxmoxErrorKind, + ProxmoxPbsDatastoreMetric, + ProxmoxPbsMetrics, + ProxmoxPmgMetrics, + ProxmoxPveMetrics, + ProxmoxPveNodeMetric, +} from './proxmox.types'; + +/** + * Nachsichtige Leser als reine Funktionen ohne Datenbankbezug — der + * gesamte Umgang mit einer unerwarteten Form ist ein Rueckgabewert + * (`null`/leere Liste), NIE eine Ausnahme. Der Nutzer prueft dieses Modul + * ausschliesslich an seinen eigenen, echten Servern; ein Wurf wuerde ihm + * eine leere Seite zeigen statt eines ehrlichen "unbekannt". + */ + +export function readNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +export function readText(value: unknown): string | null { + if (typeof value === 'string' && value.trim() !== '') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return null; +} + +export function readBool(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + if (value === 'true' || value === 1 || value === '1') return true; + if (value === 'false' || value === 0 || value === '0') return false; + return null; +} + +/** Liefert bei allem, was kein Array ist, eine LEERE Liste — nie einen Wurf. */ +export function readList(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Nimmt den ersten VORHANDENEN Schluessel einer Namensliste (mehrere plausible Namen, in Reihenfolge). */ +function readFirstPresent(record: Record, keys: readonly string[]): unknown { + for (const key of keys) { + if (key in record && record[key] !== undefined) return record[key]; + } + return undefined; +} + +export interface NormalizeResult { + metrics: TMetrics; + errorKind: ProxmoxErrorKind | null; +} + +// --------------------------------------------------------------------------- +// PVE — /api2/json/cluster/resources +// --------------------------------------------------------------------------- + +function emptyPveMetrics(): ProxmoxPveMetrics { + return { + productType: 'pve', + nodeCount: 0, + guestsRunning: 0, + guestsStopped: 0, + nodes: [], + storages: [], + }; +} + +export function normalizePve(body: unknown): NormalizeResult { + if (!isRecord(body)) { + // Ganze Antwort ist Zeichenkette/Array/null/leer — leeres Messwertobjekt, kein Wurf. + return { metrics: emptyPveMetrics(), errorKind: 'antwortform' }; + } + + const list = readList(body.data); + const isEntry = (e: unknown): e is Record => isRecord(e); + + const nodeEntries = list.filter((e) => isEntry(e) && e.type === 'node') as Record[]; + const guestEntries = list.filter( + (e) => isEntry(e) && (e.type === 'qemu' || e.type === 'lxc'), + ) as Record[]; + const storageEntries = list.filter((e) => isEntry(e) && e.type === 'storage') as Record< + string, + unknown + >[]; + const running = guestEntries.filter((g) => g.status === 'running').length; + + const nodes: ProxmoxPveNodeMetric[] = nodeEntries.map((n) => ({ + node: readText(n.node) ?? 'unbekannt', + cpu: readNumber(n.cpu), + maxcpu: readNumber(n.maxcpu), + mem: readNumber(n.mem), + maxmem: readNumber(n.maxmem), + })); + + const storages = storageEntries.map((s) => ({ + storage: readText(s.storage) ?? 'unbekannt', + node: readText(s.node) ?? 'unbekannt', + disk: readNumber(s.disk), + maxdisk: readNumber(s.maxdisk), + })); + + return { + metrics: { + productType: 'pve', + nodeCount: nodeEntries.length, + guestsRunning: running, + guestsStopped: guestEntries.length - running, + nodes, + storages, + }, + errorKind: null, + }; +} + +// --------------------------------------------------------------------------- +// PBS — /api2/json/status/datastore-usage + je Datenspeicher .../snapshots +// --------------------------------------------------------------------------- + +/** + * Feldnamen der PBS-Belegungsabfrage sind aus der Recherche nur ABGELEITET + * (Annahme A3, Forenbeleg, kein Primaerbeleg) — GENAU DIESE Konstante ist + * anzupassen, wenn ein echter PBS-Server andere Namen liefert. + */ +const PBS_USAGE_FIELDS = { + store: ['store', 'name'], + total: ['total'], + used: ['used'], + free: ['avail', 'free'], +} as const; + +/** Dieselbe Annahme A3 fuer die Sicherungsliste eines Datenspeichers. */ +const PBS_SNAPSHOT_FIELDS = { + backupTime: ['backup-time', 'backupTime'], + verifyState: ['verification', 'verify-state', 'verifyState'], +} as const; + +function emptyPbsMetrics(): ProxmoxPbsMetrics { + return { productType: 'pbs', datastores: [] }; +} + +/** + * Nur die Datenspeichernamen aus der Belegungsantwort — fuer den Deckel + * der Folgeabfragen in `proxmox.service.ts` (Aufgabe 3, hoechstens 10 je + * Durchlauf). Dieselbe Feldnamen-Konstante wie `normalizePbs`, damit es + * EINE Stelle zum Nachziehen gibt, nicht zwei. + */ +export function listPbsDatastoreNames(usage: unknown): string[] { + if (!isRecord(usage)) return []; + return readList(usage.data) + .filter(isRecord) + .map((entry) => readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store))) + .filter((name): name is string => name !== null); +} + +function readVerifyState(value: unknown): string | null { + // `verification` kann selbst ein Objekt sein ({ state: 'ok', ... }) oder + // direkt eine Zeichenkette — beide Formen kommen in Forenbeispielen vor. + if (isRecord(value)) { + const state = readFirstPresent(value, ['state', 'result']); + return readText(state); + } + return readText(value); +} + +/** + * `usage` ist die Antwort von `/status/datastore-usage`; `snapshotsByStore` + * bildet je Datenspeichernamen die (bereits abgefragte) Rohantwort seiner + * `/admin/datastore/{store}/snapshots`-Abfrage ab — `undefined`, wenn der + * Deckel von hoechstens 10 Folgeabfragen je Durchlauf (`proxmox.service.ts`) + * diesen Speicher nicht mehr erreicht hat. + */ +export function normalizePbs( + usage: unknown, + snapshotsByStore: Record, +): NormalizeResult { + if (!isRecord(usage)) { + return { metrics: emptyPbsMetrics(), errorKind: 'antwortform' }; + } + + const entries = readList(usage.data).filter(isRecord); + + const datastores: ProxmoxPbsDatastoreMetric[] = entries.map((entry) => { + const name = readText(readFirstPresent(entry, PBS_USAGE_FIELDS.store)) ?? 'unbekannt'; + const snapshotsBody = snapshotsByStore[name]; + const snapshotList = isRecord(snapshotsBody) ? readList(snapshotsBody.data).filter(isRecord) : []; + + let lastBackupAt: number | null = null; + let lastVerifyState: string | null = null; + for (const snapshot of snapshotList) { + const backupTime = readNumber(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.backupTime)); + if (backupTime !== null && (lastBackupAt === null || backupTime > lastBackupAt)) { + lastBackupAt = backupTime; + lastVerifyState = readVerifyState(readFirstPresent(snapshot, PBS_SNAPSHOT_FIELDS.verifyState)); + } + } + // Kein Eintrag in der Liste (leer, aber kein Fehler): "noch keine + // Sicherung" — Frontend (Aufgabe 6) unterscheidet das ueber + // `snapshotList.length === 0`, hier bleibt der Wert ehrlich `null`. + + return { + name, + total: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.total)), + used: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.used)), + free: readNumber(readFirstPresent(entry, PBS_USAGE_FIELDS.free)), + lastBackupAt, + lastVerifyState, + }; + }); + + return { metrics: { productType: 'pbs', datastores }, errorKind: null }; +} + +// --------------------------------------------------------------------------- +// PMG — /api2/json/statistics/mail +// --------------------------------------------------------------------------- + +/** Annahme A5 der Recherche — abgeleitet aus `pmgsh`-Community-Belegen, nicht aus Primaerdoku. */ +const PMG_STATS_FIELDS = { + countIn: ['count_in'], + countOut: ['count_out'], + spamIn: ['spamcount_in'], + spamOut: ['spamcount_out'], + virusIn: ['viruscount_in'], + virusOut: ['viruscount_out'], +} as const; + +function emptyPmgMetrics(): ProxmoxPmgMetrics { + return { productType: 'pmg', countIn: null, countOut: null, spamCount: null, virusCount: null }; +} + +function sumOrNull(a: number | null, b: number | null): number | null { + if (a === null && b === null) return null; + return (a ?? 0) + (b ?? 0); +} + +export function normalizePmg(body: unknown): NormalizeResult { + if (!isRecord(body)) { + return { metrics: emptyPmgMetrics(), errorKind: 'antwortform' }; + } + + const stats = isRecord(body.data) ? body.data : {}; + + const countIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countIn)); + const countOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.countOut)); + const spamIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamIn)); + const spamOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.spamOut)); + const virusIn = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusIn)); + const virusOut = readNumber(readFirstPresent(stats, PMG_STATS_FIELDS.virusOut)); + + return { + metrics: { + productType: 'pmg', + countIn, + countOut, + spamCount: sumOrNull(spamIn, spamOut), + virusCount: sumOrNull(virusIn, virusOut), + }, + errorKind: null, + }; +} diff --git a/apps/api/src/proxmox/proxmox-nur-lesen.spec.ts b/apps/api/src/proxmox/proxmox-nur-lesen.spec.ts index 9c227c1..b7bff80 100644 --- a/apps/api/src/proxmox/proxmox-nur-lesen.spec.ts +++ b/apps/api/src/proxmox/proxmox-nur-lesen.spec.ts @@ -120,21 +120,31 @@ function countMethodPassingCalls(text: string): number { return count; } -/** Fundstellen eines Proxmox-API-Pfads ausserhalb eines `proxmoxGet(...)`-Aufrufs. */ +/** + * Aufrufformen, deren Argumentbereich einen Proxmox-Pfad tragen darf: + * `proxmoxGet` selbst, UND `getWithRetry` — der private Umschlag in + * `proxmox.service.ts` (Aufgabe 2/3, Ticket-Erneuerung), der seinerseits + * ausschliesslich `proxmoxGet` ruft (durch dieselbe erste Aussage dieses + * Riegels abgesichert: keine zweite `undiciFetch`-Methodenstelle in dieser + * Datei). + */ +const ALLOWED_PATH_CALLEES = ['proxmoxGet', 'getWithRetry'] as const; + +/** Fundstellen eines Proxmox-API-Pfads ausserhalb einer erlaubten Aufrufform. */ function findApiPathViolations(fileName: string, text: string): string[] { if (fileName === 'proxmox-auth.ts') { // Die Ticket-Anmeldung ist die eine dokumentierte Ausnahme (D-01). return []; } - const proxmoxGetSpans = collectCallArgSpans(text, 'proxmoxGet'); + const allowedSpans = ALLOWED_PATH_CALLEES.flatMap((callee) => collectCallArgSpans(text, callee)); const violations: string[] = []; const pathRe = /\/api2\/json\/[A-Za-z0-9/{}_.-]*/g; let m: RegExpExecArray | null; // biome-ignore lint/suspicious/noAssignInExpressions: s.o. while ((m = pathRe.exec(text))) { const idx = m.index; - const insideProxmoxGetCall = proxmoxGetSpans.some((s) => idx >= s.start && idx <= s.end); - if (!insideProxmoxGetCall) { + const insideAllowedCall = allowedSpans.some((s) => idx >= s.start && idx <= s.end); + if (!insideAllowedCall) { violations.push(`${fileName}@${idx}: ${m[0]}`); } } diff --git a/apps/api/src/proxmox/proxmox.service.spec.ts b/apps/api/src/proxmox/proxmox.service.spec.ts index d7f364d..74fad44 100644 --- a/apps/api/src/proxmox/proxmox.service.spec.ts +++ b/apps/api/src/proxmox/proxmox.service.spec.ts @@ -264,3 +264,86 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () = expect(forTenant).toHaveBeenCalled(); }); }); + +describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('fragt PBS ab: Belegung plus je Datenspeicher hoechstens 10 Folgeabfragen', async () => { + const prisma = makeFakePrisma(); + const service = new ProxmoxService(prisma as any, crypto as any); + const created = await service.createServer('tenant-a', { + ...TOKEN_DTO, + name: 'pbs-1', + productType: 'pbs', + baseUrl: 'https://pbs.intern:8007', + }); + + const usageBody = { + data: Array.from({ length: 15 }, (_, i) => ({ store: `store-${i}`, total: 100, used: 10, avail: 90 })), + }; + + let snapshotCalls = 0; + const fetchSpy = vi.fn(async (url: string) => { + if (url.includes('/status/datastore-usage')) { + return new Response(JSON.stringify(usageBody), { status: 200 }); + } + snapshotCalls++; + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + const result = await service.pollServer('tenant-a', (created as any).id); + + expect(result?.reachable).toBe(true); + expect(snapshotCalls).toBe(10); + expect((result?.metrics as { datastores: unknown[] }).datastores).toHaveLength(15); + }); + + it('fragt PMG ab und normalisiert die Tageszahlen', async () => { + const prisma = makeFakePrisma(); + const service = new ProxmoxService(prisma as any, crypto as any); + const created = await service.createServer('tenant-a', { + name: 'pmg-1', + productType: 'pmg', + baseUrl: 'https://pmg.intern:8006', + authMethod: 'password', + username: 'admin@pmg', + password: 'geheim', + }); + + const fetchSpy = vi.fn(async (url: string) => { + if (url.endsWith('/access/ticket')) { + return new Response(JSON.stringify({ data: { ticket: 'PMG:admin@pmg:xyz' } }), { status: 200 }); + } + return new Response( + JSON.stringify({ data: { count_in: 10, count_out: 5, spamcount_in: 1, spamcount_out: 0, viruscount_in: 0, viruscount_out: 0 } }), + { status: 200 }, + ); + }); + vi.stubGlobal('fetch', fetchSpy); + + const result = await service.pollServer('tenant-a', (created as any).id); + + expect(result?.reachable).toBe(true); + expect(result?.metrics).toMatchObject({ productType: 'pmg', countIn: 10, countOut: 5, spamCount: 1 }); + }); + + it('401/403/404/500 bleiben fuer PBS/PMG dieselben Fehlerschluessel wie fuer PVE', async () => { + const prisma = makeFakePrisma(); + const service = new ProxmoxService(prisma as any, crypto as any); + const created = await service.createServer('tenant-a', { + ...TOKEN_DTO, + name: 'pbs-403', + productType: 'pbs', + baseUrl: 'https://pbs.intern:8007', + }); + + vi.stubGlobal('fetch', vi.fn(async () => new Response('forbidden', { status: 403 }))); + const result = await service.pollServer('tenant-a', (created as any).id); + expect(result?.reachable).toBe(false); + expect(result?.errorKind).toBe('rechte'); + }); +}); diff --git a/apps/api/src/proxmox/proxmox.service.ts b/apps/api/src/proxmox/proxmox.service.ts index fbd749c..b9dd30e 100644 --- a/apps/api/src/proxmox/proxmox.service.ts +++ b/apps/api/src/proxmox/proxmox.service.ts @@ -4,14 +4,10 @@ import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth'; -import { proxmoxGet } from './proxmox-client.service'; +import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service'; +import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize'; import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto'; -import type { - ProxmoxErrorKind, - ProxmoxPollResult, - ProxmoxPveMetrics, - ProxmoxPveNodeMetric, -} from './proxmox.types'; +import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types'; /** * Erkennungsform fuer "schon verschluesselt" — woertlich aus @@ -48,57 +44,14 @@ const SAFE_SERVER_SELECT = { status: true, } as const; -function readNumberOrNull(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return null; -} - /** - * Grundauswertung von `/api2/json/cluster/resources` (Aufgabe 1: nur PVE, - * nur Knotenzahl/Gaestezahl/je-Knoten-Auslastung — Aufgabe 3 baut PBS/PMG - * daneben und zieht diese Funktion nach `proxmox-normalize.ts` um). Jeder - * Einzelwert nachsichtig gelesen: fehlt er, ist er `null` — nie `0`, nie - * `NaN`, nie ein Wurf (der Nutzer prueft dieses Modul an seinen echten - * Servern; ein Wurf zeigt ihm eine leere Seite statt eines Hinweises). + * Deckel der Folgeabfragen je PBS-Durchlauf (Aufgabe 3, ``): ein + * PBS-Server mit vielen Datenspeichern soll den Planer nicht mit + * unbegrenzt vielen Anfragen belasten — hoechstens diese Zahl an + * `/snapshots`-Abfragen je Poll-Durchlauf, unabhaengig davon, wie viele + * Datenspeicher der Server tatsaechlich hat. */ -function normalizePveResources(body: unknown): ProxmoxPveMetrics { - const list = - body && typeof body === 'object' && Array.isArray((body as { data?: unknown }).data) - ? ((body as { data: unknown[] }).data as unknown[]) - : []; - - const isRecord = (v: unknown): v is Record => - v !== null && typeof v === 'object'; - - const nodeEntries = list.filter((e) => isRecord(e) && e.type === 'node'); - const guestEntries = list.filter( - (e) => isRecord(e) && (e.type === 'qemu' || e.type === 'lxc'), - ); - const running = guestEntries.filter((g) => isRecord(g) && g.status === 'running').length; - - const nodes: ProxmoxPveNodeMetric[] = nodeEntries.map((entry) => { - const n = entry as Record; - return { - node: typeof n.node === 'string' ? n.node : 'unbekannt', - cpu: readNumberOrNull(n.cpu), - maxcpu: readNumberOrNull(n.maxcpu), - mem: readNumberOrNull(n.mem), - maxmem: readNumberOrNull(n.maxmem), - }; - }); - - return { - productType: 'pve', - nodeCount: nodeEntries.length, - guestsRunning: running, - guestsStopped: guestEntries.length - running, - nodes, - }; -} +const PBS_SNAPSHOT_QUERY_CAP = 10; function truncateRaw(body: unknown): string { let text: string; @@ -234,27 +187,51 @@ export class ProxmoxService { } /** - * EIN Abfragedurchlauf gegen genau diesen Server. In dieser Aufgabe `pve` - * mit Token ODER Benutzer/Passwort (Aufgabe 3 ergaenzt `pbs`/`pmg`). - * - * Ticket-Erneuerung (Aufgabe 2, ``): laeuft der Zugang ueber - * `password` und antwortet Proxmox mit 401 (`errorKind: 'zugang'`), wird - * GENAU EINMAL neu angemeldet und die Abfrage wiederholt — bei einer + * Fragt EINEN Proxmox-Pfad ab, gebunden an die Kopfzeilen dieses + * Poll-Durchlaufs. Ticket-Erneuerung (Aufgabe 2, ``): laeuft + * der Zugang ueber `password` und antwortet Proxmox mit 401 + * (`errorKind: 'zugang'`), wird GENAU EINMAL je Durchlauf neu angemeldet + * (nicht je Aufruf — ein PBS-Durchlauf mit mehreren Folgeabfragen soll + * nicht mehrfach neu einloggen) und die Abfrage wiederholt; die neuen + * Kopfzeilen gelten danach fuer den Rest des Durchlaufs. Bei einer * Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle * zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`. */ - private async pollOne(server: ProxmoxServer): Promise { - if (server.productType !== 'pve') { - // PBS/PMG folgen in Aufgabe 3. - return { - reachable: false, - errorKind: 'unbekannt', - errorDetail: 'Dieser Produkttyp wird in dieser Aufgabe noch nicht abgefragt.', - metrics: null, - rawSample: null, - }; + private async getWithRetry( + server: ProxmoxServer, + session: { headers: Record; retried: boolean }, + path: string, + ): Promise { + const target = { + baseUrl: server.baseUrl, + tlsRejectUnauthorized: server.tlsRejectUnauthorized, + headers: session.headers, + }; + let result = await proxmoxGet(target, path); + + if ( + !result.ok && + result.errorKind === 'zugang' && + server.authMethod === 'password' && + !session.retried + ) { + session.retried = true; + const retryHeaders = await this.buildAuthHeaders(server); + if (retryHeaders.ok) { + session.headers = retryHeaders.headers; + result = await proxmoxGet({ ...target, headers: retryHeaders.headers }, path); + } } + return result; + } + + /** + * EIN Abfragedurchlauf gegen genau diesen Server — `pve` (Aufgabe 1), + * `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort + * (Aufgabe 2). + */ + private async pollOne(server: ProxmoxServer): Promise { const authHeaders = await this.buildAuthHeaders(server); if (!authHeaders.ok) { return { @@ -266,29 +243,86 @@ export class ProxmoxService { }; } - let result = await proxmoxGet( - { - baseUrl: server.baseUrl, - tlsRejectUnauthorized: server.tlsRejectUnauthorized, - headers: authHeaders.headers, - }, - '/api2/json/cluster/resources', - ); + const session = { headers: authHeaders.headers, retried: false }; + const productType = server.productType as ProxmoxProductType; - if (!result.ok && result.errorKind === 'zugang' && server.authMethod === 'password') { - const retryHeaders = await this.buildAuthHeaders(server); - if (retryHeaders.ok) { - result = await proxmoxGet( - { - baseUrl: server.baseUrl, - tlsRejectUnauthorized: server.tlsRejectUnauthorized, - headers: retryHeaders.headers, - }, - '/api2/json/cluster/resources', - ); + if (productType === 'pve') { + const result = await this.getWithRetry(server, session, '/api2/json/cluster/resources'); + if (!result.ok) { + return { + reachable: false, + errorKind: result.errorKind, + errorDetail: result.errorDetail, + metrics: null, + rawSample: result.body === null ? null : truncateRaw(result.body), + }; } + const normalized = normalizePve(result.body); + if (normalized.errorKind) { + return { + reachable: false, + errorKind: normalized.errorKind, + errorDetail: 'Die Antwort hatte nicht die erwartete Form.', + metrics: null, + rawSample: truncateRaw(result.body), + }; + } + return { + reachable: true, + errorKind: null, + errorDetail: null, + metrics: normalized.metrics, + rawSample: truncateRaw(result.body), + }; } + if (productType === 'pbs') { + const usageResult = await this.getWithRetry(server, session, '/api2/json/status/datastore-usage'); + if (!usageResult.ok) { + return { + reachable: false, + errorKind: usageResult.errorKind, + errorDetail: usageResult.errorDetail, + metrics: null, + rawSample: usageResult.body === null ? null : truncateRaw(usageResult.body), + }; + } + + const storeNames = listPbsDatastoreNames(usageResult.body).slice(0, PBS_SNAPSHOT_QUERY_CAP); + const snapshotsByStore: Record = {}; + for (const storeName of storeNames) { + const snapResult = await this.getWithRetry( + server, + session, + `/api2/json/admin/datastore/${encodeURIComponent(storeName)}/snapshots`, + ); + if (snapResult.ok) { + snapshotsByStore[storeName] = snapResult.body; + } + } + + const normalized = normalizePbs(usageResult.body, snapshotsByStore); + const rawSample = truncateRaw({ usage: usageResult.body, snapshots: snapshotsByStore }); + if (normalized.errorKind) { + return { + reachable: false, + errorKind: normalized.errorKind, + errorDetail: 'Die Antwort hatte nicht die erwartete Form.', + metrics: null, + rawSample, + }; + } + return { + reachable: true, + errorKind: null, + errorDetail: null, + metrics: normalized.metrics, + rawSample, + }; + } + + // pmg + const result = await this.getWithRetry(server, session, '/api2/json/statistics/mail'); if (!result.ok) { return { reachable: false, @@ -298,12 +332,21 @@ export class ProxmoxService { rawSample: result.body === null ? null : truncateRaw(result.body), }; } - + const normalized = normalizePmg(result.body); + if (normalized.errorKind) { + return { + reachable: false, + errorKind: normalized.errorKind, + errorDetail: 'Die Antwort hatte nicht die erwartete Form.', + metrics: null, + rawSample: truncateRaw(result.body), + }; + } return { reachable: true, errorKind: null, errorDetail: null, - metrics: normalizePveResources(result.body), + metrics: normalized.metrics, rawSample: truncateRaw(result.body), }; } diff --git a/apps/api/src/proxmox/proxmox.types.ts b/apps/api/src/proxmox/proxmox.types.ts index d233a24..bc412cb 100644 --- a/apps/api/src/proxmox/proxmox.types.ts +++ b/apps/api/src/proxmox/proxmox.types.ts @@ -52,12 +52,20 @@ export interface ProxmoxPveNodeMetric { maxmem: number | null; } +export interface ProxmoxPveStorageMetric { + storage: string; + node: string; + disk: number | null; + maxdisk: number | null; +} + export interface ProxmoxPveMetrics { productType: 'pve'; nodeCount: number; guestsRunning: number; guestsStopped: number; nodes: ProxmoxPveNodeMetric[]; + storages: ProxmoxPveStorageMetric[]; } export interface ProxmoxPbsDatastoreMetric {