diff --git a/src/cloudron.js b/src/cloudron.js index bd0390bf7..79630fdee 100644 --- a/src/cloudron.js +++ b/src/cloudron.js @@ -14,6 +14,7 @@ exports = module.exports = { updateToLatest: updateToLatest, reboot: reboot, retire: retire, + migrate: migrate, isConfiguredSync: isConfiguredSync, @@ -709,3 +710,45 @@ function retire(callback) { shell.sudo('retire', [ RETIRE_CMD, JSON.stringify(data) ], callback); } +function migrate(size, region, callback) { + assert.strictEqual(typeof size, 'string'); + assert.strictEqual(typeof region, 'string'); + 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) { + if (error) { + debug('Failed to migrate', error); + locker.unlock(locker.OP_MIGRATE); + } else { + debug('Migration initiated successfully'); + // do not unlock; cloudron is migrating + } + + return; + } + + // initiate the migration in the background + backups.backupBoxAndApps({ userId: null, username: 'migrator' }, function (error, backupId) { + if (error) return unlock(error); + + debug('migrate: size %s region %s', size, region); + + superagent + .post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/migrate') + .query({ token: config.token() }) + .send({ domain: config.fqdn(), size: size, region: region, restoreKey: backupId }) + .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))); + + unlock(null); + }); + }); + + callback(null); +} diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js index e23ec0559..74dd9006b 100644 --- a/src/routes/cloudron.js +++ b/src/routes/cloudron.js @@ -5,6 +5,7 @@ exports = module.exports = { setupTokenAuth: setupTokenAuth, getStatus: getStatus, reboot: reboot, + migrate: migrate, getProgress: getProgress, getConfig: getConfig, update: update, @@ -113,6 +114,20 @@ function reboot(req, res, next) { cloudron.reboot(); } +function migrate(req, res, next) { + if (typeof req.body.size !== 'string') return next(new HttpError(400, 'size must be string')); + if (typeof req.body.region !== 'string') return next(new HttpError(400, 'region must be string')); + + debug('Migration requested', req.body.size, req.body.region); + + cloudron.migrate(req.body.size, req.body.region, function (error) { + if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, 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/test/cloudron-test.js b/src/routes/test/cloudron-test.js index cf9b6a143..a356eb81e 100644 --- a/src/routes/test/cloudron-test.js +++ b/src/routes/test/cloudron-test.js @@ -1,6 +1,5 @@ 'use strict'; -/* jslint node:true */ /* global it:false */ /* global describe:false */ /* global before:false */ @@ -10,6 +9,7 @@ var async = require('async'), config = require('../../config.js'), database = require('../../database.js'), expect = require('expect.js'), + locker = require('../../locker.js'), nock = require('nock'), os = require('os'), superagent = require('superagent'), @@ -26,6 +26,7 @@ function setup(done) { nock.cleanAll(); config._reset(); config.set('version', '0.5.0'); + config.set('fqdn', 'localhost'); server.start(done); } @@ -262,6 +263,182 @@ describe('Cloudron', function () { }); + describe('migrate', function () { + before(function (done) { + async.series([ + setup, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + superagent.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + + function setupBackupConfig(callback) { + superagent.post(SERVER_URL + '/api/v1/settings/backup_config') + .send({ provider: 'caas', token: 'BACKUP_TOKEN', bucket: 'Bucket', prefix: 'Prefix' }) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(200); + + callback(); + }); + } + + ], done); + }); + + after(function (done) { + locker.unlock(locker._operation); // migrate never unlocks + cleanup(done); + }); + + it('fails without token', function (done) { + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo'}) + .end(function (error, result) { + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails without password', function (done) { + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo'}) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with missing size', function (done) { + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with wrong size type', function (done) { + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 4, region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with missing region', function (done) { + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with wrong region type', function (done) { + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 4, password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails when in wrong state', function (done) { + var scope2 = nock(config.apiServerOrigin()) + .post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=BACKUP_TOKEN') + .reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } }); + + var scope3 = nock(config.apiServerOrigin()) + .post('/api/v1/boxes/' + config.fqdn() + '/backupDone?token=APPSTORE_TOKEN', function (body) { + return body.boxVersion && body.restoreKey && !body.appId && !body.appVersion && body.appBackupIds.length === 0; + }) + .reply(200, { id: 'someid' }); + + var scope1 = nock(config.apiServerOrigin()) + .post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', function (body) { + return body.size && body.region && body.restoreKey; + }).reply(409, {}); + + injectShellMock(); + + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(202); + + function checkAppstoreServerCalled() { + if (scope1.isDone() && scope2.isDone() && scope3.isDone()) { + restoreShellMock(); + return done(); + } + + setTimeout(checkAppstoreServerCalled, 100); + } + + checkAppstoreServerCalled(); + }); + }); + + it('succeeds', function (done) { + var scope1 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', function (body) { + return body.size && body.region && body.restoreKey; + }).reply(202, {}); + + var scope2 = nock(config.apiServerOrigin()) + .post('/api/v1/boxes/' + config.fqdn() + '/backupDone?token=APPSTORE_TOKEN', function (body) { + return body.boxVersion && body.restoreKey && !body.appId && !body.appVersion && body.appBackupIds.length === 0; + }) + .reply(200, { id: 'someid' }); + + var scope3 = nock(config.apiServerOrigin()) + .post('/api/v1/boxes/' + config.fqdn() + '/awscredentials?token=BACKUP_TOKEN') + .reply(201, { credentials: { AccessKeyId: 'accessKeyId', SecretAccessKey: 'secretAccessKey', SessionToken: 'sessionToken' } }); + + injectShellMock(); + + superagent.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(result.statusCode).to.equal(202); + + function checkAppstoreServerCalled() { + if (scope1.isDone() && scope2.isDone() && scope3.isDone()) { + restoreShellMock(); + return done(); + } + + setTimeout(checkAppstoreServerCalled, 100); + } + + checkAppstoreServerCalled(); + }); + }); + }); + describe('feedback', function () { before(function (done) { async.series([ diff --git a/src/server.js b/src/server.js index 983c1ecba..c4afc1274 100644 --- a/src/server.js +++ b/src/server.js @@ -96,6 +96,7 @@ function initializeExpressSync() { router.post('/api/v1/cloudron/update', cloudronScope, routes.user.requireAdmin, routes.user.verifyPassword, 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.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.graphs.getGraphs); // feedback