backups: add backup multiple targets

This commit is contained in:
Girish Ramakrishnan
2025-07-24 19:02:02 +02:00
parent 100bea981d
commit 3aafbd2ccb
25 changed files with 744 additions and 535 deletions

View File

@@ -9,7 +9,6 @@ exports = module.exports = {
downloadApp,
backupApp,
backupMail,
downloadMail,
upload,
@@ -36,17 +35,17 @@ const apps = require('./apps.js'),
const BACKUP_UPLOAD_CMD = path.join(__dirname, 'scripts/backupupload.js');
async function checkPreconditions(backupConfig, dataLayout) {
assert.strictEqual(typeof backupConfig, 'object');
async function checkPreconditions(backupTarget, dataLayout) {
assert.strictEqual(typeof backupTarget, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
// check mount status before uploading
const status = await backupTargets.ensureMounted();
const status = await backupTargets.ensureMounted(backupTarget);
debug(`checkPreconditions: mount point status is ${JSON.stringify(status)}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not active: ${status.message}`);
// check availabe size. this requires root for df to work
const available = await storage.api(backupConfig.provider).getAvailableSize(backupConfig);
const available = await storage.api(backupTarget.provider).getAvailableSize(backupTarget.config);
let used = 0;
for (const localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
@@ -64,20 +63,22 @@ async function checkPreconditions(backupConfig, dataLayout) {
}
// this function is called via backupupload (since it needs root to traverse app's directory)
async function upload(remotePath, format, dataLayoutString, progressCallback) {
async function upload(remotePath, targetId, dataLayoutString, progressCallback) {
assert.strictEqual(typeof remotePath, 'string');
assert.strictEqual(typeof format, 'string');
assert.strictEqual(typeof targetId, 'string');
assert.strictEqual(typeof dataLayoutString, 'string');
assert.strictEqual(typeof progressCallback, 'function');
debug(`upload: path ${remotePath} format ${format} dataLayout ${dataLayoutString}`);
debug(`upload: path ${remotePath} target ${targetId} dataLayout ${dataLayoutString}`);
const backupTarget = await backupTargets.get(targetId);
if (!backupTarget) throw new BoxError(BoxError.NOT_FOUND, 'Backup target not found');
const dataLayout = DataLayout.fromString(dataLayoutString);
const backupConfig = await backupTargets.getConfig();
await checkPreconditions(backupConfig, dataLayout);
await checkPreconditions(backupTarget, dataLayout);
await backupFormat.api(format).upload(backupConfig, remotePath, dataLayout, progressCallback);
await backupFormat.api(backupTarget.format).upload(backupTarget, remotePath, dataLayout, progressCallback);
}
async function download(backupConfig, remotePath, format, dataLayout, progressCallback) {
@@ -131,16 +132,16 @@ async function runBackupUpload(uploadConfig, progressCallback) {
assert.strictEqual(typeof uploadConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const { remotePath, backupConfig, dataLayout, progressTag } = uploadConfig;
const { remotePath, backupTarget, dataLayout, progressTag } = uploadConfig;
assert.strictEqual(typeof remotePath, 'string');
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof progressTag, 'string');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
// https://stackoverflow.com/questions/48387040/node-js-recommended-max-old-space-size
const envCopy = Object.assign({}, process.env);
if (backupConfig.limits?.memoryLimit >= 2*1024*1024*1024) {
const heapSize = Math.min((backupConfig.limits.memoryLimit/1024/1024) - 256, 8192);
if (backupTarget.limits?.memoryLimit >= 2*1024*1024*1024) {
const heapSize = Math.min((backupTarget.limits.memoryLimit/1024/1024) - 256, 8192);
debug(`runBackupUpload: adjusting heap size to ${heapSize}M`);
envCopy.NODE_OPTIONS = `--max-old-space-size=${heapSize}`;
}
@@ -153,7 +154,7 @@ async function runBackupUpload(uploadConfig, progressCallback) {
}
// do not use debug for logging child output because it already has timestamps via it's own debug
const [error] = await safe(shell.sudo([ BACKUP_UPLOAD_CMD, remotePath, backupConfig.format, dataLayout.toString() ], { env: envCopy, preserveEnv: true, onMessage, logger: process.stdout.write }));
const [error] = await safe(shell.sudo([ BACKUP_UPLOAD_CMD, remotePath, backupTarget.id, dataLayout.toString() ], { env: envCopy, preserveEnv: true, onMessage, logger: process.stdout.write }));
if (error && (error.code === null /* signal */ || (error.code !== 0 && error.code !== 50))) { // backuptask crashed
debug(`runBackupUpload: backuptask crashed`, error);
throw new BoxError(BoxError.INTERNAL_ERROR, 'Backuptask crashed');
@@ -173,8 +174,8 @@ async function snapshotBox(progressCallback) {
debug(`snapshotBox: took ${(new Date() - startTime)/1000} seconds`);
}
async function uploadBoxSnapshot(backupConfig, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
async function uploadBoxSnapshot(backupTarget, progressCallback) {
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await snapshotBox(progressCallback);
@@ -184,7 +185,7 @@ async function uploadBoxSnapshot(backupConfig, progressCallback) {
const uploadConfig = {
remotePath: 'snapshot/box',
backupConfig,
backupTarget,
dataLayout: new DataLayout(boxDataDir, []),
progressTag: 'box'
};
@@ -197,21 +198,21 @@ async function uploadBoxSnapshot(backupConfig, progressCallback) {
debug(`uploadBoxSnapshot: took ${(new Date() - startTime)/1000} seconds`);
await backupTargets.setSnapshotInfo('box', { timestamp: new Date().toISOString(), format: backupConfig.format });
await backupTargets.setSnapshotInfo('box', { timestamp: new Date().toISOString(), format: backupTarget.format });
}
async function copy(backupConfig, srcRemotePath, destRemotePath, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
async function copy(backupTarget, srcRemotePath, destRemotePath, progressCallback) {
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof srcRemotePath, 'string');
assert.strictEqual(typeof destRemotePath, 'string');
assert.strictEqual(typeof progressCallback, 'function');
const { provider, format } = backupConfig;
const oldFilePath = backupFormat.api(format).getBackupFilePath(backupConfig, srcRemotePath);
const newFilePath = backupFormat.api(format).getBackupFilePath(backupConfig, destRemotePath);
const { config, format } = backupTarget;
const oldFilePath = backupFormat.api(format).getBackupFilePath(backupTarget, srcRemotePath);
const newFilePath = backupFormat.api(format).getBackupFilePath(backupTarget, destRemotePath);
const startTime = new Date();
const [copyError] = await safe(storage.api(provider).copy(backupConfig, oldFilePath, newFilePath, progressCallback));
const [copyError] = await safe(storage.api(config.provider).copy(config, oldFilePath, newFilePath, progressCallback));
if (copyError) {
debug(`copy: copied to ${destRemotePath} errored. error: ${copyError.message}`);
throw copyError;
@@ -219,8 +220,8 @@ async function copy(backupConfig, srcRemotePath, destRemotePath, progressCallbac
debug(`copy: copied successfully to ${destRemotePath}. Took ${(new Date() - startTime)/1000} seconds`);
}
async function rotateBoxBackup(backupConfig, tag, options, dependsOn, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
async function rotateBoxBackup(backupTarget, tag, options, dependsOn, progressCallback) {
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof options, 'object');
assert(Array.isArray(dependsOn));
@@ -232,7 +233,7 @@ async function rotateBoxBackup(backupConfig, tag, options, dependsOn, progressCa
const data = {
remotePath,
encryptionVersion: backupConfig.encryption ? 2 : null,
encryptionVersion: backupTarget.encryption ? 2 : null,
packageVersion: constants.VERSION,
type: backupListing.BACKUP_TYPE_BOX,
state: backupListing.BACKUP_STATE_CREATING,
@@ -244,7 +245,7 @@ async function rotateBoxBackup(backupConfig, tag, options, dependsOn, progressCa
};
const id = await backupListing.add(data);
const [error] = await safe(copy(backupConfig, 'snapshot/box', remotePath, progressCallback));
const [error] = await safe(copy(backupTarget, 'snapshot/box', remotePath, progressCallback));
const state = error ? backupListing.BACKUP_STATE_ERROR : backupListing.BACKUP_STATE_NORMAL;
await backupListing.setState(id, state);
if (error) throw error;
@@ -252,20 +253,19 @@ async function rotateBoxBackup(backupConfig, tag, options, dependsOn, progressCa
return id;
}
async function backupBox(dependsOn, tag, options, progressCallback) {
async function backupBox(backupTarget, dependsOn, tag, options, progressCallback) {
assert.strictEqual(typeof backupTarget, 'object');
assert(Array.isArray(dependsOn));
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const backupConfig = await backupTargets.getConfig();
await uploadBoxSnapshot(backupConfig, progressCallback);
return await rotateBoxBackup(backupConfig, tag, options, dependsOn, progressCallback);
await uploadBoxSnapshot(backupTarget, progressCallback);
return await rotateBoxBackup(backupTarget, tag, options, dependsOn, progressCallback);
}
async function rotateAppBackup(backupConfig, app, tag, options, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
async function rotateAppBackup(backupTarget, app, tag, options, progressCallback) {
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof options, 'object');
@@ -280,7 +280,7 @@ async function rotateAppBackup(backupConfig, app, tag, options, progressCallback
const data = {
remotePath,
encryptionVersion: backupConfig.encryption ? 2 : null,
encryptionVersion: backupTarget.encryption ? 2 : null,
packageVersion: manifest.version,
type: backupListing.BACKUP_TYPE_APP,
state: backupListing.BACKUP_STATE_CREATING,
@@ -292,7 +292,7 @@ async function rotateAppBackup(backupConfig, app, tag, options, progressCallback
};
const id = await backupListing.add(data);
const [error] = await safe(copy(backupConfig, `snapshot/app_${app.id}`, remotePath, progressCallback));
const [error] = await safe(copy(backupTarget, `snapshot/app_${app.id}`, remotePath, progressCallback));
const state = error ? backupListing.BACKUP_STATE_ERROR : backupListing.BACKUP_STATE_NORMAL;
await backupListing.setState(id, state);
if (error) throw error;
@@ -300,8 +300,9 @@ async function rotateAppBackup(backupConfig, app, tag, options, progressCallback
return id;
}
async function backupApp(app, options, progressCallback) {
async function backupApp(app, backupTarget, options, progressCallback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
@@ -311,7 +312,7 @@ async function backupApp(app, options, progressCallback) {
await snapshotApp(app, progressCallback);
} else {
const tag = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
backupId = await backupAppWithTag(app, tag, options, progressCallback);
backupId = await backupAppWithTag(app, backupTarget, tag, options, progressCallback);
}
await locks.release(`${locks.TYPE_APP_BACKUP_PREFIX}${app.id}`);
@@ -331,8 +332,8 @@ async function snapshotApp(app, progressCallback) {
debug(`snapshotApp: ${app.fqdn} took ${(new Date() - startTime)/1000} seconds`);
}
async function uploadAppSnapshot(backupConfig, app, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
async function uploadAppSnapshot(backupTarget, app, progressCallback) {
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof progressCallback, 'function');
@@ -348,7 +349,7 @@ async function uploadAppSnapshot(backupConfig, app, progressCallback) {
const uploadConfig = {
remotePath,
backupConfig,
backupTarget,
dataLayout,
progressTag: app.fqdn
};
@@ -359,11 +360,12 @@ async function uploadAppSnapshot(backupConfig, app, progressCallback) {
debug(`uploadAppSnapshot: ${app.fqdn} uploaded to ${remotePath}. ${(new Date() - startTime)/1000} seconds`);
await backupTargets.setSnapshotInfo(app.id, { timestamp: new Date().toISOString(), manifest: app.manifest, format: backupConfig.format });
await backupTargets.setSnapshotInfo(app.id, { timestamp: new Date().toISOString(), manifest: app.manifest, format: backupTarget.format });
}
async function backupAppWithTag(app, tag, options, progressCallback) {
async function backupAppWithTag(app, backupTarget, tag, options, progressCallback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
@@ -375,14 +377,12 @@ async function backupAppWithTag(app, tag, options, progressCallback) {
return results[0].id;
}
const backupConfig = await backupTargets.getConfig();
await uploadAppSnapshot(backupConfig, app, progressCallback);
return await rotateAppBackup(backupConfig, app, tag, options, progressCallback);
await uploadAppSnapshot(backupTarget, app, progressCallback);
return await rotateAppBackup(backupTarget, app, tag, options, progressCallback);
}
async function uploadMailSnapshot(backupConfig, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
async function uploadMailSnapshot(backupTarget, progressCallback) {
assert.strictEqual(typeof backupTarget, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const mailDataDir = safe.fs.realpathSync(paths.MAIL_DATA_DIR);
@@ -390,7 +390,7 @@ async function uploadMailSnapshot(backupConfig, progressCallback) {
const uploadConfig = {
remotePath: 'snapshot/mail',
backupConfig,
backupTarget,
dataLayout: new DataLayout(mailDataDir, []),
progressTag: 'mail'
};
@@ -403,11 +403,11 @@ async function uploadMailSnapshot(backupConfig, progressCallback) {
debug(`uploadMailSnapshot: took ${(new Date() - startTime)/1000} seconds`);
await backupTargets.setSnapshotInfo('mail', { timestamp: new Date().toISOString(), format: backupConfig.format });
await backupTargets.setSnapshotInfo('mail', { timestamp: new Date().toISOString(), format: backupTarget.format });
}
async function rotateMailBackup(backupConfig, tag, options, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
async function rotateMailBackup(target, tag, options, progressCallback) {
assert.strictEqual(typeof target, 'object');
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
@@ -418,7 +418,7 @@ async function rotateMailBackup(backupConfig, tag, options, progressCallback) {
const data = {
remotePath,
encryptionVersion: backupConfig.encryption ? 2 : null,
encryptionVersion: target.encryption ? 2 : null,
packageVersion: constants.VERSION,
type: backupListing.BACKUP_TYPE_MAIL,
state: backupListing.BACKUP_STATE_CREATING,
@@ -430,7 +430,7 @@ async function rotateMailBackup(backupConfig, tag, options, progressCallback) {
};
const id = await backupListing.add(data);
const [error] = await safe(copy(backupConfig, 'snapshot/mail', remotePath, progressCallback));
const [error] = await safe(copy(target, 'snapshot/mail', remotePath, progressCallback));
const state = error ? backupListing.BACKUP_STATE_ERROR : backupListing.BACKUP_STATE_NORMAL;
await backupListing.setState(id, state);
if (error) throw error;
@@ -438,25 +438,16 @@ async function rotateMailBackup(backupConfig, tag, options, progressCallback) {
return id;
}
async function backupMailWithTag(tag, options, progressCallback) {
async function backupMailWithTag(target, tag, options, progressCallback) {
assert.strictEqual(typeof target, 'object');
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
debug(`backupMailWithTag: backing up mail with tag ${tag}`);
const backupConfig = await backupTargets.getConfig();
await uploadMailSnapshot(backupConfig, progressCallback);
return await rotateMailBackup(backupConfig, tag, options, progressCallback);
}
async function backupMail(options, progressCallback) {
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const tag = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
debug(`backupMail: backing up mail with tag ${tag}`);
return await backupMailWithTag(tag, options, progressCallback);
await uploadMailSnapshot(target, progressCallback);
return await rotateMailBackup(target, tag, options, progressCallback);
}
async function downloadMail(restoreConfig, progressCallback) {
@@ -474,10 +465,14 @@ async function downloadMail(restoreConfig, progressCallback) {
}
// this function is called from external process. calling process is expected to have a lock
async function fullBackup(options, progressCallback) {
async function fullBackup(backupTargetId, options, progressCallback) {
assert.strictEqual(typeof backupTargetId, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const backupTarget = await backupTargets.get(backupTargetId);
if (!backupTarget) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Backup target not found');
const tag = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,''); // unique tag under which all apps/mail/box backs up
const allApps = await apps.list();
@@ -498,7 +493,7 @@ async function fullBackup(options, progressCallback) {
progressCallback({ percent, message: `Backing up ${app.fqdn} (${i+1}/${allApps.length}). Waiting for lock` });
await locks.wait(`${locks.TYPE_APP_BACKUP_PREFIX}${app.id}`);
const startTime = new Date();
const [appBackupError, appBackupId] = await safe(backupAppWithTag(app, tag, options, (progress) => progressCallback({ percent, message: progress.message })));
const [appBackupError, appBackupId] = await safe(backupAppWithTag(app, backupTarget, tag, options, (progress) => progressCallback({ percent, message: progress.message })));
debug(`fullBackup: app ${app.fqdn} backup finished. Took ${(new Date() - startTime)/1000} seconds`);
await locks.release(`${locks.TYPE_APP_BACKUP_PREFIX}${app.id}`);
if (appBackupError) throw appBackupError;
@@ -507,18 +502,18 @@ async function fullBackup(options, progressCallback) {
progressCallback({ percent, message: 'Backing up mail' });
percent += step;
const mailBackupId = await backupMailWithTag(tag, options, (progress) => progressCallback({ percent, message: progress.message }));
const mailBackupId = await backupMailWithTag(backupTarget, tag, options, (progress) => progressCallback({ percent, message: progress.message }));
progressCallback({ percent, message: 'Backing up system data' });
percent += step;
const dependsOn = appBackupIds.concat(mailBackupId);
const backupId = await backupBox(dependsOn, tag, options, (progress) => progressCallback({ percent, message: progress.message }));
const backupId = await backupBox(backupTarget, dependsOn, tag, options, (progress) => progressCallback({ percent, message: progress.message }));
return backupId;
}
// this function is called from external process
async function appBackup(appId, options, progressCallback) {
async function appBackup(appId, backupTargetId, options, progressCallback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
@@ -526,9 +521,12 @@ async function appBackup(appId, options, progressCallback) {
const app = await apps.get(appId);
if (!app) throw new BoxError(BoxError.BAD_FIELD, 'App not found');
const backupTarget = await backupTargets.get(backupTargetId);
if (!backupTarget) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Backup target not found');
await progressCallback({ percent: 1, message: `Backing up ${app.fqdn}. Waiting for lock` });
const startTime = new Date();
const backupId = await backupApp(app, options, progressCallback);
const backupId = await backupApp(app, backupTarget, options, progressCallback);
await progressCallback({ percent: 100, message: `app ${app.fqdn} backup finished. Took ${(new Date() - startTime)/1000} seconds` });
return backupId;
}