'use strict'; exports = module.exports = { BackupsError: BackupsError, getPaged: getPaged, getByAppIdPaged: getByAppIdPaged, getRestoreUrl: getRestoreUrl, getRestoreConfig: getRestoreConfig, ensureBackup: ensureBackup, backup: backup, backupApp: backupApp, restoreApp: restoreApp, backupBoxAndApps: backupBoxAndApps }; var addons = require('./addons.js'), appdb = require('./appdb.js'), apps = require('./apps.js'), async = require('async'), assert = require('assert'), backupdb = require('./backupdb.js'), caas = require('./storage/caas.js'), config = require('./config.js'), DatabaseError = require('./databaseerror.js'), debug = require('debug')('box:backups'), eventlog = require('./eventlog.js'), locker = require('./locker.js'), path = require('path'), paths = require('./paths.js'), progress = require('./progress.js'), s3 = require('./storage/s3.js'), safe = require('safetydance'), shell = require('./shell.js'), settings = require('./settings.js'), superagent = require('superagent'), util = require('util'), webhooks = require('./webhooks.js'); var BACKUP_BOX_CMD = path.join(__dirname, 'scripts/backupbox.sh'), BACKUP_APP_CMD = path.join(__dirname, 'scripts/backupapp.sh'), RESTORE_APP_CMD = path.join(__dirname, 'scripts/restoreapp.sh'); var NOOP_CALLBACK = function (error) { if (error) debug(error); }; function debugApp(app, args) { assert(!app || typeof app === 'object'); var prefix = app ? app.location : '(no app)'; debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1))); } function BackupsError(reason, errorOrMessage) { assert.strictEqual(typeof reason, 'string'); assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); Error.call(this); Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; this.reason = reason; if (typeof errorOrMessage === 'undefined') { this.message = reason; } else if (typeof errorOrMessage === 'string') { this.message = errorOrMessage; } else { this.message = 'Internal error'; this.nestedError = errorOrMessage; } } util.inherits(BackupsError, Error); BackupsError.EXTERNAL_ERROR = 'external error'; BackupsError.INTERNAL_ERROR = 'internal error'; BackupsError.BAD_STATE = 'bad state'; BackupsError.MISSING_CREDENTIALS = 'missing credentials'; // choose which storage backend we use for test purpose we use s3 function api(provider) { switch (provider) { case 'caas': return caas; case 's3': return s3; default: return null; } } function getPaged(page, perPage, callback) { assert(typeof page === 'number' && page > 0); assert(typeof perPage === 'number' && perPage > 0); assert.strictEqual(typeof callback, 'function'); backupdb.getPaged(page, perPage, function (error, results) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); callback(null, results); }); } function getByAppIdPaged(page, perPage, appId, callback) { assert(typeof page === 'number' && page > 0); assert(typeof perPage === 'number' && perPage > 0); assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof callback, 'function'); backupdb.getByAppIdPaged(page, perPage, appId, function (error, results) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); callback(null, results); }); } function getBoxBackupCredentials(appBackupIds, callback) { assert(util.isArray(appBackupIds)); assert.strictEqual(typeof callback, 'function'); var now = new Date(); var filebase = util.format('backup_%s-v%s', now.toISOString(), config.version()); var filename = filebase + '.tar.gz'; settings.getBackupConfig(function (error, backupConfig) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); api(backupConfig.provider).getBackupCredentials(backupConfig, function (error, result) { if (error) return callback(error); result.id = filename; result.s3Url = 's3://' + backupConfig.bucket + '/' + backupConfig.prefix + '/' + filename; result.backupKey = backupConfig.key; debug('getBoxBackupCredentials: %j', result); callback(null, result); }); }); } function getAppBackupCredentials(app, manifest, callback) { assert.strictEqual(typeof app, 'object'); assert(manifest && typeof manifest === 'object'); assert.strictEqual(typeof callback, 'function'); var now = new Date(); var filebase = util.format('appbackup_%s_%s-v%s', app.id, now.toISOString(), manifest.version); var configFilename = filebase + '.json', dataFilename = filebase + '.tar.gz'; settings.getBackupConfig(function (error, backupConfig) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); api(backupConfig.provider).getBackupCredentials(backupConfig, function (error, result) { if (error) return callback(error); result.id = dataFilename; result.s3ConfigUrl = 's3://' + backupConfig.bucket + '/' + backupConfig.prefix + '/' + configFilename; result.s3DataUrl = 's3://' + backupConfig.bucket + '/' + backupConfig.prefix + '/' + dataFilename; result.backupKey = backupConfig.key; debug('getAppBackupCredentials: %j', result); callback(null, result); }); }); } // backupId is the s3 filename. appbackup_%s_%s-v%s.tar.gz function getRestoreConfig(backupId, callback) { assert.strictEqual(typeof backupId, 'string'); assert.strictEqual(typeof callback, 'function'); var configFile = backupId.replace(/\.tar\.gz$/, '.json'); settings.getBackupConfig(function (error, backupConfig) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); api(backupConfig.provider).getRestoreUrl(backupConfig, configFile, function (error, result) { if (error) return callback(error); superagent.get(result.url).buffer(true).end(function (error, response) { if (error && !error.response) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error.message)); if (response.statusCode !== 200) return callback(new Error('Invalid response code when getting config.json : ' + response.statusCode)); var config = safe.JSON.parse(response.text); if (!config) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Error in config:' + safe.error.message)); return callback(null, config); }); }); }); } // backupId is the s3 filename. appbackup_%s_%s-v%s.tar.gz function getRestoreUrl(backupId, callback) { assert.strictEqual(typeof backupId, 'string'); assert.strictEqual(typeof callback, 'function'); settings.getBackupConfig(function (error, backupConfig) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); api(backupConfig.provider).getRestoreUrl(backupConfig, backupId, function (error, result) { if (error) return callback(error); var obj = { id: backupId, url: result.url, backupKey: backupConfig.key }; debug('getRestoreUrl: id:%s url:%s backupKey:%s', obj.id, obj.url, obj.backupKey); callback(null, obj); }); }); } function copyLastBackup(app, manifest, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof app.lastBackupId, 'string'); assert(manifest && typeof manifest === 'object'); assert.strictEqual(typeof callback, 'function'); var now = new Date(); var toFilenameArchive = util.format('appbackup_%s_%s-v%s.tar.gz', app.id, now.toISOString(), manifest.version); var toFilenameConfig = util.format('appbackup_%s_%s-v%s.json', app.id, now.toISOString(), manifest.version); settings.getBackupConfig(function (error, backupConfig) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); debug('copyLastBackup: copying archive %s to %s', app.lastBackupId, toFilenameArchive); api(backupConfig.provider).copyObject(backupConfig, app.lastBackupId, toFilenameArchive, function (error) { if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error)); // TODO change that logic by adjusting app.lastBackupId to not contain the file type var configFileId = app.lastBackupId.slice(0, -'.tar.gz'.length) + '.json'; debug('copyLastBackup: copying config %s to %s', configFileId, toFilenameConfig); api(backupConfig.provider).copyObject(backupConfig, configFileId, toFilenameConfig, function (error) { if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error)); return callback(null, toFilenameArchive); }); }); }); } function backupBoxWithAppBackupIds(appBackupIds, callback) { assert(util.isArray(appBackupIds)); getBoxBackupCredentials(appBackupIds, function (error, result) { if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error.message)); if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); debug('backupBoxWithAppBackupIds: %j', result); var args = [ result.s3Url, result.accessKeyId, result.secretAccessKey, result.region, result.backupKey ]; if (result.sessionToken) args.push(result.sessionToken); shell.sudo('backupBox', [ BACKUP_BOX_CMD ].concat(args), function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); debug('backupBoxWithAppBackupIds: success'); backupdb.add({ id: result.id, version: config.version(), type: backupdb.BACKUP_TYPE_BOX, dependsOn: appBackupIds }, function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); webhooks.backupDone(result.id, null /* app */, appBackupIds, function (error) { if (error) return callback(error); callback(null, result.id); }); }); }); }); } // this function expects you to have a lock // function backupBox(callback) { // apps.getAll(function (error, allApps) { // if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); // // var appBackupIds = allApps.map(function (app) { return app.lastBackupId; }); // appBackupIds = appBackupIds.filter(function (id) { return id !== null; }); // remove apps that were never backed up // // backupBoxWithAppBackupIds(appBackupIds, callback); // }); // } function canBackupApp(app) { // only backup apps that are installed or pending configure or called from apptask. Rest of them are in some // state not good for consistent backup (i.e addons may not have been setup completely) return (app.installationState === appdb.ISTATE_INSTALLED && app.health === appdb.HEALTH_HEALTHY) || app.installationState === appdb.ISTATE_PENDING_CONFIGURE || app.installationState === appdb.ISTATE_PENDING_BACKUP || // called from apptask app.installationState === appdb.ISTATE_PENDING_UPDATE; // called from apptask } // set the 'creation' date of lastBackup so that the backup persists across time based archival rules // s3 does not allow changing creation time, so copying the last backup is easy way out for now function reuseOldAppBackup(app, manifest, callback) { assert.strictEqual(typeof app.lastBackupId, 'string'); assert(manifest && typeof manifest === 'object'); assert.strictEqual(typeof callback, 'function'); copyLastBackup(app, manifest, function (error, newBackupId) { if (error) return callback(error); debugApp(app, 'reuseOldAppBackup: reused old backup %s as %s', app.lastBackupId, newBackupId); callback(null, newBackupId); }); } function createNewAppBackup(app, manifest, callback) { assert.strictEqual(typeof app, 'object'); assert(manifest && typeof manifest === 'object'); assert.strictEqual(typeof callback, 'function'); getAppBackupCredentials(app, manifest, function (error, result) { if (error) return callback(error); debugApp(app, 'createNewAppBackup: backup url:%s backup config url:%s', result.s3DataUrl, result.s3ConfigUrl); var args = [ app.id, result.s3ConfigUrl, result.s3DataUrl, result.accessKeyId, result.secretAccessKey, result.region, result.backupKey ]; if (result.sessionToken) args.push(result.sessionToken); async.series([ addons.backupAddons.bind(null, app, manifest.addons), shell.sudo.bind(null, 'backupApp', [ BACKUP_APP_CMD ].concat(args)) ], function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); debugApp(app, 'createNewAppBackup: %s done', result.id); backupdb.add({ id: result.id, version: manifest.version, type: backupdb.BACKUP_TYPE_APP, dependsOn: [ ] }, function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); callback(null, result.id); }); }); }); } function setRestorePoint(appId, lastBackupId, callback) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof lastBackupId, 'string'); assert.strictEqual(typeof callback, 'function'); appdb.update(appId, { lastBackupId: lastBackupId }, function (error) { if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new BackupsError(BackupsError.NOT_FOUND, 'No such app')); if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); return callback(null); }); } function backupApp(app, manifest, callback) { assert.strictEqual(typeof app, 'object'); assert(manifest && typeof manifest === 'object'); assert.strictEqual(typeof callback, 'function'); var backupFunction; if (!canBackupApp(app)) { if (!app.lastBackupId) { debugApp(app, 'backupApp: cannot backup app'); return callback(new BackupsError(BackupsError.BAD_STATE, 'App not healthy and never backed up previously')); } backupFunction = reuseOldAppBackup.bind(null, app, manifest); } else { var appConfig = apps.getAppConfig(app); appConfig.manifest = manifest; backupFunction = createNewAppBackup.bind(null, app, manifest); if (!safe.fs.writeFileSync(path.join(paths.DATA_DIR, app.id + '/config.json'), JSON.stringify(appConfig), 'utf8')) { return callback(safe.error); } } backupFunction(function (error, backupId) { if (error) return callback(error); debugApp(app, 'backupApp: successful id:%s', backupId); setRestorePoint(app.id, backupId, function (error) { if (error) return callback(error); return callback(null, backupId); }); }); } // this function expects you to have a lock function backupBoxAndApps(auditSource, callback) { assert.strictEqual(typeof auditSource, 'object'); callback = callback || NOOP_CALLBACK; eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { }); apps.getAll(function (error, allApps) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); var processed = 0; var step = 100/(allApps.length+1); progress.set(progress.BACKUP, processed, ''); async.mapSeries(allApps, function iterator(app, iteratorCallback) { ++processed; backupApp(app, app.manifest, function (error, backupId) { if (error && error.reason !== BackupsError.BAD_STATE) { debugApp(app, 'Unable to backup', error); return iteratorCallback(error); } progress.set(progress.BACKUP, step * processed, 'Backed up app at ' + app.location); iteratorCallback(null, backupId || null); // clear backupId if is in BAD_STATE and never backed up }); }, function appsBackedUp(error, backupIds) { if (error) { progress.set(progress.BACKUP, 100, error.message); return callback(error); } backupIds = backupIds.filter(function (id) { return id !== null; }); // remove apps in bad state that were never backed up backupBoxWithAppBackupIds(backupIds, function (error, filename) { progress.set(progress.BACKUP, 100, error ? error.message : ''); eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { errorMessage: error ? error.message : null, filename: filename }); callback(error, filename); }); }); }); } function backup(auditSource, callback) { assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof callback, 'function'); var error = locker.lock(locker.OP_FULL_BACKUP); if (error) return callback(new BackupsError(BackupsError.BAD_STATE, error.message)); progress.set(progress.BACKUP, 0, 'Starting'); // ensure tools can 'wait' on progress backupBoxAndApps(auditSource, function (error) { // start the backup operation in the background if (error) debug('backup failed.', error); locker.unlock(locker.OP_FULL_BACKUP); }); callback(null); } function ensureBackup(auditSource, callback) { assert.strictEqual(typeof auditSource, 'object'); getPaged(1, 1, function (error, backups) { if (error) { debug('Unable to list backups', error); return callback(error); // no point trying to backup if appstore is down } if (backups.length !== 0 && (new Date() - new Date(backups[0].creationTime) < 23 * 60 * 60 * 1000)) { // ~1 day ago debug('Previous backup was %j, no need to backup now', backups[0]); return callback(null); } backup(auditSource, callback); }); } function restoreApp(app, addonsToRestore, backupId, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof addonsToRestore, 'object'); assert.strictEqual(typeof backupId, 'string'); assert.strictEqual(typeof callback, 'function'); assert(app.lastBackupId); getRestoreUrl(backupId, function (error, result) { if (error) return callback(error); debugApp(app, 'restoreApp: restoreUrl:%s', result.url); shell.sudo('restoreApp', [ RESTORE_APP_CMD, app.id, result.url, result.backupKey, result.sessionToken ], function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); addons.restoreAddons(app, addonsToRestore, callback); }); }); }