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 = { '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 { 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 { 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 { 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 { 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 ) { 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) { 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 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