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 <noreply@anthropic.com>
This commit is contained in:
@@ -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, <behavior>)', () => {
|
||||
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, <behavior>)', () => {
|
||||
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('<html>Anmeldeseite</html>')).not.toThrow();
|
||||
const { metrics, errorKind } = normalizePve('<html>Anmeldeseite</html>');
|
||||
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, <behavior>)', () => {
|
||||
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('<html></html>', {})).not.toThrow();
|
||||
const { metrics, errorKind } = normalizePbs('<html></html>', {});
|
||||
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, <behavior>)', () => {
|
||||
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('<html></html>')).not.toThrow();
|
||||
const { metrics, errorKind } = normalizePmg('<html></html>');
|
||||
expect(errorKind).toBe('antwortform');
|
||||
expect(metrics).toEqual({
|
||||
productType: 'pmg',
|
||||
countIn: null,
|
||||
countOut: null,
|
||||
spamCount: null,
|
||||
virusCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>, keys: readonly string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (key in record && record[key] !== undefined) return record[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface NormalizeResult<TMetrics> {
|
||||
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<ProxmoxPveMetrics> {
|
||||
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<string, unknown> => isRecord(e);
|
||||
|
||||
const nodeEntries = list.filter((e) => isEntry(e) && e.type === 'node') as Record<string, unknown>[];
|
||||
const guestEntries = list.filter(
|
||||
(e) => isEntry(e) && (e.type === 'qemu' || e.type === 'lxc'),
|
||||
) as Record<string, unknown>[];
|
||||
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<string, unknown>,
|
||||
): NormalizeResult<ProxmoxPbsMetrics> {
|
||||
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<ProxmoxPmgMetrics> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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]}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, `<behavior>`): 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<string, unknown> =>
|
||||
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<string, unknown>;
|
||||
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, `<behavior>`): 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, `<behavior>`): 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<ProxmoxPollResult> {
|
||||
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<string, string>; retried: boolean },
|
||||
path: string,
|
||||
): Promise<ProxmoxGetResult> {
|
||||
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<ProxmoxPollResult> {
|
||||
const authHeaders = await this.buildAuthHeaders(server);
|
||||
if (!authHeaders.ok) {
|
||||
return {
|
||||
@@ -266,29 +243,11 @@ export class ProxmoxService {
|
||||
};
|
||||
}
|
||||
|
||||
let result = await proxmoxGet(
|
||||
{
|
||||
baseUrl: server.baseUrl,
|
||||
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
|
||||
headers: authHeaders.headers,
|
||||
},
|
||||
'/api2/json/cluster/resources',
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
const session = { headers: authHeaders.headers, retried: false };
|
||||
const productType = server.productType as ProxmoxProductType;
|
||||
|
||||
if (productType === 'pve') {
|
||||
const result = await this.getWithRetry(server, session, '/api2/json/cluster/resources');
|
||||
if (!result.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
@@ -298,12 +257,96 @@ export class ProxmoxService {
|
||||
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: normalizePveResources(result.body),
|
||||
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<string, unknown> = {};
|
||||
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,
|
||||
errorKind: result.errorKind,
|
||||
errorDetail: result.errorDetail,
|
||||
metrics: null,
|
||||
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: normalized.metrics,
|
||||
rawSample: truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user