172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
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<BackupFile[]> {
|
|
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<JobMeta, 'action' | 'target' | 'lockKey'>) {
|
|
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
|