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 } }) => {
|
||||
findUnique: vi.fn(
|
||||
async ({ where, include }: { where: { id: string }; include?: { status?: boolean } }) => {
|
||||
const row = servers.get(where.id);
|
||||
return row ? { ...row } : null;
|
||||
}),
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +175,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
||||
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||
| proxmox | 0 | 4 | 0 | neu (quick-260923-dhh, Aufgabe 1) — `proxmox.service.ts` bringt vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode). Nachgemessen mit der Gate-Schleife. Aufgabe 4 ergaenzt einen System-Rohtreffer fuer den Planer-Startpfad (`loadActiveServersForScheduler()`) — siehe dortigen Nachtrag |
|
||||
| **Summe** | **61** | **201** | **6** | **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
| proxmox | 0 | 7 | 1 | **quick-260923-dhh (Aufgabe 4, Endstand):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
||||
| **Summe** | **61** | **204** | **7** | **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||
|
||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare)
|
||||
|
||||
@@ -762,8 +762,8 @@ werden.
|
||||
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
|
||||
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
|
||||
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1 — vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `createServer`/`listWithStatus`/`pollServer` holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId);`. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). Aufgabe 4 ergaenzt den Startpfad des Planers (`loadActiveServersForScheduler()`) ueber `forSystem()` — dieser eine Zusatzaufruf hebt den Stand auf `system-gebunden`, siehe dortigen Nachtrag. |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad in Aufgabe 4 liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe bleiben unveraendert gebunden: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany`, ueber `proxmoxServerStatus` — Zeile darunter). Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
||||
|
||||
## Was diese Etappe NICHT entscheidet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user