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:
2026-09-23 10:28:25 +02:00
parent 4f8a368c9e
commit 998aba9ef3
6 changed files with 750 additions and 97 deletions
+136 -93
View File
@@ -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,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<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,
@@ -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),
};
}