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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user