É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

+18
View File
@@ -60,6 +60,24 @@ const routes = {
tokens: [{"old":"/api/containers/:id/logs","type":0,"val":"api","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/logs","type":1,"val":"id","end":""},{"old":"/api/containers/:id/logs","type":0,"val":"logs","end":""}],
types: placeholder as Registry['containers.logs']['types'],
},
'compose.show': {
methods: ["GET","HEAD"],
pattern: '/api/containers/:id/compose',
tokens: [{"old":"/api/containers/:id/compose","type":0,"val":"api","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/compose","type":1,"val":"id","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"compose","end":""}],
types: placeholder as Registry['compose.show']['types'],
},
'compose.update': {
methods: ["PUT"],
pattern: '/api/containers/:id/compose',
tokens: [{"old":"/api/containers/:id/compose","type":0,"val":"api","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/compose","type":1,"val":"id","end":""},{"old":"/api/containers/:id/compose","type":0,"val":"compose","end":""}],
types: placeholder as Registry['compose.update']['types'],
},
'compose.version': {
methods: ["GET","HEAD"],
pattern: '/api/containers/:id/compose/versions/:versionId',
tokens: [{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"api","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"containers","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":1,"val":"id","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"compose","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":0,"val":"versions","end":""},{"old":"/api/containers/:id/compose/versions/:versionId","type":1,"val":"versionId","end":""}],
types: placeholder as Registry['compose.version']['types'],
},
'containers.action': {
methods: ["POST"],
pattern: '/api/containers/:id/:action',
+36
View File
@@ -115,6 +115,42 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/containers_controller').default['logs']>>>
}
}
'compose.show': {
methods: ["GET","HEAD"]
pattern: '/api/containers/:id/compose'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['show']>>>
}
}
'compose.update': {
methods: ["PUT"]
pattern: '/api/containers/:id/compose'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/compose').updateComposeValidator)>>
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: ExtractQuery<InferInput<(typeof import('#validators/compose').updateComposeValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['update']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['update']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'compose.version': {
methods: ["GET","HEAD"]
pattern: '/api/containers/:id/compose/versions/:versionId'
types: {
body: {}
paramsTuple: [ParamValue, ParamValue]
params: { id: ParamValue; versionId: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['version']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/compose_controller').default['version']>>>
}
}
'containers.action': {
methods: ["POST"]
pattern: '/api/containers/:id/:action'
+5
View File
@@ -20,6 +20,11 @@ export interface ApiDefinition {
logs: typeof routes['containers.logs']
action: typeof routes['containers.action']
}
compose: {
show: typeof routes['compose.show']
update: typeof routes['compose.update']
version: typeof routes['compose.version']
}
sites: {
index: typeof routes['sites.index']
check: typeof routes['sites.check']
+1
View File
@@ -8,6 +8,7 @@ export const controllers = {
Auth: () => import('#controllers/auth_controller'),
Backups: () => import('#controllers/backups_controller'),
Caddy: () => import('#controllers/caddy_controller'),
Compose: () => import('#controllers/compose_controller'),
Containers: () => import('#controllers/containers_controller'),
Jobs: () => import('#controllers/jobs_controller'),
Metrics: () => import('#controllers/metrics_controller'),
+8
View File
@@ -13,6 +13,9 @@ export type ScannedRoutes = {
'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.version': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'versionId': ParamValue} }
'containers.action': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'action': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} }
'sites.check': { paramsTuple?: []; params?: {} }
@@ -58,6 +61,8 @@ export type ScannedRoutes = {
'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.version': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'versionId': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} }
'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'projects.index': { paramsTuple?: []; params?: {} }
@@ -81,6 +86,8 @@ export type ScannedRoutes = {
'containers.index': { paramsTuple?: []; params?: {} }
'containers.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'containers.logs': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'compose.version': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'versionId': ParamValue} }
'sites.index': { paramsTuple?: []; params?: {} }
'sites.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'projects.index': { paramsTuple?: []; params?: {} }
@@ -97,6 +104,7 @@ export type ScannedRoutes = {
'spa.index': { paramsTuple: [...ParamValue[]]; params: {'*': ParamValue[]} }
}
PUT: {
'compose.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'caddy.update': { paramsTuple?: []; params?: {} }
}
DELETE: {
+55
View File
@@ -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
}
}
+3
View File
@@ -0,0 +1,3 @@
import { ComposeVersionSchema } from '#database/schema'
export default class ComposeVersion extends ComposeVersionSchema {}
+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`
}
+7
View File
@@ -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(),
})
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Previous contents of the docker-compose.yml files, saved before every
* change made from the dashboard.
*/
export default class extends BaseSchema {
protected tableName = 'compose_versions'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('path').notNullable().index()
table.text('content').notNullable()
table.string('username').nullable()
table.string('reason').nullable()
table.timestamp('created_at').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
+17
View File
@@ -45,6 +45,23 @@ export class CaddyfileVersionSchema extends BaseModel {
declare username: string | null
}
export class ComposeVersionSchema extends BaseModel {
static $columns = ['content', 'createdAt', 'id', 'path', 'reason', 'username'] as const
$columns = ComposeVersionSchema.$columns
@column()
declare content: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare path: string
@column()
declare reason: string | null
@column()
declare username: string | null
}
export class MetricSchema extends BaseModel {
static $columns = ['cpu', 'diskTotal', 'diskUsed', 'id', 'load1', 'memTotal', 'memUsed', 'netRx', 'netTx', 'ts'] as const
$columns = MetricSchema.$columns
+3
View File
@@ -28,6 +28,9 @@ router
router.get('containers', [controllers.Containers, 'index'])
router.get('containers/:id', [controllers.Containers, 'show'])
router.get('containers/:id/logs', [controllers.Containers, 'logs'])
router.get('containers/:id/compose', [controllers.Compose, 'show'])
router.put('containers/:id/compose', [controllers.Compose, 'update'])
router.get('containers/:id/compose/versions/:versionId', [controllers.Compose, 'version'])
router.post('containers/:id/:action', [controllers.Containers, 'action'])
router.get('sites', [controllers.Sites, 'index'])