From 7a88d687e86b0afe9ef0065edc363884b115a75b Mon Sep 17 00:00:00 2001 From: Angus Salkeld Date: Fri, 26 Jul 2013 16:13:25 +1000 Subject: [PATCH] Add a Ceilometer alarm resource Note: this gets the signed url from the resources capable of getting signals by calling Fn::GetAtt('AlarmUrl') blueprint watch-ceilometer Change-Id: If8822f7c9bfc2113b6ee57e1faff2ab4f8ff3b16 --- heat/engine/clients.py | 31 +++ heat/engine/resource.py | 3 + heat/engine/resources/autoscaling.py | 16 ++ heat/engine/resources/ceilometer/__init__.py | 0 heat/engine/resources/ceilometer/alarm.py | 112 +++++++++ heat/engine/resources/instance.py | 11 +- heat/tests/test_ceilometer_alarm.py | 242 +++++++++++++++++++ requirements.txt | 1 + 8 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 heat/engine/resources/ceilometer/__init__.py create mode 100644 heat/engine/resources/ceilometer/alarm.py create mode 100644 heat/tests/test_ceilometer_alarm.py diff --git a/heat/engine/clients.py b/heat/engine/clients.py index 05cbcaf7c..388943a24 100644 --- a/heat/engine/clients.py +++ b/heat/engine/clients.py @@ -39,6 +39,12 @@ except ImportError: cinderclient = None logger.info('cinderclient not available') +try: + from ceilometerclient.v2 import client as ceilometerclient +except ImportError: + ceilometerclient = None + logger.info('ceilometerclient not available') + cloud_opts = [ cfg.StrOpt('cloud_backend', @@ -60,6 +66,7 @@ class OpenStackClients(object): self._swift = None self._quantum = None self._cinder = None + self._ceilometer = None @property def auth_token(self): @@ -174,6 +181,30 @@ class OpenStackClients(object): return self._cinder + def ceilometer(self): + if ceilometerclient is None: + return None + if self._ceilometer: + return self._ceilometer + + if self.auth_token is None: + logger.error("Ceilometer connection failed, no auth_token!") + return None + con = self.context + args = { + 'auth_url': con.auth_url, + 'service_type': 'metering', + 'project_id': con.tenant, + 'token': self.auth_token, + 'endpoint': self.url_for(service_type='metering'), + } + + client = ceilometerclient.Client(**args) + + self._ceilometer = client + return self._ceilometer + + if cfg.CONF.cloud_backend: cloud_backend_module = importutils.import_module(cfg.CONF.cloud_backend) Clients = cloud_backend_module.Clients diff --git a/heat/engine/resource.py b/heat/engine/resource.py index f390e328c..33d052782 100644 --- a/heat/engine/resource.py +++ b/heat/engine/resource.py @@ -327,6 +327,9 @@ class Resource(object): def cinder(self): return self.stack.clients.cinder() + def ceilometer(self): + return self.stack.clients.ceilometer() + def _do_action(self, action, pre_func=None): ''' Perform a transition to a new state via a specified action diff --git a/heat/engine/resources/autoscaling.py b/heat/engine/resources/autoscaling.py index d634b69e5..f1ae64ca9 100644 --- a/heat/engine/resources/autoscaling.py +++ b/heat/engine/resources/autoscaling.py @@ -467,6 +467,22 @@ class ScalingPolicy(signal_responder.SignalResponder, CooldownMixin): self.name) def handle_signal(self, details=None): + # ceilometer sends details like this: + # {u'state': u'alarm', u'reason': u'...'}) + # in this policy we currently assume that this gets called + # only when there is an alarm. But the template writer can + # put the policy in all the alarm notifiers (nodata, and ok). + # + # our watchrule has upper case states so lower() them all. + if details is None: + alarm_state = 'alarm' + else: + alarm_state = details.get('state', 'alarm').lower() + + logger.info('%s Alarm, new state %s' % (self.name, alarm_state)) + + if alarm_state != 'alarm': + return if self._cooldown_inprogress(): logger.info("%s NOT performing scaling action, cooldown %s" % (self.name, self.properties['Cooldown'])) diff --git a/heat/engine/resources/ceilometer/__init__.py b/heat/engine/resources/ceilometer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/heat/engine/resources/ceilometer/alarm.py b/heat/engine/resources/ceilometer/alarm.py new file mode 100644 index 000000000..85a567757 --- /dev/null +++ b/heat/engine/resources/ceilometer/alarm.py @@ -0,0 +1,112 @@ +# 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. + +from heat.engine import resource + + +class CeilometerAlarm(resource.Resource): + + properties_schema = {'comparison_operator': {'Type': 'String', + 'Required': True, + 'AllowedValues': ['ge', + 'gt', + 'eq', + 'ne', + 'lt', + 'le']}, + 'evaluation_periods': {'Type': 'String', + 'Required': True}, + 'counter_name': {'Type': 'String', + 'Required': True}, + 'period': {'Type': 'String', + 'Required': True}, + 'statistic': {'Type': 'String', + 'Required': True, + 'AllowedValues': ['count', + 'avg', + 'sum', + 'min', + 'max']}, + 'threshold': {'Type': 'String', + 'Required': True}, + 'alarm_actions': {'Type': 'List'}, + 'ok_actions': {'Type': 'List'}, + 'insufficient_data_actions': {'Type': 'List'}, + 'description': {'Type': 'String'}, + 'source': {'Type': 'String'}, + 'matching_metadata': {'Type': 'Map'}} + + update_allowed_keys = ('Properties',) + # allow the properties that affect the watch calculation. + # note: when using in-instance monitoring you can only change the + # metric name if you re-configure the instance too. + update_allowed_properties = ('comparison_operator', 'description', + 'evaluation_periods', 'period', 'statistic', + 'alarm_actions', 'ok_actions', + 'insufficient_data_actions', 'threshold') + + def _actions_to_urls(self, props): + kwargs = {} + for k, v in iter(props.items()): + if k.endswith('_actions') and v is not None: + kwargs[k] = [] + for act in v: + # if the action is a resource name + # we ask the destination resource for an alarm url. + # the template writer should really do this in the + # template if possible with: + # {Fn::GetAtt: ['MyAction', 'AlarmUrl']} + if act in self.stack: + url = self.stack[act].FnGetAtt('AlarmUrl') + kwargs[k].append(url) + else: + kwargs[k].append(act) + else: + kwargs[k] = v + return kwargs + + def handle_create(self): + props = self._actions_to_urls(self.parsed_template('Properties')) + props['name'] = self.physical_resource_name() + props['enabled'] = True + + alarm = self.ceilometer().alarms.create(**props) + self.resource_id_set(alarm.alarm_id) + + def handle_update(self, json_snippet, tmpl_diff, prop_diff): + if prop_diff: + kwargs = {'alarm_id': self.resource_id} + kwargs.update(prop_diff) + self.ceilometer().alarms.update(**self._actions_to_urls(kwargs)) + + def handle_suspend(self): + if self.resource_id is not None: + self.ceilometer().alarms.update(alarm_id=self.resource_id, + enabled=False) + + def handle_resume(self): + if self.resource_id is not None: + self.ceilometer().alarms.update(alarm_id=self.resource_id, + enabled=True) + + def handle_delete(self): + if self.resource_id is not None: + self.ceilometer().alarms.delete(self.resource_id) + + +def resource_mapping(): + return { + 'OS::Metering::Alarm': CeilometerAlarm, + } diff --git a/heat/engine/resources/instance.py b/heat/engine/resources/instance.py index 2bc4bc0e2..1ac085548 100644 --- a/heat/engine/resources/instance.py +++ b/heat/engine/resources/instance.py @@ -57,8 +57,17 @@ class Restarter(signal_responder.SignalResponder): return None def handle_signal(self, details=None): - victim = self._find_resource(self.properties['InstanceId']) + if details is None: + alarm_state = 'alarm' + else: + alarm_state = details.get('state', 'alarm').lower() + logger.info('%s Alarm, new state %s' % (self.name, alarm_state)) + + if alarm_state != 'alarm': + return + + victim = self._find_resource(self.properties['InstanceId']) if victim is None: logger.info('%s Alarm, can not find instance %s' % (self.name, self.properties['InstanceId'])) diff --git a/heat/tests/test_ceilometer_alarm.py b/heat/tests/test_ceilometer_alarm.py new file mode 100644 index 000000000..c83f0eba8 --- /dev/null +++ b/heat/tests/test_ceilometer_alarm.py @@ -0,0 +1,242 @@ +# 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 copy +import json +import mox +import testtools +import uuid + +from oslo.config import cfg + +from heat.tests import fakes +from heat.tests import generic_resource +from heat.tests.common import HeatTestCase +from heat.tests.utils import setup_dummy_db +from heat.tests.utils import stack_delete_after + +from heat.common import context +from heat.common import template_format + +from heat.openstack.common.importutils import try_import + +from heat.engine import parser +from heat.engine import resource +from heat.engine import scheduler +from heat.engine.resources.ceilometer import alarm + +ceilometerclient = try_import('ceilometerclient.v2') + +alarm_template = ''' +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Description" : "Alarm Test", + "Parameters" : {}, + "Resources" : { + "MEMAlarmHigh": { + "Type": "OS::Metering::Alarm", + "Properties": { + "description": "Scale-up if MEM > 50% for 1 minute", + "counter_name": "MemoryUtilization", + "statistic": "avg", + "period": "60", + "evaluation_periods": "1", + "threshold": "50", + "alarm_actions": [], + "matching_metadata": {}, + "comparison_operator": "gt" + } + }, + "signal_handler" : { + "Type" : "SignalResourceType" + } + } +} +''' + + +class FakeCeilometerAlarm(object): + alarm_id = 'foo' + + +class FakeCeilometerAlarms(object): + def create(self, **kwargs): + pass + + def update(self, **kwargs): + pass + + def delete(self, alarm_id): + pass + + +class FakeCeilometerClient(object): + alarms = FakeCeilometerAlarms() + + +class UUIDStub(object): + def __init__(self, value): + self.value = value + + def __enter__(self): + self.uuid4 = uuid.uuid4 + uuid_stub = lambda: self.value + uuid.uuid4 = uuid_stub + + def __exit__(self, *exc_info): + uuid.uuid4 = self.uuid4 + + +class CeilometerAlarmTest(HeatTestCase): + def setUp(self): + super(CeilometerAlarmTest, self).setUp() + setup_dummy_db() + + resource._register_class('SignalResourceType', + generic_resource.SignalResource) + + cfg.CONF.set_default('heat_waitcondition_server_url', + 'http://127.0.0.1:8000/v1/waitcondition') + + self.stack_id = 'STACKABCD1234' + self.fc = fakes.FakeKeystoneClient() + self.fa = FakeCeilometerClient() + + # Note tests creating a stack should be decorated with @stack_delete_after + # to ensure the stack is properly cleaned up + def create_stack(self, stack_name='test_stack', + template=None): + if template is None: + template = alarm_template + temp = template_format.parse(template) + template = parser.Template(temp) + ctx = context.get_admin_context() + ctx.tenant_id = 'test_tenant' + stack = parser.Stack(ctx, stack_name, template, + disable_rollback=True) + + # Stub out the stack ID so we have a known value + with UUIDStub(self.stack_id): + stack.store() + + self.m.StubOutWithMock(resource.Resource, 'keystone') + resource.Resource.keystone().MultipleTimes().AndReturn( + self.fc) + + self.m.StubOutWithMock(alarm.CeilometerAlarm, 'ceilometer') + alarm.CeilometerAlarm.ceilometer().MultipleTimes().AndReturn( + self.fa) + + al = copy.deepcopy(temp['Resources']['MEMAlarmHigh']['Properties']) + al['description'] = mox.IgnoreArg() + al['enabled'] = mox.IgnoreArg() + al['name'] = mox.IgnoreArg() + al['alarm_actions'] = mox.IgnoreArg() + self.m.StubOutWithMock(self.fa.alarms, 'create') + self.fa.alarms.create(**al).AndReturn(FakeCeilometerAlarm()) + return stack + + @testtools.skipIf(ceilometerclient is None, 'ceilometerclient unavailable') + @stack_delete_after + def test_mem_alarm_high_update_no_replace(self): + ''' + Make sure that we can change the update-able properties + without replacing the Alarm rsrc. + ''' + #short circuit the alarm's references + t = template_format.parse(alarm_template) + properties = t['Resources']['MEMAlarmHigh']['Properties'] + properties['alarm_actions'] = ['signal_handler'] + properties['matching_metadata'] = {'a': 'v'} + + self.stack = self.create_stack(template=json.dumps(t)) + self.m.StubOutWithMock(self.fa.alarms, 'update') + al2 = {} + for k in alarm.CeilometerAlarm.update_allowed_properties: + al2[k] = mox.IgnoreArg() + al2['alarm_id'] = mox.IgnoreArg() + self.fa.alarms.update(**al2).AndReturn(None) + + self.m.ReplayAll() + self.stack.create() + rsrc = self.stack['MEMAlarmHigh'] + + snippet = copy.deepcopy(rsrc.parsed_template()) + snippet['Properties']['comparison_operator'] = 'lt' + snippet['Properties']['description'] = 'fruity' + snippet['Properties']['evaluation_periods'] = '2' + snippet['Properties']['period'] = '90' + snippet['Properties']['statistic'] = 'max' + snippet['Properties']['threshold'] = '39' + snippet['Properties']['insufficient_data_actions'] = [] + snippet['Properties']['alarm_actions'] = [] + snippet['Properties']['ok_actions'] = ['signal_handler'] + + self.assertEqual(None, rsrc.update(snippet)) + + self.m.VerifyAll() + + @testtools.skipIf(ceilometerclient is None, 'ceilometerclient unavailable') + @stack_delete_after + def test_mem_alarm_high_update_replace(self): + ''' + Make sure that the Alarm resource IS replaced when non-update-able + properties are changed. + ''' + t = template_format.parse(alarm_template) + properties = t['Resources']['MEMAlarmHigh']['Properties'] + properties['alarm_actions'] = ['signal_handler'] + properties['matching_metadata'] = {'a': 'v'} + + self.stack = self.create_stack(template=json.dumps(t)) + + self.m.ReplayAll() + self.stack.create() + rsrc = self.stack['MEMAlarmHigh'] + + snippet = copy.deepcopy(rsrc.parsed_template()) + snippet['Properties']['counter_name'] = 'temp' + + self.assertRaises(resource.UpdateReplace, + rsrc.update, snippet) + + self.m.VerifyAll() + + @testtools.skipIf(ceilometerclient is None, 'ceilometerclient unavailable') + @stack_delete_after + def test_mem_alarm_suspend_resume(self): + """ + Make sure that the Alarm resource gets disabled on suspend + and reenabled on resume. + """ + self.stack = self.create_stack() + + self.m.StubOutWithMock(self.fa.alarms, 'update') + al_suspend = {'alarm_id': mox.IgnoreArg(), + 'enabled': False} + self.fa.alarms.update(**al_suspend).AndReturn(None) + al_resume = {'alarm_id': mox.IgnoreArg(), + 'enabled': True} + self.fa.alarms.update(**al_resume).AndReturn(None) + self.m.ReplayAll() + + self.stack.create() + rsrc = self.stack['MEMAlarmHigh'] + scheduler.TaskRunner(rsrc.suspend)() + self.assertTrue((rsrc.SUSPEND, rsrc.COMPLETE), rsrc.state) + scheduler.TaskRunner(rsrc.resume)() + self.assertTrue((rsrc.RESUME, rsrc.COMPLETE), rsrc.state) + + self.m.VerifyAll() diff --git a/requirements.txt b/requirements.txt index 200108a46..21a4c0ebe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ WebOb==1.2.3 python-keystoneclient>=0.2.1 python-swiftclient>=1.2 python-quantumclient>=2.2.0 +python-ceilometerclient>=1.0.1 python-cinderclient>=1.0.4 PyYAML>=3.1.0 oslo.config>=1.1.0