diff --git a/src/caas.js b/src/caas.js new file mode 100644 index 000000000..40e355a25 --- /dev/null +++ b/src/caas.js @@ -0,0 +1,164 @@ +'use strict'; + +exports = module.exports = { + migrate: migrate, + upgrade: upgrade +}; + +var assert = require('assert'), + backups = require('./backups.js'), + config = require('./config.js'), + debug = require('debug')('box:caas'), + domains = require('./domains.js'), + DomainError = domains.DomainError, + locker = require('./locker.js'), + path = require('path'), + progress = require('./progress.js'), + settings = require('./settings.js'), + SettingsError = settings.SettingsError, + shell = require('./shell.js'), + superagent = require('superagent'), + util = require('util'), + _ = require('underscore'); + +const RETIRE_CMD = path.join(__dirname, 'scripts/retire.sh'); + +function CaasError(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(CaasError, Error); +CaasError.BAD_FIELD = 'Field error'; +CaasError.INTERNAL_ERROR = 'Internal Error'; +CaasError.EXTERNAL_ERROR = 'External Error'; +CaasError.BAD_STATE = 'Bad state'; + +var NOOP_CALLBACK = function (error) { if (error) debug(error); }; + +function retire(reason, info, callback) { + assert(reason === 'migrate' || reason === 'upgrade'); + info = info || { }; + callback = callback || NOOP_CALLBACK; + + var data = { + apiServerOrigin: config.apiServerOrigin(), + isCustomDomain: config.isCustomDomain(), + fqdn: config.fqdn() + }; + shell.sudo('retire', [ RETIRE_CMD, reason, JSON.stringify(info), JSON.stringify(data) ], callback); +} + +function doMigrate(options, callback) { + assert.strictEqual(typeof options, 'object'); + assert.strictEqual(typeof callback, 'function'); + + var error = locker.lock(locker.OP_MIGRATE); + if (error) return callback(new CaasError(CaasError.BAD_STATE, error.message)); + + function unlock(error) { + debug('Failed to migrate', error); + locker.unlock(locker.OP_MIGRATE); + progress.set(progress.MIGRATE, -1, 'Backup failed: ' + error.message); + } + + progress.set(progress.MIGRATE, 10, 'Backing up for migration'); + + // initiate the migration in the background + backups.backupBoxAndApps({ userId: null, username: 'migrator' }, function (error) { + if (error) return unlock(error); + + debug('migrate: domain: %s size %s region %s', options.domain, options.size, options.region); + + superagent + .post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/migrate') + .query({ token: config.token() }) + .send(options) + .timeout(30 * 1000) + .end(function (error, result) { + if (error && !error.response) return unlock(error); // network error + if (result.statusCode === 409) return unlock(new CaasError(CaasError.BAD_STATE)); + if (result.statusCode === 404) return unlock(new CaasError(CaasError.NOT_FOUND)); + if (result.statusCode !== 202) return unlock(new CaasError(CaasError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body))); + + progress.set(progress.MIGRATE, 10, 'Migrating'); + + retire('migrate', _.pick(options, 'domain', 'size', 'region')); + }); + }); + + callback(null); +} + +function migrate(options, callback) { + assert.strictEqual(typeof options, 'object'); + assert.strictEqual(typeof callback, 'function'); + + if (config.isDemo()) return callback(new CaasError(CaasError.BAD_FIELD, 'Not allowed in demo mode')); + + if (!options.domain) return doMigrate(options, callback); + + var dnsConfig = _.pick(options, 'domain', 'provider', 'accessKeyId', 'secretAccessKey', 'region', 'endpoint', 'token', 'zoneName'); + + domains.get(options.domain, function (error, result) { + if (error && error.reason !== DomainError.NOT_FOUND) return callback(new CaasError(CaasError.INTERNAL_ERROR, error)); + + var func; + if (!result) func = domains.add.bind(null, options.domain, options.zoneName, dnsConfig, null); + else func = domains.update.bind(null, options.domain, dnsConfig, null); + + func(function (error) { + if (error && error.reason === DomainError.BAD_FIELD) return callback(new CaasError(CaasError.BAD_FIELD, error.message)); + if (error) return callback(new SettingsError(CaasError.INTERNAL_ERROR, error)); + + // TODO: should probably rollback dns config if migrate fails + doMigrate(options, callback); + }); + }); +} + +// this function expects a lock +function upgrade(boxUpdateInfo, callback) { + assert(boxUpdateInfo !== null && typeof boxUpdateInfo === 'object'); + + function upgradeError(e) { + progress.set(progress.UPDATE, -1, e.message); + callback(e); + } + + progress.set(progress.UPDATE, 5, 'Backing up for upgrade'); + + backups.backupBoxAndApps({ userId: null, username: 'upgrader' }, function (error) { + if (error) return upgradeError(error); + + superagent.post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/upgrade') + .query({ token: config.token() }) + .send({ version: boxUpdateInfo.version }) + .timeout(30 * 1000) + .end(function (error, result) { + if (error && !error.response) return upgradeError(new Error('Network error making upgrade request: ' + error)); + if (result.statusCode !== 202) return upgradeError(new Error(util.format('Server not ready to upgrade. statusCode: %s body: %j', result.status, result.body))); + + progress.set(progress.UPDATE, 10, 'Updating base system'); + + // no need to unlock since this is the last thing we ever do on this box + callback(); + + retire('upgrade'); + }); + }); +} + diff --git a/src/cloudron.js b/src/cloudron.js index 1980578a3..648bca817 100644 --- a/src/cloudron.js +++ b/src/cloudron.js @@ -17,8 +17,6 @@ exports = module.exports = { updateToLatest: updateToLatest, restore: restore, reboot: reboot, - retire: retire, - migrate: migrate, checkDiskSpace: checkDiskSpace, @@ -33,6 +31,7 @@ var appdb = require('./appdb.js'), async = require('async'), backups = require('./backups.js'), BackupsError = require('./backups.js').BackupsError, + caas = require('./caas.js'), certificates = require('./certificates.js'), child_process = require('child_process'), clients = require('./clients.js'), @@ -41,7 +40,6 @@ var appdb = require('./appdb.js'), cron = require('./cron.js'), debug = require('debug')('box:cloudron'), df = require('@sindresorhus/df'), - domaindb = require('./domaindb.js'), domains = require('./domains.js'), DomainError = domains.DomainError, eventlog = require('./eventlog.js'), @@ -74,7 +72,6 @@ var appdb = require('./appdb.js'), var REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh'), UPDATE_CMD = path.join(__dirname, 'scripts/update.sh'), - RETIRE_CMD = path.join(__dirname, 'scripts/retire.sh'), RESTART_CMD = path.join(__dirname, 'scripts/restart.sh'); var NOOP_CALLBACK = function (error) { if (error) debug(error); }; @@ -698,7 +695,7 @@ function update(boxUpdateInfo, auditSource, callback) { // initiate the update/upgrade but do not wait for it if (boxUpdateInfo.upgrade) { debug('Starting upgrade'); - doUpgrade(boxUpdateInfo, function (error) { + caas.upgrade(boxUpdateInfo, function (error) { if (error) { debug('Upgrade failed with error:', error); locker.unlock(locker.OP_BOX_UPDATE); @@ -717,7 +714,6 @@ function update(boxUpdateInfo, auditSource, callback) { callback(null); } - function updateToLatest(auditSource, callback) { assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof callback, 'function'); @@ -750,36 +746,6 @@ function doShortCircuitUpdate(boxUpdateInfo, callback) { callback(); } -function doUpgrade(boxUpdateInfo, callback) { - assert(boxUpdateInfo !== null && typeof boxUpdateInfo === 'object'); - - function upgradeError(e) { - progress.set(progress.UPDATE, -1, e.message); - callback(e); - } - - progress.set(progress.UPDATE, 5, 'Backing up for upgrade'); - - backups.backupBoxAndApps({ userId: null, username: 'upgrader' }, function (error) { - if (error) return upgradeError(error); - - superagent.post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/upgrade') - .query({ token: config.token() }) - .send({ version: boxUpdateInfo.version }) - .timeout(30 * 1000) - .end(function (error, result) { - if (error && !error.response) return upgradeError(new Error('Network error making upgrade request: ' + error)); - if (result.statusCode !== 202) return upgradeError(new Error(util.format('Server not ready to upgrade. statusCode: %s body: %j', result.status, result.body))); - - progress.set(progress.UPDATE, 10, 'Updating base system'); - - // no need to unlock since this is the last thing we ever do on this box - callback(); - retire('upgrade'); - }); - }); -} - function doUpdate(boxUpdateInfo, callback) { assert(boxUpdateInfo && typeof boxUpdateInfo === 'object'); @@ -872,87 +838,6 @@ function checkDiskSpace(callback) { }); } -function retire(reason, info, callback) { - assert(reason === 'migrate' || reason === 'upgrade'); - info = info || { }; - callback = callback || NOOP_CALLBACK; - - var data = { - apiServerOrigin: config.apiServerOrigin(), - isCustomDomain: config.isCustomDomain(), - fqdn: config.fqdn() - }; - shell.sudo('retire', [ RETIRE_CMD, reason, JSON.stringify(info), JSON.stringify(data) ], callback); -} - -function doMigrate(options, callback) { - assert.strictEqual(typeof options, 'object'); - assert.strictEqual(typeof callback, 'function'); - - var error = locker.lock(locker.OP_MIGRATE); - if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message)); - - function unlock(error) { - debug('Failed to migrate', error); - locker.unlock(locker.OP_MIGRATE); - progress.set(progress.MIGRATE, -1, 'Backup failed: ' + error.message); - } - - progress.set(progress.MIGRATE, 10, 'Backing up for migration'); - - // initiate the migration in the background - backups.backupBoxAndApps({ userId: null, username: 'migrator' }, function (error) { - if (error) return unlock(error); - - debug('migrate: domain: %s size %s region %s', options.domain, options.size, options.region); - - superagent - .post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/migrate') - .query({ token: config.token() }) - .send(options) - .timeout(30 * 1000) - .end(function (error, result) { - if (error && !error.response) return unlock(error); // network error - if (result.statusCode === 409) return unlock(new CloudronError(CloudronError.BAD_STATE)); - if (result.statusCode === 404) return unlock(new CloudronError(CloudronError.NOT_FOUND)); - if (result.statusCode !== 202) return unlock(new CloudronError(CloudronError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body))); - - progress.set(progress.MIGRATE, 10, 'Migrating'); - - retire('migrate', _.pick(options, 'domain', 'size', 'region')); - }); - }); - - callback(null); -} - -function migrate(options, callback) { - assert.strictEqual(typeof options, 'object'); - assert.strictEqual(typeof callback, 'function'); - - if (config.isDemo()) return callback(new CloudronError(CloudronError.BAD_FIELD, 'Not allowed in demo mode')); - - if (!options.domain) return doMigrate(options, callback); - - var dnsConfig = _.pick(options, 'domain', 'provider', 'accessKeyId', 'secretAccessKey', 'region', 'endpoint', 'token', 'zoneName'); - - domains.get(options.domain, function (error, result) { - if (error && error.reason !== DomainError.NOT_FOUND) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); - - var func; - if (!result) func = domains.add.bind(null, options.domain, options.zoneName, dnsConfig, null); - else func = domains.update.bind(null, options.domain, dnsConfig, null); - - func(function (error) { - if (error && error.reason === DomainError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_FIELD, error.message)); - if (error) return callback(new SettingsError(CloudronError.INTERNAL_ERROR, error)); - - // TODO: should probably rollback dns config if migrate fails - doMigrate(options, callback); - }); - }); -} - // called for dynamic dns setups where we have to update the IP function refreshDNS(callback) { callback = callback || NOOP_CALLBACK; diff --git a/src/routes/caas.js b/src/routes/caas.js new file mode 100644 index 000000000..1b91f444c --- /dev/null +++ b/src/routes/caas.js @@ -0,0 +1,42 @@ +'use strict'; + +exports = module.exports = { + migrate: migrate +}; + +var caas = require('../caas.js'), + CaasError = require('../caas.js').CaasError, + config = require('../config.js'), + debug = require('debug')('box:routes/cloudron'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + _ = require('underscore'); + +function migrate(req, res, next) { + if (config.provider() !== 'caas') return next(new HttpError(422, 'Cannot use migrate API with this provider')); + + if ('size' in req.body && typeof req.body.size !== 'string') return next(new HttpError(400, 'size must be string')); + if ('region' in req.body && typeof req.body.region !== 'string') return next(new HttpError(400, 'region must be string')); + + if ('domain' in req.body) { + if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be string')); + if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider must be string')); + } + + if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be string')); + + debug('Migration requested domain:%s size:%s region:%s', req.body.domain, req.body.size, req.body.region); + + var options = _.pick(req.body, 'domain', 'size', 'region'); + if (Object.keys(options).length === 0) return next(new HttpError(400, 'no migrate option provided')); + + if (options.domain) options.domain = options.domain.toLowerCase(); + + caas.migrate(req.body, function (error) { // pass req.body because 'domain' can have arbitrary options + if (error && error.reason === CaasError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error && error.reason === CaasError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, {})); + }); +} diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js index dc6b5a6ed..934a0e48b 100644 --- a/src/routes/cloudron.js +++ b/src/routes/cloudron.js @@ -8,7 +8,6 @@ exports = module.exports = { getStatus: getStatus, restore: restore, reboot: reboot, - migrate: migrate, getProgress: getProgress, getConfig: getConfig, getDisks: getDisks, @@ -180,35 +179,6 @@ function reboot(req, res, next) { cloudron.reboot(function () { }); } -function migrate(req, res, next) { - if (config.provider() !== 'caas') return next(new HttpError(422, 'Cannot use migrate API with this provider')); - - if ('size' in req.body && typeof req.body.size !== 'string') return next(new HttpError(400, 'size must be string')); - if ('region' in req.body && typeof req.body.region !== 'string') return next(new HttpError(400, 'region must be string')); - - if ('domain' in req.body) { - if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be string')); - if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider must be string')); - } - - if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be string')); - - debug('Migration requested domain:%s size:%s region:%s', req.body.domain, req.body.size, req.body.region); - - var options = _.pick(req.body, 'domain', 'size', 'region'); - if (Object.keys(options).length === 0) return next(new HttpError(400, 'no migrate option provided')); - - if (options.domain) options.domain = options.domain.toLowerCase(); - - cloudron.migrate(req.body, function (error) { // pass req.body because 'domain' can have arbitrary options - if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message)); - if (error && error.reason === CloudronError.BAD_FIELD) return next(new HttpError(400, error.message)); - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(202, {})); - }); -} - function getConfig(req, res, next) { cloudron.getConfig(function (error, cloudronConfig) { if (error) return next(new HttpError(500, error)); diff --git a/src/routes/index.js b/src/routes/index.js index 5b86cf128..3da01ba40 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -3,6 +3,7 @@ exports = module.exports = { apps: require('./apps.js'), backups: require('./backups.js'), + caas: require('./caas.js'), clients: require('./clients.js'), cloudron: require('./cloudron.js'), developer: require('./developer.js'), diff --git a/src/server.js b/src/server.js index 50c7e2782..65211769b 100644 --- a/src/server.js +++ b/src/server.js @@ -113,7 +113,6 @@ function initializeExpressSync() { router.post('/api/v1/cloudron/update', cloudronScope, routes.user.requireAdmin, routes.cloudron.update); router.post('/api/v1/cloudron/check_for_updates', cloudronScope, routes.user.requireAdmin, routes.cloudron.checkForUpdates); router.post('/api/v1/cloudron/reboot', cloudronScope, routes.user.requireAdmin, routes.cloudron.reboot); - router.post('/api/v1/cloudron/migrate', cloudronScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.cloudron.migrate); router.get ('/api/v1/cloudron/graphs', cloudronScope, routes.user.requireAdmin, routes.graphs.getGraphs); router.get ('/api/v1/cloudron/disks', cloudronScope, routes.user.requireAdmin, routes.cloudron.getDisks); router.get ('/api/v1/cloudron/logs', cloudronScope, routes.user.requireAdmin, routes.cloudron.getLogs); @@ -238,6 +237,9 @@ function initializeExpressSync() { router.put ('/api/v1/domains/:domain', settingsScope, routes.user.requireAdmin, routes.domains.update); router.del ('/api/v1/domains/:domain', settingsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.domains.del); + // caas routes + router.post('/api/v1/caas/migrate', cloudronScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.caas.migrate); + // disable server socket "idle" timeout. we use the timeout middleware to handle timeouts on a route level // we rely on nginx for timeouts on the TCP level (see client_header_timeout) httpServer.setTimeout(0); diff --git a/webadmin/src/js/client.js b/webadmin/src/js/client.js index 8c8bbd4c4..e26adf1be 100644 --- a/webadmin/src/js/client.js +++ b/webadmin/src/js/client.js @@ -832,7 +832,7 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification', var data = options; data.password = password; - post('/api/v1/cloudron/migrate', data).success(function(data, status) { + post('/api/v1/caas/migrate', data).success(function(data, status) { if (status !== 202 || typeof data !== 'object') return callback(new ClientError(status, data)); callback(null, data); }).error(defaultErrorHandler(callback));