630 lines
20 KiB
TypeScript
630 lines
20 KiB
TypeScript
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||
import { execFile } from 'node:child_process'
|
||
import { randomBytes } from 'node:crypto'
|
||
import { promisify } from 'node:util'
|
||
import { join, resolve } from 'node:path'
|
||
import vpsConfig from '#config/vps'
|
||
import UserError from '#exceptions/user_error'
|
||
import caddyService from '#services/caddy_service'
|
||
import dockerService, { type ContainerSummary } from '#services/docker_service'
|
||
import jobRunner, { cleanEnv, type JobContext, type JobMeta } from '#services/job_runner'
|
||
import {
|
||
composeFile,
|
||
placeholderPage,
|
||
proxyBlock,
|
||
staticBlock,
|
||
type ProjectType,
|
||
} from '#services/templates'
|
||
|
||
const execFileAsync = promisify(execFile)
|
||
|
||
const COMPOSE_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']
|
||
const SEGMENT = /^[\w][\w.-]*$/
|
||
|
||
export type ProjectKind = 'compose' | 'static' | 'infra'
|
||
|
||
export interface GitCommit {
|
||
hash: string
|
||
short: string
|
||
subject: string
|
||
author: string
|
||
date: string
|
||
}
|
||
|
||
export interface GitInfo {
|
||
repoPath: string
|
||
branch: string | null
|
||
detached: boolean
|
||
upstream: string | null
|
||
ahead: number
|
||
behind: number
|
||
/**
|
||
* Number of tracked files modified on the server.
|
||
*/
|
||
dirty: number
|
||
commit: GitCommit | null
|
||
}
|
||
|
||
export interface ProjectContainer {
|
||
id: string
|
||
name: string
|
||
state: string
|
||
status: string
|
||
health: string | null
|
||
}
|
||
|
||
export interface Project {
|
||
category: string
|
||
name: string
|
||
kind: ProjectKind
|
||
path: string
|
||
composeFile: string | null
|
||
git: GitInfo | null
|
||
containers: ProjectContainer[]
|
||
/**
|
||
* The project of the dashboard itself.
|
||
*/
|
||
self: boolean
|
||
updatedAt: number | null
|
||
}
|
||
|
||
export const PROJECT_ACTIONS = [
|
||
'deploy',
|
||
'restart',
|
||
'stop',
|
||
'start',
|
||
'recreate',
|
||
'pull',
|
||
'checkout',
|
||
'checkout-branch',
|
||
] as const
|
||
export type ProjectAction = (typeof PROJECT_ACTIONS)[number]
|
||
|
||
export const ACTION_LABELS: Record<ProjectAction, string> = {
|
||
'deploy': 'Mise à jour (git pull + redémarrage)',
|
||
'restart': 'Redémarrage',
|
||
'stop': 'Arrêt',
|
||
'start': 'Démarrage',
|
||
'recreate': 'Recréation des conteneurs',
|
||
'pull': 'Mise à jour des images Docker',
|
||
'checkout': 'Retour à un commit',
|
||
'checkout-branch': 'Retour sur la branche',
|
||
}
|
||
|
||
export interface CreateProjectInput {
|
||
type: ProjectType
|
||
name: string
|
||
category: string
|
||
gitUrl?: string
|
||
port?: number
|
||
command?: string
|
||
env?: string
|
||
createDatabase: boolean
|
||
domain: string
|
||
addCaddy: boolean
|
||
spa: boolean
|
||
}
|
||
|
||
async function exists(path: string) {
|
||
try {
|
||
await access(path)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function directories(path: string) {
|
||
try {
|
||
const entries = await readdir(path, { withFileTypes: true })
|
||
return entries
|
||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
||
.map((entry) => entry.name)
|
||
.sort((a, b) => a.localeCompare(b))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
async function findComposeFile(dir: string) {
|
||
for (const file of COMPOSE_FILES) {
|
||
if (await exists(join(dir, file))) return file
|
||
}
|
||
return null
|
||
}
|
||
|
||
function git(repo: string, args: string[], timeout = 15_000) {
|
||
return execFileAsync('git', ['-C', repo, ...args], {
|
||
timeout,
|
||
env: cleanEnv(),
|
||
maxBuffer: 10 * 1024 * 1024,
|
||
}).then(({ stdout }) => stdout.trim())
|
||
}
|
||
|
||
/**
|
||
* Hides the credentials of a git URL ("https://user:token@host/…").
|
||
*/
|
||
export function hideCredentials(url: string) {
|
||
return url.replace(/\/\/[^/@\s]+@/, '//***@')
|
||
}
|
||
|
||
function samePath(a: string, b: string) {
|
||
const normalize = (path: string) =>
|
||
resolve(path)
|
||
.replace(/[\\/]+$/, '')
|
||
.toLowerCase()
|
||
return normalize(a) === normalize(b)
|
||
}
|
||
|
||
function randomSecret(length: number) {
|
||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
|
||
return Array.from(randomBytes(length), (byte) => alphabet[byte % alphabet.length]).join('')
|
||
}
|
||
|
||
class ProjectService {
|
||
/**
|
||
* Scans /srv: "html/*" are static sites, folders with their own compose
|
||
* file are infrastructure, the others hold one project per sub-folder.
|
||
*/
|
||
async list(): Promise<Project[]> {
|
||
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
|
||
const root = vpsConfig.srvRoot
|
||
const found: { category: string; name: string; path: string; kind: ProjectKind }[] = []
|
||
|
||
for (const folder of await directories(root)) {
|
||
const path = join(root, folder)
|
||
if (folder === vpsConfig.staticCategory) {
|
||
for (const name of await directories(path)) {
|
||
found.push({ category: folder, name, path: join(path, name), kind: 'static' })
|
||
}
|
||
} else if (await findComposeFile(path)) {
|
||
found.push({ category: 'infra', name: folder, path, kind: 'infra' })
|
||
} else {
|
||
for (const name of await directories(path)) {
|
||
if (await findComposeFile(join(path, name))) {
|
||
found.push({ category: folder, name, path: join(path, name), kind: 'compose' })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return Promise.all(found.map((entry) => this.#build(entry, containers)))
|
||
}
|
||
|
||
async find(category: string, name: string): Promise<Project> {
|
||
if (!SEGMENT.test(category) || !SEGMENT.test(name)) {
|
||
throw new UserError('Projet introuvable.', 404)
|
||
}
|
||
|
||
const root = vpsConfig.srvRoot
|
||
let path: string
|
||
let kind: ProjectKind
|
||
if (category === 'infra') {
|
||
path = join(root, name)
|
||
kind = 'infra'
|
||
} else if (category === vpsConfig.staticCategory) {
|
||
path = join(root, category, name)
|
||
kind = 'static'
|
||
} else {
|
||
path = join(root, category, name)
|
||
kind = 'compose'
|
||
}
|
||
|
||
const valid = kind === 'static' ? await exists(path) : (await findComposeFile(path)) !== null
|
||
if (!valid) throw new UserError('Projet introuvable.', 404)
|
||
|
||
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
|
||
return this.#build({ category, name, path, kind }, containers)
|
||
}
|
||
|
||
async #build(
|
||
entry: { category: string; name: string; path: string; kind: ProjectKind },
|
||
containers: ContainerSummary[]
|
||
): Promise<Project> {
|
||
const selfDir = containers.find((container) => container.self)?.composeWorkingDir ?? null
|
||
const project: Project = {
|
||
...entry,
|
||
composeFile: entry.kind === 'static' ? null : await findComposeFile(entry.path),
|
||
git: null,
|
||
containers: containers
|
||
.filter((c) => c.composeWorkingDir && samePath(c.composeWorkingDir, entry.path))
|
||
.map(({ id, name, state, status, health }) => ({ id, name, state, status, health })),
|
||
self: selfDir !== null && samePath(selfDir, entry.path),
|
||
updatedAt: null,
|
||
}
|
||
|
||
if (entry.kind === 'static') {
|
||
const index = join(entry.path, 'index.html')
|
||
const info = await stat((await exists(index)) ? index : entry.path).catch(() => null)
|
||
project.updatedAt = info ? Math.floor(info.mtimeMs / 1000) : null
|
||
} else {
|
||
const repoPath = (await exists(join(entry.path, 'project', '.git')))
|
||
? join(entry.path, 'project')
|
||
: (await exists(join(entry.path, '.git')))
|
||
? entry.path
|
||
: null
|
||
if (repoPath) project.git = await this.#gitInfo(repoPath).catch(() => null)
|
||
}
|
||
|
||
return project
|
||
}
|
||
|
||
async #gitInfo(repoPath: string): Promise<GitInfo> {
|
||
const [status, log] = await Promise.all([
|
||
git(repoPath, ['status', '--porcelain=v2', '--branch']),
|
||
git(repoPath, ['log', '-1', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI']).catch(() => ''),
|
||
])
|
||
|
||
const info: GitInfo = {
|
||
repoPath,
|
||
branch: null,
|
||
detached: false,
|
||
upstream: null,
|
||
ahead: 0,
|
||
behind: 0,
|
||
dirty: 0,
|
||
commit: parseCommit(log),
|
||
}
|
||
|
||
for (const line of status.split('\n')) {
|
||
if (line.startsWith('# branch.head ')) {
|
||
const head = line.slice('# branch.head '.length)
|
||
info.detached = head === '(detached)'
|
||
info.branch = info.detached ? null : head
|
||
} else if (line.startsWith('# branch.upstream ')) {
|
||
info.upstream = line.slice('# branch.upstream '.length)
|
||
} else if (line.startsWith('# branch.ab ')) {
|
||
const [ahead, behind] = line.slice('# branch.ab '.length).split(' ')
|
||
info.ahead = Math.abs(Number(ahead))
|
||
info.behind = Math.abs(Number(behind))
|
||
} else if (/^[12u] /.test(line)) {
|
||
info.dirty++
|
||
}
|
||
}
|
||
|
||
return info
|
||
}
|
||
|
||
/**
|
||
* Branch followed by the remote ("main" most of the time).
|
||
*/
|
||
async #defaultBranch(repoPath: string) {
|
||
const head = await git(repoPath, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).catch(
|
||
() => ''
|
||
)
|
||
if (head) return head.replace(/^origin\//, '')
|
||
for (const branch of ['main', 'master']) {
|
||
const ok = await git(repoPath, ['rev-parse', '--verify', '--quiet', `origin/${branch}`])
|
||
.then(() => true)
|
||
.catch(() => false)
|
||
if (ok) return branch
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Everything shown on the project page.
|
||
*/
|
||
async details(project: Project) {
|
||
let compose: string | null = null
|
||
if (project.composeFile) {
|
||
compose = await readFile(join(project.path, project.composeFile), 'utf8').catch(() => null)
|
||
}
|
||
|
||
if (!project.git) return { ...project, compose, remote: null, defaultBranch: null, commits: [] }
|
||
|
||
const repo = project.git.repoPath
|
||
const [remote, defaultBranch] = await Promise.all([
|
||
git(repo, ['remote', 'get-url', 'origin']).catch(() => null),
|
||
this.#defaultBranch(repo),
|
||
])
|
||
|
||
/**
|
||
* When the project is on an older commit (detached), the commits of the
|
||
* remote branch are listed, to be able to come back to a newer one.
|
||
*/
|
||
const ref =
|
||
!project.git.detached && project.git.upstream
|
||
? project.git.upstream
|
||
: defaultBranch
|
||
? `origin/${defaultBranch}`
|
||
: 'HEAD'
|
||
const log = await git(repo, ['log', '-30', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI', ref]).catch(
|
||
() => ''
|
||
)
|
||
|
||
return {
|
||
...project,
|
||
compose,
|
||
remote: remote ? hideCredentials(remote) : null,
|
||
defaultBranch,
|
||
commits: log
|
||
.split('\n')
|
||
.map(parseCommit)
|
||
.filter((commit): commit is GitCommit => commit !== null),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "git fetch" to know if new commits are waiting.
|
||
*/
|
||
async fetch(project: Project) {
|
||
if (!project.git) throw new UserError('Ce projet n’a pas de dépôt git.')
|
||
try {
|
||
await git(project.git.repoPath, ['fetch', '--prune', 'origin'], 60_000)
|
||
} catch (error: any) {
|
||
throw new UserError(`git fetch a échoué : ${(error?.stderr || error?.message || '').trim()}`)
|
||
}
|
||
return this.#gitInfo(project.git.repoPath)
|
||
}
|
||
|
||
async runAction(
|
||
project: Project,
|
||
action: ProjectAction,
|
||
params: { commit?: string },
|
||
meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>
|
||
) {
|
||
if (project.kind === 'static') {
|
||
throw new UserError(
|
||
'Un site statique se met à jour en envoyant son build (voir la procédure).'
|
||
)
|
||
}
|
||
if (['deploy', 'checkout', 'checkout-branch'].includes(action) && !project.git) {
|
||
throw new UserError('Ce projet n’a pas de dépôt git.')
|
||
}
|
||
if (action === 'stop' && project.self) {
|
||
throw new UserError('Impossible d’arrêter le tableau de bord depuis lui-même.')
|
||
}
|
||
if (action === 'checkout' && !/^[0-9a-f]{7,40}$/.test(params.commit ?? '')) {
|
||
throw new UserError('Commit invalide.')
|
||
}
|
||
|
||
const repo = project.git?.repoPath ?? project.path
|
||
const compose = (ctx: JobContext, args: string[]) => this.#compose(ctx, project, args)
|
||
const recreate = (ctx: JobContext) => compose(ctx, ['up', '-d', '--build', '--force-recreate'])
|
||
|
||
return jobRunner.start(
|
||
{
|
||
...meta,
|
||
action: ACTION_LABELS[action],
|
||
target: `${project.category}/${project.name}`,
|
||
lockKey: `project:${project.path}`,
|
||
},
|
||
async (ctx) => {
|
||
switch (action) {
|
||
case 'deploy':
|
||
if (project.git?.detached) {
|
||
throw new Error(
|
||
'Le projet est sur un ancien commit : utilise d’abord « Revenir sur la branche ».'
|
||
)
|
||
}
|
||
await ctx.run('git', ['pull', '--ff-only'], { cwd: repo })
|
||
await recreate(ctx)
|
||
break
|
||
case 'restart':
|
||
await compose(ctx, ['restart'])
|
||
break
|
||
case 'stop':
|
||
await compose(ctx, ['stop'])
|
||
break
|
||
case 'start':
|
||
await compose(ctx, ['up', '-d'])
|
||
break
|
||
case 'recreate':
|
||
await recreate(ctx)
|
||
break
|
||
case 'pull':
|
||
await ctx.run('docker', ['compose', 'pull'], { cwd: project.path })
|
||
await compose(ctx, ['up', '-d'])
|
||
break
|
||
case 'checkout':
|
||
await ctx.run('git', ['checkout', '--detach', params.commit!], { cwd: repo })
|
||
await recreate(ctx)
|
||
break
|
||
case 'checkout-branch': {
|
||
const branch = await this.#defaultBranch(repo)
|
||
if (!branch) throw new Error('Branche distante introuvable.')
|
||
await ctx.run('git', ['checkout', branch], { cwd: repo })
|
||
await ctx.run('git', ['pull', '--ff-only'], { cwd: repo })
|
||
await recreate(ctx)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Runs "docker compose" in the project folder. For the dashboard itself,
|
||
* the command runs in a separate container: recreating the dashboard
|
||
* container would otherwise kill the command half-way.
|
||
*/
|
||
async #compose(ctx: JobContext, project: Project, args: string[]) {
|
||
if (!project.self) {
|
||
await ctx.run('docker', ['compose', ...args], { cwd: project.path })
|
||
return
|
||
}
|
||
|
||
ctx.log('Projet du tableau de bord : la commande est lancée dans un conteneur à part.')
|
||
await ctx.run('docker', [
|
||
'run',
|
||
'--rm',
|
||
'-d',
|
||
'--name',
|
||
`big-brother-updater-${Date.now()}`,
|
||
'-v',
|
||
'/var/run/docker.sock:/var/run/docker.sock',
|
||
'-v',
|
||
`${project.path}:${project.path}`,
|
||
'-w',
|
||
project.path,
|
||
'docker:cli',
|
||
'docker',
|
||
'compose',
|
||
...args,
|
||
])
|
||
ctx.log('Le tableau de bord va redémarrer : la page se reconnectera toute seule.')
|
||
}
|
||
|
||
/**
|
||
* Creates a project the way the procedure does it: folder, git clone,
|
||
* .env, database, docker-compose.yml, start, then the Caddy block.
|
||
*/
|
||
async create(input: CreateProjectInput, meta: Omit<JobMeta, 'action' | 'target' | 'lockKey'>) {
|
||
const isStatic = input.type === 'static'
|
||
const category = isStatic ? vpsConfig.staticCategory : input.category
|
||
const categoryPath = join(vpsConfig.srvRoot, category)
|
||
const path = join(categoryPath, input.name)
|
||
|
||
if (await exists(path)) throw new UserError(`Le dossier ${path} existe déjà.`)
|
||
|
||
const containers = await dockerService.list().catch(() => [] as ContainerSummary[])
|
||
if (!isStatic && containers.some((container) => container.name === input.name)) {
|
||
throw new UserError(`Un conteneur s’appelle déjà « ${input.name} ».`)
|
||
}
|
||
|
||
if (input.addCaddy) {
|
||
const sites = await caddyService.sites()
|
||
if (sites.some((site) => site.domains.includes(input.domain))) {
|
||
throw new UserError(`${input.domain} est déjà dans le Caddyfile.`)
|
||
}
|
||
}
|
||
|
||
return jobRunner.start(
|
||
{
|
||
...meta,
|
||
action: 'Création de projet',
|
||
target: `${category}/${input.name}`,
|
||
lockKey: `create:${path}`,
|
||
},
|
||
async (ctx) => {
|
||
await mkdir(categoryPath, { recursive: true })
|
||
await mkdir(path)
|
||
ctx.log(`Dossier créé : ${path}`)
|
||
|
||
if (isStatic) {
|
||
await writeFile(join(path, 'index.html'), placeholderPage(input.name))
|
||
ctx.log('Page d’attente créée : envoie ensuite le build du site dans ce dossier.')
|
||
} else {
|
||
await this.#createApp(ctx, input, path)
|
||
}
|
||
|
||
if (input.addCaddy) {
|
||
const block = isStatic
|
||
? staticBlock(input.domain, `${vpsConfig.srvRoot}/${category}/${input.name}`, input.spa)
|
||
: proxyBlock(
|
||
input.domain,
|
||
`${input.name}:${input.type === 'wordpress' ? 80 : input.port}`
|
||
)
|
||
ctx.log(`Ajout au Caddyfile :\n${block}`)
|
||
const result = await caddyService.appendBlock(block, {
|
||
username: meta.username,
|
||
reason: `Création de ${input.name}`,
|
||
})
|
||
if (result.output) ctx.log(result.output, result.ok ? 'out' : 'err')
|
||
if (!result.ok)
|
||
throw new Error('Caddy a refusé la configuration, le Caddyfile est inchangé.')
|
||
ctx.log(`Caddy rechargé : https://${input.domain}`)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
async #createApp(ctx: JobContext, input: CreateProjectInput, path: string) {
|
||
const type = input.type as Exclude<ProjectType, 'static'>
|
||
const isWordpress = type === 'wordpress'
|
||
|
||
if (!isWordpress) {
|
||
await ctx.run('git', ['clone', input.gitUrl!, 'project'], {
|
||
cwd: path,
|
||
display: `git clone ${hideCredentials(input.gitUrl!)} project`,
|
||
})
|
||
}
|
||
|
||
let database: { name: string; user: string; password: string } | null = null
|
||
if (isWordpress || input.createDatabase) {
|
||
const dbName = input.name.replace(/-/g, '_')
|
||
database = { name: dbName, user: dbName, password: randomSecret(24) }
|
||
await this.#createDatabase(ctx, database)
|
||
}
|
||
|
||
const envLines: string[] = []
|
||
if (isWordpress) {
|
||
envLines.push(
|
||
`WP_DB_NAME=${database!.name}`,
|
||
`WP_DB_USER=${database!.user}`,
|
||
`WP_DB_PASSWORD=${database!.password}`
|
||
)
|
||
await writeFile(join(path, '.env'), `${envLines.join('\n')}\n`, { mode: 0o600 })
|
||
} else {
|
||
const env = (input.env ?? '').replace(/\r\n/g, '\n').trim()
|
||
const has = (key: string) => new RegExp(`^${key}=`, 'm').test(env)
|
||
if (env) envLines.push(env)
|
||
if (type === 'adonis') {
|
||
if (!has('APP_KEY')) envLines.push(`APP_KEY=${randomSecret(32)}`)
|
||
if (!has('LOG_LEVEL')) envLines.push('LOG_LEVEL=info')
|
||
if (!has('APP_URL')) envLines.push(`APP_URL=https://${input.domain}`)
|
||
}
|
||
if (database) {
|
||
envLines.push(
|
||
'DB_HOST=mariadb',
|
||
'DB_PORT=3306',
|
||
`DB_USER=${database.user}`,
|
||
`DB_PASSWORD=${database.password}`,
|
||
`DB_DATABASE=${database.name}`
|
||
)
|
||
}
|
||
await writeFile(join(path, 'project', '.env'), `${envLines.join('\n')}\n`, { mode: 0o600 })
|
||
}
|
||
ctx.log('Fichier .env écrit.')
|
||
|
||
await writeFile(
|
||
join(path, 'docker-compose.yml'),
|
||
composeFile(type, input.name, input.port ?? 80, input.command)
|
||
)
|
||
ctx.log('docker-compose.yml écrit.')
|
||
|
||
await ctx.run('docker', ['compose', 'up', '-d'], { cwd: path })
|
||
}
|
||
|
||
/**
|
||
* One database and one user per project, created with the root password
|
||
* of the MariaDB container (read inside the container, never here).
|
||
*/
|
||
async #createDatabase(
|
||
ctx: JobContext,
|
||
database: { name: string; user: string; password: string }
|
||
) {
|
||
const sql = [
|
||
`CREATE DATABASE \`${database.name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`,
|
||
`CREATE USER '${database.user}'@'%' IDENTIFIED BY '${database.password}';`,
|
||
`GRANT ALL PRIVILEGES ON \`${database.name}\`.* TO '${database.user}'@'%';`,
|
||
].join(' ')
|
||
|
||
await ctx.run(
|
||
'docker',
|
||
[
|
||
'exec',
|
||
vpsConfig.mariadb.container,
|
||
'sh',
|
||
'-c',
|
||
'exec "$(command -v mariadb || command -v mysql)" -u root -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" -e "$0"',
|
||
sql,
|
||
],
|
||
{
|
||
display: `docker exec ${vpsConfig.mariadb.container} mariadb -e "CREATE DATABASE ${database.name} … (utilisateur ${database.user})"`,
|
||
}
|
||
)
|
||
ctx.log(`Base « ${database.name} » et utilisateur créés (mot de passe dans le .env).`)
|
||
}
|
||
}
|
||
|
||
function parseCommit(line: string): GitCommit | null {
|
||
const [hash, short, subject, author, date] = line.split('\x1f')
|
||
if (!hash || !short) return null
|
||
return { hash, short, subject, author, date }
|
||
}
|
||
|
||
const projectService = new ProjectService()
|
||
export default projectService
|