É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:
1 parent
dcdac915f9
commit
c106fcfc62
27 files changed
+1035
-58
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import composeService from '#services/compose_service'
|
||||
import { actor, audit } from '#services/audit'
|
||||
import { updateComposeValidator } from '#validators/compose'
|
||||
|
||||
const SAVE_ACTION = 'Modification du docker-compose.yml'
|
||||
|
||||
/**
|
||||
* The docker-compose.yml of a container, from the container page.
|
||||
*/
|
||||
export default class ComposeController {
|
||||
async show({ params }: HttpContext) {
|
||||
return composeService.show(params.id)
|
||||
}
|
||||
|
||||
async version({ params }: HttpContext) {
|
||||
return composeService.version(params.id, Number(params.versionId))
|
||||
}
|
||||
|
||||
async update(ctx: HttpContext) {
|
||||
const { content, reason, apply } = await ctx.request.validateUsing(updateComposeValidator)
|
||||
const target = await composeService.locate(ctx.params.id)
|
||||
const result = await composeService.save(target, content, {
|
||||
...actor(ctx),
|
||||
reason: reason || 'Modification manuelle',
|
||||
apply,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
await audit(ctx, {
|
||||
action: SAVE_ACTION,
|
||||
target: target.file,
|
||||
status: 'error',
|
||||
output: result.output,
|
||||
})
|
||||
return ctx.response.unprocessableEntity({
|
||||
message: 'docker compose a refusé le fichier : rien n’a été modifié.',
|
||||
output: result.output,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* When applied, the job records the action itself.
|
||||
*/
|
||||
if (result.changed && result.jobId === null) {
|
||||
await audit(ctx, {
|
||||
action: SAVE_ACTION,
|
||||
target: target.file,
|
||||
status: 'success',
|
||||
output: result.output || null,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ComposeVersionSchema } from '#database/schema'
|
||||
|
||||
export default class ComposeVersion extends ComposeVersionSchema {}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
export const updateComposeValidator = vine.create({
|
||||
content: vine.string().minLength(1).maxLength(200_000),
|
||||
reason: vine.string().trim().maxLength(200).optional(),
|
||||
apply: vine.boolean(),
|
||||
})
|
||||
Reference in new issue
Block a user