Files
mpm/apps/platform-backend/src/modules/module-container-manager.ts

245 lines
13 KiB
TypeScript

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<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]);
}
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`);
}
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`);
}
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<string, unknown>;
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<string, unknown>, 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<string, unknown>;
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<string, unknown>;
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<string, unknown>
: {};
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<string, unknown> {
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<string, unknown>;
}
private validateBuild(build: unknown, modulePath: string): void {
const context = typeof build === 'string'
? build
: build && typeof build === 'object' && !Array.isArray(build)
? String((build as Record<string, unknown>).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<void> {
return this.run('docker-compose', ['-p', project, '-f', composePath, '-f', overridePath, ...args], cwd);
}
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> {
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`));
}
});
});
}
}