feat(quick-261005-bt1): Proxmox warnt bei gestopptem Gast mit aktivem Autostart
Fuer jeden nicht laufenden qemu/lxc (ohne Vorlage) liest der PVE-Durchlauf
onboot aus /nodes/{node}/{type}/{vmid}/config (nur GET, Deckel 40).
Befund in den Metriken (autostartStopped, autostartUnchecked); Karte wird
Warnung mit einer Zeile je Gast, Kachel zeigt 'gestoppt trotz Autostart'.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
listPbsDatastoreNames,
|
||||
listStoppedPveGuests,
|
||||
normalizePbs,
|
||||
normalizePmg,
|
||||
normalizePve,
|
||||
readBool,
|
||||
readList,
|
||||
readNumber,
|
||||
readPveOnboot,
|
||||
readText,
|
||||
} from './proxmox-normalize';
|
||||
|
||||
@@ -285,3 +287,32 @@ describe('normalizePmg (Aufgabe 3, <behavior>)', () => {
|
||||
expect(metrics.countOut).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick-261005: Autostart-Hilfen', () => {
|
||||
it('listStoppedPveGuests nimmt nur nicht laufende qemu/lxc ohne Vorlage, sortiert nach vmid', () => {
|
||||
const list = listStoppedPveGuests({
|
||||
data: [
|
||||
{ type: 'lxc', node: 'b', vmid: '205', status: 'stopped' },
|
||||
{ type: 'qemu', node: 'a', vmid: 101, status: 'paused', name: 'p' },
|
||||
{ type: 'qemu', node: 'a', vmid: 100, status: 'running' },
|
||||
{ type: 'qemu', node: 'a', vmid: 9000, status: 'stopped', template: 1 },
|
||||
{ type: 'storage', node: 'a', status: 'available' },
|
||||
{ type: 'qemu', vmid: 300, status: 'stopped' },
|
||||
],
|
||||
});
|
||||
expect(list).toEqual([
|
||||
{ vmid: 101, name: 'p', type: 'qemu', node: 'a', status: 'paused' },
|
||||
{ vmid: 205, name: null, type: 'lxc', node: 'b', status: 'stopped' },
|
||||
]);
|
||||
expect(listStoppedPveGuests('kaputt')).toEqual([]);
|
||||
});
|
||||
|
||||
it('readPveOnboot: fehlendes Feld heisst aus, kein data-Objekt heisst unbekannt', () => {
|
||||
expect(readPveOnboot({ data: { onboot: 1 } })).toBe(true);
|
||||
expect(readPveOnboot({ data: { onboot: '1' } })).toBe(true);
|
||||
expect(readPveOnboot({ data: { onboot: 0 } })).toBe(false);
|
||||
expect(readPveOnboot({ data: {} })).toBe(false);
|
||||
expect(readPveOnboot({ data: null })).toBeNull();
|
||||
expect(readPveOnboot(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ProxmoxPbsDatastoreMetric,
|
||||
ProxmoxPbsMetrics,
|
||||
ProxmoxPmgMetrics,
|
||||
ProxmoxPveAutostartGuest,
|
||||
ProxmoxPveMetrics,
|
||||
ProxmoxPveNodeMetric,
|
||||
} from './proxmox.types';
|
||||
@@ -121,6 +122,46 @@ export function normalizePve(body: unknown): NormalizeResult<ProxmoxPveMetrics>
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gaeste aus `/cluster/resources`, die NICHT laufen und keine Vorlage sind
|
||||
* (quick-261005) — Kandidaten fuer die Autostart-Pruefung. Fuer jeden
|
||||
* fragt der Dienst danach `/nodes/{node}/{type}/{vmid}/config` ab, denn
|
||||
* `cluster/resources` liefert `onboot` nicht mit. Eintraege ohne gueltige
|
||||
* vmid oder Node werden still uebergangen.
|
||||
*/
|
||||
export function listStoppedPveGuests(body: unknown): ProxmoxPveAutostartGuest[] {
|
||||
if (!isRecord(body)) return [];
|
||||
const result: ProxmoxPveAutostartGuest[] = [];
|
||||
for (const entry of readList(body.data)) {
|
||||
if (!isRecord(entry)) continue;
|
||||
if (entry.type !== 'qemu' && entry.type !== 'lxc') continue;
|
||||
if (entry.status === 'running') continue;
|
||||
if (readBool(entry.template) === true) continue;
|
||||
const vmid = readNumber(entry.vmid);
|
||||
const node = readText(entry.node);
|
||||
if (vmid === null || node === null) continue;
|
||||
result.push({
|
||||
vmid,
|
||||
name: readText(entry.name),
|
||||
type: entry.type,
|
||||
node,
|
||||
status: readText(entry.status) ?? 'unknown',
|
||||
});
|
||||
}
|
||||
return result.sort((a, b) => a.vmid - b.vmid);
|
||||
}
|
||||
|
||||
/**
|
||||
* `onboot` aus der Antwort von `/nodes/{node}/{type}/{vmid}/config`.
|
||||
* Proxmox laesst das Feld weg, wenn es nie gesetzt wurde — das heisst
|
||||
* „aus“ (Vorgabe 0). `null` nur bei einer Antwort ohne `data`-Objekt.
|
||||
*/
|
||||
export function readPveOnboot(body: unknown): boolean | null {
|
||||
if (!isRecord(body) || !isRecord(body.data)) return null;
|
||||
if (!('onboot' in body.data)) return false;
|
||||
return readBool(body.data.onboot) ?? false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PBS — /api2/json/status/datastore-usage + je Datenspeicher .../snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -188,8 +188,9 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () =
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const urls: string[] = [];
|
||||
const fetchSpy = vi.fn(async (url: string) => {
|
||||
expect(url).toBe('https://pve.intern:8006/api2/json/cluster/resources');
|
||||
urls.push(url);
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
@@ -208,6 +209,51 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () =
|
||||
expect(status.reachable).toBe(true);
|
||||
expect(status.metrics).toMatchObject({ nodeCount: 1 });
|
||||
expect(status.rawSample).toContain('"node":"pve1"');
|
||||
expect(urls[0]).toBe('https://pve.intern:8006/api2/json/cluster/resources');
|
||||
});
|
||||
|
||||
it('quick-261005: meldet gestoppte Gaeste mit aktivem Autostart, Vorlagen nie, unlesbare als nicht geprueft', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
const resources = {
|
||||
data: [
|
||||
{ type: 'node', node: 'pve1', cpu: 0.1, maxcpu: 8, mem: 1, maxmem: 2 },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 100, status: 'running', name: 'laeuft' },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 101, status: 'stopped', name: 'dc01' },
|
||||
{ type: 'lxc', node: 'pve2', vmid: 201, status: 'stopped', name: 'ohne-autostart' },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 102, status: 'stopped', name: 'kein-recht' },
|
||||
{ type: 'qemu', node: 'pve1', vmid: 9000, status: 'stopped', template: 1 },
|
||||
],
|
||||
};
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
urls.push(url);
|
||||
if (url.endsWith('/cluster/resources')) {
|
||||
return new Response(JSON.stringify(resources), { status: 200 });
|
||||
}
|
||||
if (url.endsWith('/nodes/pve1/qemu/101/config')) {
|
||||
return new Response(JSON.stringify({ data: { onboot: 1, name: 'dc01' } }), { status: 200 });
|
||||
}
|
||||
if (url.endsWith('/nodes/pve2/lxc/201/config')) {
|
||||
return new Response(JSON.stringify({ data: { hostname: 'x' } }), { status: 200 });
|
||||
}
|
||||
return new Response('{"data":null}', { status: 403 });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.pollServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(result?.reachable).toBe(true);
|
||||
expect(result?.metrics).toMatchObject({
|
||||
autostartStopped: [{ vmid: 101, name: 'dc01', type: 'qemu', node: 'pve1', status: 'stopped' }],
|
||||
autostartUnchecked: 1,
|
||||
});
|
||||
expect(urls.some((u) => u.includes('/100/config'))).toBe(false);
|
||||
expect(urls.some((u) => u.includes('/9000/config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('sendet die Token-Kopfzeile im PVE-Schema (Gleichheitszeichen vor dem Geheimnis)', async () => {
|
||||
@@ -241,8 +287,8 @@ describe('ProxmoxService — Aufgabe 1 (PVE per Token, durchgehender Weg)', () =
|
||||
});
|
||||
|
||||
const dispatchers: unknown[] = [];
|
||||
const fetchSpy = vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
|
||||
dispatchers.push(options.dispatcher);
|
||||
const fetchSpy = vi.fn(async (url: string, options: RequestInit & { dispatcher?: unknown }) => {
|
||||
if (url.endsWith('/cluster/resources')) dispatchers.push(options.dispatcher);
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
@@ -479,7 +525,7 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
capturedUrl = url;
|
||||
capturedUrl ??= url;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
@@ -519,8 +565,8 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
|
||||
let fetchCalls = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
fetchCalls++;
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/cluster/resources')) fetchCalls++;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
@@ -540,8 +586,8 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
|
||||
let fetchCalls = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
fetchCalls++;
|
||||
vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/cluster/resources')) fetchCalls++;
|
||||
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,13 +5,25 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
|
||||
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
|
||||
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
|
||||
import {
|
||||
listPbsDatastoreNames,
|
||||
listStoppedPveGuests,
|
||||
normalizePbs,
|
||||
normalizePmg,
|
||||
normalizePve,
|
||||
readPveOnboot,
|
||||
} from './proxmox-normalize';
|
||||
import type {
|
||||
CreateProxmoxServerDto,
|
||||
TestProxmoxServerDto,
|
||||
UpdateProxmoxServerDto,
|
||||
} from './dto/proxmox-server.dto';
|
||||
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
|
||||
import type {
|
||||
ProxmoxErrorKind,
|
||||
ProxmoxPollResult,
|
||||
ProxmoxProductType,
|
||||
ProxmoxPveAutostartGuest,
|
||||
} from './proxmox.types';
|
||||
|
||||
/**
|
||||
* Nur die Felder, die eine Abfrage tatsaechlich braucht (Nachbesserung
|
||||
@@ -77,6 +89,13 @@ const SAFE_SERVER_SELECT = {
|
||||
*/
|
||||
const PBS_SNAPSHOT_QUERY_CAP = 10;
|
||||
|
||||
/**
|
||||
* Deckel der Autostart-Pruefung je PVE-Durchlauf (quick-261005): hoechstens
|
||||
* so viele `/config`-Abfragen fuer gestoppte Gaeste. Gaeste darueber hinaus
|
||||
* zaehlen als „nicht geprueft“ (`autostartUnchecked`), nie als „in Ordnung“.
|
||||
*/
|
||||
const PVE_AUTOSTART_QUERY_CAP = 40;
|
||||
|
||||
/**
|
||||
* Zehn-Sekunden-Sperre fuer `pollServer` (Aufgabe 4, T-DHH-06): ein Klick
|
||||
* auf "Jetzt aktualisieren" darf nicht zu ungebremsten Anfragen gegen die
|
||||
@@ -325,6 +344,34 @@ export class ProxmoxService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autostart-Pruefung (quick-261005): fuer jeden gestoppten Gast (keine
|
||||
* Vorlage) die Einstellung `onboot` aus seiner Konfiguration lesen.
|
||||
* `cluster/resources` liefert sie nicht mit. Ein fehlgeschlagener Abruf
|
||||
* (z. B. 403, weil dem Token VM.Audit fehlt) macht den Server NICHT
|
||||
* unerreichbar, sondern zaehlt als „nicht geprueft“.
|
||||
*/
|
||||
private async checkPveAutostart(
|
||||
server: ProxmoxCredentialSource,
|
||||
session: { headers: Record<string, string>; retried: boolean },
|
||||
resourcesBody: unknown,
|
||||
): Promise<{ autostartStopped: ProxmoxPveAutostartGuest[]; autostartUnchecked: number }> {
|
||||
const stopped = listStoppedPveGuests(resourcesBody);
|
||||
const autostartStopped: ProxmoxPveAutostartGuest[] = [];
|
||||
let autostartUnchecked = Math.max(0, stopped.length - PVE_AUTOSTART_QUERY_CAP);
|
||||
for (const guest of stopped.slice(0, PVE_AUTOSTART_QUERY_CAP)) {
|
||||
const configResult = await this.getWithRetry(
|
||||
server,
|
||||
session,
|
||||
`/api2/json/nodes/${encodeURIComponent(guest.node)}/${guest.type}/${guest.vmid}/config`,
|
||||
);
|
||||
const onboot = configResult.ok ? readPveOnboot(configResult.body) : null;
|
||||
if (onboot === null) autostartUnchecked++;
|
||||
else if (onboot) autostartStopped.push(guest);
|
||||
}
|
||||
return { autostartStopped, autostartUnchecked };
|
||||
}
|
||||
|
||||
/**
|
||||
* EIN Abfragedurchlauf gegen genau diesen Server — `pve` (Aufgabe 1),
|
||||
* `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort
|
||||
@@ -366,11 +413,12 @@ export class ProxmoxService {
|
||||
rawSample: truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
const autostart = await this.checkPveAutostart(server, session, result.body);
|
||||
return {
|
||||
reachable: true,
|
||||
errorKind: null,
|
||||
errorDetail: null,
|
||||
metrics: normalized.metrics,
|
||||
metrics: { ...normalized.metrics, ...autostart },
|
||||
rawSample: truncateRaw(result.body),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,15 @@ export interface ProxmoxPveStorageMetric {
|
||||
maxdisk: number | null;
|
||||
}
|
||||
|
||||
/** Eine VM oder ein LXC, der nicht laeuft, obwohl Autostart (`onboot`) aktiv ist (quick-261005). */
|
||||
export interface ProxmoxPveAutostartGuest {
|
||||
vmid: number;
|
||||
name: string | null;
|
||||
type: 'qemu' | 'lxc';
|
||||
node: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProxmoxPveMetrics {
|
||||
productType: 'pve';
|
||||
nodeCount: number;
|
||||
@@ -66,6 +75,17 @@ export interface ProxmoxPveMetrics {
|
||||
guestsStopped: number;
|
||||
nodes: ProxmoxPveNodeMetric[];
|
||||
storages: ProxmoxPveStorageMetric[];
|
||||
/**
|
||||
* Gestoppte Gaeste mit aktivem Autostart (quick-261005). Fehlt in
|
||||
* Zwischenlagerstaenden von vor dieser Erweiterung.
|
||||
*/
|
||||
autostartStopped?: ProxmoxPveAutostartGuest[];
|
||||
/**
|
||||
* Gestoppte Gaeste, deren Autostart-Einstellung NICHT gelesen werden
|
||||
* konnte (fehlendes Recht VM.Audit, Deckel erreicht, Abruf fehlgeschlagen)
|
||||
* — ehrlich „unbekannt“ statt still „kein Problem“.
|
||||
*/
|
||||
autostartUnchecked?: number;
|
||||
}
|
||||
|
||||
export interface ProxmoxPbsDatastoreMetric {
|
||||
|
||||
Reference in New Issue
Block a user