first commit
This commit is contained in:
commit
3c0479faf0
169 files changed
+27617
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user