diff --git a/README.md b/README.md index 92745cc..79f1de7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ -# detention -Handles our 404 +# Detention + +### Navi request general error message response producing service. + +Navi will proxy to this service in the event of several types of error scenarios. Detention fetches +the status of an instance from API and produces an error HTML response page. script to push it ``` diff --git a/app.js b/app.js index e701c49..d88ff34 100644 --- a/app.js +++ b/app.js @@ -1,63 +1,231 @@ +/** + * @module app + */ 'use strict'; +var ErrorCat = require('error-cat'); +var Runnable = require('runnable'); +var assign = require('101/assign'); +var bodyParser = require('body-parser'); var express = require('express'); +var keypather = require('keypather')(); var path = require('path'); -var bodyParser = require('body-parser'); +var put = require('101/put'); -var version = require('./package.json').version; var app = express(); +var log = app.log = require('./logger')(__filename); +var version = require('./package.json').version; + +// valid Detention request types (val for req validation) +var validDetentionTypes = [ + 'not_running', + 'ports', + 'signin', + 'unresponsive' +]; // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'jade'); -var locals = { - version: version +assign(app.locals, { + localVersion: version, + absoluteUrl: process.env.ABSOLUTE_URL +}); + +// this is used for hello runnable user so we only have to login once +var superUser = app.superUser = new Runnable(process.env.API_HOSTNAME, { + requestDefaults: { + headers: { + 'user-agent': 'detention-root' + }, + } +}); + +/** + * Authenticate with API as super user + * Must invoke before server begins listening + */ +app.loginSuperUser = function (cb) { + var logData = { + tx: true + }; + log.info(logData, 'api.loginSuperUser'); + superUser.githubLogin(process.env.HELLO_RUNNABLE_GITHUB_TOKEN, function (err) { + if (err) { + log.error(put({ + err: err + }, logData), 'loginSuperUser error'); + } else { + log.trace(logData, 'loginSuperUser success'); + } + cb(err); + }); +}; + +/** + * Validate query parameters + */ +app._validateRequest = function (req, res, next) { + log.info('app._validateRequest'); + if (!req.query.shortHash && req.query.type !== 'signin') { + log.trace('_validateRequest !shortHash'); + // TODO?: switch to createAndReport + return next(ErrorCat.create(500, 'instance shortHash required')); + } + if (!~validDetentionTypes.indexOf(req.query.type)) { + // only valid occurance if login error + log.trace('_validateRequest !type'); + // TODO?: switch to createAndReport + return next(ErrorCat.create(500, 'invalid request type')); + } + log.trace('_validateRequest success'); + next(); }; +/** + * Fetch Instance resource from API + */ +app._fetchInstance = function (req, res, next) { + log.info({ + shortHash: req.query.shortHash + }, 'api._fetchInstance'); + if (req.query.type === 'signin') { + log.trace('_fetchInstance signin bypass'); + return next(); + } + req.instance = superUser.fetchInstance(req.query.shortHash, function (err) { + if (err) { + log.error({ + err: err + }, '_fetchInstance superUser.fetchInstance error'); + // TODO?: switch to createAndReport + return next(ErrorCat.create(404, 'instance not found')); + } + log.trace('_fetchInstance superUser.fetchInstance success'); + next(); + }); +}; // uncomment after placing your favicon in /public -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: false })); -app.use(express.static(path.join(__dirname, 'public'))); -app.route('/*').get(function (req, res, next) { - var options = { - localVersion: version, - absoluteUrl: process.env.ABSOLUTE_URL || 'detention.runnable.io' - }; - if (req.query.type) { - var page = req.query.type; - [ - 'status', - 'branchName', - 'redirectUrl', - 'containerUrl', - 'ownerName', - 'instanceName' - ].forEach(function (option) { - options[option] = req.query[option]; - }); - if (req.query.ports) { - var value = req.query.ports; - if (!Array.isArray(value)) { - value = [value]; - } - options.ports = value; +/** + * Resolve instance status and render + return relevant error message html page + */ +app._processNaviError = function (req, res, next) { + log.info({ + query: req.query + }, 'processNaviError'); + var options = {}; + + [ + 'redirectUrl', + 'shortHash' + ].forEach(function (option) { + options[option] = req.query[option]; + }); + + if (req.instance) { + options.branchName = req.instance.getBranchName(); + // Temp missing pending resolution of SAN-3018 + // https://runnable.atlassian.net/browse/SAN-3018 + options.instanceName = keypather.get(req.instance, 'attrs.lowerName'); + options.ownerName = keypather.get(req.instance, 'attrs.owner.username'); + var ports = keypather.get(req.instance, 'attrs.container.ports'); + if (ports) { + options.ports = Object.keys(ports).map(function (portKey) { + // Ex: '3000/tcp' --> '3000' + return portKey.replace(/\/tcp$/, ''); + }); + } else { + log.warn({ + instance: req.instance + }, '_processNaviError instance !ports'); } - options.headerText = options.status; - if (options.status) { - if (options.status === 'buildFailed') { - options.headerText = 'build failed.'; - options.status = 'failed to build'; - } else { - options.status = 'is ' + options.status; - } + } + + if (req.query.type === 'signin') { + log.trace('processNaviError type signin'); + return res.render('pages/signin', options); + } + if (req.query.type === 'not_running') { + log.trace('processNaviError type not_running'); + + // container state error pages. + // - Not running (building, starting, crashed) + // - Running, but unresponsive + var status = req.instance.status(); + log.trace({ + status: status, + options: options + }, 'processNaviError instance status'); + + options.status = status; + switch(status) { + case 'stopped': + case 'crashed': + case 'stopping': + options.headerText = 'is ' + status; + res.render('pages/dead', options); + break; + case 'running': + // The instance could have started after Navi fetched it and proxied to detention. + // Might not be the best idea to trigger a refresh, could easily result in user-unfriendly + // infinite redirect loops. Better to display an error page prompting user to refresh? + options.headerText = 'is running'; + res.render('pages/dead', options); + break; + case 'buildFailed': + options.headerText = 'build failed'; + res.render('pages/dead', options); + break; + case 'building': + options.headerText = 'is building'; + res.render('pages/dead', options); + break; + case 'neverStarted': + case 'starting': + options.headerText = 'is starting'; + res.render('pages/dead', options); + break; + case 'unknown': + options.headerText = 'unknown'; + res.render('pages/dead', options); + break; } - res.render('pages/' + page, options); - } else { - res.render('pages/invalid', options); + return; } -}); + if (req.query.type === 'ports'){ + log.trace('processNaviError type ports'); + /* + * Currently not implemented, might be bundled into 'unresponsive' + * + * Userland hipache will only route to navi if a request is made to an elastic url on a port + * that's explicitly set on the instance (we set hipache redis entries when ports are exposed) + * otherwise userland-hipache will return an error page due to a lack of a redis entry. + * + * Probably could fix by patching Hipache or perhaps reading the manual to see if there's a + * some kind of forward-for-all-ports functionality + * + * Anand if you read this Monday morning lets chat about it at 3pm + */ + return; + } + if (req.query.type === 'unresponsive'){ + log.trace('processNaviError type unresponsive'); + res.render('pages/unresponsive', options); + return; + } +}; + +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: false })); +app.use(express.static(path.join(__dirname, 'public'))); + +app.route('/*').get( + app._validateRequest, + app._fetchInstance, + app._processNaviError +); // catch 404 and forward to error handler app.use(function(req, res, next) { @@ -66,15 +234,10 @@ app.use(function(req, res, next) { next(err); }); -// error handlers - // production error handler // no stacktraces leaked to user app.use(function(err, req, res, next) { - res.render('pages/invalid', { - localVersion: version - }); + res.render('pages/invalid', {}); }); - module.exports = app; diff --git a/bin/www b/bin/www index 965f15e..aeabb9f 100755 --- a/bin/www +++ b/bin/www @@ -4,10 +4,29 @@ * Module dependencies. */ -var app = require('../app'); +var ErrorCat = require('error-cat'); var debug = require('debug')('detention:server'); +var hasKeypaths = require('101/has-keypaths'); var http = require('http'); +var app = require('../app'); +var log = require('../logger')(__filename); + +var requiredEnvKeys = [ + 'HELLO_RUNNABLE_GITHUB_TOKEN', + 'API_HOSTNAME' +]; +if (!hasKeypaths(process.env, requiredEnvKeys)) { + log.error({ + requiredEnvKeys: requiredEnvKeys, + env: process.env + }, 'Missing required ENV keys'); + // send to rollbar + ErrorCat.report(new Error('Detention missing required ENV values'), null, function () { + process.exit(1); + }); +} + /** * Get port from environment and store in Express. */ @@ -25,7 +44,9 @@ var server = http.createServer(app); * Listen on provided port, on all network interfaces. */ -server.listen(port); +app.loginSuperUser(function () { + server.listen(port); +}); server.on('error', onError); server.on('listening', onListening); diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..ba431f8 --- /dev/null +++ b/circle.yml @@ -0,0 +1,15 @@ +machine: + environment: + NODE_ENV: test + LOG_LEVEL_STDOUT: error +dependencies: + override: + - nvm install 4.2.0 + - nvm alias default 4.2.0 + - npm install -g npm@2.8.3 + - npm install +test: + pre: + - ulimit -n 10240 + override: + - npm run test diff --git a/logger.js b/logger.js new file mode 100644 index 0000000..cf558cd --- /dev/null +++ b/logger.js @@ -0,0 +1,31 @@ +/** + * @module lib/logger + */ +'use strict'; + +var bunyan = require('bunyan'); +var envIs = require('101/env-is'); +var path = require('path'); +var put = require('101/put'); + +var logger = bunyan.createLogger({ + name: 'detention', + streams: [{ + level: process.env.LOG_LEVEL_STDOUT || 'trace', + stream: process.stdout + }], + serializers: bunyan.stdSerializers, + // DO NOT use src in prod, slow + src: !envIs('production'), + environment: process.env.NODE_ENV +}); + +/** + * Return a new child bunyan instance + * @param {String} namespace + */ +module.exports = function (namespace) { + return logger.child({ + module: path.relative(process.cwd(), namespace) + }); +} diff --git a/package.json b/package.json index 693cb85..52d3c46 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,13 @@ "private": true, "scripts": { "grunt": "./node_modules/.bin/grunt", - "start": "node ./bin/www" + "start": "node ./bin/www", + "test": "lab -c -v -t 0 --leaks" }, "dependencies": { + "101": "^1.2.0", "body-parser": "~1.12.4", + "bunyan": "^1.5.1", "debug": "~2.2.0", "express": "~4.12.4", "grunt": "^0.4.5", @@ -18,6 +21,14 @@ "grunt-contrib-sass": "^0.9.2", "grunt-jade-plugin": "^0.6.0", "jade": "~1.9.2", - "serve-favicon": "~2.2.1" + "keypather": "^1.10.1", + "runnable": "git+ssh://git@github.com:CodeNow/runnable-api-client#v4.7.1", + "serve-favicon": "~2.2.1", + "error-cat": "~1.4.0" + }, + "devDependencies": { + "code": "^2.0.1", + "lab": "^7.3.0", + "sinon": "^1.17.2" } } diff --git a/test/app.js b/test/app.js new file mode 100644 index 0000000..edabc80 --- /dev/null +++ b/test/app.js @@ -0,0 +1,414 @@ +/** + * @module test/app + */ +'use strict'; + +var Code = require('code'); +var Lab = require('lab'); +var noop = require('101/noop'); +var sinon = require('sinon'); + +var lab = exports.lab = Lab.script(); + +var afterEach = lab.afterEach; +var beforeEach = lab.beforeEach; +var describe = lab.describe; +var expect = Code.expect; +var it = lab.test; + +var app = require('../app'); + +describe('app.js', function () { + describe('app.loginSuperUser', function () { + beforeEach(function (done) { + sinon.stub(app.superUser, 'githubLogin').yieldsAsync(); + sinon.stub(app.log, 'error'); + sinon.stub(app.log, 'trace'); + done(); + }); + + afterEach(function (done) { + app.superUser.githubLogin.restore(); + app.log.error.restore(); + app.log.trace.restore(); + done(); + }); + + it('should authenticate with API as hello-runnable', function (done) { + app.loginSuperUser(function (err) { + expect(err).to.be.undefined(); + sinon.assert.callCount(app.superUser.githubLogin, 1); + sinon.assert.calledWith(app.superUser.githubLogin, process.env.HELLO_RUNNABLE_GITHUB_TOKEN); + sinon.assert.calledWith(app.log.trace, + sinon.match.any, 'loginSuperUser success'); + done(); + }) + }); + + it('should log if authentication errors', function (done) { + var error = new Error('api error'); + app.superUser.githubLogin.yieldsAsync(error); + app.loginSuperUser(function (err) { + expect(err).to.equal(error); + sinon.assert.callCount(app.superUser.githubLogin, 1); + sinon.assert.calledWith(app.superUser.githubLogin, process.env.HELLO_RUNNABLE_GITHUB_TOKEN); + sinon.assert.calledWith(app.log.error, + sinon.match.any, 'loginSuperUser error'); + done(); + }) + }); + }); + + describe('app._validateRequest', function () { + it('should next with error if non-signin error with no shortHash', function (done) { + var req = { + query: { + type: 'not_running' // !signin + } + }; + app._validateRequest(req, {}, function (err) { + expect(err.message).to.equal('instance shortHash required'); + done(); + }); + }); + + it('should next with error if invalid query.type value', function (done) { + var req = { + query: { + shortHash: '5555', + type: '*^^$^#&$@' + } + }; + app._validateRequest(req, {}, function (err) { + expect(err.message).to.equal('invalid request type'); + done(); + }); + }); + + it('should next without error for valid request', function (done) { + var req = { + query: { + shortHash: '5555', + type: 'not_running' + } + }; + app._validateRequest(req, {}, function (err) { + expect(err).to.be.undefined(); + done(); + }); + }); + }); + + describe('app._fetchInstance', function () { + beforeEach(function (done) { + sinon.stub(app.superUser, 'fetchInstance'); + done(); + }); + + afterEach(function (done) { + app.superUser.fetchInstance.restore(); + done(); + }); + + it('should next without fetching if query is signin', function (done) { + var req = { + query: { + type: 'signin' + } + }; + app._fetchInstance(req, {}, function (err) { + expect(err).to.be.undefined(); + sinon.assert.notCalled(app.superUser.fetchInstance); + done(); + }); + }); + + it('should next with error if fetchInstance yields an error', function (done) { + var instance = {}; + app.superUser.fetchInstance.returns(instance).yieldsAsync(new Error('error')); + var req = { + query: { + shortHash: 'axcde', + type: 'not_running' + } + }; + app._fetchInstance(req, {}, function (err) { + expect(err.message).to.equal('instance not found'); + sinon.assert.callCount(app.superUser.fetchInstance, 1); + sinon.assert.calledWith(app.superUser.fetchInstance, 'axcde'); + done(); + }); + }); + + it('should fetch instance and assign to req.instance, then next', function (done) { + var instance = {}; + app.superUser.fetchInstance.returns(instance).yieldsAsync(); + var req = { + query: { + shortHash: 'axcde', + type: 'not_running' + } + }; + app._fetchInstance(req, {}, function (err) { + expect(err).to.be.undefined(); + sinon.assert.callCount(app.superUser.fetchInstance, 1); + sinon.assert.calledWith(app.superUser.fetchInstance, 'axcde'); + expect(req.instance).to.equal(instance); + done(); + }); + }); + }); + + describe('app._processNaviError', function (done) { + var instance; + beforeEach(function (done) { + instance = { + getBranchName: sinon.stub().returns('master'), + attrs: { + lowerName: 'api', + owner: { + username: 'casey' + }, + contextVersion: { + branch: 'master' + }, + container: { + ports: { + '80/tcp': {} + } + } + } + }; + done(); + }); + + afterEach(function (done) { + done(); + }); + + it('should render signin page if error type is signin', function (done) { + var req = { + instance: instance, + query: { + type: 'signin', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/signin'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'] + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + it('should render not_running state stopped', function (done) { + instance.status = function () { + return 'stopped'; + }; + var req = { + instance: instance, + query: { + type: 'not_running', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/dead'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'], + headerText: 'is stopped' + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + it('should render not_running state running', function (done) { + instance.status = function () { + return 'running'; + }; + var req = { + instance: instance, + query: { + type: 'not_running', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/dead'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'], + headerText: 'is running' + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + it('should render not_running state buildFailed', function (done) { + instance.status = function () { + return 'buildFailed'; + }; + var req = { + instance: instance, + query: { + type: 'not_running', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/dead'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'], + headerText: 'build failed' + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + it('should render not_running state building', function (done) { + instance.status = function () { + return 'building'; + }; + var req = { + instance: instance, + query: { + type: 'not_running', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/dead'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'], + headerText: 'is building' + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + it('should render not_running state starting', function (done) { + instance.status = function () { + return 'neverStarted'; + }; + var req = { + instance: instance, + query: { + type: 'not_running', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/dead'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'], + headerText: 'is starting' + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + it('should render not_running state starting', function (done) { + instance.status = function () { + return 'neverStarted'; + }; + var req = { + instance: instance, + query: { + type: 'not_running', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/dead'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'], + headerText: 'is starting' + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + it('should render unresponsive error if type is unresponsive', function (done) { + var req = { + instance: instance, + query: { + type: 'unresponsive', + shortHash: 'axcde' + } + }; + var res = { + render: function (page, opts) { + expect(page).to.equal('pages/unresponsive'); + expect(opts).to.deep.contain({ + shortHash: 'axcde', + branchName: 'master', + instanceName: 'api', + ownerName: 'casey', + ports: ['80'] + }); + sinon.assert.callCount(req.instance.getBranchName, 1); + done(); + } + }; + app._processNaviError(req, res, noop); + }); + + }); +}); diff --git a/views/index.jade b/views/index.jade index 370e5fb..bb095ec 100644 --- a/views/index.jade +++ b/views/index.jade @@ -13,13 +13,13 @@ html( title Uh oh //- css link( - href = "//#{locals.absoluteUrl}/stylesheets/error.css?v=#{locals.localVersion}" + href = "//#{absoluteUrl}/stylesheets/error.css?v=#{localVersion}" media = "screen" rel = "stylesheet" ) //- favicon link( - href = "//#{locals.absoluteUrl}/images/favicon.png" + href = "//#{absoluteUrl}/images/favicon.png" rel = "shortcut icon" ) meta( @@ -49,4 +49,4 @@ html( body.error-wrapper .error-content - block content \ No newline at end of file + block content diff --git a/views/pages/dead.jade b/views/pages/dead.jade index 7b7cd75..cad5544 100644 --- a/views/pages/dead.jade +++ b/views/pages/dead.jade @@ -8,6 +8,7 @@ block content //- link goes to the relevant container in the containers view, - with build logs open if container is building - with CMD logs open if container is stopped or crashed +// a.link( href = "//runnable.io/#{ownerName}/#{instanceName}" - ) View logs \ No newline at end of file + ) View logs diff --git a/views/pages/ports.jade b/views/pages/ports.jade index ba36043..af0f80b 100644 --- a/views/pages/ports.jade +++ b/views/pages/ports.jade @@ -14,8 +14,7 @@ block content a.link( href = ":#{port}" ) #{port} - - +// a.link( href = "//runnable.io/#{ownerName}/configure/#{instanceName}" - ) Expose a port in Runnable \ No newline at end of file + ) Expose a port in Runnable