From c106fcfc6221eaea13433efe8888948e8b7338b9 Mon Sep 17 00:00:00 2001 From: Rutra Date: Mon, 12 Oct 2026 18:31:33 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89dition=20du=20docker-compose.yml=20depuis?= =?UTF-8?q?=20la=20page=20d'un=20conteneur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- api/.adonisjs/client/registry/index.ts | 18 + api/.adonisjs/client/registry/schema.d.ts | 36 ++ api/.adonisjs/client/registry/tree.d.ts | 5 + api/.adonisjs/server/controllers.ts | 1 + api/.adonisjs/server/routes.d.ts | 8 + api/app/controllers/compose_controller.ts | 55 +++ api/app/models/compose_version.ts | 3 + api/app/services/caddy_service.ts | 5 +- api/app/services/compose_service.ts | 314 ++++++++++++++++++ api/app/services/file_content.ts | 8 + api/app/validators/compose.ts | 7 + ...000000005_create_compose_versions_table.ts | 24 ++ api/database/schema.ts | 17 + api/start/routes.ts | 3 + web/src/app/app.routes.ts | 6 + web/src/app/core/api.ts | 16 + web/src/app/core/models.ts | 33 ++ web/src/app/pages/caddy/caddy.scss | 55 +-- web/src/app/pages/caddy/caddy.ts | 2 +- .../pages/containers/container-compose.html | 193 +++++++++++ .../pages/containers/container-compose.scss | 25 ++ .../app/pages/containers/container-compose.ts | 178 ++++++++++ .../pages/containers/container-detail.html | 5 + .../app/pages/projects/project-detail.html | 5 + web/src/app/shared/editor-page.scss | 68 ++++ web/src/app/shared/icon.ts | 1 + 27 files changed, 1035 insertions(+), 58 deletions(-) create mode 100644 api/app/controllers/compose_controller.ts create mode 100644 api/app/models/compose_version.ts create mode 100644 api/app/services/compose_service.ts create mode 100644 api/app/services/file_content.ts create mode 100644 api/app/validators/compose.ts create mode 100644 api/database/migrations/1770000000005_create_compose_versions_table.ts create mode 100644 web/src/app/pages/containers/container-compose.html create mode 100644 web/src/app/pages/containers/container-compose.scss create mode 100644 web/src/app/pages/containers/container-compose.ts create mode 100644 web/src/app/shared/editor-page.scss diff --git a/README.md b/README.md index 36ffff0..5f5b4fa 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Tableau de bord web pour gérer et surveiller le VPS : serveur, conteneurs, site | Page | Contenu | |---|---| | Vue d'ensemble | Processeur, mémoire, disque, réseau, charge, durée depuis le démarrage. Graphiques en direct (toutes les 5 s) et historique sur 1 h, 24 h, 7 j et 30 j. État des conteneurs, des sites et des sauvegardes, dernières actions. | -| Conteneurs | Tous les conteneurs avec leur état, CPU et mémoire. Alerte quand un port est publié sur Internet (seul Caddy devrait l'être). Logs en direct (filtre, pause, téléchargement). Démarrer, arrêter, redémarrer. | +| Conteneurs | Tous les conteneurs avec leur état, CPU et mémoire. Alerte quand un port est publié sur Internet (seul Caddy devrait l'être). Logs en direct (filtre, pause, téléchargement). Démarrer, arrêter, redémarrer. **Édition du `docker-compose.yml`** du conteneur : le fichier est vérifié par `docker compose config` avant d'être écrit, puis appliqué avec `docker compose up -d` si demandé. Si `up` échoue, l'ancienne version est remise en place et relancée. Historique des 30 dernières versions de chaque fichier. Le compose du tableau de bord lui-même reste en lecture seule. | | Sites | Les domaines du Caddyfile, vérifiés chaque minute : état, temps de réponse, disponibilité sur 24 h et 7 j, expiration du certificat HTTPS, incidents. | | Projets | Les projets de `/srv` (même organisation que la procédure). Pour chacun : dépôt git (version en ligne, nouveautés), mise à jour (`git pull` puis recréation des conteneurs), retour à un ancien commit, redémarrage, arrêt, mise à jour des images. **Création de projet** Node, Adonis, WordPress ou site statique : dossier, clone, `.env`, base MariaDB dédiée, `docker-compose.yml` (templates de la procédure), démarrage et bloc Caddy. | | Caddy | Éditeur du Caddyfile. Si Caddy refuse la nouvelle configuration, l'ancien fichier est remis en place automatiquement. Historique des 50 dernières versions, restaurables. | diff --git a/api/.adonisjs/client/registry/index.ts b/api/.adonisjs/client/registry/index.ts index 9469fdf..94ec8ed 100644 --- a/api/.adonisjs/client/registry/index.ts +++ b/api/.adonisjs/client/registry/index.ts @@ -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', diff --git a/api/.adonisjs/client/registry/schema.d.ts b/api/.adonisjs/client/registry/schema.d.ts index c036da6..291991f 100644 --- a/api/.adonisjs/client/registry/schema.d.ts +++ b/api/.adonisjs/client/registry/schema.d.ts @@ -115,6 +115,42 @@ export interface Registry { errorResponse: ExtractErrorResponse>> } } + 'compose.show': { + methods: ["GET","HEAD"] + pattern: '/api/containers/:id/compose' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'compose.update': { + methods: ["PUT"] + pattern: '/api/containers/:id/compose' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { 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>> + errorResponse: ExtractErrorResponse>> + } + } 'containers.action': { methods: ["POST"] pattern: '/api/containers/:id/:action' diff --git a/api/.adonisjs/client/registry/tree.d.ts b/api/.adonisjs/client/registry/tree.d.ts index f8dfd4a..fc8dd8d 100644 --- a/api/.adonisjs/client/registry/tree.d.ts +++ b/api/.adonisjs/client/registry/tree.d.ts @@ -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'] diff --git a/api/.adonisjs/server/controllers.ts b/api/.adonisjs/server/controllers.ts index d54d1a1..f673ffd 100644 --- a/api/.adonisjs/server/controllers.ts +++ b/api/.adonisjs/server/controllers.ts @@ -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'), diff --git a/api/.adonisjs/server/routes.d.ts b/api/.adonisjs/server/routes.d.ts index cf49997..45ee743 100644 --- a/api/.adonisjs/server/routes.d.ts +++ b/api/.adonisjs/server/routes.d.ts @@ -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: { diff --git a/api/app/controllers/compose_controller.ts b/api/app/controllers/compose_controller.ts new file mode 100644 index 0000000..e7597f7 --- /dev/null +++ b/api/app/controllers/compose_controller.ts @@ -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 + } +} diff --git a/api/app/models/compose_version.ts b/api/app/models/compose_version.ts new file mode 100644 index 0000000..ba4c24e --- /dev/null +++ b/api/app/models/compose_version.ts @@ -0,0 +1,3 @@ +import { ComposeVersionSchema } from '#database/schema' + +export default class ComposeVersion extends ComposeVersionSchema {} diff --git a/api/app/services/caddy_service.ts b/api/app/services/caddy_service.ts index bae65a2..8bdab6b 100644 --- a/api/app/services/caddy_service.ts +++ b/api/app/services/caddy_service.ts @@ -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 { 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) diff --git a/api/app/services/compose_service.ts b/api/app/services/compose_service.ts new file mode 100644 index 0000000..857ec83 --- /dev/null +++ b/api/app/services/compose_service.ts @@ -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 { + 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 { + 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 & { reason: string; apply: boolean } + ): Promise { + 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 diff --git a/api/app/services/file_content.ts b/api/app/services/file_content.ts new file mode 100644 index 0000000..3f6f7b7 --- /dev/null +++ b/api/app/services/file_content.ts @@ -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` +} diff --git a/api/app/validators/compose.ts b/api/app/validators/compose.ts new file mode 100644 index 0000000..42d5e02 --- /dev/null +++ b/api/app/validators/compose.ts @@ -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(), +}) diff --git a/api/database/migrations/1770000000005_create_compose_versions_table.ts b/api/database/migrations/1770000000005_create_compose_versions_table.ts new file mode 100644 index 0000000..d503df6 --- /dev/null +++ b/api/database/migrations/1770000000005_create_compose_versions_table.ts @@ -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) + } +} diff --git a/api/database/schema.ts b/api/database/schema.ts index 856c437..83f10ca 100644 --- a/api/database/schema.ts +++ b/api/database/schema.ts @@ -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 diff --git a/api/start/routes.ts b/api/start/routes.ts index 8d61443..0e5627b 100644 --- a/api/start/routes.ts +++ b/api/start/routes.ts @@ -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']) diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 5fe5a40..01c3ccf 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -30,6 +30,12 @@ export const routes: Routes = [ loadComponent: () => import('./pages/containers/container-detail').then((m) => m.ContainerDetailPage), }, + { + path: 'containers/:id/compose', + title: 'docker-compose.yml · Big Brother', + loadComponent: () => + import('./pages/containers/container-compose').then((m) => m.ContainerComposePage), + }, { path: 'sites', title: 'Sites · Big Brother', diff --git a/web/src/app/core/api.ts b/web/src/app/core/api.ts index 7658f09..e82214c 100644 --- a/web/src/app/core/api.ts +++ b/web/src/app/core/api.ts @@ -8,6 +8,9 @@ import type { BackupSchedule, CaddyInfo, CaddyVersion, + ComposeInfo, + ComposeSaveResult, + ComposeVersion, ContainerAction, ContainerDetails, ContainerSummary, @@ -82,6 +85,19 @@ export class Api { containerAction(id: string, action: ContainerAction) { return this.send('POST', `/containers/${encodeURIComponent(id)}/${action}`); } + compose(id: string) { + return this.get(`/containers/${enc(id)}/compose`); + } + composeVersion(id: string, versionId: number) { + return this.get(`/containers/${enc(id)}/compose/versions/${versionId}`); + } + saveCompose(id: string, content: string, reason: string, apply: boolean) { + return this.send('PUT', `/containers/${enc(id)}/compose`, { + content, + reason, + apply, + }); + } // Sites sites() { diff --git a/web/src/app/core/models.ts b/web/src/app/core/models.ts index 6546671..6b9d42d 100644 --- a/web/src/app/core/models.ts +++ b/web/src/app/core/models.ts @@ -246,6 +246,39 @@ export interface CaddyVersion { content?: string; } +export interface ComposeVersion { + id: number; + username: string | null; + reason: string | null; + createdAt: string; + content?: string; +} + +export interface ComposeInfo { + project: string; + service: string | null; + workingDir: string; + file: string; + /** + * Containers of the same compose project. + */ + containers: string[]; + editable: boolean; + readOnlyReason: string | null; + content: string; + versions: ComposeVersion[]; +} + +export interface ComposeSaveResult { + ok: true; + changed: boolean; + output: string; + /** + * Set when "docker compose up -d" was started. + */ + jobId: number | null; +} + export interface JobLine { ts: number; stream: 'out' | 'err' | 'info'; diff --git a/web/src/app/pages/caddy/caddy.scss b/web/src/app/pages/caddy/caddy.scss index f5a2a9b..c69c95c 100644 --- a/web/src/app/pages/caddy/caddy.scss +++ b/web/src/app/pages/caddy/caddy.scss @@ -1,43 +1,4 @@ -.layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 340px; - align-items: start; - gap: 16px; - - @media (max-width: 1100px) { - grid-template-columns: minmax(0, 1fr); - } -} - -.editor { - min-height: 520px; - white-space: pre; - overflow-wrap: normal; - overflow-x: auto; -} - -.save-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - - .input { - flex: 1; - min-width: 200px; - } -} - -.output { - max-height: 240px; - margin: 8px 0 0; - overflow: auto; - font-family: var(--mono); - font-size: 12px; - white-space: pre-wrap; -} - -.site-list, -.version-list { +.site-list { margin: 0; padding: 0; list-style: none; @@ -55,17 +16,3 @@ } } } - -.version-list li { - gap: 8px; - - .muted { - display: block; - font-size: 12.5px; - } -} - -.version-actions { - display: flex; - gap: 6px; -} diff --git a/web/src/app/pages/caddy/caddy.ts b/web/src/app/pages/caddy/caddy.ts index c94e985..2ed9c3e 100644 --- a/web/src/app/pages/caddy/caddy.ts +++ b/web/src/app/pages/caddy/caddy.ts @@ -11,7 +11,7 @@ import { Icon } from '../../shared/icon'; selector: 'app-caddy', imports: [FormsModule, Icon], templateUrl: './caddy.html', - styleUrl: './caddy.scss', + styleUrls: ['../../shared/editor-page.scss', './caddy.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CaddyPage { diff --git a/web/src/app/pages/containers/container-compose.html b/web/src/app/pages/containers/container-compose.html new file mode 100644 index 0000000..8721517 --- /dev/null +++ b/web/src/app/pages/containers/container-compose.html @@ -0,0 +1,193 @@ +
+ + + @if (error()) { + + } + + @if (jobId(); as job) { + + } + + @if (info(); as i) { + @if (!i.editable) { +
+ +

{{ i.readOnlyReason }}

+
+ } + +
+
+
+
+

{{ fileName() }}

+

{{ lineCount() }} lignes{{ dirty() ? ' · modifications non enregistrées' : '' }}

+
+
+
+ + + @if (output(); as out) { +
+ +
+

+ {{ + out.ok + ? 'docker compose a accepté le fichier, avec ces remarques :' + : 'docker compose a refusé le fichier : rien n’a été modifié.' + }} +

+
{{ out.text }}
+
+
+ } + + @if (i.editable) { +
+ + + + +
+

+ Le fichier est toujours vérifié par docker compose config + avant d’être écrit. « Enregistrer » ne touche pas aux conteneurs : les changements + s’appliquent au prochain démarrage du projet. « Appliquer » lance + docker compose up -d tout de suite. +

+ } +
+
+ + +
+ + @if (viewed(); as version) { +
+
+

Version #{{ version.id }} · {{ dateTime(version.createdAt) }}

+
+ @if (i.editable) { + + } + +
+
+
+
{{ version.content }}
+
+
+ } + } +
diff --git a/web/src/app/pages/containers/container-compose.scss b/web/src/app/pages/containers/container-compose.scss new file mode 100644 index 0000000..21b1fc8 --- /dev/null +++ b/web/src/app/pages/containers/container-compose.scss @@ -0,0 +1,25 @@ +.hint { + margin: 0; + color: var(--muted); + font-size: 12.5px; +} + +.container-list { + margin: 0; + padding: 0; + list-style: none; + + li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px 18px; + border-bottom: 1px solid var(--border); + overflow-wrap: anywhere; + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/web/src/app/pages/containers/container-compose.ts b/web/src/app/pages/containers/container-compose.ts new file mode 100644 index 0000000..07d3cf9 --- /dev/null +++ b/web/src/app/pages/containers/container-compose.ts @@ -0,0 +1,178 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + signal, + untracked, +} from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { Api } from '../../core/api'; +import { Feedback } from '../../core/feedback'; +import { errorMessage, formatDateTime } from '../../core/format'; +import type { AuditStatus, ComposeInfo, ComposeVersion } from '../../core/models'; +import { Icon } from '../../shared/icon'; +import { JobConsole } from '../../shared/job-console'; + +/** + * The textarea gives back "\n" line breaks: the file is compared the same way. + */ +function normalize(content: string) { + return content.replace(/\r\n/g, '\n'); +} + +@Component({ + selector: 'app-container-compose', + imports: [FormsModule, RouterLink, Icon, JobConsole], + templateUrl: './container-compose.html', + styleUrls: ['../../shared/editor-page.scss', './container-compose.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContainerComposePage { + private api = inject(Api); + private feedback = inject(Feedback); + + /** + * Container name or id (route parameter). + */ + readonly id = input.required(); + + protected readonly info = signal(null); + protected readonly content = signal(''); + protected readonly reason = signal(''); + protected readonly busy = signal(false); + protected readonly error = signal(null); + protected readonly output = signal<{ ok: boolean; text: string } | null>(null); + protected readonly viewed = signal(null); + protected readonly jobId = signal(null); + + protected readonly dirty = computed(() => { + const info = this.info(); + return info !== null && this.content() !== normalize(info.content); + }); + protected readonly lineCount = computed(() => this.content().split('\n').length); + protected readonly fileName = computed( + () => this.info()?.file.split(/[\\/]/).pop() ?? 'docker-compose.yml', + ); + protected readonly dateTime = formatDateTime; + + constructor() { + effect(() => { + const id = this.id(); + untracked(() => this.load(id)); + }); + } + + /** + * keepContent: after a failed apply, the edited text stays in the editor. + */ + protected async load(id = this.id(), keepContent = false) { + try { + const info = await this.api.compose(id); + this.info.set(info); + if (!keepContent) this.content.set(normalize(info.content)); + this.error.set(null); + } catch (error) { + this.error.set(errorMessage(error)); + } + } + + protected async save(apply: boolean) { + const info = this.info(); + if (!info) return; + + if (apply) { + const others = info.containers.length > 1 ? ` (${info.containers.join(', ')})` : ''; + const ok = await this.feedback.confirm({ + title: this.dirty() ? 'Enregistrer et appliquer ?' : 'Appliquer le fichier ?', + message: `« docker compose up -d » recrée les conteneurs du projet ${info.project}${others} dont la configuration a changé : ils sont coupés quelques secondes. Si la commande échoue, la version précédente est remise en place et relancée.`, + confirmLabel: this.dirty() ? 'Enregistrer et appliquer' : 'Appliquer', + }); + if (!ok) return; + } + + this.busy.set(true); + this.output.set(null); + this.jobId.set(null); + try { + const result = await this.api.saveCompose( + this.id(), + this.content(), + this.reason().trim(), + apply, + ); + this.reason.set(''); + if (result.output && result.output !== 'Aucun changement.') { + this.output.set({ ok: true, text: result.output }); + } + if (result.jobId !== null) { + /** + * The page stays busy until the job console reports the end. + */ + this.jobId.set(result.jobId); + return; + } + this.feedback.success( + result.changed ? `${this.fileName()} enregistré.` : 'Aucun changement.', + ); + await this.load(); + } catch (error) { + const body = error instanceof HttpErrorResponse ? error.error : null; + this.output.set({ ok: false, text: body?.output || errorMessage(error) }); + this.feedback.error(errorMessage(error)); + } finally { + if (this.jobId() === null) this.busy.set(false); + } + } + + protected async onFinished(status: AuditStatus) { + this.busy.set(false); + if (status === 'success') { + this.feedback.success(`${this.fileName()} appliqué.`); + await this.load(); + } else { + this.feedback.error('L’application a échoué : le détail est dans la console.'); + await this.load(this.id(), true); + } + } + + protected reset() { + this.content.set(normalize(this.info()?.content ?? '')); + this.output.set(null); + } + + protected async view(version: ComposeVersion) { + try { + this.viewed.set(await this.api.composeVersion(this.id(), version.id)); + } catch (error) { + this.feedback.error(errorMessage(error)); + } + } + + protected loadVersion(version: ComposeVersion) { + this.content.set(normalize(version.content ?? '')); + this.viewed.set(null); + this.output.set(null); + this.feedback.info( + `Version #${version.id} chargée dans l’éditeur : enregistre pour la remettre.`, + ); + } + + /** + * Tab inserts two spaces: YAML does not accept tabs for indentation. + */ + protected onKeydown(event: KeyboardEvent) { + if (event.key !== 'Tab' || event.shiftKey) return; + event.preventDefault(); + const textarea = event.target as HTMLTextAreaElement; + const { selectionStart, selectionEnd, value } = textarea; + const next = `${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`; + this.content.set(next); + textarea.value = next; + textarea.selectionStart = textarea.selectionEnd = selectionStart + 2; + } +} diff --git a/web/src/app/pages/containers/container-detail.html b/web/src/app/pages/containers/container-detail.html index b61508e..75817a4 100644 --- a/web/src/app/pages/containers/container-detail.html +++ b/web/src/app/pages/containers/container-detail.html @@ -9,6 +9,11 @@ @if (container(); as c) {
+ @if (c.composeProject) { + + docker-compose.yml + + } @if (c.state === 'running') {