diff --git a/aodh/api/controllers/v2/alarm_rules/composite.py b/aodh/api/controllers/v2/alarm_rules/composite.py new file mode 100644 index 000000000..33c8af173 --- /dev/null +++ b/aodh/api/controllers/v2/alarm_rules/composite.py @@ -0,0 +1,119 @@ +# +# 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 + +from stevedore import named +from wsme.rest import json as wjson +from wsme import types as wtypes + +from aodh.api.controllers.v2 import base +from aodh.i18n import _ + + +class InvalidCompositeRule(base.ClientSideError): + def __init__(self, error): + err = _('Invalid input composite rule: %s, it should ' + 'be a dict with an "and" or "or" as key, and the ' + 'value of dict should be a list of basic threshold ' + 'rules or sub composite rules, can be nested.') % error + super(InvalidCompositeRule, self).__init__(err) + + +class CompositeRule(wtypes.UserType): + """Composite alarm rule. + + A simple dict type to preset composite rule. + """ + + basetype = wtypes.text + name = 'composite_rule' + + threshold_plugins = None + + def __init__(self): + threshold_rules = ('threshold', + 'gnocchi_resources_threshold', + 'gnocchi_aggregation_by_metrics_threshold', + 'gnocchi_aggregation_by_resources_threshold') + CompositeRule.threshold_plugins = named.NamedExtensionManager( + "aodh.alarm.rule", threshold_rules) + super(CompositeRule, self).__init__() + + @staticmethod + def valid_composite_rule(rules): + if isinstance(rules, dict) and len(rules) == 1: + and_or_key = rules.keys()[0] + if and_or_key not in ('and', 'or'): + raise base.ClientSideError( + _('Threshold rules should be combined with "and" or "or"')) + if isinstance(rules[and_or_key], list): + for sub_rule in rules[and_or_key]: + CompositeRule.valid_composite_rule(sub_rule) + else: + raise InvalidCompositeRule(rules) + elif isinstance(rules, dict): + rule_type = rules.pop('type', None) + if not rule_type: + raise base.ClientSideError(_('type must be set in every rule')) + + if rule_type not in CompositeRule.threshold_plugins: + plugins = sorted(CompositeRule.threshold_plugins.names()) + err = _('Unsupported sub-rule type :%(rule)s in composite ' + 'rule, should be one of: %(plugins)s') % { + 'rule': rule_type, + 'plugins': plugins} + raise base.ClientSideError(err) + plugin = CompositeRule.threshold_plugins[rule_type].plugin + wjson.fromjson(plugin, rules) + rule_dict = plugin(**rules).as_dict() + rules.update(rule_dict) + rules.update(type=rule_type) + else: + raise InvalidCompositeRule(rules) + + @staticmethod + def validate(value): + try: + json.dumps(value) + except TypeError: + raise base.ClientSideError(_('%s is not JSON serializable') + % value) + else: + CompositeRule.valid_composite_rule(value) + return value + + @staticmethod + def frombasetype(value): + return CompositeRule.validate(value) + + @staticmethod + def create_hook(alarm): + pass + + @staticmethod + def validate_alarm(alarm): + pass + + @staticmethod + def update_hook(alarm): + pass + + @staticmethod + def as_dict(): + pass + + @staticmethod + def __call__(**rule): + return rule + +composite_rule = CompositeRule() diff --git a/aodh/api/controllers/v2/alarm_rules/threshold.py b/aodh/api/controllers/v2/alarm_rules/threshold.py index 2419e0165..168f7a7ba 100644 --- a/aodh/api/controllers/v2/alarm_rules/threshold.py +++ b/aodh/api/controllers/v2/alarm_rules/threshold.py @@ -58,8 +58,7 @@ class AlarmThresholdRule(base.AlarmRule): "Whether datapoints with anomalously low sample counts are excluded" def __init__(self, query=None, **kwargs): - if query: - query = [base.Query(**q) for q in query] + query = [base.Query(**q) for q in query] if query else [] super(AlarmThresholdRule, self).__init__(query=query, **kwargs) @staticmethod diff --git a/aodh/api/controllers/v2/alarms.py b/aodh/api/controllers/v2/alarms.py index 93fa69639..1f049e162 100644 --- a/aodh/api/controllers/v2/alarms.py +++ b/aodh/api/controllers/v2/alarms.py @@ -371,7 +371,8 @@ class Alarm(base.Base): for k in d: if k.endswith('_rule'): del d[k] - d['rule'] = getattr(self, "%s_rule" % self.type).as_dict() + rule = getattr(self, "%s_rule" % self.type) + d['rule'] = rule if isinstance(rule, dict) else rule.as_dict() if self.time_constraints: d['time_constraints'] = [tc.as_dict() for tc in self.time_constraints] diff --git a/aodh/tests/functional/api/v2/test_alarm_scenarios.py b/aodh/tests/functional/api/v2/test_alarm_scenarios.py index 785915339..f3f4972b6 100644 --- a/aodh/tests/functional/api/v2/test_alarm_scenarios.py +++ b/aodh/tests/functional/api/v2/test_alarm_scenarios.py @@ -3085,3 +3085,158 @@ class TestAlarmsEvent(TestAlarmsBase): break else: self.fail("Alarm not found") + + +class TestAlarmsCompositeRule(TestAlarmsBase): + + def setUp(self): + super(TestAlarmsCompositeRule, self).setUp() + self.sub_rule1 = { + "type": "threshold", + "meter_name": "cpu_util", + "evaluation_periods": 5, + "threshold": 0.8, + "query": [{ + "field": "metadata.metering.stack_id", + "value": "36b20eb3-d749-4964-a7d2-a71147cd8147", + "op": "eq" + }], + "statistic": "avg", + "period": 60, + "exclude_outliers": False, + "comparison_operator": "gt" + } + self.sub_rule2 = { + "type": "threshold", + "meter_name": "disk.iops", + "evaluation_periods": 4, + "threshold": 200, + "query": [{ + "field": "metadata.metering.stack_id", + "value": "36b20eb3-d749-4964-a7d2-a71147cd8147", + "op": "eq" + }], + "statistic": "max", + "period": 60, + "exclude_outliers": False, + "comparison_operator": "gt" + } + self.sub_rule3 = { + "type": "threshold", + "meter_name": "network.incoming.packets.rate", + "evaluation_periods": 3, + "threshold": 1000, + "query": [{ + "field": "metadata.metering.stack_id", + "value": + "36b20eb3-d749-4964-a7d2-a71147cd8147", + "op": "eq" + }], + "statistic": "avg", + "period": 60, + "exclude_outliers": False, + "comparison_operator": "gt" + } + + self.rule = { + "or": [self.sub_rule1, + { + "and": [self.sub_rule2, self.sub_rule3] + }]} + + def test_list_alarms(self): + alarm = models.Alarm(name='composite_alarm', + type='composite', + enabled=True, + alarm_id='composite', + description='composite', + state='insufficient data', + severity='moderate', + state_timestamp=constants.MIN_DATETIME, + timestamp=constants.MIN_DATETIME, + ok_actions=[], + insufficient_data_actions=[], + alarm_actions=[], + repeat_actions=False, + user_id=self.auth_headers['X-User-Id'], + project_id=self.auth_headers['X-Project-Id'], + time_constraints=[], + rule=self.rule, + ) + self.alarm_conn.update_alarm(alarm) + + data = self.get_json('/alarms', headers=self.auth_headers) + self.assertEqual(1, len(data)) + self.assertEqual(set(['composite_alarm']), + set(r['name'] for r in data)) + self.assertEqual(self.rule, data[0]['composite_rule']) + + def test_post_with_composite_rule(self): + json = { + "type": "composite", + "name": "composite_alarm", + "composite_rule": self.rule, + "repeat_actions": False + } + self.post_json('/alarms', params=json, status=201, + headers=self.auth_headers) + alarms = list(self.alarm_conn.get_alarms()) + self.assertEqual(1, len(alarms)) + self.assertEqual(self.rule, alarms[0].rule) + + def test_post_with_sub_rule_with_wrong_type(self): + self.sub_rule1['type'] = 'non-type' + json = { + "type": "composite", + "name": "composite_alarm", + "composite_rule": self.rule, + "repeat_actions": False + } + response = self.post_json('/alarms', params=json, status=400, + expect_errors=True, + headers=self.auth_headers) + + err = ("Unsupported sub-rule type :non-type in composite " + "rule, should be one of: " + "['gnocchi_aggregation_by_metrics_threshold', " + "'gnocchi_aggregation_by_resources_threshold', " + "'gnocchi_resources_threshold', 'threshold']") + faultstring = response.json['error_message']['faultstring'] + self.assertEqual(err, faultstring) + + def test_post_with_sub_rule_with_only_required_params(self): + sub_rulea = { + "meter_name": "cpu_util", + "threshold": 0.8, + "type": "threshold"} + sub_ruleb = { + "meter_name": "disk.iops", + "threshold": 200, + "type": "threshold"} + json = { + "type": "composite", + "name": "composite_alarm", + "composite_rule": {"and": [sub_rulea, sub_ruleb]}, + "repeat_actions": False + } + self.post_json('/alarms', params=json, status=201, + headers=self.auth_headers) + alarms = list(self.alarm_conn.get_alarms()) + self.assertEqual(1, len(alarms)) + + def test_post_with_sub_rule_with_invalid_params(self): + self.sub_rule1['threshold'] = False + json = { + "type": "composite", + "name": "composite_alarm", + "composite_rule": self.rule, + "repeat_actions": False + } + response = self.post_json('/alarms', params=json, status=400, + expect_errors=True, + headers=self.auth_headers) + faultstring = ("Invalid input for field/attribute threshold. " + "Value: 'False'. Wrong type. Expected '', got ''") + self.assertEqual(faultstring, + response.json['error_message']['faultstring']) diff --git a/aodh/tests/unit/evaluator/test_composite.py b/aodh/tests/unit/evaluator/test_composite.py index 64749e04a..3c5c9cb13 100644 --- a/aodh/tests/unit/evaluator/test_composite.py +++ b/aodh/tests/unit/evaluator/test_composite.py @@ -39,7 +39,7 @@ class TestEvaluate(base.TestEvaluatorBase): "threshold": 0.8, "query": [{ "field": "metadata.metering.stack_id", - "value": "36b20eb3-d749-4964-a7d2-a71147cd8147", + "value": "36b20eb3-d749-4964-a7d2-a71147cd8145", "op": "eq" }], "statistic": "avg", @@ -55,7 +55,7 @@ class TestEvaluate(base.TestEvaluatorBase): "threshold": 200, "query": [{ "field": "metadata.metering.stack_id", - "value": "36b20eb3-d749-4964-a7d2-a71147cd8147", + "value": "36b20eb3-d749-4964-a7d2-a71147cd8145", "op": "eq" }], "statistic": "max", @@ -71,7 +71,7 @@ class TestEvaluate(base.TestEvaluatorBase): "threshold": 1000, "query": [{ "field": "metadata.metering.stack_id", - "value": "36b20eb3-d749-4964-a7d2-a71147cd8147", + "value": "36b20eb3-d749-4964-a7d2-a71147cd8145", "op": "eq" }], "statistic": "avg", diff --git a/setup.cfg b/setup.cfg index c0365bd03..3fcf30171 100644 --- a/setup.cfg +++ b/setup.cfg @@ -83,6 +83,7 @@ aodh.alarm.rule = gnocchi_aggregation_by_metrics_threshold = aodh.api.controllers.v2.alarm_rules.gnocchi:AggregationMetricsByIdLookupRule gnocchi_aggregation_by_resources_threshold = aodh.api.controllers.v2.alarm_rules.gnocchi:AggregationMetricByResourcesLookupRule event = aodh.api.controllers.v2.alarm_rules.event:AlarmEventRule + composite = aodh.api.controllers.v2.alarm_rules.composite:composite_rule aodh.evaluator = threshold = aodh.evaluator.threshold:ThresholdEvaluator