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:
2026-10-05 08:30:20 +02:00
parent 781bc9f9ee
commit 873d15d25e
21 changed files with 455 additions and 21 deletions
@@ -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();
});
});
+41
View File
@@ -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
// ---------------------------------------------------------------------------
+54 -8
View File
@@ -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 });
}),
);
+51 -3
View File
@@ -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),
};
}
+20
View File
@@ -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 {