diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 590faca..b814a46 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,7 @@ in development * Small refactor and more tests (for ``scripts/stackstorm.js``) (improvement) * Refactor chat providers into their own modules (improvement) * Modernize directory structure to be more consistent with other hubot plugins (improvement) +* Split out the functionality of ``src/stackstorm.js`` into ``stackstorm_api.js`` and refactor it be a JS old style class with a wrapper (improvement) 0.9.6 ----- diff --git a/src/stackstorm.js b/src/stackstorm.js index 380e7db..53eca57 100644 --- a/src/stackstorm.js +++ b/src/stackstorm.js @@ -29,433 +29,14 @@ "use strict"; -var _ = require('lodash'), - util = require('util'), - env = _.clone(process.env), - Promise = require('rsvp').Promise, - utils = require('./lib/utils.js'), - formatCommand = require('./lib/format_command.js'), - CommandFactory = require('./lib/command_factory.js'), - adapters = require('./lib/adapters'), - st2client = require('st2client'), - uuid = require('uuid') - ; +var env = process.env; +var StackStormApi = require('./stackstorm_api'); -// Setup the Environment -env.ST2_API_URL = env.ST2_API || env.ST2_API_URL || 'http://localhost:9101'; -env.ST2_ROUTE = env.ST2_ROUTE || null; -env.ST2_WEBUI_URL = env.ST2_WEBUI_URL || null; +module.exports = function (robot) { + var stackstormApi = new StackStormApi(robot); -// Optional authentication info -env.ST2_AUTH_USERNAME = env.ST2_AUTH_USERNAME || null; -env.ST2_AUTH_PASSWORD = env.ST2_AUTH_PASSWORD || null; - -// Optional authentication token -env.ST2_AUTH_TOKEN = env.ST2_AUTH_TOKEN || null; - -// Optional API key -env.ST2_API_KEY = env.ST2_API_KEY || null; - -// slack attachment colors -env.ST2_SLACK_SUCCESS_COLOR = env.ST2_SLACK_SUCCESS_COLOR || 'dfdfdf'; -env.ST2_SLACK_FAIL_COLOR = env.ST2_SLACK_FAIL_COLOR || 'danger'; - -// Optional, if not provided, we infer it from the API URL -env.ST2_AUTH_URL = env.ST2_AUTH_URL || null; - -// Optional, if not provided, we infer it from the API URL -env.ST2_STREAM_URL = env.ST2_STREAM_URL || null; - -// Command reload interval in seconds -env.ST2_COMMANDS_RELOAD_INTERVAL = parseInt(env.ST2_COMMANDS_RELOAD_INTERVAL || 120, 10); - -// Cap message length to a certain number of characters. -env.ST2_MAX_MESSAGE_LENGTH = parseInt(env.ST2_MAX_MESSAGE_LENGTH || 500, 10); - -// Constants -// Fun human-friendly commands. Use %s for payload output. -var START_MESSAGES = [ - "I'll take it from here! Your execution ID for reference is %s", - "Got it! Remember %s as your execution ID", - "I'm on it! Your execution ID is %s", - "Let me get right on that. Remember %s as your execution ID", - "Always something with you. :) I'll take care of that. Your ID is %s", - "I have it covered. Your execution ID is %s", - "Let me start up the machine! Your execution ID is %s", - "I'll throw that task in the oven and get cookin'! Your execution ID is %s", - "Want me to take that off your hand? You got it! Don't forget your execution ID: %s", - "River Tam will get it done with her psychic powers. Your execution ID is %s" -]; - -var ERROR_MESSAGES = [ - "I'm sorry, Dave. I'm afraid I can't do that. {~} %s" -]; - -var TWOFACTOR_MESSAGE = "This action requires two-factor auth! Waiting for your confirmation."; - - -module.exports = function(robot) { - // Makes the script crash on unhandled rejections instead of ignoring them and keep running. - // Usually happens when trying to connect to a nonexistent instances or similar unrecoverable issues. - // In the future Node.js versions, promise rejections that are not handled will terminate the process with a non-zero exit code. - process.on('unhandledRejection', function(err) { - throw err; - }); - - // Handle uncaught exceptions, log error and terminate hubot if one occurs - robot.error(function(err, res) { - if (err) { - robot.logger.error(err.stack || JSON.stringify(err)); - } - if (res) { - res.send(JSON.stringify({ - "status": "failed", - "msg": "An error occurred trying to post the message:\n" + err - })); - } - - robot.logger.info('Hubot will shut down ...'); - robot.shutdown(); - }); - - var self = this; - - var promise = Promise.resolve(); - - if (env.ST2_API) { - robot.logger.warning("ST2_API is deprecated and will be removed in a future releases. Instead, please use the ST2_API_URL environment variable."); - } - var self = this, - authenticated = Promise.resolve(), - url = utils.parseUrl(env.ST2_API_URL), - opts = { - protocol: url.protocol, - host: url.hostname, - port: url.port, - prefix: url.path, - rejectUnauthorized: false - }; - - if (env.ST2_STREAM_URL) { - var stream_url = utils.parseUrl(env.ST2_STREAM_URL); - opts.stream = { - protocol: stream_url.protocol, - host: stream_url.hostname, - port: stream_url.port, - prefix: stream_url.path - }; - } - - var api_client = st2client(opts); - - function authenticate() { - api_client.removeListener('expiry', authenticate); - - // API key gets precedence 1 - if (env.ST2_API_KEY) { - robot.logger.info('Using ST2_API_KEY as authentication. Expiry will lead to bot exit.'); - return Promise.resolve(); - } - // Auth token gets precedence 2 - if (env.ST2_AUTH_TOKEN) { - robot.logger.info('Using ST2_AUTH_TOKEN as authentication. Expiry will lead to bot exit.'); - return Promise.resolve(); - } - - robot.logger.info('Requesting a token...'); - - var url = utils.parseUrl(env.ST2_AUTH_URL); - - var auth_client = st2client({ - auth: { - protocol: url.protocol, - host: url.hostname, - port: url.port, - prefix: url.path - } - }); - - return auth_client.authenticate(env.ST2_AUTH_USERNAME, env.ST2_AUTH_PASSWORD) - .then(function (token) { - robot.logger.info('Token received. Expiring ' + token.expiry); - api_client.setToken(token); - auth_client.on('expiry', authenticate); - }) - .catch(function (err) { - robot.logger.error('Failed to authenticate: ' + err.message); - - throw err; - }); - } - - // factory to manage commands - var command_factory = new CommandFactory(robot); - - // adapter - specific to each chat provider - var adapter = adapters.getAdapter(robot.adapterName, robot); - - var loadCommands = function() { - robot.logger.info('Loading commands....'); - - api_client.actionAlias.list({limit: -1}) - .then(function (aliases) { - // Remove all the existing commands - command_factory.removeCommands(); - - _.each(aliases, function (alias) { - var name = alias.name; - var formats = alias.formats; - var description = alias.description; - - if (alias.enabled === false) { - return; - } - - if (!formats || formats.length === 0) { - robot.logger.error('No formats specified for command: ' + name); - return; - } - - _.each(formats, function (format) { - var command = formatCommand(robot.logger, name, format.display || format, description); - command_factory.addCommand(command, name, format.display || format, alias, - format.display ? utils.DISPLAY : false); - - _.each(format.representation, function (representation) { - command = formatCommand(robot.logger, name, representation, description); - command_factory.addCommand(command, name, representation, alias, utils.REPRESENTATION); - }); - }); - }); - - robot.logger.info(command_factory.st2_hubot_commands.length + ' commands are loaded'); - }) - .catch(function (err) { - robot.logger.error(util.format('Failed to retrieve commands from "%s": %s', env.ST2_API_URL, err.message)); - if (err.status === 401 || err.message.includes('Unauthorized')) { - throw err; - } - }); - }; - - var sendAck = function (msg, res) { - var history_url = utils.getExecutionHistoryUrl(res.execution); - var history = history_url ? util.format(' (details available at %s)', history_url) : ''; - - if (res.actionalias && res.actionalias.ack) { - if (res.actionalias.ack.enabled === false) { - return; - } else if (res.actionalias.ack.append_url === false) { - history = ''; - } - } - - if (res.message) { - return msg.send(res.message + history); - } - - var message = util.format(_.sample(START_MESSAGES), res.execution.id); - return msg.send(message + history); - }; - - var sendAliasExecutionRequest = function (msg, payload) { - robot.logger.debug('Sending command payload:', JSON.stringify(payload)); - - api_client.aliasExecution.create(payload) - .then(function (res) { sendAck(msg, res); }) - .catch(function (err) { - // Compatibility with older StackStorm versions - if (err.status === 200) { - return sendAck(msg, { execution: { id: err.message } }); - } - robot.logger.error('Failed to create an alias execution:', err); - var addressee = adapter.normalizeAddressee(msg); - var message = util.format(_.sample(ERROR_MESSAGES), err.message); - if (err.requestId) { - message = util.format( - message, - util.format('; Use request ID %s to grep st2 api logs.', err.requestId)); - } - adapter.postData({ - whisper: false, - user: addressee.name, - channel: addressee.room, - message: message, - extra: { - color: '#F35A00' - } - }); - }); - }; - - var executeCommand = function(msg, command_name, format_string, command, action_alias) { - var addressee = adapter.normalizeAddressee(msg); - var payload = { - 'name': command_name, - 'format': format_string, - 'command': command, - 'user': addressee.name, - 'source_channel': addressee.room, - 'source_context': msg.envelope, - 'notification_route': env.ST2_ROUTE || 'hubot' - }; - - if (utils.enable2FA(action_alias)) { - var twofactor_id = uuid.v4(); - robot.logger.debug('Requested an action that requires 2FA. Guid: ' + twofactor_id); - msg.send(TWOFACTOR_MESSAGE); - api_client.executions.create({ - 'action': env.HUBOT_2FA, - 'parameters': { - 'uuid': twofactor_id, - 'user': addressee.name, - 'channel': addressee.room, - 'hint': action_alias.description - } - }); - twofactor[twofactor_id] = { - 'msg': msg, - 'payload': payload - }; - } else { - sendAliasExecutionRequest(msg, payload); - } - }; - - robot.respond(/([\s\S]+?)$/i, function(msg) { - var command, result; - - // Normalize the command and remove special handling provided by the chat service. - // e.g. slack replace quote marks with left double quote which would break behavior. - command = adapter.normalizeCommand(msg.match[1]); - - result = command_factory.getMatchingCommand(command); - - if (!result) { - // No command found - return; - } - - var [command_name, format_string, action_alias] = result; - - executeCommand(msg, command_name, format_string, command, action_alias); - }); - - robot.router.post('/hubot/st2', function(req, res) { - var data; - - try { - if (req.body.payload) { - data = JSON.parse(req.body.payload); - } else { - data = req.body; - } - adapter.postData(data); - - res.send('{"status": "completed", "msg": "Message posted successfully"}'); - } catch (e) { - robot.logger.error("Unable to decode JSON: " + e); - robot.logger.error(e.stack); - res.send('{"status": "failed", "msg": "An error occurred trying to post the message: ' + e + '"}'); - } - }); - - var commands_load_interval; - - function start() { - api_client.stream.listen().catch(function (err) { - robot.logger.error('Unable to connect to stream:', err); - }).then(function (st2stream) { - st2stream.onerror = function (err) { - // TODO: squeeze a little bit more info out of evensource.js - robot.logger.warning('Stream error:', err); - if (err.status === 401) { - throw err; - } - }; - st2stream.addEventListener('st2.announcement__chatops', function (e) { - var data; - - robot.logger.debug('Chatops message received:', e.data); - - if (e.data) { - data = JSON.parse(e.data).payload; - } else { - data = e.data; - } - - adapter.postData(data); - }); - - if (env.HUBOT_2FA) { - st2stream.addEventListener('st2.announcement__2fa', function (e) { - var data; - - robot.logger.debug('Successfull two-factor auth:', e.data); - - if (e.data) { - data = JSON.parse(e.data).payload; - } else { - data = e.data; - } - - var executionData = twofactor[data.uuid]; - sendAliasExecutionRequest(executionData.msg, executionData.payload); - delete twofactor[data.uuid]; - }); - } - }); - - // Add an interval which tries to re-load the commands - commands_load_interval = setInterval(loadCommands.bind(self), (env.ST2_COMMANDS_RELOAD_INTERVAL * 1000)); - - // Initial command loading - loadCommands(); - - // Install SIGUSR2 handler which reloads the command - install_sigusr2_handler(); - } - - function stop() { - clearInterval(commands_load_interval); - api_client.stream.listen().then(function (st2stream) { - st2stream.removeAllListeners(); - st2stream.close(); - }); - } - - function install_sigusr2_handler() { - process.on('SIGUSR2', function() { - robot.logger.debug("Caught SIGUSR2, reloading commands"); - loadCommands(); - }); - } - - if (env.ST2_API_KEY) { - api_client.setKey({ key: env.ST2_API_KEY }); - } else if (env.ST2_AUTH_TOKEN) { - api_client.setToken({ token: env.ST2_AUTH_TOKEN }); - } - - if (env.ST2_API_KEY || env.ST2_AUTH_TOKEN || env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) { - // If using username and password then all are required. - if ((env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) && - !(env.ST2_AUTH_USERNAME && env.ST2_AUTH_PASSWORD && env.ST2_AUTH_URL)) { - throw new Error('Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together.'); - } - authenticated = authenticate(); - } - - // Pending 2-factor auth commands - if (env.HUBOT_2FA) { - var twofactor = {}; - robot.logger.info('Two-factor auth is enabled'); - } - - // Authenticate with StackStorm backend and then call start. - // On a failure to authenticate log the error but do not quit. - return authenticated.then(function () { - start(); - return stop; + return stackstormApi.authenticate().then(function () { + stackstormApi.start(); + return stackstormApi.stop.bind(stackstormApi); }); }; diff --git a/src/stackstorm_api.js b/src/stackstorm_api.js new file mode 100644 index 0000000..8dda627 --- /dev/null +++ b/src/stackstorm_api.js @@ -0,0 +1,486 @@ +// Copyright 2019 Extreme Networks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Description: +// StackStorm hubot integration +// +// Dependencies: +// +// +// Configuration: +// ST2_API_URL - FQDN + port to StackStorm endpoint +// ST2_ROUTE - StackStorm notification route name +// ST2_COMMANDS_RELOAD_INTERVAL - Reload interval for commands +// +// Notes: +// Command list is automatically generated from StackStorm ChatOps metadata +// + +"use strict"; + +var _ = require('lodash'), + util = require('util'), + env = _.clone(process.env), + Promise = require('rsvp').Promise, + utils = require('./lib/utils.js'), + formatCommand = require('./lib/format_command.js'), + CommandFactory = require('./lib/command_factory.js'), + adapters = require('./lib/adapters'), + st2client = require('st2client'), + uuid = require('uuid') + ; + +// Setup the Environment +env.ST2_API_URL = env.ST2_API || env.ST2_API_URL || 'http://localhost:9101'; +env.ST2_ROUTE = env.ST2_ROUTE || null; +env.ST2_WEBUI_URL = env.ST2_WEBUI_URL || null; + +// Optional authentication info +env.ST2_AUTH_USERNAME = env.ST2_AUTH_USERNAME || null; +env.ST2_AUTH_PASSWORD = env.ST2_AUTH_PASSWORD || null; + +// Optional authentication token +env.ST2_AUTH_TOKEN = env.ST2_AUTH_TOKEN || null; + +// Optional API key +env.ST2_API_KEY = env.ST2_API_KEY || null; + +// slack attachment colors +env.ST2_SLACK_SUCCESS_COLOR = env.ST2_SLACK_SUCCESS_COLOR || 'dfdfdf'; +env.ST2_SLACK_FAIL_COLOR = env.ST2_SLACK_FAIL_COLOR || 'danger'; + +// Optional, if not provided, we infer it from the API URL +env.ST2_AUTH_URL = env.ST2_AUTH_URL || null; + +// Optional, if not provided, we infer it from the API URL +env.ST2_STREAM_URL = env.ST2_STREAM_URL || null; + +// Command reload interval in seconds +env.ST2_COMMANDS_RELOAD_INTERVAL = parseInt(env.ST2_COMMANDS_RELOAD_INTERVAL || 120, 10); + +// Cap message length to a certain number of characters. +env.ST2_MAX_MESSAGE_LENGTH = parseInt(env.ST2_MAX_MESSAGE_LENGTH || 500, 10); + +// Constants +// Fun human-friendly commands. Use %s for payload output. +var START_MESSAGES = [ + "I'll take it from here! Your execution ID for reference is %s", + "Got it! Remember %s as your execution ID", + "I'm on it! Your execution ID is %s", + "Let me get right on that. Remember %s as your execution ID", + "Always something with you. :) I'll take care of that. Your ID is %s", + "I have it covered. Your execution ID is %s", + "Let me start up the machine! Your execution ID is %s", + "I'll throw that task in the oven and get cookin'! Your execution ID is %s", + "Want me to take that off your hand? You got it! Don't forget your execution ID: %s", + "River Tam will get it done with her psychic powers. Your execution ID is %s" +]; + +var ERROR_MESSAGES = [ + "I'm sorry, Dave. I'm afraid I can't do that. {~} %s" +]; + +var TWOFACTOR_MESSAGE = "This action requires two-factor auth! Waiting for your confirmation."; + + +function StackStorm(robot) { + var self = this; + + self.robot = robot; + + // factory to manage commands + self.command_factory = new CommandFactory(self.robot); + + // adapter - specific to each chat provider + self.adapter = adapters.getAdapter(self.robot.adapterName, self.robot); + + self.commands_load_interval = null; + self.st2stream = null; + + self.two_factor_authorization_enabled = env.HUBOT_2FA || false; + + + // Makes the script crash on unhandled rejections instead of ignoring them and keep running. + // Usually happens when trying to connect to a nonexistent instances or similar unrecoverable issues. + // In the future Node.js versions, promise rejections that are not handled will terminate the process with a non-zero exit code. + process.on('unhandledRejection', function(err) { + throw err; + }); + + // Handle uncaught exceptions, log error and terminate hubot if one occurs + self.robot.error(function(err, res) { + if (err) { + self.robot.logger.error(err.stack || JSON.stringify(err)); + } + if (res) { + res.send(JSON.stringify({ + "status": "failed", + "msg": "An error occurred trying to post the message:\n" + err + })); + } + + self.robot.logger.info('Hubot will shut down ...'); + self.robot.shutdown(); + }); + + self.robot.respond(/([\s\S]+?)$/i, function(msg) { + var command, result; + + // Normalize the command and remove special handling provided by the chat service. + // e.g. slack replace quote marks with left double quote which would break behavior. + command = self.adapter.normalizeCommand(msg.match[1]); + + result = self.command_factory.getMatchingCommand(command); + + if (!result) { + // No command found + return; + } + + var [command_name, format_string, action_alias] = result; + + self.executeCommand(msg, command_name, format_string, command, action_alias); + }); + + self.robot.router.post('/hubot/st2', function(req, res) { + var data; + + try { + if (req.body.payload) { + data = JSON.parse(req.body.payload); + } else { + data = req.body; + } + self.adapter.postData(data); + + res.send('{"status": "completed", "msg": "Message posted successfully"}'); + } catch (e) { + self.robot.logger.error("Unable to decode JSON: " + e); + self.robot.logger.error(e.stack); + res.send('{"status": "failed", "msg": "An error occurred trying to post the message: ' + e + '"}'); + } + }); + + + if (env.ST2_API) { + self.robot.logger.warning("ST2_API is deprecated and will be removed in a future releases. Instead, please use the ST2_API_URL environment variable."); + } + + if (self.two_factor_authorization_enabled) { + self.twofactor = {}; + self.robot.logger.info('Two-factor auth is enabled'); + } + + var url = utils.parseUrl(env.ST2_API_URL), + opts = { + protocol: url.protocol, + host: url.hostname, + port: url.port, + prefix: url.path, + rejectUnauthorized: false + }; + + if (env.ST2_STREAM_URL) { + var stream_url = utils.parseUrl(env.ST2_STREAM_URL); + opts.stream = { + protocol: stream_url.protocol, + host: stream_url.hostname, + port: stream_url.port, + prefix: stream_url.path + }; + } + + self.auth_client = null + self.api_client = st2client(opts); + + if (env.ST2_API_KEY) { + self.api_client.setKey({ key: env.ST2_API_KEY }); + } else if (env.ST2_AUTH_TOKEN) { + self.api_client.setToken({ token: env.ST2_AUTH_TOKEN }); + } + + if (env.ST2_API_KEY || env.ST2_AUTH_TOKEN || env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) { + // If using username and password then all are required. + if ((env.ST2_AUTH_USERNAME || env.ST2_AUTH_PASSWORD) && + !(env.ST2_AUTH_USERNAME && env.ST2_AUTH_PASSWORD && env.ST2_AUTH_URL)) { + throw new Error('Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together.'); + } + } +}; + + +// This is a privileged method, and is not shared amongst StackStorm objects. +// It is a method on every StackStorm _object_. Luckily, this should only be +// created once. +// For more information, see: +// https://stackoverflow.com/a/2294252 +StackStorm.prototype.authenticate = function () { + var self = this; + + self.api_client.removeListener('expiry', self.authenticate.bind(self)); + + // API key gets precedence 1 + if (env.ST2_API_KEY) { + self.robot.logger.info('Using ST2_API_KEY as authentication. Expiry will lead to bot exit.'); + return Promise.resolve(); + } + // Auth token gets precedence 2 + if (env.ST2_AUTH_TOKEN) { + self.robot.logger.info('Using ST2_AUTH_TOKEN as authentication. Expiry will lead to bot exit.'); + return Promise.resolve(); + } + + self.robot.logger.info('Requesting a token...'); + + var url = utils.parseUrl(env.ST2_AUTH_URL); + + self.auth_client = st2client({ + auth: { + protocol: url.protocol, + host: url.hostname, + port: url.port, + prefix: url.path + } + }); + + return self.auth_client.authenticate(env.ST2_AUTH_USERNAME, env.ST2_AUTH_PASSWORD) + .then(function (token) { + self.robot.logger.info('Token received. Expiring ' + token.expiry); + self.api_client.setToken(token); + self.auth_client.on('expiry', self.authenticate.bind(self)); + }) + .catch(function (err) { + self.robot.logger.error('Failed to authenticate: ' + err.message); + + throw err; + }); +}; + +StackStorm.prototype.loadCommands = function () { + var self = this; + + self.robot.logger.info('Loading commands....'); + + self.api_client.actionAlias.list({limit: -1}) + .then(function (aliases) { + // Remove all the existing commands + self.command_factory.removeCommands(); + + _.each(aliases, function (alias) { + var name = alias.name; + var formats = alias.formats; + var description = alias.description; + + if (alias.enabled === false) { + return; + } + + if (!formats || formats.length === 0) { + self.robot.logger.error('No formats specified for command: ' + name); + return; + } + + _.each(formats, function (format) { + var command = formatCommand(self.robot.logger, name, format.display || format, description); + self.command_factory.addCommand(command, name, format.display || format, alias, + format.display ? utils.DISPLAY : false); + + _.each(format.representation, function (representation) { + command = formatCommand(self.robot.logger, name, representation, description); + self.command_factory.addCommand(command, name, representation, alias, utils.REPRESENTATION); + }); + }); + }); + + self.robot.logger.info(self.command_factory.st2_hubot_commands.length + ' commands are loaded'); + }) + .catch(function (err) { + self.robot.logger.error(util.format('Failed to retrieve commands from "%s": %s', env.ST2_API_URL, err.message)); + if (err.status === 401 || err.message.includes('Unauthorized')) { + throw err; + } + }); +}; + +StackStorm.prototype.sendAck = function (msg, res) { + var self = this; + + var history_url = utils.getExecutionHistoryUrl(res.execution); + var history = history_url ? util.format(' (details available at %s)', history_url) : ''; + + if (res.actionalias && res.actionalias.ack) { + if (res.actionalias.ack.enabled === false) { + return; + } else if (res.actionalias.ack.append_url === false) { + history = ''; + } + } + + if (res.message) { + return msg.send(res.message + history); + } + + var message = util.format(_.sample(START_MESSAGES), res.execution.id); + return msg.send(message + history); +}; + +StackStorm.prototype.sendAliasExecutionRequest = function (msg, payload) { + var self = this; + + self.robot.logger.debug('Sending command payload:', JSON.stringify(payload)); + + self.api_client.aliasExecution.create(payload) + .then(function (res) { + self.sendAck(msg, res); + }) + .catch(function (err) { + // Compatibility with older StackStorm versions + if (err.status === 200) { + return self.sendAck(msg, { execution: { id: err.message } }); + } + self.robot.logger.error('Failed to create an alias execution:', err); + var addressee = self.adapter.normalizeAddressee(msg); + var message = util.format(_.sample(ERROR_MESSAGES), err.message); + if (err.requestId) { + message = util.format( + message, + util.format('; Use request ID %s to grep st2 api logs.', err.requestId)); + } + self.adapter.postData({ + whisper: false, + user: addressee.name, + channel: addressee.room, + message: message, + extra: { + color: '#F35A00' + } + }); + }); +}; + +StackStorm.prototype.executeCommand = function (msg, command_name, format_string, command, action_alias) { + var self = this; + + var addressee = self.adapter.normalizeAddressee(msg); + var payload = { + 'name': command_name, + 'format': format_string, + 'command': command, + 'user': addressee.name, + 'source_channel': addressee.room, + 'source_context': msg.envelope, + 'notification_route': env.ST2_ROUTE || 'hubot' + }; + + if (utils.enable2FA(action_alias)) { + var twofactor_id = uuid.v4(); + self.robot.logger.debug('Requested an action that requires 2FA. Guid: ' + twofactor_id); + msg.send(TWOFACTOR_MESSAGE); + self.api_client.executions.create({ + 'action': self.two_factor_authorization_enabled, + 'parameters': { + 'uuid': twofactor_id, + 'user': addressee.name, + 'channel': addressee.room, + 'hint': action_alias.description + } + }); + self.twofactor[twofactor_id] = { + 'msg': msg, + 'payload': payload + }; + } else { + self.sendAliasExecutionRequest(msg, payload); + } +}; + +StackStorm.prototype.install_sigusr2_handler = function () { + var self = this; + + process.on('SIGUSR2', function() { + self.robot.logger.debug("Caught SIGUSR2, reloading commands"); + self.loadCommands(); + }); +}; + +StackStorm.prototype.start = function () { + var self = this; + + self.api_client.stream.listen().catch(function (err) { + self.robot.logger.error('Unable to connect to stream:', err); + }).then(function (st2stream) { + // Save the connection stream object + self.st2stream = st2stream; + + self.st2stream.onerror = function (err) { + // TODO: squeeze a little bit more info out of evensource.js + self.robot.logger.warning('Stream error:', err); + if (err.status === 401) { + throw err; + } + }; + self.st2stream.addEventListener('st2.announcement__chatops', function (e) { + var data; + + self.robot.logger.debug('Chatops message received:', e.data); + + if (e.data) { + data = JSON.parse(e.data).payload; + } else { + data = e.data; + } + + + self.adapter.postData(data); + }); + + if (self.two_factor_authorization_enabled) { + st2stream.addEventListener('st2.announcement__2fa', function (e) { + var data; + + self.robot.logger.debug('Successfull two-factor auth:', e.data); + + if (e.data) { + data = JSON.parse(e.data).payload; + } else { + data = e.data; + } + + + }); + } + }); + + // Add an interval which tries to re-load the commands + self.commands_load_interval = setInterval(self.loadCommands.bind(self), (env.ST2_COMMANDS_RELOAD_INTERVAL * 1000)); + + // Initial command loading + self.loadCommands(); + + // Install SIGUSR2 handler which reloads the command + self.install_sigusr2_handler(); +}; + +StackStorm.prototype.stop = function () { + var self = this; + + clearInterval(self.commands_load_interval); + self.api_client.stream.listen().then(function (second_st2stream) { + second_st2stream.removeAllListeners(); + second_st2stream.close(); + }); +}; + + +module.exports = StackStorm; diff --git a/test/test-st2-invalid-auth.js b/test/test-st2-invalid-auth.js index fd9a239..e456df8 100644 --- a/test/test-st2-invalid-auth.js +++ b/test/test-st2-invalid-auth.js @@ -51,6 +51,7 @@ describe("invalid st2 credential configuration", function() { // Remove stackstorm.js from the require cache // https://medium.com/@gattermeier/invalidate-node-js-require-cache-c2989af8f8b0 delete require.cache[require.resolve("../src/stackstorm.js")]; + delete require.cache[require.resolve("../src/stackstorm_api.js")]; }); it("should error out with missing auth URL", function(done) { @@ -63,13 +64,8 @@ describe("invalid st2 credential configuration", function() { // Load script under test var stackstorm = require("../src/stackstorm.js"); - try { - stackstorm(robot); - done(new Error("The previous code should have thrown an exception")) - } catch (err) { - expect(err.message).to.equal("Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together."); - done(); - } + expect(stackstorm.bind(this, robot)).to.throw("Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together."); + done(); }); it("should error out with missing auth username", function(done) { @@ -82,13 +78,8 @@ describe("invalid st2 credential configuration", function() { // Load script under test var stackstorm = require("../src/stackstorm.js"); - try { - stackstorm(robot); - done(new Error("The previous code should have thrown an exception")) - } catch (err) { - expect(err.message).to.equal("Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together."); - done(); - } + expect(stackstorm.bind(this, robot)).to.throw("Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together."); + done(); }); @@ -102,16 +93,13 @@ describe("invalid st2 credential configuration", function() { // Load script under test var stackstorm = require("../src/stackstorm.js"); - try { - stackstorm(robot); - done(new Error("The previous code should have thrown an exception")) - } catch (err) { - expect(err.message).to.equal("Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together."); - done(); - } + expect(stackstorm.bind(this, robot)).to.throw("Env variables ST2_AUTH_USERNAME, ST2_AUTH_PASSWORD and ST2_AUTH_URL should only be used together."); + done(); }); it("should throw exception with bad auth URL", function(done) { + // Mock process.env for all modules + // https://glebbahmutov.com/blog/mocking-process-env/ restore_env = mockedEnv({ ST2_AUTH_URL: 'https://nonexistent-st2-auth-url:9101', ST2_AUTH_USERNAME: 'nonexistent-st2-auth-username', @@ -120,7 +108,9 @@ describe("invalid st2 credential configuration", function() { // Load script under test var i, stackstorm = require("../src/stackstorm.js"); - stackstorm(robot).catch(function (err) { + stackstorm(robot).then(function (result) { + done(new Error("The previous code should have thrown an exception")); + }).catch(function (err) { expect(error_spy.args).to.have.lengthOf(1); expect(error_spy.args[0][0]).to.be.a('string'); expect(error_spy.args[0][0]).to.startWith('Failed to authenticate'); diff --git a/test/test-st2-unauthorized.js b/test/test-st2-unauthorized.js index 85a4284..e355089 100644 --- a/test/test-st2-unauthorized.js +++ b/test/test-st2-unauthorized.js @@ -61,6 +61,10 @@ describe("auth with invalid st2 API key", function() { //done(); }); + // Remove stackstorm.js from the require cache + // https://medium.com/@gattermeier/invalidate-node-js-require-cache-c2989af8f8b0 + delete require.cache[require.resolve("../src/stackstorm.js")]; + delete require.cache[require.resolve("../src/stackstorm_api.js")]; var stackstorm = require("../src/stackstorm.js"); stackstorm(robot).then(function (result) { stop = result; @@ -76,6 +80,7 @@ describe("auth with invalid st2 API key", function() { // Remove stackstorm.js from the require cache // https://medium.com/@gattermeier/invalidate-node-js-require-cache-c2989af8f8b0 delete require.cache[require.resolve("../src/stackstorm.js")]; + delete require.cache[require.resolve("../src/stackstorm_api.js")]; }); // CAUTION: These tests are brittle - do not move them around, remove diff --git a/test/test-st2bot-envvars.js b/test/test-st2bot-envvars.js index c2f258f..9617450 100644 --- a/test/test-st2bot-envvars.js +++ b/test/test-st2bot-envvars.js @@ -33,16 +33,26 @@ describe("environment variable configuration", function () { var restore_env = null, debug_spy = sinon.spy(robot.logger, 'debug'), info_spy = sinon.spy(robot.logger, 'info'), + warning_spy = sinon.spy(robot.logger, 'warning'), error_spy = sinon.spy(robot.logger, 'error'); + before(function() { + // Remove stackstorm.js from the require cache + // https://medium.com/@gattermeier/invalidate-node-js-require-cache-c2989af8f8b0 + delete require.cache[require.resolve("../src/stackstorm.js")]; + delete require.cache[require.resolve("../src/stackstorm_api.js")]; + }); + afterEach(function() { restore_env && restore_env(); error_spy.resetHistory(); + warning_spy.resetHistory(); info_spy.resetHistory(); debug_spy.resetHistory(); // Remove stackstorm.js from the require cache // https://medium.com/@gattermeier/invalidate-node-js-require-cache-c2989af8f8b0 delete require.cache[require.resolve("../src/stackstorm.js")]; + delete require.cache[require.resolve("../src/stackstorm_api.js")]; if (robot) { robot.shutdown(); if (robot.server) { @@ -55,14 +65,17 @@ describe("environment variable configuration", function () { // Mock process.env for all modules // https://glebbahmutov.com/blog/mocking-process-env/ restore_env = mockedEnv({ - ST2_API: 'https://nonexistent-st2-auth-url:9101' + ST2_API: 'https://nonexistent-st2-auth-url:9101', + ST2_AUTH_URL: 'localhost:8000', + ST2_AUTH_USERNAME: 'user', + ST2_AUTH_PASSWORD: 'pass' }); // Load script under test var stackstorm = require("../src/stackstorm.js"); stackstorm(robot).then(function (stop) { - expect(robot.logger.logs.warning).to.have.length.above(0); - expect(robot.logger.logs.warning).to.contain( + expect(warning_spy.args).length.to.be.above(0); + expect(warning_spy).to.have.been.calledWith( 'ST2_API is deprecated and will be removed in a future releases. Instead, please use the '+ 'ST2_API_URL environment variable.'); @@ -70,7 +83,7 @@ describe("environment variable configuration", function () { done(); }).catch(function (err) { - console.log(err); + console.error(err); done(err); }); }); @@ -79,6 +92,9 @@ describe("environment variable configuration", function () { // Mock process.env for all modules // https://glebbahmutov.com/blog/mocking-process-env/ restore_env = mockedEnv({ + ST2_AUTH_URL: 'localhost:8000', + ST2_AUTH_USERNAME: 'user', + ST2_AUTH_PASSWORD: 'pass', HUBOT_2FA: 'true' }); @@ -87,13 +103,12 @@ describe("environment variable configuration", function () { stackstorm(robot).then(function (stop) { expect(info_spy.args).length.to.be.above(1); expect(info_spy).to.have.been.calledWith('Two-factor auth is enabled'); - expect(info_spy).to.have.been.calledWith('Loading commands....'); stop(); done(); }).catch(function (err) { - console.log(err); + console.error(err); done(err); }); });