991 lines
36 KiB
JavaScript
991 lines
36 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/* jslint node:true */
|
|
|
|
'use strict';
|
|
|
|
require('supererror')({ splatchError: true });
|
|
|
|
var addons = require('./addons.js'),
|
|
appdb = require('./appdb.js'),
|
|
tokendb = require('./tokendb.js'),
|
|
apps = require('./apps.js'),
|
|
assert = require('assert'),
|
|
async = require('async'),
|
|
clientdb = require('./clientdb.js'),
|
|
child_process = require('child_process'),
|
|
cloudron = require('./cloudron.js'),
|
|
config = require('../config.js'),
|
|
constants = require('../constants.js'),
|
|
database = require('./database.js'),
|
|
DatabaseError = require('./databaseerror.js'),
|
|
debug = require('debug')('box:apptask'),
|
|
dns = require('native-dns'),
|
|
docker = require('./docker.js'),
|
|
ejs = require('ejs'),
|
|
fs = require('fs'),
|
|
hat = require('hat'),
|
|
manifestFormat = require('manifestformat'),
|
|
net = require('net'),
|
|
os = require('os'),
|
|
path = require('path'),
|
|
paths = require('./paths.js'),
|
|
safe = require('safetydance'),
|
|
settings = require('./settings.js'),
|
|
superagent = require('superagent'),
|
|
util = require('util'),
|
|
uuid = require('node-uuid'),
|
|
vbox = require('./vbox.js');
|
|
|
|
exports = module.exports = {
|
|
initialize: initialize,
|
|
startTask: startTask,
|
|
writeNginxNakedDomainConfig: writeNginxNakedDomainConfig,
|
|
|
|
// exported for testing
|
|
_getFreePort: getFreePort,
|
|
_configureNginx: configureNginx,
|
|
_unconfigureNginx: unconfigureNginx,
|
|
_createVolume: createVolume,
|
|
_deleteVolume: deleteVolume,
|
|
_allocateOAuthProxyCredentials: allocateOAuthProxyCredentials,
|
|
_removeOAuthProxyCredentials: removeOAuthProxyCredentials,
|
|
_allocateAccessToken: allocateAccessToken,
|
|
_removeAccessToken: removeAccessToken,
|
|
_verifyManifest: verifyManifest,
|
|
_registerSubdomain: registerSubdomain,
|
|
_unregisterSubdomain: unregisterSubdomain,
|
|
_reloadNginx: reloadNginx,
|
|
_waitForDnsPropagation: waitForDnsPropagation
|
|
};
|
|
|
|
var NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/../setup/start/nginx/appconfig.ejs', { encoding: 'utf8' }),
|
|
COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd.config.ejs', { encoding: 'utf8' }),
|
|
SUDO = '/usr/bin/sudo',
|
|
RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh'),
|
|
RELOAD_COLLECTD_CMD = path.join(__dirname, 'scripts/reloadcollectd.sh'),
|
|
RMAPPDIR_CMD = path.join(__dirname, 'scripts/rmappdir.sh'),
|
|
CREATEAPPDIR_CMD = path.join(__dirname, 'scripts/createappdir.sh');
|
|
|
|
function initialize(callback) {
|
|
database.initialize(callback);
|
|
}
|
|
|
|
// We expect conflicts to not happen despite closing the port (parallel app installs, app update does not reconfigure nginx etc)
|
|
// https://tools.ietf.org/html/rfc6056#section-3.5 says linux uses random ephemeral port allocation
|
|
function getFreePort(callback) {
|
|
var server = net.createServer();
|
|
server.listen(0, function () {
|
|
var port = server.address().port;
|
|
server.close(function () {
|
|
return callback(null, port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function execFile(tag, file, args, callback) {
|
|
assert(typeof tag === 'string');
|
|
assert(typeof file === 'string');
|
|
assert(util.isArray(args));
|
|
assert(typeof callback === 'function');
|
|
|
|
var options = { timeout: 0, encoding: 'utf8' };
|
|
|
|
child_process.execFile(file, args, options, function (error, stdout, stderr) {
|
|
debug(tag + ' execFile: %s %s', file, args.join(' '));
|
|
debug(tag + ' (stdout): %s', stdout.toString('utf8'));
|
|
debug(tag + ' (stderr): %s', stderr.toString('utf8'));
|
|
|
|
if (error) debug(tag + ' code: %s, signal: %s', error.code, error.signal);
|
|
|
|
callback(error);
|
|
});
|
|
}
|
|
|
|
function reloadNginx(callback) {
|
|
execFile('reloadNginx', SUDO, [ RELOAD_NGINX_CMD ], callback);
|
|
}
|
|
|
|
function configureNginx(app, callback) {
|
|
getFreePort(function (error, freePort) {
|
|
if (error) return callback(error);
|
|
|
|
var sourceDir = path.resolve(__dirname, '..');
|
|
var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, { sourceDir: sourceDir, vhost: config.appFqdn(app.location), isAdmin: false, port: freePort, accessRestriction: app.accessRestriction });
|
|
|
|
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf');
|
|
debug('writing config to ' + nginxConfigFilename);
|
|
|
|
if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) {
|
|
console.error('Error creating nginx config ' + safe.error);
|
|
return callback(safe.error);
|
|
}
|
|
|
|
async.series([
|
|
exports._reloadNginx,
|
|
updateApp.bind(null, app, { httpPort: freePort })
|
|
], callback);
|
|
|
|
vbox.forwardFromHostToVirtualBox(app.id + '-http', freePort);
|
|
});
|
|
}
|
|
|
|
function unconfigureNginx(app, callback) {
|
|
var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf');
|
|
if (!safe.fs.unlinkSync(nginxConfigFilename)) {
|
|
console.error('Error removing nginx configuration ' + safe.error);
|
|
return callback(null);
|
|
}
|
|
|
|
exports._reloadNginx(callback);
|
|
|
|
vbox.unforwardFromHostToVirtualBox(app.id + '-http');
|
|
}
|
|
|
|
function writeNginxNakedDomainConfig(app, callback) {
|
|
assert(app === null || typeof app === 'object');
|
|
assert(typeof callback === 'function');
|
|
|
|
var sourceDir = path.resolve(__dirname, '..');
|
|
var nginxConf;
|
|
if (app === null) { // admin
|
|
nginxConf = ejs.render(NGINX_APPCONFIG_EJS, { sourceDir: sourceDir, vhost: config.fqdn(), isAdmin: true });
|
|
} else {
|
|
nginxConf = ejs.render(NGINX_APPCONFIG_EJS, { sourceDir: sourceDir, vhost: config.fqdn(), isAdmin: false, port: app.httpPort, accessRestriction: app.accessRestriction });
|
|
}
|
|
|
|
var nginxNakedDomainFilename = path.join(paths.NGINX_CONFIG_DIR, 'naked_domain.conf');
|
|
debug('writing naked domain config to ' + nginxNakedDomainFilename);
|
|
|
|
fs.writeFile(nginxNakedDomainFilename, nginxConf, function (error) {
|
|
if (error) return callback(error);
|
|
|
|
exports._reloadNginx(callback);
|
|
});
|
|
}
|
|
|
|
function configureNakedDomain(app, callback) {
|
|
assert(typeof app === 'object');
|
|
assert(typeof callback === 'function');
|
|
|
|
settings.getNakedDomain(function (error, nakedDomainAppId) {
|
|
if (error) return callback(error);
|
|
|
|
if (nakedDomainAppId !== app.id) return callback(null);
|
|
|
|
debug('configureNakedDomain: writing nginx config for %s', app.id);
|
|
|
|
writeNginxNakedDomainConfig(app, callback);
|
|
});
|
|
}
|
|
|
|
function unconfigureNakedDomain(app, callback) {
|
|
assert(typeof app === 'object');
|
|
assert(typeof callback === 'function');
|
|
|
|
settings.getNakedDomain(function (error, nakedDomainAppId) {
|
|
if (error) return callback(error);
|
|
|
|
if (nakedDomainAppId !== app.id) return callback(null);
|
|
|
|
debug('unconfigureNakedDomain: resetting to admin');
|
|
|
|
settings.setNakedDomain(constants.ADMIN_APPID, callback);
|
|
});
|
|
}
|
|
|
|
function downloadImage(app, callback) {
|
|
debug('Will download app now');
|
|
|
|
var manifest = app.manifest;
|
|
|
|
docker.pull(manifest.dockerImage, function (err, stream) {
|
|
if (err) return callback(new Error('Error connecting to docker'));
|
|
|
|
// https://github.com/dotcloud/docker/issues/1074 says each status message
|
|
// is emitted as a chunk
|
|
stream.on('data', function (chunk) {
|
|
var data = safe.JSON.parse(chunk) || { };
|
|
debug('downloadImage:', JSON.stringify(data));
|
|
|
|
// The information here is useless because this is per layer as opposed to per image
|
|
if (data.status) {
|
|
debug('Progress: ' + data.status); // progressDetail { current, total }
|
|
} else if (data.error) {
|
|
console.error('Error detail:' + data.errorDetail.message);
|
|
}
|
|
});
|
|
|
|
stream.on('end', function () {
|
|
debug('pulled successfully');
|
|
|
|
var image = docker.getImage(manifest.dockerImage);
|
|
|
|
image.inspect(function (err, data) {
|
|
if (err) {
|
|
return callback(new Error('Error inspecting image:' + err.message));
|
|
}
|
|
|
|
if (!data || !data.Config) {
|
|
return callback(new Error('Missing Config in image:' + JSON.stringify(data, null, 4)));
|
|
}
|
|
|
|
if (!data.Config.Entrypoint && !data.Config.Cmd) {
|
|
return callback(new Error('Only images with entry point are allowed'));
|
|
}
|
|
|
|
debug('This image exposes ports: ' + JSON.stringify(data.Config.ExposedPorts));
|
|
return callback(null);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function createContainer(app, callback) {
|
|
appdb.getPortBindings(app.id, function (error, portBindings) {
|
|
if (error) return callback(error);
|
|
|
|
var manifest = app.manifest;
|
|
var exposedPorts = {};
|
|
var env = [];
|
|
|
|
// docker portBindings requires ports to be exposed
|
|
exposedPorts[manifest.httpPort + '/tcp'] = {};
|
|
|
|
for (var e in portBindings) {
|
|
var hostPort = portBindings[e];
|
|
var containerPort = manifest.tcpPorts[e].containerPort || hostPort;
|
|
exposedPorts[containerPort + '/tcp'] = {};
|
|
|
|
env.push(e + '=' + hostPort);
|
|
}
|
|
|
|
env.push('CLOUDRON=1');
|
|
env.push('ADMIN_ORIGIN' + '=' + config.adminOrigin());
|
|
|
|
tokendb.getByIdentifier(tokendb.PREFIX_APP + app.id, function (error, results) {
|
|
if (error || results.length === 0) return callback(new Error('No access token found: ' + error));
|
|
|
|
env.push('CLOUDRON_TOKEN' + '=' + results[0].accessToken);
|
|
|
|
addons.getEnvironment(app.id, function (error, addonEnv) {
|
|
if (error) return callback(new Error('Error getting addon env: ' + error));
|
|
|
|
var containerOptions = {
|
|
name: app.id,
|
|
Hostname: config.appFqdn(app.location),
|
|
Tty: true,
|
|
Image: manifest.dockerImage,
|
|
Cmd: null,
|
|
Volumes: { },
|
|
VolumesFrom: '',
|
|
Env: env.concat(addonEnv),
|
|
ExposedPorts: exposedPorts
|
|
};
|
|
|
|
debug('Creating container for %s', manifest.dockerImage);
|
|
|
|
docker.createContainer(containerOptions, function (error, container) {
|
|
if (error) return callback(new Error('Error creating container: ' + error));
|
|
|
|
updateApp(app, { containerId: container.id }, callback);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function deleteContainer(app, callback) {
|
|
if (app.containerId === null) return callback(null);
|
|
|
|
var container = docker.getContainer(app.containerId);
|
|
|
|
var removeOptions = {
|
|
force: true, // kill container if it's running
|
|
v: false // removes volumes associated with the container
|
|
};
|
|
|
|
container.remove(removeOptions, function (error) {
|
|
if (error && error.statusCode === 404) return updateApp(app, { containerId: null }, callback);
|
|
|
|
if (error) console.error('Error removing container', error);
|
|
callback(error);
|
|
});
|
|
}
|
|
|
|
function deleteImage(app, callback) {
|
|
var docker_image = app.manifest ? app.manifest.dockerImage : '';
|
|
var image = docker.getImage(docker_image);
|
|
|
|
var removeOptions = {
|
|
force: true,
|
|
noprune: false
|
|
};
|
|
|
|
image.remove(removeOptions, function (error) {
|
|
if (error && error.statusCode === 404) return callback(null);
|
|
if (error && error.statusCode === 409) return callback(null); // another container using the image
|
|
|
|
if (error) console.error('Error removing image', error);
|
|
callback(error);
|
|
});
|
|
}
|
|
|
|
function createVolume(app, callback) {
|
|
execFile('createVolume', SUDO, [ CREATEAPPDIR_CMD, app.id ], callback);
|
|
}
|
|
|
|
function deleteVolume(app, callback) {
|
|
execFile('deleteVolume', SUDO, [ RMAPPDIR_CMD, app.id ], callback);
|
|
}
|
|
|
|
function allocateOAuthProxyCredentials(app, callback) {
|
|
assert(typeof app === 'object');
|
|
assert(typeof callback === 'function');
|
|
|
|
if (!app.accessRestriction) return callback(null);
|
|
|
|
var appId = 'proxy-' + app.id;
|
|
var id = 'cid-proxy-' + uuid.v4();
|
|
var clientSecret = hat();
|
|
var redirectURI = 'https://' + config.appFqdn(app.location);
|
|
var scope = 'profile,' + app.accessRestriction;
|
|
|
|
clientdb.add(id, appId, clientSecret, redirectURI, scope, callback);
|
|
}
|
|
|
|
function removeOAuthProxyCredentials(app, callback) {
|
|
assert(typeof app === 'object');
|
|
assert(typeof callback === 'function');
|
|
|
|
clientdb.delByAppId('proxy-' + app.id, function (error) {
|
|
if (error && error.reason !== DatabaseError.NOT_FOUND) {
|
|
console.error('Error removing OAuth client id', error);
|
|
return callback(error);
|
|
}
|
|
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
function allocateAccessToken(app, callback) {
|
|
assert(typeof app === 'object');
|
|
assert(typeof callback === 'function');
|
|
|
|
var token = tokendb.generateToken();
|
|
var expiresAt = Number.MAX_SAFE_INTEGER; // basically never expire
|
|
var scopes = 'profile,users'; // TODO This should be put into the manifest and the user should know those
|
|
var clientId = ''; // meaningless for apps so far
|
|
|
|
tokendb.add(token, tokendb.PREFIX_APP + app.id, clientId, expiresAt, scopes, callback);
|
|
}
|
|
|
|
function removeAccessToken(app, callback) {
|
|
assert(typeof app === 'object');
|
|
assert(typeof callback === 'function');
|
|
|
|
tokendb.delByIdentifier(tokendb.PREFIX_APP + app.id, function (error) {
|
|
if (error && error.reason !== DatabaseError.NOT_FOUND) {
|
|
console.error('Error removing access token', error);
|
|
return callback(error);
|
|
}
|
|
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
function addCollectdProfile(app, callback) {
|
|
var collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { appId: app.id, containerId: app.containerId });
|
|
fs.writeFile(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), collectdConf, function (error) {
|
|
if (error) return callback(error);
|
|
execFile('addCollectdProfile', SUDO, [ RELOAD_COLLECTD_CMD ], callback);
|
|
});
|
|
}
|
|
|
|
function removeCollectdProfile(app, callback) {
|
|
fs.unlink(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), function (error, stdout, stderr) {
|
|
if (error) console.error('Error removing collectd profile', error, stdout, stderr);
|
|
execFile('removeCollectdProfile', SUDO, [ RELOAD_COLLECTD_CMD ], callback);
|
|
});
|
|
}
|
|
|
|
function startContainer(app, callback) {
|
|
appdb.getPortBindings(app.id, function (error, portBindings) {
|
|
if (error) return callback(error);
|
|
|
|
var manifest = app.manifest;
|
|
var appDataDir = path.join(paths.DATA_DIR, app.id, 'data');
|
|
|
|
var dockerPortBindings = { };
|
|
var isMac = os.platform() === 'darwin';
|
|
|
|
// On Mac (boot2docker), we have to export the port to external world for port forwarding from Mac to work
|
|
dockerPortBindings[manifest.httpPort + '/tcp'] = [ { HostIp: isMac ? '0.0.0.0' : '127.0.0.1', HostPort: app.httpPort + '' } ];
|
|
|
|
for (var env in portBindings) {
|
|
var hostPort = portBindings[env];
|
|
var containerPort = manifest.tcpPorts[env].containerPort || hostPort;
|
|
dockerPortBindings[containerPort + '/tcp'] = [ { HostIp: '0.0.0.0', HostPort: hostPort + '' } ];
|
|
vbox.forwardFromHostToVirtualBox(app.id + '-tcp' + containerPort, hostPort);
|
|
}
|
|
|
|
var startOptions = {
|
|
Binds: [ appDataDir + ':/app/data:rw' ],
|
|
PortBindings: dockerPortBindings,
|
|
PublishAllPorts: false,
|
|
Links: addons.getLinksSync(app),
|
|
RestartPolicy: {
|
|
"Name": "on-failure",
|
|
"MaximumRetryCount": 100
|
|
}
|
|
};
|
|
|
|
var container = docker.getContainer(app.containerId);
|
|
debug('Starting container ' + container.id + ' with options: ' + JSON.stringify(startOptions));
|
|
|
|
container.start(startOptions, function (error, data) {
|
|
if (error && error.statusCode !== 304) return callback(new Error('Error starting container:' + error));
|
|
|
|
return callback(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopContainer(app, callback) {
|
|
var container = docker.getContainer(app.containerId);
|
|
debug('Stopping container ' + container.id);
|
|
|
|
var options = {
|
|
t: 10 // wait for 10 seconds before killing it
|
|
};
|
|
|
|
container.stop(options, function (error) {
|
|
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error stopping container:' + error));
|
|
|
|
var tcpPorts = safe.query(app, 'manifest.tcpPorts', { });
|
|
for (var containerPort in tcpPorts) {
|
|
vbox.unforwardFromHostToVirtualBox(app.id + '-tcp' + containerPort);
|
|
}
|
|
|
|
debug('Waiting for container ' + container.id);
|
|
|
|
container.wait(function (error, data) {
|
|
if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error waiting on container:' + error));
|
|
|
|
debug('Container stopped with status code [%s]', data ? String(data.StatusCode) : '');
|
|
|
|
return callback(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
function verifyManifest(app, callback) {
|
|
debug('Verifying manifest for :', app.id);
|
|
|
|
var manifest = app.manifest;
|
|
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) {
|
|
debug('Downloading icon of %s@%s', app.appStoreId, app.manifest.version);
|
|
|
|
var iconUrl = config.apiServerOrigin() + '/api/v1/apps/' + app.appStoreId + '/versions/' + app.manifest.version + '/icon';
|
|
|
|
superagent
|
|
.get(iconUrl)
|
|
.buffer(true)
|
|
.end(function (error, res) {
|
|
if (error) return callback(new Error('Error downloading icon:' + error.message));
|
|
|
|
if (!safe.fs.writeFileSync(path.join(paths.APPICONS_DIR, app.id + '.png'), res.body)) return callback(new Error('Error saving icon:' + safe.error.message));
|
|
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
function registerSubdomain(app, callback) {
|
|
if (config.LOCAL) {
|
|
debug('Skipping subdomain registration for development');
|
|
return callback(null);
|
|
}
|
|
|
|
debug('Registering subdomain for ' + app.id + ' at ' + app.location);
|
|
|
|
var record = { subdomain: app.location, type: 'A', value: cloudron.getIp() };
|
|
|
|
superagent
|
|
.post(config.apiServerOrigin() + '/api/v1/subdomains')
|
|
.set('Accept', 'application/json')
|
|
.query({ token: config.token() })
|
|
.send({ records: [ record ] })
|
|
.end(function (error, res) {
|
|
if (error) return callback(error);
|
|
|
|
debug('Registered subdomain for ' + app.id + ' ' + res.status);
|
|
|
|
if (res.status === 409) return callback(null); // already registered
|
|
if (res.status !== 201) return callback(new Error(util.format('Subdomain Registration failed. %s %j', res.status, res.body)));
|
|
|
|
updateApp(app, { dnsRecordId: res.body.ids[0] }, callback);
|
|
});
|
|
}
|
|
|
|
function unregisterSubdomain(app, callback) {
|
|
if (config.LOCAL) {
|
|
debug('Skipping subdomain unregistration for development');
|
|
return callback(null);
|
|
}
|
|
|
|
debug('Unregistering subdomain for ' + app.id + ' at ' + app.location);
|
|
superagent
|
|
.del(config.apiServerOrigin() + '/api/v1/subdomains/' + app.dnsRecordId)
|
|
.query({ token: config.token() })
|
|
.end(function (error, res) {
|
|
if (error) {
|
|
console.error('Error making request: ', error);
|
|
} else if (res.status !== 204) {
|
|
console.error('Error unregistering subdomain:', res.status, res.body);
|
|
}
|
|
|
|
updateApp(app, { dnsRecordId: null }, function (error) {
|
|
if (error) console.error(error);
|
|
callback(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
function removeIcon(app, callback) {
|
|
fs.unlink(path.join(paths.APPICONS_DIR, app.id + '.png'), function (error) {
|
|
if (error && error.code !== 'ENOENT') console.error(error);
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
function waitForDnsPropagation(app, callback) {
|
|
if (!config.CLOUDRON) {
|
|
debug('Skipping dns propagation check for development');
|
|
return callback(null);
|
|
}
|
|
|
|
function retry(error) {
|
|
console.log(error);
|
|
setTimeout(waitForDnsPropagation.bind(null, app, callback), 5000);
|
|
}
|
|
|
|
superagent
|
|
.get(config.apiServerOrigin() + '/api/v1/subdomains/' + app.dnsRecordId + '/status')
|
|
.set('Accept', 'application/json')
|
|
.query({ token: config.token() })
|
|
.end(function (error, res) {
|
|
if (error) return retry(new Error('Failed to get dns record status : ' + error.message));
|
|
|
|
debug('waitForDnsPropagation: app:%s dnsRecordId:%s status:%s', app.id, app.dnsRecordId, res.status);
|
|
|
|
if (res.status !== 200) return retry(new Error(util.format('Error getting record status: %s %j', res.status, res.body)));
|
|
|
|
if (res.body.status !== 'done') return retry(new Error(util.format('app:%s not ready yet: %s', app.id, res.body.status)));
|
|
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
// updates the app object and the database
|
|
function updateApp(app, values, callback) {
|
|
for (var value in values) {
|
|
app[value] = values[value];
|
|
}
|
|
|
|
debug(app.id + ' installationState:' + app.installationState + ' progress: ' + app.installationProgress);
|
|
|
|
appdb.update(app.id, values, callback);
|
|
}
|
|
|
|
function install(app, callback) {
|
|
async.series([
|
|
// configure nginx
|
|
configureNginx.bind(null, app),
|
|
|
|
// register subdomain
|
|
updateApp.bind(null, app, { installationProgress: '0, Registering subdomain' }),
|
|
registerSubdomain.bind(null, app),
|
|
|
|
// verify manifest
|
|
updateApp.bind(null, app, { installationProgress: '10, Verifying manifest' }),
|
|
verifyManifest.bind(null, app),
|
|
downloadIcon.bind(null, app),
|
|
|
|
// create proxy OAuth credentials
|
|
updateApp.bind(null, app, { installationProgress: '20, Creating OAuth proxy credentials' }),
|
|
allocateOAuthProxyCredentials.bind(null, app),
|
|
|
|
// allocate access token
|
|
updateApp.bind(null, app, { installationProgress: '30, Allocate access token' }),
|
|
allocateAccessToken.bind(null, app),
|
|
|
|
// download the image
|
|
updateApp.bind(null, app, { installationProgress: '40, Downloading image' }),
|
|
downloadImage.bind(null, app),
|
|
|
|
// recreate data volume
|
|
updateApp.bind(null, app, { installationProgress: '50, Creating volume' }),
|
|
deleteVolume.bind(null, app),
|
|
createVolume.bind(null, app),
|
|
|
|
// setup addons
|
|
updateApp.bind(null, app, { installationProgress: '60, Setting up addons' }),
|
|
addons.teardownAddons.bind(null, app),
|
|
addons.setupAddons.bind(null, app),
|
|
|
|
// recreate container
|
|
updateApp.bind(null, app, { installationProgress: '70, Creating container' }),
|
|
deleteContainer.bind(null, app),
|
|
createContainer.bind(null, app),
|
|
|
|
// add collectd profile
|
|
updateApp.bind(null, app, { installationProgress: '80, Setting up collectd profile' }),
|
|
addCollectdProfile.bind(null, app),
|
|
|
|
// wait until dns propagated
|
|
updateApp.bind(null, app, { installationProgress: '90, Waiting for DNS propagation' }),
|
|
exports._waitForDnsPropagation.bind(null, app),
|
|
|
|
// done!
|
|
function (callback) {
|
|
debug('App ' + app.id + ' installed');
|
|
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '' }, callback);
|
|
}
|
|
], function seriesDone(error) {
|
|
if (error) {
|
|
console.error('Error installing app:', error);
|
|
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
|
|
}
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
function restore(app, callback) {
|
|
var oldManifest = app.manifest; // TODO: this won't be correct all the time should we crash after download manifest
|
|
|
|
async.series([
|
|
// configure nginx
|
|
updateApp.bind(null, app, { installationProgress: '0, Configuring nginx' }),
|
|
configureNginx.bind(null, app),
|
|
configureNakedDomain.bind(null, app),
|
|
|
|
// register subdomain
|
|
updateApp.bind(null, app, { installationProgress: '5, Registering subdomain' }),
|
|
registerSubdomain.bind(null, app),
|
|
|
|
// verify manifest
|
|
updateApp.bind(null, app, { installationProgress: '10, Verify manifest' }),
|
|
verifyManifest.bind(null, app),
|
|
downloadIcon.bind(null, app),
|
|
|
|
// setup oauth proxy
|
|
updateApp.bind(null, app, { installationProgress: '15, Setting up OAuth proxy credentials' }),
|
|
removeOAuthProxyCredentials.bind(null, app),
|
|
allocateOAuthProxyCredentials.bind(null, app),
|
|
|
|
// allocate access token
|
|
updateApp.bind(null, app, { installationProgress: '20, Allocate access token' }),
|
|
removeAccessToken.bind(null, app),
|
|
allocateAccessToken.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '25, Downloading last backup' }),
|
|
deleteVolume.bind(null, app),
|
|
createVolume.bind(null, app),
|
|
cloudron.restoreApp.bind(null, app),
|
|
|
|
// download the image
|
|
updateApp.bind(null, app, { installationProgress: '40, Downloading image' }),
|
|
downloadImage.bind(null, app),
|
|
|
|
// restore addons
|
|
updateApp.bind(null, app, { installationProgress: '60, Restoring addons' }),
|
|
addons.restoreAddons.bind(null, app),
|
|
|
|
// create container (old containers are deleted by update script)
|
|
updateApp.bind(null, app, { installationProgress: '70, Creating container' }),
|
|
deleteContainer.bind(null, app),
|
|
createContainer.bind(null, app),
|
|
|
|
// add collectd profile
|
|
updateApp.bind(null, app, { installationProgress: '80, Add collectd profile' }),
|
|
addCollectdProfile.bind(null, app),
|
|
|
|
// wait until dns propagated
|
|
updateApp.bind(null, app, { installationProgress: '90, Waiting for DNS propagation' }),
|
|
exports._waitForDnsPropagation.bind(null, app),
|
|
|
|
// done!
|
|
function (callback) {
|
|
debug('App ' + app.id + ' installed');
|
|
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '' }, callback);
|
|
}
|
|
], function seriesDone(error) {
|
|
if (error) {
|
|
console.error('Error installing app:', error);
|
|
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
|
|
}
|
|
|
|
postInstall(app, callback);
|
|
});
|
|
}
|
|
|
|
// TODO: optimize by checking if location actually changed
|
|
function configure(app, callback) {
|
|
async.series([
|
|
updateApp.bind(null, app, { installationProgress: '0, Stopping app' }),
|
|
stopApp.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '5, Deleting container' }),
|
|
deleteContainer.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '10, Unregistering subdomain' }),
|
|
unregisterSubdomain.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '15, Remove OAuth credentials' }),
|
|
removeOAuthProxyCredentials.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '20, Remove access token' }),
|
|
removeAccessToken.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '25, Configuring Nginx' }),
|
|
configureNginx.bind(null, app),
|
|
configureNakedDomain.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '30, Registering subdomain' }),
|
|
registerSubdomain.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '35, Create OAuth proxy credentials' }),
|
|
allocateOAuthProxyCredentials.bind(null, app),
|
|
|
|
// allocate access token
|
|
updateApp.bind(null, app, { installationProgress: '40, Allocate access token' }),
|
|
allocateAccessToken.bind(null, app),
|
|
|
|
// addons like oauth might rely on the app's fqdn
|
|
updateApp.bind(null, app, { installationProgress: '50, Setting up addons' }),
|
|
addons.setupAddons.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '60, Creating container' }),
|
|
createContainer.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '70, Add collectd profile' }),
|
|
addCollectdProfile.bind(null, app),
|
|
|
|
runApp.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '80, Waiting for DNS propagation' }),
|
|
exports._waitForDnsPropagation.bind(null, app),
|
|
|
|
// done!
|
|
function (callback) {
|
|
debug('App ' + app.id + ' installed');
|
|
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '' }, callback);
|
|
}
|
|
], function seriesDone(error) {
|
|
if (error) {
|
|
console.error('Error reconfiguring app:', app, error);
|
|
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
|
|
}
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
// nginx and naked domain configuration is skipped because app.httpPort is expected to be available
|
|
// TODO: old image should probably be deleted, but what if it's used by another app instance
|
|
function update(app, callback) {
|
|
var oldManifest = app.manifest; // TODO: this won't be correct all the time should we crash after download manifest
|
|
|
|
debug('Updating %s to %s', app.id, safe.query(app, 'manifest.version'));
|
|
|
|
async.series([
|
|
updateApp.bind(null, app, { installationProgress: '0, Stopping app' }),
|
|
stopApp.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '10, Deleting container' }),
|
|
deleteContainer.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '20, Backup and uploading app data' }),
|
|
cloudron.backupApp.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '35, Verify manifest' }),
|
|
verifyManifest.bind(null, app),
|
|
downloadIcon.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '45, Downloading image' }),
|
|
downloadImage.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '70, Updating addons' }),
|
|
addons.updateAddons.bind(null, app, oldManifest),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '80, Creating container' }),
|
|
createContainer.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '90, Add collectd profile' }),
|
|
addCollectdProfile.bind(null, app),
|
|
|
|
runApp.bind(null, app),
|
|
|
|
// done!
|
|
function (callback) {
|
|
debug('App ' + app.id + ' updated');
|
|
updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '' }, callback);
|
|
}
|
|
], function seriesDone(error) {
|
|
if (error) {
|
|
console.error('Error updating app:', error);
|
|
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
|
|
}
|
|
callback(null);
|
|
});
|
|
}
|
|
|
|
function uninstall(app, callback) {
|
|
debug('uninstalling ' + app.id);
|
|
|
|
// TODO: figure what happens if one of the steps fail
|
|
async.series([
|
|
// unset naked domain
|
|
function (callback) {
|
|
if (config.nakedDomain !== app.id) return callback(null);
|
|
|
|
config.set('nakedDomain', null);
|
|
callback(null);
|
|
},
|
|
|
|
updateApp.bind(null, app, { installationProgress: '0, Unconfiguring Nginx' }),
|
|
unconfigureNginx.bind(null, app),
|
|
unconfigureNakedDomain.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '10, Stopping app' }),
|
|
stopApp.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '20, Deleting container' }),
|
|
deleteContainer.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '30, Add collectd profile' }),
|
|
removeCollectdProfile.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '40, Deleting image' }),
|
|
deleteImage.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '50, Teardown addons' }),
|
|
addons.teardownAddons.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '60, Deleting volume' }),
|
|
deleteVolume.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '70, Unregistering subdomain' }),
|
|
unregisterSubdomain.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '80, Remove access token' }),
|
|
removeAccessToken.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '85, Remove OAuth credentials' }),
|
|
removeOAuthProxyCredentials.bind(null, app),
|
|
|
|
updateApp.bind(null, app, { installationProgress: '90, Cleanup manifest' }),
|
|
removeIcon.bind(null, app),
|
|
|
|
appdb.del.bind(null, app.id)
|
|
], callback);
|
|
}
|
|
|
|
function runApp(app, callback) {
|
|
startContainer(app, function (error) {
|
|
if (error) {
|
|
console.error('Error starting container.', error);
|
|
return updateApp(app, { runState: appdb.RSTATE_ERROR }, callback);
|
|
}
|
|
|
|
updateApp(app, { runState: appdb.RSTATE_RUNNING }, callback);
|
|
});
|
|
}
|
|
|
|
function stopApp(app, callback) {
|
|
stopContainer(app, function (error) {
|
|
if (error) return callback(error);
|
|
|
|
updateApp(app, { runState: appdb.RSTATE_STOPPED }, callback);
|
|
});
|
|
}
|
|
|
|
function postInstall(app, callback) {
|
|
if (app.runState === appdb.RSTATE_PENDING_STOP) {
|
|
return stopApp(app, callback);
|
|
}
|
|
|
|
if (app.runState === appdb.RSTATE_PENDING_START || app.runState === appdb.RSTATE_RUNNING) {
|
|
debug('Resuming app with state : %s %s', app.runState, app.id);
|
|
return runApp(app, callback);
|
|
}
|
|
|
|
debug('postInstall - doing nothing: %s %s', app.runState, app.id);
|
|
return callback(null);
|
|
}
|
|
|
|
function startTask(appId, callback) {
|
|
// determine what to do
|
|
appdb.get(appId, function (error, app) {
|
|
if (error) return callback(error);
|
|
|
|
debug('ISTATE:' + app.installationState + ' RSTATE:' + app.runState);
|
|
|
|
if (app.installationState === appdb.ISTATE_PENDING_UNINSTALL) {
|
|
return uninstall(app, callback);
|
|
}
|
|
|
|
if (app.installationState === appdb.ISTATE_PENDING_CONFIGURE) {
|
|
return configure(app, callback);
|
|
}
|
|
|
|
if (app.installationState === appdb.ISTATE_PENDING_UPDATE) {
|
|
return update(app, callback);
|
|
}
|
|
|
|
if (app.installationState === appdb.ISTATE_PENDING_RESTORE) {
|
|
return restore(app, callback);
|
|
}
|
|
|
|
if (app.installationState === appdb.ISTATE_INSTALLED) {
|
|
return postInstall(app, callback);
|
|
}
|
|
|
|
if (app.installationState === appdb.ISTATE_PENDING_INSTALL) {
|
|
install(app, function (error) {
|
|
if (error) return callback(error);
|
|
|
|
runApp(app, callback);
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.error('Apptask launched but nothing to do.', app);
|
|
return callback(null);
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
assert(process.argv.length === 3, 'Pass the appid as argument');
|
|
|
|
debug('Apptask for ' + process.argv[2]);
|
|
|
|
initialize(function (error) {
|
|
if (error) throw error;
|
|
|
|
startTask(process.argv[2], function (error) {
|
|
debug('Apptask completed for ' + process.argv[2], error);
|
|
process.exit(error ? 1 : 0);
|
|
});
|
|
});
|
|
}
|
|
|