#!/usr/bin/env node 'use strict'; exports = module.exports = { run, // exported for testing _createAppDir: createAppDir, _deleteAppDir: deleteAppDir, _verifyManifest: verifyManifest, _waitForDnsPropagation: waitForDnsPropagation }; const appdb = require('./appdb.js'), apps = require('./apps.js'), assert = require('assert'), async = require('async'), backups = require('./backups.js'), BoxError = require('./boxerror.js'), collectd = require('./collectd.js'), constants = require('./constants.js'), debug = require('debug')('box:apptask'), df = require('@sindresorhus/df'), docker = require('./docker.js'), domains = require('./domains.js'), ejs = require('ejs'), fs = require('fs'), iputils = require('./iputils.js'), manifestFormat = require('cloudron-manifestformat'), os = require('os'), path = require('path'), paths = require('./paths.js'), reverseProxy = require('./reverseproxy.js'), rimraf = require('rimraf'), safe = require('safetydance'), services = require('./services.js'), settings = require('./settings.js'), shell = require('./shell.js'), superagent = require('superagent'), sysinfo = require('./sysinfo.js'), util = require('util'), _ = require('underscore'); const COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd/app.ejs', { encoding: 'utf8' }), MV_VOLUME_CMD = path.join(__dirname, 'scripts/mvvolume.sh'), LOGROTATE_CONFIG_EJS = fs.readFileSync(__dirname + '/logrotate.ejs', { encoding: 'utf8' }), CONFIGURE_LOGROTATE_CMD = path.join(__dirname, 'scripts/configurelogrotate.sh'); function debugApp(app) { assert.strictEqual(typeof app, 'object'); debug(app.fqdn + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1))); } function makeTaskError(error, app) { assert.strictEqual(typeof error, 'object'); assert.strictEqual(typeof app, 'object'); // track a few variables which helps 'repair' restart the task (see also scheduleTask in apps.js) error.details.taskId = app.taskId; error.details.installationState = app.installationState; return error.toPlainObject(); } // updates the app object and the database function updateApp(app, values, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof values, 'object'); assert.strictEqual(typeof callback, 'function'); appdb.update(app.id, values, function (error) { if (error) return callback(error); for (var value in values) { app[value] = values[value]; } callback(null); }); } function allocateContainerIp(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); async.retry({ times: 10 }, function (retryCallback) { const iprange = iputils.intFromIp('172.18.20.255') - iputils.intFromIp('172.18.16.1'); let rnd = Math.floor(Math.random() * iprange); const containerIp = iputils.ipFromInt(iputils.intFromIp('172.18.16.1') + rnd); updateApp(app, { containerIp }, retryCallback); }, callback); } function configureReverseProxy(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); reverseProxy.configureApp(app, { userId: null, username: 'apptask' }, function (error) { if (error) return callback(error); callback(null); }); } function unconfigureReverseProxy(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); reverseProxy.unconfigureApp(app, function (error) { if (error) return callback(error); callback(null); }); } function createContainer(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); assert(!app.containerId); // otherwise, it will trigger volumeFrom debugApp(app, 'creating container'); docker.createContainer(app, function (error, container) { if (error) return callback(error); updateApp(app, { containerId: container.id }, function (error) { if (error) return callback(error); // re-generate configs that rely on container id async.series([ addLogrotateConfig.bind(null, app), addCollectdProfile.bind(null, app) ], callback); }); }); } function deleteContainers(app, options, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); debugApp(app, 'deleting app containers (app, scheduler)'); async.series([ // remove configs that rely on container id removeCollectdProfile.bind(null, app), removeLogrotateConfig.bind(null, app), docker.stopContainers.bind(null, app.id), docker.deleteContainers.bind(null, app.id, options), updateApp.bind(null, app, { containerId: null }) ], callback); } function createAppDir(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); const appDir = path.join(paths.APPS_DATA_DIR, app.id); fs.mkdir(appDir, { recursive: true }, function (error) { if (error) return callback(new BoxError(BoxError.FS_ERROR, `Error creating directory: ${error.message}`, { appDir })); callback(null); }); } function deleteAppDir(app, options, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); const appDataDir = path.join(paths.APPS_DATA_DIR, app.id); // resolve any symlinked data dir const stat = safe.fs.lstatSync(appDataDir); if (!stat) return callback(null); const resolvedAppDataDir = stat.isSymbolicLink() ? safe.fs.readlinkSync(appDataDir) : appDataDir; if (safe.fs.existsSync(resolvedAppDataDir)) { const entries = safe.fs.readdirSync(resolvedAppDataDir); if (!entries) return callback(new BoxError(BoxError.FS_ERROR, `Error listing ${resolvedAppDataDir}: ${safe.error.message}`)); // remove only files. directories inside app dir are currently volumes managed by the addons // we cannot delete those dirs anyway because of perms entries.forEach(function (entry) { let stat = safe.fs.statSync(path.join(resolvedAppDataDir, entry)); if (stat && !stat.isDirectory()) safe.fs.unlinkSync(path.join(resolvedAppDataDir, entry)); }); } // if this fails, it's probably because the localstorage/redis addons have not cleaned up properly if (options.removeDirectory) { if (stat.isSymbolicLink()) { if (!safe.fs.unlinkSync(appDataDir)) { if (safe.error.code !== 'ENOENT') return callback(new BoxError(BoxError.FS_ERROR, `Error unlinking dir ${appDataDir} : ${safe.error.message}`)); } } else { if (!safe.fs.rmdirSync(appDataDir)) { if (safe.error.code !== 'ENOENT') return callback(new BoxError(BoxError.FS_ERROR, `Error removing dir ${appDataDir} : ${safe.error.message}`)); } } } callback(null); } function addCollectdProfile(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); var collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { appId: app.id, containerId: app.containerId, appDataDir: apps.getDataDir(app, app.dataDir) }); collectd.addProfile(app.id, collectdConf, callback); } function removeCollectdProfile(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); collectd.removeProfile(app.id, callback); } function addLogrotateConfig(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); docker.inspect(app.containerId, function (error, result) { if (error) return callback(error); var runVolume = result.Mounts.find(function (mount) { return mount.Destination === '/run'; }); if (!runVolume) return callback(new BoxError(BoxError.DOCKER_ERROR, 'App does not have /run mounted')); // logrotate configs can have arbitrary commands, so the config files must be owned by root var logrotateConf = ejs.render(LOGROTATE_CONFIG_EJS, { volumePath: runVolume.Source, appId: app.id }); var tmpFilePath = path.join(os.tmpdir(), app.id + '.logrotate'); fs.writeFile(tmpFilePath, logrotateConf, function (error) { if (error) return callback(new BoxError(BoxError.FS_ERROR, `Error writing logrotate config: ${error.message}`)); shell.sudo('addLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'add', app.id, tmpFilePath ], {}, function (error) { if (error) return callback(new BoxError(BoxError.LOGROTATE_ERROR, `Error adding logrotate config: ${error.message}`)); callback(null); }); }); }); } function removeLogrotateConfig(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); shell.sudo('removeLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'remove', app.id ], {}, function (error) { if (error) return callback(new BoxError(BoxError.LOGROTATE_ERROR, `Error removing logrotate config: ${error.message}`)); callback(null); }); } function cleanupLogs(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); // note that redis container logs are cleaned up by the addon rimraf(path.join(paths.LOG_DIR, app.id), function (error) { if (error) debugApp(app, 'cannot cleanup logs:', error); callback(null); }); } function verifyManifest(manifest, callback) { assert.strictEqual(typeof manifest, 'object'); assert.strictEqual(typeof callback, 'function'); var error = manifestFormat.parse(manifest); if (error) return callback(new BoxError(BoxError.BAD_FIELD, `Manifest error: ${error.message}`, { field: 'manifest' })); error = apps.checkManifestConstraints(manifest); if (error) return callback(new BoxError(BoxError.CONFLICT, `Manifest constraint check failed: ${error.message}`, { field: 'manifest' })); callback(null); } function downloadIcon(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); // nothing to download if we dont have an appStoreId if (!app.appStoreId) return callback(null); debugApp(app, `Downloading icon of ${app.appStoreId}@${app.manifest.version}`); const iconUrl = settings.apiServerOrigin() + '/api/v1/apps/' + app.appStoreId + '/versions/' + app.manifest.version + '/icon'; async.retry({ times: 10, interval: 5000 }, function (retryCallback) { superagent .get(iconUrl) .buffer(true) .timeout(30 * 1000) .end(function (error, res) { if (error && !error.response) return retryCallback(new BoxError(BoxError.NETWORK_ERROR, `Network error downloading icon : ${error.message}`)); if (res.statusCode !== 200) return retryCallback(null); // ignore error. this can also happen for apps installed with cloudron-cli updateApp(app, { appStoreIcon: res.body }, retryCallback); }); }, callback); } function waitForDnsPropagation(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); if (!constants.CLOUDRON) { debugApp(app, 'Skipping dns propagation check for development'); return callback(null); } sysinfo.getServerIp(function (error, ip) { if (error) return callback(new BoxError(BoxError.NETWORK_ERROR, `Error getting public IP: ${error.message}`)); domains.waitForDnsRecord(app.location, app.domain, 'A', ip, { times: 240 }, function (error) { if (error) return callback(new BoxError(BoxError.DNS_ERROR, `DNS Record is not synced yet: ${error.message}`, { ip: ip, subdomain: app.location, domain: app.domain })); // now wait for alternateDomains and aliasDomains, if any async.eachSeries(app.alternateDomains.concat(app.aliasDomains), function (domain, iteratorCallback) { domains.waitForDnsRecord(domain.subdomain, domain.domain, 'A', ip, { times: 240 }, function (error) { if (error) return callback(new BoxError(BoxError.DNS_ERROR, `DNS Record is not synced yet: ${error.message}`, { ip: ip, subdomain: domain.subdomain, domain: domain.domain })); iteratorCallback(); }); }, callback); }); }); } function moveDataDir(app, targetDir, callback) { assert.strictEqual(typeof app, 'object'); assert(targetDir === null || typeof targetDir === 'string'); assert.strictEqual(typeof callback, 'function'); let resolvedSourceDir = apps.getDataDir(app, app.dataDir); let resolvedTargetDir = apps.getDataDir(app, targetDir); debugApp(app, `moveDataDir: migrating data from ${resolvedSourceDir} to ${resolvedTargetDir}`); if (resolvedSourceDir === resolvedTargetDir) return callback(); shell.sudo('moveDataDir', [ MV_VOLUME_CMD, resolvedSourceDir, resolvedTargetDir ], {}, function (error) { if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Error migrating data directory: ${error.message}`)); callback(null); }); } function downloadImage(manifest, callback) { assert.strictEqual(typeof manifest, 'object'); assert.strictEqual(typeof callback, 'function'); docker.info(function (error, info) { if (error) return callback(error); const dfAsync = util.callbackify(df.file); dfAsync(info.DockerRootDir, function (error, diskUsage) { if (error) return callback(new BoxError(BoxError.FS_ERROR, `Error getting file system info: ${error.message}`)); if (diskUsage.available < (1024*1024*1024)) return callback(new BoxError(BoxError.DOCKER_ERROR, 'Not enough disk space to pull docker image', { diskUsage: diskUsage, dockerRootDir: info.DockerRootDir })); docker.downloadImage(manifest, function (error) { if (error) return callback(error); callback(null); }); }); }); } function startApp(app, callback){ debugApp(app, 'startApp: starting container'); if (app.runState === apps.RSTATE_STOPPED) return callback(); docker.startContainer(app.id, callback); } function install(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); const restoreConfig = args.restoreConfig; // has to be set when restoring const overwriteDns = args.overwriteDns; const skipDnsSetup = args.skipDnsSetup; const oldManifest = args.oldManifest; async.series([ // this protects against the theoretical possibility of an app being marked for install/restore from // a previous version of box code verifyManifest.bind(null, app.manifest), // teardown for re-installs progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, { managedOnly: true }), function teardownAddons(next) { // when restoring, app does not require these addons anymore. remove carefully to preserve the db passwords let addonsToRemove; if (oldManifest) { addonsToRemove = _.omit(oldManifest.addons, Object.keys(app.manifest.addons)); } else { addonsToRemove = app.manifest.addons; } services.teardownAddons(app, addonsToRemove, next); }, function deleteAppDirIfNeeded(done) { if (restoreConfig && !restoreConfig.backupId) return done(); // in-place import should not delete data dir deleteAppDir(app, { removeDirectory: false }, done); // do not remove any symlinked appdata dir }, function deleteImageIfChanged(done) { if (!oldManifest || oldManifest.dockerImage === app.manifest.dockerImage) return done(); docker.deleteImage(oldManifest, done); }, // allocating container ip here, lets the users "repair" an app if allocation fails at appdb.add time allocateContainerIp.bind(null, app), progressCallback.bind(null, { percent: 20, message: 'Downloading icon' }), downloadIcon.bind(null, app), function setupDnsIfNeeded(done) { if (skipDnsSetup) return done(); async.series([ progressCallback.bind(null, { percent: 30, message: 'Registering subdomains' }), domains.registerLocations.bind(null, [ { subdomain: app.location, domain: app.domain }].concat(app.alternateDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback) ], done); }, progressCallback.bind(null, { percent: 40, message: 'Downloading image' }), downloadImage.bind(null, app.manifest), progressCallback.bind(null, { percent: 50, message: 'Creating app data directory' }), createAppDir.bind(null, app), function restoreFromBackup(next) { if (!restoreConfig) { async.series([ progressCallback.bind(null, { percent: 60, message: 'Setting up addons' }), services.setupAddons.bind(null, app, app.manifest.addons), ], next); } else if (!restoreConfig.backupId) { // in-place import async.series([ progressCallback.bind(null, { percent: 60, message: 'Importing addons in-place' }), services.setupAddons.bind(null, app, app.manifest.addons), services.clearAddons.bind(null, app, _.omit(app.manifest.addons, 'localstorage')), apps.restoreConfig.bind(null, app), services.restoreAddons.bind(null, app, app.manifest.addons), ], next); } else { async.series([ progressCallback.bind(null, { percent: 65, message: 'Download backup and restoring addons' }), services.setupAddons.bind(null, app, app.manifest.addons), services.clearAddons.bind(null, app, app.manifest.addons), backups.downloadApp.bind(null, app, restoreConfig, (progress) => { progressCallback({ percent: 65, message: progress.message }); }), (done) => { if (app.installationState === apps.ISTATE_PENDING_IMPORT) apps.restoreConfig(app, done); else done(); }, progressCallback.bind(null, { percent: 70, message: 'Restoring addons' }), services.restoreAddons.bind(null, app, app.manifest.addons) ], next); } }, progressCallback.bind(null, { percent: 80, message: 'Creating container' }), createContainer.bind(null, app), startApp.bind(null, app), function waitForDns(done) { if (skipDnsSetup) return done(); async.series([ progressCallback.bind(null, { percent: 85, message: 'Waiting for DNS propagation' }), exports._waitForDnsPropagation.bind(null, app), ], done); }, progressCallback.bind(null, { percent: 95, message: 'Configuring reverse proxy' }), configureReverseProxy.bind(null, app), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error installing app:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(null); }); } function backup(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); async.series([ progressCallback.bind(null, { percent: 10, message: 'Backing up' }), backups.backupApp.bind(null, app, { snapshotOnly: !!args.snapshotOnly }, (progress) => { progressCallback({ percent: 30, message: progress.message }); }), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error backing up app:', error); // return to installed state intentionally. the error is stashed only in the task and not the app (the UI shows error state otherwise) return updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null }, callback.bind(null, error)); } callback(null); }); } function create(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); async.series([ progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), deleteContainers.bind(null, app, { managedOnly: true }), // FIXME: re-setup addons only because sendmail addon to re-inject env vars on mailboxName change progressCallback.bind(null, { percent: 30, message: 'Setting up addons' }), services.setupAddons.bind(null, app, app.manifest.addons), progressCallback.bind(null, { percent: 60, message: 'Creating container' }), createContainer.bind(null, app), startApp.bind(null, app), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error creating :', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(null); }); } function changeLocation(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); const oldConfig = args.oldConfig; const locationChanged = oldConfig.fqdn !== app.fqdn; const skipDnsSetup = args.skipDnsSetup; const overwriteDns = args.overwriteDns; async.series([ progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, { managedOnly: true }), function (next) { let obsoleteDomains = oldConfig.alternateDomains.filter(function (o) { return !app.alternateDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; }); }); if (oldConfig.aliasDomains) { obsoleteDomains = obsoleteDomains.concat(oldConfig.aliasDomains.filter(function (o) { return !app.aliasDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; }); })); } if (locationChanged) obsoleteDomains.push({ subdomain: oldConfig.location, domain: oldConfig.domain }); if (obsoleteDomains.length === 0) return next(); domains.unregisterLocations(obsoleteDomains, progressCallback, next); }, function setupDnsIfNeeded(done) { if (skipDnsSetup) return done(); async.series([ progressCallback.bind(null, { percent: 30, message: 'Registering subdomains' }), domains.registerLocations.bind(null, [ { subdomain: app.location, domain: app.domain }].concat(app.alternateDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback) ], done); }, // re-setup addons since they rely on the app's fqdn (e.g oauth) progressCallback.bind(null, { percent: 50, message: 'Setting up addons' }), services.setupAddons.bind(null, app, app.manifest.addons), progressCallback.bind(null, { percent: 60, message: 'Creating container' }), createContainer.bind(null, app), startApp.bind(null, app), function waitForDns(done) { if (skipDnsSetup) return done(); async.series([ progressCallback.bind(null, { percent: 80, message: 'Waiting for DNS propagation' }), exports._waitForDnsPropagation.bind(null, app), ], done); }, progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }), configureReverseProxy.bind(null, app), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error changing location:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(null); }); } function migrateDataDir(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); let newDataDir = args.newDataDir; assert(newDataDir === null || typeof newDataDir === 'string'); async.series([ progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), deleteContainers.bind(null, app, { managedOnly: true }), progressCallback.bind(null, { percent: 45, message: 'Ensuring app data directory' }), createAppDir.bind(null, app), // re-setup addons since this creates the localStorage volume progressCallback.bind(null, { percent: 50, message: 'Setting up addons' }), services.setupAddons.bind(null, _.extend({}, app, { dataDir: newDataDir }), app.manifest.addons), progressCallback.bind(null, { percent: 60, message: 'Moving data dir' }), moveDataDir.bind(null, app, newDataDir), progressCallback.bind(null, { percent: 90, message: 'Creating container' }), createContainer.bind(null, app), startApp.bind(null, app), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null, dataDir: newDataDir }) ], function seriesDone(error) { if (error) { debugApp(app, 'error migrating data dir:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(); }); } // configure is called for an infra update and repair to re-create container, reverseproxy config. it's all "local" function configure(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); async.series([ progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, { managedOnly: true }), progressCallback.bind(null, { percent: 20, message: 'Downloading icon' }), downloadIcon.bind(null, app), progressCallback.bind(null, { percent: 40, message: 'Downloading image' }), downloadImage.bind(null, app.manifest), progressCallback.bind(null, { percent: 45, message: 'Ensuring app data directory' }), createAppDir.bind(null, app), // re-setup addons since they rely on the app's fqdn (e.g oauth) progressCallback.bind(null, { percent: 50, message: 'Setting up addons' }), services.setupAddons.bind(null, app, app.manifest.addons), progressCallback.bind(null, { percent: 60, message: 'Creating container' }), createContainer.bind(null, app), startApp.bind(null, app), progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }), configureReverseProxy.bind(null, app), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error reconfiguring:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(); }); } function update(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); const updateConfig = args.updateConfig; debugApp(app, `Updating to ${updateConfig.manifest.version}`); // app does not want these addons anymore // FIXME: this does not handle option changes (like multipleDatabases) const unusedAddons = _.omit(app.manifest.addons, Object.keys(updateConfig.manifest.addons)); const httpPathsChanged = app.manifest.httpPaths !== updateConfig.manifest.httpPaths; const httpPortChanged = app.manifest.httpPort !== updateConfig.manifest.httpPort; const proxyAuthChanged = !_.isEqual(safe.query(app.manifest, 'addons.proxyAuth'), safe.query(updateConfig.manifest, 'addons.proxyAuth')); async.series([ // this protects against the theoretical possibility of an app being marked for update from // a previous version of box code progressCallback.bind(null, { percent: 5, message: 'Verify manifest' }), verifyManifest.bind(null, updateConfig.manifest), function (next) { if (updateConfig.skipBackup) return next(null); async.series([ progressCallback.bind(null, { percent: 15, message: 'Backing up app' }), // preserve update backups for 3 weeks backups.backupApp.bind(null, app, { preserveSecs: 3*7*24*60*60 }, (progress) => { progressCallback({ percent: 15, message: `Backup - ${progress.message}` }); }) ], function (error) { if (error) error.backupError = true; next(error); }); }, // download new image before app is stopped. this is so we can reduce downtime // and also not remove the 'common' layers when the old image is deleted progressCallback.bind(null, { percent: 25, message: 'Downloading image' }), downloadImage.bind(null, updateConfig.manifest), // note: we cleanup first and then backup. this is done so that the app is not running should backup fail // we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings progressCallback.bind(null, { percent: 35, message: 'Cleaning up old install' }), deleteContainers.bind(null, app, { managedOnly: true }), function deleteImageIfChanged(done) { if (app.manifest.dockerImage === updateConfig.manifest.dockerImage) return done(); docker.deleteImage(app.manifest, done); }, // only delete unused addons after backup services.teardownAddons.bind(null, app, unusedAddons), // free unused ports function (next) { const currentPorts = app.portBindings || {}; const newTcpPorts = updateConfig.manifest.tcpPorts || {}; const newUdpPorts = updateConfig.manifest.udpPorts || {}; async.each(Object.keys(currentPorts), function (portName, callback) { if (newTcpPorts[portName] || newUdpPorts[portName]) return callback(null); // port still in use appdb.delPortBinding(currentPorts[portName], apps.PORT_TYPE_TCP, function (error) { if (error && error.reason === BoxError.NOT_FOUND) debugApp(app, 'update: portbinding does not exist in database', error); else if (error) return next(error); // also delete from app object for further processing (the db is updated in the next step) delete app.portBindings[portName]; callback(null); }); }, next); }, updateApp.bind(null, app, _.pick(updateConfig, 'manifest', 'appStoreId', 'memoryLimit')), // switch over to the new config progressCallback.bind(null, { percent: 45, message: 'Downloading icon' }), downloadIcon.bind(null, app), progressCallback.bind(null, { percent: 60, message: 'Updating addons' }), services.setupAddons.bind(null, app, updateConfig.manifest.addons), progressCallback.bind(null, { percent: 70, message: 'Creating container' }), createContainer.bind(null, app), startApp.bind(null, app), progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }), function (next) { if (!httpPathsChanged && !proxyAuthChanged && !httpPortChanged) return next(); configureReverseProxy(app, next); }, progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null, updateTime: new Date() }) ], function seriesDone(error) { if (error && error.backupError) { debugApp(app, 'update aborted because backup failed', error); updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }, callback.bind(null, error)); } else if (error) { debugApp(app, 'Error updating app:', error); updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } else { callback(null); } }); } function start(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); async.series([ progressCallback.bind(null, { percent: 10, message: 'Starting app services' }), services.startAppServices.bind(null, app), progressCallback.bind(null, { percent: 35, message: 'Starting container' }), docker.startContainer.bind(null, app.id), progressCallback.bind(null, { percent: 60, message: 'Adding collectd profile' }), addCollectdProfile.bind(null, app), // stopped apps do not renew certs. currently, we don't do DNS to not overwrite existing user settings progressCallback.bind(null, { percent: 80, message: 'Configuring reverse proxy' }), configureReverseProxy.bind(null, app), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error starting app:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(null); }); } function stop(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); async.series([ progressCallback.bind(null, { percent: 20, message: 'Stopping container' }), docker.stopContainers.bind(null, app.id), progressCallback.bind(null, { percent: 50, message: 'Stopping app services' }), services.stopAppServices.bind(null, app), progressCallback.bind(null, { percent: 80, message: 'Removing collectd profile' }), removeCollectdProfile.bind(null, app), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error starting app:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(null); }); } function restart(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); async.series([ progressCallback.bind(null, { percent: 20, message: 'Restarting container' }), docker.restartContainer.bind(null, app.id), progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { if (error) { debugApp(app, 'error starting app:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(null); }); } function uninstall(app, args, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); async.series([ progressCallback.bind(null, { percent: 20, message: 'Deleting container' }), unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, {}), progressCallback.bind(null, { percent: 30, message: 'Teardown addons' }), services.teardownAddons.bind(null, app, app.manifest.addons), progressCallback.bind(null, { percent: 40, message: 'Cleanup file manager' }), progressCallback.bind(null, { percent: 50, message: 'Deleting app data directory' }), deleteAppDir.bind(null, app, { removeDirectory: true }), progressCallback.bind(null, { percent: 60, message: 'Deleting image' }), docker.deleteImage.bind(null, app.manifest), progressCallback.bind(null, { percent: 70, message: 'Unregistering domains' }), domains.unregisterLocations.bind(null, [ { subdomain: app.location, domain: app.domain } ].concat(app.alternateDomains).concat(app.aliasDomains), progressCallback), progressCallback.bind(null, { percent: 90, message: 'Cleanup logs' }), cleanupLogs.bind(null, app), progressCallback.bind(null, { percent: 95, message: 'Remove app from database' }), appdb.del.bind(null, app.id) ], function seriesDone(error) { if (error) { debugApp(app, 'error uninstalling app:', error); return updateApp(app, { installationState: apps.ISTATE_ERROR, error: makeTaskError(error, app) }, callback.bind(null, error)); } callback(null); }); } function run(appId, args, progressCallback, callback) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof args, 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); // determine what to do apps.get(appId, function (error, app) { if (error) return callback(error); debugApp(app, `startTask installationState: ${app.installationState} runState: ${app.runState}`); switch (app.installationState) { case apps.ISTATE_PENDING_INSTALL: case apps.ISTATE_PENDING_CLONE: case apps.ISTATE_PENDING_RESTORE: case apps.ISTATE_PENDING_IMPORT: return install(app, args, progressCallback, callback); case apps.ISTATE_PENDING_CONFIGURE: return configure(app, args, progressCallback, callback); case apps.ISTATE_PENDING_RECREATE_CONTAINER: case apps.ISTATE_PENDING_RESIZE: case apps.ISTATE_PENDING_DEBUG: return create(app, args, progressCallback, callback); case apps.ISTATE_PENDING_LOCATION_CHANGE: return changeLocation(app, args, progressCallback, callback); case apps.ISTATE_PENDING_DATA_DIR_MIGRATION: return migrateDataDir(app, args, progressCallback, callback); case apps.ISTATE_PENDING_UNINSTALL: return uninstall(app, args, progressCallback, callback); case apps.ISTATE_PENDING_UPDATE: return update(app, args, progressCallback, callback); case apps.ISTATE_PENDING_BACKUP: return backup(app, args, progressCallback, callback); case apps.ISTATE_PENDING_START: return start(app, args, progressCallback, callback); case apps.ISTATE_PENDING_STOP: return stop(app, args, progressCallback, callback); case apps.ISTATE_PENDING_RESTART: return restart(app, args, progressCallback, callback); case apps.ISTATE_INSTALLED: // can only happen when we have a bug in our code while testing/development return updateApp(app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }, callback); default: debugApp(app, 'apptask launched with invalid command'); return callback(new BoxError(BoxError.INTERNAL_ERROR, 'Unknown install command in apptask:' + app.installationState)); } }); }