Files
RutraandClaude Opus 5.5 c106fcfc62 Édition du docker-compose.yml depuis la page d'un conteneur
Vérification par docker compose config avant écriture, application avec
docker compose up -d et retour à la version précédente en cas d'échec.
Historique des versions par fichier. Le Caddyfile et le compose gardent
un retour à la ligne final.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:31:33 +02:00

315 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ComposeTarget> {
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<ComposeCheck> {
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<JobMeta, 'action' | 'target' | 'lockKey'> & { reason: string; apply: boolean }
): Promise<ComposeSaveResult> {
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