feat: Phase 3 – Modul-System (Manifest, ZIP-Installation, Lifecycle, Prozess-Manager)
This commit is contained in:
@@ -37,8 +37,8 @@ RUN apt-get update \
|
||||
# Unprivilegierter Benutzer für alle Prozesse im Container
|
||||
RUN groupadd --gid 1001 app \
|
||||
&& useradd --uid 1001 --gid 1001 --create-home --shell /usr/sbin/nologin app \
|
||||
&& mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor \
|
||||
&& chown -R app:app /tmp/nginx /var/log/supervisor
|
||||
&& mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi /var/log/supervisor /app/data/modules /app/data/logs \
|
||||
&& chown -R app:app /tmp/nginx /var/log/supervisor /app/data
|
||||
|
||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Zentrale, webbasierte Management-Plattform, über die eigenständige Web-Applikationen als **Module** integriert, verwaltet und Benutzern zugewiesen werden können.
|
||||
|
||||
**Status: Phase 2 – Benutzerverwaltung (abgeschlossen)**
|
||||
**Status: Phase 3 – Modul-System (abgeschlossen)**
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
@@ -97,6 +97,9 @@ Login/Logout mit serverseitigen Sessions, Rollen (ADMIN/USER), Health-Monitoring
|
||||
### Phase 2 – Benutzerverwaltung
|
||||
Vollständige Benutzer-CRUD-API (nur Admin) mit Duplikat-Schutz, Schutz des letzten Admins, sofortiger Session-Sperrung bei Deaktivierung, Passwort-Reset, eigenes Passwort ändern, Benutzerverwaltungs-UI (Tabelle, Modals, Toasts) und Profil-Seite.
|
||||
|
||||
### Phase 3 – Modul-System
|
||||
Modul-Registry mit Manifest-Vertrag (`module.json`, Zod-validiert), ZIP-Installation mit Zip-Slip-Schutz, Prozess-Manager (Kindprozesse mit minimaler ENV, eigene Logs), Lifecycle (INSTALLED/STARTING/RUNNING/STOPPED/ERROR/DISABLED), Healthchecks mit Startup-Grace, Modulverwaltungs-UI und persistente Volumes für Modul-Dateien.
|
||||
|
||||
## Annahme
|
||||
|
||||
„ChatCM" wurde als **shadcn-artige Komponentenbasis** interpretiert: Tailwind CSS plus zentral gepflegte, wiederverwendbare UI-Komponenten (`apps/platform-frontend/src/components/ui`).
|
||||
32
apps/platform-backend/package-lock.json
generated
32
apps/platform-backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"@node-rs/argon2": "^2.0.0",
|
||||
"adm-zip": "^0.6.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
@@ -25,9 +26,11 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@@ -2445,6 +2448,16 @@
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/adm-zip": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
|
||||
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -2651,6 +2664,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
|
||||
@@ -3262,6 +3285,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",
|
||||
"integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"@node-rs/argon2": "^2.0.0",
|
||||
"adm-zip": "^0.6.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
@@ -31,9 +32,11 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@@ -45,4 +48,4 @@
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
||||
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
|
||||
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
||||
{ to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true },
|
||||
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
352
apps/platform-frontend/src/features/admin/modules-page.tsx
Normal file
352
apps/platform-frontend/src/features/admin/modules-page.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { type ReactNode, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { useToast } from '../../components/ui/toast';
|
||||
import { ApiError } from '../../lib/api-client';
|
||||
import {
|
||||
checkModuleHealth,
|
||||
fetchModules,
|
||||
installModule,
|
||||
removeModule,
|
||||
restartModule,
|
||||
setModuleEnabled,
|
||||
startModule,
|
||||
stopModule,
|
||||
} from '../../lib/modules-api';
|
||||
import type { Module, ModuleStatus } from '../../lib/schemas';
|
||||
|
||||
/** Status-Badge-Farben je Lifecycle-Zustand. */
|
||||
function statusVariant(status: ModuleStatus): 'success' | 'warning' | 'danger' | 'neutral' | 'info' {
|
||||
switch (status) {
|
||||
case 'RUNNING':
|
||||
return 'success';
|
||||
case 'STARTING':
|
||||
case 'STOPPING':
|
||||
return 'warning';
|
||||
case 'ERROR':
|
||||
return 'danger';
|
||||
case 'DISABLED':
|
||||
return 'neutral';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
/** Bestätigungsdialog: Modul entfernen. */
|
||||
function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: () => removeModule(module.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
showToast('success', `Modul "${module.name}" wurde entfernt`);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="Modul entfernen"
|
||||
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? Dateien und Registrierung werden gelöscht.`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{formError && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={removeMutation.isPending}
|
||||
onClick={() => removeMutation.mutate()}
|
||||
>
|
||||
Endgültig entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */
|
||||
export function ModulesPage(): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
|
||||
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
|
||||
|
||||
const modulesQuery = useQuery({
|
||||
queryKey: ['modules'],
|
||||
queryFn: fetchModules,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const invalidate = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
};
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (file: File) => installModule(file),
|
||||
onSuccess: (module) => {
|
||||
showToast('success', `Modul "${module.name}" wurde installiert`);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen');
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycleMutation = useMutation({
|
||||
mutationFn: async (input: { module: Module; action: 'start' | 'stop' | 'restart' | 'enable' | 'disable' }) => {
|
||||
switch (input.action) {
|
||||
case 'start':
|
||||
return startModule(input.module.id);
|
||||
case 'stop':
|
||||
return stopModule(input.module.id);
|
||||
case 'restart':
|
||||
return restartModule(input.module.id);
|
||||
case 'enable':
|
||||
return setModuleEnabled(input.module.id, true);
|
||||
case 'disable':
|
||||
return setModuleEnabled(input.module.id, false);
|
||||
}
|
||||
},
|
||||
onSuccess: (module, variables) => {
|
||||
showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen');
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const healthMutation = useMutation({
|
||||
mutationFn: (module: Module) => checkModuleHealth(module.id),
|
||||
onSuccess: (result, module) => {
|
||||
setHealthResults((current) => ({
|
||||
...current,
|
||||
[module.id]: { healthy: result.healthy, detail: result.detail },
|
||||
}));
|
||||
showToast(
|
||||
result.healthy ? 'success' : 'error',
|
||||
`Healthcheck "${module.name}": ${result.detail}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setSelectedFile(event.target.files?.[0] ?? null);
|
||||
}
|
||||
|
||||
function handleInstall(): void {
|
||||
if (selectedFile) {
|
||||
installMutation.mutate(selectedFile);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Modulverwaltung</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Module installieren, starten, stoppen und entfernen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installation */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
||||
aria-label="Modul-Paket (ZIP) auswählen"
|
||||
/>
|
||||
<Button
|
||||
disabled={!selectedFile}
|
||||
loading={installMutation.isPending}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Installieren
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500">
|
||||
ZIP-Paket mit module.json (Manifest). Maximal 10 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modul-Liste */}
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-4 py-3 font-semibold">Modul</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="px-4 py-3 font-semibold">URL</th>
|
||||
<th className="px-4 py-3 font-semibold">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modulesQuery.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-slate-500">
|
||||
Module werden geladen…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{modulesQuery.isError && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-red-600">
|
||||
Module konnten nicht geladen werden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{modulesQuery.data?.map((module) => (
|
||||
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-900">{module.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{module.moduleId} · Version {module.version}
|
||||
{module.author && ` · ${module.author}`}
|
||||
</div>
|
||||
{module.description && (
|
||||
<div className="mt-0.5 text-xs text-slate-500">{module.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
|
||||
${
|
||||
statusVariant(module.status) === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
|
||||
: statusVariant(module.status) === 'warning'
|
||||
? 'bg-amber-50 text-amber-700 ring-amber-600/20'
|
||||
: statusVariant(module.status) === 'danger'
|
||||
? 'bg-red-50 text-red-700 ring-red-600/20'
|
||||
: statusVariant(module.status) === 'neutral'
|
||||
? 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
||||
: 'bg-brand-50 text-brand-700 ring-brand-600/20'
|
||||
}`}
|
||||
>
|
||||
{module.status}
|
||||
</span>
|
||||
{healthResults[module.id] && (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
|
||||
/{module.slug}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{module.status !== 'RUNNING' && module.enabled && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'start'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{(module.status === 'RUNNING' || module.status === 'STARTING') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'stop'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'restart'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => healthMutation.mutate(module)}
|
||||
>
|
||||
Health
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
(lifecycleMutation.variables?.action === 'enable' ||
|
||||
lifecycleMutation.variables?.action === 'disable')
|
||||
}
|
||||
onClick={() =>
|
||||
lifecycleMutation.mutate({
|
||||
module,
|
||||
action: module.enabled ? 'disable' : 'enable',
|
||||
})
|
||||
}
|
||||
>
|
||||
{module.enabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{modulesQuery.data?.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-slate-500">
|
||||
Noch keine Module installiert.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{removeTarget && (
|
||||
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
apps/platform-frontend/src/lib/modules-api.ts
Normal file
100
apps/platform-frontend/src/lib/modules-api.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { apiRequest, ApiError } from './api-client';
|
||||
import { moduleSchema, type Module } from './schemas';
|
||||
|
||||
/** Typsichere API-Funktionen für die Modul-Verwaltung. */
|
||||
|
||||
export async function fetchModules(): Promise<Module[]> {
|
||||
const response = await apiRequest<{ modules: unknown[] }>('/api/v1/modules');
|
||||
return response.modules.map((module) => moduleSchema.parse(module));
|
||||
}
|
||||
|
||||
export async function installModule(file: File): Promise<Module> {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const csrfToken = readCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/modules/install', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Installation fehlgeschlagen';
|
||||
let details: Record<string, string | string[]> | undefined;
|
||||
try {
|
||||
const errorBody = (await response.json()) as {
|
||||
message?: string | string[];
|
||||
details?: Record<string, string | string[]>;
|
||||
};
|
||||
if (typeof errorBody.message === 'string') {
|
||||
message = errorBody.message;
|
||||
} else if (Array.isArray(errorBody.message)) {
|
||||
message = errorBody.message.join(', ');
|
||||
}
|
||||
details = errorBody.details;
|
||||
} catch {
|
||||
// Kein JSON in der Antwort – Standardmeldung verwenden.
|
||||
}
|
||||
throw new ApiError(response.status, message, details);
|
||||
}
|
||||
|
||||
const response_ = (await response.json()) as { module: unknown };
|
||||
return moduleSchema.parse(response_.module);
|
||||
}
|
||||
|
||||
export async function startModule(id: string): Promise<Module> {
|
||||
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return moduleSchema.parse(response.module);
|
||||
}
|
||||
|
||||
export async function stopModule(id: string): Promise<Module> {
|
||||
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return moduleSchema.parse(response.module);
|
||||
}
|
||||
|
||||
export async function restartModule(id: string): Promise<Module> {
|
||||
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/restart`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return moduleSchema.parse(response.module);
|
||||
}
|
||||
|
||||
export async function setModuleEnabled(id: string, enabled: boolean): Promise<Module> {
|
||||
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/enabled`, {
|
||||
method: 'PATCH',
|
||||
body: { enabled },
|
||||
});
|
||||
return moduleSchema.parse(response.module);
|
||||
}
|
||||
|
||||
export async function removeModule(id: string): Promise<void> {
|
||||
await apiRequest(`/api/v1/modules/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function checkModuleHealth(
|
||||
id: string,
|
||||
): Promise<{ healthy: boolean; latencyMs: number; detail: string }> {
|
||||
return apiRequest(`/api/v1/modules/${id}/health`);
|
||||
}
|
||||
|
||||
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
|
||||
function readCsrfToken(): string | null {
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const [name, ...value] = part.trim().split('=');
|
||||
if (name === 'mpm_csrf') {
|
||||
return decodeURIComponent(value.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -106,4 +106,33 @@ export const changePasswordSchema = z
|
||||
message: 'Passwörter stimmen nicht überein',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
/** Modul-Lifecycle-Zustände (muss zum Backend passen). */
|
||||
export const MODULE_STATUSES = [
|
||||
'INSTALLED',
|
||||
'STARTING',
|
||||
'RUNNING',
|
||||
'STOPPING',
|
||||
'STOPPED',
|
||||
'ERROR',
|
||||
'DISABLED',
|
||||
] as const;
|
||||
export type ModuleStatus = (typeof MODULE_STATUSES)[number];
|
||||
|
||||
/** Modul-Datensatz der Admin-API (/api/v1/modules). */
|
||||
export const moduleSchema = z.object({
|
||||
id: z.string(),
|
||||
moduleId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string(),
|
||||
author: z.string(),
|
||||
status: z.enum(MODULE_STATUSES),
|
||||
internalPort: z.number(),
|
||||
healthcheckUrl: z.string(),
|
||||
enabled: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
export type Module = z.infer<typeof moduleSchema>;
|
||||
@@ -10,6 +10,7 @@ import { LoginPage } from './features/auth/login-page';
|
||||
import { DashboardPage } from './features/dashboard/dashboard-page';
|
||||
import { SystemStatusPage } from './features/admin/system-status-page';
|
||||
import { UsersPage } from './features/admin/users-page';
|
||||
import { ModulesPage } from './features/admin/modules-page';
|
||||
import { ProfilePage } from './features/profile/profile-page';
|
||||
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
|
||||
import './index.css';
|
||||
@@ -38,6 +39,10 @@ function AppRoutes(): ReactNode {
|
||||
path="/admin/users"
|
||||
element={<RequireAdmin><UsersPage /></RequireAdmin>}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/modules"
|
||||
element={<RequireAdmin><ModulesPage /></RequireAdmin>}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/system"
|
||||
element={<RequireAdmin><SystemStatusPage /></RequireAdmin>}
|
||||
|
||||
@@ -39,11 +39,19 @@ services:
|
||||
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-120}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
BEHIND_PROXY: "true"
|
||||
MODULES_DIR: /app/data/modules
|
||||
LOGS_DIR: /app/data/logs
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:?Bitte ADMIN_USERNAME in .env setzen}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:?Bitte ADMIN_EMAIL in .env setzen}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Bitte ADMIN_PASSWORD in .env setzen}
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
volumes:
|
||||
# Modul-Installationen und Logs persistent halten (Container-Updates überleben)
|
||||
- modules-data:/app/data/modules
|
||||
- module-logs:/app/data/logs
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
postgres-data:
|
||||
modules-data:
|
||||
module-logs:
|
||||
@@ -142,8 +142,17 @@ Security Hardening (Penetrationstests, Dependency/Container-Scans, HSTS, Backup/
|
||||
| DELETE | `/api/v1/users/:id` | Admin | Benutzer löschen |
|
||||
| GET | `/api/v1/profile` | Session | Eigenes Profil |
|
||||
| PATCH | `/api/v1/profile/password` | Session | Eigenes Passwort ändern |
|
||||
| GET | `/api/v1/modules` | Admin | Alle Module |
|
||||
| POST | `/api/v1/modules/install` | Admin | ZIP-Paket installieren (multipart, Feld `package`) |
|
||||
| GET | `/api/v1/modules/:id` | Admin | Einzelnes Modul |
|
||||
| POST | `/api/v1/modules/:id/start` | Admin | Modul starten |
|
||||
| POST | `/api/v1/modules/:id/stop` | Admin | Modul stoppen |
|
||||
| POST | `/api/v1/modules/:id/restart` | Admin | Modul neu starten |
|
||||
| PATCH | `/api/v1/modules/:id/enabled` | Admin | Modul aktivieren/deaktivieren |
|
||||
| GET | `/api/v1/modules/:id/health` | Admin | Healthcheck ausführen |
|
||||
| DELETE | `/api/v1/modules/:id` | Admin | Modul entfernen |
|
||||
|
||||
OpenAPI/Swagger UI: `/api/docs` · Ab Phase 3: `/api/v1/modules/*`, `/api/v1/permissions/*`, `/api/v1/audit/*`.
|
||||
OpenAPI/Swagger UI: `/api/docs` · Ab Phase 4: dynamisches Routing `/slug`.
|
||||
|
||||
### Schutzregeln der Benutzerverwaltung
|
||||
|
||||
@@ -153,9 +162,11 @@ OpenAPI/Swagger UI: `/api/docs` · Ab Phase 3: `/api/v1/modules/*`, `/api/v1/per
|
||||
- Passwort-Reset (Admin) meldet den Benutzer von allen Sessions ab
|
||||
- Eigenes Passwort ändern meldet alle übrigen Sessions ab (aktuelle bleibt aktiv)
|
||||
|
||||
## 9. Modul-Vertrag (Ausblick Phase 3+)
|
||||
## 9. Modul-System (Phase 3)
|
||||
|
||||
Jedes Modul liefert ein Manifest (`module.json`) und einen minimalen API-Vertrag:
|
||||
### Manifest-Vertrag
|
||||
|
||||
Jedes Modul-Paket (ZIP, max. 10 MB) enthält ein `module.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -164,6 +175,7 @@ Jedes Modul liefert ein Manifest (`module.json`) und einen minimalen API-Vertrag
|
||||
"version": "1.0.0",
|
||||
"slug": "kalender-tool",
|
||||
"description": "Kalenderverwaltung",
|
||||
"author": "…",
|
||||
"runtime": "node",
|
||||
"entrypoint": "server.js",
|
||||
"port": 41001,
|
||||
@@ -172,7 +184,27 @@ Jedes Modul liefert ein Manifest (`module.json`) und einen minimalen API-Vertrag
|
||||
}
|
||||
```
|
||||
|
||||
Minimal-API je Modul: `GET /health`, `GET /api/manifest`, `GET /api/me`. Installation als ZIP-Paket mit Validierung, Dependency-Installation, Migrationen und Healthcheck. Details: [`modules/README.md`](../modules/README.md).
|
||||
Validierung per Zod: ID/Slug-Muster, SemVer, Port 41000–41999, kein Pfad-Traversal im Entrypoint, Runtime `node`, apiVersion `v1`.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
```
|
||||
INSTALLED → STARTING → RUNNING → STOPPING → STOPPED
|
||||
↓ ↓
|
||||
ERROR ERROR DISABLED (via Enable/Disable)
|
||||
```
|
||||
|
||||
### Sicherheitsmaßnahmen
|
||||
|
||||
- **Zip-Slip-Schutz**: Alle ZIP-Einträge müssen im Zielverzeichnis bleiben
|
||||
- **Minimale Prozess-ENV**: Module erhalten nur `PATH`, `NODE_ENV`, `PORT` – keine Plattform-Secrets
|
||||
- **Spawn ohne Shell**: keine Command-Injection möglich
|
||||
- **Eigene Log-Dateien** pro Modul (`/app/data/logs/module-<id>.log`)
|
||||
- **Startup-Grace**: Healthcheck mit 10 Retries, bevor ein Modul als ERROR gilt
|
||||
- **Isolation**: Ein abgestürztes Modul (ERROR) beeinträchtigt weder Plattform noch andere Module
|
||||
- Persistente Volumes: Modul-Dateien und Logs überleben Container-Updates
|
||||
|
||||
Minimal-API je Modul (Phase 6): `GET /health`, `GET /api/manifest`, `GET /api/me`. Details zum Paketformat: [`modules/README.md`](../modules/README.md).
|
||||
|
||||
## 10. Architektur-Entscheidungen (ADR-Kurzform)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Der Arbeitsplan sieht zehn inkrementelle Phasen vor. Nach jeder Phase muss das S
|
||||
|---|---|---|
|
||||
| 1 | Grundgerüst (Docker, DB, Login, Rollen) | ✅ Abgeschlossen |
|
||||
| 2 | Benutzerverwaltung | ✅ Abgeschlossen |
|
||||
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ⏳ Geplant |
|
||||
| 3 | Modul-System (Manifest, Installation, Lifecycle) | ✅ Abgeschlossen |
|
||||
| 4 | Gateway & dynamisches Routing (`/slug`) | ⏳ Geplant |
|
||||
| 5 | Berechtigungssystem (User ↔ Module) | ⏳ Geplant |
|
||||
| 6 | Modul-API (`/health`, `/api/manifest`, `/api/me`) | ⏳ Geplant |
|
||||
@@ -51,10 +51,26 @@ Definition of Done: Admin kann Benutzer vollständig verwalten.
|
||||
- [x] Backend-Tests: 49 bestanden (inkl. UsersService, ProfileService)
|
||||
- [x] E2E verifiziert: CRUD, RBAC (User → 403), Login-Sperre nach Deaktivierung, Passwort-Flows
|
||||
|
||||
## Nächste Schritte (Phase 3 – Modul-System)
|
||||
## Phase 3 – Modul-System (abgeschlossen)
|
||||
|
||||
- Modul-Datenmodell (`modules`-Tabelle) und Manifest-Vertrag (`module.json`)
|
||||
- Modul-Installation als ZIP-Paket (Validierung, Dateien, Registrierung)
|
||||
- Modul-Lifecycle (INSTALLED/STARTING/RUNNING/STOPPING/STOPPED/ERROR/DISABLED)
|
||||
- Prozessverwaltung der Modul-Prozesse über Supervisor
|
||||
- Healthchecks und Statusanzeige im Admin-Bereich
|
||||
Definition of Done: Modul-Datenmodell, Manifest, Installation, Registrierung, Status, Start/Stop/Restart, Healthcheck.
|
||||
|
||||
- [x] `modules`-Tabelle (Migration 002) mit Lifecycle-Status und eindeutigen Ports/Slugs
|
||||
- [x] Manifest-Vertrag `module.json` (Zod: ID, Name, Version, Slug, Runtime, Entrypoint, Port 41000–41999, Healthcheck, apiVersion)
|
||||
- [x] ZIP-Installer mit Manifest-Validierung, Größenlimit (10 MB) und **Zip-Slip-Schutz**
|
||||
- [x] Modul-Prozess-Manager: Start/Stop (SIGTERM→SIGKILL) als Kindprozesse, minimale ENV (keine Plattform-Secrets), eigene Log-Dateien
|
||||
- [x] Healthcheck-Service mit Startup-Grace (10 Retries × 500 ms)
|
||||
- [x] Lifecycle: INSTALLED → STARTING → RUNNING → STOPPING → STOPPED, ERROR, DISABLED
|
||||
- [x] `GET/POST /api/v1/modules`, `POST :id/start|stop|restart`, `PATCH :id/enabled`, `GET :id/health`, `DELETE :id` (nur ADMIN)
|
||||
- [x] Duplikat-Schutz: Modul-ID, Slug und Port müssen eindeutig sein (409)
|
||||
- [x] Frontend: Modulverwaltungs-Seite (ZIP-Upload, Tabelle mit Status-Badges, Lifecycle-Buttons, Health-Anzeige, Remove-Dialog)
|
||||
- [x] Persistente Volumes für Modul-Dateien und Logs (`modules-data`, `module-logs`)
|
||||
- [x] Demo-Modul (`modules/demo`) als Referenzimplementierung
|
||||
- [x] Backend-Tests: 72 bestanden (inkl. ModulesService-Lifecycle, Manifest-Validierung, Installer)
|
||||
- [x] E2E verifiziert: Installation (201), Start → RUNNING, Healthcheck (healthy, 2 ms), Stop, Restart, Disable (stoppt Prozess), Start-Sperre bei Disable (400), Remove, RBAC (USER → 403)
|
||||
|
||||
## Nächste Schritte (Phase 4 – Gateway & Routing)
|
||||
|
||||
- Dynamisches Routing: `/slug` → Modul-Prozess (Nginx-Konfiguration zur Laufzeit)
|
||||
- Zugriffskontrolle beim Routing (Session → Permission Check → 403/Proxy)
|
||||
- Modul-Gateway mit sicherer Identitätsübergabe an das Modul
|
||||
50
modules/demo/backend/server.js
Normal file
50
modules/demo/backend/server.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Demo-Modul: Minimaler HTTP-Server, der den Modul-API-Vertrag erfüllt.
|
||||
* Läuft als eigener Node-Prozess im Management-Container (Port via ENV PORT).
|
||||
*/
|
||||
|
||||
const http = require('node:http');
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 41001);
|
||||
|
||||
/** Manifest des Moduls (identisch zu module.json). */
|
||||
const MANIFEST = {
|
||||
moduleId: 'demo',
|
||||
version: '1.0.0',
|
||||
status: 'healthy',
|
||||
};
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? '/', `http://127.0.0.1:${PORT}`);
|
||||
|
||||
if (url.pathname === '/health') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ moduleId: 'demo', version: '1.0.0', status: 'healthy' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/manifest') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify(MANIFEST));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/me') {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
user: { id: 'demo', username: 'demo' },
|
||||
module: { id: 'demo' },
|
||||
permissions: [],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Demo-Modul läuft');
|
||||
});
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`Demo-Modul läuft auf Port ${PORT}`);
|
||||
});
|
||||
BIN
modules/demo/demo-module.zip
Normal file
BIN
modules/demo/demo-module.zip
Normal file
Binary file not shown.
13
modules/demo/module.json
Normal file
13
modules/demo/module.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "demo",
|
||||
"name": "Demo-Modul",
|
||||
"version": "1.0.0",
|
||||
"slug": "demo",
|
||||
"description": "Referenzmodul zum Testen des Modul-Systems",
|
||||
"author": "MPM",
|
||||
"runtime": "node",
|
||||
"entrypoint": "server.js",
|
||||
"port": 41001,
|
||||
"healthcheck": "/health",
|
||||
"apiVersion": "v1"
|
||||
}
|
||||
Reference in New Issue
Block a user