diff --git a/src/addons.js b/src/addons.js index ead972b6f..7cc8b13b7 100644 --- a/src/addons.js +++ b/src/addons.js @@ -1,6 +1,14 @@ 'use strict'; exports = module.exports = { + AddonsError: AddonsError, + + getAddons: getAddons, + getStatus: getStatus, + getLogs: getLogs, + startAddon: startAddon, + stopAddon: stopAddon, + startAddons: startAddons, updateAddonConfig: updateAddonConfig, @@ -47,6 +55,30 @@ var accesscontrol = require('./accesscontrol.js'), request = require('request'), util = require('util'); +// http://dustinsenos.com/articles/customErrorsInNode +// http://code.google.com/p/v8/wiki/JavaScriptStackTraceApi +function AddonsError(reason, errorOrMessage) { + assert.strictEqual(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(AddonsError, Error); +AddonsError.INTERNAL_ERROR = 'Internal Error'; +AddonsError.NOT_FOUND = 'Not Found'; + const NOOP = function (app, options, callback) { return callback(); }; const NOOP_CALLBACK = function (error) { if (error) debug(error); }; const RMADDON_CMD = path.join(__dirname, 'scripts/rmaddon.sh'); @@ -59,84 +91,96 @@ var KNOWN_ADDONS = { teardown: teardownEmail, backup: NOOP, restore: setupEmail, - clear: NOOP + clear: NOOP, + status: statusEmail }, ldap: { setup: setupLdap, teardown: teardownLdap, backup: NOOP, restore: setupLdap, - clear: NOOP + clear: NOOP, + status: null }, localstorage: { setup: setupLocalStorage, // docker creates the directory for us teardown: teardownLocalStorage, backup: NOOP, // no backup because it's already inside app data restore: NOOP, - clear: clearLocalStorage + clear: clearLocalStorage, + status: null }, mongodb: { setup: setupMongoDb, teardown: teardownMongoDb, backup: backupMongoDb, restore: restoreMongoDb, - clear: clearMongodb + clear: clearMongodb, + status: statusMongoDb }, mysql: { setup: setupMySql, teardown: teardownMySql, backup: backupMySql, restore: restoreMySql, - clear: clearMySql + clear: clearMySql, + status: statusMySql }, oauth: { setup: setupOauth, teardown: teardownOauth, backup: NOOP, restore: setupOauth, - clear: NOOP + clear: NOOP, + status: null }, postgresql: { setup: setupPostgreSql, teardown: teardownPostgreSql, backup: backupPostgreSql, restore: restorePostgreSql, - clear: clearPostgreSql + clear: clearPostgreSql, + status: statusPostgreSql }, recvmail: { setup: setupRecvMail, teardown: teardownRecvMail, backup: NOOP, restore: setupRecvMail, - clear: NOOP + clear: NOOP, + status: null }, redis: { setup: setupRedis, teardown: teardownRedis, backup: backupRedis, restore: restoreRedis, - clear: clearRedis + clear: clearRedis, + status: null }, sendmail: { setup: setupSendMail, teardown: teardownSendMail, backup: NOOP, restore: setupSendMail, - clear: NOOP + clear: NOOP, + status: null }, scheduler: { setup: NOOP, teardown: NOOP, backup: NOOP, restore: NOOP, - clear: NOOP + clear: NOOP, + status: null }, docker: { setup: NOOP, teardown: NOOP, backup: NOOP, restore: NOOP, - clear: NOOP + clear: NOOP, + status: statusDocker } }; @@ -170,6 +214,89 @@ function dumpPath(addon, appId) { } } +function getAddons(callback) { + assert.strictEqual(typeof callback, 'function'); + + // we currently list only addons which have a status function to report + var addons = Object.keys(KNOWN_ADDONS).filter(function (a) { return !!KNOWN_ADDONS[a].status; }); + + callback(null, addons); +} + +function getStatus(addon, callback) { + assert.strictEqual(typeof containerName, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (!KNOWN_ADDONS[addon] || !KNOWN_ADDONS[addon].status) return callback(new AddonsError(AddonsError.NOT_FOUND)); + + KNOWN_ADDONS[addon].status(function (error, result) { + if (error) return callback(new AddonsError(AddonsError.INTERNAL_ERROR, error)); + + callback(null, { name: addon, status: result }); + }); +} + +function getLogs(addon, options, callback) { + assert.strictEqual(typeof addon, 'string'); + assert(options && typeof options === 'object'); + assert.strictEqual(typeof callback, 'function'); + + if (!KNOWN_ADDONS[addon] || !KNOWN_ADDONS[addon].status) return callback(new AddonsError(AddonsError.NOT_FOUND)); + + debug('Getting logs for %s', addon); + + var lines = options.lines || 100, + format = options.format || 'json', + follow = !!options.follow; + + assert.strictEqual(typeof lines, 'number'); + assert.strictEqual(typeof format, 'string'); + + var args = [ '--lines=' + lines ]; + if (follow) args.push('--follow', '--retry', '--quiet'); // same as -F. to make it work if file doesn't exist, --quiet to not output file headers, which are no logs + args.push(path.join(paths.LOG_DIR, addon, 'app.log')); + + var cp = spawn('/usr/bin/tail', args); + + var transformStream = split(function mapper(line) { + if (format !== 'json') return line + '\n'; + + var data = line.split(' '); // logs are + var timestamp = (new Date(data[0])).getTime(); + if (isNaN(timestamp)) timestamp = 0; + var message = line.slice(data[0].length+1); + + // ignore faulty empty logs + if (!timestamp && !message) return; + + return JSON.stringify({ + realtimeTimestamp: timestamp * 1000, + message: message, + source: appId + }) + '\n'; + }); + + transformStream.close = cp.kill.bind(cp, 'SIGKILL'); // closing stream kills the child process + + cp.stdout.pipe(transformStream); + + callback(null, transformStream); +} + +function startAddon(addon, callback) { + assert.strictEqual(typeof addon, 'string'); + assert.strictEqual(typeof callback, 'function'); + + callback(new AddonsError(AddonsError.INTERNAL_ERROR, 'not implemented')); +} + +function stopAddon(addon, callback) { + assert.strictEqual(typeof addon, 'string'); + assert.strictEqual(typeof callback, 'function'); + + callback(new AddonsError(AddonsError.INTERNAL_ERROR, 'not implemented')); +} + function getAddonDetails(containerName, tokenEnvName, callback) { assert.strictEqual(typeof containerName, 'string'); assert.strictEqual(typeof tokenEnvName, 'string'); @@ -577,6 +704,11 @@ function teardownEmail(app, options, callback) { appdb.unsetAddonConfig(app.id, 'email', callback); } +function statusEmail(callback) { + assert.strictEqual(typeof callback, 'function'); + callback(null, { active: true }); +} + function setupLdap(app, options, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); @@ -904,6 +1036,11 @@ function restoreMySql(app, options, callback) { }); } +function statusMySql(callback) { + assert.strictEqual(typeof callback, 'function'); + callback(null, { active: true }); +} + function postgreSqlNames(appId) { appId = appId.replace(/-/g, ''); return { database: `db${appId}`, username: `user${appId}` }; @@ -1079,6 +1216,11 @@ function restorePostgreSql(app, options, callback) { }); } +function statusPostgreSql(callback) { + assert.strictEqual(typeof callback, 'function'); + callback(null, { active: true }); +} + function startMongodb(existingInfra, callback) { assert.strictEqual(typeof existingInfra, 'object'); assert.strictEqual(typeof callback, 'function'); @@ -1241,6 +1383,11 @@ function restoreMongoDb(app, options, callback) { }); } +function statusMongoDb(callback) { + assert.strictEqual(typeof callback, 'function'); + callback(null, { active: true }); +} + function startRedis(existingInfra, callback) { assert.strictEqual(typeof existingInfra, 'object'); assert.strictEqual(typeof callback, 'function'); @@ -1414,3 +1561,8 @@ function restoreRedis(app, options, callback) { input.pipe(restoreReq); }); } + +function statusDocker(callback) { + assert.strictEqual(typeof callback, 'function'); + callback(null, { active: true }); +} diff --git a/src/routes/addons.js b/src/routes/addons.js new file mode 100644 index 000000000..cffc6d80d --- /dev/null +++ b/src/routes/addons.js @@ -0,0 +1,125 @@ +'use strict'; + +exports = module.exports = { + getAll: getAll, + get: get, + getLogs: getLogs, + getLogStream: getLogStream, + start: start, + stop: stop +}; + +var addons = require('../addons.js'), + AddonsError = addons.AddonsError, + assert = require('assert'), + debug = require('debug')('box:routes/addons'); + +function getAll(req, res, next) { + addons.getAddons(function (error, result) { + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(200, { addons: result })); + }); +} + +function get(req, res, next) { + assert.strictEqual(typeof req.params.addon, 'string'); + + addons.getStatus(req.params.addon, function (error, result) { + if (error && error.reason === AddonsError.NOT_FOUND) return next(new HttpError(404, 'No such addon')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, { addon: result })); + }); +} + +function getLogs(req, res, next) { + assert.strictEqual(typeof req.params.addon, 'string'); + + var lines = req.query.lines ? parseInt(req.query.lines, 10) : 100; + if (isNaN(lines)) return next(new HttpError(400, 'lines must be a number')); + + debug(`Getting logs of addon ${req.params.addon}`); + + var options = { + lines: lines, + follow: false, + format: req.query.format + }; + + addons.getLogs(req.params.addon, options, function (error, logStream) { + if (error && error.reason === AddonsError.NOT_FOUND) return next(new HttpError(404, 'No such addon')); + if (error) return next(new HttpError(500, error)); + + res.writeHead(200, { + 'Content-Type': 'application/x-logs', + 'Content-Disposition': 'attachment; filename="log.txt"', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' // disable nginx buffering + }); + logStream.pipe(res); + }); +} + +// this route is for streaming logs +function getLogStream(req, res, next) { + assert.strictEqual(typeof req.params.addon, 'string'); + + debug(`Getting logstream of addon ${req.params.addon}`); + + var lines = req.query.lines ? parseInt(req.query.lines, 10) : -10; // we ignore last-event-id + if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number')); + + function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; } + + if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream')); + + var options = { + lines: lines, + follow: true + }; + + addons.getLogs(req.params.addon, options, function (error, logStream) { + if (error && error.reason === AddonsError.NOT_FOUND) return next(new HttpError(404, 'No such addon')); + if (error) return next(new HttpError(500, error)); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // disable nginx buffering + 'Access-Control-Allow-Origin': '*' + }); + res.write('retry: 3000\n'); + res.on('close', logStream.close); + logStream.on('data', function (data) { + var obj = JSON.parse(data); + res.write(sse(obj.monotonicTimestamp, JSON.stringify(obj))); // send timestamp as id + }); + logStream.on('end', res.end.bind(res)); + logStream.on('error', res.end.bind(res, null)); + }); +} + +function start(req, res, next) { + assert.strictEqual(typeof req.params.addon, 'string'); + + debug(`Starting addon ${req.params.addon}`); + + addons.startAddon(req.params.addon, function (error) { + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, {})); + }); +} + +function stop(req, res, next) { + assert.strictEqual(typeof req.params.addon, 'string'); + + debug(`Stopping addon ${req.params.addon}`); + + addons.stopAddon(req.params.addon, function (error) { + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, {})); + }); +} diff --git a/src/routes/index.js b/src/routes/index.js index a6df86931..86f5796ca 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -2,6 +2,7 @@ exports = module.exports = { accesscontrol: require('./accesscontrol.js'), + addons: require('./addons.js'), apps: require('./apps.js'), backups: require('./backups.js'), caas: require('./caas.js'), diff --git a/src/server.js b/src/server.js index 1fe469c78..4e989956b 100644 --- a/src/server.js +++ b/src/server.js @@ -284,6 +284,14 @@ function initializeExpressSync() { router.post('/api/v1/domains/:domain/renew_certs', domainsManageScope, verifyDomainLock, routes.domains.renewCerts); router.del ('/api/v1/domains/:domain', domainsManageScope, verifyDomainLock, routes.users.verifyPassword, routes.domains.del); + // addon routes + router.get ('/api/v1/addons', cloudronScope, routes.addons.getAll); + router.get ('/api/v1/addons/:addon', cloudronScope, routes.addons.get); + router.get ('/api/v1/addons/:addon/logs', cloudronScope, routes.addons.getLogs); + router.get ('/api/v1/addons/:addon/logstream', cloudronScope, routes.addons.getLogStream); + router.post('/api/v1/addons/:addon/start', cloudronScope, routes.addons.start); + router.post('/api/v1/addons/:addon/stop', cloudronScope, routes.addons.stop); + // caas routes router.get('/api/v1/caas/config', cloudronScope, routes.caas.getConfig); router.post('/api/v1/caas/change_plan', cloudronScope, routes.users.verifyPassword, routes.caas.changePlan);