diff --git a/bin/heat-watch b/bin/heat-watch new file mode 100755 index 0000000000..b35644d516 --- /dev/null +++ b/bin/heat-watch @@ -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 [options] [args] + +Commands: + + help 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() diff --git a/etc/boto.cfg b/etc/boto.cfg index 384906d2c2..a043fbad66 100644 --- a/etc/boto.cfg +++ b/etc/boto.cfg @@ -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 diff --git a/heat/boto_client.py b/heat/boto_client.py index 7b5d84d03d..d2de000077 100644 --- a/heat/boto_client.py +++ b/heat/boto_client.py @@ -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 index 0000000000..27d3e0da4d --- /dev/null +++ b/heat/boto_client_cloudwatch.py @@ -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 diff --git a/setup.py b/setup.py index 092c020329..163700edaa 100755 --- 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=[])