Building a Command Line Utility with Node

Last week DevSmash went down for what I believe was the first time since I launched it back in September. The first thing I did was check out the Nodejitsu status page, which indicated that all their services were up and running. Since I was already hanging out in the #nodejitsu channel on Freenode, I mentioned my issue and was informed that MongoHQ (which Nodejitsu provides to customers for free) had experienced some hardware failure. Fortunately, the fine folks over at MongoHQ had things fully recovered within about 20 minutes, which represented the sum total of our downtime here.

So, why the story time? Well, given that DevSmash does rely on a few external services, I thought it would be nice to have a single place to look the next time something goes down. I had also been wanting to give TJ Holowaychuk's Commander a spin, so I took the opportunity to build a "cloud status" command line utility. In case it's useful to someone else, here's a quick overview of how it works:

Example Usage

Just to give an idea of where we're headed, this utility will know how to check the status of three different services:

  • Nodejitsu - where the DevSmash app server is deployed
  • MongoHQ (Alex Environment) - where the DevSmash data is
  • EC2 (US East Region) - not used by DevSmash, but added as example due to popularity

The CLI itself exposes two commands, which operate as follows:

list: lists all supported servies.

$ ./status list
The following services are supported:
  mongohq-alex
  nodejitsu
  ec2-us-east-1

check: checks the status of all services.

$ ./status check
Latest messages for: ec2-us-east-1
  Jan 17 2013 10:55 | [RESOLVED] Increased API Error Rates
  Jan 09 2013 14:10 | [RESOLVED] Increased API error rate
  Jan 02 2013 02:11 | [RESOLVED] Increased API Errors
  Dec 27 2012 00:06 | [Resolved] EC2 Network connectivity and elevated EBS latencies 
  Dec 25 2012 16:18 | [RESOLVED] Elastic Load Balancer issues
Latest messages for: nodejitsu
  Jan 14 2013 09:41 | All good!
  Jan 09 2013 12:24 | Connection issues with newly created IrisCouch Redis instances
  Dec 25 2012 16:33 | All Good!
  Dec 23 2012 10:45 | Balancer issues!
Latest messages for: mongohq-alex
  Jan 14 2013 16:19 | Environment is fully recovered and all database processes are now running properly.
  Jan 14 2013 16:04 | Server is back up and functional, we are verifying that all database processes are running correctly.
  Jan 14 2013 16:02 | The ephemeral disk on the Alex environment experienced a failure. All DB data is safe, but the instance needs to be stopped and moved to new hardware. Should be back up in a few minute.

check [service]: or, alternatively, check the status of a single service.

$ ./status check nodejitsu
Latest messages for: nodejitsu
  Jan 14 2013 09:41 | All good!
  Jan 09 2013 12:24 | Connection issues with newly created IrisCouch Redis instances
  Dec 25 2012 16:33 | All Good!
  Dec 23 2012 10:45 | Balancer issues!

Commander is also able to create some --help (or -h) output automatically:

$ ./status --help

  Usage: status [options] [command]

  Commands:

    list                   list all supported services
    check [service]        check the status of [service], or of all services if omitted

  Options:

    -h, --help     output usage information
    -V, --version  output the version number

File Structure

One of the nice things about Commander is that it's virtually free of boilerplate code. Our entire command line utility only needs two files:

./package.json

Assuming your projects will end up having one dependency or another, it's a good idea to get into the habit of starting things off with a package.json. Fortunately, this is pretty easy to do with the npm init command, which helps with package.json scaffolding. Here's what my process looked like (note that several of the prompts could simply be skipped):

$ mkdir ~/Projects/status
$ cd ~/Projects/status
$ npm init
This utility will walk you through creating a package.json file.
...[verbiage skipped]...

Press ^C at any time to quit.
name: (status)
version: (0.0.0) 0.0.1
description: CLI for checking the operational status of various cloud services
entry point: (index.js) status
test command: 
git repository: 
keywords: 
author: Jeremy Martin <jmar777@gmail.com>
license: (BSD) 
About to write to /Users/jeremymartin/Projects/status/package.json:

{
  "name": "status",
  "version": "0.0.1",
  "description": "CLI for checking the operational status of various cloud services",
  "main": "status",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": "",
  "author": "Jeremy Martin <jmar777@gmail.com>",
  "license": "BSD"
}

Is this ok? (yes)

