MPM marketplace and UI updates

This commit is contained in:
leon
2026-10-08 21:13:57 +02:00
parent 7b121fa908
commit fd9de823ea
66 changed files with 5187 additions and 749 deletions

View File

@@ -15,10 +15,12 @@ export const AUDIT_ACTIONS = {
USER_PASSWORD_RESET: 'USER_PASSWORD_RESET',
USER_PASSWORD_CHANGED: 'USER_PASSWORD_CHANGED',
MODULE_INSTALLED: 'MODULE_INSTALLED',
MODULE_UPDATED: 'MODULE_UPDATED',
MODULE_REMOVED: 'MODULE_REMOVED',
MODULE_STARTED: 'MODULE_STARTED',
MODULE_STOPPED: 'MODULE_STOPPED',
MODULE_RESTARTED: 'MODULE_RESTARTED',
MODULE_CONFIG_UPDATED: 'MODULE_CONFIG_UPDATED',
MODULE_ENABLED: 'MODULE_ENABLED',
MODULE_DISABLED: 'MODULE_DISABLED',
PERMISSION_GRANTED: 'PERMISSION_GRANTED',
@@ -66,4 +68,4 @@ export class AuditService {
);
}
}
}
}

View File

@@ -25,7 +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' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
};
}

View File

@@ -35,6 +35,7 @@ export interface RuntimeConfig {
readonly modulesDir: string;
readonly logsDir: string;
readonly moduleUidBase?: number;
readonly moduleConfigurationEncryptionKey: string;
}
export interface MarketplaceProviderConfig {
@@ -85,6 +86,7 @@ const environmentSchema = z.object({
MODULES_DIR: z.string().min(1).default('./data/modules'),
MODULE_DATA_DIR: z.string().min(1).default('./data/module-data'),
LOGS_DIR: z.string().min(1).default('./data/logs'),
MODULE_CONFIG_ENCRYPTION_KEY: z.string().default(''),
MODULE_UID_BASE: z.coerce.number().int().min(10_000).max(64_535).optional(),
MARKETPLACE_PUBLIC_URL: z.string().url().default('http://127.0.0.1:8081'),
MARKETPLACE_TOKEN_ENCRYPTION_KEY: z.string().default(''),
@@ -184,6 +186,7 @@ export function loadConfiguration(): AppConfig {
runtime: {
modulesDir: path.resolve(environment.MODULES_DIR),
logsDir: path.resolve(environment.LOGS_DIR),
moduleConfigurationEncryptionKey: environment.MODULE_CONFIG_ENCRYPTION_KEY,
...(environment.MODULE_UID_BASE !== undefined
? { moduleUidBase: environment.MODULE_UID_BASE }
: {}),

View File

@@ -8,6 +8,8 @@ import { migration007MarketplaceCatalog } from '../../modules/migrations/007-mar
import { migration008MarketplaceSourceBranch } from '../../modules/migrations/008-marketplace-source-branch';
import { migration009MarketplaceInstallations } from '../../modules/migrations/009-marketplace-installations';
import { migration010ModuleContainers } from '../../modules/migrations/010-module-containers';
import { migration011ModuleConfiguration } from '../../modules/migrations/011-module-configuration';
import { migration012MarketplaceBranchUpdates } from '../../modules/migrations/012-marketplace-branch-updates';
/** Registrierte Migrationen in aufsteigender Reihenfolge. */
export const MIGRATIONS = [
@@ -21,4 +23,6 @@ export const MIGRATIONS = [
migration008MarketplaceSourceBranch,
migration009MarketplaceInstallations,
migration010ModuleContainers,
migration011ModuleConfiguration,
migration012MarketplaceBranchUpdates,
];

View File

@@ -16,6 +16,40 @@ export type ModuleStatus = (typeof MODULE_STATUSES)[number];
export const MODULE_PORT_MIN = 41000;
export const MODULE_PORT_MAX = 41999;
/** Vom Modul deklarierte, durch Admins setzbare Compose-Umgebungsvariable. */
export const moduleConfigurationFieldSchema = z.object({
key: z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/),
label: z.string().trim().min(1).max(100),
description: z.string().max(500).default(''),
type: z.enum(['text', 'url', 'boolean']).default('text'),
secret: z.boolean().default(false),
required: z.boolean().default(true),
defaultValue: z.string().max(8192).optional(),
services: z.array(z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/)).min(1).max(20),
}).superRefine((field, context) => {
if (field.secret && field.defaultValue !== undefined) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Geheimnisse dürfen keinen Manifest-Standardwert haben' });
}
if (field.required && field.defaultValue !== undefined && !field.defaultValue.trim()) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Pflichtfelder benötigen einen nicht leeren Standardwert' });
}
if (field.type === 'boolean' && field.defaultValue !== undefined && !['true', 'false'].includes(field.defaultValue)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'Boolean-Standardwerte müssen true oder false sein' });
}
if (field.type === 'url' && field.defaultValue !== undefined) {
try {
if (!['http:', 'https:'].includes(new URL(field.defaultValue).protocol)) throw new Error('protocol');
} catch {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['defaultValue'], message: 'URL-Standardwerte müssen HTTP oder HTTPS verwenden' });
}
}
const processEnvironmentKey = /^(PATH|HOME|TMPDIR|PORT|NODE_ENV|MPM_MODULE_DATA_DIR|MPM_MODULE_IDENTITY_KEY|DOCKER_.*|COMPOSE_.*|NODE_.*|NPM_.*|PYTHON.*|BASH_.*|LD_.*|DYLD_.*|RUBY.*|PERL.*|GIT_.*|SSH_AUTH_SOCK)$/;
if (processEnvironmentKey.test(field.key)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['key'], message: 'Dieser Umgebungsvariablenname ist für MPM reserviert' });
}
});
export type ModuleConfigurationField = z.infer<typeof moduleConfigurationFieldSchema>;
/** Modul-IDs: kleinbuchstaben, Zahlen, Bindestriche – keine Pfadzeichen. */
const MODULE_ID_PATTERN = /^[a-z][a-z0-9-]{2,63}$/;
@@ -54,6 +88,15 @@ export const moduleManifestSchema = z.object({
apiVersion: z.literal('v1'),
composeFile: z.string().min(1).max(200).optional(),
appService: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/).optional(),
configuration: z.array(moduleConfigurationFieldSchema).max(50).default([]),
}).superRefine((manifest, context) => {
const keys = new Set<string>();
manifest.configuration.forEach((field, index) => {
if (keys.has(field.key)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['configuration', index, 'key'], message: 'Konfigurationsschlüssel dürfen nicht doppelt vorkommen' });
}
keys.add(field.key);
});
});
export type ModuleManifest = z.infer<typeof moduleManifestSchema>;
@@ -75,4 +118,6 @@ export interface ModuleRecord {
readonly updatedAt: Date;
readonly composeFile?: string | null;
readonly appService?: string | null;
readonly configuration: readonly ModuleConfigurationField[];
readonly configurationReady: boolean;
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@@ -9,6 +9,7 @@ import { SessionService } from '../auth/session.service';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { MarketplaceService } from './marketplace.service';
import { ModulesService } from './modules.service';
import type { ModuleRecord } from './manifest.types';
@ApiTags('Marketplace')
@Controller({ path: 'api/v1/marketplace' })
@@ -42,12 +43,14 @@ export class MarketplaceController {
): Promise<{ module: {
id: string; moduleId: string; name: string; slug: string; version: string; description: string;
author: string; status: string; internalPort: number; healthcheckUrl: string; enabled: boolean; createdAt: string;
configuration: ModuleRecord['configuration']; configurationReady: boolean;
} }> {
const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository);
const branch = await this.marketplaceService.defaultBranch(provider, owner, repository);
const archive = await this.marketplaceService.downloadRepositoryArchive(provider, owner, repository, branch);
const manifest = await this.modulesService.validatePackage(archive);
const module = await this.modulesService.findByModuleId(manifest.id) ??
await this.modulesService.install(archive, actor, request.ip ?? null);
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id);
await this.marketplaceService.recordInstallation(provider, owner, repository, module.id, branch);
return {
module: {
id: module.id,
@@ -62,10 +65,33 @@ export class MarketplaceController {
healthcheckUrl: module.healthcheckUrl,
enabled: module.enabled,
createdAt: module.createdAt.toISOString(),
configuration: module.configuration,
configurationReady: module.configurationReady,
},
};
}
@Get('modules/:moduleId/updates')
@Roles('ADMIN')
updates(@Param('moduleId') moduleId: string) {
return this.marketplaceService.availableUpdates(moduleId);
}
@Post('modules/:moduleId/update')
@Roles('ADMIN')
async updateModule(
@Param('moduleId') moduleId: string,
@Body() body: { branch?: unknown },
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest,
): Promise<{ module: ModuleRecord }> {
if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen');
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch);
const module = await this.modulesService.updateFromMarketplace(moduleId, update.archive, actor, request.ip ?? null);
await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit);
return { module };
}
@Post('connections/:provider/start')
@Roles('ADMIN')
async startConnection(

View File

@@ -3,8 +3,11 @@ import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
UnauthorizedException,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
@@ -37,6 +40,31 @@ interface ProviderIdentity {
readonly username?: string;
}
export interface MarketplaceBranch {
name: string;
commit: string;
}
export interface MarketplaceUpdatePackage {
archive: Buffer;
commit: string;
provider: MarketplaceProvider;
owner: string;
repository: string;
branch: string;
}
interface MarketplaceInstallationRow {
module_id: string;
provider: MarketplaceProvider;
owner: string;
repository: string;
installed_branch: string;
installed_commit: string | null;
available_branches: MarketplaceBranch[];
observed_branches: MarketplaceBranch[];
}
const MAX_MARKETPLACE_DOWNLOAD = 10 * 1024 * 1024;
function isProvider(value: string): value is MarketplaceProvider {
@@ -54,13 +82,47 @@ function safeBaseUrl(value: string): string {
return url.toString().replace(/\/$/, '');
}
function branchVersion(branch: string): number[] | null {
const match = branch.match(/(?:^|[-_/.])v?(\d+(?:\.\d+)+)$/i);
return match ? match[1].split('.').map(Number) : null;
}
function isNewerVersionBranch(candidate: string, installed: string): boolean {
const candidateVersion = branchVersion(candidate);
const installedVersion = branchVersion(installed);
// Preserve support for repositories that use non-versioned release branch
// names. When both names carry versions, only offer a strictly newer one.
if (!candidateVersion || !installedVersion) return true;
const length = Math.max(candidateVersion.length, installedVersion.length);
for (let index = 0; index < length; index += 1) {
const candidatePart = candidateVersion[index] ?? 0;
const installedPart = installedVersion[index] ?? 0;
if (candidatePart !== installedPart) return candidatePart > installedPart;
}
return false;
}
@Injectable()
export class MarketplaceService {
export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MarketplaceService.name);
private branchCheckTimer: NodeJS.Timeout | undefined;
constructor(
private readonly database: DatabaseService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
onModuleInit(): void {
const initialCheck = setTimeout(() => void this.refreshAllBranchSnapshots(), 10_000);
initialCheck.unref();
this.branchCheckTimer = setInterval(() => void this.refreshAllBranchSnapshots(), 60 * 1000);
this.branchCheckTimer.unref();
}
onModuleDestroy(): void {
if (this.branchCheckTimer) clearInterval(this.branchCheckTimer);
}
async providers(): Promise<ProviderStatus[]> {
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
'SELECT provider, account_login FROM marketplace_connections',
@@ -204,17 +266,56 @@ export class MarketplaceService {
}));
}
async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string): Promise<void> {
async recordInstallation(providerParam: string, owner: string, repository: string, moduleId: string, branch: string): Promise<void> {
const provider = this.requireProvider(providerParam);
const branchInfo = await this.getBranch(provider, owner, repository, branch);
const branches = await this.fetchBranches(provider, owner, repository);
await this.database.query(
`INSERT INTO marketplace_module_installations (provider, owner, repository, module_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, owner, repository) DO UPDATE SET module_id = EXCLUDED.module_id`,
[provider, owner, repository, moduleId],
`INSERT INTO marketplace_module_installations
(provider, owner, repository, module_id, installed_branch, installed_commit, available_branches, observed_branches, branches_checked_at)
VALUES ($1, $2, $3, $4, $5, $6, '[]'::jsonb, $7::jsonb, now())
ON CONFLICT (provider, owner, repository) DO UPDATE SET
module_id = EXCLUDED.module_id, installed_branch = EXCLUDED.installed_branch,
installed_commit = EXCLUDED.installed_commit, available_branches = '[]'::jsonb,
observed_branches = EXCLUDED.observed_branches, branches_checked_at = now()`,
[provider, owner, repository, moduleId, branch, branchInfo.commit, JSON.stringify(branches)],
);
}
async downloadRepositoryArchive(providerParam: string, owner: string, repository: string): Promise<Buffer> {
async defaultBranch(providerParam: string, owner: string, repository: string): Promise<string> {
const provider = this.requireProvider(providerParam);
const config = this.config.marketplace.providers[provider];
if (!config) throw new BadRequestException('Forge-Anbieter ist nicht konfiguriert');
const repo = await this.forgeJson<Record<string, unknown>>(provider, config.baseUrl, null,
this.repositoryApiPath(provider, owner, repository));
const branch = String(repo.default_branch ?? 'main');
if (!branch || branch.length > 200) throw new BadRequestException('Standard-Branch ist ungültig');
return branch;
}
async availableUpdates(moduleId: string): Promise<{ installedBranch: string; installedCommit: string | null; branches: MarketplaceBranch[] }> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches, observed_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
);
const row = result.rows[0];
if (!row) return { installedBranch: '', installedCommit: null, branches: [] };
return {
installedBranch: row.installed_branch,
installedCommit: row.installed_commit,
// The default branch is the source branch used for installation, not a
// release candidate. Other branches are selectable only when they point
// to a commit newer/different from the currently installed snapshot.
branches: Array.isArray(row.available_branches)
? row.available_branches.filter((branch) => branch.name !== 'main'
&& branch.name !== row.installed_branch
&& isNewerVersionBranch(branch.name, row.installed_branch)
&& (!row.installed_commit || branch.commit.toLowerCase() !== row.installed_commit.toLowerCase()))
: [],
};
}
async downloadRepositoryArchive(providerParam: string, owner: string, repository: string, branch?: string): Promise<Buffer> {
const provider = this.requireProvider(providerParam);
if (![owner, repository].every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) {
throw new BadRequestException('Repository-Angabe ist ungueltig');
@@ -228,11 +329,12 @@ export class MarketplaceService {
this.repositoryApiPath(provider, owner, repository),
);
if (repo.private === true) throw new BadRequestException('Private Repositories werden aktuell nicht unterstuetzt');
const defaultBranch = String(repo.default_branch ?? 'main');
if (!defaultBranch || defaultBranch.length > 200) throw new BadRequestException('Standard-Branch ist ungueltig');
const selectedBranch = branch ?? String(repo.default_branch ?? 'main');
if (!selectedBranch || selectedBranch.length > 200 || selectedBranch.includes('\0')) throw new BadRequestException('Branch ist ungueltig');
if (branch) await this.getBranch(provider, owner, repository, branch);
const archivePath = provider === 'github'
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(defaultBranch)
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(defaultBranch) + '.zip';
? '/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/zipball/' + encodeURIComponent(selectedBranch)
: new URL(providerConfig.baseUrl).pathname.replace(/[/]$/, '') + '/api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repository) + '/archive/' + encodeURIComponent(selectedBranch) + '.zip';
const archiveUrl = provider === 'github'
? new URL(archivePath, 'https://api.github.com').toString()
: new URL(archivePath, providerConfig.baseUrl).toString();
@@ -242,6 +344,116 @@ export class MarketplaceService {
return this.normalizeRepositoryArchive(archive);
}
async updateInstalledBranch(moduleId: string, branch: string): Promise<MarketplaceUpdatePackage> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
);
const installation = result.rows[0];
if (!installation) throw new NotFoundException('Für dieses Modul ist keine Marketplace-Quelle hinterlegt');
if (branch === 'main') throw new BadRequestException('Der Haupt-Branch main ist keine Update-Version');
if (branch === installation.installed_branch) throw new BadRequestException('Diese Branch ist bereits installiert');
if (!isNewerVersionBranch(branch, installation.installed_branch)) {
throw new BadRequestException('Diese Versions-Branch ist älter oder gleich der installierten Version');
}
if (!Array.isArray(installation.available_branches) || !installation.available_branches.some((item) => item.name === branch)) {
throw new BadRequestException('Diese Branch wurde bei der letzten Repository-Prüfung nicht als Update gefunden');
}
const branchInfo = await this.getBranch(installation.provider, installation.owner, installation.repository, branch);
if (installation.installed_commit && branchInfo.commit.toLowerCase() === installation.installed_commit.toLowerCase()) {
throw new BadRequestException('Diese Branch enthält keine Änderungen gegenüber der installierten Version');
}
const archive = await this.downloadRepositoryArchive(installation.provider, installation.owner, installation.repository, branch);
// Caller updates and validates the module files before this source record is advanced.
return { archive, commit: branchInfo.commit, provider: installation.provider,
owner: installation.owner, repository: installation.repository, branch };
}
async commitInstalledBranch(moduleId: string, provider: MarketplaceProvider, owner: string, repository: string, branch: string, commit: string): Promise<void> {
await this.database.query(
`UPDATE marketplace_module_installations SET installed_branch = $2, installed_commit = $3,
available_branches = COALESCE((
SELECT jsonb_agg(item.value) FROM jsonb_array_elements(available_branches) AS item(value)
WHERE item.value->>'name' <> $2
), '[]'::jsonb), branches_checked_at = now()
WHERE module_id = $1 AND provider = $4 AND owner = $5 AND repository = $6`,
[moduleId, branch, commit, provider, owner, repository],
);
await this.refreshInstallation(moduleId);
}
private async getBranch(provider: MarketplaceProvider, owner: string, repository: string, branch: string): Promise<MarketplaceBranch> {
if (!/^[A-Za-z0-9_.\-/]{1,200}$/.test(branch) || branch.startsWith('/') || branch.split('/').includes('..')) {
throw new BadRequestException('Branch-Name ist ungültig');
}
const basePath = provider === 'github'
? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`
: `${new URL(this.config.marketplace.providers[provider]!.baseUrl).pathname.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`;
const value = await this.forgeJson<Record<string, unknown>>(provider, this.config.marketplace.providers[provider]!.baseUrl, null, basePath);
const commit = value.commit as Record<string, unknown> | undefined;
const sha = String(commit?.id ?? commit?.sha ?? '');
if (!sha) throw new NotFoundException('Branch konnte beim Forge nicht gefunden werden');
return { name: String(value.name ?? branch), commit: sha };
}
private async refreshAllBranchSnapshots(): Promise<void> {
try {
const result = await this.database.query<{ module_id: string }>('SELECT module_id FROM marketplace_module_installations');
for (const row of result.rows) {
try { await this.refreshInstallation(row.module_id); }
catch (error) { this.logger.warn(`Branch-Prüfung für Modul ${row.module_id} fehlgeschlagen: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`); }
}
} catch (error) {
this.logger.warn(`Tägliche Marketplace-Branch-Prüfung nicht verfügbar: ${error instanceof Error ? error.message : 'unbekannter Fehler'}`);
}
}
private async refreshInstallation(moduleId: string): Promise<void> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches, observed_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
);
const row = result.rows[0];
if (!row) return;
const providerConfig = this.config.marketplace.providers[row.provider];
if (!providerConfig) return;
const branches = await this.fetchBranches(row.provider, row.owner, row.repository);
// Every branch other than the installed one is a selectable tested version.
// Do not rely on whether it existed when this installation was first recorded:
// removing and reinstalling a module must not hide an available release branch.
const installedCommit = row.installed_commit?.toLowerCase();
const candidates = branches.filter((branch) => branch.name !== 'main'
&& branch.name !== row.installed_branch
&& isNewerVersionBranch(branch.name, row.installed_branch)
&& (!installedCommit || branch.commit.toLowerCase() !== installedCommit));
await this.database.query(
`UPDATE marketplace_module_installations SET available_branches = $2::jsonb,
observed_branches = $3::jsonb, branches_checked_at = now() WHERE module_id = $1`,
[moduleId, JSON.stringify(candidates), JSON.stringify(branches)],
);
}
private async fetchBranches(provider: MarketplaceProvider, owner: string, repository: string): Promise<MarketplaceBranch[]> {
const providerConfig = this.config.marketplace.providers[provider];
if (!providerConfig) return [];
const branches: MarketplaceBranch[] = [];
const pageSize = provider === 'github' ? 100 : 50;
for (let page = 1; page <= 100; page += 1) {
const path = provider === 'github'
? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?per_page=${pageSize}&page=${page}`
: `${new URL(providerConfig.baseUrl).pathname.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches?limit=${pageSize}&page=${page}`;
const values = await this.forgeJson<Array<Record<string, unknown>>>(provider, providerConfig.baseUrl, null, path);
branches.push(...values.flatMap((value) => {
const name = String(value.name ?? '');
const commit = value.commit as Record<string, unknown> | undefined;
const sha = String(commit?.sha ?? commit?.id ?? '');
return name && sha ? [{ name, commit: sha }] : [];
}));
if (values.length < pageSize) break;
}
return branches;
}
private async normalizeRepositoryArchive(archive: Buffer): Promise<Buffer> {
const AdmZip = (await import('adm-zip')).default;
const input = new AdmZip(archive);

View File

@@ -0,0 +1,22 @@
import type { Migration } from '../../database/migration.types';
export const migration011ModuleConfiguration: Migration = {
id: '011-module-configuration',
description: 'Deklarierte Modulkonfiguration und verschlüsselte Werte speichern',
up: async (client) => {
await client.query(`
ALTER TABLE modules
ADD COLUMN configuration_schema JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN configuration_ready BOOLEAN NOT NULL DEFAULT false
`);
await client.query(`
CREATE TABLE module_configurations (
module_id UUID PRIMARY KEY REFERENCES modules(id) ON DELETE CASCADE,
ciphertext TEXT NOT NULL,
iv TEXT NOT NULL,
auth_tag TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
},
};

View File

@@ -0,0 +1,16 @@
import type { Migration } from '../../database/migration.types';
export const migration012MarketplaceBranchUpdates: Migration = {
id: '012-marketplace-branch-updates',
description: 'Installierte Branches und verfügbare Modul-Updates speichern',
up: async (client) => {
await client.query(`
ALTER TABLE marketplace_module_installations
ADD COLUMN installed_branch TEXT NOT NULL DEFAULT 'main',
ADD COLUMN installed_commit TEXT,
ADD COLUMN available_branches JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN observed_branches JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN branches_checked_at TIMESTAMPTZ
`);
},
};

View File

@@ -0,0 +1,208 @@
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
import { DatabaseService } from '../database/database.service';
import type { ModuleConfigurationField, ModuleRecord } from './manifest.types';
interface EncryptedConfigurationRow {
ciphertext: string;
iv: string;
auth_tag: string;
}
export interface ModuleConfigurationFieldState {
key: string;
label: string;
description: string;
type: ModuleConfigurationField['type'];
secret: boolean;
required: boolean;
services: readonly string[];
isSet: boolean;
value?: string;
}
export interface ModuleConfigurationState {
ready: boolean;
fields: ModuleConfigurationFieldState[];
}
@Injectable()
export class ModuleConfigurationService {
constructor(
private readonly database: DatabaseService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async state(module: ModuleRecord): Promise<ModuleConfigurationState> {
const values = await this.values(module);
const fields = module.configuration.map((field) => {
const value = values[field.key];
return {
key: field.key,
label: field.label,
description: field.description,
type: field.type,
secret: field.secret,
required: field.required,
services: field.services,
isSet: value !== undefined && value.length > 0,
...(!field.secret && value !== undefined ? { value } : {}),
};
});
return {
ready: module.configuration.every((field) => !field.required || Boolean(values[field.key]?.trim())),
fields,
};
}
async resolvedValues(module: ModuleRecord): Promise<Record<string, string>> {
const values = await this.values(module);
const missing = module.configuration.filter((field) => field.required && !values[field.key]?.trim());
if (missing.length) {
throw new BadRequestException(`Konfiguration erforderlich: ${missing.map((field) => field.label).join(', ')}`);
}
return values;
}
async save(
module: ModuleRecord,
input: { values?: unknown; clearKeys?: unknown },
): Promise<{ state: ModuleConfigurationState; changedKeys: string[] }> {
if (module.configuration.length === 0) {
throw new BadRequestException('Dieses Modul benötigt keine Konfiguration');
}
const valuesInput = input.values ?? {};
const clearInput = input.clearKeys ?? [];
if (!valuesInput || typeof valuesInput !== 'object' || Array.isArray(valuesInput)) {
throw new BadRequestException('Konfigurationswerte müssen ein Objekt sein');
}
if (!Array.isArray(clearInput) || clearInput.some((key) => typeof key !== 'string')) {
throw new BadRequestException('clearKeys muss eine Liste aus Schlüsseln sein');
}
this.assertEncryptionKey();
const allowed = new Map(module.configuration.map((field) => [field.key, field]));
const unknown = [
...Object.keys(valuesInput as Record<string, unknown>),
...(clearInput as string[]),
].filter((key) => !allowed.has(key));
if (unknown.length) throw new BadRequestException(`Unbekannte Konfigurationsfelder: ${[...new Set(unknown)].join(', ')}`);
const current = await this.savedValues(module.id);
const next = { ...current };
const changed = new Set<string>();
for (const [key, rawValue] of Object.entries(valuesInput as Record<string, unknown>)) {
const field = allowed.get(key)!;
if (typeof rawValue !== 'string' || rawValue.length > 8192 || rawValue.includes('\0')) {
throw new BadRequestException(`Ungültiger Wert für ${field.label}`);
}
if (rawValue === '' && field.secret) continue;
if (rawValue === '') {
if (Object.hasOwn(next, key)) changed.add(key);
delete next[key];
continue;
}
if (field.type === 'boolean' && rawValue !== 'true' && rawValue !== 'false') {
throw new BadRequestException(`${field.label} muss true oder false sein`);
}
if (field.type === 'url') {
try {
const url = new URL(rawValue);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('protocol');
} catch {
throw new BadRequestException(`${field.label} muss eine gültige HTTP- oder HTTPS-URL sein`);
}
}
if (next[key] !== rawValue) changed.add(key);
next[key] = rawValue;
}
for (const key of clearInput as string[]) {
if (Object.hasOwn(next, key)) changed.add(key);
delete next[key];
}
for (const field of module.configuration) {
if (field.defaultValue !== undefined && next[field.key] === undefined) next[field.key] = field.defaultValue;
}
const ready = module.configuration.every((field) => !field.required || Boolean(next[field.key]?.trim()));
if (module.status === 'RUNNING' && !ready) {
throw new BadRequestException('Ein laufendes Modul kann nicht ohne vollständige Pflichtkonfiguration gespeichert werden');
}
const explicitValues = Object.fromEntries(
Object.entries(next).filter(([key, value]) => {
const field = allowed.get(key);
return field && value !== field.defaultValue;
}),
);
const encrypted = this.encrypt(JSON.stringify(explicitValues));
await this.database.query(
`INSERT INTO module_configurations (module_id, ciphertext, iv, auth_tag, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (module_id) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
iv = EXCLUDED.iv,
auth_tag = EXCLUDED.auth_tag,
updated_at = now()`,
[module.id, encrypted.ciphertext, encrypted.iv, encrypted.authTag],
);
await this.database.query(
'UPDATE modules SET configuration_ready = $2, updated_at = now() WHERE id = $1',
[module.id, ready],
);
return { state: await this.state({ ...module, configurationReady: ready }), changedKeys: [...changed] };
}
async values(module: ModuleRecord): Promise<Record<string, string>> {
const values = await this.savedValues(module.id);
for (const field of module.configuration) {
if (values[field.key] === undefined && field.defaultValue !== undefined) values[field.key] = field.defaultValue;
}
return values;
}
private async savedValues(moduleId: string): Promise<Record<string, string>> {
const result = await this.database.query<EncryptedConfigurationRow>(
'SELECT ciphertext, iv, auth_tag FROM module_configurations WHERE module_id = $1',
[moduleId],
);
const row = result.rows[0];
if (!row) return {};
this.assertEncryptionKey();
try {
const key = createHash('sha256').update(this.config.runtime.moduleConfigurationEncryptionKey).digest();
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(row.iv, 'base64'));
decipher.setAuthTag(Buffer.from(row.auth_tag, 'base64'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(row.ciphertext, 'base64')),
decipher.final(),
]).toString('utf8');
const parsed: unknown = JSON.parse(plaintext);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) ||
Object.values(parsed).some((value) => typeof value !== 'string')) {
throw new Error('invalid shape');
}
return parsed as Record<string, string>;
} catch {
throw new InternalServerErrorException('Gespeicherte Modulkonfiguration kann nicht entschlüsselt werden');
}
}
private encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
const key = createHash('sha256').update(this.config.runtime.moduleConfigurationEncryptionKey).digest();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
ciphertext: ciphertext.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
};
}
private assertEncryptionKey(): void {
if (this.config.runtime.moduleConfigurationEncryptionKey.length < 32) {
throw new InternalServerErrorException('MODULE_CONFIG_ENCRYPTION_KEY muss mindestens 32 Zeichen lang sein');
}
}
}

View File

@@ -1,11 +1,12 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { spawn } from 'node:child_process';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { stringify, parseDocument } from 'yaml';
import { ModuleIdentityService } from './module-identity.service';
import type { ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service';
const SAFE_SERVICE_KEYS = new Set([
'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes',
@@ -14,6 +15,17 @@ const SAFE_SERVICE_KEYS = new Set([
'stop_grace_period', 'read_only', 'tty', 'stdin_open',
]);
/** Ein Docker-CLI-Fehler mit einer für die Admin-Oberfläche bereinigten Diagnose. */
export class ModuleCommandError extends Error {
constructor(
readonly exitCode: number | null,
readonly diagnostic: string,
) {
super(exitCode === null ? 'Docker-Befehl konnte nicht gestartet werden' : `Docker-Befehl endete mit Status ${exitCode}`);
this.name = 'ModuleCommandError';
}
}
/** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */
@Injectable()
export class ModuleContainerManager {
@@ -21,41 +33,54 @@ export class ModuleContainerManager {
private readonly dockerHost = process.env.MODULE_DOCKER_HOST ?? 'unix:///var/run/docker.sock';
private readonly mpmContainer = process.env.MPM_CONTAINER_NAME ?? '';
constructor(private readonly identityService: ModuleIdentityService) {}
constructor(
private readonly identityService: ModuleIdentityService,
private readonly configurationService: ModuleConfigurationService,
) {}
async start(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
// Recreate stopped containers and project networks before each start. This
// prevents Compose v1 from trying to reconcile stale Docker Desktop network
// defaults after a stop; named data volumes are deliberately left untouched.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
await this.runDocker(['network', 'create', gatewayNetwork]);
await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans']);
if (this.mpmContainer) {
await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]);
const { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues } = await this.prepare(module);
try {
// Recreate stopped containers and project networks before each start. This
// prevents Compose v1 from reconciling stale Docker Desktop network defaults;
// named data volumes are deliberately left untouched.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans'], cleanupValues);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
await this.runDocker(['network', 'create', gatewayNetwork]);
await this.runCompose(module.path, projectName, composePath, overridePath, ['up', '-d', '--build', '--remove-orphans'], moduleValues);
if (this.mpmContainer) {
await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
await this.runDocker(['network', 'connect', gatewayNetwork, this.mpmContainer]);
}
this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`);
} finally {
await rm(overridePath, { force: true });
}
this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`);
}
async stop(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop']);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`);
const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
try {
await this.runCompose(module.path, projectName, composePath, overridePath, ['stop'], cleanupValues);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
this.logger.log(`Container-Stack für "${module.moduleId}" gestoppt`);
} finally {
await rm(overridePath, { force: true });
}
}
async remove(module: ModuleRecord): Promise<void> {
const { composePath, overridePath, projectName, gatewayNetwork } = await this.prepare(module);
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
// Compose down removes every app/database container and its networks. Named
// volumes remain, so uninstalling code does not silently destroy database data.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--remove-orphans']);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
await rm(overridePath, { force: true });
this.logger.log(`Container für "${module.moduleId}" entfernt; Datenvolumes bleiben erhalten`);
const { composePath, overridePath, projectName, gatewayNetwork, cleanupValues } = await this.prepare(module);
try {
if (this.mpmContainer) await this.runDocker(['network', 'disconnect', '-f', gatewayNetwork, this.mpmContainer], true);
// Removing a module is an explicit delete: remove its project-scoped data volumes too.
await this.runCompose(module.path, projectName, composePath, overridePath, ['down', '--volumes', '--remove-orphans'], cleanupValues);
await this.runDocker(['network', 'rm', gatewayNetwork], true);
this.logger.log(`Container und Datenvolumes für "${module.moduleId}" entfernt`);
} finally {
await rm(overridePath, { force: true });
}
}
private async prepare(module: ModuleRecord): Promise<{
@@ -63,6 +88,8 @@ export class ModuleContainerManager {
overridePath: string;
projectName: string;
gatewayNetwork: string;
moduleValues: Record<string, string>;
cleanupValues: Record<string, string>;
}> {
if (!module.composeFile || !module.appService) {
throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration');
@@ -77,37 +104,70 @@ export class ModuleContainerManager {
if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML');
const compose = document.toJS() as Record<string, unknown>;
this.validateCompose(compose, module);
const serviceMap = compose.services as Record<string, unknown>;
const moduleValues = await this.configurationService.values(module);
// Compose validates required interpolations even for stop/down. Supply
// harmless placeholders only to cleanup commands so incomplete modules
// can still be stopped and removed. Never pass these placeholders to `up`.
const cleanupValues = { ...moduleValues };
for (const field of module.configuration) {
cleanupValues[field.key] ??= 'mpm-unset-configuration';
}
for (const match of source.matchAll(/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)/g)) {
const key = match[1];
if (key && !Object.hasOwn(cleanupValues, key) &&
!/^(PATH|HOME|TMPDIR|NODE_OPTIONS|PYTHONPATH|DOCKER_HOST|DOCKER_CONTEXT)$/.test(key)) {
cleanupValues[key] = 'mpm-unset-configuration';
}
}
const projectName = `mpm-${module.moduleId}`;
const gatewayNetwork = `mpm-module-${module.moduleId}-gateway`;
// Keep generated secrets outside the package/build context so Dockerfiles
// cannot accidentally copy them into an application image.
const overridePath = path.join(tmpdir(), 'mpm-compose', `${module.moduleId}.yml`);
const overrideServices: Record<string, Record<string, unknown>> = {
[module.appService]: {
container_name: `mpm-${module.moduleId}-app`,
environment: {
PORT: String(module.internalPort),
NODE_ENV: process.env.NODE_ENV ?? 'production',
MPM_MODULE_DATA_DIR: '/var/lib/mpm-module',
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
},
volumes: ['mpm-runtime-data:/var/lib/mpm-module'],
networks: {
default: {},
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
},
security_opt: ['no-new-privileges:true'],
},
};
for (const field of module.configuration) {
const value = moduleValues[field.key];
if (value === undefined) continue;
for (const serviceName of field.services) {
if (!Object.hasOwn(serviceMap, serviceName)) {
throw new BadRequestException(`Konfiguration ${field.key} verweist auf fehlenden Compose-Service "${serviceName}"`);
}
const serviceOverride = overrideServices[serviceName] ?? {};
const environment = (serviceOverride.environment ?? {}) as Record<string, string>;
overrideServices[serviceName] = {
...serviceOverride,
environment: { ...environment, [field.key]: value },
};
}
}
const override = {
version: '3.8',
services: {
[module.appService]: {
container_name: `mpm-${module.moduleId}-app`,
environment: {
PORT: String(module.internalPort),
NODE_ENV: process.env.NODE_ENV ?? 'production',
MPM_MODULE_DATA_DIR: '/var/lib/mpm-module',
MPM_MODULE_IDENTITY_KEY: this.identityService.keyForModule(module.moduleId),
},
volumes: ['mpm-runtime-data:/var/lib/mpm-module'],
networks: {
default: {},
'mpm-gateway': { aliases: [`mpm-${module.moduleId}`] },
},
security_opt: ['no-new-privileges:true'],
},
},
services: overrideServices,
volumes: { 'mpm-runtime-data': {} },
networks: { 'mpm-gateway': { external: true, name: gatewayNetwork } },
};
await mkdir(path.dirname(overridePath), { recursive: true, mode: 0o700 });
await writeFile(overridePath, stringify(override), { mode: 0o600 });
return { composePath, overridePath, projectName, gatewayNetwork };
await chmod(overridePath, 0o600);
return { composePath, overridePath, projectName, gatewayNetwork, moduleValues, cleanupValues };
}
private validateCompose(compose: Record<string, unknown>, module: ModuleRecord): void {
@@ -204,41 +264,79 @@ export class ModuleContainerManager {
}
}
private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[]): Promise<void> {
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd);
private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[], config: Record<string, string>): Promise<void> {
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd, false, config);
}
private runDocker(args: string[], ignoreFailure = false): Promise<void> {
return this.run('docker', args, process.cwd(), ignoreFailure);
}
private run(command: string, args: string[], cwd: string, ignoreFailure = false): Promise<void> {
private run(command: string, args: string[], cwd: string, ignoreFailure = false, extraEnv: Record<string, string> = {}): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
...extraEnv,
PATH: process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
// Do not let a root-owned /root/.docker configuration affect a child
// command started by the unprivileged backend user.
HOME: '/tmp',
DOCKER_HOST: this.dockerHost,
},
stdio: ['ignore', 'ignore', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const keepTail = (current: string, chunk: string): string => (current + chunk).slice(-12_000);
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => { stdout = keepTail(stdout, chunk); });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-2000); });
child.stderr.on('data', (chunk: string) => { stderr = keepTail(stderr, chunk); });
let processStartFailed = false;
child.once('error', (error) => {
if (ignoreFailure) resolve();
else reject(new Error(`${command} konnte nicht gestartet werden: ${error.message}`));
else {
processStartFailed = true;
const errorCode = (error as NodeJS.ErrnoException).code ?? 'unbekannt';
this.logger.error(`${command} konnte nicht gestartet werden (${errorCode})`);
reject(new ModuleCommandError(null, `Der Befehl „${command}“ konnte nicht gestartet werden. Prüfe, ob Docker auf dem System verfügbar ist.`));
}
});
child.once('close', (code) => {
if (processStartFailed) return;
if (code === 0 || ignoreFailure) resolve();
else {
this.logger.error(`${command} ${args[args.length - 1]} schlug mit Status ${code} fehl: ${stderr.trim()}`);
reject(new Error(`${command} schlug mit Status ${code} fehl`));
const diagnostic = this.sanitizeDiagnostic(`${stdout}\n${stderr}`, extraEnv);
const exitCode = code ?? 1;
this.logger.error(`${command} schlug mit Status ${exitCode} fehl: ${diagnostic}`);
reject(new ModuleCommandError(exitCode, diagnostic));
}
});
});
}
/** Entfernt Umgebungswerte, ANSI-Codes und wahrscheinliche Secrets aus CLI-Ausgaben. */
private sanitizeDiagnostic(output: string, environment: Record<string, string>): string {
let safe = output
.replace(/\u001b\[[0-9;]*m/g, '')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.replace(/\r/g, '')
.trim();
const configuredValues = Object.values(environment)
.filter((value) => value.length >= 4)
.sort((left, right) => right.length - left.length);
for (const value of configuredValues) {
safe = safe.split(value).join('[geschwärzt]');
}
safe = safe.replace(
/(["']?[A-Z0-9_-]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|AUTHORIZATION|COOKIE)[A-Z0-9_-]*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi,
'$1[geschwärzt]',
);
if (!safe) return 'Docker Compose ist ohne eine Fehlerbeschreibung fehlgeschlagen.';
return safe.slice(-2_500);
}
}

View File

@@ -25,6 +25,8 @@ function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides,
};
}
@@ -253,4 +255,4 @@ describe('ModuleGatewayMiddleware', () => {
proxySpy.mockRestore();
});
});
});

