Files
cloudron-box/src/apptask.js

1003 lines
41 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
'use strict';
exports = module.exports = {
initialize: initialize,
startTask: startTask,
2019-08-26 15:55:57 -07:00
run: run,
// exported for testing
_reserveHttpPort: reserveHttpPort,
_configureReverseProxy: configureReverseProxy,
_unconfigureReverseProxy: unconfigureReverseProxy,
_createAppDir: createAppDir,
_deleteAppDir: deleteAppDir,
_verifyManifest: verifyManifest,
_registerSubdomain: registerSubdomain,
_unregisterSubdomain: unregisterSubdomain,
_waitForDnsPropagation: waitForDnsPropagation
};
require('supererror')({ splatchError: true });
var addons = require('./addons.js'),
appdb = require('./appdb.js'),
apps = require('./apps.js'),
assert = require('assert'),
async = require('async'),
auditsource = require('./auditsource.js'),
backups = require('./backups.js'),
constants = require('./constants.js'),
database = require('./database.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:apptask'),
df = require('@sindresorhus/df'),
2015-10-19 14:09:20 -07:00
docker = require('./docker.js'),
domains = require('./domains.js'),
2018-04-29 11:20:12 -07:00
DomainsError = domains.DomainsError,
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
manifestFormat = require('cloudron-manifestformat'),
mkdirp = require('mkdirp'),
net = require('net'),
os = require('os'),
path = require('path'),
paths = require('./paths.js'),
reverseProxy = require('./reverseproxy.js'),
2018-06-04 11:45:22 +02:00
rimraf = require('rimraf'),
safe = require('safetydance'),
settings = require('./settings.js'),
shell = require('./shell.js'),
superagent = require('superagent'),
sysinfo = require('./sysinfo.js'),
util = require('util'),
_ = require('underscore');
2015-12-11 14:48:02 -08:00
var COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd.config.ejs', { encoding: 'utf8' }),
CONFIGURE_COLLECTD_CMD = path.join(__dirname, 'scripts/configurecollectd.sh'),
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');
2018-11-30 09:42:15 -08:00
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function initialize(callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof callback, 'function');
async.series([
database.initialize,
settings.initCache
], callback);
}
2016-01-05 12:14:39 +01:00
function debugApp(app) {
assert.strictEqual(typeof app, 'object');
2018-02-08 15:07:49 +01:00
debug(app.fqdn + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1)));
}
2017-12-21 01:04:38 -08:00
// 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');
debugApp(app, 'updating app with values: %j', values);
appdb.update(app.id, values, function (error) {
if (error) return callback(error);
for (var value in values) {
app[value] = values[value];
}
return callback(null);
});
}
function reserveHttpPort(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
var server = net.createServer();
server.listen(0, function () {
var port = server.address().port;
updateApp(app, { httpPort: port }, function (error) {
if (error) {
server.close();
return callback(error);
}
server.close(callback);
});
});
}
function configureReverseProxy(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2018-01-30 15:16:34 -08:00
reverseProxy.configureApp(app, { userId: null, username: 'apptask' }, callback);
}
function unconfigureReverseProxy(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2015-12-11 14:48:02 -08:00
// TODO: maybe revoke the cert
reverseProxy.unconfigureApp(app, callback);
}
function createContainer(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2015-10-19 16:22:35 -07:00
assert(!app.containerId); // otherwise, it will trigger volumeFrom
2015-10-19 18:48:56 -07:00
debugApp(app, 'creating container');
2015-10-19 21:33:53 -07:00
docker.createContainer(app, function (error, container) {
2015-10-19 16:00:40 -07:00
if (error) return callback(new Error('Error creating container: ' + error));
2015-10-19 16:00:40 -07:00
updateApp(app, { containerId: container.id }, callback);
});
}
function deleteContainers(app, options, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'deleting app containers (app, scheduler)');
2015-10-19 18:48:56 -07:00
docker.deleteContainers(app.id, options, function (error) {
2015-10-19 11:40:19 -07:00
if (error) return callback(new Error('Error deleting container: ' + error));
2015-10-19 11:40:19 -07:00
updateApp(app, { containerId: null }, callback);
});
}
function createAppDir(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
mkdirp(path.join(paths.APPS_DATA_DIR, app.id), callback);
}
function deleteAppDir(app, options, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
2016-01-05 12:14:39 +01:00
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);
2018-09-14 14:37:20 +02:00
const resolvedAppDataDir = stat.isSymbolicLink() ? safe.fs.readlinkSync(appDataDir) : appDataDir;
if (safe.fs.existsSync(resolvedAppDataDir)) {
const entries = safe.fs.readdirSync(resolvedAppDataDir);
if (!entries) return callback(`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)) return callback(safe.error.code === 'ENOENT' ? null : safe.error);
} else {
if (!safe.fs.rmdirSync(appDataDir)) return callback(safe.error.code === 'ENOENT' ? null : safe.error);
}
}
callback(null);
}
function addCollectdProfile(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2019-08-17 03:37:22 -07:00
var collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { appId: app.id, containerId: app.containerId, appDataDir: apps.getDataDir(app, app.dataDir) });
fs.writeFile(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), collectdConf, function (error) {
if (error) return callback(error);
2018-11-25 14:57:17 -08:00
shell.sudo('addCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'add', app.id ], {}, callback);
});
}
function removeCollectdProfile(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
fs.unlink(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), function (error) {
if (error && error.code !== 'ENOENT') debugApp(app, 'Error removing collectd profile', error);
2018-11-25 14:57:17 -08:00
shell.sudo('removeCollectdProfile', [ CONFIGURE_COLLECTD_CMD, 'remove', 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 Error('App does not have /run mounted'));
2017-09-12 21:45:42 -07:00
// 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(error);
2018-11-25 14:57:17 -08:00
shell.sudo('addLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'add', app.id, tmpFilePath ], {}, callback);
});
});
}
function removeLogrotateConfig(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2018-11-25 14:57:17 -08:00
shell.sudo('removeLogrotateConfig', [ CONFIGURE_LOGROTATE_CMD, 'remove', app.id ], {}, callback);
}
function verifyManifest(manifest, callback) {
assert.strictEqual(typeof manifest, 'object');
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof callback, 'function');
var error = manifestFormat.parse(manifest);
if (error) return callback(new Error(util.format('Manifest error: %s', error.message)));
error = apps.checkManifestConstraints(manifest);
if (error) return callback(error);
return callback(null);
}
function downloadIcon(app, callback) {
2016-01-05 12:14:39 +01:00
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 %s@%s', app.appStoreId, app.manifest.version);
var 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 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
2017-01-24 10:13:25 -08:00
if (!safe.fs.writeFileSync(path.join(paths.APP_ICONS_DIR, app.id + '.png'), res.body)) return retryCallback(new Error('Error saving icon:' + safe.error.message));
retryCallback(null);
2017-09-17 21:25:07 -07:00
});
}, callback);
}
2017-02-02 10:40:10 -08:00
function registerSubdomain(app, overwrite, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
2017-02-02 10:40:10 -08:00
assert.strictEqual(typeof overwrite, 'boolean');
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof callback, 'function');
2017-02-23 22:03:44 -08:00
sysinfo.getPublicIp(function (error, ip) {
2016-01-05 12:16:39 +01:00
if (error) return callback(error);
2016-01-05 12:16:39 +01:00
async.retry({ times: 200, interval: 5000 }, function (retryCallback) {
2018-02-08 15:07:49 +01:00
debugApp(app, 'Registering subdomain location [%s] overwrite: %s', app.fqdn, overwrite);
// get the current record before updating it
2018-02-08 12:05:29 -08:00
domains.getDnsRecords(app.location, app.domain, 'A', function (error, values) {
if (error && error.reason === DomainsError.EXTERNAL_ERROR) return retryCallback(error); // try again
if (error) return retryCallback(null, error); // give up for access and other errors
// refuse to update any existing DNS record for custom domains that we did not create
if (values.length !== 0 && !overwrite) return retryCallback(null, new Error('DNS Record already exists'));
2018-06-29 22:25:34 +02:00
domains.upsertDnsRecords(app.location, app.domain, 'A', [ ip ], function (error) {
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) {
debug('Upsert error. Will retry.', error.message);
return retryCallback(error); // try again
}
2018-06-29 22:25:34 +02:00
retryCallback(null, error);
});
2016-01-05 12:16:39 +01:00
});
}, function (error, result) {
if (error || result instanceof Error) return callback(error || result);
2018-06-30 14:02:40 +02:00
callback(null);
2016-01-05 12:16:39 +01:00
});
});
}
function unregisterSubdomain(app, location, domain, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof domain, 'string');
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof callback, 'function');
2017-02-23 22:03:44 -08:00
sysinfo.getPublicIp(function (error, ip) {
2016-01-05 12:16:39 +01:00
if (error) return callback(error);
2016-01-05 12:16:39 +01:00
async.retry({ times: 30, interval: 5000 }, function (retryCallback) {
2018-02-08 15:07:49 +01:00
debugApp(app, 'Unregistering subdomain: %s', app.fqdn);
2018-02-08 12:05:29 -08:00
domains.removeDnsRecords(location, domain, 'A', [ ip ], function (error) {
2018-04-29 11:20:12 -07:00
if (error && error.reason === DomainsError.NOT_FOUND) return retryCallback(null, null); // domain can be not found if oldConfig.domain or restoreConfig.domain was removed
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) return retryCallback(error); // try again
2016-01-05 12:16:39 +01:00
retryCallback(null, error);
});
}, function (error, result) {
if (error || result instanceof Error) return callback(error || result);
2018-06-30 14:02:40 +02:00
callback(null);
2016-01-05 12:16:39 +01:00
});
});
}
function registerAlternateDomains(app, overwrite, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof overwrite, 'boolean');
assert.strictEqual(typeof callback, 'function');
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(error);
async.eachSeries(app.alternateDomains, function (domain, callback) {
async.retry({ times: 200, interval: 5000 }, function (retryCallback) {
debugApp(app, 'Registering alternate subdomain [%s] overwrite: %s', (domain.subdomain ? (domain.subdomain + '.') : '') + domain.domain, overwrite);
// get the current record before updating it
domains.getDnsRecords(domain.subdomain, domain.domain, 'A', function (error, values) {
if (error) return retryCallback(error);
// refuse to update any existing DNS record for custom domains that we did not create
if (values.length !== 0 && !overwrite) return retryCallback(null, new Error('DNS Record already exists'));
2018-06-29 22:25:34 +02:00
domains.upsertDnsRecords(domain.subdomain, domain.domain, 'A', [ ip ], function (error) {
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) {
debug('Upsert error. Will retry.', error.message);
return retryCallback(error); // try again
}
2018-06-29 22:25:34 +02:00
retryCallback(null, error);
});
});
}, function (error, result) {
if (error || result instanceof Error) return callback(error || result);
2018-06-29 22:25:34 +02:00
callback();
});
}, callback);
});
}
function unregisterAlternateDomains(app, all, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof all, 'boolean');
assert.strictEqual(typeof callback, 'function');
2018-10-15 16:29:30 -07:00
let obsoleteDomains = [];
if (all) {
obsoleteDomains = app.alternateDomains;
} else if (app.oldConfig) { // oldConfig can be null during an infra update
obsoleteDomains = app.oldConfig.alternateDomains.filter(function (o) {
return !app.alternateDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; });
});
}
if (obsoleteDomains.length === 0) return callback();
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(error);
async.eachSeries(obsoleteDomains, function (domain, callback) {
async.retry({ times: 30, interval: 5000 }, function (retryCallback) {
debugApp(app, 'Unregistering subdomain: %s%s', domain.subdomain ? (domain.subdomain + '.') : '', domain.domain);
domains.removeDnsRecords(domain.subdomain, domain.domain, 'A', [ ip ], function (error) {
if (error && error.reason === DomainsError.NOT_FOUND) return retryCallback(null, null);
if (error && (error.reason === DomainsError.STILL_BUSY || error.reason === DomainsError.EXTERNAL_ERROR)) return retryCallback(error); // try again
retryCallback(null, error);
});
}, function (error, result) {
if (error || result instanceof Error) return callback(error || result);
2018-06-29 22:25:34 +02:00
callback();
});
}, callback);
});
}
function removeIcon(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2019-05-17 09:47:11 -07:00
if (!safe.fs.unlinkSync(path.join(paths.APP_ICONS_DIR, app.id + '.png'))) {
if (safe.error.code !== 'ENOENT') debugApp(app, 'cannot remove icon : %s', safe.error);
}
if (!safe.fs.unlinkSync(path.join(paths.APP_ICONS_DIR, app.id + '.user.png'))) {
if (safe.error.code !== 'ENOENT') debugApp(app, 'cannot remove user icon : %s', safe.error);
}
callback(null);
}
2018-06-04 11:45:22 +02:00
function cleanupLogs(app, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2018-09-18 14:15:23 -07:00
// 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: %s', error);
2018-09-18 14:15:23 -07:00
2018-06-04 11:45:22 +02:00
callback(null);
});
}
function waitForDnsPropagation(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
if (!constants.CLOUDRON) {
debugApp(app, 'Skipping dns propagation check for development');
return callback(null);
}
2017-02-23 22:03:44 -08:00
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(error);
domains.waitForDnsRecord(app.location, app.domain, 'A', ip, { interval: 5000, times: 240 }, function (error) {
if (error) return callback(error);
// now wait for alternateDomains, if any
async.eachSeries(app.alternateDomains, function (domain, iteratorCallback) {
domains.waitForDnsRecord(domain.subdomain, domain.domain, 'A', ip, { interval: 5000, times: 240 }, iteratorCallback);
}, callback);
});
});
}
function migrateDataDir(app, sourceDir, callback) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof sourceDir, 'string');
assert.strictEqual(typeof callback, 'function');
let resolvedSourceDir = apps.getDataDir(app, sourceDir);
let resolvedTargetDir = apps.getDataDir(app, app.dataDir);
debug(`migrateDataDir: migrating data from ${resolvedSourceDir} to ${resolvedTargetDir}`);
shell.sudo('migrateDataDir', [ MV_VOLUME_CMD, resolvedSourceDir, resolvedTargetDir ], {}, callback);
}
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(error);
if (diskUsage.available < (1024*1024*1024)) return callback(new Error('Not enough disk space to pull docker image. See https://cloudron.io/documentation/storage/#docker-image-location'));
docker.downloadImage(manifest, callback);
});
});
}
// Ordering is based on the following rationale:
// - configure nginx, icon, oauth
// - register subdomain.
// at this point, the user can visit the site and the above nginx config can show some install screen.
// the icon can be displayed in this nginx page and oauth proxy means the page can be protected
// - download image
// - setup volumes
// - setup addons (requires the above volume)
// - setup the container (requires image, volumes, addons)
// - setup collectd (requires container id)
// restore is also handled here since restore is just an install with some oldConfig to clean up
2019-08-26 20:43:15 -07:00
function install(app, progressCallback, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
2019-08-26 20:43:15 -07:00
assert.strictEqual(typeof progressCallback, 'function');
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof callback, 'function');
const restoreConfig = app.restoreConfig, isRestoring = app.installationState === appdb.ISTATE_PENDING_RESTORE;
2017-04-11 12:49:21 -07:00
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
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }),
unconfigureReverseProxy.bind(null, app),
removeCollectdProfile.bind(null, app),
removeLogrotateConfig.bind(null, app),
stopApp.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
var addonsToRemove = !isRestoring ? app.manifest.addons : _.omit(app.oldConfig.manifest.addons, Object.keys(app.manifest.addons));
addons.teardownAddons(app, addonsToRemove, next);
},
deleteAppDir.bind(null, app, { removeDirectory: false }), // do not remove any symlinked appdata dir
// for restore case
function deleteImageIfChanged(done) {
2017-09-17 21:25:07 -07:00
if (!app.oldConfig || (app.oldConfig.manifest.dockerImage === app.manifest.dockerImage)) return done();
2017-09-17 21:25:07 -07:00
docker.deleteImage(app.oldConfig.manifest, done);
},
reserveHttpPort.bind(null, app),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 20, message: 'Downloading icon' }),
downloadIcon.bind(null, app),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 30, message: 'Registering subdomain' }),
registerSubdomain.bind(null, app, isRestoring /* overwrite */),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 35, message: 'Registering alternate domains' }),
2018-09-22 10:09:46 -07:00
registerAlternateDomains.bind(null, app, isRestoring /* overwrite */),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 40, message: 'Downloading image' }),
downloadImage.bind(null, app.manifest),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 50, message: 'Creating app data directory' }),
createAppDir.bind(null, app),
2017-04-11 12:49:21 -07:00
function restoreFromBackup(next) {
if (!restoreConfig) {
async.series([
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 60, message: 'Setting up addons' }),
addons.setupAddons.bind(null, app, app.manifest.addons),
], next);
} else {
async.series([
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 65, message: 'Download backup and restoring addons' }),
addons.setupAddons.bind(null, app, app.manifest.addons),
addons.clearAddons.bind(null, app, app.manifest.addons),
backups.restoreApp.bind(null, app, app.manifest.addons, restoreConfig, (progress) => updateApp(app, { installationProgress: `65, Restore - ${progress.message}` }, NOOP_CALLBACK))
], next);
}
2017-04-11 12:49:21 -07:00
},
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 70, message: 'Creating container' }),
createContainer.bind(null, app),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 75, message: 'Setting up logrotate config' }),
addLogrotateConfig.bind(null, app),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 80, message: 'Setting up collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 85, message: 'Waiting for DNS propagation' }),
exports._waitForDnsPropagation.bind(null, app),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 95, message: 'Configuring reverse proxy' }),
configureReverseProxy.bind(null, app),
2019-08-26 20:43:15 -07:00
progressCallback.bind(null, { percent: 100, message: 'Done' }),
updateApp.bind(null, app, { installationState: appdb.ISTATE_INSTALLED, health: null, taskId: null })
], function seriesDone(error) {
if (error) {
debugApp(app, 'error installing app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
function backup(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
async.series([
updateApp.bind(null, app, { installationProgress: '10, Backing up' }),
backups.backupApp.bind(null, app, { /* options */ }, (progress) => updateApp(app, { installationProgress: `30, ${progress.message}` }, NOOP_CALLBACK)),
// done!
function (callback) {
debugApp(app, 'installed');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '' }, callback);
}
], function seriesDone(error) {
if (error) {
debugApp(app, 'error backing up app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: error.message }, callback.bind(null, error)); // return to installed state intentionally
}
callback(null);
});
}
// note that configure is called after an infra update as well
function configure(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
// oldConfig can be null during an infra update
const locationChanged = app.oldConfig && (app.oldConfig.fqdn !== app.fqdn);
const dataDirChanged = app.oldConfig && (app.oldConfig.dataDir !== app.dataDir);
async.series([
updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }),
unconfigureReverseProxy.bind(null, app),
removeCollectdProfile.bind(null, app),
removeLogrotateConfig.bind(null, app),
stopApp.bind(null, app),
deleteContainers.bind(null, app, { managedOnly: true }),
unregisterAlternateDomains.bind(null, app, false /* all */),
function (next) {
if (!locationChanged) return next();
unregisterSubdomain(app, app.oldConfig.location, app.oldConfig.domain, next);
},
reserveHttpPort.bind(null, app),
2017-03-15 20:24:03 -07:00
updateApp.bind(null, app, { installationProgress: '20, Downloading icon' }),
downloadIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '30, Registering subdomain' }),
registerSubdomain.bind(null, app, !locationChanged /* overwrite */), // if location changed, do not overwrite to detect conflicts
updateApp.bind(null, app, { installationProgress: '35, Registering alternate domains'}),
registerAlternateDomains.bind(null, app, true /* overwrite */), // figure out when to overwrite
updateApp.bind(null, app, { installationProgress: '40, Downloading image' }),
downloadImage.bind(null, app.manifest),
updateApp.bind(null, app, { installationProgress: '45, Ensuring app data directory' }),
createAppDir.bind(null, app),
// re-setup addons since they rely on the app's fqdn (e.g oauth)
updateApp.bind(null, app, { installationProgress: '50, Setting up addons' }),
addons.setupAddons.bind(null, app, app.manifest.addons),
// migrate dataDir
function (next) {
if (!dataDirChanged) return next();
migrateDataDir(app, app.oldConfig.dataDir, next);
},
updateApp.bind(null, app, { installationProgress: '60, Creating container' }),
createContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '65, Setting up logrotate config' }),
addLogrotateConfig.bind(null, app),
updateApp.bind(null, app, { installationProgress: '70, Add collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
2015-08-30 17:00:23 -07:00
updateApp.bind(null, app, { installationProgress: '80, Waiting for DNS propagation' }),
exports._waitForDnsPropagation.bind(null, app),
updateApp.bind(null, app, { installationProgress: '90, Configuring reverse proxy' }),
configureReverseProxy.bind(null, app),
// done!
function (callback) {
debugApp(app, 'configured');
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback);
}
], function seriesDone(error) {
if (error) {
debugApp(app, 'error reconfiguring : %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
// nginx configuration is skipped because app.httpPort is expected to be available
function update(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2017-11-16 12:36:07 -08:00
debugApp(app, `Updating to ${app.updateConfig.manifest.version}`);
// app does not want these addons anymore
// FIXME: this does not handle option changes (like multipleDatabases)
2017-11-16 12:36:07 -08:00
var unusedAddons = _.omit(app.manifest.addons, Object.keys(app.updateConfig.manifest.addons));
const FORCED_UPDATE = (app.installationState === appdb.ISTATE_PENDING_FORCE_UPDATE);
async.series([
// this protects against the theoretical possibility of an app being marked for update from
// a previous version of box code
updateApp.bind(null, app, { installationProgress: '0, Verify manifest' }),
2017-11-16 12:36:07 -08:00
verifyManifest.bind(null, app.updateConfig.manifest),
function (next) {
if (FORCED_UPDATE) return next(null);
async.series([
updateApp.bind(null, app, { installationProgress: '15, Backing up app' }),
// preserve update backups for 3 weeks
backups.backupApp.bind(null, app, { preserveSecs: 3*7*24*60*60 }, (progress) => updateApp(app, { installationProgress: `15, Backup - ${progress.message}` }, NOOP_CALLBACK))
], 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
updateApp.bind(null, app, { installationProgress: '25, Downloading image' }),
downloadImage.bind(null, app.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
updateApp.bind(null, app, { installationProgress: '35, Cleaning up old install' }),
removeCollectdProfile.bind(null, app),
removeLogrotateConfig.bind(null, app),
stopApp.bind(null, app),
deleteContainers.bind(null, app, { managedOnly: true }),
function deleteImageIfChanged(done) {
2017-11-16 12:36:07 -08:00
if (app.manifest.dockerImage === app.updateConfig.manifest.dockerImage) return done();
docker.deleteImage(app.manifest, done);
2015-07-20 10:09:02 -07:00
},
2016-06-13 23:07:41 -07:00
// only delete unused addons after backup
addons.teardownAddons.bind(null, app, unusedAddons),
// free unused ports
function (next) {
const currentPorts = app.portBindings || {};
const newTcpPorts = app.updateConfig.manifest.tcpPorts || {};
const newUdpPorts = app.updateConfig.manifest.udpPorts || {};
async.each(Object.keys(currentPorts), function (portName, callback) {
if (newTcpPorts[portName] || newUdpPorts[portName]) return callback(); // port still in use
appdb.delPortBinding(currentPorts[portName], apps.PORT_TYPE_TCP, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) console.error('Portbinding does not exist in database.');
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();
});
}, next);
},
// switch over to the new config. manifest, memoryLimit, portBindings, appstoreId are updated here
2017-11-16 12:36:07 -08:00
updateApp.bind(null, app, app.updateConfig),
updateApp.bind(null, app, { installationProgress: '45, Downloading icon' }),
downloadIcon.bind(null, app),
updateApp.bind(null, app, { installationProgress: '70, Updating addons' }),
addons.setupAddons.bind(null, app, app.updateConfig.manifest.addons),
updateApp.bind(null, app, { installationProgress: '80, Creating container' }),
createContainer.bind(null, app),
updateApp.bind(null, app, { installationProgress: '85, Setting up logrotate config' }),
addLogrotateConfig.bind(null, app),
updateApp.bind(null, app, { installationProgress: '90, Add collectd profile' }),
addCollectdProfile.bind(null, app),
runApp.bind(null, app),
// done!
function (callback) {
debugApp(app, 'updated');
2017-11-19 16:11:51 -08:00
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null, updateConfig: null, updateTime: new Date() }, callback);
}
], function seriesDone(error) {
if (error && error.backupError) {
debugApp(app, 'update aborted because backup failed', error);
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null, updateConfig: null }, callback.bind(null, error));
} else if (error) {
debugApp(app, 'Error updating app: %s', error);
updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message, updateTime: new Date() }, callback.bind(null, error));
} else {
// do not spam the notifcation view
if (FORCED_UPDATE) return callback();
eventlog.add(eventlog.ACTION_APP_UPDATE_FINISH, auditsource.APP_TASK, { app: app, success: true }, callback);
}
});
}
function uninstall(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
debugApp(app, 'uninstalling');
async.series([
updateApp.bind(null, app, { installationProgress: '0, Remove collectd profile' }),
removeCollectdProfile.bind(null, app),
updateApp.bind(null, app, { installationProgress: '5, Remove logrotate config' }),
removeLogrotateConfig.bind(null, app),
updateApp.bind(null, app, { installationProgress: '10, Stopping app' }),
stopApp.bind(null, app),
updateApp.bind(null, app, { installationProgress: '20, Deleting container' }),
deleteContainers.bind(null, app, {}),
updateApp.bind(null, app, { installationProgress: '30, Teardown addons' }),
addons.teardownAddons.bind(null, app, app.manifest.addons),
updateApp.bind(null, app, { installationProgress: '40, Deleting app data directory' }),
deleteAppDir.bind(null, app, { removeDirectory: true }),
updateApp.bind(null, app, { installationProgress: '50, Deleting image' }),
2015-10-19 15:39:26 -07:00
docker.deleteImage.bind(null, app.manifest),
updateApp.bind(null, app, { installationProgress: '60, Unregistering domains' }),
unregisterAlternateDomains.bind(null, app, true /* all */),
unregisterSubdomain.bind(null, app, app.location, app.domain),
2018-06-04 11:45:22 +02:00
updateApp.bind(null, app, { installationProgress: '70, Cleanup icon' }),
removeIcon.bind(null, app),
2018-06-04 11:45:22 +02:00
updateApp.bind(null, app, { installationProgress: '80, Unconfiguring reverse proxy' }),
unconfigureReverseProxy.bind(null, app),
2018-06-04 11:45:22 +02:00
updateApp.bind(null, app, { installationProgress: '90, Cleanup logs' }),
cleanupLogs.bind(null, app),
updateApp.bind(null, app, { installationProgress: '95, Remove app from database' }),
appdb.del.bind(null, app.id)
], function seriesDone(error) {
if (error) {
debugApp(app, 'error uninstalling app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
function runApp(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2015-10-19 15:39:26 -07:00
docker.startContainer(app.containerId, function (error) {
if (error) return callback(error);
updateApp(app, { runState: appdb.RSTATE_RUNNING }, callback);
});
}
function stopApp(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
2015-10-20 00:05:07 -07:00
docker.stopContainers(app.id, function (error) {
if (error) return callback(error);
2015-11-30 15:41:56 -08:00
updateApp(app, { runState: appdb.RSTATE_STOPPED, health: null }, callback);
});
}
function handleRunCommand(app, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof callback, 'function');
if (app.runState === appdb.RSTATE_PENDING_STOP) {
return stopApp(app, callback);
}
if (app.runState === appdb.RSTATE_PENDING_START || app.runState === appdb.RSTATE_RUNNING) {
debugApp(app, 'Resuming app with state : %s', app.runState);
return runApp(app, callback);
}
debugApp(app, 'handleRunCommand - doing nothing: %s', app.runState);
return callback(null);
}
function startTask(appId, callback) {
2016-01-05 12:14:39 +01:00
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof callback, 'function');
// determine what to do
2018-01-09 21:03:59 -08:00
apps.get(appId, function (error, app) {
if (error) return callback(error);
debugApp(app, 'startTask installationState: %s runState: %s', app.installationState, app.runState);
2019-08-26 15:55:57 -07:00
switch (app.installationState) {
case appdb.ISTATE_PENDING_UNINSTALL: return uninstall(app, callback);
case appdb.ISTATE_PENDING_CONFIGURE: return configure(app, callback);
case appdb.ISTATE_PENDING_UPDATE: return update(app, callback);
case appdb.ISTATE_PENDING_FORCE_UPDATE: return update(app, callback);
case appdb.ISTATE_PENDING_INSTALL: return install(app, callback);
case appdb.ISTATE_PENDING_CLONE: return install(app, callback);
case appdb.ISTATE_PENDING_RESTORE: return install(app, callback);
case appdb.ISTATE_PENDING_BACKUP: return backup(app, callback);
case appdb.ISTATE_INSTALLED: return handleRunCommand(app, callback);
case appdb.ISTATE_ERROR:
debugApp(app, 'Internal error. apptask launched with error status.');
return callback(null);
default:
debugApp(app, 'apptask launched with invalid command');
return callback(new Error('Unknown command in apptask:' + app.installationState));
}
});
}
function run(appId, progressCallback, callback) {
assert.strictEqual(typeof appId, 'string');
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: %s runState: %s', app.installationState, app.runState);
switch (app.installationState) {
2019-08-26 20:43:15 -07:00
case appdb.ISTATE_PENDING_INSTALL: return install(app, progressCallback, callback);
default:
debugApp(app, 'apptask launched with invalid command');
return callback(new Error('Unknown command in apptask:' + app.installationState));
}
});
}
if (require.main === module) {
assert.strictEqual(process.argv.length, 3, 'Pass the appid as argument');
// add a separator for the log file
debug('------------------------------------------------------------');
debug('Apptask for %s', process.argv[2]);
process.on('SIGTERM', function () {
debug('taskmanager sent SIGTERM since it got a new task for this app');
process.exit(0);
});
initialize(function (error) {
if (error) throw error;
startTask(process.argv[2], function (error) {
2015-10-28 16:08:12 -07:00
if (error) debug('Apptask completed with error', error);
debug('Apptask completed for %s', process.argv[2]);
// https://nodejs.org/api/process.html are exit codes used by node. apps.js uses the value below
// to check apptask crashes
process.exit(error ? 50 : 0);
});
});
}