World of BotCraft

DevOps related tasks are a large part of life for engineers in many orgs. Wouldn't being able to offload some of this to bots be awesome? Well, let's try and see what we can do.

We'll build a simple chat bot that helps figure out what bids you need to place for Spot instances based on historical prices. This normally requires logging into the console and looking at various historical graphs. It would be great if we could just ask our bot what our bid price should be.

The Convo

So the idea then, is to get something similar to a conversation like this going...

Matt
DevOps guy with too many things to do
JARED B.
Just A Really Excellent Devops Bot

    Intent: sayHi
  1. Hey Jared

  2. Hi there.

  3. Intent: getSpotPrices
  4. I want to find latest spot prices

  5. Sure. In which region?

  6. us-west-2

  7. Got it. What instance type?

  8. c4.4xlarge

  9. OK, max price for c4.4xlarge in us-west-2 over the past 2 days is $0.96340.
    Can I help with other instance types and regions?

  10. How about r3.8xlarge in us-east-1

  11. OK, max price for r3.8xlarge in us-east-1 over the past 2 days is $1.23340.
    Can I help with other instance types and regions?

  12. Intent: getCloudTrail
    Not part of included code, but shows how we can add/extend
  13. No, but can you check if changes were made to my environment

  14. Sure. What service?

  15. RDS

  16. I don't see any changes to RDS in your environment over the past 30 days
    Can I help check other services?

  17. No thanks, I have everything

  18. Ok. Ping me if you need anything else.

Looks neat. Let's build it!

AWS Services

We'll use the following services on AWS to develop our bot.

Familiarity with AWS Lambda and Amazon CloudWatch and how to use & leverage them can greatly help.

Getting Started

Please ensure that the region chosen in your console is Virginia (us-east-1). You can see this in the region dropdown in the top right-hand corner.

IAM

Because we'll be using AWS Lambda for the backend logic, we will need to grant this Lambda appropriate execution permissions and the appropriate trust relationships.

To do this, we will need to create the following IAM policies and attach it to an IAM role to associate with our Lambda.

Go to the AWS IAM console.

Permissions to invoke Lambda

Create a Lambda execution policy called DevopsBotLambdaPolicy that, when attached (to a role, user, or group), can give permissions required to execute Lambda functions.

Steps
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1475958598000",
            "Effect": "Allow",
            "Action": [
                "lambda:InvokeFunction",
                "lambda:UpdateFunctionConfiguration"
            ],
            "Resource": [ "*" ]
        }
    ]
}

Permissions to invoke Lex & Polly APIs

Repeat the same steps above and create a Lex and Polly invokation policy called DevopsBotLexPollyPollicy that allows access to Lex and Polly APIs.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1480120057000",
            "Effect": "Allow",
            "Action": [
                "lex:*",
                "polly:*"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

Permissions to Write Logs to CloudWatch

Now follow the same steps to create a final policy called DevopsBotLambdaLogWriter that grants permissions to write to CloudWatch logs. This is useful to see errors and debug your bot.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        }
    ]
}

Create a Role

Now we need to create an IAM Role and attach policies to it -- the 3 policies we created above and an default EC2 policy to this role to make it useful.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    },
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lex.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    },
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "channels.lex.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Here's a screenshot to helps show how that screen should look. Ignore the policy names and such, since they may not match verbatim.

Creating Bot Lambda

We assume you already know (and have used) Lambda, so not diving too deep into this here. But adding some quick instructions anyway on how to set it up.

Testing Lambda

If you want to test your lambda, you can. Click on Test button and enter this event.json to test it

{
  "messageVersion": "1.0",
  "invocationSource": "FulfillmentCodeHook",
  "userId": "johnnycash",
  "sessionAttributes":
  {
     "dummyKey1": "dummyValue1",
     "dummyKey2": "dummyValue2"
  },
  "bot":
  {
    "name": "SpotPriceBot",
    "alias": null,
    "version": "$LATEST"
  },
  "outputDialogMode": "Text",
  "currentIntent":
  {
    "name": "findMaxPrice",
    "slots":
    {
      "region": "us-west-2",
      "instanceType": "c4.8xlarge"
    },
    "confirmationStatus": "None"
  }
}

