diff options
-rwxr-xr-x | bin/heat-api-cloudwatch | 59 | ||||
-rw-r--r-- | etc/heat-api-cloudwatch-paste.ini | 88 | ||||
-rw-r--r-- | etc/heat-api-cloudwatch.conf | 27 | ||||
-rw-r--r-- | heat/api/cloudwatch/__init__.py | 81 | ||||
-rw-r--r-- | heat/api/cloudwatch/watch.py | 334 | ||||
-rw-r--r-- | heat/tests/test_api_cloudwatch.py | 487 | ||||
-rwxr-xr-x | setup.py | 1 |
7 files changed, 1077 insertions, 0 deletions
diff --git a/bin/heat-api-cloudwatch b/bin/heat-api-cloudwatch new file mode 100755 index 000000000..7c88518b2 --- /dev/null +++ b/bin/heat-api-cloudwatch @@ -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 index 000000000..da1e83d59 --- /dev/null +++ b/etc/heat-api-cloudwatch-paste.ini @@ -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 index 000000000..564a0412b --- /dev/null +++ b/etc/heat-api-cloudwatch.conf @@ -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 index 000000000..877857857 --- /dev/null +++ b/heat/api/cloudwatch/__init__.py @@ -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 index 000000000..726146bea --- /dev/null +++ b/heat/api/cloudwatch/watch.py @@ -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 index 000000000..3e5fb7acf --- /dev/null +++ b/heat/tests/test_api_cloudwatch.py @@ -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() @@ -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', |