diff --git a/src/apps.js b/src/apps.js index 5f8fd95de..1b1756c25 100644 --- a/src/apps.js +++ b/src/apps.js @@ -15,6 +15,7 @@ exports = module.exports = { uninstall: uninstall, restore: restore, + clone: clone, update: update, @@ -715,6 +716,68 @@ function restore(appId, data, auditSource, callback) { }); } +function clone(appId, data, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof data, 'object'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debug('Will clone app with id:%s', appId); + + var location = data.location.toLowerCase(), + portBindings = data.portBindings || null, + backupId = data.backupId; + + assert.strictEqual(typeof backupId, 'string'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof portBindings, 'object'); + + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + backups.getRestoreConfig(backupId, function (error, restoreConfig) { + if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + if (!restoreConfig) callback(new AppsError(AppsError.EXTERNAL_ERROR, 'Could not get restore config')); + + // re-validate because this new box version may not accept old configs + error = checkManifestConstraints(restoreConfig.manifest); + if (error) return callback(error); + + error = validateHostname(location, config.fqdn()); + if (error) return callback(error); + + error = validatePortBindings(portBindings, restoreConfig.manifest.tcpPorts); + if (error) return callback(error); + + var newAppId = uuid.v4(), appStoreId = app.appStoreId, manifest = restoreConfig.manifest; + + purchase(appStoreId, function (error) { + if (error) return callback(error); + + var data = { + installationState: appdb.ISTATE_PENDING_CLONE, + memoryLimit: app.memoryLimit, + accessRestriction: app.accessRestriction + }; + + appdb.add(newAppId, appStoreId, manifest, location, portBindings, data, function (error) { + if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location, portBindings, error)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(newAppId); + + eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: appId, backupId: backupId, location: location, manifest: manifest }); + + callback(null, { id : newAppId }); + }); + }); + }); + }); +} + function uninstall(appId, auditSource, callback) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof auditSource, 'object'); diff --git a/src/apptask.js b/src/apptask.js index 6ad817ce4..b903f7ed7 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -778,6 +778,7 @@ function startTask(appId, callback) { case appdb.ISTATE_PENDING_BACKUP: return backup(app, callback); case appdb.ISTATE_INSTALLED: return handleRunCommand(app, callback); case appdb.ISTATE_PENDING_INSTALL: return install(app, callback); + case appdb.ISTATE_PENDING_CLONE: return restore(app, callback); case appdb.ISTATE_PENDING_FORCE_UPDATE: return update(app, callback); case appdb.ISTATE_ERROR: debugApp(app, 'Internal error. apptask launched with error status.'); diff --git a/src/eventlog.js b/src/eventlog.js index 4dbccd8c5..ee618df77 100644 --- a/src/eventlog.js +++ b/src/eventlog.js @@ -9,6 +9,7 @@ exports = module.exports = { // keep in sync with webadmin index.js filter ACTION_ACTIVATE: 'cloudron.activate', + ACTION_APP_CLONE: 'app.clone', ACTION_APP_CONFIGURE: 'app.configure', ACTION_APP_INSTALL: 'app.install', ACTION_APP_RESTORE: 'app.restore', diff --git a/src/routes/apps.js b/src/routes/apps.js index 5ed8cde7b..707f63fa1 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -16,7 +16,9 @@ exports = module.exports = { stopApp: stopApp, startApp: startApp, - exec: exec + exec: exec, + + cloneApp: cloneApp }; var apps = require('../apps.js'), @@ -192,6 +194,29 @@ function restoreApp(req, res, next) { }); } +function cloneApp(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + var data = req.body; + + debug('Clone app id:%s', req.params.id); + + if (typeof data.backupId !== 'string') return next(new HttpError(400, 'backupId must be a string')); + if (typeof data.location !== 'string') return next(new HttpError(400, 'location is required')); + if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object')); + + apps.clone(req.params.id, data, auditSource(req), function (error, result) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(424, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(201, { id: result.id })); + }); +} + function backupApp(req, res, next) { assert.strictEqual(typeof req.params.id, 'string'); diff --git a/src/server.js b/src/server.js index eb25e148f..8ccd57704 100644 --- a/src/server.js +++ b/src/server.js @@ -173,6 +173,7 @@ function initializeExpressSync() { router.get ('/api/v1/apps/:id/logstream', appsScope, routes.user.requireAdmin, routes.apps.getLogStream); router.get ('/api/v1/apps/:id/logs', appsScope, routes.user.requireAdmin, routes.apps.getLogs); router.get ('/api/v1/apps/:id/exec', routes.developer.enabled, appsScope, routes.user.requireAdmin, routes.apps.exec); + router.post('/api/v1/apps/:id/clone', appsScope, routes.user.requireAdmin, routes.apps.cloneApp); // settings routes (these are for the settings tab - avatar & name have public routes for normal users. see above) router.get ('/api/v1/settings/autoupdate_pattern', settingsScope, routes.user.requireAdmin, routes.settings.getAutoupdatePattern);