245 lines
13 KiB
TypeScript
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`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|