Lex

Now we have everything we need to start building our Lex bot.

Step 1 - Create Lex App

Step 2 - Create the sayHi intent

Now let's create our very first intent. This is the simplest of intents used just for greetings / salutations.

Step 3 - Configure the sayHi intent

Let's now update the sayHi intent and configure it with a few appropriate Utterances (a few different ways of how one person can greet another) and a Confirmation Prompt (the way the bot greets you back)

After providing each utterance, remember click the + button next to it.

Here are some sample greetings. For convenience, just copy-paste.

In the Cancel text box, enter (for when the user cancels, or doesn't want to continue chatting)

Once you've created these utterances, click on Save Intent.

Step 4 - Create the FindSpotPrices intent

Let's create our second intent. Let's call this findSpotPrices and click on Add.

Finally, click on Save Intent at the bottom (you may need to scroll down if it's off your screen).

We're going to leave this intent as-is and move forward with creating slot types (the next few steps) and then come back and update this intent to use those newly created slot types.

Step 5 - Create the AWSRegion slot type

In the left hand pane of the Lex console, look for a + button to the right of Slot Types. Click on the + button.

Call the slot AWSRegion and enter the region values below (copy-paste is fine). We just need a few, no need to enter the entire AWS region list.

And finally click on Add slot to intent button. What we're doing here is to add this SlotType to the findSpotPrices intent.

Once added, look under the Slots section. You will find the AWSRegion slot type that you just created.

What we've just done here, is configured Lex so that whenever it detects a findSpotPrices intent (via sample utterances that you will soon train it with) to always ask for an AWSRegion (because Required is checked) by prompting the user withSure. In which region?".

Step 6 - Create the AWSInstanceType slot type

Let's create another slot type for AWSInstanceType. In the left hand pane, click on the + button next to Slot types.

Now follow similar steps as above and create the AWSInstanceType slot.

Here are some sample AWS instance types to configure this slot with (fine to copy paste). Just a few instance types should do.

Here's a screenshot (ignore the exact values shown)

We have now configured Lex to additionally ask for AWSInstanceType (since Required is checked) when Lex detects the findSpotPrices intent.

Step 7 - Update the FindSpotPrices intent

Utterances:

You can think of utterances as - 'In how many ways can my user say something to express this intent?'

So let's go ahead and enter some sample utterances for the findSpotPrices intent.

Click on the + button and start adding utterances. Here are some below that you can copy-paste. Feel free to change it to what you think sounds most natural.

This last utterannce (the one above) is a bit special and shows you how you can use slot types in utterances to enable natural conversations.

SlotTypes

You should have already configured the prompt, but just give it a look and ensure that the prompts are configured for each SlotType.

The slots, slot types, and associated prompts should look something like this:

Required Name Slot Type Prompt
Yes region AWSRegion Sure. In which region?
Yes instanceType AWSInstanceType Got it. What instance type?

Be sure that you have clicked the + button next to the last utterance as well as the last slot promopt.

Fulfillment

Choose AWS Lambda function and select the lambda function that you just uploaded from the dropdown.

Follow-up message

You can copy-paste these message into the Follow-up message and Cancel text input boxes.

And finally click on Save Intent

Test your Bot

You should now have everything you need to start testing. Let's build the bot that we just created. To do this, click Build on the top right hand corner.

If everything's good and the build finishes successfully, you should see the Chat Bot in the lower right corner open up automatically.

Fire it up and start talking with Jared! You can start of by saying Hey Jared to see how your bot responds. Scroll up to see sample interaction.

Bot Code: Explained

The bot's Lambda backend is comprised of 4 files -- mostly to keep things small and separate.

index.js

This is Lambda's starting point (or Lambda Hook).

/*jshint esversion: 6 */
'use strict';

let SpotBot       = require('./spot_bot');
let LexMixin      = require('./lex_mixin');
let Colloquialism = require('./colloquialisms');

// Instantiate a new SpotBot with Lex features mixed in to it
let spotBot = SpotBot.newInstance(LexMixin);

/*
 * Lambda...
 */
exports.handler = (event, context, callback) =>
{
  console.info("Received event: ");
  console.dir(event); // Log a human readable event hash


  //
  // Part I. Get all the params and build the request
  //

  let regionSlot = spotBot.getSlotValue(event, "region");
  let region     = Colloquialism.getRegion(regionSlot);

  let instanceTypeSlot = spotBot.getSlotValue(event, "instanceType");
  let instanceType     = Colloquialism.getInstanceType(instanceTypeSlot);

  // Build the instance types parameter for the spot API call from Lex intent (event)
  let spotRequest  = SpotBot.buildSpotRequest(instanceType);

  // If different from default region, change it
  if (SpotBot.DEFAULT_REGION !== region)
  {
    SpotBot.changeRegion(region);
  }


  //
  // Part II. Find spot prices
  //

  SpotBot.findMaxPrice(spotRequest).then((price) =>
  {
    let response = `OK. Max price for ${instanceTypeSlot} in ${regionSlot} over past ${SpotBot.DURATION} days is $${price}`;

    callback(null, spotBot.respond(event, response));

  }).catch((exception) =>
  {
    // promise failed, log exception to console (for convenience) and let it bubble up
    console.log(exception);

    callback(exception);
  });
};

SpotBot.js

This class contains all code related to getting / calculating spot prices.

/*jshint esversion: 6 */
'use strict';

/** 
 * The meat of the bot. 
 */
class SpotBot
{
  /**
   * Method to create a new SpotBot with Lex mixed in
   */
  static newInstance(mixin)
  {
    // Mix Lex into the Bot. Nice to keep Lex and Bot code separate.
    Object.assign(this.prototype, mixin);

    return new SpotBot();
  }

  static changeRegion(region)
  {
    console.log(`Changing region to "${region}"`);

    SpotBot.AWS.config.region = region;
    SpotBot.ec2 = new SpotBot.AWS.EC2();
  }

  /**
   * Build & return a request for the describeSpotPriceHistory API call
   */
  static buildSpotRequest(instanceType)
  {
    let now = new Date();

    return {
      MaxResults: 10,
      StartTime: new Date(now.setDate(now.getDate() - 1)),
      EndTime: now,
      InstanceTypes: [instanceType]
    };
  }

  /**
   * Returns a promise that resolves to a JSON array of spot prices
   */
  static findSpotPriceHistory(instances)
  {
    return SpotBot.ec2.describeSpotPriceHistory(instances).promise();
  }

  /**
   * Returns a promise that resolves to a single maximum price
   */
  static findMaxPrice(instances)
  {
    // Nested promise (since findSpotPriceHistory also returns a promise)
    return Promise.resolve(SpotBot.findSpotPriceHistory(instances))
      .then(function(data)
      {
        let maxPrice = 0.0;

        data.SpotPriceHistory.forEach((pricing) =>
        {
          if (pricing.SpotPrice > maxPrice)
          {
            maxPrice = pricing.SpotPrice;
          }
        });

        return maxPrice;
      });
  }
}

SpotBot.DURATION = 2;

// This should be Lambda environment var, but doing it to simplify instructions
SpotBot.DEFAULT_REGION    = 'us-west-2';

SpotBot.AWS               = require('aws-sdk');
SpotBot.AWS.config.region = SpotBot.DEFAULT_REGION;

SpotBot.ec2 = new SpotBot.AWS.EC2();

module.exports = SpotBot;

LexMixin.js

This class contains code specific to Lex (constants and such) -- this is just to keep each source file small and focused.

/*jshint esversion: 6 */
'use strict';

/**
 * An adapter (mixin) that can be added to any bot class to make it easier to interface
 * with Lex (so the bot code stays clean, focused, and separate from Lex code)
 */
let LexMixin =
{
  /**
   * Returns a slot's value
   */
  getSlotValue: function(event, slotName)
  {
    return event.currentIntent.slots[slotName];
  },

  /**
   * Return conversational message as a Lex JSON response
   */
  respond: function(event, message)
  {
    return this.close(message, LexMixin.fulfillmentState.FULFILLED, event);
  },

  /**
   * Each convo started with Lex is a session. Close the session when we're done.
   */
  close: function(message, fulfillmentState, event)
  {
    // Set the response message as part of the sessionAttributes, so Lex can respond with
    // "Follow Up" configuration
    if (event.sessionAttributes === null)
    {
      event.sessionAttributes = {};
    }
    event.sessionAttributes["closeMessage"] = message;

    let responseJson = {
      sessionAttributes: event.sessionAttributes,
      dialogAction:
      {
        type: LexMixin.dialogActionType.CLOSE,
        fulfillmentState: fulfillmentState
      }
    };

    console.log(responseJson);
    return responseJson;
  },

  // Some Lex constants (for convenience)
  fulfillmentState:
  {
    FULFILLED: "Fulfilled",
    FAILED:    "Failed"
  },

  dialogActionType:
  {
    ELICIT_INTENT:  "ElicitIntent",
    ELICIT_SLOT:    "ElicitSlot",
    CONFIRM_INTENT: "ConfirmIntent",
    DELEGATE:       "Delegate",
    CLOSE:          "Close",
  },

  contentType:
  {
    PLAIN_TEXT: "PlainText",
    SSML:       "SSML"
  }
};

module.exports = LexMixin;

Colloquialism.js

Enables conversational references to things that are jargon-y

/*jshint esversion: 6 */
'use strict';

/**
 * Colloquialisms - to help define conversational references to complicated things
 */
let Colloquialisms =
{
  getRegion: function(region)
  {
    let _region = Colloquialisms.AWS_REGIONS[region.toLowerCase()];
    return _region === undefined ? region : _region;
  },

  getInstanceType: function(instanceType)
  {
    let _instanceType = Colloquialisms.AWS_INSTANCE_TYPES[instanceType.toLowerCase()];
    return _instanceType === undefined ? instanceType : _instanceType;
  },

  // Allow user to type Virginia instead of us-east-1, for example
  AWS_REGIONS:
  {
    virginia:    'us-east-1',
    ohio:        'us-east-2',
    california:  'us-west-1',
    oregon:      'us-west-2',
    ireland:     'eu-west-1',
    frankfurt:   'eu-central-1',
    tokyo:       'ap-northeast-1',
    seoul:       'ap-northeast-2',
    singapore:   'ap-southeast-1',
    sydney:      'ap-southeast-2',
    mumbai:      'ap-south-1',
    "sao paolo": 'sa-east-1'
  },

  // Instance type colloquialisms (feel free to tweak to suit your users)
  AWS_INSTANCE_TYPES:
  {
    "general purpose":   't2.nano',   // 't2.micro', 't2.small', 't2.medium', 't2.large'],
    "compute":           'c4.large',  // 'c4.xlarge', 'c4.2xlarge', 'c4.4xlarge', 'c4.8xlarge'],
    "compute optimized": 'c4.large',  // 'c4.xlarge', 'c4.2xlarge', 'c4.4xlarge', 'c4.8xlarge'],
    "memory":            'r3.large',  // 'r3.xlarge', 'r3.2xlarge', 'r3.4xlarge', 'r3.8xlarge'],
    "memory optimized":  'r3.large',  // 'r3.xlarge', 'r3.2xlarge', 'r3.4xlarge', 'r3.8xlarge'],
    "storage":           'i2.xlarge', // 'i2.2xlarge', 'i2.4xlarge', 'i2.8xlarge'],
    "storage opimized":  'i2.xlarge', // 'i2.2xlarge', 'i2.4xlarge', 'i2.8xlarge'],
    "gpu":               'p2.xlarge', // 'p2.8xlarge', 'p2.16xlarge', 'g2.2xlarge', 'g2.8xlarge']
    "accelerated":       'p2.xlarge'  // 'p2.8xlarge', 'p2.16xlarge', 'g2.2xlarge', 'g2.8xlarge']
  }
};

module.exports = Colloquialisms;

Quo Vadis

This here really just scratches the surface of what's possible. We could take this forward in many different ways, from offloading a vast array of mundane tasks all the way to sophisticated things like incorporating a Machine Learning backend that can predictively respond to user interactions!

The world is our Oyster. :)