diff --git a/packages/hydrooj/src/commands/db.ts b/packages/hydrooj/src/commands/db.ts index e34575004..042d8546c 100644 --- a/packages/hydrooj/src/commands/db.ts +++ b/packages/hydrooj/src/commands/db.ts @@ -4,7 +4,7 @@ import path from 'path'; import readline from 'readline/promises'; import cac, { CAC } from 'cac'; import fs from 'fs-extra'; -import { Logger, size } from '@hydrooj/utils'; +import { Logger, size, sleep } from '@hydrooj/utils'; import { hydroPath } from '../options'; const argv = cac().parse(); @@ -42,30 +42,68 @@ export function register(cli: CAC) { cli.command('backup') .option('--dbOnly', 'Only dump database', { default: false }) .option('--withAddons', 'Include addons', { default: false }) + .option('--withLogs', 'Include logs', { default: false }) + .option('-r ', 'Restic repository', { default: '' }) + .option('-p ', 'Restic password', { default: '' }) .action(() => { const url = getUrl(); - exec('mongodump', [url, `--out=${dir}/dump`], { stdio: 'inherit' }); + exec('mongodump', [ + url, `--out=${dir}/dump`, + '--excludeCollection=opcount', '--excludeCollection=event', + ...(argv.options.withLogs ? [] : ['--excludeCollection=oplog']), + ], { stdio: 'inherit' }); const target = `${process.cwd()}/backup-${new Date().toISOString().replace(':', '-').split(':')[0]}.zip`; - const addToZip = (cwd: string, item: string) => exec('zip', ['-r', target, item], { cwd, stdio: 'inherit' }); - addToZip(dir, 'dump'); - if (!argv.options.dbOnly) addToZip('/data', 'file'); + const filesToAdd = []; + const filesToRemove = []; + const addFile = argv.options.r + ? (cwd: string, item: string, keepSource = true) => { + filesToAdd.push(item); + if (cwd === '/data') return; + if (keepSource) fs.copySync(path.join(cwd, item), path.join('/data', item), { overwrite: true }); + else fs.moveSync(path.join(cwd, item), path.join('/data', item), { overwrite: true }); + filesToRemove.push(path.join(cwd, item)); + } + : (cwd: string, item: string, keepSource = true) => { + exec('zip', ['-r', target, item], { cwd, stdio: 'inherit' }); + if (!keepSource) fs.removeSync(path.join(cwd, item)); + }; + addFile(dir, 'dump', false); + if (!argv.options.dbOnly) addFile('/data', 'file'); if (argv.options.withAddons) { - if (fs.existsSync(path.join(hydroPath, 'addons'))) addToZip(hydroPath, 'addons'); - if (fs.existsSync(path.join(hydroPath, 'addon.json'))) addToZip(hydroPath, 'addon.json'); + if (fs.existsSync(path.join(hydroPath, 'addons'))) addFile(hydroPath, 'addons'); + if (fs.existsSync(path.join(hydroPath, 'addon.json'))) addFile(hydroPath, 'addon.json'); + } + if (argv.options.withLogs && fs.existsSync('/data/access.log')) addFile('/data', 'access.log'); + if (argv.options.r) { + if (!argv.options.p) throw new Error('Restic password is required'); + const passwordFile = path.join(os.tmpdir(), 'hydrooj-restic-password'); + fs.writeFileSync(passwordFile, argv.options.p.toString()); + exec('restic', ['backup', '-r', argv.options.r.toString(), '-p', passwordFile, ...filesToAdd], { stdio: 'inherit', cwd: '/data' }); + fs.removeSync(passwordFile); + for (const file of filesToRemove) fs.removeSync(file); + } else { + const stat = fs.statSync(target); + logger.success(`Database backup saved at ${target} , size: ${size(stat.size)}`); } fs.removeSync(dir); - const stat = fs.statSync(target); - logger.success(`Database backup saved at ${target} , size: ${size(stat.size)}`); }); - cli.command('restore ') + cli.command('restore [filename]') .option('-y', 'Assume yes', { default: false }) .option('--withAddons', 'Include addons', { default: false }) + .option('-r ', 'Restic repository', { default: '' }) + .option('-p ', 'Restic password', { default: '' }) .action(async (filename) => { const url = getUrl(); - if (!fs.existsSync(filename)) { + let restic: child.ChildProcess; + let dataDir = dir; + if (filename && !fs.existsSync(filename)) { logger.error('Cannot find file'); return; } + if (!filename && !argv.options.r) { + logger.error('No backup file or restic repository specified'); + return; + } if (!argv.options.y) { const rl = readline.createInterface(process.stdin, process.stdout); const answer = await rl.question(`Overwrite current database with backup file ${filename}? [y/N]`); @@ -75,21 +113,46 @@ export function register(cli: CAC) { return; } } - exec('unzip', [filename, '-d', dir], { stdio: 'inherit' }); - exec('mongorestore', [`--uri=${url}`, `--dir=${dir}/dump/hydro`, '--drop'], { stdio: 'inherit' }); - if (fs.existsSync(`${dir}/file`)) { + if (filename && filename.endsWith('.zip')) { + exec('unzip', [filename, '-d', dir], { stdio: 'inherit' }); + } else if (filename) { + dataDir = filename; + } else { + if (!argv.options.p) throw new Error('Restic password is required'); + const passwordFile = path.join(os.tmpdir(), 'hydrooj-restic-password'); + fs.writeFileSync(passwordFile, argv.options.p.toString()); + fs.ensureDirSync(dir); + restic = child.spawn('restic', ['mount', '-r', argv.options.r, '-p', passwordFile, dir], { stdio: 'inherit' }); + await sleep(1000); + fs.removeSync(passwordFile); + dataDir = path.join(dir, 'snapshots/latest'); + } + let isReadOnly = false; + try { + await fs.access(dataDir, fs.constants.W_OK); + } catch (e) { + isReadOnly = true; + } + exec('mongorestore', [`--uri=${url}`, `--dir=${dataDir}/dump/hydro`, '--drop'], { stdio: 'inherit' }); + if (fs.existsSync(`${dataDir}/file`)) { exec('rm', ['-rf', '/data/file/hydro'], { stdio: 'inherit' }); - exec('bash', ['-c', `mv ${dir}/file/* /data/file`], { stdio: 'inherit' }); + exec('bash', ['-c', `${isReadOnly ? 'cp -r' : 'mv'} ${dataDir}/file/* /data/file`], { stdio: 'inherit' }); } if (argv.options.withAddons) { - if (fs.existsSync(`${dir}/addons.json`)) { - fs.copyFileSync(`${dir}/addons.json`, path.join(hydroPath, 'addons.json')); + if (fs.existsSync(`${dataDir}/addons.json`)) { + fs.copyFileSync(`${dataDir}/addons.json`, path.join(hydroPath, 'addons.json')); } - if (fs.existsSync(`${dir}/addons`)) { + if (fs.existsSync(`${dataDir}/addons`)) { fs.removeSync(path.join(hydroPath, 'addons')); - fs.copySync(`${dir}/addons`, path.join(hydroPath, 'addons')); + fs.copySync(`${dataDir}/addons`, path.join(hydroPath, 'addons')); } } + if (restic) { + await new Promise((resolve) => { + restic.on('exit', () => resolve(null)); + restic.kill('SIGINT'); + }); + } fs.removeSync(dir); logger.success('Successfully restored.'); });