feat(260923-dhh): Proxmox-Modul Aufgabe 4 - Hintergrundabfrage je Mandant, Verbindungstest

- proxmox-scheduler.service.ts: ein Cron-Auftrag je aktivem Mandant
  (proxmox-poll:<tenantId>), onApplicationBootstrap (nicht onModuleInit,
  Tender-Muster), Abfrageintervall = kleinstes pollIntervalMin der
  aktiven Server, ein fehlgeschlagener Server bricht die Tick-Schleife
  nicht ab, refreshTenant() zieht nach jedem Speichern sofort nach
- proxmox.service.ts: loadActiveServersForScheduler() als einziger
  forSystem()-Aufruf des Moduls (Erlaubnisliste in
  rls-access-inventory.spec.ts), testConnection() schreibt nicht ins
  Zwischenlager, pollServer() bekommt eine Zehn-Sekunden-Sperre (T-DHH-06)
- proxmox.controller.ts: POST servers/:id/test, create() zieht den
  Planer nach dem Anlegen sofort nach
- Zugriffsklassifikation: proxmoxServer wechselt auf system-gebunden
  (Startpfad des Planers), proxmoxServerStatus bleibt gebunden

Tore: api 1306/1306 (>=1240), type-check 4/4, rls-access-inventory
und rls-coverage gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:34:11 +02:00
parent 998aba9ef3
commit fccaf8db0f
8 changed files with 621 additions and 15 deletions
@@ -165,11 +165,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
* Summe neu: 5 Dateien, 6 Aufrufe.
*
* quick-260923-dhh (Aufgabe 4): eine sechste Datei kommt hinzu —
* `proxmox.service.ts`/`loadActiveServersForScheduler()`, derselbe
* Startpfad-Fall wie `dkv.service.ts`: der Planer liest beim Start ALLE
* aktiven `ProxmoxServer`-Zeilen aller Mandanten (`system_read_policy` auf
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
*/
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
['apps/api/src/dkv/dkv.service.ts', 1],
['apps/api/src/ldap/ldap-config.service.ts', 2],
['apps/api/src/proxmox/proxmox.service.ts', 1],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
['apps/api/src/tenders/tender-matching.service.ts', 1],
]);
@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
/**
* ProxmoxSchedulerService.spec (Aufgabe 4) — Vorbild
* `dkv-scheduler.service.spec.ts`: echte Fake-Registry (Map-basiert,
* `getCronJob` wirft bei Unbekannt wie `@nestjs/schedule`), ECHTES `cron`
* (Peer von `@nestjs/schedule`) — `cronTime.source` und `fireOnTick()`
* sind die beobachtbaren Eigenschaften eines Auftrags.
*/
function makeFakeRegistry() {
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe
const jobs = new Map<string, any>();
return {
__jobs: jobs,
addCronJob: vi.fn((name: string, job: any) => {
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
jobs.set(name, job);
}),
getCronJob: vi.fn((name: string) => {
const job = jobs.get(name);
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
return job;
}),
deleteCronJob: vi.fn((name: string) => {
const job = jobs.get(name);
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
jobs.delete(name);
}),
getCronJobs: vi.fn(() => jobs),
};
}
interface FakeServerRow {
id: string;
tenantId: string;
pollIntervalMin: number;
isActive: boolean;
}
function makeFakeProxmoxService(
servers: FakeServerRow[] | Error,
options: { pollShouldThrowFor?: string[] } = {},
) {
const polledServerIds: string[] = [];
return {
loadActiveServersForScheduler: vi.fn(async () => {
if (servers instanceof Error) throw servers;
return servers.filter((s) => s.isActive).map((s) => ({
id: s.id,
tenantId: s.tenantId,
pollIntervalMin: s.pollIntervalMin,
}));
}),
loadActiveServersForTenantScheduling: vi.fn(async (tenantId: string) => {
if (servers instanceof Error) return [];
return servers
.filter((s) => s.isActive && s.tenantId === tenantId)
.map((s) => ({ pollIntervalMin: s.pollIntervalMin }));
}),
listActiveServerIdsForTenant: vi.fn(async (tenantId: string) => {
if (servers instanceof Error) return [];
return servers.filter((s) => s.isActive && s.tenantId === tenantId).map((s) => s.id);
}),
pollServer: vi.fn(async (_tenantId: string, serverId: string) => {
polledServerIds.push(serverId);
if (options.pollShouldThrowFor?.includes(serverId)) {
throw new Error(`poll boom for ${serverId}`);
}
return { reachable: true, errorKind: null, errorDetail: null, metrics: null, rawSample: null };
}),
__polledServerIds: polledServerIds,
};
}
function makeScheduler(
servers: FakeServerRow[] | Error,
options: { pollShouldThrowFor?: string[] } = {},
) {
const registry = makeFakeRegistry();
const proxmoxService = makeFakeProxmoxService(servers, options);
const scheduler = new ProxmoxSchedulerService(registry as any, proxmoxService as any);
const logSpy = vi.spyOn((scheduler as any).logger, 'log').mockImplementation(() => undefined);
const errorSpy = vi.spyOn((scheduler as any).logger, 'error').mockImplementation(() => undefined);
return { registry, proxmoxService, scheduler, logSpy, errorSpy };
}
describe('ProxmoxSchedulerService — ein Auftrag je Mandant (Aufgabe 4, <behavior>)', () => {
const registries: ReturnType<typeof makeFakeRegistry>[] = [];
afterEach(() => {
for (const registry of registries) {
for (const job of registry.__jobs.values()) job.stop();
registry.__jobs.clear();
}
registries.length = 0;
vi.restoreAllMocks();
});
it('Beim Start registriert der Planer je Mandant mit mindestens einem aktiven Server genau einen Auftrag unter proxmox-poll:<tenantId>', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect([...registry.__jobs.keys()]).toEqual(['proxmox-poll:t1']);
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/15 * * * *');
});
it('Das Abfrageintervall eines Mandanten ist das KLEINSTE pollIntervalMin seiner aktiven Server', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 30, isActive: true },
{ id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/5 * * * *');
});
it('Ein zweiter Mandant verdraengt den Auftrag des ersten nicht — beide Auftraege bestehen nebeneinander', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
{ id: 's2', tenantId: 't2', pollIntervalMin: 10, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(new Set(registry.__jobs.keys())).toEqual(new Set(['proxmox-poll:t1', 'proxmox-poll:t2']));
});
it('Keine aktiven Server bedeutet: kein Auftrag, ein Protokolleintrag, kein Fehler, nichts geloescht', async () => {
const { registry, scheduler, logSpy, errorSpy } = makeScheduler([]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.size).toBe(0);
expect(logSpy).toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
it('Ein Fehler beim Start wird gefangen und protokolliert, nie weitergeworfen', async () => {
const { scheduler, errorSpy } = makeScheduler(new Error('DB weg'));
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
});
it('Der Planer haengt an onApplicationBootstrap, nicht an onModuleInit', () => {
const registry = makeFakeRegistry();
const scheduler = new ProxmoxSchedulerService(registry as any, {} as any);
expect(typeof (scheduler as unknown as { onApplicationBootstrap?: unknown }).onApplicationBootstrap).toBe(
'function',
);
expect((scheduler as unknown as { onModuleInit?: unknown }).onModuleInit).toBeUndefined();
});
it('Der Tick eines Mandanten geht ueber dessen Server und fragt jeden einzeln ab; ein fehlgeschlagener Server bricht die Schleife nicht ab', async () => {
const { registry, scheduler, proxmoxService, errorSpy } = makeScheduler(
[
{ id: 's1', tenantId: 't1', pollIntervalMin: 5, isActive: true },
{ id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true },
],
{ pollShouldThrowFor: ['s1'] },
);
registries.push(registry);
await scheduler.onApplicationBootstrap();
registry.__jobs.get('proxmox-poll:t1').fireOnTick();
await new Promise((resolve) => setImmediate(resolve));
expect((proxmoxService as any).__polledServerIds).toEqual(['s1', 's2']);
expect(errorSpy).toHaveBeenCalled();
});
it('refreshTenant zieht den Auftrag eines Mandanten sofort nach — ohne Neustart', async () => {
const { registry, scheduler } = makeScheduler([]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.size).toBe(0);
(scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => [
{ pollIntervalMin: 20 },
]);
await scheduler.refreshTenant('t1');
expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/20 * * * *');
});
it('refreshTenant entfernt den Auftrag, wenn keine aktiven Server mehr uebrig sind', async () => {
const { registry, scheduler } = makeScheduler([
{ id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true },
]);
registries.push(registry);
await scheduler.onApplicationBootstrap();
expect(registry.__jobs.has('proxmox-poll:t1')).toBe(true);
(scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => []);
await scheduler.refreshTenant('t1');
expect(registry.__jobs.has('proxmox-poll:t1')).toBe(false);
});
});
@@ -0,0 +1,186 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { ProxmoxService } from './proxmox.service';
/**
* CronJob constructor — resolved at runtime via require() because `cron` is
* a transitive dependency of @nestjs/schedule (not a direct api dep under
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
* it as a peer dep. Reuses the exact DkvSchedulerService resolution
* workaround verbatim.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
require('cron').CronJob as new (cronTime: string, onTick: () => void) => { start(): void };
/**
* ProxmoxSchedulerService — Hintergrundabfrage je Mandant (Aufgabe 4).
* Kombiniert die zwei Bestandsmuster (Recherche, Block 3):
*
* - Das Mandanten-Auffaechern von `DkvSchedulerService`: EIN Cron-Auftrag
* je aktivem Mandanten, Registry-Name `proxmox-poll:<tenantId>` — die
* Vorgaengerform mit EINEM Auftragsfeld war genau der Fehler WINDOWS #21,
* ERSATZLOS vermieden.
* - Die Lebenszyklus-Stufe von `TenderSchedulerService`:
* `implements OnApplicationBootstrap`, NICHT `OnModuleInit` — die
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis
* zum zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
*
* Anders als bei DKV ist ein Mandant NICHT gleich ein Server: der Tick
* eines Mandanten geht ueber dessen Serverzeilen. Das Abfrageintervall
* eines Mandanten ist das KLEINSTE `pollIntervalMin` seiner aktiven Server.
* Ein fehlgeschlagener Server schreibt seinen Fehler ins Zwischenlager
* (das erledigt `ProxmoxService.pollServer` bereits selbst — ein
* geworfener Fehler waere hier ein echter Bug, nicht ein "nicht
* erreichbar") und die Schleife laeuft weiter.
*/
@Injectable()
export class ProxmoxSchedulerService implements OnApplicationBootstrap {
private readonly logger = new Logger(ProxmoxSchedulerService.name);
/** Praefix der Registry-Namen; der volle Name ist `<Praefix>:<tenantId>`. */
private readonly JOB_NAME_PREFIX = 'proxmox-poll';
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly proxmoxService: ProxmoxService,
) {}
private jobNameFor(tenantId: string): string {
return `${this.JOB_NAME_PREFIX}:${tenantId}`;
}
/**
* Beim Start: laedt ALLE aktiven `ProxmoxServer`-Zeilen (Systemkontext,
* `ProxmoxService.loadActiveServersForScheduler`) und registriert je
* aktivem Mandanten genau einen Cron-Auftrag. Eine LEERE Liste bedeutet
* "nichts tun" — kein Auftrag, ein Protokolleintrag, kein Fehler, nichts
* geloescht. Ein Fehler beim Start wird gefangen und protokolliert, nie
* weitergeworfen — die Anwendung startet trotzdem.
*/
async onApplicationBootstrap(): Promise<void> {
try {
const servers = await this.proxmoxService.loadActiveServersForScheduler();
if (!servers || servers.length === 0) {
this.logger.log('Proxmox scheduler: no active server found — cron job not registered');
return;
}
const byTenant = new Map<string, number[]>();
for (const server of servers) {
const intervals = byTenant.get(server.tenantId) ?? [];
intervals.push(server.pollIntervalMin);
byTenant.set(server.tenantId, intervals);
}
for (const [tenantId, intervals] of byTenant) {
this.setInterval(Math.min(...intervals), tenantId);
}
this.logger.log(`Proxmox scheduler initialized: ${byTenant.size} tenant(s)`);
} catch (err) {
this.logger.error(`Proxmox scheduler init failed: ${(err as Error).message}`);
}
}
/**
* Erzeugt (oder ersetzt) den Poll-Auftrag GENAU EINES Mandanten. Ersetzt
* nur den Auftrag unter diesem Registry-Namen — ein zweiter Mandant
* verdraengt den Auftrag des ersten nicht.
*/
setInterval(intervalMin: number, tenantId: string): void {
const jobName = this.jobNameFor(tenantId);
try {
this.schedulerRegistry.getCronJob(jobName).stop();
this.schedulerRegistry.deleteCronJob(jobName);
} catch {
/* Auftrag noch nicht registriert — beim ersten Aufruf erwartet */
}
let cronExpr: string;
if (intervalMin < 60) {
cronExpr = `*/${intervalMin} * * * *`;
} else {
const hours = Math.floor(intervalMin / 60);
cronExpr = `0 */${hours} * * *`;
}
const job = new CronJobClass(cronExpr, () => {
this.tick(tenantId).catch((err) =>
this.logger.error(
`Proxmox poll tick failed for tenant ${tenantId}: ${(err as Error).message}`,
),
);
});
// Cast noetig — dasselbe Muster wie DkvSchedulerService/TenderSchedulerService.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.schedulerRegistry.addCronJob(jobName, job as any);
job.start();
this.logger.log(
`Proxmox cron job registered: every ${intervalMin} minutes for tenant ${tenantId}`,
);
}
/** Entfernt NUR den Poll-Auftrag dieses Mandanten. */
stopJob(tenantId: string): void {
const jobName = this.jobNameFor(tenantId);
try {
this.schedulerRegistry.getCronJob(jobName).stop();
this.schedulerRegistry.deleteCronJob(jobName);
this.logger.log(`Proxmox cron job stopped and removed for tenant ${tenantId}`);
} catch {
/* Nicht registriert — kein Vorgang */
}
}
/**
* Vom Controller nach jedem Anlegen/Speichern eines Servers gerufen, damit
* der Planer ohne Neustart nachzieht (Vorbild `DkvController`). Ohne
* aktive Server dieses Mandanten wird der Auftrag entfernt.
*/
async refreshTenant(tenantId: string): Promise<void> {
const servers = await this.proxmoxService.loadActiveServersForTenantScheduling(tenantId);
if (!servers || servers.length === 0) {
this.stopJob(tenantId);
return;
}
this.setInterval(Math.min(...servers.map((s) => s.pollIntervalMin)), tenantId);
}
/**
* Der Tick EINES Mandanten: geht ueber dessen aktive Server und fragt
* jeden einzeln ab. `pollServer` faengt jeden Proxmox-seitigen Fehler
* bereits selbst ab (Ergebnis statt Wurf) — dieses try/catch schuetzt
* zusaetzlich vor einem echten Programmfehler (z. B. einem
* Datenbankfehler beim Schreiben), damit ein einzelner defekter Server
* die Abfrage der uebrigen Server desselben Mandanten nicht verhindert.
*/
private async tick(tenantId: string): Promise<void> {
const serverIds = await this.proxmoxService.listActiveServerIdsForTenant(tenantId);
for (const serverId of serverIds) {
try {
await this.proxmoxService.pollServer(tenantId, serverId);
} catch (err) {
this.logger.error(
`Proxmox poll failed for server ${serverId} (tenant ${tenantId}): ${(err as Error).message}`,
);
}
}
}
/**
* Alle Mandanten, fuer die derzeit ein Auftrag registriert ist — aus der
* Registry abgeleitet, fuer Tests und Diagnose.
*/
registeredTenantIds(): string[] {
const prefix = `${this.JOB_NAME_PREFIX}:`;
const names = [...this.schedulerRegistry.getCronJobs().keys()] as string[];
return names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length));
}
}
+23 -4
View File
@@ -4,6 +4,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
import { ProxmoxService } from './proxmox.service';
/**
@@ -11,13 +12,16 @@ import { ProxmoxService } from './proxmox.service';
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`)
* zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05).
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`,
* `POST servers/:id/test`) zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05).
*/
@Controller('modules/proxmox')
@UseModule('proxmox')
export class ProxmoxController {
constructor(private readonly proxmoxService: ProxmoxService) {}
constructor(
private readonly proxmoxService: ProxmoxService,
private readonly scheduler: ProxmoxSchedulerService,
) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
@@ -35,7 +39,11 @@ export class ProxmoxController {
@Post('servers')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
return this.proxmoxService.createServer(this.requireTenantId(req), dto);
const tenantId = this.requireTenantId(req);
const created = await this.proxmoxService.createServer(tenantId, dto);
// Planer sofort nachziehen — ohne Neustart (Aufgabe 4, Vorbild DkvController).
await this.scheduler.refreshTenant(tenantId);
return created;
}
@Post('servers/:id/poll')
@@ -43,4 +51,15 @@ export class ProxmoxController {
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.proxmoxService.pollServer(this.requireTenantId(req), id);
}
/**
* Verbindungstest (Aufgabe 4, `<behavior>`): liefert bei Erfolg eine
* Erfolgsmeldung und bei Misserfolg einen der sieben Fehlerschluessel
* samt kurzer Ergaenzung, OHNE den Zwischenlagerstand zu ueberschreiben.
*/
@Post('servers/:id/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async test(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.proxmoxService.testConnection(this.requireTenantId(req), id);
}
}
+4 -2
View File
@@ -2,6 +2,7 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { ProxmoxController } from './proxmox.controller';
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
import { seedProxmoxModule } from './proxmox.seed';
import { ProxmoxService } from './proxmox.service';
@@ -9,12 +10,13 @@ import { ProxmoxService } from './proxmox.service';
* NestJS module for the Proxmox feature (260923-dhh). Vorbild
* `DomaincheckModule`: seeds itself into the module registry on startup.
* `ScheduleModule` ist bereits global in `app.module.ts` registriert — der
* Planer aus Aufgabe 4 braucht hier nichts zusaetzlich.
* Planer (Aufgabe 4) braucht hier nichts zusaetzlich, nur die Aufnahme in
* `providers`.
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [ProxmoxController],
providers: [ProxmoxService],
providers: [ProxmoxService, ProxmoxSchedulerService],
})
export class ProxmoxModule implements OnModuleInit {
private readonly logger = new Logger(ProxmoxModule.name);
+91 -4
View File
@@ -68,12 +68,19 @@ function makeFakePrisma() {
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
let rows = [...servers.values()];
if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId);
if (where?.isActive !== undefined) rows = rows.filter((r) => r.isActive === where.isActive);
return rows.map((r) => applySelect(r, select));
}),
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
const row = servers.get(where.id);
return row ? { ...row } : null;
}),
findUnique: vi.fn(
async ({ where, include }: { where: { id: string }; include?: { status?: boolean } }) => {
const row = servers.get(where.id);
if (!row) return null;
if (include?.status) {
return { ...row, status: statuses.get(row.id) ?? null };
}
return { ...row };
},
),
};
const proxmoxServerStatus = {
@@ -347,3 +354,83 @@ describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => {
expect(result?.errorKind).toBe('rechte');
});
});
describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('testConnection liefert das Ergebnis, schreibt aber NICHT ins Zwischenlager', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(pveResourcesBody()), { status: 200 })),
);
const result = await service.testConnection('tenant-a', (created as any).id);
expect(result?.reachable).toBe(true);
const status = prisma.__statuses.get((created as any).id);
// Die leere Zwischenlagerzeile aus createServer bleibt unveraendert.
expect(status.lastPolledAt).toBeUndefined();
expect(status.reachable).toBe(false);
});
it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
const first = await service.pollServer('tenant-a', (created as any).id);
const second = await service.pollServer('tenant-a', (created as any).id);
expect(fetchCalls).toBe(1);
expect(second).toEqual(first);
});
it('nach zehn Sekunden ist ein erneuter Durchlauf wieder erlaubt', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
let fetchCalls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCalls++;
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
}),
);
await service.pollServer('tenant-a', (created as any).id);
const status = prisma.__statuses.get((created as any).id);
status.lastPolledAt = new Date(Date.now() - 11_000); // Sperre kuenstlich veraltern
await service.pollServer('tenant-a', (created as any).id);
expect(fetchCalls).toBe(2);
});
it('loadActiveServersForScheduler nutzt forSystem (D-08, der einzige Systemkontext-Aufruf des Moduls)', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
await service.createServer('tenant-a', TOKEN_DTO);
const servers = await service.loadActiveServersForScheduler();
expect(servers).toHaveLength(1);
expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 });
});
});
+94 -1
View File
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import type { ProxmoxServer } from '@prisma/client';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
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';
@@ -53,6 +53,14 @@ const SAFE_SERVER_SELECT = {
*/
const PBS_SNAPSHOT_QUERY_CAP = 10;
/**
* Zehn-Sekunden-Sperre fuer `pollServer` (Aufgabe 4, T-DHH-06): ein Klick
* auf "Jetzt aktualisieren" darf nicht zu ungebremsten Anfragen gegen die
* Fremd-API werden. Regulaer fragt ohnehin nur der Planer mit begrenzter
* Frequenz ab (D-05).
*/
const POLL_LOCK_MS = 10_000;
function truncateRaw(body: unknown): string {
let text: string;
try {
@@ -354,16 +362,37 @@ export class ProxmoxService {
/**
* Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager.
* Liefert `null`, wenn der Server unter diesem Mandanten nicht existiert.
*
* Zehn-Sekunden-Sperre (Aufgabe 4, `<behavior>`, T-DHH-06): ein zweiter
* Durchlauf innerhalb von zehn Sekunden nach dem letzten fragt Proxmox
* NICHT erneut, sondern liefert den vorhandenen Zwischenlagerstand —
* Schutz davor, dass ein Klick in der Oberflaeche zu ungebremsten
* Anfragen gegen die Fremd-API wird.
*/
async pollServer(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const server = await tenantPrisma.proxmoxServer.findUnique({
where: { id: serverId },
include: { status: true },
});
if (!server || server.tenantId !== tenantId) {
return null;
}
const cachedStatus = server.status;
if (cachedStatus?.lastPolledAt) {
const ageMs = Date.now() - cachedStatus.lastPolledAt.getTime();
if (ageMs < POLL_LOCK_MS) {
return {
reachable: cachedStatus.reachable,
errorKind: cachedStatus.errorKind as ProxmoxErrorKind | null,
errorDetail: cachedStatus.errorDetail,
metrics: cachedStatus.metrics as ProxmoxPollResult['metrics'],
rawSample: cachedStatus.rawSample,
};
}
}
const result = await this.pollOne(server);
const now = new Date();
@@ -393,4 +422,68 @@ export class ProxmoxService {
return result;
}
/**
* Verbindungstest (Aufgabe 4, `<behavior>`, `POST servers/:id/test`):
* benutzt denselben Klienten und dieselbe Fehleruebersetzung wie der
* Planer, schreibt aber NICHT ins Zwischenlager — ein Testklick darf den
* zuletzt gemessenen Stand nicht ueberschreiben (Vorbild
* `TenderEmailConfigService.testConnection`/LDAP-Test). Keine
* Zehn-Sekunden-Sperre: ein Test ist ein bewusster Einzelklick, kein
* automatisierter Auffrischungsweg.
*/
async testConnection(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!server || server.tenantId !== tenantId) {
return null;
}
return this.pollOne(server);
}
/**
* Aktive Server-IDs eines Mandanten fuer den Planer-Tick (gebunden).
*/
async listActiveServerIdsForTenant(tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = await tenantPrisma.proxmoxServer.findMany({
where: { tenantId, isActive: true },
select: { id: true },
});
return rows.map((r) => r.id);
}
/**
* Abfrageintervalle der aktiven Server eines Mandanten (gebunden) — der
* Controller ruft dies nach jedem Anlegen/Speichern, um den Planer
* sofort nachzuziehen (`ProxmoxSchedulerService.refreshTenant`).
*/
async loadActiveServersForTenantScheduling(
tenantId: string,
): Promise<{ pollIntervalMin: number }[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.findMany({
where: { tenantId, isActive: true },
select: { pollIntervalMin: true },
});
}
/**
* Startpfad des Planers — der EINZIGE Systemkontext-Aufruf dieses Moduls
* (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`, Aufgabe 4):
* `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE
* `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst
* keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden
* (Muster `DkvSchedulerService`/`DashboardImagesService`, einmal lesen,
* viele bedienen).
*/
async loadActiveServersForScheduler(): Promise<
{ id: string; tenantId: string; pollIntervalMin: number }[]
> {
const systemPrisma = forSystem(this.prisma);
return systemPrisma.proxmoxServer.findMany({
where: { isActive: true },
select: { id: true, tenantId: true, pollIntervalMin: true },
});
}
}