Files
big-brother/api/app/services/project_service.ts
T
2026-10-12 14:25:22 +02:00

630 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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