É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>
This commit is contained in:
RutraandClaude Opus 5.5 committed 2026-10-12 18:31:33 +02:00
1 parent dcdac915f9
commit c106fcfc62
27 files changed
+1035 -58

No files matched your search

+3 -2
View File
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'
import vpsConfig from '#config/vps'
import dockerService from '#services/docker_service'
import CaddyfileVersion from '#models/caddyfile_version'
import { toFileContent } from '#services/file_content'
export interface CaddySite {
/**
@@ -170,8 +171,8 @@ class CaddyService {
meta: { username: string | null; reason: string }
): Promise<ReloadResult> {
const previous = await this.read()
const normalized = content.replace(/\r\n/g, '\n')
if (normalized === previous) return { ok: true, output: 'Aucun changement.' }
const normalized = toFileContent(content)
if (normalized === toFileContent(previous)) return { ok: true, output: 'Aucun changement.' }
await this.#write(normalized)
+314
View File
@@ -0,0 +1,314 @@
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
+8
View File
@@ -0,0 +1,8 @@
/**
* Content written to a configuration file edited from the dashboard:
* "\n" line breaks and a final line break (the request body comes trimmed).
*/
export function toFileContent(content: string) {
const normalized = content.replace(/\r\n/g, '\n')
return normalized.endsWith('\n') ? normalized : `${normalized}\n`
}