This gets us about 90% of the way there, but we still need to make a couple tweaks like specifying our dependencies and cleaning up some keys we don't need. We'll also tell npm that the forthcoming ./status file is our "binary", which will cause it to be registered during global installs. Crack open the new package.json and make the updates needed to look like the following:

{
  "name": "status",
  "version": "0.0.1",
  "description": "CLI for checking the operational status of various cloud services",
  "main": "status",
  "bin": { "status": "./status" },
  "dependencies": {
    "commander": "1.1.x",
    "request": "2.12.x"
  },
  "author": "Jeremy Martin <jmar777@gmail.com>",
  "license": "BSD"
}

Lastly, we need to install the dependencies themselves:

$ npm install

./status

Our second file is our actual CLI program - since we'll be running it as an executable (rather than passing it as an argument to node) we'll need to make sure we set the executable flag on it:

$ touch status
$ chmod u+x status

At this point, your permissions should look like: -rwxr--r--.

To avoid stepping through this whole file line by line, I'm just going to dump it all right here with gratuitous code comments:

#!/usr/bin/env node
// ^ Since we've made this an executable, we need the shebang directive.

// Require our dependencies
var program = require('commander'),
    request = require('request');

// We're going to do a lot of printing...
var log = console.log;

// CLI/Commander code starts here
program.version('0.0.1');

// Define the "list" command, allowing users to list all supported services.
// The action callback defines our behavior for this command, which simply
// prints the keys from our services map (below).
program.command('list')
    .description('list all supported services')
    .action(function() {
        log('The following services are supported:');
        Object.keys(services).forEach(function(serviceName) {
            log('  %s', serviceName);
        });
    });

// Define the "check" command, allowing users to check the status of all
// supported services (or, optionally, of a single service).  Note that
// Commander automatically knows about the optional "[service]" argument based
// on the `command()` statement.
program.command('check [service]')
    .description('check the status of [service], or of all services if omitted')
    .action(function(service) {
        (service ? [service] : Object.keys(services)).forEach(getStatus);
    });

// Helper function for fetching and printing a service's current status.
function getStatus(serviceName) {
    // Make sure the specified service is supported.
    if (!services.hasOwnProperty(serviceName)) {
        log('Unknown service: %s', serviceName);
        log('Hint: use "list" command to view supported services.');
        return;
    }

    // Grab our service data from the map and make the request.
    var service = services[serviceName],
        opts = { url: service.url, json: true, timeout: 10000 };
    request.get(opts, function(error, resp, body) {
        if (error) {
            log('Error checking status of service: %s', serviceName);
            log(error);
            return;
        }

        if (resp.statusCode !== 200) {
            var format = 'Received non-200 status code for service: %s (%s)';
            log(format, serviceName, resp.statusCode);
            return;
        }

        // Each service definition provides its own logic for getting the
        // correct data out of the response.
        var events = service.getEvents(body);

        // Print the first 5 results.
        log('Latest messages for: %s', serviceName);
        events.slice(0, 5).forEach(function(event) {
            var date = event.date.toString().substr(4, 17);
            log('  %s | %s', date, event.message);
        });
    });
};

// Our map of supported services - each service consists of the URL of the JSON
// service to hit, and a function that parses the most recent status events from
// the response.
var services = {
    'mongohq-alex': {
        url: 'http://status.mongohq.com/api/v1/services/alex/events',
        getEvents: function(data) {
            return data.events.map(function(event) {
                return {
                    date: new Date(event.timestamp),
                    message: event.message
                };
            });
        }
    },
    'nodejitsu': {
        url: 'http://status.nodejitsu.com/aggregate',
        getEvents: function(data) {
            return data.updates.map(function(event) {
                return {
                    date: new Date(+event.date),
                    message: event.title
                };
            });
        }
    },
    'ec2-us-east-1': {
        url: 'http://status.aws.amazon.com/data.json',
        getEvents: function(data) {
            return data.archive.filter(function(event) {
                return event.service === 'ec2-us-east-1';
            }).map(function(event) {
                return {
                    date: new Date(1000 * +event.date),
                    message: event.summary
                };
            }).reverse();
        }
    }
};

// Tell Commander to parse the arguments array, which effectively starts our program.
program.parse(process.argv);

// Lastly, display our help text if a command wasn't specified. Note that this has
// to be after parse() so that the args array is populated.
if (!program.args.length) program.help();

And... that's it! Just run $ ./status <command> from your terminal to try it out. Thanks for reading!

comments powered by Disqus