]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
heat cli : initial heat-watch cloudwatch API client
authorSteven Hardy <shardy@redhat.com>
Wed, 22 Aug 2012 09:30:46 +0000 (10:30 +0100)
committerSteven Hardy <shardy@redhat.com>
Fri, 31 Aug 2012 17:27:48 +0000 (18:27 +0100)
Implements new client to demonstrate new Cloudwatch API

Currently only provides options for DescribeAlarms,
ListMetrics, PutMetricData and SetAlarmState

Signed-off-by: Steven Hardy <shardy@redhat.com>
Change-Id: I3963a07694cec9af96d9d7369cc7d18d629fcd2d

bin/heat-watch [new file with mode: 0755]
etc/boto.cfg
heat/boto_client.py
heat/boto_client_cloudwatch.py [new file with mode: 0644]
setup.py

diff --git a/bin/heat-watch b/bin/heat-watch
new file mode 100755 (executable)
index 0000000..b35644d
--- /dev/null
@@ -0,0 +1,281 @@
+#!/usr/bin/env python
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+#    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.
+
+"""
+This is the administration program for heat-api-cloudwatch.
+It is simply a command-line interface for adding, modifying, and retrieving
+information about the cloudwatch alarms and metrics belonging to a user.
+It is a convenience application that talks to the heat Cloudwatch API server.
+"""
+
+import gettext
+import optparse
+import os
+import os.path
+import sys
+import time
+import json
+import logging
+
+from urlparse import urlparse
+# If ../heat/__init__.py exists, add ../ to Python search path, so that
+# it will override what happens to be installed in /usr/(local/)lib/python...
+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
+                                   os.pardir,
+                                   os.pardir))
+if os.path.exists(os.path.join(possible_topdir, 'heat', '__init__.py')):
+    sys.path.insert(0, possible_topdir)
+
+scriptname = os.path.basename(sys.argv[0])
+
+gettext.install('heat', unicode=1)
+
+from heat import  boto_client_cloudwatch as heat_client
+from heat import version
+from heat.common import config
+from heat.common import exception
+from heat import utils
+
+DEFAULT_PORT=8003
+
+@utils.catch_error('alarm-describe')
+def alarm_describe(options, arguments):
+    '''
+    Describe detail for specified alarm, or all alarms
+    if no AlarmName is specified
+    '''
+    parameters={}
+    try:
+        parameters['AlarmName'] = arguments.pop(0)
+    except IndexError:
+        logging.info("No AlarmName passed, getting results for ALL alarms")
+
+    c = heat_client.get_client(options.port)
+    result = c.describe_alarm(**parameters)
+    print c.format_metric_alarm(result)
+
+@utils.catch_error('alarm-set-state')
+def alarm_set_state(options, arguments):
+    '''
+    Temporarily set state for specified alarm
+    '''
+    usage = ('''Usage:
+%s alarm-set-state AlarmName StateValue [StateReason]''' %
+        (scriptname))
+
+    parameters={}
+    try:
+        parameters['AlarmName'] = arguments.pop(0)
+        parameters['StateValue'] = arguments.pop(0)
+    except IndexError:
+        logging.error("Must specify AlarmName and StateValue")
+        print usage
+        print "StateValue must be one of %s, %s or %s" % (
+                      heat_client.BotoCWClient.ALARM_STATES)
+        return utils.FAILURE
+    try:
+        parameters['StateReason'] = arguments.pop(0)
+    except IndexError:
+        parameters['StateReason'] = ""
+
+    # We don't handle attaching data to state via this cli tool (yet)
+    parameters['StateReasonData'] = None
+
+    c = heat_client.get_client(options.port)
+    result = c.set_alarm_state(**parameters)
+    print result
+
+
+@utils.catch_error('metric-list')
+def metric_list(options, arguments):
+    '''
+    List all metric data for a given metric (or all metrics if none specified)
+    '''
+    parameters={}
+    try:
+        parameters['MetricName'] = arguments.pop(0)
+    except IndexError:
+        logging.info("No MetricName passed, getting results for ALL alarms")
+
+    c = heat_client.get_client(options.port)
+    result = c.list_metrics(**parameters)
+    print c.format_metric(result)
+
+
+@utils.catch_error('metric-put-data')
+def metric_put_data(options, arguments):
+    '''
+    Create a datapoint for a specified metric
+    '''
+    usage = ('''Usage:
+%s metric-put-data AlarmName Namespace MetricName Units MetricValue
+e.g
+%s metric-put-data HttpFailureAlarm system/linux ServiceFailure Count 1
+''' % (scriptname, scriptname))
+
+    # NOTE : we currently only support metric datapoints associated with a
+    # specific AlarmName, due to the current engine/db cloudwatch
+    # implementation, we should probably revisit this so we can support
+    # more generic metric data collection
+    parameters={}
+    try:
+        parameters['AlarmName'] = arguments.pop(0)
+        parameters['Namespace'] = arguments.pop(0)
+        parameters['MetricName'] = arguments.pop(0)
+        parameters['MetricUnit'] = arguments.pop(0)
+        parameters['MetricValue'] = arguments.pop(0)
+    except IndexError:
+        logging.error("Please specify the alarm, metric, unit and value")
+        print usage
+        return utils.FAILURE
+
+    c = heat_client.get_client(options.port)
+    result = c.put_metric_data(**parameters)
+    print result
+
+
+def create_options(parser):
+    """
+    Sets up the CLI and config-file options that may be
+    parsed and program commands.
+
+    :param parser: The option parser
+    """
+    parser.add_option('-v', '--verbose', default=False, action="store_true",
+                      help="Print more verbose output")
+    parser.add_option('-d', '--debug', default=False, action="store_true",
+                      help="Print more verbose output")
+    parser.add_option('-p', '--port', dest="port", type=int,
+                      default=DEFAULT_PORT,
+                      help="Port the heat API host listens on. "
+                           "Default: %default")
+
+
+def parse_options(parser, cli_args):
+    """
+    Returns the parsed CLI options, command to run and its arguments, merged
+    with any same-named options found in a configuration file
+
+    :param parser: The option parser
+    """
+    if not cli_args:
+        cli_args.append('-h')  # Show options in usage output...
+
+    (options, args) = parser.parse_args(cli_args)
+
+    # HACK(sirp): Make the parser available to the print_help method
+    # print_help is a command, so it only accepts (options, args); we could
+    # one-off have it take (parser, options, args), however, for now, I think
+    # this little hack will suffice
+    options.__parser = parser
+
+    if not args:
+        parser.print_usage()
+        sys.exit(0)
+
+    command_name = args.pop(0)
+    command = lookup_command(parser, command_name)
+
+    if options.debug:
+        logging.basicConfig(format='%(levelname)s:%(message)s',
+            level=logging.DEBUG)
+        logging.debug("Debug level logging enabled")
+    elif options.verbose:
+        logging.basicConfig(format='%(levelname)s:%(message)s',
+            level=logging.INFO)
+    else:
+        logging.basicConfig(format='%(levelname)s:%(message)s',
+            level=logging.WARNING)
+
+    return (options, command, args)
+
+
+def print_help(options, args):
+    """
+    Print help specific to a command
+    """
+    parser = options.__parser
+
+    if not args:
+        parser.print_usage()
+
+    subst = {'prog': os.path.basename(sys.argv[0])}
+    docs = [lookup_command(parser, cmd).__doc__ % subst for cmd in args]
+    print '\n\n'.join(docs)
+
+
+def lookup_command(parser, command_name):
+    base_commands = {'help': print_help}
+
+    watch_commands = {
+        'describe': alarm_describe,
+        'set-state': alarm_set_state,
+        'metric-list': metric_list,
+        'metric-put-data': metric_put_data}
+
+    commands = {}
+    for command_set in (base_commands, watch_commands):
+        commands.update(command_set)
+
+    try:
+        command = commands[command_name]
+    except KeyError:
+        parser.print_usage()
+        sys.exit("Unknown command: %s" % command_name)
+
+    return command
+
+
+def main():
+    '''
+    '''
+    usage = """
+%prog <command> [options] [args]
+
+Commands:
+
+    help <command>  Output help for one of the commands below
+
+    describe                    Describe a specified alarm (or all alarms)
+
+    set-state                   Temporarily set the state of an alarm
+
+    metric-list                 List data-points for specified metric
+
+    metric-put-data             Publish data-point for specified  metric
+
+"""
+    oparser = optparse.OptionParser(version='%%prog %s'
+                                    % version.version_string(),
+                                    usage=usage.strip())
+    create_options(oparser)
+    (opts, cmd, args) = parse_options(oparser, sys.argv[1:])
+
+    try:
+        start_time = time.time()
+        result = cmd(opts, args)
+        end_time = time.time()
+        logging.debug("Completed in %-0.4f sec." % (end_time - start_time))
+        sys.exit(result)
+    except (RuntimeError,
+            NotImplementedError,
+            exception.ClientConfigurationError), ex:
+        oparser.print_usage()
+        logging.error("ERROR: %s" % ex)
+        sys.exit(1)
+
+
+if __name__ == '__main__':
+    main()
index 384906d2c2b7e280b6d085d7522c4a4c8797d0cc..a043fbad6694c5e07223c7765bd2ae65c57f4a1d 100644 (file)
@@ -11,6 +11,9 @@ debug = 0
 cfn_region_name = heat
 cfn_region_endpoint = 127.0.0.1
 
