Files
tessera-ctl/apps/api/src/proxmox/proxmox.service.ts
T
schalli dd54ec5d42 feat(260924-m4n): Bilderrahmen Stufe 2 - alte Bildspalte data entfernt, storagePath Pflicht
- Migration 20260924120000_dashboard_image_drop_data: Schutzpruefung
  (bricht ab, solange eine Zeile ohne storagePath existiert; row_security
  aus, damit ein Eigentuemer ohne BYPASSRLS nicht still 0 Zeilen sieht),
  dann NOT NULL, DROP COLUMN data, DROP POLICY system_read_policy
- Dienst: Bootstrap-Umzug samt forSystem() und Selbstheilung aus data
  entfernt; Upload vergibt die UUID selbst, Zeile gleich mit Pfad
- FORSYSTEM_ALLOWED_CALL_SITES, Tests, Zugriffsklassifikation (per
  Gate-Schleife gemessen: 61/213/6) nachgezogen
- Betriebshandbuch Kap. 4: Hinweis und Wiederherstellungsweg bei Abbruch
- Todo 2026-09-22 nach completed/

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 16:09:36 +02:00

639 lines
23 KiB
TypeScript

import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { ProxmoxServer } from '@prisma/client';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
import type {
CreateProxmoxServerDto,
TestProxmoxServerDto,
UpdateProxmoxServerDto,
} from './dto/proxmox-server.dto';
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
/**
* Nur die Felder, die eine Abfrage tatsaechlich braucht (Nachbesserung
* Befund 1) — `pollOne`/`buildAuthHeaders`/`getWithRetry` nehmen diesen
* schmalen Ausschnitt statt der vollen `ProxmoxServer`-Zeile entgegen, damit
* `resolveEffectiveTestServer` unten eine rein im Speicher gebaute Mischung
* aus Formular- und gespeicherten Werten uebergeben kann, ohne eine
* vollstaendige Datenbankzeile vorzutaeuschen.
*/
type ProxmoxCredentialSource = Pick<
ProxmoxServer,
| 'productType'
| 'baseUrl'
| 'authMethod'
| 'tokenId'
| 'encryptedTokenSecret'
| 'username'
| 'encryptedPassword'
| 'tlsRejectUnauthorized'
>;
/**
* Erkennungsform fuer "schon verschluesselt" — woertlich aus
* `ldap-config.service.ts:17` uebernommen (Format `iv:authTag:ciphertext`,
* hex, Doppelpunkt-getrennt). Fuer Proxmox als NEUES Feature ab Tag 1
* irrelevant (keine Altdaten), aber derselbe defensive Riegel wie ueberall
* sonst im Projekt.
*/
const ENCRYPTED_VALUE_SHAPE = /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i;
/** Rohantwort wird auf hoechstens diese Zeichenzahl gekuerzt in `rawSample` abgelegt. */
const RAW_SAMPLE_MAX_CHARS = 20000;
/**
* `select` OHNE die beiden Geheimnisfelder — die Felder verlassen die
* Datenbank gar nicht erst, statt nachtraeglich maskiert zu werden
* (T-DHH-01, `must_haves.truths`).
*/
const SAFE_SERVER_SELECT = {
id: true,
tenantId: true,
name: true,
productType: true,
baseUrl: true,
authMethod: true,
tokenId: true,
username: true,
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: true,
position: true,
createdAt: true,
updatedAt: true,
status: true,
} as const;
/**
* Deckel der Folgeabfragen je PBS-Durchlauf (Aufgabe 3, `<behavior>`): ein
* PBS-Server mit vielen Datenspeichern soll den Planer nicht mit
* unbegrenzt vielen Anfragen belasten — hoechstens diese Zahl an
* `/snapshots`-Abfragen je Poll-Durchlauf, unabhaengig davon, wie viele
* Datenspeicher der Server tatsaechlich hat.
*/
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 {
text = JSON.stringify(body) ?? String(body);
} catch {
text = String(body);
}
return text.length > RAW_SAMPLE_MAX_CHARS ? text.slice(0, RAW_SAMPLE_MAX_CHARS) : text;
}
type AuthHeaderResult =
| { ok: true; headers: Record<string, string> }
| { ok: false; errorKind: ProxmoxErrorKind; errorDetail: string };
@Injectable()
export class ProxmoxService {
private readonly logger = new Logger(ProxmoxService.name);
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
) {}
/**
* Entschluesselt fuer den internen Gebrauch in GENAU dieser einen
* privaten Methode (Vorbild `LdapConfigService.decryptBindPassword`) —
* ein Wert, der nicht in `iv:authTag:ciphertext`-Form ist, wird
* unveraendert durchgereicht.
*/
private decryptSecret(stored: string | null): string | null {
if (!stored) return null;
if (!ENCRYPTED_VALUE_SHAPE.test(stored)) return stored;
return this.crypto.decrypt(stored);
}
/**
* Server anlegen (Aufgabe 1: nur `pve`+Token gepflegt vom Aufrufer;
* Aufgabe 2 ergaenzt den Passwort-Zweig, Aufgabe 5 das Bearbeiten). Legt
* zugleich eine leere Zwischenlagerzeile an, damit `listWithStatus` immer
* eine Statuszeile findet.
*/
async createServer(tenantId: string, dto: CreateProxmoxServerDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.create({
data: {
tenantId,
name: dto.name,
productType: dto.productType,
baseUrl: dto.baseUrl,
authMethod: dto.authMethod,
tokenId: dto.authMethod === 'token' ? (dto.tokenId ?? null) : null,
encryptedTokenSecret:
dto.authMethod === 'token' && dto.tokenSecret
? this.crypto.encrypt(dto.tokenSecret)
: null,
username: dto.authMethod === 'password' ? (dto.username ?? null) : null,
encryptedPassword:
dto.authMethod === 'password' && dto.password
? this.crypto.encrypt(dto.password)
: null,
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? true,
pollIntervalMin: dto.pollIntervalMin ?? 5,
isActive: dto.isActive ?? true,
status: { create: { tenantId, reachable: false } },
},
select: SAFE_SERVER_SELECT,
});
}
/**
* Serverliste samt Zwischenlager, OHNE jedes Geheimnisfeld (T-DHH-01).
* Liest ausschliesslich aus dem Zwischenlager — kein Live-Zugriff bei
* Proxmox (D-05).
*/
async listWithStatus(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.proxmoxServer.findMany({
where: { tenantId },
orderBy: { position: 'asc' },
select: SAFE_SERVER_SELECT,
});
}
/**
* Bearbeiten (Aufgabe 5). Dieselbe Regel wie
* `LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld
* laesst den gespeicherten Wert unveraendert, eine LEERE Zeichenkette
* bedeutet "loeschen", ein gefuellter Wert wird neu verschluesselt. Die
* Ablehnung "PMG plus Token" gilt auch hier — geprueft gegen den
* EFFEKTIVEN Stand nach dem Zusammenfuehren mit der vorhandenen Zeile,
* nicht nur gegen die gesendeten Felder (ein Teil-Update, das nur
* `authMethod` aendert, wuerde die DTO-eigene Pruefung sonst umgehen,
* weil `productType` in diesem Aufruf gar nicht gesendet wird).
*/
async updateServer(tenantId: string, serverId: string, dto: UpdateProxmoxServerDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!existing || existing.tenantId !== tenantId) {
return null;
}
const effectiveProductType = dto.productType ?? existing.productType;
const effectiveAuthMethod = dto.authMethod ?? existing.authMethod;
if (effectiveProductType === 'pmg' && effectiveAuthMethod === 'token') {
throw new BadRequestException(
'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.',
);
}
const data: Record<string, unknown> = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.productType !== undefined) data.productType = dto.productType;
if (dto.baseUrl !== undefined) data.baseUrl = dto.baseUrl;
if (dto.authMethod !== undefined) data.authMethod = dto.authMethod;
if (dto.tokenId !== undefined) data.tokenId = dto.tokenId || null;
if (dto.tokenSecret !== undefined) {
data.encryptedTokenSecret = dto.tokenSecret ? this.crypto.encrypt(dto.tokenSecret) : null;
}
if (dto.username !== undefined) data.username = dto.username || null;
if (dto.password !== undefined) {
data.encryptedPassword = dto.password ? this.crypto.encrypt(dto.password) : null;
}
if (dto.tlsRejectUnauthorized !== undefined) {
data.tlsRejectUnauthorized = dto.tlsRejectUnauthorized;
}
if (dto.pollIntervalMin !== undefined) data.pollIntervalMin = dto.pollIntervalMin;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
return tenantPrisma.proxmoxServer.update({
where: { id: serverId },
data,
select: SAFE_SERVER_SELECT,
});
}
/**
* Loeschen (Aufgabe 5) — entfernt den Server samt Zwischenlagerzeile
* (Fremdschluessel mit Loeschweitergabe, `onDelete: Cascade`). Liefert
* `false`, wenn der Server unter diesem Mandanten nicht existiert.
*/
async deleteServer(tenantId: string, serverId: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!existing || existing.tenantId !== tenantId) {
return false;
}
await tenantPrisma.proxmoxServer.delete({ where: { id: serverId } });
return true;
}
/**
* Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber
* `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine
* Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls,
* D-01) — deshalb `async`.
*/
private async buildAuthHeaders(server: ProxmoxCredentialSource): Promise<AuthHeaderResult> {
if (server.authMethod === 'token') {
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
if (!server.tokenId || !tokenSecret) {
return {
ok: false,
errorKind: 'zugang',
errorDetail: 'Kein Token hinterlegt.',
};
}
return {
ok: true,
headers: buildTokenAuthHeader(
server.productType as 'pve' | 'pbs' | 'pmg',
server.tokenId,
tokenSecret,
),
};
}
const password = this.decryptSecret(server.encryptedPassword);
if (!server.username || !password) {
return {
ok: false,
errorKind: 'zugang',
errorDetail: 'Kein Benutzer/Passwort hinterlegt.',
};
}
const login = await loginTicket(
{ baseUrl: server.baseUrl, tlsRejectUnauthorized: server.tlsRejectUnauthorized },
server.productType as 'pve' | 'pbs' | 'pmg',
server.username,
password,
);
if (!login.ok) {
return { ok: false, errorKind: login.errorKind, errorDetail: login.errorDetail };
}
return {
ok: true,
headers: buildTicketCookieHeader(server.productType as 'pve' | 'pbs' | 'pmg', login.ticket),
};
}
/**
* Fragt EINEN Proxmox-Pfad ab, gebunden an die Kopfzeilen dieses
* Poll-Durchlaufs. Ticket-Erneuerung (Aufgabe 2, `<behavior>`): laeuft
* der Zugang ueber `password` und antwortet Proxmox mit 401
* (`errorKind: 'zugang'`), wird GENAU EINMAL je Durchlauf neu angemeldet
* (nicht je Aufruf — ein PBS-Durchlauf mit mehreren Folgeabfragen soll
* nicht mehrfach neu einloggen) und die Abfrage wiederholt; die neuen
* Kopfzeilen gelten danach fuer den Rest des Durchlaufs. Bei einer
* Ticketdauer von zwei Stunden erzeugt ein normaler Ablauf sonst alle
* zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`.
*/
private async getWithRetry(
server: ProxmoxCredentialSource,
session: { headers: Record<string, string>; retried: boolean },
path: string,
): Promise<ProxmoxGetResult> {
const target = {
baseUrl: server.baseUrl,
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
headers: session.headers,
};
let result = await proxmoxGet(target, path);
if (
!result.ok &&
result.errorKind === 'zugang' &&
server.authMethod === 'password' &&
!session.retried
) {
session.retried = true;
const retryHeaders = await this.buildAuthHeaders(server);
if (retryHeaders.ok) {
session.headers = retryHeaders.headers;
result = await proxmoxGet({ ...target, headers: retryHeaders.headers }, path);
}
}
return result;
}
/**
* EIN Abfragedurchlauf gegen genau diesen Server — `pve` (Aufgabe 1),
* `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort
* (Aufgabe 2).
*/
private async pollOne(server: ProxmoxCredentialSource): Promise<ProxmoxPollResult> {
const authHeaders = await this.buildAuthHeaders(server);
if (!authHeaders.ok) {
return {
reachable: false,
errorKind: authHeaders.errorKind,
errorDetail: authHeaders.errorDetail,
metrics: null,
rawSample: null,
};
}
const session = { headers: authHeaders.headers, retried: false };
const productType = server.productType as ProxmoxProductType;
if (productType === 'pve') {
const result = await this.getWithRetry(server, session, '/api2/json/cluster/resources');
if (!result.ok) {
return {
reachable: false,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: null,
rawSample: result.body === null ? null : truncateRaw(result.body),
};
}
const normalized = normalizePve(result.body);
if (normalized.errorKind) {
return {
reachable: false,
errorKind: normalized.errorKind,
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
metrics: null,
rawSample: truncateRaw(result.body),
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalized.metrics,
rawSample: truncateRaw(result.body),
};
}
if (productType === 'pbs') {
const usageResult = await this.getWithRetry(server, session, '/api2/json/status/datastore-usage');
if (!usageResult.ok) {
return {
reachable: false,
errorKind: usageResult.errorKind,
errorDetail: usageResult.errorDetail,
metrics: null,
rawSample: usageResult.body === null ? null : truncateRaw(usageResult.body),
};
}
const storeNames = listPbsDatastoreNames(usageResult.body).slice(0, PBS_SNAPSHOT_QUERY_CAP);
const snapshotsByStore: Record<string, unknown> = {};
for (const storeName of storeNames) {
const snapResult = await this.getWithRetry(
server,
session,
`/api2/json/admin/datastore/${encodeURIComponent(storeName)}/snapshots`,
);
if (snapResult.ok) {
snapshotsByStore[storeName] = snapResult.body;
}
}
const normalized = normalizePbs(usageResult.body, snapshotsByStore);
const rawSample = truncateRaw({ usage: usageResult.body, snapshots: snapshotsByStore });
if (normalized.errorKind) {
return {
reachable: false,
errorKind: normalized.errorKind,
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
metrics: null,
rawSample,
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalized.metrics,
rawSample,
};
}
// pmg
const result = await this.getWithRetry(server, session, '/api2/json/statistics/mail');
if (!result.ok) {
return {
reachable: false,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: null,
rawSample: result.body === null ? null : truncateRaw(result.body),
};
}
const normalized = normalizePmg(result.body);
if (normalized.errorKind) {
return {
reachable: false,
errorKind: normalized.errorKind,
errorDetail: 'Die Antwort hatte nicht die erwartete Form.',
metrics: null,
rawSample: truncateRaw(result.body),
};
}
return {
reachable: true,
errorKind: null,
errorDetail: null,
metrics: normalized.metrics,
rawSample: truncateRaw(result.body),
};
}
/**
* 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();
await tenantPrisma.proxmoxServerStatus.upsert({
where: { serverId },
create: {
serverId,
tenantId,
lastPolledAt: now,
lastOkAt: result.reachable ? now : null,
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
update: {
lastPolledAt: now,
...(result.reachable ? { lastOkAt: now } : {}),
reachable: result.reachable,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
metrics: result.metrics as never,
rawSample: result.rawSample as never,
},
});
return result;
}
/**
* Mischt Formularwerte (`dto`, ungespeichert) mit dem gespeicherten Server
* (`existing`, `null` bei der Neuanlage) zu genau den Feldern, die eine
* Abfrage braucht (Nachbesserung Befund 1). Zwei Regeln, je nachdem, ob
* das Formular das Feld beim Laden vorbefuellt (Vorbild `serverToForm`):
*
* - Normale Felder (`productType`, `baseUrl`, `authMethod`, `tokenId`,
* `username`, `tlsRejectUnauthorized`): das Formular zeigt immer den
* zuletzt gespeicherten Wert an, bis der Nutzer ihn aendert — ein vom
* Aufrufer GESENDETES Feld gilt also als Formularwert, auch wenn es
* absichtlich geleert wurde (`tokenId: ''` -> `null`). Nur ein NICHT
* gesendetes Feld (Aufrufer ohne diesen Schluessel im Body) faellt auf
* den gespeicherten Wert zurueck.
* - Geheimnisfelder (`tokenSecret`/`password`): `ServerForm` befuellt
* diese beim Laden bewusst NIE aus der Datenbank (Geheimnis nie im
* Klartext anzeigen). Ein leeres Feld bedeutet hier deshalb NICHT
* "Nutzer will loeschen", sondern "Nutzer hat nichts eingetippt" ->
* gespeicherten (verschluesselten) Wert weiterverwenden. Ein gefuelltes
* Feld ist der eingetippte Klartext und wird unveraendert durchgereicht;
* `decryptSecret()` erkennt anhand der Form `iv:authTag:ciphertext`
* automatisch, ob entschluesselt werden muss, und laesst Klartext sonst
* unangetastet.
*/
private resolveEffectiveTestServer(
existing: ProxmoxServer | null,
dto: TestProxmoxServerDto,
): ProxmoxCredentialSource {
return {
productType: dto.productType ?? existing?.productType ?? 'pve',
baseUrl: dto.baseUrl ?? existing?.baseUrl ?? '',
authMethod: dto.authMethod ?? existing?.authMethod ?? 'token',
tokenId: dto.tokenId !== undefined ? dto.tokenId || null : (existing?.tokenId ?? null),
encryptedTokenSecret: dto.tokenSecret
? dto.tokenSecret
: (existing?.encryptedTokenSecret ?? null),
username: dto.username !== undefined ? dto.username || null : (existing?.username ?? null),
encryptedPassword: dto.password ? dto.password : (existing?.encryptedPassword ?? null),
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? existing?.tlsRejectUnauthorized ?? true,
};
}
/**
* Verbindungstest fuer einen GESPEICHERTEN Server (Aufgabe 4, `<behavior>`,
* `POST servers/:id/test`; Nachbesserung Befund 1: prueft jetzt die
* Formularwerte aus `dto`, nicht mehr blind den gespeicherten Stand).
* 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,
dto: TestProxmoxServerDto = {},
): 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(this.resolveEffectiveTestServer(server, dto));
}
/**
* Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1,
* `POST servers/test`, ohne `:id`) — es gibt noch keinen gespeicherten
* Server, also ausschliesslich die Formularwerte aus `dto`. Fehlende
* Pflichtangaben (z. B. kein Geheimnis) fuehren zum selben Fehlerschluessel
* wie beim Abfragen eines gespeicherten Servers ohne Zugang (`zugang`).
*/
async testDraftConnection(dto: TestProxmoxServerDto): Promise<ProxmoxPollResult> {
return this.pollOne(this.resolveEffectiveTestServer(null, dto));
}
/**
* 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`, 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 },
});
}
}