UNPKG

hubot-stackstorm

Version:

A hubot plugin for integrating with StackStorm event-driven infrastructure automation platform.

505 lines (412 loc) 15.7 kB
// 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('./utils.js'), formatCommand = require('./format_command.js'), CommandFactory = require('./command_factory.js'), adapters = require('./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.error("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 ack.extra.slack.thread_response is set in the action-alias definition, // then we will thread the ACK response message (SLACK ONLY) var threaded_message = {} if (res.extra && res.extra.slack && res.extra.slack.thread_response) { threaded_message.thread_ts = msg.message.id; } if (res.message && threaded_message.thread_ts !== undefined) { threaded_message.text = res.message + history; return msg.send(threaded_message); } else 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.message, '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.error('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;