import { mkdir, readdir, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { DateTime } from 'luxon' import logger from '@adonisjs/core/services/logger' import vpsConfig from '#config/vps' import UserError from '#exceptions/user_error' import jobRunner, { type JobMeta } from '#services/job_runner' /** * db-2026-10-11_0300.sql.gz (all the MariaDB databases) * srv-2026-10-11_0300.tar.gz (the /srv folder) */ const BACKUP_FILE = /^(db|srv)-\d{4}-\d{2}-\d{2}_\d{4}\.(sql\.gz|tar\.gz)$/ export interface BackupFile { name: string type: 'db' | 'srv' size: number createdAt: number } class BackupService { #timer: NodeJS.Timeout | null = null #lastScheduledDay: string | null = null async list(): Promise { let names: string[] = [] try { names = await readdir(vpsConfig.backups.dir) } catch { return [] } const files = await Promise.all( names .filter((name) => BACKUP_FILE.test(name)) .map(async (name) => { const info = await stat(join(vpsConfig.backups.dir, name)) return { name, type: name.startsWith('db-') ? 'db' : 'srv', size: info.size, createdAt: Math.floor(info.mtimeMs / 1000), } satisfies BackupFile }) ) return files.sort((a, b) => b.createdAt - a.createdAt) } /** * Absolute path of a backup, after checking its name (no path traversal). */ path(name: string) { if (!BACKUP_FILE.test(name)) throw new UserError('Sauvegarde introuvable.', 404) return join(vpsConfig.backups.dir, name) } async delete(name: string) { await rm(this.path(name)) } run(meta: Omit) { return jobRunner.start( { ...meta, action: 'Sauvegarde', target: vpsConfig.backups.dir, lockKey: 'backup' }, async (ctx) => { const dir = vpsConfig.backups.dir const stamp = DateTime.now().toFormat('yyyy-LL-dd_HHmm') await mkdir(dir, { recursive: true }) const dbFile = join(dir, `db-${stamp}.sql.gz`) ctx.log('1/2 Export de toutes les bases MariaDB') try { await ctx.run( 'docker', [ 'exec', vpsConfig.mariadb.container, 'sh', '-c', 'exec "$(command -v mariadb-dump || command -v mysqldump)" -u root -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --all-databases --single-transaction --routines --events', ], { display: `docker exec ${vpsConfig.mariadb.container} mariadb-dump --all-databases > ${basename(dbFile)}`, stdoutFile: dbFile, gzipStdout: true, } ) } catch (error) { await rm(dbFile, { force: true }) throw error } /** * node_modules can be reinstalled and db_data holds the raw MariaDB * files, already covered (consistently) by the export above. */ const srvFile = join(dir, `srv-${stamp}.tar.gz`) ctx.log(`2/2 Archive de ${vpsConfig.srvRoot}`) try { await ctx.run( 'tar', [ 'czf', srvFile, '--exclude=node_modules', '--exclude=db_data', '-C', dirname(vpsConfig.srvRoot), basename(vpsConfig.srvRoot), ], /** * 1 = some files changed while being read (logs…): not an error. */ { okCodes: [0, 1] } ) } catch (error) { await rm(srvFile, { force: true }) throw error } const removed = await this.#applyRetention() if (removed.length) ctx.log(`Anciennes sauvegardes supprimées : ${removed.join(', ')}`) } ) } /** * Keeps the last N backups of each type. */ async #applyRetention() { const files = await this.list() const removed: string[] = [] for (const type of ['db', 'srv'] as const) { const old = files.filter((file) => file.type === type).slice(vpsConfig.backups.keep) for (const file of old) { await rm(join(vpsConfig.backups.dir, file.name), { force: true }) removed.push(file.name) } } return removed } get schedule() { return { dailyAt: vpsConfig.backups.dailyAt, keep: vpsConfig.backups.keep } } /** * Daily backup at BACKUP_DAILY_AT (server time). */ startSchedule() { const dailyAt = vpsConfig.backups.dailyAt if (!dailyAt) return this.#timer = setInterval(() => { const now = DateTime.now() const today = now.toISODate() if (now.toFormat('HH:mm') !== dailyAt || this.#lastScheduledDay === today) return this.#lastScheduledDay = today this.run({ username: 'automatique', ip: null }).catch((error) => logger.error({ err: error }, 'scheduled backup failed to start') ) }, 30_000) } stopSchedule() { if (this.#timer) clearInterval(this.#timer) } } const backupService = new BackupService() export default backupService