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