]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
heat API : Implement initial CloudWatch API
authorSteven Hardy <shardy@redhat.com>
Wed, 22 Aug 2012 09:30:03 +0000 (10:30 +0100)
committerSteven Hardy <shardy@redhat.com>
Fri, 31 Aug 2012 17:27:48 +0000 (18:27 +0100)
Initial AWS-compatible CloudWatch API implementation
Supports the following API actions:
- DescribeAlarms : describe alarm/watch details
- ListMetrics : List watch metric datapoints
- PutMetricData : Create metric datapoint
- SetAlarmState : temporarily set alarm state

Skeleton implementation of all other TODO actions which
returns HeatAPINotImplementedError.

Only basic filtering parameters supported at this time.

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

bin/heat-api-cloudwatch [new file with mode: 0755]
etc/heat-api-cloudwatch-paste.ini [new file with mode: 0644]
etc/heat-api-cloudwatch.conf [new file with mode: 0644]
heat/api/cloudwatch/__init__.py [new file with mode: 0644]
heat/api/cloudwatch/watch.py [new file with mode: 0644]
heat/tests/test_api_cloudwatch.py [new file with mode: 0644]
setup.py

diff --git a/bin/heat-api-cloudwatch b/bin/heat-api-cloudwatch
new file mode 100755 (executable)
index 0000000..7c88518
--- /dev/null
@@ -0,0 +1,59 @@
+#!/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.
+
+"""
+Heat API Server.  This implements an approximation of the Amazon
+CloudWatch API and translates it into a native representation.  It then
+calls the heat-engine via AMQP RPC to implement them.
+"""
+
+import gettext
+import os
+import sys
+
+# 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)
+
+gettext.install('heat', unicode=1)
+
+from heat.common import config
+from heat.common import wsgi
+
+from heat.openstack.common import cfg
+from heat.openstack.common import log as logging
+
+LOG = logging.getLogger('heat.api.cloudwatch')
+
+if __name__ == '__main__':
+    try:
+        cfg.CONF(project='heat', prog='heat-api-cloudwatch')
+        config.setup_logging()
+        config.register_api_opts()
+
+        app = config.load_paste_app()
+
+        port = cfg.CONF.bind_port
+        host = cfg.CONF.bind_host
+        LOG.info('Starting Heat CloudWatch API on %s:%s' % (host, port))
+        server = wsgi.Server()
+        server.start(app, cfg.CONF, default_port=port)
+        server.wait()
+    except RuntimeError, e:
+        sys.exit("ERROR: %s" % e)
diff --git a/etc/heat-api-cloudwatch-paste.ini b/etc/heat-api-cloudwatch-paste.ini
new file mode 100644 (file)
index 0000000..da1e83d
--- /dev/null
@@ -0,0 +1,88 @@
+
+# Default pipeline
+[pipeline:heat-api-cloudwatch]
+pipeline = versionnegotiation ec2authtoken authtoken context apicwapp
+
+# Use the following pipeline for keystone auth
+# i.e. in heat-api-cloudwatch.conf:
+#   [paste_deploy]
+#   flavor = keystone
+#
+[pipeline:heat-api-cloudwatch-keystone]
+pipeline = versionnegotiation ec2authtoken authtoken context apicwapp
+
+# Use the following pipeline to enable transparent caching of image files
+# i.e. in heat-api-cloudwatch.conf:
+#   [paste_deploy]
+#   flavor = caching
+#
+[pipeline:heat-api-cloudwatch-caching]
+pipeline = versionnegotiation ec2authtoken authtoken context cache apicwapp
+
+# Use the following pipeline for keystone auth with caching
+# i.e. in heat-api-cloudwatch.conf:
+#   [paste_deploy]
+#   flavor = keystone+caching
+#
+[pipeline:heat-api-cloudwatch-keystone+caching]
+pipeline = versionnegotiation ec2authtoken authtoken context cache apicwapp
+
+# Use the following pipeline to enable the Image Cache Management API
+# i.e. in heat-api-cloudwatch.conf:
+#   [paste_deploy]
+#   flavor = cachemanagement
+#
+[pipeline:heat-api-cloudwatch-cachemanagement]
+pipeline = versionnegotiation ec2authtoken authtoken context cache cachemanage apicwapp
+
+# Use the following pipeline for keystone auth with cache management
+# i.e. in heat-api-cloudwatch.conf:
+#   [paste_deploy]
+#   flavor = keystone+cachemanagement
+#
+[pipeline:heat-api-cloudwatch-keystone+cachemanagement]
+pipeline = versionnegotiation ec2authtoken authtoken auth-context cache cachemanage apicwapp
+
+[app:apicwapp]
+paste.app_factory = heat.common.wsgi:app_factory
+heat.app_factory = heat.api.cloudwatch:API
+
+[filter:versionnegotiation]
+paste.filter_factory = heat.common.wsgi:filter_factory
+heat.filter_factory = heat.api.middleware.version_negotiation:VersionNegotiationFilter
+
+[filter:cache]
+paste.filter_factory = heat.common.wsgi:filter_factory
+heat.filter_factory = heat.api.middleware.cache:CacheFilter
+
+[filter:cachemanage]
+paste.filter_factory = heat.common.wsgi:filter_factory
+heat.filter_factory = heat.api.middleware.cache_manage:CacheManageFilter
+
+[filter:context]
+paste.filter_factory = heat.common.context:ContextMiddleware_filter_factory
+
+[filter:ec2authtoken]
+paste.filter_factory = heat.api.aws.ec2token:EC2Token_filter_factory
+auth_uri = http://127.0.0.1:5000/v2.0
+keystone_ec2_uri = http://localhost:5000/v2.0/ec2tokens
+
+[filter:authtoken]
+paste.filter_factory = heat.common.auth_token:filter_factory
+service_protocol = http
+service_host = 127.0.0.1
+service_port = 5000
+auth_host = 127.0.0.1
+auth_port = 35357
+auth_protocol = http
+auth_uri = http://127.0.0.1:5000/v2.0
+
+# These must be set to your local values in order for the token
+# authentication to work.
+admin_tenant_name = admin
+admin_user = admin
+admin_password = verybadpass
+
+[filter:auth-context]
+paste.filter_factory = heat.common.wsgi:filter_factory
+heat.filter_factory = keystone.middleware.heat_auth_token:KeystoneContextMiddleware
diff --git a/etc/heat-api-cloudwatch.conf b/etc/heat-api-cloudwatch.conf
new file mode 100644 (file)
index 0000000..564a041
--- /dev/null
@@ -0,0 +1,27 @@
+[DEFAULT]
+# Show more verbose log output (sets INFO log level output)
+verbose = True
+
+# Show debugging output in logs (sets DEBUG log level output)
+debug = True
+
+# Address to bind the server to
+bind_host = 0.0.0.0
+
+# Port the bind the server to
+bind_port = 8003
+
+# Log to this file. Make sure the user running heat-api has
+# permissions to write to this file!
+log_file = /var/log/heat/api-cloudwatch.log
+
+# ================= Syslog Options ============================
+
+# Send logs to syslog (/dev/log) instead of to file specified
+# by `log_file`
+use_syslog = False
+
+# Facility to use. If unset defaults to LOG_USER.
+# syslog_log_facility = LOG_LOCAL0
+
+rpc_backend=heat.openstack.common.rpc.impl_qpid
diff --git a/heat/api/cloudwatch/__init__.py b/heat/api/cloudwatch/__init__.py
new file mode 100644 (file)
index 0000000..8778578
--- /dev/null
@@ -0,0 +1,81 @@
+# 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.
+
+import json
+import urlparse
+import httplib
+import routes
+import gettext
+
+gettext.install('heat', unicode=1)
+
+from heat.api.cloudwatch import watch
+from heat.common import wsgi
+
+from webob import Request
+import webob
+from heat import utils
+from heat.common import context
+from heat.api.aws import exception
+
+from heat.openstack.common import log as logging
+
+logger = logging.getLogger(__name__)
+
+
+class API(wsgi.Router):
+
+    """
+    WSGI router for Heat CloudWatch API
+    """
+
+    _actions = {
+        'delete_alarms': 'DeleteAlarms',
+        'describe_alarm_history': 'DescribeAlarmHistory',
+        'describe_alarms': 'DescribeAlarms',
+        'describe_alarms_for_metric': 'DescribeAlarmsForMetric',
+        'disable_alarm_actions': 'DisableAlarmActions',
+        'enable_alarm_actions': 'EnableAlarmActions',
+        'get_metric_statistics': 'GetMetricStatistics',
+        'list_metrics': 'ListMetrics',
+        'put_metric_alarm': 'PutMetricAlarm',
+        'put_metric_data': 'PutMetricData',
+        'set_alarm_state': 'SetAlarmState',
+    }
+
+    def __init__(self, conf, **local_conf):
+        self.conf = conf
+        mapper = routes.Mapper()
+
+        mapper = routes.Mapper()
+        controller_resource = watch.create_resource(conf)
+
+        def conditions(action):
+            api_action = self._actions[action]
+
+            def action_match(environ, result):
+                req = Request(environ)
+                env_action = req.params.get("Action")
+                return env_action == api_action
+
+            return {'function': action_match}
+
+        for action in self._actions:
+            mapper.connect("/", controller=controller_resource, action=action,
+                conditions=conditions(action))
+
+        mapper.connect("/", controller=controller_resource, action="index")
+
+        super(API, self).__init__(mapper)
diff --git a/heat/api/cloudwatch/watch.py b/heat/api/cloudwatch/watch.py
new file mode 100644 (file)
index 0000000..726146b
--- /dev/null
@@ -0,0 +1,334 @@
+# 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.
+
+"""
+endpoint for heat AWS-compatible CloudWatch API
+"""
+import os
+import sys
+import re
+import webob
+from heat.api.aws import exception
+from heat.api.aws import utils as api_utils
+from heat.common import wsgi
+from heat.common import config
+from heat.common import context
+from heat import utils
+from heat.engine import rpcapi as engine_rpcapi
+import heat.engine.api as engine_api
+
+from heat.openstack.common import rpc
+import heat.openstack.common.rpc.common as rpc_common
+from heat.openstack.common import log as logging
+
+logger = logging.getLogger('heat.api.cloudwatch.controller')
+
+
+class WatchController(object):
+
+    """
+    WSGI controller for CloudWatch resource in heat API
+    Implements the API actions
+    """
+
+    def __init__(self, options):
+        self.options = options
+        self.engine_rpcapi = engine_rpcapi.EngineAPI()
+
+    @staticmethod
+    def _reformat_dimensions(dims):
+        '''
+        Reformat dimensions list into AWS API format
+        Parameter dims is a list of dicts
+        '''
+        newdims = []
+        for count, d in enumerate(dims, 1):
+            for key in d.keys():
+                newdims.append({'Name': key, 'Value': d[key]})
+        return newdims
+
+    def delete_alarms(self, req):
+        """
+        Implements DeleteAlarms API action
+        """
+        return exception.HeatAPINotImplementedError()
+
+    def describe_alarm_history(self, req):
+        """
+        Implements DescribeAlarmHistory API action
+        """
+        return exception.HeatAPINotImplementedError()
+
+    def describe_alarms(self, req):
+        """
+        Implements DescribeAlarms API action
+        """
+
+        def format_metric_alarm(a):
+            """
+            Reformat engine output into the AWS "MetricAlarm" format
+            """
+            keymap = {
+            engine_api.WATCH_ACTIONS_ENABLED: 'ActionsEnabled',
+            engine_api.WATCH_ALARM_ACTIONS: 'AlarmActions',
+            engine_api.WATCH_TOPIC: 'AlarmArn',
+            engine_api.WATCH_UPDATED_TIME:
+                'AlarmConfigurationUpdatedTimestamp',
+            engine_api.WATCH_DESCRIPTION: 'AlarmDescription',
+            engine_api.WATCH_NAME: 'AlarmName',
+            engine_api.WATCH_COMPARISON: 'ComparisonOperator',
+            engine_api.WATCH_DIMENSIONS: 'Dimensions',
+            engine_api.WATCH_PERIODS: 'EvaluationPeriods',
+            engine_api.WATCH_INSUFFICIENT_ACTIONS: 'InsufficientDataActions',
+            engine_api.WATCH_METRIC_NAME: 'MetricName',
+            engine_api.WATCH_NAMESPACE: 'Namespace',
+            engine_api.WATCH_OK_ACTIONS: 'OKActions',
+            engine_api.WATCH_PERIOD: 'Period',
+            engine_api.WATCH_STATE_REASON: 'StateReason',
+            engine_api.WATCH_STATE_REASON_DATA: 'StateReasonData',
+            engine_api.WATCH_STATE_UPDATED_TIME: 'StateUpdatedTimestamp',
+            engine_api.WATCH_STATE_VALUE: 'StateValue',
+            engine_api.WATCH_STATISTIC: 'Statistic',
+            engine_api.WATCH_THRESHOLD: 'Threshold',
+            engine_api.WATCH_UNIT: 'Unit'}
+
+            # AWS doesn't return StackName in the main MetricAlarm
+            # structure, so we add StackName as a dimension to all responses
+            a[engine_api.WATCH_DIMENSIONS].append({'StackName':
+                                           a[engine_api.WATCH_STACK_NAME]})
+
+            # Reformat dimensions list into AWS API format
+            a[engine_api.WATCH_DIMENSIONS] = self._reformat_dimensions(
+                                             a[engine_api.WATCH_DIMENSIONS])
+
+            return api_utils.reformat_dict_keys(keymap, a)
+
+        con = req.context
+        parms = dict(req.params)
+        try:
+            name = parms['AlarmName']
+        except KeyError:
+            name = None
+
+        try:
+            watch_list = self.engine_rpcapi.show_watch(con, watch_name=name)
+        except rpc_common.RemoteError as ex:
+            return exception.map_remote_error(ex)
+
+        res = {'MetricAlarms': [format_metric_alarm(a)
+                                   for a in watch_list]}
+
+        result = api_utils.format_response("DescribeAlarms", res)
+        return result
+
+    def describe_alarms_for_metric(self, req):
+        """
+        Implements DescribeAlarmsForMetric API action
+        """
+        return exception.HeatAPINotImplementedError()
+
+    def disable_alarm_actions(self, req):
+        """
+        Implements DisableAlarmActions API action
+        """
+        return exception.HeatAPINotImplementedError()
+
+    def enable_alarm_actions(self, req):
+        """
+        Implements EnableAlarmActions API action
+        """
+        return exception.HeatAPINotImplementedError()
+
+    def get_metric_statistics(self, req):
+        """
+        Implements GetMetricStatistics API action
+        """
+        return exception.HeatAPINotImplementedError()
+
+    def list_metrics(self, req):
+        """
+        Implements ListMetrics API action
+        Lists metric datapoints associated with a particular alarm,
+        or all alarms if none specified
+        """
+        def format_metric_data(d, fil={}):
+            """
+            Reformat engine output into the AWS "Metric" format
+            Takes an optional filter dict, which is traversed
+            so a metric dict is only returned if all keys match
+            the filter dict
+            """
+            dimensions = [
+                {'AlarmName': d[engine_api.WATCH_DATA_ALARM]},
+                {'Timestamp': d[engine_api.WATCH_DATA_TIME]}
+            ]
+            for key in d[engine_api.WATCH_DATA]:
+                dimensions.append({key: d[engine_api.WATCH_DATA][key]})
+
+            newdims = self._reformat_dimensions(dimensions)
+
+            result = {
+                'MetricName': d[engine_api.WATCH_DATA_METRIC],
+                'Dimensions': newdims,
+                'Namespace': d[engine_api.WATCH_DATA_NAMESPACE],
+            }
+
+            for f in fil:
+                try:
+                    value = result[f]
+                    if value != fil[f]:
+                        # Filter criteria not met, return None
+                        return
+                except KeyError:
+                    logger.warning("Invalid filter key %s, ignoring" % f)
+
+            return result
+
+        con = req.context
+        parms = dict(req.params)
+        # FIXME : Don't yet handle filtering by Dimensions
+        filter_result = dict((k, v) for (k, v) in parms.iteritems() if k in
+                             ("MetricName", "Namespace"))
+        logger.debug("filter parameters : %s" % filter_result)
+
+        try:
+            # Engine does not currently support query by namespace/metric
+            # so we pass None/None and do any filtering locally
+            watch_data = self.engine_rpcapi.show_watch_metric(con,
+                                                              namespace=None,
+                                                              metric_name=None)
+        except rpc_common.RemoteError as ex:
+            return exception.map_remote_error(ex)
+
+        res = {'Metrics': []}
+        for d in watch_data:
+            metric = format_metric_data(d, filter_result)
+            if metric:
+                res['Metrics'].append(metric)
+
+        result = api_utils.format_response("ListMetrics", res)
+        return result
+
+    def put_metric_alarm(self, req):
+        """
+        Implements PutMetricAlarm API action
+        """
+        return exception.HeatAPINotImplementedError()
+
+    def put_metric_data(self, req):
+        """
+        Implements PutMetricData API action
+        """
+
+        con = req.context
+        parms = dict(req.params)
+        namespace = api_utils.get_param_value(parms, 'Namespace')
+
+        # Extract data from the request so we can pass it to the engine
+        # We have to do this in two passes, because the AWS
+        # query format nests the dimensions within the MetricData
+        # query-parameter-list (see AWS PutMetricData docs)
+        # extract_param_list gives a list-of-dict, which we then
+        # need to process (each dict) for dimensions
+        metric_data = api_utils.extract_param_list(parms, prefix='MetricData')
+        if not len(metric_data):
+            logger.error("Request does not contain required MetricData")
+            return exception.HeatMissingParameterError("MetricData list")
+
+        watch_name = None
+        dimensions = []
+        for p in metric_data:
+            dimension = api_utils.extract_param_pairs(p,
+                                                   prefix='Dimensions',
+                                                   keyname='Name',
+                                                   valuename='Value')
+            if 'AlarmName' in dimension:
+                watch_name = dimension['AlarmName']
+            else:
+                dimensions.append(dimension)
+
+        # We expect an AlarmName dimension as currently the engine
+        # implementation requires metric data to be associated
+        # with an alarm.  When this is fixed, we can simply
+        # parse the user-defined dimensions and add the list to
+        # the metric data
+        if not watch_name:
+            logger.error("Request does not contain AlarmName dimension!")
+            return exception.HeatMissingParameterError("AlarmName dimension")
+
+        # Extract the required data from the metric_data
+        # and format dict to pass to engine
+        data = {'Namespace': namespace,
+                api_utils.get_param_value(metric_data[0], 'MetricName'): {
+                    'Unit': api_utils.get_param_value(metric_data[0], 'Unit'),
+                    'Value': api_utils.get_param_value(metric_data[0],
+                                                       'Value'),
+                    'Dimensions': dimensions}}
+
+        try:
+            res = self.engine_rpcapi.create_watch_data(con, watch_name, data)
+        except rpc_common.RemoteError as ex:
+            return exception.map_remote_error(ex)
+
+        result = {'ResponseMetadata': None}
+        return api_utils.format_response("PutMetricData", result)
+
+    def set_alarm_state(self, req):
+        """
+        Implements SetAlarmState API action
+        """
+        # Map from AWS state names to those used in the engine
+        state_map = {'OK': engine_api.WATCH_STATE_OK,
+                      'ALARM': engine_api.WATCH_STATE_ALARM,
+                      'INSUFFICIENT_DATA': engine_api.WATCH_STATE_NODATA}
+
+        con = req.context
+        parms = dict(req.params)
+
+        # Get mandatory parameters
+        name = api_utils.get_param_value(parms, 'AlarmName')
+        state = api_utils.get_param_value(parms, 'StateValue')
+
+        if state not in state_map:
+            logger.error("Invalid state %s, expecting one of %s" %
+                         (state, state_map.keys()))
+            return exception.HeatInvalidParameterValueError("Invalid state %s"
+                                                            % state)
+
+        # Check for optional parameters
+        # FIXME : We don't actually do anything with these in the engine yet..
+        state_reason = None
+        state_reason_data = None
+        if 'StateReason' in parms:
+            state_reason = parms['StateReason']
+        if 'StateReasonData' in parms:
+            state_reason_data = parms['StateReasonData']
+
+        logger.debug("setting %s to %s" % (name, state_map[state]))
+        try:
+            ret = self.engine_rpcapi.set_watch_state(con, watch_name=name,
+                                                       state=state_map[state])
+        except rpc_common.RemoteError as ex:
+            return exception.map_remote_error(ex)
+
+        return api_utils.format_response("SetAlarmState", "")
+
+
+def create_resource(options):
+    """
+    Watch resource factory method.
+    """
+    deserializer = wsgi.JSONRequestDeserializer()
+    return wsgi.Resource(WatchController(options), deserializer)
diff --git a/heat/tests/test_api_cloudwatch.py b/heat/tests/test_api_cloudwatch.py
new file mode 100644 (file)
index 0000000..3e5fb7a
--- /dev/null
@@ -0,0 +1,487 @@
+# 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.
+
+
+import sys
+import socket
+import nose
+import mox
+import json
+import unittest
+from nose.plugins.attrib import attr
+
+import httplib
+import json
+import urlparse
+
+from heat.common import config
+from heat.common import context
+from heat.engine import auth
+from heat.openstack.common import cfg
+from heat.openstack.common import rpc
+import heat.openstack.common.rpc.common as rpc_common
+from heat.common.wsgi import Request
+from heat.api.aws import exception
+import heat.api.cloudwatch.watch as watches
+import heat.engine.api as engine_api
+
+
+@attr(tag=['unit', 'api-cloudwatch', 'WatchController'])
+@attr(speed='fast')
+class WatchControllerTest(unittest.TestCase):
+    '''
+    Tests the API class which acts as the WSGI controller,
+    the endpoint processing API requests after they are routed
+    '''
+    # Utility functions
+    def _create_context(self, user='api_test_user'):
+        ctx = context.get_admin_context()
+        self.m.StubOutWithMock(ctx, 'username')
+        ctx.username = user
+        self.m.StubOutWithMock(auth, 'authenticate')
+        return ctx
+
+    def _dummy_GET_request(self, params={}):
+        # Mangle the params dict into a query string
+        qs = "&".join(["=".join([k, str(params[k])]) for k in params])
+        environ = {'REQUEST_METHOD': 'GET', 'QUERY_STRING': qs}
+        req = Request(environ)
+        req.context = self._create_context()
+        return req
+
+    # The tests
+    def test_reformat_dimensions(self):
+
+        dims = [{'StackName': u'wordpress_ha5',
+               'Foo': 'bar'}]
+        response = self.controller._reformat_dimensions(dims)
+        expected = [{'Name': 'StackName', 'Value': u'wordpress_ha5'},
+                    {'Name': 'Foo', 'Value': 'bar'}]
+        self.assert_(response == expected)
+
+    def test_delete(self):
+        # Not yet implemented, should raise HeatAPINotImplementedError
+        params = {'Action': 'DeleteAlarms'}
+        dummy_req = self._dummy_GET_request(params)
+        result = self.controller.delete_alarms(dummy_req)
+        self.assert_(type(result) == exception.HeatAPINotImplementedError)
+
+    def test_describe_alarm_history(self):
+        # Not yet implemented, should raise HeatAPINotImplementedError
+        params = {'Action': 'DescribeAlarmHistory'}
+        dummy_req = self._dummy_GET_request(params)
+        result = self.controller.describe_alarm_history(dummy_req)
+        self.assert_(type(result) == exception.HeatAPINotImplementedError)
+
+    def test_describe_all(self):
+        watch_name = None   # Get all watches
+
+        # Format a dummy GET request to pass into the WSGI handler
+        params = {'Action': 'DescribeAlarms'}
+        dummy_req = self._dummy_GET_request(params)
+
+        # Stub out the RPC call to the engine with a pre-canned response
+        engine_resp = [{u'state_updated_time': u'2012-08-30T14:13:21Z',
+                        u'stack_name': u'wordpress_ha5',
+                        u'period': u'300',
+                        u'actions': [u'WebServerRestartPolicy'],
+                        u'topic': None,
+                        u'periods': u'1',
+                        u'statistic': u'SampleCount',
+                        u'threshold': u'2',
+                        u'unit': None,
+                        u'state_reason': None,
+                        u'dimensions': [],
+                        u'namespace': u'system/linux',
+                        u'state_value': u'NORMAL',
+                        u'ok_actions': None,
+                        u'description': u'Restart the WikiDatabase',
+                        u'actions_enabled': None,
+                        u'state_reason_data': None,
+                        u'insufficient_actions': None,
+                        u'metric_name': u'ServiceFailure',
+                        u'comparison': u'GreaterThanThreshold',
+                        u'name': u'HttpFailureAlarm',
+                        u'updated_time': u'2012-08-30T14:10:46Z'}]
+
+        self.m.StubOutWithMock(rpc, 'call')
+        rpc.call(dummy_req.context, self.topic, {'args':
+            {'watch_name': watch_name},
+            'method': 'show_watch',
+            'version': self.api_version},
+            None).AndReturn(engine_resp)
+
+        self.m.ReplayAll()
+
+        # Call the list controller function and compare the response
+        response = self.controller.describe_alarms(dummy_req)
+
+        expected = {'DescribeAlarmsResponse': {'DescribeAlarmsResult':
+                     {'MetricAlarms': [
+                        {'EvaluationPeriods': u'1',
+                        'StateReasonData': None,
+                        'AlarmArn': None,
+                        'StateUpdatedTimestamp': u'2012-08-30T14:13:21Z',
+                        'AlarmConfigurationUpdatedTimestamp':
+                            u'2012-08-30T14:10:46Z',
+                        'AlarmActions': [u'WebServerRestartPolicy'],
+                        'Threshold': u'2',
+                        'AlarmDescription': u'Restart the WikiDatabase',
+                        'Namespace': u'system/linux',
+                        'Period': u'300',
+                        'StateValue': u'NORMAL',
+                        'ComparisonOperator': u'GreaterThanThreshold',
+                        'AlarmName': u'HttpFailureAlarm',
+                        'Unit': None,
+                        'Statistic': u'SampleCount',
+                        'StateReason': None,
+                        'InsufficientDataActions': None,
+                        'OKActions': None,
+                        'MetricName': u'ServiceFailure',
+                        'ActionsEnabled': None,
+                        'Dimensions': [
+                          {'Name': 'StackName',
+                          'Value': u'wordpress_ha5'}]}]}}}
+
+        self.assert_(response == expected)
+
+    def test_describe_alarms_for_metric(self):
+        # Not yet implemented, should raise HeatAPINotImplementedError
+        params = {'Action': 'DescribeAlarmsForMetric'}
+        dummy_req = self._dummy_GET_request(params)
+        result = self.controller.describe_alarms_for_metric(dummy_req)
+        self.assert_(type(result) == exception.HeatAPINotImplementedError)
+
+    def test_disable_alarm_actions(self):
+        # Not yet implemented, should raise HeatAPINotImplementedError
+        params = {'Action': 'DisableAlarmActions'}
+        dummy_req = self._dummy_GET_request(params)
+        result = self.controller.disable_alarm_actions(dummy_req)
+        self.assert_(type(result) == exception.HeatAPINotImplementedError)
+
+    def test_enable_alarm_actions(self):
+        # Not yet implemented, should raise HeatAPINotImplementedError
+        params = {'Action': 'EnableAlarmActions'}
+        dummy_req = self._dummy_GET_request(params)
+        result = self.controller.enable_alarm_actions(dummy_req)
+        self.assert_(type(result) == exception.HeatAPINotImplementedError)
+
+    def test_get_metric_statistics(self):
+        # Not yet implemented, should raise HeatAPINotImplementedError
+        params = {'Action': 'GetMetricStatistics'}
+        dummy_req = self._dummy_GET_request(params)
+        result = self.controller.get_metric_statistics(dummy_req)
+        self.assert_(type(result) == exception.HeatAPINotImplementedError)
+
+    def test_list_metrics_all(self):
+        params = {'Action': 'ListMetrics'}
+        dummy_req = self._dummy_GET_request(params)
+
+        # Stub out the RPC call to the engine with a pre-canned response
+        # We dummy three different metrics and namespaces to test
+        # filtering by parameter
+        engine_resp = [
+                        {u'timestamp': u'2012-08-30T15:09:02Z',
+                        u'watch_name': u'HttpFailureAlarm',
+                        u'namespace': u'system/linux',
+                        u'metric_name': u'ServiceFailure',
+                        u'data': {u'Units': u'Counter', u'Value': 1}},
+
+                        {u'timestamp': u'2012-08-30T15:10:03Z',
+                        u'watch_name': u'HttpFailureAlarm2',
+                        u'namespace': u'system/linux2',
+                        u'metric_name': u'ServiceFailure2',
+                        u'data': {u'Units': u'Counter', u'Value': 1}},
+
+                        {u'timestamp': u'2012-08-30T15:16:03Z',
+                        u'watch_name': u'HttpFailureAlar3m',
+                        u'namespace': u'system/linux3',
+                        u'metric_name': u'ServiceFailure3',
+                        u'data': {u'Units': u'Counter', u'Value': 1}}]
+
+        self.m.StubOutWithMock(rpc, 'call')
+        # Current engine implementation means we filter in the API
+        # and pass None/None for namespace/watch_name which returns
+        # all metric data which we post-process in the API
+        rpc.call(dummy_req.context, self.topic, {'args':
+                 {'namespace': None,
+                 'metric_name': None},
+                 'method': 'show_watch_metric', 'version': self.api_version},
+                 None).AndReturn(engine_resp)
+
+        self.m.ReplayAll()
+
+        # First pass no query paramters filtering, should get all three
+        response = self.controller.list_metrics(dummy_req)
+        expected = {'ListMetricsResponse': {'ListMetricsResult': {'Metrics': [
+                        {'Namespace': u'system/linux',
+                        'Dimensions': [
+                        {'Name': 'AlarmName', 'Value': u'HttpFailureAlarm'},
+                        {'Name': 'Timestamp',
+                            'Value': u'2012-08-30T15:09:02Z'},
+                        {'Name': u'Units', 'Value': u'Counter'},
+                        {'Name': u'Value', 'Value': 1}],
+                        'MetricName': u'ServiceFailure'},
+
+                        {'Namespace': u'system/linux2',
+                        'Dimensions': [
+                        {'Name': 'AlarmName', 'Value': u'HttpFailureAlarm2'},
+                        {'Name': 'Timestamp',
+                            'Value': u'2012-08-30T15:10:03Z'},
+                        {'Name': u'Units', 'Value': u'Counter'},
+                        {'Name': u'Value', 'Value': 1}],
+                        'MetricName': u'ServiceFailure2'},
+
+                        {'Namespace': u'system/linux3',
+                        'Dimensions': [
+                        {'Name': 'AlarmName', 'Value': u'HttpFailureAlar3m'},
+                        {'Name': 'Timestamp',
+                            'Value': u'2012-08-30T15:16:03Z'},
+                        {'Name': u'Units', 'Value': u'Counter'},
+                        {'Name': u'Value', 'Value': 1}],
+                        'MetricName': u'ServiceFailure3'}]}}}
+        self.assert_(response == expected)
+
+    def test_list_metrics_filter_name(self):
+
+        # Add a MetricName filter, so we should only get one of the three
+        params = {'Action': 'ListMetrics',
+                  'MetricName': 'ServiceFailure'}
+        dummy_req = self._dummy_GET_request(params)
+
+        # Stub out the RPC call to the engine with a pre-canned response
+        # We dummy three different metrics and namespaces to test
+        # filtering by parameter
+        engine_resp = [
+                        {u'timestamp': u'2012-08-30T15:09:02Z',
+                        u'watch_name': u'HttpFailureAlarm',
+                        u'namespace': u'system/linux',
+                        u'metric_name': u'ServiceFailure',
+                        u'data': {u'Units': u'Counter', u'Value': 1}},
+
+                        {u'timestamp': u'2012-08-30T15:10:03Z',
+                        u'watch_name': u'HttpFailureAlarm2',
+                        u'namespace': u'system/linux2',
+                        u'metric_name': u'ServiceFailure2',
+                        u'data': {u'Units': u'Counter', u'Value': 1}},
+
+                        {u'timestamp': u'2012-08-30T15:16:03Z',
+                        u'watch_name': u'HttpFailureAlar3m',
+                        u'namespace': u'system/linux3',
+                        u'metric_name': u'ServiceFailure3',
+                        u'data': {u'Units': u'Counter', u'Value': 1}}]
+
+        self.m.StubOutWithMock(rpc, 'call')
+        # Current engine implementation means we filter in the API
+        # and pass None/None for namespace/watch_name which returns
+        # all metric data which we post-process in the API
+        rpc.call(dummy_req.context, self.topic, {'args':
+                 {'namespace': None,
+                 'metric_name': None},
+                 'method': 'show_watch_metric', 'version': self.api_version},
+                 None).AndReturn(engine_resp)
+
+        self.m.ReplayAll()
+
+        # First pass no query paramters filtering, should get all three
+        response = self.controller.list_metrics(dummy_req)
+        expected = {'ListMetricsResponse': {'ListMetricsResult': {'Metrics': [
+                        {'Namespace': u'system/linux',
+                        'Dimensions': [
+                        {'Name': 'AlarmName', 'Value': u'HttpFailureAlarm'},
+                        {'Name': 'Timestamp',
+                            'Value': u'2012-08-30T15:09:02Z'},
+                        {'Name': u'Units', 'Value': u'Counter'},
+                        {'Name': u'Value', 'Value': 1}],
+                        'MetricName': u'ServiceFailure'},
+                        ]}}}
+        self.assert_(response == expected)
+
+    def test_list_metrics_filter_namespace(self):
+
+        # Add a Namespace filter and change the engine response so
+        # we should get two reponses
+        params = {'Action': 'ListMetrics',
+                  'Namespace': 'atestnamespace/foo'}
+        dummy_req = self._dummy_GET_request(params)
+
+        # Stub out the RPC call to the engine with a pre-canned response
+        # We dummy three different metrics and namespaces to test
+        # filtering by parameter
+        engine_resp = [
+                        {u'timestamp': u'2012-08-30T15:09:02Z',
+                        u'watch_name': u'HttpFailureAlarm',
+                        u'namespace': u'atestnamespace/foo',
+                        u'metric_name': u'ServiceFailure',
+                        u'data': {u'Units': u'Counter', u'Value': 1}},
+
+                        {u'timestamp': u'2012-08-30T15:10:03Z',
+                        u'watch_name': u'HttpFailureAlarm2',
+                        u'namespace': u'atestnamespace/foo',
+                        u'metric_name': u'ServiceFailure2',
+                        u'data': {u'Units': u'Counter', u'Value': 1}},
+
+                        {u'timestamp': u'2012-08-30T15:16:03Z',
+                        u'watch_name': u'HttpFailureAlar3m',
+                        u'namespace': u'system/linux3',
+                        u'metric_name': u'ServiceFailure3',
+                        u'data': {u'Units': u'Counter', u'Value': 1}}]
+
+        self.m.StubOutWithMock(rpc, 'call')
+        # Current engine implementation means we filter in the API
+        # and pass None/None for namespace/watch_name which returns
+        # all metric data which we post-process in the API
+        rpc.call(dummy_req.context, self.topic, {'args':
+                 {'namespace': None,
+                 'metric_name': None},
+                 'method': 'show_watch_metric', 'version': self.api_version},
+                 None).AndReturn(engine_resp)
+
+        self.m.ReplayAll()
+
+        response = self.controller.list_metrics(dummy_req)
+        expected = {'ListMetricsResponse': {'ListMetricsResult': {'Metrics': [
+                    {'Namespace': u'atestnamespace/foo',
+                    'Dimensions': [
+                    {'Name': 'AlarmName', 'Value': u'HttpFailureAlarm'},
+                    {'Name': 'Timestamp', 'Value': u'2012-08-30T15:09:02Z'},
+                    {'Name': u'Units', 'Value': u'Counter'},
+                    {'Name': u'Value', 'Value': 1}],
+                    'MetricName': u'ServiceFailure'},
+
+                    {'Namespace': u'atestnamespace/foo',
+                    'Dimensions': [
+                    {'Name': 'AlarmName', 'Value': u'HttpFailureAlarm2'},
+                    {'Name': 'Timestamp', 'Value': u'2012-08-30T15:10:03Z'},
+                    {'Name': u'Units', 'Value': u'Counter'},
+                    {'Name': u'Value', 'Value': 1}],
+                    'MetricName': u'ServiceFailure2'}]}}}
+        self.assert_(response == expected)
+
+    def test_put_metric_alarm(self):
+        # Not yet implemented, should raise HeatAPINotImplementedError
+        params = {'Action': 'PutMetricAlarm'}
+        dummy_req = self._dummy_GET_request(params)
+        result = self.controller.put_metric_alarm(dummy_req)
+        self.assert_(type(result) == exception.HeatAPINotImplementedError)
+
+    def test_put_metric_data(self):
+
+        params = {u'Namespace': u'system/linux',
+                  u'MetricData.member.1.Unit': u'Count',
+                  u'MetricData.member.1.Value': u'1',
+                  u'MetricData.member.1.MetricName': u'ServiceFailure',
+                  u'MetricData.member.1.Dimensions.member.1.Name':
+                    u'AlarmName',
+                  u'MetricData.member.1.Dimensions.member.1.Value':
+                    u'HttpFailureAlarm',
+                  u'Action': u'PutMetricData'}
+
+        dummy_req = self._dummy_GET_request(params)
+
+        # Stub out the RPC call to verify the engine call parameters
+        engine_resp = {}
+
+        self.m.StubOutWithMock(rpc, 'call')
+        rpc.call(dummy_req.context, self.topic, {'args': {'stats_data':
+                 {'Namespace': u'system/linux',
+                 u'ServiceFailure':
+                    {'Value': u'1',
+                    'Unit': u'Count',
+                    'Dimensions': []}},
+                    'watch_name': u'HttpFailureAlarm'},
+                 'method': 'create_watch_data',
+                 'version': self.api_version},
+                 None).AndReturn(engine_resp)
+
+        self.m.ReplayAll()
+
+        response = self.controller.put_metric_data(dummy_req)
+        expected = {'PutMetricDataResponse': {'PutMetricDataResult':
+                    {'ResponseMetadata': None}}}
+        self.assert_(response == expected)
+
+    def test_set_alarm_state(self):
+        state_map = {'OK': engine_api.WATCH_STATE_OK,
+                      'ALARM': engine_api.WATCH_STATE_ALARM,
+                      'INSUFFICIENT_DATA': engine_api.WATCH_STATE_NODATA}
+
+        for state in state_map.keys():
+            params = {u'StateValue': state,
+                      u'StateReason': u'',
+                      u'AlarmName': u'HttpFailureAlarm',
+                      u'Action': u'SetAlarmState'}
+
+            dummy_req = self._dummy_GET_request(params)
+
+            # Stub out the RPC call to verify the engine call parameters
+            # The real engine response is the same as show_watch but with
+            # the state overridden, but since the API doesn't make use
+            # of the response at present we pass nothing back from the stub
+            engine_resp = {}
+
+            self.m.StubOutWithMock(rpc, 'call')
+            rpc.call(dummy_req.context, self.topic, {'args':
+                 {'state': state_map[state],
+                 'watch_name': u'HttpFailureAlarm'},
+                 'method': 'set_watch_state',
+                 'version': self.api_version},
+                 None).AndReturn(engine_resp)
+
+            self.m.ReplayAll()
+
+            response = self.controller.set_alarm_state(dummy_req)
+            expected = {'SetAlarmStateResponse': {'SetAlarmStateResult': ''}}
+            self.assert_(response == expected)
+
+            self.m.UnsetStubs()
+            self.m.VerifyAll()
+
+    def test_set_alarm_state_badstate(self):
+        params = {u'StateValue': "baaaaad",
+                  u'StateReason': u'',
+                  u'AlarmName': u'HttpFailureAlarm',
+                  u'Action': u'SetAlarmState'}
+        dummy_req = self._dummy_GET_request(params)
+
+        # should raise HeatInvalidParameterValueError
+        result = self.controller.set_alarm_state(dummy_req)
+        self.assert_(type(result) == exception.HeatInvalidParameterValueError)
+
+    def setUp(self):
+        self.maxDiff = None
+        self.m = mox.Mox()
+
+        config.register_engine_opts()
+        cfg.CONF.set_default('engine_topic', 'engine')
+        cfg.CONF.set_default('host', 'host')
+        self.topic = '%s.%s' % (cfg.CONF.engine_topic, cfg.CONF.host)
+        self.api_version = '1.0'
+
+        # Create WSGI controller instance
+        class DummyConfig():
+            bind_port = 8003
+        cfgopts = DummyConfig()
+        self.controller = watches.WatchController(options=cfgopts)
+        print "setup complete"
+
+    def tearDown(self):
+        self.m.UnsetStubs()
+        self.m.VerifyAll()
+        print "teardown complete"
+
+
+if __name__ == '__main__':
+    sys.argv.append(__file__)
+    nose.main()
index 6cafbb08b7a7a5a028ffbef2e8244f5f6b5aa079..092c02032937a4553e2d56e95572067f757b2b38 100755 (executable)
--- a/setup.py
+++ b/setup.py
@@ -45,6 +45,7 @@ setuptools.setup(
     ],
     scripts=['bin/heat',
              'bin/heat-api',
+             'bin/heat-api-cloudwatch',
              'bin/heat-boto',
              'bin/heat-metadata',
              'bin/heat-engine',