feat: Phase 3 – Modul-System (Manifest, ZIP-Installation, Lifecycle, Prozess-Manager)
This commit is contained in:
@@ -7,6 +7,7 @@ import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ModulesModule } from './modules/modules.module';
|
||||
import { SessionGuard } from './auth/guards/session.guard';
|
||||
import { CsrfGuard } from './auth/guards/csrf.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
@@ -16,7 +17,15 @@ import { RolesGuard } from './common/guards/roles.guard';
|
||||
* Globale Guards: SessionGuard (Authentifizierung) → CsrfGuard → RolesGuard.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule, DatabaseModule, AuditModule, AuthModule, UsersModule, HealthModule],
|
||||
imports: [
|
||||
ConfigModule,
|
||||
DatabaseModule,
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
HealthModule,
|
||||
ModulesModule,
|
||||
],
|
||||
providers: [
|
||||
MigrationRunner,
|
||||
{ provide: APP_GUARD, useClass: SessionGuard },
|
||||
|
||||
@@ -14,6 +14,13 @@ export const AUDIT_ACTIONS = {
|
||||
USER_DELETED: 'USER_DELETED',
|
||||
USER_PASSWORD_RESET: 'USER_PASSWORD_RESET',
|
||||
USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED',
|
||||
MODULE_INSTALLED: 'MODULE_INSTALLED',
|
||||
MODULE_REMOVED: 'MODULE_REMOVED',
|
||||
MODULE_STARTED: 'MODULE_STARTED',
|
||||
MODULE_STOPPED: 'MODULE_STOPPED',
|
||||
MODULE_RESTARTED: 'MODULE_RESTARTED',
|
||||
MODULE_ENABLED: 'MODULE_ENABLED',
|
||||
MODULE_DISABLED: 'MODULE_DISABLED',
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||
|
||||
@@ -25,6 +25,7 @@ function createConfig(overrides: Partial<AppConfig['security']> = {}): AppConfig
|
||||
...overrides,
|
||||
},
|
||||
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
||||
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Zentrale, typsichere Konfiguration der Management-Plattform.
|
||||
@@ -28,12 +29,18 @@ export interface AdminSeedConfig {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
readonly modulesDir: string;
|
||||
readonly logsDir: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
readonly nodeEnv: NodeEnvironment;
|
||||
readonly port: number;
|
||||
readonly database: DatabaseConfig;
|
||||
readonly security: SecurityConfig;
|
||||
readonly adminSeed: AdminSeedConfig;
|
||||
readonly runtime: RuntimeConfig;
|
||||
}
|
||||
|
||||
const booleanFromString = z
|
||||
@@ -55,6 +62,8 @@ const environmentSchema = z.object({
|
||||
ADMIN_USERNAME: z.string().trim().min(3).max(100),
|
||||
ADMIN_EMAIL: z.string().trim().email(),
|
||||
ADMIN_PASSWORD: z.string().min(10, 'ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein').max(200),
|
||||
MODULES_DIR: z.string().min(1).default('./data/modules'),
|
||||
LOGS_DIR: z.string().min(1).default('./data/logs'),
|
||||
});
|
||||
|
||||
/** Lädt und validiert die Konfiguration aus den Umgebungsvariablen. */
|
||||
@@ -79,5 +88,9 @@ export function loadConfiguration(): AppConfig {
|
||||
email: environment.ADMIN_EMAIL,
|
||||
password: environment.ADMIN_PASSWORD,
|
||||
},
|
||||
runtime: {
|
||||
modulesDir: path.resolve(environment.MODULES_DIR),
|
||||
logsDir: path.resolve(environment.LOGS_DIR),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { migration001CoreSchema } from './001-core-schema';
|
||||
import { migration002Modules } from '../../modules/migrations/002-modules';
|
||||
|
||||
/** Registrierte Migrationen in aufsteigender Reihenfolge. */
|
||||
export const MIGRATIONS = [migration001CoreSchema];
|
||||
export const MIGRATIONS = [migration001CoreSchema, migration002Modules];
|
||||
79
apps/platform-backend/src/modules/manifest.types.spec.ts
Normal file
79
apps/platform-backend/src/modules/manifest.types.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { moduleManifestSchema } from './manifest.types';
|
||||
import { ModuleInstaller } from './module-installer';
|
||||
|
||||
describe('moduleManifestSchema', () => {
|
||||
const validManifest = {
|
||||
id: 'calendar',
|
||||
name: 'Kalender',
|
||||
version: '1.0.0',
|
||||
slug: 'kalender-tool',
|
||||
description: 'Kalenderverwaltung',
|
||||
author: 'MPM',
|
||||
runtime: 'node',
|
||||
entrypoint: 'server.js',
|
||||
port: 41001,
|
||||
healthcheck: '/health',
|
||||
apiVersion: 'v1',
|
||||
};
|
||||
|
||||
it('akzeptiert ein gültiges Manifest', () => {
|
||||
const result = moduleManifestSchema.safeParse(validManifest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt ungültige Modul-IDs ab', () => {
|
||||
const result = moduleManifestSchema.safeParse({ ...validManifest, id: 'Invalid_ID!' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('lehnt ungültige Versionen ab', () => {
|
||||
const result = moduleManifestSchema.safeParse({ ...validManifest, version: '1.0' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('lehnt Ports außerhalb des erlaubten Bereichs ab', () => {
|
||||
const result = moduleManifestSchema.safeParse({ ...validManifest, port: 80 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('lehnt Pfad-Traversal im Entrypoint ab', () => {
|
||||
const result = moduleManifestSchema.safeParse({ ...validManifest, entrypoint: '../evil.js' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('lehnt unbekannte Runtimes ab', () => {
|
||||
const result = moduleManifestSchema.safeParse({ ...validManifest, runtime: 'python' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('lehnt ungültige Healthcheck-Pfade ab', () => {
|
||||
const result = moduleManifestSchema.safeParse({ ...validManifest, healthcheck: 'http://evil' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleInstaller.validatePackage', () => {
|
||||
it('lehnt leere Pakete ab', async () => {
|
||||
const installer = new ModuleInstaller();
|
||||
await expect(installer.validatePackage(Buffer.alloc(0))).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('lehnt Pakete ohne module.json ab', async () => {
|
||||
const AdmZip = (await import('adm-zip')).default;
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('irgendwas.txt', Buffer.from('Inhalt'));
|
||||
const installer = new ModuleInstaller();
|
||||
await expect(installer.validatePackage(zip.toBuffer())).rejects.toThrow(
|
||||
'Paket enthält keine module.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('lehnt ungültige Manifeste ab', async () => {
|
||||
const AdmZip = (await import('adm-zip')).default;
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('module.json', Buffer.from(JSON.stringify({ id: 'X' })));
|
||||
const installer = new ModuleInstaller();
|
||||
await expect(installer.validatePackage(zip.toBuffer())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
74
apps/platform-backend/src/modules/manifest.types.ts
Normal file
74
apps/platform-backend/src/modules/manifest.types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Mögliche Lebenszyklus-Zustände eines Moduls. */
|
||||
export const MODULE_STATUSES = [
|
||||
'INSTALLED',
|
||||
'STARTING',
|
||||
'RUNNING',
|
||||
'STOPPING',
|
||||
'STOPPED',
|
||||
'ERROR',
|
||||
'DISABLED',
|
||||
] as const;
|
||||
export type ModuleStatus = (typeof MODULE_STATUSES)[number];
|
||||
|
||||
/** Erlaubter interner Portbereich für Modul-Prozesse (nicht nach außen sichtbar). */
|
||||
export const MODULE_PORT_MIN = 41000;
|
||||
export const MODULE_PORT_MAX = 41999;
|
||||
|
||||
/** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */
|
||||
const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/;
|
||||
|
||||
/** URL-Slugs für das spätere Routing (/slug). */
|
||||
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{2,99}$/;
|
||||
|
||||
/** Semantische Versionierung (major.minor.patch). */
|
||||
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
/**
|
||||
* Manifest-Vertrag (module.json) eines Moduls.
|
||||
* Jedes installierbare Paket muss dieses Schema erfüllen.
|
||||
*/
|
||||
export const moduleManifestSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.regex(MODULE_ID_PATTERN, 'Modul-ID muss dem Muster [a-z][a-z0-9-]{2,63} folgen'),
|
||||
name: z.string().trim().min(1, 'Name ist erforderlich').max(100),
|
||||
version: z.string().regex(VERSION_PATTERN, 'Version muss dem Muster major.minor.patch folgen'),
|
||||
slug: z.string().regex(SLUG_PATTERN, 'Slug muss dem Muster [a-z0-9-]{3,100} folgen'),
|
||||
description: z.string().max(500).default(''),
|
||||
author: z.string().max(200).default(''),
|
||||
runtime: z.literal('node'),
|
||||
entrypoint: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*\.js$/, 'Entrypoint muss ein relativer .js-Pfad sein')
|
||||
.refine((entrypoint) => !entrypoint.split('/').includes('..'), {
|
||||
message: 'Entrypoint darf nicht über das Modulverzeichnis hinauszeigen',
|
||||
}),
|
||||
port: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MODULE_PORT_MIN, `Port muss zwischen ${MODULE_PORT_MIN} und ${MODULE_PORT_MAX} liegen`)
|
||||
.max(MODULE_PORT_MAX, `Port muss zwischen ${MODULE_PORT_MIN} und ${MODULE_PORT_MAX} liegen`),
|
||||
healthcheck: z.string().regex(/^\/[A-Za-z0-9\-./]*$/, 'Healthcheck muss ein Pfad sein'),
|
||||
apiVersion: z.literal('v1'),
|
||||
});
|
||||
export type ModuleManifest = z.infer<typeof moduleManifestSchema>;
|
||||
|
||||
/** Vollständiger Modul-Datensatz aus der Datenbank. */
|
||||
export interface ModuleRecord {
|
||||
readonly id: string;
|
||||
readonly moduleId: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly version: string;
|
||||
readonly description: string;
|
||||
readonly author: string;
|
||||
readonly path: string;
|
||||
readonly status: ModuleStatus;
|
||||
readonly internalPort: number;
|
||||
readonly healthcheckUrl: string;
|
||||
readonly enabled: boolean;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
30
apps/platform-backend/src/modules/migrations/002-modules.ts
Normal file
30
apps/platform-backend/src/modules/migrations/002-modules.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Migration } from '../../database/migration.types';
|
||||
|
||||
/** Phase 3: Modul-Registry für installierte Module. */
|
||||
export const migration002Modules: Migration = {
|
||||
id: '002-modules',
|
||||
description: 'Modul-Registry anlegen',
|
||||
up: async (client) => {
|
||||
await client.query(`
|
||||
CREATE TABLE modules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
module_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
version TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'INSTALLED'
|
||||
CHECK (status IN ('INSTALLED', 'STARTING', 'RUNNING', 'STOPPING', 'STOPPED', 'ERROR', 'DISABLED')),
|
||||
internal_port INTEGER NOT NULL,
|
||||
healthcheck_url TEXT NOT NULL DEFAULT '/health',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
await client.query('CREATE INDEX idx_modules_status ON modules(status)');
|
||||
},
|
||||
};
|
||||
43
apps/platform-backend/src/modules/module-health-checker.ts
Normal file
43
apps/platform-backend/src/modules/module-health-checker.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ModuleRecord } from './manifest.types';
|
||||
|
||||
/** Ergebnis eines Modul-Healthchecks. */
|
||||
export interface ModuleHealthResult {
|
||||
readonly healthy: boolean;
|
||||
readonly latencyMs: number;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modul-Healthchecks (Infrastructure): Prüft die Healthcheck-URL eines
|
||||
* Modul-Prozesses. Fehler werden abgefangen – ein krankes Modul darf
|
||||
* die Plattform niemals mitreißen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModuleHealthChecker {
|
||||
|
||||
/** Prüft einen Modul-Prozess über seine Healthcheck-URL. */
|
||||
async check(module: ModuleRecord): Promise<ModuleHealthResult> {
|
||||
const url = `http://127.0.0.1:${module.internalPort}${module.healthcheckUrl}`;
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3_000);
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const healthy = response.ok;
|
||||
return {
|
||||
healthy,
|
||||
latencyMs,
|
||||
detail: healthy ? 'healthy' : `HTTP ${response.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return { healthy: false, latencyMs, detail: `unreachable: ${detail}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
130
apps/platform-backend/src/modules/module-installer.ts
Normal file
130
apps/platform-backend/src/modules/module-installer.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { moduleManifestSchema, type ModuleManifest } from './manifest.types';
|
||||
|
||||
/** Maximale Größe eines Modul-Pakets (10 MB). */
|
||||
const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Dateien, die in einem Modul-Paket erwartet werden. */
|
||||
const REQUIRED_MANIFEST_FILE = 'module.json';
|
||||
|
||||
/**
|
||||
* Modul-Installer (Infrastructure):
|
||||
* Nimmt ein ZIP-Paket entgegen, validiert es (Größe, Manifest,
|
||||
* Zip-Slip-Schutz) und installiert es in das Modulverzeichnis.
|
||||
*
|
||||
* Ein Modul ist grundsätzlich nicht vertrauenswürdig – deshalb:
|
||||
* - strikte Manifest-Validierung (Zod)
|
||||
* - kein Pfad-Exit aus dem Zielverzeichnis (Zip-Slip)
|
||||
* - keine Ausführung von Paket-Skripten (npm install --ignore-scripts)
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModuleInstaller {
|
||||
private readonly logger = new Logger('ModuleInstaller');
|
||||
|
||||
/** Validiert ein hochgeladenes Paket und gibt das Manifest zurück. */
|
||||
async validatePackage(buffer: Buffer): Promise<ModuleManifest> {
|
||||
if (buffer.length === 0) {
|
||||
throw new BadRequestException('Paket ist leer');
|
||||
}
|
||||
if (buffer.length > MAX_PACKAGE_SIZE_BYTES) {
|
||||
throw new BadRequestException('Paket ist zu groß (maximal 10 MB)');
|
||||
}
|
||||
|
||||
const AdmZip = (await import('adm-zip')).default;
|
||||
const zip = new AdmZip(buffer);
|
||||
|
||||
const manifestEntry = zip.getEntry(REQUIRED_MANIFEST_FILE);
|
||||
if (!manifestEntry) {
|
||||
throw new BadRequestException(`Paket enthält keine ${REQUIRED_MANIFEST_FILE}`);
|
||||
}
|
||||
|
||||
let manifestJson: unknown;
|
||||
try {
|
||||
manifestJson = JSON.parse(manifestEntry.getData().toString('utf8'));
|
||||
} catch {
|
||||
throw new BadRequestException(`${REQUIRED_MANIFEST_FILE} ist kein gültiges JSON`);
|
||||
}
|
||||
|
||||
const result = moduleManifestSchema.safeParse(manifestJson);
|
||||
if (!result.success) {
|
||||
const details: Record<string, string[]> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path.join('.') || 'form';
|
||||
if (!details[key]) {
|
||||
details[key] = [issue.message];
|
||||
}
|
||||
}
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
message: 'Manifest-Validierung fehlgeschlagen',
|
||||
error: 'Bad Request',
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installiert ein validiertes Paket in das Modulverzeichnis.
|
||||
* @returns Installationsverzeichnis und Manifest.
|
||||
*/
|
||||
async install(
|
||||
buffer: Buffer,
|
||||
manifest: ModuleManifest,
|
||||
modulesDir: string,
|
||||
): Promise<{ directory: string; manifest: ModuleManifest }> {
|
||||
const directory = path.join(modulesDir, manifest.id);
|
||||
|
||||
// Zip-Slip-Schutz: Alle Einträge müssen innerhalb des Zielverzeichnisses liegen.
|
||||
const AdmZip = (await import('adm-zip')).default;
|
||||
const zip = new AdmZip(buffer);
|
||||
const resolvedDirectory = path.resolve(directory);
|
||||
|
||||
for (const entry of zip.getEntries()) {
|
||||
const entryName = entry.entryName;
|
||||
if (entryName.startsWith('/') || entryName.includes('..') || /^[A-Za-z]:/.test(entryName)) {
|
||||
throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
|
||||
}
|
||||
const resolvedEntry = path.resolve(resolvedDirectory, entryName);
|
||||
if (!resolvedEntry.startsWith(resolvedDirectory + path.sep)) {
|
||||
throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Bestehende Installation entfernen (Update-Szenario).
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await mkdir(directory, { recursive: true });
|
||||
|
||||
zip.extractAllTo(resolvedDirectory, true);
|
||||
|
||||
// Paket-Metadaten für spätere Diagnose speichern.
|
||||
await writeFile(
|
||||
path.join(directory, '.installed.json'),
|
||||
JSON.stringify({ installedAt: new Date().toISOString(), manifest }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
this.logger.log(`Modul "${manifest.id}" installiert in ${directory}`);
|
||||
return { directory, manifest };
|
||||
}
|
||||
|
||||
/** Entfernt eine Modul-Installation vom Dateisystem. */
|
||||
async remove(modulesDir: string, moduleId: string): Promise<void> {
|
||||
const directory = path.join(modulesDir, moduleId);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Liest das Manifest einer bestehenden Installation. */
|
||||
async readInstalledManifest(directory: string): Promise<ModuleManifest | null> {
|
||||
try {
|
||||
const raw = await readFile(path.join(directory, REQUIRED_MANIFEST_FILE), 'utf8');
|
||||
const result = moduleManifestSchema.safeParse(JSON.parse(raw));
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
apps/platform-backend/src/modules/module-process-manager.ts
Normal file
110
apps/platform-backend/src/modules/module-process-manager.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { mkdir, open } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import type { ModuleRecord } from './manifest.types';
|
||||
|
||||
/** Laufende Modul-Prozesse im Speicher (nicht persistent). */
|
||||
interface RunningProcess {
|
||||
readonly child: ChildProcess;
|
||||
readonly logFilePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modul-Prozess-Manager (Infrastructure):
|
||||
* Startet, stoppt und überwacht Modul-Prozesse innerhalb des
|
||||
* Management-Containers. Kein Docker-in-Docker – Module laufen als
|
||||
* Kindprozesse des Backends mit eigenen internen Ports.
|
||||
*
|
||||
* Sicherheitsprinzipien:
|
||||
* - Prozesse laufen ohne Shell (keine Command-Injection)
|
||||
* - Umgebungsvariablen minimal gehalten (keine Plattform-Secrets)
|
||||
* - Logs pro Modul in eigene Dateien
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModuleProcessManager implements OnModuleDestroy {
|
||||
private readonly logger = new Logger('ModuleProcesses');
|
||||
private readonly running = new Map<string, RunningProcess>();
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
||||
|
||||
/** Startet einen Modul-Prozess. */
|
||||
async start(module: ModuleRecord): Promise<void> {
|
||||
if (this.running.has(module.moduleId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entrypoint = path.join(module.path, 'backend', 'server.js');
|
||||
const logFilePath = path.join(this.config.runtime.logsDir, `module-${module.moduleId}.log`);
|
||||
await mkdir(this.config.runtime.logsDir, { recursive: true });
|
||||
const logFile = await open(logFilePath, 'a');
|
||||
|
||||
const child = spawn(process.execPath, [entrypoint], {
|
||||
cwd: module.path,
|
||||
// Log-Datei bleibt offen: Der fd wird vom Kindprozess geerbt und
|
||||
// darf erst nach Prozessende geschlossen werden.
|
||||
stdio: ['ignore', logFile.fd, logFile.fd],
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '',
|
||||
NODE_ENV: this.config.nodeEnv,
|
||||
PORT: String(module.internalPort),
|
||||
// Modul erhält nur seinen eigenen Kontext – keine Plattform-Secrets.
|
||||
},
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
child.on('exit', (code) => {
|
||||
this.logger.warn(`Modul-Prozess "${module.moduleId}" beendet (Code ${code ?? 'signal'})`);
|
||||
this.running.delete(module.moduleId);
|
||||
});
|
||||
|
||||
this.running.set(module.moduleId, { child, logFilePath });
|
||||
this.logger.log(`Modul-Prozess "${module.moduleId}" gestartet (Port ${module.internalPort})`);
|
||||
}
|
||||
|
||||
/** Stoppt einen Modul-Prozess (SIGTERM, dann SIGKILL). */
|
||||
async stop(moduleId: string): Promise<void> {
|
||||
const process_ = this.running.get(moduleId);
|
||||
if (!process_) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running.delete(moduleId);
|
||||
const child = process_.child;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 5_000);
|
||||
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
|
||||
this.logger.log(`Modul-Prozess "${moduleId}" gestoppt`);
|
||||
}
|
||||
|
||||
/** Prüft, ob ein Modul-Prozess läuft. */
|
||||
isRunning(moduleId: string): boolean {
|
||||
return this.running.has(moduleId);
|
||||
}
|
||||
|
||||
/** Stoppt alle Modul-Prozesse (Herunterfahren). */
|
||||
async stopAll(): Promise<void> {
|
||||
const moduleIds = [...this.running.keys()];
|
||||
await Promise.all(moduleIds.map((moduleId) => this.stop(moduleId)));
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.stopAll();
|
||||
}
|
||||
}
|
||||
137
apps/platform-backend/src/modules/module.repository.ts
Normal file
137
apps/platform-backend/src/modules/module.repository.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types';
|
||||
|
||||
interface ModuleRow {
|
||||
id: string;
|
||||
module_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
path: string;
|
||||
status: ModuleStatus;
|
||||
internal_port: number;
|
||||
healthcheck_url: string;
|
||||
enabled: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path,
|
||||
status, internal_port, healthcheck_url, enabled, created_at, updated_at`;
|
||||
|
||||
/**
|
||||
* Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry.
|
||||
* Keine Business-Logik – nur parametrisierten Datenzugriff.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModuleRepository {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async list(): Promise<ModuleRecord[]> {
|
||||
const result = await this.database.query<ModuleRow>(
|
||||
`SELECT ${MODULE_COLUMNS} FROM modules ORDER BY created_at ASC`,
|
||||
);
|
||||
return result.rows.map((row) => this.mapRow(row));
|
||||
}
|
||||
|
||||
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
|
||||
const result = await this.database.query<ModuleRow>(
|
||||
`SELECT ${MODULE_COLUMNS} FROM modules WHERE module_id = $1`,
|
||||
[moduleId],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<ModuleRecord | null> {
|
||||
const result = await this.database.query<ModuleRow>(
|
||||
`SELECT ${MODULE_COLUMNS} FROM modules WHERE slug = $1`,
|
||||
[slug],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByPort(port: number): Promise<ModuleRecord | null> {
|
||||
const result = await this.database.query<ModuleRow>(
|
||||
`SELECT ${MODULE_COLUMNS} FROM modules WHERE internal_port = $1`,
|
||||
[port],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ModuleRecord | null> {
|
||||
const result = await this.database.query<ModuleRow>(
|
||||
`SELECT ${MODULE_COLUMNS} FROM modules WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0] ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByStatus(statuses: readonly ModuleStatus[]): Promise<ModuleRecord[]> {
|
||||
const result = await this.database.query<ModuleRow>(
|
||||
`SELECT ${MODULE_COLUMNS} FROM modules WHERE status = ANY($1::text[])`,
|
||||
[statuses],
|
||||
);
|
||||
return result.rows.map((row) => this.mapRow(row));
|
||||
}
|
||||
|
||||
async create(manifest: ModuleManifest, directory: string): Promise<ModuleRecord> {
|
||||
const result = await this.database.query<ModuleRow>(
|
||||
`INSERT INTO modules
|
||||
(module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9)
|
||||
RETURNING ${MODULE_COLUMNS}`,
|
||||
[
|
||||
manifest.id,
|
||||
manifest.name,
|
||||
manifest.slug,
|
||||
manifest.version,
|
||||
manifest.description,
|
||||
manifest.author,
|
||||
directory,
|
||||
manifest.port,
|
||||
manifest.healthcheck,
|
||||
],
|
||||
);
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: ModuleStatus): Promise<void> {
|
||||
await this.database.query(
|
||||
'UPDATE modules SET status = $2, updated_at = now() WHERE id = $1',
|
||||
[id, status],
|
||||
);
|
||||
}
|
||||
|
||||
async updateEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
await this.database.query(
|
||||
'UPDATE modules SET enabled = $2, updated_at = now() WHERE id = $1',
|
||||
[id, enabled],
|
||||
);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.database.query('DELETE FROM modules WHERE id = $1', [id]);
|
||||
}
|
||||
|
||||
private mapRow(row: ModuleRow): ModuleRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
moduleId: row.module_id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
version: row.version,
|
||||
description: row.description,
|
||||
author: row.author,
|
||||
path: row.path,
|
||||
status: row.status,
|
||||
internalPort: row.internal_port,
|
||||
healthcheckUrl: row.healthcheck_url,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
154
apps/platform-backend/src/modules/modules.controller.ts
Normal file
154
apps/platform-backend/src/modules/modules.controller.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request } from 'express';
|
||||
import type { Multer } from 'multer';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
/** Hochgeladene Datei (Multer). */
|
||||
type UploadedPackageFile = Multer & { buffer: Buffer };
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import type { AuthUser } from '../users/user.types';
|
||||
import type { ModuleRecord, ModuleStatus } from './manifest.types';
|
||||
import { ModulesService } from './modules.service';
|
||||
|
||||
/** Modul-Daten in API-Antworten. */
|
||||
interface ModuleResponse {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
status: ModuleStatus;
|
||||
internalPort: number;
|
||||
healthcheckUrl: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function toModuleResponse(module: ModuleRecord): ModuleResponse {
|
||||
return {
|
||||
id: module.id,
|
||||
moduleId: module.moduleId,
|
||||
name: module.name,
|
||||
slug: module.slug,
|
||||
version: module.version,
|
||||
description: module.description,
|
||||
author: module.author,
|
||||
status: module.status,
|
||||
internalPort: module.internalPort,
|
||||
healthcheckUrl: module.healthcheckUrl,
|
||||
enabled: module.enabled,
|
||||
createdAt: module.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Modul-Verwaltung (nur Administratoren): Installation, Lifecycle,
|
||||
* Healthchecks und Entfernen von Modulen.
|
||||
*/
|
||||
@ApiTags('Modules')
|
||||
@Roles('ADMIN')
|
||||
@Controller({ path: 'api/v1/modules' })
|
||||
export class ModulesController {
|
||||
constructor(private readonly modulesService: ModulesService) {}
|
||||
|
||||
@Get()
|
||||
async list(): Promise<{ modules: ModuleResponse[] }> {
|
||||
const modules = await this.modulesService.list();
|
||||
return { modules: modules.map(toModuleResponse) };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getById(@Param('id', ParseUUIDPipe) id: string): Promise<{ module: ModuleResponse }> {
|
||||
const module = await this.modulesService.getById(id);
|
||||
return { module: toModuleResponse(module) };
|
||||
}
|
||||
|
||||
@Post('install')
|
||||
@UseInterceptors(FileInterceptor('package'))
|
||||
async install(
|
||||
@UploadedFile() file: UploadedPackageFile | undefined,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ module: ModuleResponse }> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Paketdatei hochgeladen (Feld "package")');
|
||||
}
|
||||
const module = await this.modulesService.install(file.buffer, actor, request.ip ?? null);
|
||||
return { module: toModuleResponse(module) };
|
||||
}
|
||||
|
||||
@Post(':id/start')
|
||||
async start(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ module: ModuleResponse }> {
|
||||
const module = await this.modulesService.start(id, actor, request.ip ?? null);
|
||||
return { module: toModuleResponse(module) };
|
||||
}
|
||||
|
||||
@Post(':id/stop')
|
||||
async stop(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ module: ModuleResponse }> {
|
||||
const module = await this.modulesService.stop(id, actor, request.ip ?? null);
|
||||
return { module: toModuleResponse(module) };
|
||||
}
|
||||
|
||||
@Post(':id/restart')
|
||||
async restart(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ module: ModuleResponse }> {
|
||||
const module = await this.modulesService.restart(id, actor, request.ip ?? null);
|
||||
return { module: toModuleResponse(module) };
|
||||
}
|
||||
|
||||
@Patch(':id/enabled')
|
||||
async setEnabled(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { enabled: boolean },
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ module: ModuleResponse }> {
|
||||
const module = await this.modulesService.setEnabled(id, body.enabled, actor, request.ip ?? null);
|
||||
return { module: toModuleResponse(module) };
|
||||
}
|
||||
|
||||
@Get(':id/health')
|
||||
async checkHealth(@Param('id', ParseUUIDPipe) id: string): Promise<{ healthy: boolean; latencyMs: number; detail: string }> {
|
||||
const { health } = await this.modulesService.checkHealth(id);
|
||||
return { healthy: health.healthy, latencyMs: health.latencyMs, detail: health.detail };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Req() request: AuthenticatedRequest & Request,
|
||||
): Promise<{ success: true }> {
|
||||
await this.modulesService.remove(id, actor, request.ip ?? null);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
19
apps/platform-backend/src/modules/modules.module.ts
Normal file
19
apps/platform-backend/src/modules/modules.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { ModuleHealthChecker } from './module-health-checker';
|
||||
import { ModuleInstaller } from './module-installer';
|
||||
import { ModuleProcessManager } from './module-process-manager';
|
||||
import { ModuleRepository } from './module.repository';
|
||||
import { ModulesController } from './modules.controller';
|
||||
import { ModulesService } from './modules.service';
|
||||
|
||||
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Healthchecks. */
|
||||
@Module({
|
||||
imports: [ConfigModule, DatabaseModule, AuditModule],
|
||||
controllers: [ModulesController],
|
||||
providers: [ModuleRepository, ModuleInstaller, ModuleProcessManager, ModuleHealthChecker, ModulesService],
|
||||
exports: [ModuleRepository, ModulesService],
|
||||
})
|
||||
export class ModulesModule {}
|
||||
333
apps/platform-backend/src/modules/modules.service.spec.ts
Normal file
333
apps/platform-backend/src/modules/modules.service.spec.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service';
|
||||
import type { AppConfig } from '../config/config.tokens';
|
||||
import type { ActingUser } from '../users/users.service';
|
||||
import { ModuleHealthChecker } from './module-health-checker';
|
||||
import { ModuleInstaller } from './module-installer';
|
||||
import { ModuleProcessManager } from './module-process-manager';
|
||||
import { ModuleRepository } from './module.repository';
|
||||
import type { ModuleManifest, ModuleRecord } from './manifest.types';
|
||||
import { ModulesService } from './modules.service';
|
||||
|
||||
/** Gültiges Test-Manifest. */
|
||||
function createManifest(overrides: Partial<ModuleManifest> = {}): ModuleManifest {
|
||||
return {
|
||||
id: 'calendar',
|
||||
name: 'Kalender',
|
||||
version: '1.0.0',
|
||||
slug: 'kalender-tool',
|
||||
description: 'Kalenderverwaltung',
|
||||
author: 'MPM',
|
||||
runtime: 'node',
|
||||
entrypoint: 'server.js',
|
||||
port: 41001,
|
||||
healthcheck: '/health',
|
||||
apiVersion: 'v1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Erzeugt einen Modul-Datensatz für Tests. */
|
||||
function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord {
|
||||
const manifest = createManifest();
|
||||
return {
|
||||
id: 'module-1',
|
||||
moduleId: manifest.id,
|
||||
name: manifest.name,
|
||||
slug: manifest.slug,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
path: '/data/modules/calendar',
|
||||
status: 'STOPPED',
|
||||
internalPort: manifest.port,
|
||||
healthcheckUrl: manifest.healthcheck,
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' };
|
||||
|
||||
/** Mock des ModuleRepository. */
|
||||
class MockModuleRepository {
|
||||
public modules: ModuleRecord[] = [];
|
||||
public statusUpdates: Array<{ id: string; status: ModuleRecord['status'] }> = [];
|
||||
public enabledUpdates: Array<{ id: string; enabled: boolean }> = [];
|
||||
public deletedIds: string[] = [];
|
||||
|
||||
async list(): Promise<ModuleRecord[]> {
|
||||
return this.modules;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ModuleRecord | null> {
|
||||
return this.modules.find((module) => module.id === id) ?? null;
|
||||
}
|
||||
|
||||
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
|
||||
return this.modules.find((module) => module.moduleId === moduleId) ?? null;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<ModuleRecord | null> {
|
||||
return this.modules.find((module) => module.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
async findByPort(port: number): Promise<ModuleRecord | null> {
|
||||
return this.modules.find((module) => module.internalPort === port) ?? null;
|
||||
}
|
||||
|
||||
async create(manifest: ModuleManifest, directory: string): Promise<ModuleRecord> {
|
||||
const module = createModuleRecord({
|
||||
id: 'new-module',
|
||||
moduleId: manifest.id,
|
||||
name: manifest.name,
|
||||
slug: manifest.slug,
|
||||
version: manifest.version,
|
||||
path: directory,
|
||||
internalPort: manifest.port,
|
||||
healthcheckUrl: manifest.healthcheck,
|
||||
});
|
||||
this.modules.push(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: ModuleRecord['status']): Promise<void> {
|
||||
this.statusUpdates.push({ id, status });
|
||||
const module = this.modules.find((m) => m.id === id);
|
||||
if (module) {
|
||||
(module as { status: ModuleRecord['status'] }).status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async updateEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
this.enabledUpdates.push({ id, enabled });
|
||||
const module = this.modules.find((m) => m.id === id);
|
||||
if (module) {
|
||||
(module as { enabled: boolean }).enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.deletedIds.push(id);
|
||||
this.modules = this.modules.filter((m) => m.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des ModuleInstaller. */
|
||||
class MockModuleInstaller {
|
||||
public validateResult: ModuleManifest = createManifest();
|
||||
public installed: Array<{ moduleId: string }> = [];
|
||||
public removed: string[] = [];
|
||||
|
||||
async validatePackage(): Promise<ModuleManifest> {
|
||||
return this.validateResult;
|
||||
}
|
||||
|
||||
async install(
|
||||
_buffer: Buffer,
|
||||
manifest: ModuleManifest,
|
||||
_modulesDir: string,
|
||||
): Promise<{ directory: string; manifest: ModuleManifest }> {
|
||||
this.installed.push({ moduleId: manifest.id });
|
||||
return { directory: `/data/modules/${manifest.id}`, manifest };
|
||||
}
|
||||
|
||||
async remove(_modulesDir: string, moduleId: string): Promise<void> {
|
||||
this.removed.push(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des ModuleProcessManager. */
|
||||
class MockProcessManager {
|
||||
public started: string[] = [];
|
||||
public stopped: string[] = [];
|
||||
|
||||
async start(module: ModuleRecord): Promise<void> {
|
||||
this.started.push(module.moduleId);
|
||||
}
|
||||
|
||||
async stop(moduleId: string): Promise<void> {
|
||||
this.stopped.push(moduleId);
|
||||
}
|
||||
|
||||
isRunning(moduleId: string): boolean {
|
||||
return this.started.includes(moduleId) && !this.stopped.includes(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des ModuleHealthChecker. */
|
||||
class MockHealthChecker {
|
||||
public healthy = true;
|
||||
|
||||
async check(): Promise<{ healthy: boolean; latencyMs: number; detail: string }> {
|
||||
return { healthy: this.healthy, latencyMs: 5, detail: this.healthy ? 'healthy' : 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock des AuditService. */
|
||||
class MockAuditService {
|
||||
public records: Array<{ action: string }> = [];
|
||||
|
||||
async record(entry: { action: string }): Promise<void> {
|
||||
this.records.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-Konfiguration. */
|
||||
const TEST_CONFIG: AppConfig = {
|
||||
nodeEnv: 'test',
|
||||
port: 3000,
|
||||
database: { url: 'postgresql://test' },
|
||||
security: {
|
||||
sessionTtlMinutes: 120,
|
||||
cookieSecure: false,
|
||||
behindProxy: false,
|
||||
loginMaxAttempts: 5,
|
||||
loginLockoutMinutes: 15,
|
||||
loginRateLimitAttempts: 10,
|
||||
loginRateLimitWindowMinutes: 5,
|
||||
},
|
||||
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
|
||||
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
|
||||
};
|
||||
|
||||
describe('ModulesService', () => {
|
||||
let repository: MockModuleRepository;
|
||||
let installer: MockModuleInstaller;
|
||||
let processManager: MockProcessManager;
|
||||
let healthChecker: MockHealthChecker;
|
||||
let auditService: MockAuditService;
|
||||
let modulesService: ModulesService;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new MockModuleRepository();
|
||||
installer = new MockModuleInstaller();
|
||||
processManager = new MockProcessManager();
|
||||
healthChecker = new MockHealthChecker();
|
||||
auditService = new MockAuditService();
|
||||
modulesService = new ModulesService(
|
||||
repository as unknown as ModuleRepository,
|
||||
installer as unknown as ModuleInstaller,
|
||||
processManager as unknown as ModuleProcessManager,
|
||||
healthChecker as unknown as ModuleHealthChecker,
|
||||
auditService as unknown as AuditService,
|
||||
TEST_CONFIG,
|
||||
);
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
it('installiert ein neues Modul und schreibt Audit', async () => {
|
||||
const module = await modulesService.install(Buffer.from('zip'), ACTOR, null);
|
||||
expect(module.moduleId).toBe('calendar');
|
||||
expect(installer.installed).toEqual([{ moduleId: 'calendar' }]);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_INSTALLED);
|
||||
});
|
||||
|
||||
it('lehnt doppelte Modul-IDs ab', async () => {
|
||||
repository.modules = [createModuleRecord()];
|
||||
await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('lehnt doppelte Slugs ab', async () => {
|
||||
repository.modules = [createModuleRecord({ moduleId: 'anderes', slug: 'kalender-tool' })];
|
||||
await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('lehnt belegte Ports ab', async () => {
|
||||
repository.modules = [createModuleRecord({ moduleId: 'anderes', slug: 'anderer-slug' })];
|
||||
await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('startet ein gestopptes Modul (STARTING → RUNNING)', async () => {
|
||||
repository.modules = [createModuleRecord({ status: 'STOPPED' })];
|
||||
const module = await modulesService.start('module-1', ACTOR, null);
|
||||
expect(module.status).toBe('RUNNING');
|
||||
expect(processManager.started).toEqual(['calendar']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STARTED);
|
||||
});
|
||||
|
||||
it('setzt ERROR bei fehlgeschlagenem Healthcheck', async () => {
|
||||
repository.modules = [createModuleRecord({ status: 'STOPPED' })];
|
||||
healthChecker.healthy = false;
|
||||
await expect(modulesService.start('module-1', ACTOR, null)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(repository.statusUpdates).toContainEqual({ id: 'module-1', status: 'ERROR' });
|
||||
expect(processManager.stopped).toEqual(['calendar']);
|
||||
});
|
||||
|
||||
it('lehnt Start deaktivierter Module ab', async () => {
|
||||
repository.modules = [createModuleRecord({ enabled: false })];
|
||||
await expect(modulesService.start('module-1', ACTOR, null)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('wirft NotFoundException bei unbekannter ID', async () => {
|
||||
await expect(modulesService.start('unbekannt', ACTOR, null)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('stoppt ein laufendes Modul (STOPPING → STOPPED)', async () => {
|
||||
repository.modules = [createModuleRecord({ status: 'RUNNING' })];
|
||||
const module = await modulesService.stop('module-1', ACTOR, null);
|
||||
expect(module.status).toBe('STOPPED');
|
||||
expect(processManager.stopped).toEqual(['calendar']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STOPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
it('stoppt und startet ein laufendes Modul', async () => {
|
||||
repository.modules = [createModuleRecord({ status: 'RUNNING' })];
|
||||
const module = await modulesService.restart('module-1', ACTOR, null);
|
||||
expect(module.status).toBe('RUNNING');
|
||||
expect(processManager.stopped).toEqual(['calendar']);
|
||||
expect(processManager.started).toEqual(['calendar']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STARTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('deaktiviert ein laufendes Modul (stoppt es zuerst)', async () => {
|
||||
repository.modules = [createModuleRecord({ status: 'RUNNING' })];
|
||||
const module = await modulesService.setEnabled('module-1', false, ACTOR, null);
|
||||
expect(module.enabled).toBe(false);
|
||||
expect(module.status).toBe('DISABLED');
|
||||
expect(processManager.stopped).toEqual(['calendar']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_DISABLED);
|
||||
});
|
||||
|
||||
it('aktiviert ein deaktiviertes Modul', async () => {
|
||||
repository.modules = [createModuleRecord({ enabled: false, status: 'DISABLED' })];
|
||||
const module = await modulesService.setEnabled('module-1', true, ACTOR, null);
|
||||
expect(module.enabled).toBe(true);
|
||||
expect(module.status).toBe('STOPPED');
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_ENABLED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('stoppt, löscht Registry-Eintrag und Dateien', async () => {
|
||||
repository.modules = [createModuleRecord({ status: 'RUNNING' })];
|
||||
await modulesService.remove('module-1', ACTOR, null);
|
||||
expect(processManager.stopped).toEqual(['calendar']);
|
||||
expect(repository.deletedIds).toEqual(['module-1']);
|
||||
expect(installer.removed).toEqual(['calendar']);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_REMOVED);
|
||||
});
|
||||
});
|
||||
});
|
||||
252
apps/platform-backend/src/modules/modules.service.ts
Normal file
252
apps/platform-backend/src/modules/modules.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
|
||||
import { AUDIT_ACTIONS, AuditService, type AuditAction } from '../audit/audit.service';
|
||||
import type { ActingUser } from '../users/users.service';
|
||||
import { ModuleHealthChecker } from './module-health-checker';
|
||||
import { ModuleInstaller } from './module-installer';
|
||||
import { ModuleProcessManager } from './module-process-manager';
|
||||
import { ModuleRepository } from './module.repository';
|
||||
import type { ModuleRecord } from './manifest.types';
|
||||
|
||||
/**
|
||||
* Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module.
|
||||
*
|
||||
* Zustände: INSTALLED → STARTING → RUNNING → STOPPING → STOPPED
|
||||
* (sowie ERROR und DISABLED)
|
||||
*
|
||||
* Grundsätze:
|
||||
* - Die Plattform hängt nie von einem Modul ab (Fehler werden isoliert)
|
||||
* - Jede Lifecycle-Aktion wird auditiert
|
||||
* - Ports und Slugs sind eindeutig
|
||||
*/
|
||||
@Injectable()
|
||||
export class ModulesService {
|
||||
constructor(
|
||||
private readonly moduleRepository: ModuleRepository,
|
||||
private readonly installer: ModuleInstaller,
|
||||
private readonly processManager: ModuleProcessManager,
|
||||
private readonly healthChecker: ModuleHealthChecker,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async list(): Promise<ModuleRecord[]> {
|
||||
return this.moduleRepository.list();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<ModuleRecord> {
|
||||
const module = await this.moduleRepository.findById(id);
|
||||
if (!module) {
|
||||
throw new NotFoundException('Modul nicht gefunden');
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
/** Installiert ein Modul-Paket (ZIP) und registriert es. */
|
||||
async install(
|
||||
packageBuffer: Buffer,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<ModuleRecord> {
|
||||
const manifest = await this.installer.validatePackage(packageBuffer);
|
||||
|
||||
const [existingId, existingSlug, existingPort] = await Promise.all([
|
||||
this.moduleRepository.findByModuleId(manifest.id),
|
||||
this.moduleRepository.findBySlug(manifest.slug),
|
||||
this.moduleRepository.findByPort(manifest.port),
|
||||
]);
|
||||
if (existingId) {
|
||||
throw new ConflictException(`Modul-ID "${manifest.id}" ist bereits installiert`);
|
||||
}
|
||||
if (existingSlug) {
|
||||
throw new ConflictException(`Slug "${manifest.slug}" ist bereits vergeben`);
|
||||
}
|
||||
if (existingPort) {
|
||||
throw new ConflictException(`Port ${manifest.port} ist bereits belegt`);
|
||||
}
|
||||
|
||||
const { directory } = await this.installer.install(
|
||||
packageBuffer,
|
||||
manifest,
|
||||
this.config.runtime.modulesDir,
|
||||
);
|
||||
const module = await this.moduleRepository.create(manifest, directory);
|
||||
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.MODULE_INSTALLED,
|
||||
details: { moduleId: manifest.id, version: manifest.version, slug: manifest.slug },
|
||||
ipAddress,
|
||||
});
|
||||
return module;
|
||||
}
|
||||
|
||||
/** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */
|
||||
async start(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
|
||||
const module = await this.getById(id);
|
||||
this.assertEnabled(module);
|
||||
if (module.status === 'RUNNING' || module.status === 'STARTING') {
|
||||
return module;
|
||||
}
|
||||
|
||||
await this.moduleRepository.updateStatus(id, 'STARTING');
|
||||
try {
|
||||
await this.processManager.start(module);
|
||||
// Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten.
|
||||
// Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt.
|
||||
const health = await this.waitForHealthy(module);
|
||||
if (!health.healthy) {
|
||||
throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`);
|
||||
}
|
||||
await this.moduleRepository.updateStatus(id, 'RUNNING');
|
||||
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STARTED, actor, ipAddress);
|
||||
} catch (error) {
|
||||
await this.moduleRepository.updateStatus(id, 'ERROR');
|
||||
await this.processManager.stop(module.moduleId);
|
||||
throw new BadRequestException(
|
||||
`Modul konnte nicht gestartet werden: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
return (await this.moduleRepository.findById(id)) ?? module;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet bis zum ersten erfolgreichen Healthcheck (max. 10 Versuche
|
||||
* mit 500 ms Abstand). Ein Modul darf beim Start nicht zu früh
|
||||
* als fehlerhaft gelten.
|
||||
*/
|
||||
private async waitForHealthy(
|
||||
module: ModuleRecord,
|
||||
): Promise<{ healthy: boolean; latencyMs: number; detail: string }> {
|
||||
const maxAttempts = 10;
|
||||
const delayMs = 500;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const health = await this.healthChecker.check(module);
|
||||
if (health.healthy) {
|
||||
return health;
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
return this.healthChecker.check(module);
|
||||
}
|
||||
|
||||
/** Stoppt ein Modul (RUNNING → STOPPING → STOPPED). */
|
||||
async stop(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
|
||||
const module = await this.getById(id);
|
||||
if (module.status === 'STOPPED' || module.status === 'STOPPING') {
|
||||
return module;
|
||||
}
|
||||
|
||||
await this.moduleRepository.updateStatus(id, 'STOPPING');
|
||||
try {
|
||||
await this.processManager.stop(module.moduleId);
|
||||
await this.moduleRepository.updateStatus(id, 'STOPPED');
|
||||
await this.auditLifecycle(module, AUDIT_ACTIONS.MODULE_STOPPED, actor, ipAddress);
|
||||
} catch (error) {
|
||||
await this.moduleRepository.updateStatus(id, 'ERROR');
|
||||
throw new BadRequestException(
|
||||
`Modul konnte nicht gestoppt werden: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
return (await this.moduleRepository.findById(id)) ?? module;
|
||||
}
|
||||
|
||||
/** Startet ein Modul neu (Stop + Start). */
|
||||
async restart(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
|
||||
const module = await this.getById(id);
|
||||
if (module.status === 'RUNNING' || module.status === 'STARTING') {
|
||||
await this.stop(id, actor, ipAddress);
|
||||
}
|
||||
return this.start(id, actor, ipAddress);
|
||||
}
|
||||
|
||||
/** Aktiviert oder deaktiviert ein Modul (DISABLED-Zustand). */
|
||||
async setEnabled(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<ModuleRecord> {
|
||||
const module = await this.getById(id);
|
||||
if (module.enabled === enabled) {
|
||||
return module;
|
||||
}
|
||||
|
||||
if (!enabled && (module.status === 'RUNNING' || module.status === 'STARTING')) {
|
||||
await this.stop(id, actor, ipAddress);
|
||||
}
|
||||
|
||||
await this.moduleRepository.updateEnabled(id, enabled);
|
||||
await this.moduleRepository.updateStatus(
|
||||
id,
|
||||
enabled ? 'STOPPED' : 'DISABLED',
|
||||
);
|
||||
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: enabled ? AUDIT_ACTIONS.MODULE_ENABLED : AUDIT_ACTIONS.MODULE_DISABLED,
|
||||
details: { moduleId: module.moduleId },
|
||||
ipAddress,
|
||||
});
|
||||
return (await this.moduleRepository.findById(id)) ?? module;
|
||||
}
|
||||
|
||||
/** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */
|
||||
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> {
|
||||
const module = await this.getById(id);
|
||||
|
||||
if (module.status === 'RUNNING' || module.status === 'STARTING') {
|
||||
await this.stop(id, actor, ipAddress);
|
||||
}
|
||||
|
||||
await this.moduleRepository.delete(id);
|
||||
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId);
|
||||
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action: AUDIT_ACTIONS.MODULE_REMOVED,
|
||||
details: { moduleId: module.moduleId },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/** Führt einen Healthcheck für ein Modul aus (ohne Statusänderung). */
|
||||
async checkHealth(id: string): Promise<{ module: ModuleRecord; health: Awaited<ReturnType<ModuleHealthChecker['check']>> }> {
|
||||
const module = await this.getById(id);
|
||||
const health = await this.healthChecker.check(module);
|
||||
return { module, health };
|
||||
}
|
||||
|
||||
private assertEnabled(module: ModuleRecord): void {
|
||||
if (!module.enabled) {
|
||||
throw new BadRequestException('Modul ist deaktiviert');
|
||||
}
|
||||
}
|
||||
|
||||
private async auditLifecycle(
|
||||
module: ModuleRecord,
|
||||
action: AuditAction,
|
||||
actor: ActingUser,
|
||||
ipAddress: string | null,
|
||||
): Promise<void> {
|
||||
await this.auditService.record({
|
||||
userId: actor.id,
|
||||
username: actor.username,
|
||||
action,
|
||||
details: { moduleId: module.moduleId, version: module.version },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user