View File

@@ -1,8 +1,10 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { moduleManifestSchema, type ModuleManifest } from './manifest.types';
import { secureModuleDirectory } from './module-filesystem';
import { parseDocument } from 'yaml';
/** Maximale Größe eines Modul-Pakets (10 MB). */
const MAX_PACKAGE_SIZE_BYTES = 10 * 1024 * 1024;
@@ -76,6 +78,21 @@ export class ModuleInstaller {
if (!zip.getEntry(manifest.composeFile)) {
throw new BadRequestException(`Container-Konfiguration ${manifest.composeFile} fehlt im Paket`);
}
const composeDocument = parseDocument(zip.getEntry(manifest.composeFile)!.getData().toString('utf8'), { uniqueKeys: true });
if (composeDocument.errors.length) {
throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition');
}
const compose = composeDocument.toJS() as { services?: Record<string, unknown> } | null;
if (!compose?.services || typeof compose.services !== 'object' || Array.isArray(compose.services)) {
throw new BadRequestException('Compose-Datei enthält keine gültige Service-Definition');
}
for (const field of manifest.configuration) {
for (const service of field.services) {
if (!Object.hasOwn(compose.services, service)) {
throw new BadRequestException(`Konfiguration ${field.key} verweist auf fehlenden Compose-Service "${service}"`);
}
}
}
return manifest;
}
@@ -125,6 +142,56 @@ export class ModuleInstaller {
return { directory, manifest };
}
/** Stages and atomically swaps an installed module directory, retaining a rollback copy. */
async replace(
buffer: Buffer,
manifest: ModuleManifest,
modulesDir: string,
): Promise<{ directory: string; backupDirectory: string }> {
const directory = path.join(modulesDir, manifest.id);
const suffix = randomUUID();
const stagingDirectory = path.join(modulesDir, `.update-${manifest.id}-${suffix}`);
const backupDirectory = path.join(modulesDir, `.backup-${manifest.id}-${suffix}`);
const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(buffer);
const resolvedStage = path.resolve(stagingDirectory);
for (const entry of zip.getEntries()) {
const entryName = entry.entryName;
if (entryName.startsWith('/') || entryName.includes('..') || entryName.includes('\\') || /^[A-Za-z]:/.test(entryName)) {
throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
const resolvedEntry = path.resolve(resolvedStage, entryName);
if (!resolvedEntry.startsWith(resolvedStage + path.sep)) throw new BadRequestException(`Unsicherer Pfad im Paket: ${entryName}`);
}
try {
await mkdir(stagingDirectory, { recursive: true });
zip.extractAllTo(resolvedStage, true);
await secureModuleDirectory(resolvedStage);
await writeFile(path.join(stagingDirectory, '.installed.json'),
JSON.stringify({ installedAt: new Date().toISOString(), manifest }, null, 2), 'utf8');
await rename(directory, backupDirectory);
try {
await rename(stagingDirectory, directory);
} catch (error) {
await rename(backupDirectory, directory);
throw error;
}
return { directory, backupDirectory };
} catch (error) {
await rm(stagingDirectory, { recursive: true, force: true });
throw error;
}
}
async rollbackReplacement(directory: string, backupDirectory: string): Promise<void> {
await rm(directory, { recursive: true, force: true });
await rename(backupDirectory, directory);
}
async finalizeReplacement(backupDirectory: string): Promise<void> {
await rm(backupDirectory, { recursive: true, force: true });
}
/** Entfernt eine Modul-Installation vom Dateisystem. */
async remove(modulesDir: string, moduleId: string): Promise<void> {
const directory = path.join(modulesDir, moduleId);

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { ModuleRecord } from './manifest.types';
import type { ModuleConfigurationField } from './manifest.types';
/** Modul-Berechtigung eines Benutzers. */
export interface ModulePermissionRecord {
@@ -89,7 +90,7 @@ export class ModulePermissionRepository {
const result = await this.database.query(
`SELECT m.id, m.module_id, m.name, m.slug, m.version, m.description, m.author,
m.path, m.status, m.internal_port, m.healthcheck_url, m.enabled,
m.created_at, m.updated_at
m.created_at, m.updated_at, m.configuration_schema, m.configuration_ready
FROM user_module_permissions p
JOIN modules m ON m.id = p.module_id
WHERE p.user_id = $1 AND p.permission = 'GRANTED' AND m.enabled
@@ -111,6 +112,8 @@ export class ModulePermissionRepository {
enabled: row.enabled,
createdAt: row.created_at,
updatedAt: row.updated_at,
configuration: row.configuration_schema as ModuleConfigurationField[],
configurationReady: row.configuration_ready as boolean,
}));
}
@@ -123,4 +126,4 @@ export class ModulePermissionRepository {
createdAt: row.created_at,
};
}
}
}

View File

@@ -28,6 +28,8 @@ function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides,
};
}
@@ -219,4 +221,4 @@ describe('ModulePermissionsService', () => {
expect(modules[0].moduleId).toBe('demo');
});
});
});
});

View File

@@ -46,7 +46,18 @@ export class ModuleProcessManager implements OnModuleDestroy {
if (module.composeFile && module.appService) {
await secureModuleDirectory(module.path);
await this.containerManager.start(module);
try {
await this.containerManager.start(module);
} catch (error) {
// Compose kann beim Build oder beim Start teilweise Container angelegt
// haben. Bereinige den Stack, bevor der ursprüngliche Fehler zurückgeht.
try {
await this.containerManager.remove(module);
} catch {
this.logger.error(`Teilweise gestarteter Container-Stack für "${module.moduleId}" konnte nicht bereinigt werden`);
}
throw error;
}
this.containerModules.set(module.moduleId, module);
return;
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types';
import type { ModuleConfigurationField, ModuleManifest, ModuleRecord, ModuleStatus } from './manifest.types';
interface ModuleRow {
id: string;
@@ -19,11 +19,13 @@ interface ModuleRow {
updated_at: Date;
compose_file: string | null;
app_service: string | null;
configuration_schema: ModuleConfigurationField[];
configuration_ready: boolean;
}
const MODULE_COLUMNS = `id, module_id, name, slug, version, description, author, path,
status, internal_port, healthcheck_url, enabled, created_at, updated_at,
compose_file, app_service`;
compose_file, app_service, configuration_schema, configuration_ready`;
/**
* Modul-Repository (Infrastructure): Datenbankzugriffe für die Modul-Registry.
@@ -84,8 +86,8 @@ export class ModuleRepository {
const result = await this.database.query<ModuleRow>(
`INSERT INTO modules
(module_id, name, slug, version, description, author, path, status, internal_port, healthcheck_url,
compose_file, app_service)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11)
compose_file, app_service, configuration_schema, configuration_ready)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'INSTALLED', $8, $9, $10, $11, $12::jsonb, $13)
RETURNING ${MODULE_COLUMNS}`,
[
manifest.id,
@@ -99,6 +101,8 @@ export class ModuleRepository {
manifest.healthcheck,
manifest.composeFile ?? null,
manifest.appService ?? null,
JSON.stringify(manifest.configuration),
manifest.configuration.every((field) => !field.required || field.defaultValue !== undefined),
],
);
return this.mapRow(result.rows[0]);
@@ -118,6 +122,18 @@ export class ModuleRepository {
);
}
async updateManifest(id: string, manifest: ModuleManifest, configurationReady: boolean): Promise<void> {
await this.database.query(
`UPDATE modules SET name = $2, version = $3, description = $4, author = $5,
internal_port = $6, healthcheck_url = $7, compose_file = $8, app_service = $9,
configuration_schema = $10::jsonb, configuration_ready = $11, updated_at = now()
WHERE id = $1`,
[id, manifest.name, manifest.version, manifest.description, manifest.author, manifest.port,
manifest.healthcheck, manifest.composeFile ?? null, manifest.appService ?? null,
JSON.stringify(manifest.configuration), configurationReady],
);
}
async delete(id: string): Promise<void> {
await this.database.query('DELETE FROM modules WHERE id = $1', [id]);
}
@@ -140,6 +156,15 @@ export class ModuleRepository {
updatedAt: row.updated_at,
composeFile: row.compose_file,
appService: row.app_service,
configuration: row.configuration_schema,
configurationReady: row.configuration_ready,
};
}
async updateConfigurationReady(id: string, ready: boolean): Promise<void> {
await this.database.query(
'UPDATE modules SET configuration_ready = $2, updated_at = now() WHERE id = $1',
[id, ready],
);
}
}

View File

@@ -25,6 +25,7 @@ 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';
import type { ModuleConfigurationState } from './module-configuration.service';
/** Modul-Daten in API-Antworten. */
interface ModuleResponse {
@@ -40,6 +41,8 @@ interface ModuleResponse {
healthcheckUrl: string;
enabled: boolean;
createdAt: string;
configuration: ModuleRecord['configuration'];
configurationReady: boolean;
}
function toModuleResponse(module: ModuleRecord): ModuleResponse {
@@ -56,6 +59,8 @@ function toModuleResponse(module: ModuleRecord): ModuleResponse {
healthcheckUrl: module.healthcheckUrl,
enabled: module.enabled,
createdAt: module.createdAt.toISOString(),
configuration: module.configuration,
configurationReady: module.configurationReady,
};
}
@@ -81,6 +86,21 @@ export class ModulesController {
return { module: toModuleResponse(module) };
}
@Get(':id/configuration')
async getConfiguration(@Param('id', ParseUUIDPipe) id: string): Promise<ModuleConfigurationState> {
return this.modulesService.getConfiguration(id);
}
@Patch(':id/configuration')
async saveConfiguration(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { values?: unknown; clearKeys?: unknown },
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest & Request,
): Promise<ModuleConfigurationState> {
return this.modulesService.saveConfiguration(id, body, actor, request.ip ?? null);
}
@Post('install')
@UseInterceptors(FileInterceptor('package'))
async install(
@@ -147,8 +167,8 @@ export class ModulesController {
@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 };
): Promise<{ success: true; cleanupWarning?: string }> {
const result = await this.modulesService.remove(id, actor, request.ip ?? null);
return { success: true, ...result };
}
}
}

View File

@@ -25,6 +25,7 @@ import { ModuleIdentityService } from './module-identity.service';
import { MarketplaceController } from './marketplace.controller';
import { MarketplaceService } from './marketplace.service';
import { ModuleContainerManager } from './module-container-manager';
import { ModuleConfigurationService } from './module-configuration.service';
/** Modul-System: Installation, Lifecycle, Prozessverwaltung, Gateway. */
@Module({
@@ -36,6 +37,7 @@ import { ModuleContainerManager } from './module-container-manager';
ModuleProcessManager,
ModuleIdentityService,
ModuleContainerManager,
ModuleConfigurationService,
ModuleHealthChecker,
ModulesService,
SessionService,

View File

@@ -23,6 +23,7 @@ function createManifest(overrides: Partial<ModuleManifest> = {}): ModuleManifest
port: 41001,
healthcheck: '/health',
apiVersion: 'v1',
configuration: [],
...overrides,
};
}
@@ -45,6 +46,8 @@ function createModuleRecord(overrides: Partial<ModuleRecord> = {}): ModuleRecord
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
configuration: [],
configurationReady: true,
...overrides,
};
}
@@ -190,7 +193,7 @@ const TEST_CONFIG: AppConfig = {
loginRateLimitWindowMinutes: 5,
},
adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs' },
runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' },
marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} },
};
@@ -214,6 +217,7 @@ describe('ModulesService', () => {
processManager as unknown as ModuleProcessManager,
healthChecker as unknown as ModuleHealthChecker,
auditService as unknown as AuditService,
{ resolvedValues: async () => ({}), state: async () => ({ ready: true, fields: [] }), save: async () => ({ state: { ready: true, fields: [] }, changedKeys: [] }) } as never,
TEST_CONFIG,
);
});

