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>
315 lines
9.9 KiB
TypeScript
315 lines
9.9 KiB
TypeScript
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
|