Files
cloudron-box/src/apps.js
T
2015-02-28 19:31:43 +01:00

511 lines
18 KiB
JavaScript

/* jslint node:true */
'use strict';
var appdb = require('./appdb.js'),
assert = require('assert'),
child_process = require('child_process'),
config = require('../config.js'),
constants = require('../constants.js'),
DatabaseError = require('./databaseerror.js'),
debug = require('debug')('box:apps'),
docker = require('./docker.js'),
updater = require('./updater.js'),
fs = require('fs'),
paths = require('./paths.js'),
safe = require('safetydance'),
semver = require('semver'),
split = require('split'),
util = require('util');
exports = module.exports = {
AppsError: AppsError,
initialize: initialize,
uninitialize: uninitialize,
get: get,
getBySubdomain: getBySubdomain,
getAll: getAll,
install: install,
configure: configure,
uninstall: uninstall,
update: update,
getLogStream: getLogStream,
getLogs: getLogs,
start: start,
stop: stop,
validateManifest: validateManifest,
checkManifestConstraints: checkManifestConstraints,
// exported for testing
_validateHostname: validateHostname,
_validatePortBindings: validatePortBindings
};
var gTasks = { };
function initialize(callback) {
assert(typeof callback === 'function');
resume(callback); // TODO: potential race here since resume is async
}
function startTask(appId) {
assert(typeof appId === 'string');
assert(!(appId in gTasks));
gTasks[appId] = child_process.fork(__dirname + '/apptask.js', [ appId ]);
gTasks[appId].once('exit', function (code, signal) {
debug('Task completed :' + appId);
delete gTasks[appId];
});
}
function stopTask(appId) {
assert(typeof appId === 'string');
if (gTasks[appId]) {
debug('Killing existing task : ' + gTasks[appId].pid);
gTasks[appId].kill();
delete gTasks[appId];
}
}
// resume install and uninstalls
function resume(callback) {
assert(typeof callback === 'function');
appdb.getAll(function (error, apps) {
if (error) return callback(error);
apps.forEach(function (app) {
debug('Creating process for ' + app.id + ' with state ' + app.installationState);
startTask(app.id);
});
callback(null);
});
}
function uninitialize(callback) {
assert(typeof callback === 'function');
for (var appId in gTasks) {
stopTask(appId);
}
callback(null);
}
// http://dustinsenos.com/articles/customErrorsInNode
// http://code.google.com/p/v8/wiki/JavaScriptStackTraceApi
function AppsError(reason, errorOrMessage) {
assert(typeof reason === 'string');
assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined');
Error.call(this);
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.reason = reason;
if (typeof errorOrMessage === 'undefined') {
this.message = reason;
} else if (typeof errorOrMessage === 'string') {
this.message = errorOrMessage;
} else {
this.message = 'Internal error';
this.nestedError = errorOrMessage;
}
}
util.inherits(AppsError, Error);
AppsError.INTERNAL_ERROR = 'Internal Error';
AppsError.ALREADY_EXISTS = 'Already Exists';
AppsError.NOT_FOUND = 'Not Found';
AppsError.BAD_FIELD = 'Bad Field';
AppsError.BAD_STATE = 'Bad State';
// Hostname validation comes from RFC 1123 (section 2.1)
// Domain name validation comes from RFC 2181 (Name syntax)
// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
// We are validating the validity of the location-fqdn as host name
function validateHostname(location, fqdn) {
var RESERVED_LOCATIONS = [ constants.ADMIN_LOCATION ];
if (RESERVED_LOCATIONS.indexOf(location) !== -1) return new Error(location + ' is reserved');
if ((location.length + 1 + /* hyphen */ + fqdn.indexOf('.')) > 63) return new Error('Hostname length cannot be greater than 63');
if (location.match(/^[A-Za-z0-9-]+$/) === null) return new Error('Hostname can only contain alphanumerics and hyphen');
if (location[0] === '-' || location[location.length-1] === '-') return new Error('Hostname cannot start or end with hyphen');
if (location.length + 1 /* hyphen */ + fqdn.length > 253) return new Error('FQDN length exceeds 253 characters');
return null;
}
// validate the port bindings
function validatePortBindings(portBindings) {
// keep the public ports in sync with firewall rules in scripts/initializeBaseUbuntuImage.sh
var RESERVED_PORTS = [
22, /* ssh */
25, /* smtp */
53, /* dns */
80, /* http */
443, /* https */
2003, /* graphite (lo) */
2004, /* graphite (lo) */
2020, /* install server */
3000, /* app server (lo) */
3306, /* mysql (lo) */
8000 /* graphite (lo) */
];
for (var containerPort in portBindings) {
var containerPortInt = parseInt(containerPort, 10);
if (isNaN(containerPortInt) || containerPortInt <= 0 || containerPortInt > 65535) {
return new Error(containerPort + ' is not a valid port');
}
var hostPortInt = parseInt(portBindings[containerPort], 10);
if (isNaN(hostPortInt) || hostPortInt <= 1024 || hostPortInt > 65535) {
return new Error(portBindings[containerPort] + ' is not a valid port');
}
if (RESERVED_PORTS.indexOf(hostPortInt) !== -1) return new Error(hostPortInt + ' is reserved');
}
return null;
}
function getIconUrlSync(app) {
var iconPath = paths.APPICONS_DIR + '/' + app.id + '.png';
return fs.existsSync(iconPath) ? '/api/v1/apps/' + app.id + '/icon' : null;
}
function get(appId, callback) {
assert(typeof appId === 'string');
assert(typeof callback === 'function');
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
app.iconUrl = getIconUrlSync(app);
app.fqdn = config.appFqdn(app.location);
callback(null, app);
});
}
function getBySubdomain(subdomain, callback) {
assert(typeof subdomain === 'string');
assert(typeof callback === 'function');
appdb.getBySubdomain(subdomain, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
app.iconUrl = getIconUrlSync(app);
app.fqdn = config.appFqdn(app.location);
callback(null, app);
});
}
function getAll(callback) {
assert(typeof callback === 'function');
var updates = updater.getUpdateInfo().apps || [];
appdb.getAll(function (error, apps) {
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
apps.forEach(function (app) {
app.iconUrl = getIconUrlSync(app);
app.fqdn = config.appFqdn(app.location);
app.updateVersion = null;
updates.some(function (update) {
if (update.appId === app.appStoreId && update.version !== app.manifest.version) {
app.updateVersion = update.version;
return true;
} else {
return false;
}
});
});
callback(null, apps);
});
}
function validateAccessRestriction(accessRestriction) {
// TODO: make the values below enumerations in the oauth code
switch (accessRestriction) {
case '':
case 'roleUser':
case 'roleAdmin':
return null;
default:
return new Error('Invalid accessRestriction');
}
}
function install(appId, appStoreId, manifest, location, portBindings, accessRestriction, callback) {
assert(typeof appId === 'string');
assert(typeof appStoreId === 'string');
assert(manifest && typeof manifest === 'object');
assert(typeof location === 'string');
assert(!portBindings || typeof portBindings === 'object');
assert(typeof accessRestriction === 'string');
assert(typeof callback === 'function');
var error = validateManifest(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Mainfest error: ' + error.message));
error = checkManifestConstraints(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Mainfest cannot be installed: ' + error.message));
var error = validateHostname(location, config.fqdn());
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
error = validatePortBindings(portBindings);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
error = validateAccessRestriction(accessRestriction);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
debug('Will install app with id : ' + appId);
appdb.add(appId, appStoreId, manifest, location.toLowerCase(), portBindings, accessRestriction, function (error) {
if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(new AppsError(AppsError.ALREADY_EXISTS));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
stopTask(appId);
startTask(appId);
callback(null);
});
}
function configure(appId, location, portBindings, accessRestriction, callback) {
assert(typeof appId === 'string');
assert(!portBindings || typeof portBindings === 'object');
assert(typeof accessRestriction === 'string');
assert(typeof callback === 'function');
var error = location ? validateHostname(location, config.fqdn()) : null;
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
error = portBindings ? validatePortBindings(portBindings) : null;
if (error) return callback(error);
error = validateAccessRestriction(accessRestriction);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message));
var values = { };
if (location) values.location = location.toLowerCase();
values.portBindings = portBindings;
values.accessRestriction = accessRestriction;
debug('Will install app with id:%s', appId);
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_CONFIGURE, values, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
stopTask(appId);
startTask(appId);
callback(null);
});
}
function update(appId, manifest, callback) {
assert(typeof appId === 'string');
assert(manifest && typeof manifest === 'object');
assert(typeof callback === 'function');
debug('Will update app with id:%s', appId);
var error = validateManifest(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Mainfest error:' + error.message));
error = checkManifestConstraints(manifest);
if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Mainfest cannot be installed:' + error.message));
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_UPDATE, { manifest: manifest }, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
stopTask(appId);
startTask(appId);
callback(null);
});
}
function getLogStream(appId, options, callback) {
assert(typeof appId === 'string');
assert(typeof options === 'object');
assert(typeof callback === 'function');
debug('Getting logs for %s', appId);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, 'App not installed'));
var container = docker.getContainer(app.containerId);
// note: cannot access docker file directly because it needs root access
container.logs({ stdout: true, stderr: true, follow: true, timestamps: true, tail: 'all' }, function (error, logStream) {
if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
var lineCount = 0;
var skipLinesStream = split(function mapper(line) {
if (++lineCount < options.fromLine) return undefined;
return JSON.stringify({ lineNumber: lineCount, log: line });
});
skipLinesStream.close = logStream.req.abort;
logStream.pipe(skipLinesStream);
return callback(null, skipLinesStream);
});
});
}
function getLogs(appId, callback) {
assert(typeof appId === 'string');
assert(typeof callback === 'function');
debug('Getting logs for %s', appId);
appdb.get(appId, function (error, app) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, 'App not installed'));
var container = docker.getContainer(app.containerId);
// note: cannot access docker file directly because it needs root access
container.logs({ stdout: true, stderr: true, follow: false, timestamps: true, tail: 'all' }, function (error, logStream) {
if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
return callback(null, logStream);
});
});
}
function uninstall(appId, callback) {
assert(typeof appId === 'string');
assert(typeof callback === 'function');
debug('Will uninstall app with id:%s', appId);
appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_UNINSTALL, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app'));
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
stopTask(appId);
startTask(appId);
callback(null);
});
}
function start(appId, callback) {
assert(typeof appId === 'string');
assert(typeof callback === 'function');
debug('Will start app with id:%s', appId);
appdb.setRunCommand(appId, appdb.RSTATE_PENDING_START, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
stopTask(appId);
startTask(appId);
callback(null);
});
}
function stop(appId, callback) {
assert(typeof appId === 'string');
assert(typeof callback === 'function');
debug('Will stop app with id:%s', appId);
appdb.setRunCommand(appId, appdb.RSTATE_PENDING_STOP, function (error) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess
if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error));
stopTask(appId);
startTask(appId);
callback(null);
});
}
// NOTE: keep this in sync with appstore's apps.js
function validateManifest(manifest) {
assert(manifest && typeof manifest === 'object');
if (manifest['manifestVersion'] !== 1) return new Error('manifestVersion must be set');
var fields = [ 'version', 'dockerImage', 'healthCheckPath', 'httpPort', 'title' ];
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
if (!(field in manifest)) return new Error('Missing ' + field + ' in manifest');
if (typeof manifest[field] !== 'string') return new Error(field + ' must be a string');
if (manifest[field].length === 0) return new Error(field + ' cannot be empty');
}
if (!semver.valid(manifest['version'])) return new Error('version is not valid semver');
if ('addons' in manifest) {
// addons must be array of strings
if (!util.isArray(manifest.addons)) return new Error('addons must be an array');
for (var i = 0; i < manifest.addons.length; i++) {
if (typeof manifest.addons[i] !== 'string') return new Error('addons must be strings');
}
}
if ('minBoxVersion' in manifest) {
if (!semver.valid(manifest['minBoxVersion'])) return new Error('minBoxVersion is not valid semver');
}
if ('maxBoxVersion' in manifest) {
if (!semver.valid(manifest['maxBoxVersion'])) return new Error('maxBoxVersion is not valid semver');
}
if ('targetBoxVersion' in manifest) {
if (!semver.valid(manifest['targetBoxVersion'])) return new Error('targetBoxVersion is not valid semver');
}
if ('iconUrl' in manifest) {
if (!safe.url.parse(manifest.iconUrl)) return new Error('Invalid icon url');
}
return null;
}
function checkManifestConstraints(manifest) {
if (semver.valid(manifest.maxBoxVersion) && semver.gt(config.version(), manifest.maxBoxVersion)) {
return new Error('Box version exceeds Apps maxBoxVersion');
}
if (semver.valid(manifest.minBoxVersion) && semver.lt(manifest.minBoxVersion, config.version())) {
return new Error('Box version exceeds Apps maxBoxVersion');
}
return null;
}