Files
cloudron-box/src/backups.js

567 lines
23 KiB
JavaScript
Raw Normal View History

'use strict';
exports = module.exports = {
BackupsError: BackupsError,
testConfig: testConfig,
getByStatePaged: getByStatePaged,
2016-03-08 08:52:20 -08:00
getByAppIdPaged: getByAppIdPaged,
getRestoreConfig: getRestoreConfig,
2015-09-21 14:14:21 -07:00
ensureBackup: ensureBackup,
backup: backup,
backupApp: backupApp,
restoreApp: restoreApp,
backupBoxAndApps: backupBoxAndApps,
cleanup: cleanup
};
var addons = require('./addons.js'),
appdb = require('./appdb.js'),
apps = require('./apps.js'),
async = require('async'),
assert = require('assert'),
backupdb = require('./backupdb.js'),
2015-11-06 18:22:29 -08:00
caas = require('./storage/caas.js'),
config = require('./config.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:backups'),
2016-05-01 11:42:12 -07:00
eventlog = require('./eventlog.js'),
filesystem = require('./storage/filesystem.js'),
2016-10-14 14:46:34 -07:00
locker = require('./locker.js'),
mailer = require('./mailer.js'),
noop = require('./storage/noop.js'),
path = require('path'),
paths = require('./paths.js'),
progress = require('./progress.js'),
2015-11-06 18:22:29 -08:00
s3 = require('./storage/s3.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
2015-11-07 22:06:09 -08:00
settings = require('./settings.js'),
SettingsError = require('./settings.js').SettingsError,
util = require('util');
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';
2017-04-20 17:23:31 -07:00
BackupsError.BAD_FIELD = 'bad field';
BackupsError.NOT_FOUND = 'not found';
2015-11-06 18:14:59 -08:00
// choose which storage backend we use for test purpose we use s3
2015-11-07 22:06:09 -08:00
function api(provider) {
switch (provider) {
2017-09-17 18:50:23 -07:00
case 'caas': return caas;
case 's3': return s3;
case 'filesystem': return filesystem;
case 'minio': return s3;
case 'exoscale-sos': return s3;
case 'noop': return noop;
default: return null;
2015-11-07 22:06:09 -08:00
}
2015-11-06 18:14:59 -08:00
}
function testConfig(backupConfig, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof callback, 'function');
var func = api(backupConfig.provider);
if (!func) return callback(new SettingsError(SettingsError.BAD_FIELD, 'unkown storage provider'));
api(backupConfig.provider).testConfig(backupConfig, callback);
}
function getByStatePaged(state, page, perPage, callback) {
assert.strictEqual(typeof state, 'string');
2016-03-08 08:52:20 -08:00
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
assert.strictEqual(typeof callback, 'function');
backupdb.getByTypeAndStatePaged(backupdb.BACKUP_TYPE_BOX, state, page, perPage, function (error, results) {
2015-11-07 22:06:09 -08:00
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
2016-03-08 08:52:20 -08:00
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));
2016-03-08 08:52:20 -08:00
callback(null, results);
});
}
function getRestoreConfig(backupId, callback) {
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof callback, 'function');
backupdb.get(backupId, function (error, result) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new BackupsError(BackupsError.NOT_FOUND, error));
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
if (!result.restoreConfig) return callback(new BackupsError(BackupsError.NOT_FOUND, error));
callback(null, result.restoreConfig);
});
}
function copyLastBackup(app, manifest, prefix, backupConfig, callback) {
2016-01-29 11:54:40 +01:00
assert.strictEqual(typeof app, 'object');
2015-09-21 15:57:06 -07:00
assert.strictEqual(typeof app.lastBackupId, 'string');
assert(manifest && typeof manifest === 'object');
2017-01-05 22:55:27 -08:00
assert.strictEqual(typeof prefix, 'string');
assert.strictEqual(typeof backupConfig, 'object');
2015-09-21 14:14:21 -07:00
assert.strictEqual(typeof callback, 'function');
2017-01-05 22:55:27 -08:00
var timestamp = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
var newBackupId = util.format('%s/app_%s_%s_v%s', prefix, app.id, timestamp, manifest.version);
2015-09-21 14:14:21 -07:00
2017-06-01 14:08:51 -07:00
var restoreConfig = apps.getAppConfig(app);
restoreConfig.manifest = manifest;
debug('copyLastBackup: copying backup %s to %s', app.lastBackupId, newBackupId);
2016-04-20 19:40:58 -07:00
backupdb.add({ id: newBackupId, version: manifest.version, type: backupdb.BACKUP_TYPE_APP, dependsOn: [ ], restoreConfig: restoreConfig }, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
2017-06-01 14:08:51 -07:00
api(backupConfig.provider).copyBackup(backupConfig, app.lastBackupId, newBackupId, function (copyBackupError) {
const state = copyBackupError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL;
2017-06-01 14:08:51 -07:00
debugApp(app, 'copyLastBackup: %s done with state %s', newBackupId, state);
2017-06-01 14:08:51 -07:00
backupdb.update(newBackupId, { state: state }, function (error) {
if (copyBackupError) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, copyBackupError.message));
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
2015-11-07 22:06:09 -08:00
callback(null, newBackupId);
2017-06-01 14:08:51 -07:00
});
2015-11-07 22:06:09 -08:00
});
2015-09-21 14:14:21 -07:00
});
}
2017-01-05 22:55:27 -08:00
function backupBoxWithAppBackupIds(appBackupIds, prefix, callback) {
2017-04-21 10:31:43 +02:00
assert(Array.isArray(appBackupIds));
2017-01-05 22:55:27 -08:00
assert.strictEqual(typeof prefix, 'string');
2017-01-05 22:55:27 -08:00
var timestamp = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
var backupId = util.format('%s/box_%s_v%s', prefix, timestamp, config.version());
2016-09-16 10:58:34 +02:00
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
var startTime = new Date();
2017-04-23 17:39:43 -07:00
var password = config.database().password ? '-p' + config.database().password : '--skip-password';
var mysqlDumpArgs = [
'-c',
`/usr/bin/mysqldump -u root ${password} --single-transaction --routines \
--triggers ${config.database().name} > "${paths.BOX_DATA_DIR}/box.mysqldump"`
];
2017-09-09 19:48:05 -07:00
shell.exec('backupBox', '/bin/bash', mysqlDumpArgs, { }, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
backupdb.add({ id: backupId, version: config.version(), type: backupdb.BACKUP_TYPE_BOX, dependsOn: appBackupIds, restoreConfig: null }, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
api(backupConfig.provider).upload(backupConfig, backupId, paths.BOX_DATA_DIR, function (backupTaskError) {
const state = backupTaskError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL;
debug('backupBoxWithAppBackupIds: %s time: %s secs', state, (new Date() - startTime)/1000);
backupdb.update(backupId, { state: state }, function (error) {
if (backupTaskError) return callback(backupTaskError);
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
// FIXME this is only needed for caas, hopefully we can remove that in the future
api(backupConfig.provider).backupDone(backupId, appBackupIds, function (error) {
if (error) return callback(error);
callback(null, backupId);
});
});
});
});
});
});
}
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
}
function createNewAppBackup(app, manifest, prefix, backupConfig, callback) {
assert.strictEqual(typeof app, 'object');
assert(manifest && typeof manifest === 'object');
2017-01-05 22:55:27 -08:00
assert.strictEqual(typeof prefix, 'string');
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof callback, 'function');
2017-01-05 22:55:27 -08:00
var timestamp = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
var backupId = util.format('%s/app_%s_%s_v%s', prefix, app.id, timestamp, manifest.version);
var restoreConfig = apps.getAppConfig(app);
restoreConfig.manifest = manifest;
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(restoreConfig))) {
return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Error creating config.json: ' + safe.error.message));
}
2016-04-10 21:41:53 -07:00
addons.backupAddons(app, manifest.addons, function (error) {
if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error.message));
2016-09-16 11:21:08 +02:00
backupdb.add({ id: backupId, version: manifest.version, type: backupdb.BACKUP_TYPE_APP, dependsOn: [ ], restoreConfig: restoreConfig }, function (error) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
var appDataDir = safe.fs.realpathSync(path.join(paths.APPS_DATA_DIR, app.id));
api(backupConfig.provider).upload(backupConfig, backupId, appDataDir, function (backupTaskError) {
const state = backupTaskError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL;
2016-09-16 11:21:08 +02:00
debugApp(app, 'createNewAppBackup: %s done with state %s', backupId, state);
backupdb.update(backupId, { state: state }, function (error) {
if (backupTaskError) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, backupTaskError.message));
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
callback(null, backupId);
});
});
});
});
}
2016-06-13 19:19:28 -07:00
function setRestorePoint(appId, lastBackupId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof lastBackupId, 'string');
assert.strictEqual(typeof callback, 'function');
2016-06-13 19:19:28 -07:00
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);
});
}
2017-01-05 22:55:27 -08:00
function backupApp(app, manifest, prefix, callback) {
assert.strictEqual(typeof app, 'object');
assert(manifest && typeof manifest === 'object');
2017-01-05 22:55:27 -08:00
assert.strictEqual(typeof prefix, 'string');
assert.strictEqual(typeof callback, 'function');
var backupFunction, startTime = new Date();
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
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'));
}
// 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
backupFunction = copyLastBackup.bind(null, app, manifest, prefix, backupConfig);
} else {
backupFunction = createNewAppBackup.bind(null, app, manifest, prefix, backupConfig);
}
backupFunction(function (error, backupId) {
if (error) return callback(error);
debugApp(app, 'backupApp: successful id:%s time:%s secs', backupId, (new Date() - startTime)/1000);
setRestorePoint(app.id, backupId, function (error) {
if (error) return callback(error);
return callback(null, backupId);
});
});
});
}
2016-04-10 22:24:01 -07:00
// this function expects you to have a lock
function backupBoxAndApps(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
2016-04-10 22:24:01 -07:00
callback = callback || NOOP_CALLBACK;
2017-01-05 22:55:27 -08:00
var prefix = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { });
2016-04-10 22:24:01 -07:00
apps.getAll(function (error, allApps) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
var processed = 0;
var step = 100/(allApps.length+1);
2017-01-12 15:12:41 +01:00
progress.set(progress.BACKUP, step * processed, '');
2016-04-10 22:24:01 -07:00
async.mapSeries(allApps, function iterator(app, iteratorCallback) {
2017-01-12 15:39:52 +01:00
progress.set(progress.BACKUP, step * processed, 'Backing up ' + (app.altDomain || config.appFqdn(app.location)));
2016-04-10 22:24:01 -07:00
++processed;
if (!app.enableBackup) {
progress.set(progress.BACKUP, step * processed, 'Skipped backup ' + (app.altDomain || config.appFqdn(app.location)));
return iteratorCallback(null, app.lastBackupId); // just use the last backup
}
2017-01-05 22:55:27 -08:00
backupApp(app, app.manifest, prefix, function (error, backupId) {
2016-04-10 22:24:01 -07:00
if (error && error.reason !== BackupsError.BAD_STATE) {
debugApp(app, 'Unable to backup', error);
return iteratorCallback(error);
}
2017-01-12 15:39:52 +01:00
progress.set(progress.BACKUP, step * processed, 'Backed up ' + (app.altDomain || config.appFqdn(app.location)));
2016-04-10 22:24:01 -07:00
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
2017-01-12 15:12:41 +01:00
progress.set(progress.BACKUP, step * processed, 'Backing up system data');
2017-01-05 22:55:27 -08:00
backupBoxWithAppBackupIds(backupIds, prefix, function (error, filename) {
2016-04-10 22:24:01 -07:00
progress.set(progress.BACKUP, 100, error ? error.message : '');
eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { errorMessage: error ? error.message : null, filename: filename });
2016-04-10 22:24:01 -07:00
callback(error, filename);
});
});
});
}
function backup(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
2016-04-10 22:24:01 -07:00
assert.strictEqual(typeof callback, 'function');
var error = locker.lock(locker.OP_FULL_BACKUP);
if (error) return callback(new BackupsError(BackupsError.BAD_STATE, error.message));
var startTime = new Date();
2016-05-01 11:42:12 -07:00
progress.set(progress.BACKUP, 0, 'Starting'); // ensure tools can 'wait' on progress
backupBoxAndApps(auditSource, function (error) { // start the backup operation in the background
2016-10-14 14:46:34 -07:00
if (error) {
debug('backup failed.', error);
2017-01-26 13:03:36 -08:00
mailer.backupFailed(error);
2016-10-14 14:46:34 -07:00
}
2016-04-10 22:24:01 -07:00
locker.unlock(locker.OP_FULL_BACKUP);
debug('backup took %s seconds', (new Date() - startTime)/1000);
2016-04-10 22:24:01 -07:00
});
callback(null);
}
2016-06-02 18:51:50 -07:00
function ensureBackup(auditSource, callback) {
assert.strictEqual(typeof auditSource, 'object');
2016-04-10 22:24:01 -07:00
2017-01-26 12:46:41 -08:00
debug('ensureBackup: %j', auditSource);
getByStatePaged(backupdb.BACKUP_STATE_NORMAL, 1, 1, function (error, backups) {
2016-04-10 22:24:01 -07:00
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);
}
2016-06-02 18:51:50 -07:00
backup(auditSource, callback);
2016-04-10 22:24:01 -07:00
});
}
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);
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
var appDataDir = safe.fs.realpathSync(path.join(paths.APPS_DATA_DIR, app.id));
var startTime = new Date();
async.series([
api(backupConfig.provider).download.bind(null, backupConfig, backupId, appDataDir),
addons.restoreAddons.bind(null, app, addonsToRestore)
], function (error) {
debug('restoreApp: time: %s', (new Date() - startTime)/1000);
callback(error);
});
});
}
2017-05-30 13:18:58 -07:00
function cleanupAppBackups(backupConfig, referencedAppBackups, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert(Array.isArray(referencedAppBackups));
assert.strictEqual(typeof callback, 'function');
2017-05-30 13:18:58 -07:00
const now = new Date();
// we clean app backups of any state because the ones to keep are determined by the box cleanup code
backupdb.getByTypePaged(backupdb.BACKUP_TYPE_APP, 1, 1000, function (error, appBackups) {
2017-05-30 13:18:58 -07:00
if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error));
async.eachSeries(appBackups, function iterator(backup, iteratorDone) {
if (referencedAppBackups.indexOf(backup.id) !== -1) return iteratorDone();
if ((now - backup.creationTime) < (backupConfig.retentionSecs * 1000)) return iteratorDone();
debug('cleanup: removing %s', backup.id);
api(backupConfig.provider).removeMany(backupConfig, [ backup.id ], function (error) {
if (error) {
debug('cleanup: error removing backup %j : %s', backup, error.message);
iteratorDone();
}
backupdb.del(backup.id, function (error) {
if (error) debug('cleanup: error removing from database', error);
else debug('cleanup: removed %s', backup.id);
iteratorDone();
});
2017-05-30 13:18:58 -07:00
});
}, function () {
debug('cleanup: done cleaning app backups');
callback();
2017-05-30 13:18:58 -07:00
});
});
}
2017-05-30 13:18:58 -07:00
function cleanupBoxBackups(backupConfig, callback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof callback, 'function');
2017-05-30 13:18:58 -07:00
const now = new Date();
var referencedAppBackups = [];
backupdb.getByTypePaged(backupdb.BACKUP_TYPE_BOX, 1, 1000, function (error, boxBackups) {
2017-05-30 13:18:58 -07:00
if (error) return callback(error);
2017-05-30 13:18:58 -07:00
if (boxBackups.length === 0) return callback(null, []);
// search for the first valid backup
var i;
for (i = 0; i < boxBackups.length; i++) {
if (boxBackups[i].state === backupdb.BACKUP_STATE_NORMAL) break;
}
// keep the first valid backup
if (i !== boxBackups.length) {
2017-06-01 09:38:39 -07:00
debug('cleanup: preserving box backup %j', boxBackups[i]);
referencedAppBackups = boxBackups[i].dependsOn;
boxBackups.splice(i, 1);
2017-06-01 09:38:39 -07:00
} else {
debug('cleanup: no box backup to preserve');
}
2017-05-30 13:18:58 -07:00
async.eachSeries(boxBackups, function iterator(backup, iteratorDone) {
referencedAppBackups = referencedAppBackups.concat(backup.dependsOn);
// TODO: errored backups should probably be cleaned up before retention time, but we will
// have to be careful not to remove any backup currently being created
2017-05-30 13:18:58 -07:00
if ((now - backup.creationTime) < (backupConfig.retentionSecs * 1000)) return iteratorDone();
2017-05-30 13:18:58 -07:00
debug('cleanup: removing %s', backup.id);
var backupIds = [].concat(backup.id, backup.dependsOn);
api(backupConfig.provider).removeMany(backupConfig, backupIds, function (error) {
2017-05-30 13:18:58 -07:00
if (error) {
debug('cleanup: error removing backup %j : %s', backup, error.message);
iteratorDone();
}
backupdb.del(backup.id, function (error) {
if (error) debug('cleanup: error removing from database', error);
else debug('cleanup: removed %j', backupIds);
iteratorDone();
});
});
2017-05-30 13:18:58 -07:00
}, function () {
return callback(null, referencedAppBackups);
});
});
}
function cleanup(callback) {
assert(!callback || typeof callback === 'function'); // callback is null when called from cronjob
callback = callback || NOOP_CALLBACK;
settings.getBackupConfig(function (error, backupConfig) {
if (error) return callback(error);
if (backupConfig.retentionSecs < 0) {
debug('cleanup: keeping all backups');
return callback();
}
cleanupBoxBackups(backupConfig, function (error, referencedAppBackups) {
if (error) return callback(error);
debug('cleanup: done cleaning box backups');
cleanupAppBackups(backupConfig, referencedAppBackups, callback);
});
});
}