+cloudwatch_region_name = heat
+cloudwatch_region_endpoint = 127.0.0.1
+
 # Set the client retries to 1, or errors connecting to heat repeat
 # which is not useful when debugging API issues
 num_retries = 1
index 7b5d84d03d8dbb15cdb64234a4fd806e1c7918da..d2de0000779f249fa6ddf7cae580325a6e6c03bd 100644 (file)
@@ -25,6 +25,9 @@ import json
 
 
 class BotoClient(CloudFormationConnection):
+    '''
+    Wrapper class for boto CloudFormationConnection class
+    '''
 
     def list_stacks(self, **kwargs):
         return super(BotoClient, self).list_stacks()
@@ -274,7 +277,7 @@ def get_client(host, port=None, username=None,
                is_silent_upload=False, insecure=True):
 
     """
-    Returns a new boto client object to a heat server
+    Returns a new boto Cloudformation client connection to a heat server
     """
 
     # Note we pass None/None for the keys so boto reads /etc/boto.cfg
@@ -286,7 +289,7 @@ def get_client(host, port=None, username=None,
     if cloudformation:
         logger.debug("Got CF connection object OK")
     else:
-        logger.error("Error establishing connection!")
+        logger.error("Error establishing Cloudformation connection!")
         sys.exit(1)
 
     return cloudformation
diff --git a/heat/boto_client_cloudwatch.py b/heat/boto_client_cloudwatch.py
new file mode 100644 (file)
index 0000000..27d3e0d
--- /dev/null
@@ -0,0 +1,210 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+#
+#    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.
+
+"""
+Client implementation based on the boto AWS client library
+"""
+
+from heat.openstack.common import log as logging
+logger = logging.getLogger(__name__)
+
+from boto.ec2.cloudwatch import CloudWatchConnection
+from boto.ec2.cloudwatch.metric import Metric
+from boto.ec2.cloudwatch.alarm import MetricAlarm, MetricAlarms
+from boto.ec2.cloudwatch.alarm import AlarmHistoryItem
+from boto.ec2.cloudwatch.datapoint import Datapoint
+
+
+class BotoCWClient(CloudWatchConnection):
+    '''
+    Wrapper class for boto CloudWatchConnection class
+    '''
+    # TODO : These should probably go in the CW API and be imported
+    DEFAULT_NAMESPACE = "heat/unknown"
+    METRIC_UNITS = ("Seconds", "Microseconds", "Milliseconds", "Bytes",
+                  "Kilobytes", "Megabytes", "Gigabytes", "Terabytes",
+                  "Bits", "Kilobits", "Megabits", "Gigabits", "Terabits",
+                  "Percent", "Count", "Bytes/Second", "Kilobytes/Second",
+                  "Megabytes/Second", "Gigabytes/Second", "Terabytes/Second",
+                  "Bits/Second", "Kilobits/Second", "Megabits/Second",
+                  "Gigabits/Second", "Terabits/Second", "Count/Second", None)
+    METRIC_COMPARISONS = (">=", ">", "<", "<=")
+    ALARM_STATES = ("OK", "ALARM", "INSUFFICIENT_DATA")
+    METRIC_STATISTICS = ("Average", "Sum", "SampleCount", "Maximum", "Minimum")
+
+    # Note, several of these boto calls take a list of alarm names, so
+    # we could easily handle multiple alarms per-action, but in the
+    # interests of keeping the client simple, we just handle one 'AlarmName'
+
+    def describe_alarm(self, **kwargs):
+        # If no AlarmName specified, we pass None, which returns
+        # results for ALL alarms
+        try:
+            name = kwargs['AlarmName']
+        except KeyError:
+            name = None
+        return super(BotoCWClient, self).describe_alarms(
+                     alarm_names=[name])
+
+    def list_metrics(self, **kwargs):
+        # list_metrics returns non-null index in next_token if there
+        # are more than 500 metric results, in which case we have to
+        # re-read with the token to get the next batch of results
+        #
+        # Also note that we can do more advanced filtering by dimension
+        # and/or namespace, but for simplicity we only filter by
+        # MetricName for the time being
+        try:
+            name = kwargs['MetricName']
+        except KeyError:
+            name = None
+
+        results = []
+        token = None
+        while True:
+            results.append(super(BotoCWClient, self).list_metrics(
+                                 next_token=token,
+                                 dimensions=None,
+                                 metric_name=name,
+                                 namespace=None))
+            if not token:
+                break
+
+        return results
+
+    def put_metric_data(self, **kwargs):
+        '''
+        Publish metric data points to CloudWatch
+        '''
+        try:
+            metric_name = kwargs['MetricName']
+            metric_unit = kwargs['MetricUnit']
+            metric_value = kwargs['MetricValue']
+            metric_namespace = kwargs['Namespace']
+        except KeyError:
+            logger.error("Must pass MetricName, MetricUnit, " +\
+                          "Namespace, MetricValue!")
+            return
+
+        try:
+            metric_unit = kwargs['MetricUnit']
+        except KeyError:
+            metric_unit = None
+
+        # If we're passed AlarmName, we attach it to the metric
+        # as a dimension
+        try:
+            metric_dims = [{'AlarmName': kwargs['AlarmName']}]
+        except KeyError:
+            metric_dims = []
+
+        if metric_unit not in self.METRIC_UNITS:
+            logger.error("MetricUnit not an allowed value")
+            logger.error("MetricUnit must be one of %s" % self.METRIC_UNITS)
+            return
+
+        return super(BotoCWClient, self).put_metric_data(
+                     namespace=metric_namespace,
+                     name=metric_name,
+                     value=metric_value,
+                     timestamp=None,  # This means use "now" in the engine
+                     unit=metric_unit,
+                     dimensions=metric_dims,
+                     statistics=None)
+
+    def set_alarm_state(self, **kwargs):
+        return super(BotoCWClient, self).set_alarm_state(
+                     alarm_name=kwargs['AlarmName'],
+                     state_reason=kwargs['StateReason'],
+                     state_value=kwargs['StateValue'],
+                     state_reason_data=kwargs['StateReasonData'])
+
+    def format_metric_alarm(self, alarms):
+        '''
+        Return string formatted representation of
+        boto.ec2.cloudwatch.alarm.MetricAlarm objects
+        '''
+        ret = []
+        for s in alarms:
+            ret.append("AlarmName : %s" % s.name)
+            ret.append("AlarmDescription : %s" % s.description)
+            ret.append("ActionsEnabled : %s" % s.actions_enabled)
+            ret.append("AlarmActions : %s" % s.alarm_actions)
+            ret.append("AlarmArn : %s" % s.alarm_arn)
+            ret.append("AlarmConfigurationUpdatedTimestamp : %s" %
+                        s.last_updated)
+            ret.append("ComparisonOperator : %s" % s.comparison)
+            ret.append("Dimensions : %s" % s.dimensions)
+            ret.append("EvaluationPeriods : %s" % s.evaluation_periods)
+            ret.append("InsufficientDataActions : %s" %
+                        s.insufficient_data_actions)
+            ret.append("MetricName : %s" % s.metric)
+            ret.append("Namespace : %s" % s.namespace)
+            ret.append("OKActions : %s" % s.ok_actions)
+            ret.append("Period : %s" % s.period)
+            ret.append("StateReason : %s" % s.state_reason)
+            ret.append("StateUpdatedTimestamp : %s" %
+                        s.last_updated)
+            ret.append("StateValue : %s" % s.state_value)
+            ret.append("Statistic : %s" % s.statistic)
+            ret.append("Threshold : %s" % s.threshold)
+            ret.append("Unit : %s" % s.unit)
+            ret.append("--")
+        return '\n'.join(ret)
+
+    def format_metric(self, metrics):
+        '''
+        Return string formatted representation of
+        boto.ec2.cloudwatch.metric.Metric objects
+        '''
+        # Boto appears to return metrics as a list-inside-a-list
+        # probably a bug in boto, but work around here
+        if len(metrics) == 1:
+            metlist = metrics[0]
+        elif len(metrics) == 0:
+            metlist = []
+        else:
+            # Shouldn't get here, unless boto gets fixed..
+            logger.error("Unexpected metric list-of-list length (boto fixed?)")
+            return "ERROR\n--"
+
+        ret = []
+        for m in metlist:
+                ret.append("MetricName : %s" % m.name)
+                ret.append("Namespace  : %s" % m.namespace)
+                ret.append("Dimensions : %s" % m.dimensions)
+                ret.append("--")
+        return '\n'.join(ret)
+
+
+def get_client(port=None):
+    """
+    Returns a new boto CloudWatch client connection to a heat server
+    Note : Configuration goes in /etc/boto.cfg, not via arguments
+    """
+
+    # Note we pass None/None for the keys so boto reads /etc/boto.cfg
+    # Also note is_secure is defaulted to False as HTTPS connections
+    # don't seem to work atm, FIXME
+    cloudwatch = BotoCWClient(aws_access_key_id=None,
+        aws_secret_access_key=None, is_secure=False,
+        port=port, path="/v1")
+    if cloudwatch:
+        logger.debug("Got CW connection object OK")
+    else:
+        logger.error("Error establishing CloudWatch connection!")
+        sys.exit(1)
+
+    return cloudwatch
index 092c02032937a4553e2d56e95572067f757b2b38..163700edaa7f77b1123999e5c684bb26dd81e2ec 100755 (executable)
--- a/setup.py
+++ b/setup.py
@@ -49,5 +49,6 @@ setuptools.setup(
              'bin/heat-boto',
              'bin/heat-metadata',
              'bin/heat-engine',
+             'bin/heat-watch',
              'bin/heat-db-setup'],
     py_modules=[])