View File

@@ -2,7 +2,9 @@ import {
BadRequestException,
ConflictException,
Inject,
InternalServerErrorException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../config/config.tokens';
@@ -11,8 +13,10 @@ 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 { ModuleCommandError } from './module-container-manager';
import { ModuleRepository } from './module.repository';
import type { ModuleRecord } from './manifest.types';
import { ModuleConfigurationService } from './module-configuration.service';
/**
* Modul-Verwaltung (Application-Layer): Lifecycle-Logik für Module.
@@ -27,12 +31,15 @@ import type { ModuleRecord } from './manifest.types';
*/
@Injectable()
export class ModulesService {
private readonly logger = new Logger(ModulesService.name);
constructor(
private readonly moduleRepository: ModuleRepository,
private readonly installer: ModuleInstaller,
private readonly processManager: ModuleProcessManager,
private readonly healthChecker: ModuleHealthChecker,
private readonly auditService: AuditService,
private readonly configurationService: ModuleConfigurationService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@@ -48,6 +55,31 @@ export class ModulesService {
return module;
}
async getConfiguration(id: string) {
return this.configurationService.state(await this.getById(id));
}
async saveConfiguration(
id: string,
input: { values?: unknown; clearKeys?: unknown },
actor: ActingUser,
ipAddress: string | null,
) {
const module = await this.getById(id);
const result = await this.configurationService.save(module, input);
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.MODULE_CONFIG_UPDATED,
details: { moduleId: module.moduleId, keys: result.changedKeys },
ipAddress,
});
if (result.changedKeys.length && module.status === 'RUNNING') {
await this.restart(id, actor, ipAddress);
}
return this.configurationService.state(await this.getById(id));
}
/** Installiert ein Modul-Paket (ZIP) und registriert es. */
async install(
packageBuffer: Buffer,
@@ -76,7 +108,27 @@ export class ModulesService {
manifest,
this.config.runtime.modulesDir,
);
const module = await this.moduleRepository.create(manifest, directory);
let module: ModuleRecord;
try {
module = await this.moduleRepository.create(manifest, directory);
} catch {
let cleanupFailed = false;
try {
await this.installer.remove(this.config.runtime.modulesDir, manifest.id);
} catch {
cleanupFailed = true;
this.logger.error(`Temporäre Dateien für Modul ${manifest.id} konnten nach fehlgeschlagener Registrierung nicht entfernt werden`);
}
throw new InternalServerErrorException({
statusCode: 500,
error: 'Internal Server Error',
code: 'MODULE_INSTALL_REGISTRATION_FAILED',
message: `Modul „${manifest.name}“ konnte nicht in MPM registriert werden.`,
diagnostic: cleanupFailed
? 'Die Registrierung ist fehlgeschlagen und die temporären Dateien konnten nicht bereinigt werden. Bitte Plattform-Logs prüfen.'
: 'Die Registrierung in der Datenbank ist fehlgeschlagen; die entpackten Dateien wurden zurückgerollt.',
});
}
await this.auditService.record({
userId: actor.id,
@@ -92,6 +144,66 @@ export class ModulesService {
return this.installer.validatePackage(packageBuffer);
}
async updateFromMarketplace(
id: string,
packageBuffer: Buffer,
actor: ActingUser,
ipAddress: string | null,
): Promise<ModuleRecord> {
const current = await this.getById(id);
if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) {
throw new ConflictException('Das Modul muss einen stabilen Status haben, bevor ein Update gestartet werden kann');
}
const manifest = await this.installer.validatePackage(packageBuffer);
const previousManifest = await this.installer.readInstalledManifest(current.path);
if (!previousManifest) throw new InternalServerErrorException('Installiertes Modulmanifest kann vor dem Update nicht gelesen werden');
if (manifest.id !== current.moduleId || manifest.slug !== current.slug || manifest.port !== current.internalPort ||
manifest.composeFile !== current.composeFile || manifest.appService !== current.appService) {
throw new BadRequestException('Das Update muss Modul-ID, URL-Slug, Port und Compose-Service beibehalten');
}
const wasRunning = current.status === 'RUNNING';
if (wasRunning) await this.stop(id, actor, ipAddress);
let replacement: { directory: string; backupDirectory: string } | undefined;
try {
replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir);
const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description,
author: manifest.author, configuration: manifest.configuration };
const configuration = await this.configurationService.state(candidate);
await this.moduleRepository.updateManifest(id, manifest, configuration.ready);
if (wasRunning) await this.start(id, actor, ipAddress);
await this.installer.finalizeReplacement(replacement.backupDirectory).catch((cleanupError: unknown) => {
this.logger.warn(`Alte Moduldateien für ${current.moduleId} konnten nicht bereinigt werden: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
});
await this.auditService.record({
userId: actor.id,
username: actor.username,
action: AUDIT_ACTIONS.MODULE_UPDATED,
details: { moduleId: current.moduleId, fromVersion: current.version, toVersion: manifest.version },
ipAddress,
});
return await this.getById(id);
} catch (error) {
if (replacement) {
try {
if (wasRunning) {
try { await this.processManager.stop(current.moduleId); } catch { /* Continue restoring the previous package. */ }
}
await this.installer.rollbackReplacement(replacement.directory, replacement.backupDirectory);
await this.moduleRepository.updateManifest(id, previousManifest, current.configurationReady);
await this.moduleRepository.updateStatus(id, wasRunning ? 'STOPPED' : current.status);
if (wasRunning) await this.start(id, actor, ipAddress);
} catch (rollbackError) {
this.logger.error(`Rollback des Modulupdates für ${current.moduleId} fehlgeschlagen: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
throw new InternalServerErrorException('Update fehlgeschlagen; die vorherige Modulversion konnte nicht vollständig wiederhergestellt werden. Plattform-Logs prüfen.');
}
} else if (wasRunning) {
try { await this.start(id, actor, ipAddress); } catch { /* Preserve the original update error. */ }
}
throw error;
}
}
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
return this.moduleRepository.findByModuleId(moduleId);
}
@@ -103,6 +215,7 @@ export class ModulesService {
if (module.status === 'RUNNING' || module.status === 'STARTING') {
return module;
}
await this.configurationService.resolvedValues(module);
await this.moduleRepository.updateStatus(id, 'STARTING');
try {
@@ -116,11 +229,21 @@ export class ModulesService {
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)}`,
);
let statusUpdateFailed = false;
try {
await this.moduleRepository.updateStatus(id, 'ERROR');
} catch {
statusUpdateFailed = true;
this.logger.error(`Fehlerstatus für Modul ${module.moduleId} konnte nicht gespeichert werden`);
}
try {
await this.processManager.stop(module.moduleId);
} catch {
// Erhalte den ursprünglichen Startfehler; ein fehlgeschlagener Cleanup
// darf ihn nicht durch eine zweite Ausnahme ersetzen.
this.logger.error(`Cleanup nach fehlgeschlagenem Start von ${module.moduleId} ist fehlgeschlagen`);
}
throw this.lifecycleFailure('start', module, error, statusUpdateFailed);
}
return (await this.moduleRepository.findById(id)) ?? module;
}
@@ -161,10 +284,14 @@ export class ModulesService {
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)}`,
);
let statusUpdateFailed = false;
try {
await this.moduleRepository.updateStatus(id, 'ERROR');
} catch {
statusUpdateFailed = true;
this.logger.error(`Fehlerstatus für Modul ${module.moduleId} konnte nicht gespeichert werden`);
}
throw this.lifecycleFailure('stop', module, error, statusUpdateFailed);
}
return (await this.moduleRepository.findById(id)) ?? module;
}
@@ -211,17 +338,31 @@ export class ModulesService {
}
/** Entfernt ein Modul vollständig (Prozess, Dateien, Registry). */
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<void> {
async remove(id: string, actor: ActingUser, ipAddress: string | null): Promise<{ cleanupWarning?: string }> {
const module = await this.getById(id);
if (module.status === 'RUNNING' || module.status === 'STARTING') {
await this.stop(id, actor, ipAddress);
}
await this.processManager.remove(module);
try {
await this.processManager.remove(module);
} catch (error) {
throw this.lifecycleFailure('remove', module, error);
}
await this.moduleRepository.delete(id);
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId);
try {
await this.moduleRepository.delete(id);
} catch (error) {
throw this.lifecycleFailure('remove', module, error);
}
let cleanupWarning: string | undefined;
try {
await this.installer.remove(this.config.runtime.modulesDir, module.moduleId);
} catch {
cleanupWarning = 'Das Modul wurde aus MPM entfernt, aber seine Dateien konnten nicht vollständig gelöscht werden.';
this.logger.error(`Dateien von Modul ${module.moduleId} konnten nach dem Entfernen nicht bereinigt werden`);
}
await this.auditService.record({
userId: actor.id,
@@ -230,6 +371,7 @@ export class ModulesService {
details: { moduleId: module.moduleId },
ipAddress,
});
return cleanupWarning ? { cleanupWarning } : {};
}
/** Führt einen Healthcheck für ein Modul aus (ohne Statusänderung). */
@@ -245,6 +387,31 @@ export class ModulesService {
}
}
private lifecycleFailure(
action: 'start' | 'stop' | 'remove',
module: ModuleRecord,
error: unknown,
statusUpdateFailed = false,
): BadRequestException {
const actionText = { start: 'gestartet', stop: 'gestoppt', remove: 'entfernt' }[action];
const commandDiagnostic = error instanceof ModuleCommandError
? error.diagnostic
: error instanceof Error && error.message.startsWith('Healthcheck fehlgeschlagen:')
? error.message
: 'Die technische Ursache steht im Plattform-Log.';
const diagnostic = statusUpdateFailed
? `${commandDiagnostic} MPM konnte den Fehlerstatus nicht speichern; bitte Status erneut laden.`
: commandDiagnostic;
return new BadRequestException({
statusCode: 400,
error: 'Bad Request',
code: `MODULE_${action.toUpperCase()}_FAILED`,
message: `Modul „${module.name}“ konnte nicht ${actionText} werden.`,
diagnostic,
});
}
private async auditLifecycle(
module: ModuleRecord,
action: AuditAction,