É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
@@ -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
@@ -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
@@ -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']
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Vendored
+8
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in new issue
Block a user