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
|
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
|
||||||
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
||||||
* Summe neu: 5 Dateien, 6 Aufrufe.
|
* 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>([
|
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||||
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
||||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
['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-digest.scheduler.ts', 1],
|
||||||
['apps/api/src/tenders/tender-matching.service.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 type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||||
import { UseModule } from '../module-registry/module.guard';
|
import { UseModule } from '../module-registry/module.guard';
|
||||||
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||||
|
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
||||||
import { ProxmoxService } from './proxmox.service';
|
import { ProxmoxService } from './proxmox.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,13 +12,16 @@ import { ProxmoxService } from './proxmox.service';
|
|||||||
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
|
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
|
||||||
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
|
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
|
||||||
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
|
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
|
||||||
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`)
|
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`,
|
||||||
* zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05).
|
* `POST servers/:id/test`) zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05).
|
||||||
*/
|
*/
|
||||||
@Controller('modules/proxmox')
|
@Controller('modules/proxmox')
|
||||||
@UseModule('proxmox')
|
@UseModule('proxmox')
|
||||||
export class ProxmoxController {
|
export class ProxmoxController {
|
||||||
constructor(private readonly proxmoxService: ProxmoxService) {}
|
constructor(
|
||||||
|
private readonly proxmoxService: ProxmoxService,
|
||||||
|
private readonly scheduler: ProxmoxSchedulerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private requireTenantId(req: AuthenticatedRequest): string {
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
const tenantId = req.tenantId;
|
const tenantId = req.tenantId;
|
||||||
@@ -35,7 +39,11 @@ export class ProxmoxController {
|
|||||||
@Post('servers')
|
@Post('servers')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
|
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')
|
@Post('servers/:id/poll')
|
||||||
@@ -43,4 +51,15 @@ export class ProxmoxController {
|
|||||||
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
return this.proxmoxService.pollServer(this.requireTenantId(req), id);
|
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 { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
import { ProxmoxController } from './proxmox.controller';
|
import { ProxmoxController } from './proxmox.controller';
|
||||||
|
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
||||||
import { seedProxmoxModule } from './proxmox.seed';
|
import { seedProxmoxModule } from './proxmox.seed';
|
||||||
import { ProxmoxService } from './proxmox.service';
|
import { ProxmoxService } from './proxmox.service';
|
||||||
|
|
||||||
@@ -9,12 +10,13 @@ import { ProxmoxService } from './proxmox.service';
|
|||||||
* NestJS module for the Proxmox feature (260923-dhh). Vorbild
|
* NestJS module for the Proxmox feature (260923-dhh). Vorbild
|
||||||
* `DomaincheckModule`: seeds itself into the module registry on startup.
|
* `DomaincheckModule`: seeds itself into the module registry on startup.
|
||||||
* `ScheduleModule` ist bereits global in `app.module.ts` registriert — der
|
* `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({
|
@Module({
|
||||||
imports: [ModuleRegistryModule],
|
imports: [ModuleRegistryModule],
|
||||||
controllers: [ProxmoxController],
|
controllers: [ProxmoxController],
|
||||||
providers: [ProxmoxService],
|
providers: [ProxmoxService, ProxmoxSchedulerService],
|
||||||
})
|
})
|
||||||
export class ProxmoxModule implements OnModuleInit {
|
export class ProxmoxModule implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ProxmoxModule.name);
|
private readonly logger = new Logger(ProxmoxModule.name);
|
||||||
|
|||||||
@@ -68,12 +68,19 @@ function makeFakePrisma() {
|
|||||||
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
|
findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => {
|
||||||
let rows = [...servers.values()];
|
let rows = [...servers.values()];
|
||||||
if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId);
|
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));
|
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);
|
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 = {
|
const proxmoxServerStatus = {
|
||||||
@@ -347,3 +354,83 @@ describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => {
|
|||||||
expect(result?.errorKind).toBe('rechte');
|
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 type { ProxmoxServer } from '@prisma/client';
|
||||||
import { CryptoService } from '../crypto/crypto.service';
|
import { CryptoService } from '../crypto/crypto.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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 { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
|
||||||
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
|
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
|
||||||
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
|
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
|
||||||
@@ -53,6 +53,14 @@ const SAFE_SERVER_SELECT = {
|
|||||||
*/
|
*/
|
||||||
const PBS_SNAPSHOT_QUERY_CAP = 10;
|
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 {
|
function truncateRaw(body: unknown): string {
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
@@ -354,16 +362,37 @@ export class ProxmoxService {
|
|||||||
/**
|
/**
|
||||||
* Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager.
|
* Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager.
|
||||||
* Liefert `null`, wenn der Server unter diesem Mandanten nicht existiert.
|
* 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> {
|
async pollServer(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
const server = await tenantPrisma.proxmoxServer.findUnique({
|
const server = await tenantPrisma.proxmoxServer.findUnique({
|
||||||
where: { id: serverId },
|
where: { id: serverId },
|
||||||
|
include: { status: true },
|
||||||
});
|
});
|
||||||
if (!server || server.tenantId !== tenantId) {
|
if (!server || server.tenantId !== tenantId) {
|
||||||
return null;
|
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 result = await this.pollOne(server);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -393,4 +422,68 @@ export class ProxmoxService {
|
|||||||
|
|
||||||
return result;
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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** | **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 |
|
| **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)
|
## 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.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 | 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/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 | 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 — 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 | 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
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user