diff --git a/migrations/20181116191032-tasks-add-table.js b/migrations/20181116191032-tasks-add-table.js new file mode 100644 index 000000000..d1585b2ac --- /dev/null +++ b/migrations/20181116191032-tasks-add-table.js @@ -0,0 +1,25 @@ +'use strict'; + +exports.up = function(db, callback) { + var cmd = "CREATE TABLE tasks(" + + "id VARCHAR(32) NOT NULL UNIQUE," + + "percent INTEGER DEFAULT 0," + + "message TEXT," + + "detail TEXT," + + "result TEXT," + + "creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," + + "ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," + + "PRIMARY KEY (id))"; + + db.runSql(cmd, function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('DROP TABLE tasks', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 832255961..6f1c62e8c 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -195,6 +195,17 @@ CREATE TABLE IF NOT EXISTS subdomains( FOREIGN KEY(domain) REFERENCES domains(domain), FOREIGN KEY(appId) REFERENCES apps(id), - UNIQUE (subdomain, domain)) + UNIQUE (subdomain, domain)); + +CREATE TABLE IF NOT EXISTS tasks( + id VARCHAR(32) NOT NULL UNIQUE, + percent INTEGER DEFAULT 0, + message TEXT, + detail TEXT, + result TEXT, + creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id)); CHARACTER SET utf8 COLLATE utf8_bin; + diff --git a/src/backups.js b/src/backups.js index 6afc3acaa..d6c03e1c5 100644 --- a/src/backups.js +++ b/src/backups.js @@ -56,7 +56,6 @@ var addons = require('./addons.js'), once = require('once'), path = require('path'), paths = require('./paths.js'), - progress = require('./progress.js'), progressStream = require('progress-stream'), safe = require('safetydance'), shell = require('./shell.js'), @@ -64,6 +63,7 @@ var addons = require('./addons.js'), superagent = require('superagent'), syncer = require('./syncer.js'), tar = require('tar-fs'), + tasks = require('./tasks.js'), util = require('util'), zlib = require('zlib'); @@ -186,11 +186,6 @@ function getBackupFilePath(backupConfig, backupId, format) { } } -function log(detail) { - debug(detail); - progress.setDetail(progress.BACKUP, detail); -} - function encryptFilePath(filePath, key) { assert.strictEqual(typeof filePath, 'string'); assert.strictEqual(typeof key, 'string'); @@ -470,7 +465,7 @@ function restoreFsMetadata(appDataDir, callback) { assert.strictEqual(typeof appDataDir, 'string'); assert.strictEqual(typeof callback, 'function'); - log('Recreating empty directories'); + tasks.setProgress(tasks.TASK_BACKUP, { detail: 'Recreating empty directories' }, NOOP_CALLBACK); var metadataJson = safe.fs.readFileSync(path.join(appDataDir, 'fsmetadata.json'), 'utf8'); if (metadataJson === null) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Error loading fsmetadata.txt:' + safe.error.message)); @@ -538,7 +533,7 @@ function download(backupConfig, backupId, format, dataDir, callback) { assert.strictEqual(typeof dataDir, 'string'); assert.strictEqual(typeof callback, 'function'); - log(`Downloading ${backupId} of format ${format} to ${dataDir}`); + tasks.setProgress(tasks.TASK_BACKUP, { detail: `Downloading ${backupId} of format ${format} to ${dataDir}` }, NOOP_CALLBACK); if (format === 'tgz') { api(backupConfig.provider).download(backupConfig, getBackupFilePath(backupConfig, backupId, format), function (error, sourceStream) { @@ -642,7 +637,7 @@ function setSnapshotInfo(id, info, callback) { function snapshotBox(callback) { assert.strictEqual(typeof callback, 'function'); - log('Snapshotting box'); + tasks.setProgress(tasks.TASK_BACKUP, { detail: 'Snapshotting box' }, NOOP_CALLBACK); database.exportToFile(`${paths.BOX_DATA_DIR}/box.mysqldump`, function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); @@ -711,13 +706,13 @@ function rotateBoxBackup(backupConfig, timestamp, appBackupIds, callback) { var backupId = util.format('%s/box_%s_v%s', timestamp, snapshotTime, config.version()); const format = backupConfig.format; - log(`Rotating box backup to id ${backupId}`); + tasks.setProgress(tasks.TASK_BACKUP, { detail: `Rotating box backup to id ${backupId}` }, NOOP_CALLBACK); backupdb.add({ id: backupId, version: config.version(), type: backupdb.BACKUP_TYPE_BOX, dependsOn: appBackupIds, manifest: null, format: format }, function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); var copy = api(backupConfig.provider).copy(backupConfig, getBackupFilePath(backupConfig, 'snapshot/box', format), getBackupFilePath(backupConfig, backupId, format)); - copy.on('progress', log); + copy.on('progress', function (detail) { tasks.setProgress(tasks.TASK_BACKUP, { detail }, NOOP_CALLBACK); }); copy.on('done', function (copyBackupError) { const state = copyBackupError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL; @@ -725,7 +720,7 @@ function rotateBoxBackup(backupConfig, timestamp, appBackupIds, callback) { if (copyBackupError) return callback(copyBackupError); if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); - log(`Rotated box backup successfully as id ${backupId}`); + tasks.setProgress(tasks.TASK_BACKUP, { detail: `Rotated box backup successfully as id ${backupId}` }, NOOP_CALLBACK); backupDone(backupConfig, backupId, appBackupIds, function (error) { if (error) return callback(error); @@ -766,7 +761,7 @@ function snapshotApp(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); - log(`Snapshotting app ${app.id}`); + tasks.setProgress(tasks.TASK_BACKUP, { detail: `Snapshotting app ${app.id}` }, NOOP_CALLBACK); if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(apps.getAppConfig(app)))) { return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Error creating config.json: ' + safe.error.message)); @@ -793,13 +788,13 @@ function rotateAppBackup(backupConfig, app, timestamp, callback) { var backupId = util.format('%s/app_%s_%s_v%s', timestamp, app.id, snapshotTime, manifest.version); const format = backupConfig.format; - log(`Rotating app backup of ${app.id} to id ${backupId}`); + tasks.setProgress(tasks.TASK_BACKUP, { detail: `Rotating app backup of ${app.id} to id ${backupId}` }, NOOP_CALLBACK); backupdb.add({ id: backupId, version: manifest.version, type: backupdb.BACKUP_TYPE_APP, dependsOn: [ ], manifest: manifest, format: format }, function (error) { if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); var copy = api(backupConfig.provider).copy(backupConfig, getBackupFilePath(backupConfig, `snapshot/app_${app.id}`, format), getBackupFilePath(backupConfig, backupId, format)); - copy.on('progress', log); + copy.on('progress', function (detail) { tasks.setProgress(tasks.TASK_BACKUP, { detail }, NOOP_CALLBACK); }); copy.on('done', function (copyBackupError) { const state = copyBackupError ? backupdb.BACKUP_STATE_ERROR : backupdb.BACKUP_STATE_NORMAL; @@ -807,7 +802,7 @@ function rotateAppBackup(backupConfig, app, timestamp, callback) { if (copyBackupError) return callback(copyBackupError); if (error) return callback(new BackupsError(BackupsError.INTERNAL_ERROR, error)); - log(`Rotated app backup of ${app.id} successfully to id ${backupId}`); + tasks.setProgress(tasks.TASK_BACKUP, { detail: `Rotated app backup of ${app.id} successfully to id ${backupId}` }, NOOP_CALLBACK); callback(null, backupId); }); @@ -863,10 +858,10 @@ function backupApp(app, callback) { const timestamp = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,''); - progress.set(progress.BACKUP, 10, 'Backing up ' + app.fqdn); + tasks.setProgress(tasks.TASK_BACKUP, { precent: 10, mesage: `Backing up ${app.fqdn}` }, NOOP_CALLBACK); backupAppWithTimestamp(app, timestamp, function (error) { - progress.set(progress.BACKUP, 100, error ? error.message : ''); + tasks.setProgress(tasks.TASK_BACKUP, { percent: 100, result: error ? error.message : '' }, NOOP_CALLBACK); callback(error); }); @@ -889,12 +884,12 @@ function backupBoxAndApps(auditSource, callback) { var step = 100/(allApps.length+2); async.mapSeries(allApps, function iterator(app, iteratorCallback) { - progress.set(progress.BACKUP, step * processed, 'Backing up ' + app.fqdn); + tasks.setProgress(tasks.TASK_BACKUP, { percent: step * processed, message: `Backing up ${app.fqdn}` }, NOOP_CALLBACK); ++processed; if (!app.enableBackup) { - progress.set(progress.BACKUP, step * processed, 'Skipped backup ' + app.fqdn); + tasks.setProgress(tasks.TASK_BACKUP, { percent: step * processed, message: `Skipped backup ${app.fqdn}` }, NOOP_CALLBACK); return iteratorCallback(null, null); // nothing to backup } @@ -904,22 +899,22 @@ function backupBoxAndApps(auditSource, callback) { return iteratorCallback(error); } - progress.set(progress.BACKUP, step * processed, 'Backed up ' + app.fqdn); + tasks.setProgress(tasks.TASK_BACKUP, { percent: step * processed, message: `Backed up ${app.fqdn}` }, NOOP_CALLBACK); 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); + tasks.setProgress(tasks.TASK_BACKUP, { percent: 100, result: error.message }, NOOP_CALLBACK); return callback(error); } backupIds = backupIds.filter(function (id) { return id !== null; }); // remove apps in bad state that were never backed up - progress.set(progress.BACKUP, step * processed, 'Backing up system data'); + tasks.setProgress(tasks.TASK_BACKUP, { percent: step * processed, message: 'Backing up system data' }, NOOP_CALLBACK); backupBoxWithAppBackupIds(backupIds, timestamp, function (error, backupId) { - progress.set(progress.BACKUP, 100, error ? error.message : ''); + tasks.setProgress(tasks.TASK_BACKUP, { percent: 100, result: error ? error.message : '' }, NOOP_CALLBACK); eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { errorMessage: error ? error.message : null, backupId: backupId, timestamp: timestamp }); @@ -937,7 +932,7 @@ function startBackupTask(auditSource, callback) { if (error) return callback(new BackupsError(BackupsError.BAD_STATE, error.message)); let startTime = new Date(); - progress.set(progress.BACKUP, 0, 'Starting'); // ensure tools can 'wait' on progress + tasks.setProgress(tasks.TASK_BACKUP, { percent: 0, message: 'Starting' }, NOOP_CALLBACK); // ensure tools can 'wait' on progress let fd = safe.fs.openSync(paths.BACKUP_LOG_FILE, 'a'); // will autoclose if (!fd) { @@ -964,7 +959,7 @@ function startBackupTask(auditSource, callback) { locker.unlock(locker.OP_FULL_BACKUP); - progress.set(progress.BACKUP, 100, error ? error.message : ''); + tasks.setProgress(tasks.TASK_BACKUP, { percent: 100, result: error ? error.message : '' }, NOOP_CALLBACK); if (error) mailer.backupFailed(error); debug('startBackupTask: backup took %s seconds', (new Date() - startTime)/1000); diff --git a/src/routes/backups.js b/src/routes/backups.js index 052530228..c39813619 100644 --- a/src/routes/backups.js +++ b/src/routes/backups.js @@ -2,8 +2,7 @@ exports = module.exports = { list: list, - startBackup: startBackup, - stopBackup: stopBackup + startBackup: startBackup }; var backupdb = require('../backupdb.js'), @@ -42,12 +41,3 @@ function startBackup(req, res, next) { next(new HttpSuccess(202, {})); }); } - -function stopBackup(req, res, next) { - backups.stopBackupTask(auditSource(req), function (error) { - if (error && error.reason === BackupsError.BAD_STATE) return next(new HttpError(409, error.message)); - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(202, {})); - }); -} diff --git a/src/routes/index.js b/src/routes/index.js index ea54dc1d4..a6df86931 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -19,5 +19,6 @@ exports = module.exports = { sysadmin: require('./sysadmin.js'), settings: require('./settings.js'), ssh: require('./ssh.js'), + tasks: require('./tasks.js'), users: require('./users.js') }; diff --git a/src/routes/tasks.js b/src/routes/tasks.js new file mode 100644 index 000000000..e0d28c067 --- /dev/null +++ b/src/routes/tasks.js @@ -0,0 +1,39 @@ +'use strict'; + +exports = module.exports = { + getProgress: getProgress, + stopTask: stopTask +}; + +let assert = require('assert'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + TaskError = require('../tasks.js').TaskError, + tasks = require('../tasks.js'); + +function auditSource(req) { + var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null; + return { ip: ip, username: req.user ? req.user.username : null, userId: req.user ? req.user.id : null }; +} + +function stopTask(req, res, next) { + assert.strictEqual(typeof req.params.taskId, 'string'); + + tasks.stopTask(req.params.taskId, auditSource(req), function (error) { + if (error && error.reason === TaskError.NOT_FOUND) return next(new HttpError(404, 'No such task')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(204, {})); + }); +} + +function getProgress(req, res, next) { + assert.strictEqual(typeof req.params.taskId, 'string'); + + tasks.getProgress(req.params.taskId, function (error, progress) { + if (error && error.reason === TaskError.NOT_FOUND) return next(new HttpError(404, 'No such task')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, progress)); + }); +} diff --git a/src/routes/test/backups-test.js b/src/routes/test/backups-test.js index d744bef28..b7083c272 100644 --- a/src/routes/test/backups-test.js +++ b/src/routes/test/backups-test.js @@ -84,7 +84,7 @@ describe('Backups API', function () { describe('create', function () { it('fails due to mising token', function (done) { - superagent.post(SERVER_URL + '/api/v1/cloudron/start_backup') + superagent.post(SERVER_URL + '/api/v1/backups') .end(function (error, result) { expect(result.statusCode).to.equal(401); done(); @@ -92,7 +92,7 @@ describe('Backups API', function () { }); it('fails due to wrong token', function (done) { - superagent.post(SERVER_URL + '/api/v1/cloudron/start_backup') + superagent.post(SERVER_URL + '/api/v1/backups') .query({ access_token: token.toUpperCase() }) .end(function (error, result) { expect(result.statusCode).to.equal(401); @@ -101,7 +101,7 @@ describe('Backups API', function () { }); it('succeeds', function (done) { - superagent.post(SERVER_URL + '/api/v1/cloudron/start_backup') + superagent.post(SERVER_URL + '/api/v1/backups') .query({ access_token: token }) .end(function (error, result) { expect(result.statusCode).to.equal(202); diff --git a/src/server.js b/src/server.js index 3234fdc89..3f6dd161f 100644 --- a/src/server.js +++ b/src/server.js @@ -134,11 +134,13 @@ function initializeExpressSync() { router.del ('/api/v1/cloudron/ssh/authorized_keys/:identifier', cloudronScope, isUnmanaged, routes.ssh.delAuthorizedKey); router.get ('/api/v1/cloudron/eventlog', cloudronScope, routes.eventlog.get); - router.post('/api/v1/cloudron/start_backup', settingsScope, routes.backups.startBackup); - router.post('/api/v1/cloudron/stop_backup', settingsScope, routes.backups.stopBackup); + // tasks + router.get ('/api/v1/tasks/:taskId', settingsScope, routes.tasks.getProgress); + router.post('/api/v1/tasks/:taskId/stop', settingsScope, routes.tasks.stopTask); // backups router.get ('/api/v1/backups', settingsScope, routes.backups.list); + router.post('/api/v1/backups', settingsScope, routes.backups.startBackup); // config route (for dashboard) router.get ('/api/v1/config', profileScope, routes.cloudron.getConfig); diff --git a/src/taskdb.js b/src/taskdb.js new file mode 100644 index 000000000..35d04b59f --- /dev/null +++ b/src/taskdb.js @@ -0,0 +1,51 @@ +'use strict'; + +exports = module.exports = { + setProgress: setProgress, + getProgress: getProgress +}; + +let assert = require('assert'), + database = require('./database.js'), + DatabaseError = require('./databaseerror'), + _ = require('underscore'); + +const TASKS_FIELDS = [ 'id', 'percent', 'message', 'detail', 'creationTime', 'result', 'ts' ]; + +function setProgress(id, progress, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof progress, 'object'); + assert.strictEqual(typeof callback, 'function'); + + let data = _.extend({ id: id }, progress); + + let keys = [ ], + questionMarks = Array(Object.keys(data).length).fill('?').join(','), + fields = [ ], values = [ ]; + + for (var f in data) { + keys.push(f); + fields.push(`${f} = ?`); + values.push(data[f]); // for the INSERT fields + } + + values = values.concat(values); // for the UPDATE fields + + database.query(`INSERT INTO tasks (${keys.join(', ')}) VALUES (${questionMarks}) ON DUPLICATE KEY UPDATE ${fields}`, values, function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function getProgress(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + TASKS_FIELDS + ' FROM tasks WHERE id = ?', [ id ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} diff --git a/src/tasks.js b/src/tasks.js new file mode 100644 index 000000000..18e999b93 --- /dev/null +++ b/src/tasks.js @@ -0,0 +1,89 @@ +'use strict'; + +exports = module.exports = { + setProgress: setProgress, + getProgress: getProgress, + + stopTask: stopTask, + + TaskError: TaskError, + + TASK_BACKUP: 'backup' +}; + +let assert = require('assert'), + BackupsError = require('./backups.js').BackupsError, + backups = require('./backups.js'), + DatabaseError = require('./databaseerror.js'), + debug = require('debug')('box:tasks'), + taskdb = require('./taskdb.js'), + util = require('util'); + +function TaskError(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(TaskError, Error); +TaskError.INTERNAL_ERROR = 'Internal Error'; +TaskError.BAD_STATE = 'Bad State'; +TaskError.NOT_FOUND = 'Not Found'; + +function setProgress(id, progress, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof progress, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debug(`${id}: ${JSON.stringify(progress)}`); + + taskdb.setProgress(id, progress, function (error) { + if (error) return callback(new TaskError(TaskError.INTERNAL_ERROR, error)); + + callback(); + }); +} + +function getProgress(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + taskdb.getProgress(id, function (error, progress) { + if (error && error.reason == DatabaseError.NOT_FOUND) return callback(new TaskError(TaskError.NOT_FOUND)); + if (error) return callback(new TaskError(TaskError.INTERNAL_ERROR, error)); + + callback(null, progress); + }); +} + +function stopTask(id, auditSource, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + switch (id) { + case exports.TASK_BACKUP: + backups.stopBackupTask(auditSource, function (error) { + if (error && error.reason === BackupsError.BAD_STATE) return callback(new TaskError(TaskError.NOT_FOUND)); + if (error) return callback(new TaskError(TaskError.INTERNAL_ERROR, error)); + + callback(null); + }); + break; + + default: + return callback(new TaskError(TaskError.NOT_FOUND)); + } +} diff --git a/src/test/backups-test.js b/src/test/backups-test.js index f62ea91a7..acbf8b744 100644 --- a/src/test/backups-test.js +++ b/src/test/backups-test.js @@ -21,7 +21,8 @@ var async = require('async'), progress = require('../progress.js'), rimraf = require('rimraf'), settings = require('../settings.js'), - SettingsError = require('../settings.js').SettingsError; + SettingsError = require('../settings.js').SettingsError, + tasks = require('../tasks.js'); function compareDirectories(one, two, callback) { readdirp({ root: one }, function (error, treeOne) { @@ -73,16 +74,19 @@ function createBackup(callback) { if (error) return callback(error); function waitForBackup() { - var p = progress.getAll(); - if (p.backup.percent !== 100) return setTimeout(waitForBackup, 1000); - - if (p.backup.message) return callback(new Error('backup failed:' + p.backup.message)); - - backups.getByStatePaged(backupdb.BACKUP_STATE_NORMAL, 1, 1, function (error, result) { + tasks.getProgress(tasks.TASK_BACKUP, function (error, p) { if (error) return callback(error); - if (result.length !== 1) return callback(new Error('result is not of length 1')); - callback(null, result[0]); + if (p.percent !== 100) return setTimeout(waitForBackup, 1000); + + if (p.result) return callback(new Error('backup failed:' + p.result)); + + backups.getByStatePaged(backupdb.BACKUP_STATE_NORMAL, 1, 1, function (error, result) { + if (error) return callback(error); + if (result.length !== 1) return callback(new Error('result is not of length 1')); + + callback(null, result[0]); + }); }); }