first commit

This commit is contained in:
Rutra committed 2026-10-12 14:25:22 +02:00
commit 3c0479faf0
169 files changed
+27617

No files matched your search

+629
View File
@@ -0,0 +1,629 @@
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