import { spawn } from 'node:child_process' import { readFile, realpath, writeFile } from 'node:fs/promises' import { isAbsolute, relative, resolve, sep } from 'node:path' import vpsConfig from '#config/vps' import UserError from '#exceptions/user_error' import ComposeVersion from '#models/compose_version' import dockerService from '#services/docker_service' import { toFileContent } from '#services/file_content' import jobRunner, { cleanEnv, type JobContext, type JobMeta } from '#services/job_runner' export interface ComposeTarget { project: string service: string | null workingDir: string file: string /** * Containers of the same compose project: "up -d" may recreate them all. */ containers: string[] editable: boolean /** * Why the file can only be read. */ readOnlyReason: string | null } export interface ComposeCheck { ok: boolean output: string } export type ComposeSaveResult = | { ok: false; output: string } | { ok: true; changed: boolean; output: string; jobId: number | null } /** * Versions kept for each file. */ const KEPT_VERSIONS = 30 /** * Reads and edits the docker-compose.yml of a container, found with the * labels "docker compose" puts on the containers it creates. */ class ComposeService { async locate(containerId: string): Promise { const info = await dockerService.docker.getContainer(containerId).inspect() const labels = info.Config.Labels ?? {} const project = labels['com.docker.compose.project'] const workingDir = labels['com.docker.compose.project.working_dir'] const files = (labels['com.docker.compose.project.config_files'] ?? '') .split(',') .map((file) => file.trim()) .filter(Boolean) if (!project || !workingDir || files.length === 0) { throw new UserError( 'Ce conteneur n’a pas été créé avec docker compose : il n’a pas de fichier à modifier.', 404 ) } const file = resolve(workingDir, files[0]) if (!isInside(vpsConfig.srvRoot, file)) { throw new UserError( `Le fichier ${file} est en dehors de ${vpsConfig.srvRoot} : le tableau de bord n’y a pas accès.`, 404 ) } /** * Same check once the symbolic links are resolved. */ const real = await Promise.all([realpath(vpsConfig.srvRoot), realpath(file)]).catch(() => null) if (!real || !isInside(real[0], real[1])) { throw new UserError(`Le fichier ${file} est introuvable.`, 404) } let readOnlyReason: string | null = null if (files.length > 1) { readOnlyReason = 'Ce projet combine plusieurs fichiers compose : modifie-les en SSH.' } else if (await dockerService.isSelf(info.Id)) { readOnlyReason = 'Le compose du tableau de bord se modifie en SSH : une erreur ici couperait l’accès au tableau de bord.' } const containers = await dockerService.list().catch(() => []) return { project, service: labels['com.docker.compose.service'] ?? null, workingDir, file, containers: containers .filter((container) => container.composeProject === project) .map((container) => container.name), editable: readOnlyReason === null, readOnlyReason, } } async show(containerId: string) { const target = await this.locate(containerId) const [content, versions] = await Promise.all([ readFile(target.file, 'utf8'), ComposeVersion.query() .select('id', 'username', 'reason', 'created_at') .where('path', target.file) .orderBy('id', 'desc'), ]) return { ...target, content, versions: versions.map((version) => ({ id: version.id, username: version.username, reason: version.reason, createdAt: version.createdAt.toISO(), })), } } async version(containerId: string, versionId: number) { const target = await this.locate(containerId) const version = await ComposeVersion.query() .where('id', versionId) .where('path', target.file) .first() if (!version) throw new UserError('Version introuvable.', 404) return { id: version.id, username: version.username, reason: version.reason, createdAt: version.createdAt.toISO(), content: version.content, } } /** * "docker compose config" on the new content, without writing it: catches * YAML errors, unknown keys and missing env files. */ async check(target: ComposeTarget, content: string): Promise { const result = await runWithInput( 'docker', [...composeArgs(target, '-'), 'config', '--quiet'], { cwd: target.workingDir, input: content, timeoutMs: 30_000 } ) return { ok: result.code === 0, output: readableComposeOutput(result.output) } } /** * Checks and writes the new file, then runs "docker compose up -d" when * asked. When "up" fails, the previous file is put back and started again. */ async save( target: ComposeTarget, content: string, options: Omit & { reason: string; apply: boolean } ): Promise { if (!target.editable) throw new UserError(target.readOnlyReason ?? 'Fichier non modifiable.') const lockKey = `project:${target.workingDir}` if (jobRunner.isLocked(lockKey)) { throw new UserError('Une action est déjà en cours sur ce projet, attends la fin.', 409) } const next = toFileContent(content) const previous = await readFile(target.file, 'utf8') const changed = next !== toFileContent(previous) if (!changed && !options.apply) return { ok: true, changed, output: 'Aucun changement.', jobId: null } const check = await this.check(target, next) if (!check.ok) return { ok: false, output: check.output } if (changed) { await ComposeVersion.create({ path: target.file, content: previous, username: options.username, reason: options.reason, }) await this.#write(target.file, next) await this.#pruneVersions(target.file) } if (!options.apply) return { ok: true, changed, output: check.output, jobId: null } const job = await jobRunner.start( { username: options.username, ip: options.ip, action: changed ? 'Modification du docker-compose.yml' : 'Application du docker-compose.yml', target: target.file, lockKey, }, async (ctx) => { if (check.output) ctx.log(check.output) ctx.log( changed ? 'Fichier enregistré (la version précédente est dans l’historique).' : 'Fichier inchangé : application de la version actuelle.' ) try { await this.#up(ctx, target) } catch (error) { if (!changed) throw error ctx.log('Échec : remise en place de la version précédente…', 'err') await this.#write(target.file, previous) await this.#up(ctx, target).catch((rollbackError: Error) => ctx.log( `La version précédente n’a pas pu être relancée : ${rollbackError.message}`, 'err' ) ) throw new Error( 'La nouvelle version n’a pas pu être appliquée : la version précédente a été remise en place.' ) } } ) return { ok: true, changed, output: check.output, jobId: job.id } } #up(ctx: JobContext, target: ComposeTarget) { return ctx.run('docker', [...composeArgs(target, target.file), 'up', '-d'], { cwd: target.workingDir, }) } /** * Writes in place, like the Caddyfile: the file may be mounted on its own. */ #write(file: string, content: string) { return writeFile(file, content, 'utf8') } async #pruneVersions(file: string) { const kept = await ComposeVersion.query() .select('id') .where('path', file) .orderBy('id', 'desc') .limit(KEPT_VERSIONS) if (kept.length < KEPT_VERSIONS) return await ComposeVersion.query().where('path', file).where('id', '<', kept.at(-1)!.id).delete() } } /** * Same project name and folder as the existing containers, so that * "up -d" updates them instead of creating new ones. */ function composeArgs(target: ComposeTarget, file: string) { return ['compose', '-p', target.project, '--project-directory', target.workingDir, '-f', file] } function isInside(root: string, path: string) { const rel = relative(root, path) return rel !== '' && !isAbsolute(rel) && rel.split(sep)[0] !== '..' } /** * "time=… level=warning msg="…"" -> "Avertissement : …". */ function readableComposeOutput(output: string) { return output .split(/\r?\n/) .filter((line) => line.trim()) .map((line) => { const match = /level=(\w+)\s+msg="((?:[^"\\]|\\.)*)"/.exec(line) if (!match) return line const text = match[2].replace(/\\"/g, '"') return match[1] === 'warning' ? `Avertissement : ${text}` : text }) .join('\n') } function runWithInput( command: string, args: string[], options: { cwd: string; input: string; timeoutMs: number } ) { return new Promise<{ code: number | null; output: string }>((done) => { const child = spawn(command, args, { cwd: options.cwd, env: cleanEnv(), stdio: ['pipe', 'pipe', 'pipe'], }) let output = '' const timer = setTimeout(() => child.kill('SIGKILL'), options.timeoutMs) child.stdout.on('data', (chunk) => (output += chunk)) child.stderr.on('data', (chunk) => (output += chunk)) child.on('error', (error) => { clearTimeout(timer) done({ code: null, output: `Impossible de lancer "${command}" : ${error.message}` }) }) child.on('close', (code) => { clearTimeout(timer) done({ code, output }) }) child.stdin.on('error', () => {}) child.stdin.end(options.input) }) } const composeService = new ComposeService() export default composeService