import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { spawn } from 'node:child_process'; import { 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'; const SAFE_SERVICE_KEYS = new Set([ 'image', 'build', 'command', 'entrypoint', 'environment', 'depends_on', 'volumes', 'healthcheck', 'working_dir', 'user', 'restart', 'expose', 'networks', 'hostname', 'logging', 'mem_limit', 'cpus', 'pids_limit', 'init', 'tmpfs', 'labels', 'stop_grace_period', 'read_only', 'tty', 'stdin_open', ]); /** Orchestriert einen isolierten Docker-Compose-Stack für jedes Modul. */ @Injectable() export class ModuleContainerManager { private readonly logger = new Logger('ModuleContainers'); 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) {} async start(module: ModuleRecord): Promise { 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]); } this.logger.log(`Container-Stack für "${module.moduleId}" gestartet`); } async stop(module: ModuleRecord): Promise { 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`); } async remove(module: ModuleRecord): Promise { 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`); } private async prepare(module: ModuleRecord): Promise<{ composePath: string; overridePath: string; projectName: string; gatewayNetwork: string; }> { if (!module.composeFile || !module.appService) { throw new BadRequestException('Dieses Modul hat keine Docker-Compose-Konfiguration'); } const root = path.resolve(module.path); const composePath = path.resolve(root, module.composeFile); if (!composePath.startsWith(root + path.sep)) throw new BadRequestException('Compose-Datei liegt außerhalb des Modulpakets'); const source = await readFile(composePath, 'utf8').catch(() => { throw new BadRequestException(`Compose-Datei "${module.composeFile}" wurde nicht gefunden`); }); const document = parseDocument(source, { uniqueKeys: true }); if (document.errors.length) throw new BadRequestException('Compose-Datei enthält ungültiges YAML'); const compose = document.toJS() as Record; this.validateCompose(compose, module); 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 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'], }, }, 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 }; } private validateCompose(compose: Record, module: ModuleRecord): void { if (!compose || typeof compose !== 'object' || Array.isArray(compose)) { throw new BadRequestException('Compose-Datei muss ein YAML-Objekt enthalten'); } const topLevel = new Set(['version', 'services', 'volumes', 'networks']); if (Object.keys(compose).some((key) => !topLevel.has(key))) { throw new BadRequestException('Compose darf nur services, volumes und networks enthalten'); } const services = compose.services; if (!services || typeof services !== 'object' || Array.isArray(services)) { throw new BadRequestException('Compose benötigt mindestens einen Service'); } const serviceMap = services as Record; if (!Object.hasOwn(serviceMap, module.appService!)) { throw new BadRequestException(`Compose-Service "${module.appService}" fehlt`); } const definedNetworks = this.record(compose.networks, 'networks'); const definedVolumes = this.record(compose.volumes, 'volumes'); if (Object.hasOwn(definedNetworks, 'mpm-gateway') || Object.hasOwn(definedVolumes, 'mpm-runtime-data')) { throw new BadRequestException('Compose verwendet einen für MPM reservierten Netzwerk- oder Volume-Namen'); } for (const [name, rawService] of Object.entries(serviceMap)) { if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/.test(name) || !rawService || typeof rawService !== 'object' || Array.isArray(rawService)) { throw new BadRequestException('Compose enthält einen ungültigen Service'); } const service = rawService as Record; if (Object.keys(service).some((key) => !SAFE_SERVICE_KEYS.has(key))) { throw new BadRequestException(`Compose-Service "${name}" enthält nicht erlaubte Optionen`); } if (service.ports !== undefined || service.privileged !== undefined || service.cap_add !== undefined || service.devices !== undefined || service.network_mode !== undefined || service.pid !== undefined || service.ipc !== undefined || service.volumes_from !== undefined || service.env_file !== undefined || service.container_name !== undefined || service.secrets !== undefined || service.configs !== undefined) { throw new BadRequestException(`Compose-Service "${name}" darf keine Host- oder privilegierten Ressourcen verwenden`); } if (service.build !== undefined) this.validateBuild(service.build, module.path); if (service.volumes !== undefined) this.validateVolumes(service.volumes); service.security_opt = ['no-new-privileges:true']; } for (const [name, volume] of Object.entries(definedVolumes)) { if (name === 'mpm-runtime-data' || (volume !== undefined && volume !== null && (!volume || typeof volume !== 'object' || Array.isArray(volume) || Object.keys(volume).length > 0))) { throw new BadRequestException('Compose darf nur projektlokale Datenvolumes definieren'); } } for (const [name, network] of Object.entries(definedNetworks)) { const networkConfig = network && typeof network === 'object' && !Array.isArray(network) ? network as Record : {}; if (name === 'mpm-gateway' || (network !== undefined && network !== null && (!network || typeof network !== 'object' || Array.isArray(network) || Object.keys(networkConfig).some((key) => !['internal', 'attachable', 'labels'].includes(key))))) { throw new BadRequestException('Compose darf keine externen Netzwerke verwenden'); } } } private record(value: unknown, label: string): Record { if (value === undefined) return {}; if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new BadRequestException(`Compose-${label} muss ein Objekt sein`); } return value as Record; } private validateBuild(build: unknown, modulePath: string): void { const context = typeof build === 'string' ? build : build && typeof build === 'object' && !Array.isArray(build) ? String((build as Record).context ?? '.') : ''; if (!context || path.isAbsolute(context) || context.split(/[\\/]/).includes('..')) { throw new BadRequestException('Build-Kontext muss innerhalb des Modulpakets liegen'); } const resolved = path.resolve(modulePath, context); if (resolved !== modulePath && !resolved.startsWith(modulePath + path.sep)) { throw new BadRequestException('Build-Kontext liegt außerhalb des Modulpakets'); } } private validateVolumes(volumes: unknown): void { if (!Array.isArray(volumes)) throw new BadRequestException('Compose-Volumes müssen als Liste angegeben werden'); for (const volume of volumes) { if (typeof volume !== 'string') throw new BadRequestException('Compose-Volume-Angabe ist ungültig'); const parts = volume.split(':'); const [source, target, mode] = parts; if (parts.length > 3 || !target || !target.startsWith('/') || (source && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(source)) || (mode !== undefined && !/^(ro|rw)(,ro|,rw)?$/.test(mode))) { throw new BadRequestException('Compose darf keine Host-Verzeichnisse mounten'); } } } private runCompose(cwd: string, project: string, composePath: string, overridePath: string, args: string[]): Promise { return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd); } private runDocker(args: string[], ignoreFailure = false): Promise { return this.run('docker', args, process.cwd(), ignoreFailure); } private run(command: string, args: string[], cwd: string, ignoreFailure = false): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env: { 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'], }); let stderr = ''; child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk: string) => { stderr = (stderr + chunk).slice(-2000); }); child.once('error', (error) => { if (ignoreFailure) resolve(); else reject(new Error(`${command} konnte nicht gestartet werden: ${error.message}`)); }); child.once('close', (code) => { 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`)); } }); }); } }