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