From 48c85f740a6aac94a8cd8cd250e59ee7d1a42d70 Mon Sep 17 00:00:00 2001 From: Mehdi Abaakouk Date: Fri, 30 Aug 2013 10:00:52 +0200 Subject: [PATCH] Alarm API update This updates the alarm API to match the latest discussion: https://wiki.openstack.org/wiki/Ceilometer/blueprints/alarm-api It allows creation of different kinds of alarm. The current kind of alarm has been named 'threshold'. It move the defaults values from the storage models to the API with all tools provided by wsme to ensure mandatory field and default. A behavior change, it is now mandatory to PUT a full alarm description in a PUT call. In the future a new endpoint can be added to allow to modify only one field (example for state: /v2/alarms//state) Implements blueprint alarming-logical-combination Change-Id: Ib85636728d427cdb70ef530ff9ff20d2b75c5ed1 --- ceilometer/alarm/threshold_evaluation.py | 33 +- ceilometer/api/controllers/v2.py | 283 ++++++++++---- ceilometer/storage/impl_db2.py | 66 +++- ceilometer/storage/impl_mongodb.py | 69 +++- ceilometer/storage/impl_sqlalchemy.py | 9 +- ceilometer/storage/models.py | 44 +-- .../versions/016_simpler_alarm.py | 109 ++++++ ceilometer/storage/sqlalchemy/models.py | 11 +- tests/alarm/test_singleton_alarm_svc.py | 26 +- tests/alarm/test_threshold_evaluation.py | 98 +++-- tests/api/v2/test_alarm_scenarios.py | 368 +++++++++++++++--- tests/api/v2/test_wsme_custom_type.py | 58 +++ tests/storage/test_impl_mongodb.py | 87 ++++- tests/storage/test_storage_scenarios.py | 151 +++++-- 14 files changed, 1084 insertions(+), 328 deletions(-) create mode 100644 ceilometer/storage/sqlalchemy/migrate_repo/versions/016_simpler_alarm.py create mode 100644 tests/api/v2/test_wsme_custom_type.py diff --git a/ceilometer/alarm/threshold_evaluation.py b/ceilometer/alarm/threshold_evaluation.py index 2376af62..d577d3c3 100644 --- a/ceilometer/alarm/threshold_evaluation.py +++ b/ceilometer/alarm/threshold_evaluation.py @@ -80,20 +80,12 @@ class Evaluator(object): self.api_client = ceiloclient.get_client(2, **creds) return self.api_client - @staticmethod - def _constraints(alarm): - """Assert the constraints on the statistics query.""" - constraints = [] - for (field, value) in alarm.matching_metadata.iteritems(): - constraints.append(dict(field=field, op='eq', value=value)) - return constraints - @classmethod def _bound_duration(cls, alarm, constraints): """Bound the duration of the statistics query.""" now = timeutils.utcnow() - window = (alarm.period * - (alarm.evaluation_periods + cls.look_back)) + window = (alarm.rule['period'] * + (alarm.rule['evaluation_periods'] + cls.look_back)) start = now - datetime.timedelta(seconds=window) LOG.debug(_('query stats from %(start)s to ' '%(now)s') % {'start': start, 'now': now}) @@ -111,7 +103,7 @@ class Evaluator(object): LOG.debug(_('sanitize stats %s') % statistics) # in practice statistics are always sorted by period start, not # strictly required by the API though - statistics = statistics[:alarm.evaluation_periods] + statistics = statistics[:alarm.rule['evaluation_periods']] LOG.debug(_('pruned statistics to %d') % len(statistics)) return statistics @@ -119,9 +111,9 @@ class Evaluator(object): """Retrieve statistics over the current window.""" LOG.debug(_('stats query %s') % query) try: - return self._client.statistics.list(alarm.meter_name, - q=query, - period=alarm.period) + return self._client.statistics.list( + meter_name=alarm.rule['meter_name'], q=query, + period=alarm.rule['period']) except Exception: LOG.exception(_('alarm stats retrieval failed')) return [] @@ -151,7 +143,8 @@ class Evaluator(object): """ sufficient = len(statistics) >= self.quorum if not sufficient and alarm.state != UNKNOWN: - reason = _('%d datapoints are unknown') % alarm.evaluation_periods + reason = _('%d datapoints are unknown') % alarm.rule[ + 'evaluation_periods'] self._refresh(alarm, UNKNOWN, reason) return sufficient @@ -160,7 +153,7 @@ class Evaluator(object): """Fabricate reason string.""" count = len(statistics) disposition = 'inside' if state == OK else 'outside' - last = getattr(statistics[-1], alarm.statistic) + last = getattr(statistics[-1], alarm.rule['statistic']) transition = alarm.state != state if transition: return (_('Transition to %(state)s due to %(count)d samples' @@ -216,7 +209,7 @@ class Evaluator(object): query = self._bound_duration( alarm, - self._constraints(alarm) + alarm.rule['query'] ) statistics = self._sanitize( @@ -227,9 +220,9 @@ class Evaluator(object): if self._sufficient(alarm, statistics): def _compare(stat): - op = COMPARATORS[alarm.comparison_operator] - value = getattr(stat, alarm.statistic) - limit = alarm.threshold + op = COMPARATORS[alarm.rule['comparison_operator']] + value = getattr(stat, alarm.rule['statistic']) + limit = alarm.rule['threshold'] LOG.debug(_('comparing value %(value)s against threshold' ' %(limit)s') % {'value': value, 'limit': limit}) diff --git a/ceilometer/api/controllers/v2.py b/ceilometer/api/controllers/v2.py index e64021ca..ee4fc4d5 100644 --- a/ceilometer/api/controllers/v2.py +++ b/ceilometer/api/controllers/v2.py @@ -38,6 +38,7 @@ import inspect import json import uuid import pecan +import six from pecan import rest from oslo.config import cfg @@ -73,6 +74,51 @@ cfg.CONF.register_opts(ALARM_API_OPTS, group='alarm') operation_kind = wtypes.Enum(str, 'lt', 'le', 'eq', 'ne', 'ge', 'gt') +class BoundedInt(wtypes.UserType): + basetype = int + name = 'bounded int' + + def __init__(self, min=None, max=None): + self.min = min + self.max = max + + def validate(self, value): + if self.min is not None and value < self.min: + error = _('Value %(value)s is invalid (should be greater or equal ' + 'to %(min)s)') % dict(value=value, min=self.min) + pecan.response.translatable_error = error + raise wsme.exc.ClientSideError(unicode(error)) + + if self.max is not None and value > self.max: + error = _('Value %(value)s is invalid (should be lower or equal ' + 'to %(max)s)') % dict(value=value, max=self.max) + pecan.response.translatable_error = error + raise wsme.exc.ClientSideError(unicode(error)) + return value + + +class AdvEnum(wtypes.wsproperty): + """Handle default and mandatory for wtypes.Enum + """ + def __init__(self, name, *args, **kwargs): + self._name = '_advenum_%s' % name + self._default = kwargs.pop('default', None) + mandatory = kwargs.pop('mandatory', False) + enum = wtypes.Enum(*args, **kwargs) + super(AdvEnum, self).__init__(datatype=enum, fget=self._get, + fset=self._set, mandatory=mandatory) + + def _get(self, parent): + if hasattr(parent, self._name): + value = getattr(parent, self._name) + return value or self._default + return self._default + + def _set(self, parent, value): + if self.datatype.validate(value): + setattr(parent, self._name, value) + + class _Base(wtypes.Base): @classmethod @@ -87,9 +133,11 @@ class _Base(wtypes.Base): valid_keys = inspect.getargspec(db_model.__init__)[0] if 'self' in valid_keys: valid_keys.remove('self') + return self.as_dict_from_keys(valid_keys) + def as_dict_from_keys(self, keys): return dict((k, getattr(self, k)) - for k in valid_keys + for k in keys if hasattr(self, k) and getattr(self, k) != wsme.Unset) @@ -154,6 +202,9 @@ class Query(_Base): type='string' ) + def as_dict(self): + return self.as_dict_from_keys(['field', 'op', 'type', 'value']) + def _get_value_as_type(self): """Convert metadata value to the specified data type. @@ -852,6 +903,83 @@ class ResourcesController(rest.RestController): return resources +class AlarmThresholdRule(_Base): + meter_name = wsme.wsattr(wtypes.text, mandatory=True) + "The name of the meter" + + #FIXME(sileht): default doesn't work + #workaround: default is set in validate method + query = wsme.wsattr([Query], default=[]) + """The query to find the data for computing statistics. + Ownership settings are automatically included based on the Alarm owner. + """ + + period = wsme.wsattr(BoundedInt(min=1), default=60) + "The time range in seconds over which query" + + comparison_operator = AdvEnum('comparison_operator', str, + 'lt', 'le', 'eq', 'ne', 'ge', 'gt', + default='eq') + "The comparison against the alarm threshold" + + threshold = wsme.wsattr(float, mandatory=True) + "The threshold of the alarm" + + statistic = AdvEnum('statistic', str, 'max', 'min', 'avg', 'sum', + 'count', default='avg') + "The statistic to compare to the threshold" + + evaluation_periods = wsme.wsattr(BoundedInt(min=1), default=1) + "The number of historical periods to evaluate the threshold" + + def __init__(self, query=None, **kwargs): + if query: + query = [Query(**q) for q in query] + super(AlarmThresholdRule, self).__init__(query=query, **kwargs) + + @staticmethod + def validate(threshold_rule): + if not threshold_rule.query: + threshold_rule.query = [] + #note(sileht): _query_to_kwargs implicitly call _sanitize_query + #that add project_id in query + _query_to_kwargs(threshold_rule.query, storage.SampleFilter.__init__, + internal_keys=['timestamp', 'start', 'start_timestamp' + 'end', 'end_timestamp']) + return threshold_rule + + @property + def default_description(self): + return _( + 'Alarm when %(meter_name)s is %(comparison_operator)s a ' + '%(statistic)s of %(threshold)s over %(period)s seconds') % \ + dict(comparison_operator=self.comparison_operator, + statistic=self.statistic, + threshold=self.threshold, + meter_name=self.meter_name, + period=self.period) + + def as_dict(self): + rule = self.as_dict_from_keys(['period', 'comparison_operator', + 'threshold', 'statistic', + 'evaluation_periods', 'meter_name']) + rule['query'] = [q.as_dict() for q in self.query] + return rule + + @classmethod + def sample(cls): + return cls(meter_name='cpu_util', + period=60, + evaluation_periods=1, + threshold=300.0, + statistic='avg', + comparison_operator='gt', + query=[{'field': 'resource_id', + 'value': '2a4d689b-f0b8-49c1-9eef-87cae58d80db', + 'op': 'eq', + 'type': 'string'}]) + + class Alarm(_Base): """Representation of an alarm. """ @@ -859,79 +987,81 @@ class Alarm(_Base): alarm_id = wtypes.text "The UUID of the alarm" - name = wtypes.text + name = wsme.wsattr(wtypes.text, mandatory=True) "The name for the alarm" - description = wtypes.text + _description = None # provide a default + + def get_description(self): + rule = getattr(self, '%s_rule' % self.type, None) + if not self._description and rule: + return six.text_type(rule.default_description) + return self._description + + def set_description(self, value): + self._description = value + + description = wsme.wsproperty(wtypes.text, get_description, + set_description) "The description of the alarm" - meter_name = wtypes.text - "The name of meter" + enabled = wsme.wsattr(bool, default=True) + "This alarm is enabled?" + ok_actions = wsme.wsattr([wtypes.text], default=[]) + "The actions to do when alarm state change to ok" + + alarm_actions = wsme.wsattr([wtypes.text], default=[]) + "The actions to do when alarm state change to alarm" + + insufficient_data_actions = wsme.wsattr([wtypes.text], default=[]) + "The actions to do when alarm state change to insufficient data" + + repeat_actions = wsme.wsattr(bool, default=False) + "The actions should be re-triggered on each evaluation cycle" + + type = AdvEnum('type', str, 'threshold', mandatory=True) + "Explicit type specifier to select which rule to follow below." + + threshold_rule = AlarmThresholdRule + "Describe when to trigger the alarm based on computed statistics" + + # These settings are ignored in the PUT or POST operations, but are + # filled in for GET project_id = wtypes.text "The ID of the project or tenant that owns the alarm" user_id = wtypes.text "The ID of the user who created the alarm" - comparison_operator = wtypes.Enum(str, 'lt', 'le', 'eq', 'ne', 'ge', 'gt') - "The comparison against the alarm threshold" - - threshold = float - "The threshold of the alarm" - - statistic = wtypes.Enum(str, 'max', 'min', 'avg', 'sum', 'count') - "The statistic to compare to the threshold" - - enabled = bool - "This alarm is enabled?" - - evaluation_periods = int - "The number of periods to evaluate the threshold" - - period = int - "The time range in seconds over which to evaluate the threshold" - timestamp = datetime.datetime "The date of the last alarm definition update" - state = wtypes.Enum(str, 'ok', 'alarm', 'insufficient data') + #TODO(sileht): Add an explicit "set_state" operation instead of + #forcing the caller to PUT the entire definition of the alarm to test it. + #(example: POST/PUT? alarms//state) + state = AdvEnum('state', str, 'ok', 'alarm', 'insufficient data', + default='insufficient data') "The state offset the alarm" state_timestamp = datetime.datetime "The date of the last alarm state changed" - ok_actions = [wtypes.text] - "The actions to do when alarm state change to ok" - - alarm_actions = [wtypes.text] - "The actions to do when alarm state change to alarm" - - insufficient_data_actions = [wtypes.text] - "The actions to do when alarm state change to insufficient data" - - repeat_actions = bool - "The actions should be re-triggered on each evaluation cycle" - - matching_metadata = {wtypes.text: wtypes.text} - "The matching_metadata of the alarm" - - def __init__(self, **kwargs): + def __init__(self, rule=None, **kwargs): super(Alarm, self).__init__(**kwargs) + if rule and self.type == 'threshold': + self.threshold_rule = AlarmThresholdRule(**rule) + @classmethod def sample(cls): return cls(alarm_id=None, name="SwiftObjectAlarm", description="An alarm", - meter_name="storage.objects", - comparison_operator="gt", - threshold=200, - statistic="avg", + type='threshold', + threshold_rule=None, user_id="c96c887c216949acbdfbd8b494863567", project_id="c96c887c216949acbdfbd8b494863567", - evaluation_periods=2, - period=240, enabled=True, timestamp=datetime.datetime.utcnow(), state="ok", @@ -939,11 +1069,17 @@ class Alarm(_Base): ok_actions=["http://site:8000/ok"], alarm_actions=["http://site:8000/alarm"], insufficient_data_actions=["http://site:8000/nodata"], - matching_metadata={"key_name": - "key_value"}, repeat_actions=False, ) + def as_dict(self, db_model): + d = super(Alarm, self).as_dict(db_model) + for k in d: + if k.endswith('_rule'): + del d[k] + d['rule'] = getattr(self, "%s_rule" % self.type).as_dict() + return d + class AlarmChange(_Base): """Representation of an event in an alarm's history @@ -1017,9 +1153,7 @@ class AlarmController(rest.RestController): def _record_change(self, data, now, on_behalf_of=None, type=None): if not cfg.CONF.alarm.record_history: return - type = type or (storage.models.AlarmChange.STATE_TRANSITION - if data.get('state') - else storage.models.AlarmChange.RULE_CHANGE) + type = type or storage.models.AlarmChange.RULE_CHANGE detail = json.dumps(utils.stringify_timestamps(data)) user_id = pecan.request.headers.get('X-User-Id') project_id = pecan.request.headers.get('X-Project-Id') @@ -1045,20 +1179,35 @@ class AlarmController(rest.RestController): @wsme_pecan.wsexpose(Alarm, wtypes.text, body=Alarm) def put(self, data): """Modify this alarm.""" - # merge the new values from kwargs into the current - # alarm "alarm_in". + # Ensure alarm exists alarm_in = self._alarm() + now = timeutils.utcnow() - change = data.as_dict(storage.models.Alarm) - data.state_timestamp = wsme.Unset + data.alarm_id = self._id - kwargs = data.as_dict(storage.models.Alarm) - for k, v in kwargs.iteritems(): - setattr(alarm_in, k, v) - if k == 'state': - alarm_in.state_timestamp = now + data.user_id = alarm_in.user_id + data.project_id = alarm_in.project_id + data.timestamp = now + if alarm_in.state != data.state: + data.state_timestamp = now + else: + data.state_timestamp = alarm_in.state_timestamp + + old_alarm = Alarm.from_db_model(alarm_in).as_dict(storage.models.Alarm) + updated_alarm = data.as_dict(storage.models.Alarm) + try: + alarm_in = storage.models.Alarm(**updated_alarm) + except Exception: + LOG.exception("Error while putting alarm: %s" % updated_alarm) + error = _("Alarm incorrect") + pecan.response.translatable_error = error + raise wsme.exc.ClientSideError(unicode(error)) alarm = self.conn.update_alarm(alarm_in) + + change = dict((k, v) for k, v in updated_alarm.items() + if v != old_alarm[k] and k not in + ['timestamp', 'state_timestamp']) self._record_change(change, now, on_behalf_of=alarm.project_id) return Alarm.from_db_model(alarm) @@ -1126,14 +1275,15 @@ class AlarmsController(rest.RestController): def post(self, data): """Create a new alarm.""" conn = pecan.request.storage_conn - now = timeutils.utcnow() + data.alarm_id = str(uuid.uuid4()) data.user_id = pecan.request.headers.get('X-User-Id') data.project_id = pecan.request.headers.get('X-Project-Id') - data.state_timestamp = wsme.Unset - change = data.as_dict(storage.models.Alarm) data.timestamp = now + data.state_timestamp = now + + change = data.as_dict(storage.models.Alarm) # make sure alarms are unique by name per project. alarms = list(conn.get_alarms(name=data.name, @@ -1144,10 +1294,9 @@ class AlarmsController(rest.RestController): raise wsme.exc.ClientSideError(unicode(error)) try: - kwargs = data.as_dict(storage.models.Alarm) - alarm_in = storage.models.Alarm(**kwargs) - except Exception as ex: - LOG.exception(ex) + alarm_in = storage.models.Alarm(**change) + except Exception: + LOG.exception("Error while posting alarm: %s" % change) error = _("Alarm incorrect") pecan.response.translatable_error = error raise wsme.exc.ClientSideError(unicode(error)) diff --git a/ceilometer/storage/impl_db2.py b/ceilometer/storage/impl_db2.py index 410c5bc0..bbc331ee 100644 --- a/ceilometer/storage/impl_db2.py +++ b/ceilometer/storage/impl_db2.py @@ -615,14 +615,55 @@ class Connection(base.Connection): new_matching_metadata[elem['key']] = elem['value'] return new_matching_metadata - @staticmethod - def _encode_matching_metadata(matching_metadata): - if matching_metadata: - new_matching_metadata = [] - for k, v in matching_metadata.iteritems(): - new_matching_metadata.append({'key': k, 'value': v}) - return new_matching_metadata - return matching_metadata + @classmethod + def _ensure_encapsulated_rule_format(cls, alarm): + """This ensure the alarm returned by the storage have the correct + format. The previous format looks like: + { + 'alarm_id': '0ld-4l3rt', + 'enabled': True, + 'name': 'old-alert', + 'description': 'old-alert', + 'timestamp': None, + 'meter_name': 'cpu', + 'user_id': 'me', + 'project_id': 'and-da-boys', + 'comparison_operator': 'lt', + 'threshold': 36, + 'statistic': 'count', + 'evaluation_periods': 1, + 'period': 60, + 'state': "insufficient data", + 'state_timestamp': None, + 'ok_actions': [], + 'alarm_actions': ['http://nowhere/alarms'], + 'insufficient_data_actions': [], + 'repeat_actions': False, + 'matching_metadata': {'key': 'value'} + # or 'matching_metadata': [{'key': 'key', 'value': 'value'}] + } + """ + + if isinstance(alarm.get('rule'), dict): + return + + alarm['type'] = 'threshold' + alarm['rule'] = {} + alarm['matching_metadata'] = cls._decode_matching_metadata( + alarm['matching_metadata']) + for field in ['period', 'evaluation_period', 'threshold', + 'statistic', 'comparison_operator', 'meter_name']: + if field in alarm: + alarm['rule'][field] = alarm[field] + del alarm[field] + + query = [] + for key in alarm['matching_metadata']: + query.append({'field': key, + 'op': 'eq', + 'value': alarm['matching_metadata'][key]}) + del alarm['matching_metadata'] + alarm['rule']['query'] = query def get_alarms(self, name=None, user=None, project=None, enabled=True, alarm_id=None, pagination=None): @@ -655,17 +696,13 @@ class Connection(base.Connection): a = {} a.update(alarm) del a['_id'] - a['matching_metadata'] = \ - self._decode_matching_metadata(a['matching_metadata']) + self._ensure_encapsulated_rule_format(a) yield models.Alarm(**a) def update_alarm(self, alarm): """update alarm """ data = alarm.as_dict() - data['matching_metadata'] = \ - self._encode_matching_metadata(data['matching_metadata']) - self.db.alarm.update( {'alarm_id': alarm.alarm_id}, {'$set': data}, @@ -673,8 +710,7 @@ class Connection(base.Connection): stored_alarm = self.db.alarm.find({'alarm_id': alarm.alarm_id})[0] del stored_alarm['_id'] - stored_alarm['matching_metadata'] = \ - self._decode_matching_metadata(stored_alarm['matching_metadata']) + self._ensure_encapsulated_rule_format(stored_alarm) return models.Alarm(**stored_alarm) create_alarm = update_alarm diff --git a/ceilometer/storage/impl_mongodb.py b/ceilometer/storage/impl_mongodb.py index 31927888..dd2a9711 100644 --- a/ceilometer/storage/impl_mongodb.py +++ b/ceilometer/storage/impl_mongodb.py @@ -789,7 +789,8 @@ class Connection(base.Connection): @staticmethod def _decode_matching_metadata(matching_metadata): if isinstance(matching_metadata, dict): - #note(sileht): keep compatibility with old db format + #note(sileht): keep compatibility with alarm + #with matching_metadata as a dict return matching_metadata else: new_matching_metadata = {} @@ -797,14 +798,56 @@ class Connection(base.Connection): new_matching_metadata[elem['key']] = elem['value'] return new_matching_metadata - @staticmethod - def _encode_matching_metadata(matching_metadata): - if matching_metadata: - new_matching_metadata = [] - for k, v in matching_metadata.iteritems(): - new_matching_metadata.append({'key': k, 'value': v}) - return new_matching_metadata - return matching_metadata + @classmethod + def _ensure_encapsulated_rule_format(cls, alarm): + """This ensure the alarm returned by the storage have the correct + format. The previous format looks like: + { + 'alarm_id': '0ld-4l3rt', + 'enabled': True, + 'name': 'old-alert', + 'description': 'old-alert', + 'timestamp': None, + 'meter_name': 'cpu', + 'user_id': 'me', + 'project_id': 'and-da-boys', + 'comparison_operator': 'lt', + 'threshold': 36, + 'statistic': 'count', + 'evaluation_periods': 1, + 'period': 60, + 'state': "insufficient data", + 'state_timestamp': None, + 'ok_actions': [], + 'alarm_actions': ['http://nowhere/alarms'], + 'insufficient_data_actions': [], + 'repeat_actions': False, + 'matching_metadata': {'key': 'value'} + # or 'matching_metadata': [{'key': 'key', 'value': 'value'}] + } + """ + + if isinstance(alarm.get('rule'), dict): + return + + alarm['type'] = 'threshold' + alarm['rule'] = {} + alarm['matching_metadata'] = cls._decode_matching_metadata( + alarm['matching_metadata']) + for field in ['period', 'evaluation_periods', 'threshold', + 'statistic', 'comparison_operator', 'meter_name']: + if field in alarm: + alarm['rule'][field] = alarm[field] + del alarm[field] + + query = [] + for key in alarm['matching_metadata']: + query.append({'field': key, + 'op': 'eq', + 'value': alarm['matching_metadata'][key], + 'type': 'string'}) + del alarm['matching_metadata'] + alarm['rule']['query'] = query def get_alarms(self, name=None, user=None, project=None, enabled=True, alarm_id=None, pagination=None): @@ -835,16 +878,13 @@ class Connection(base.Connection): a = {} a.update(alarm) del a['_id'] - a['matching_metadata'] = \ - self._decode_matching_metadata(a['matching_metadata']) + self._ensure_encapsulated_rule_format(a) yield models.Alarm(**a) def update_alarm(self, alarm): """update alarm """ data = alarm.as_dict() - data['matching_metadata'] = \ - self._encode_matching_metadata(data['matching_metadata']) self.db.alarm.update( {'alarm_id': alarm.alarm_id}, @@ -853,8 +893,7 @@ class Connection(base.Connection): stored_alarm = self.db.alarm.find({'alarm_id': alarm.alarm_id})[0] del stored_alarm['_id'] - stored_alarm['matching_metadata'] = \ - self._decode_matching_metadata(stored_alarm['matching_metadata']) + self._ensure_encapsulated_rule_format(stored_alarm) return models.Alarm(**stored_alarm) create_alarm = update_alarm diff --git a/ceilometer/storage/impl_sqlalchemy.py b/ceilometer/storage/impl_sqlalchemy.py index 2c39fe03..93139aa3 100644 --- a/ceilometer/storage/impl_sqlalchemy.py +++ b/ceilometer/storage/impl_sqlalchemy.py @@ -581,24 +581,19 @@ class Connection(base.Connection): def _row_to_alarm_model(row): return api_models.Alarm(alarm_id=row.id, enabled=row.enabled, + type=row.type, name=row.name, description=row.description, timestamp=row.timestamp, - meter_name=row.meter_name, user_id=row.user_id, project_id=row.project_id, - comparison_operator=row.comparison_operator, - threshold=row.threshold, - statistic=row.statistic, - evaluation_periods=row.evaluation_periods, - period=row.period, state=row.state, state_timestamp=row.state_timestamp, ok_actions=row.ok_actions, alarm_actions=row.alarm_actions, insufficient_data_actions= row.insufficient_data_actions, - matching_metadata=row.matching_metadata, + rule=row.rule, repeat_actions=row.repeat_actions) def get_alarms(self, name=None, user=None, diff --git a/ceilometer/storage/models.py b/ceilometer/storage/models.py index 47f39a96..40944eff 100644 --- a/ceilometer/storage/models.py +++ b/ceilometer/storage/models.py @@ -265,14 +265,12 @@ class Alarm(Model): An alarm to monitor. :param alarm_id: UUID of the alarm + :param type: type of the alarm :param name: The Alarm name :param description: User friendly description of the alarm :param enabled: Is the alarm enabled - :param state: Alarm state (alarm/nodata/ok) - :param meter_name: The counter that the alarm is based on - :param comparison_operator: How to compare the samples and the threshold - :param threshold: the value to compare to the samples - :param statistic: the function from Statistic (min/max/avg/count) + :param state: Alarm state (ok/alarm/insufficient data) + :param rule: A rule that defines when the alarm fires :param user_id: the owner/creator of the alarm :param project_id: the project_id of the creator :param evaluation_periods: the number of periods @@ -284,47 +282,23 @@ class Alarm(Model): alarm state :param insufficient_data_actions: the list of webhooks to call when entering the insufficient data state - :param matching_metadata: the key/values of metadata to match on. :param repeat_actions: Is the actions should be triggered on each alarm evaluation. """ - def __init__(self, alarm_id, name, meter_name, - comparison_operator, threshold, statistic, - user_id, project_id, - evaluation_periods=1, - period=60, - enabled=True, - description='', - timestamp=None, - state=ALARM_INSUFFICIENT_DATA, - state_timestamp=None, - ok_actions=[], - alarm_actions=[], - insufficient_data_actions=[], - matching_metadata={}, - repeat_actions=False - ): - if not description: - # make a nice user friendly description by default - description = 'Alarm when %s is %s a %s of %s over %s seconds' % ( - meter_name, comparison_operator, - statistic, threshold, period) - + def __init__(self, alarm_id, type, enabled, name, description, + timestamp, user_id, project_id, state, state_timestamp, + ok_actions, alarm_actions, insufficient_data_actions, + repeat_actions, rule): Model.__init__( self, alarm_id=alarm_id, + type=type, enabled=enabled, name=name, description=description, timestamp=timestamp, - meter_name=meter_name, user_id=user_id, project_id=project_id, - comparison_operator=comparison_operator, - threshold=threshold, - statistic=statistic, - evaluation_periods=evaluation_periods, - period=period, state=state, state_timestamp=state_timestamp, ok_actions=ok_actions, @@ -332,7 +306,7 @@ class Alarm(Model): insufficient_data_actions= insufficient_data_actions, repeat_actions=repeat_actions, - matching_metadata=matching_metadata) + rule=rule) class AlarmChange(Model): diff --git a/ceilometer/storage/sqlalchemy/migrate_repo/versions/016_simpler_alarm.py b/ceilometer/storage/sqlalchemy/migrate_repo/versions/016_simpler_alarm.py new file mode 100644 index 00000000..703b7fa6 --- /dev/null +++ b/ceilometer/storage/sqlalchemy/migrate_repo/versions/016_simpler_alarm.py @@ -0,0 +1,109 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2013 eNovance +# +# Author: Mehdi Abaakouk +# +# 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 sqlalchemy import MetaData, Table, Column, Index +from sqlalchemy import String, Float, Integer, Text + +meta = MetaData() + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + table = Table('alarm', meta, autoload=True) + + type = Column('type', String(50), default='threshold') + type.create(table, populate_default=True) + + rule = Column('rule', Text()) + rule.create(table) + + for row in table.select().execute().fetchall(): + query = [] + if row.matching_metadata is not None: + matching_metadata = json.loads(row.matching_metadata) + for key in matching_metadata: + query.append({'field': key, + 'op': 'eq', + 'value': matching_metadata[key]}) + rule = { + 'meter_name': row.meter_name, + 'comparison_operator': row.comparison_operator, + 'threshold': row.threshold, + 'statistic': row.statistic, + 'evaluation_periods': row.evaluation_periods, + 'period': row.period, + 'query': query + } + table.update().where(table.c.id == row.id).values(rule=rule).execute() + + index = Index('ix_alarm_counter_name', table.c.meter_name) + index.drop(bind=migrate_engine) + table.c.meter_name.drop() + table.c.comparison_operator.drop() + table.c.threshold.drop() + table.c.statistic.drop() + table.c.evaluation_periods.drop() + table.c.period.drop() + table.c.matching_metadata.drop() + + +def downgrade(migrate_engine): + meta.bind = migrate_engine + table = Table('alarm', meta, autoload=True) + + columns = [ + Column('meter_name', String(255)), + Column('comparison_operator', String(2)), + Column('threshold', Float), + Column('statistic', String(255)), + Column('evaluation_periods', Integer), + Column('period', Integer), + Column('matching_metadata', Text()) + ] + for c in columns: + c.create(table) + + for row in table.select().execute().fetchall(): + if row.type != 'threshold': + #note: type insupported in previous version + table.delete().where(table.c.id == row.id).execute() + else: + rule = json.loads(row.rule) + values = {'comparison_operator': rule['comparison_operator'], + 'threshold': float(rule['threshold']), + 'statistic': rule['statistic'], + 'evaluation_periods': int(rule['evaluation_periods']), + 'period': int(rule['period']), + 'meter_name': int(rule['mater_name']), + 'matching_metadata': {}} + + #note: op are ignored because previous format don't support it + for q in rule['query']: + values['matching_metadata'][q['field']] = q['value'] + values['matching_metadata'] = json.dumps( + values['matching_metadata']) + table.update().where(table.c.id == row.id + ).values(**values).execute() + + index = Index('ix_alarm_counter_name', table.c.meter_name) + index.create(bind=migrate_engine) + + table.c.type.drop() + table.c.rule.drop() diff --git a/ceilometer/storage/sqlalchemy/models.py b/ceilometer/storage/sqlalchemy/models.py index c43dc62d..0b828366 100644 --- a/ceilometer/storage/sqlalchemy/models.py +++ b/ceilometer/storage/sqlalchemy/models.py @@ -175,24 +175,17 @@ class Alarm(Base): __table_args__ = ( Index('ix_alarm_user_id', 'user_id'), Index('ix_alarm_project_id', 'project_id'), - Index('ix_alarm_meter_name', 'meter_name'), ) id = Column(String(255), primary_key=True) enabled = Column(Boolean) name = Column(Text) + type = Column(String(50)) description = Column(Text) timestamp = Column(DateTime, default=timeutils.utcnow) - meter_name = Column(String(255)) user_id = Column(String(255), ForeignKey('user.id')) project_id = Column(String(255), ForeignKey('project.id')) - comparison_operator = Column(String(2)) - threshold = Column(Float) - statistic = Column(String(255)) - evaluation_periods = Column(Integer) - period = Column(Integer) - state = Column(String(255)) state_timestamp = Column(DateTime, default=timeutils.utcnow) @@ -201,7 +194,7 @@ class Alarm(Base): insufficient_data_actions = Column(JSONEncodedDict) repeat_actions = Column(Boolean) - matching_metadata = Column(JSONEncodedDict) + rule = Column(JSONEncodedDict) class AlarmChange(Base): diff --git a/tests/alarm/test_singleton_alarm_svc.py b/tests/alarm/test_singleton_alarm_svc.py index 3cfee4a9..1e91620c 100644 --- a/tests/alarm/test_singleton_alarm_svc.py +++ b/tests/alarm/test_singleton_alarm_svc.py @@ -63,15 +63,27 @@ class TestSingletonAlarmService(base.TestCase): def test_evaluation_cycle(self): alarms = [ models.Alarm(name='instance_running_hot', - meter_name='cpu_util', - comparison_operator='gt', - threshold=80.0, - evaluation_periods=5, - statistic='avg', + type='threshold', user_id='foobar', project_id='snafu', - period=60, - alarm_id=str(uuid.uuid4())), + enabled=True, + description='', + repeat_actions=False, + state='insufficient data', + state_timestamp=None, + timestamp=None, + ok_actions=[], + alarm_actions=[], + insufficient_data_actions=[], + alarm_id=str(uuid.uuid4()), + rule=dict( + statistic='avg', + comparison_operator='gt', + threshold=80.0, + evaluation_periods=5, + period=60, + query=[], + )), ] self.api_client.alarms.list.return_value = alarms with mock.patch('ceilometerclient.client.get_client', diff --git a/tests/alarm/test_threshold_evaluation.py b/tests/alarm/test_threshold_evaluation.py index 4bbd8c33..bfcd64e6 100644 --- a/tests/alarm/test_threshold_evaluation.py +++ b/tests/alarm/test_threshold_evaluation.py @@ -36,29 +36,61 @@ class TestEvaluate(base.TestCase): self.notifier = mock.MagicMock() self.alarms = [ models.Alarm(name='instance_running_hot', - meter_name='cpu_util', - comparison_operator='gt', - threshold=80.0, - evaluation_periods=5, - statistic='avg', + description='instance_running_hot', + type='threshold', + enabled=True, user_id='foobar', project_id='snafu', - period=60, alarm_id=str(uuid.uuid4()), - matching_metadata={'resource_id': - 'my_instance'}), + state='insufficient data', + state_timestamp=None, + timestamp=None, + insufficient_data_actions=[], + ok_actions=[], + alarm_actions=[], + repeat_actions=False, + rule=dict( + comparison_operator='gt', + threshold=80.0, + evaluation_periods=5, + statistic='avg', + period=60, + meter_name='cpu_util', + query=[{'field': 'meter', + 'op': 'eq', + 'value': 'cpu_util'}, + {'field': 'resource_id', + 'op': 'eq', + 'value': 'my_instance'}]) + ), models.Alarm(name='group_running_idle', - meter_name='cpu_util', - comparison_operator='le', - threshold=10.0, - statistic='max', - evaluation_periods=4, + description='group_running_idle', + type='threshold', + enabled=True, user_id='foobar', project_id='snafu', - period=300, + state='insufficient data', + state_timestamp=None, + timestamp=None, + insufficient_data_actions=[], + ok_actions=[], + alarm_actions=[], + repeat_actions=False, alarm_id=str(uuid.uuid4()), - matching_metadata={'metadata.user_metadata.AS': - 'my_group'}), + rule=dict( + comparison_operator='le', + threshold=10.0, + evaluation_periods=4, + statistic='max', + period=300, + meter_name='cpu_util', + query=[{'field': 'meter', + 'op': 'eq', + 'value': 'cpu_util'}, + {'field': 'metadata.user_metadata.AS', + 'op': 'eq', + 'value': 'my_group'}]) + ), ] self.evaluator = threshold_evaluation.Evaluator(self.notifier) self.evaluator.assign_alarms(self.alarms) @@ -83,9 +115,9 @@ class TestEvaluate(base.TestCase): with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): broken = exc.CommunicationError(message='broken') - avgs = [self._get_stat('avg', self.alarms[0].threshold - v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] - v) for v in xrange(5)] - maxs = [self._get_stat('max', self.alarms[1].threshold + v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] + v) for v in xrange(1, 4)] self.api_client.statistics.list.side_effect = [broken, broken, @@ -110,7 +142,7 @@ class TestEvaluate(base.TestCase): expected = [mock.call(alarm, 'ok', ('%d datapoints are unknown' % - alarm.evaluation_periods)) + alarm.rule['evaluation_periods'])) for alarm in self.alarms] self.assertEqual(self.notifier.notify.call_args_list, expected) @@ -137,9 +169,9 @@ class TestEvaluate(base.TestCase): self._set_all_alarms('ok') with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): - avgs = [self._get_stat('avg', self.alarms[0].threshold + v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] + v) for v in xrange(1, 6)] - maxs = [self._get_stat('max', self.alarms[1].threshold - v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] - v) for v in xrange(4)] self.api_client.statistics.list.side_effect = [avgs, maxs] self.evaluator.evaluate() @@ -160,9 +192,9 @@ class TestEvaluate(base.TestCase): self._set_all_alarms('alarm') with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): - avgs = [self._get_stat('avg', self.alarms[0].threshold - v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] - v) for v in xrange(5)] - maxs = [self._get_stat('max', self.alarms[1].threshold + v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] + v) for v in xrange(1, 5)] self.api_client.statistics.list.side_effect = [avgs, maxs] self.evaluator.evaluate() @@ -183,9 +215,9 @@ class TestEvaluate(base.TestCase): self._set_all_alarms('ok') with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): - avgs = [self._get_stat('avg', self.alarms[0].threshold + v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] + v) for v in xrange(5)] - maxs = [self._get_stat('max', self.alarms[1].threshold - v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] - v) for v in xrange(-1, 3)] self.api_client.statistics.list.side_effect = [avgs, maxs] self.evaluator.evaluate() @@ -199,9 +231,9 @@ class TestEvaluate(base.TestCase): self.alarms[1].repeat_actions = True with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): - avgs = [self._get_stat('avg', self.alarms[0].threshold + v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] + v) for v in xrange(5)] - maxs = [self._get_stat('max', self.alarms[1].threshold - v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] - v) for v in xrange(-1, 3)] self.api_client.statistics.list.side_effect = [avgs, maxs] self.evaluator.evaluate() @@ -218,9 +250,9 @@ class TestEvaluate(base.TestCase): self.alarms[1].repeat_actions = True with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): - avgs = [self._get_stat('avg', self.alarms[0].threshold + v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] + v) for v in xrange(1, 6)] - maxs = [self._get_stat('max', self.alarms[1].threshold - v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] - v) for v in xrange(4)] self.api_client.statistics.list.side_effect = [avgs, maxs] self.evaluator.evaluate() @@ -238,9 +270,9 @@ class TestEvaluate(base.TestCase): self.alarms[1].repeat_actions = True with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): - avgs = [self._get_stat('avg', self.alarms[0].threshold + v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] + v) for v in xrange(1, 6)] - maxs = [self._get_stat('max', self.alarms[1].threshold - v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] - v) for v in xrange(4)] self.api_client.statistics.list.side_effect = [avgs, maxs] self.evaluator.evaluate() @@ -261,9 +293,9 @@ class TestEvaluate(base.TestCase): self._set_all_alarms('insufficient data') with mock.patch('ceilometerclient.client.get_client', return_value=self.api_client): - avgs = [self._get_stat('avg', self.alarms[0].threshold + v) + avgs = [self._get_stat('avg', self.alarms[0].rule['threshold'] + v) for v in xrange(1, 6)] - maxs = [self._get_stat('max', self.alarms[1].threshold - v) + maxs = [self._get_stat('max', self.alarms[1].rule['threshold'] - v) for v in xrange(4)] self.api_client.statistics.list.side_effect = [avgs, maxs] self.evaluator.evaluate() diff --git a/tests/api/v2/test_alarm_scenarios.py b/tests/api/v2/test_alarm_scenarios.py index aa289c89..4d70e8ea 100644 --- a/tests/api/v2/test_alarm_scenarios.py +++ b/tests/api/v2/test_alarm_scenarios.py @@ -33,6 +33,7 @@ from .base import FunctionalTest from ceilometer.storage.models import Alarm from ceilometer.tests import db as tests_db + load_tests = testscenarios.load_tests_apply_scenarios LOG = logging.getLogger(__name__) @@ -55,27 +56,83 @@ class TestAlarms(FunctionalTest, self.auth_headers = {'X-User-Id': str(uuid.uuid4()), 'X-Project-Id': str(uuid.uuid4())} for alarm in [Alarm(name='name1', + type='threshold', + enabled=True, alarm_id='a', - meter_name='meter.test', - comparison_operator='gt', threshold=2.0, - statistic='avg', + description='a', + state='insufficient data', + state_timestamp=None, + timestamp=None, + ok_actions=[], + insufficient_data_actions=[], + alarm_actions=[], repeat_actions=True, user_id=self.auth_headers['X-User-Id'], - project_id=self.auth_headers['X-Project-Id']), + project_id=self.auth_headers['X-Project-Id'], + rule=dict(comparison_operator='gt', + threshold=2.0, + statistic='avg', + evaluation_periods=60, + period=1, + meter_name='meter.test', + query=[ + {'field': 'project_id', + 'op': 'eq', 'value': + self.auth_headers['X-Project-Id']} + ]) + ), Alarm(name='name2', + type='threshold', + enabled=True, alarm_id='b', - meter_name='meter.mine', - comparison_operator='gt', threshold=2.0, - statistic='avg', + description='b', + state='insufficient data', + state_timestamp=None, + timestamp=None, + 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']), + project_id=self.auth_headers['X-Project-Id'], + rule=dict(comparison_operator='gt', + threshold=4.0, + statistic='avg', + evaluation_periods=60, + period=1, + meter_name='meter.test', + query=[ + {'field': 'project_id', + 'op': 'eq', 'value': + self.auth_headers['X-Project-Id']} + ]) + ), Alarm(name='name3', + type='threshold', + enabled=True, alarm_id='c', - meter_name='meter.test', - comparison_operator='gt', threshold=2.0, - statistic='avg', + description='c', + state='insufficient data', + state_timestamp=None, + timestamp=None, + 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'])]: + project_id=self.auth_headers['X-Project-Id'], + rule=dict(comparison_operator='gt', + threshold=3.0, + statistic='avg', + evaluation_periods=60, + period=1, + meter_name='meter.mine', + query=[ + {'field': 'project_id', + 'op': 'eq', 'value': + self.auth_headers['X-Project-Id']} + ]) + )]: self.conn.update_alarm(alarm) def test_list_alarms(self): @@ -83,7 +140,8 @@ class TestAlarms(FunctionalTest, self.assertEqual(3, len(data)) self.assertEqual(set(r['name'] for r in data), set(['name1', 'name2', 'name3'])) - self.assertEqual(set(r['meter_name'] for r in data), + self.assertEqual(set(r['threshold_rule']['meter_name'] + for r in data), set(['meter.test', 'meter.mine'])) def test_get_alarm(self): @@ -94,51 +152,183 @@ class TestAlarms(FunctionalTest, for a in alarms: print('%s: %s' % (a['name'], a['alarm_id'])) self.assertEqual(alarms[0]['name'], 'name1') - self.assertEqual(alarms[0]['meter_name'], 'meter.test') + self.assertEqual(alarms[0]['threshold_rule']['meter_name'], + 'meter.test') one = self.get_json('/alarms/%s' % alarms[0]['alarm_id']) self.assertEqual(one['name'], 'name1') - self.assertEqual(one['meter_name'], 'meter.test') + self.assertEqual(one['threshold_rule']['meter_name'], + 'meter.test') self.assertEqual(one['alarm_id'], alarms[0]['alarm_id']) self.assertEqual(one['repeat_actions'], alarms[0]['repeat_actions']) - def test_post_invalid_alarm(self): + def test_post_invalid_alarm_period(self): json = { - 'name': 'added_alarm', - 'meter_name': 'ameter', - 'comparison_operator': 'gt', - 'threshold': 2.0, - 'statistic': 'magic', + 'name': 'added_alarm_invalid_period', + 'type': 'threshold', + 'threshold_rule': { + 'meter_name': 'ameter', + 'comparison_operator': 'gt', + 'threshold': 2.0, + 'statistic': 'avg', + 'period': -1, + } + } self.post_json('/alarms', params=json, expect_errors=True, status=400, headers=self.auth_headers) alarms = list(self.conn.get_alarms()) self.assertEqual(3, len(alarms)) - def test_post_alarm(self): + def test_post_invalid_alarm_statistic(self): json = { 'name': 'added_alarm', - 'meter_name': 'ameter', - 'comparison_operator': 'gt', - 'threshold': 2.0, - 'statistic': 'avg', - 'repeat_actions': True, + 'type': 'threshold', + 'threshold_rule': { + 'meter_name': 'ameter', + 'comparison_operator': 'gt', + 'threshold': 2.0, + 'statistic': 'magic', + } + } + self.post_json('/alarms', params=json, expect_errors=True, status=400, + headers=self.auth_headers) + alarms = list(self.conn.get_alarms()) + self.assertEqual(3, len(alarms)) + + def test_post_invalid_alarm_query(self): + json = { + 'name': 'added_alarm', + 'type': 'threshold', + 'threshold_rule': { + 'meter_name': 'ameter', + 'query': [{'field': 'metadata.invalid', + 'field': 'gt', + 'value': 'value'}], + 'comparison_operator': 'gt', + 'threshold': 2.0, + 'statistic': 'avg', + } + } + self.post_json('/alarms', params=json, expect_errors=True, status=400, + headers=self.auth_headers) + alarms = list(self.conn.get_alarms()) + self.assertEqual(3, len(alarms)) + + def test_post_alarm_defaults(self): + to_check = { + 'enabled': True, + 'name': 'added_alarm_defaults', + 'state': 'insufficient data', + 'description': ('Alarm when ameter is eq a avg of ' + '300.0 over 60 seconds'), + 'type': 'threshold', + 'ok_actions': [], + 'alarm_actions': [], + 'insufficient_data_actions': [], + 'repeat_actions': False, + 'threshold_rule': { + 'meter_name': 'ameter', + 'query': [{'field': 'project_id', + 'op': 'eq', + 'value': self.auth_headers['X-Project-Id']}], + 'threshold': 300.0, + 'comparison_operator': 'eq', + 'statistic': 'avg', + 'evaluation_periods': 1, + 'period': 60, + } + + } + + json = { + 'name': 'added_alarm_defaults', + 'type': 'threshold', + 'threshold_rule': { + 'meter_name': 'ameter', + 'threshold': 300.0 + } } self.post_json('/alarms', params=json, status=201, headers=self.auth_headers) alarms = list(self.conn.get_alarms()) self.assertEqual(4, len(alarms)) for alarm in alarms: - if alarm.name == 'added_alarm': - self.assertEqual(alarm.repeat_actions, True) + if alarm.name == 'added_alarm_defaults': + for key in to_check: + if key.endswith('_rule'): + storage_key = 'rule' + else: + storage_key = key + self.assertEqual(getattr(alarm, storage_key), + to_check[key]) break else: self.fail("Alarm not found") + def test_post_alarm(self): + json = { + 'enabled': False, + 'name': 'added_alarm', + 'state': 'ok', + 'type': 'threshold', + 'ok_actions': ['http://something/ok'], + 'alarm_actions': ['http://something/alarm'], + 'insufficient_data_actions': ['http://something/no'], + 'repeat_actions': True, + 'threshold_rule': { + 'meter_name': 'ameter', + 'query': [{'field': 'metadata.field', + 'op': 'eq', + 'value': '5', + 'type': 'string'}], + 'comparison_operator': 'le', + 'statistic': 'count', + 'threshold': 50, + 'evaluation_periods': 3, + 'period': 180, + } + } + self.post_json('/alarms', params=json, status=201, + headers=self.auth_headers) + alarms = list(self.conn.get_alarms(enabled=False)) + self.assertEqual(1, len(alarms)) + json['threshold_rule']['query'].append({ + 'field': 'project_id', 'op': 'eq', + 'value': self.auth_headers['X-Project-Id']}) + if alarms[0].name == 'added_alarm': + for key in json: + if key.endswith('_rule'): + storage_key = 'rule' + else: + storage_key = key + self.assertEqual(getattr(alarms[0], storage_key), + json[key]) + else: + self.fail("Alarm not found") + def test_put_alarm(self): json = { - 'name': 'renamed_alarm', + 'enabled': False, + 'name': 'name_put', + 'state': 'ok', + 'type': 'threshold', + 'ok_actions': ['http://something/ok'], + 'alarm_actions': ['http://something/alarm'], + 'insufficient_data_actions': ['http://something/no'], 'repeat_actions': True, + 'threshold_rule': { + 'meter_name': 'ameter', + 'query': [{'field': 'metadata.field', + 'op': 'eq', + 'value': '5', + 'type': 'string'}], + 'comparison_operator': 'le', + 'statistic': 'count', + 'threshold': 50, + 'evaluation_periods': 3, + 'period': 180, + } } data = self.get_json('/alarms', q=[{'field': 'name', @@ -150,27 +340,53 @@ class TestAlarms(FunctionalTest, self.put_json('/alarms/%s' % alarm_id, params=json, headers=self.auth_headers) - alarm = list(self.conn.get_alarms(alarm_id=alarm_id))[0] - self.assertEqual(alarm.name, json['name']) - self.assertEqual(alarm.repeat_actions, json['repeat_actions']) + alarm = list(self.conn.get_alarms(alarm_id=alarm_id, enabled=False))[0] + json['threshold_rule']['query'].append({ + 'field': 'project_id', 'op': 'eq', + 'value': self.auth_headers['X-Project-Id']}) + for key in json: + if key.endswith('_rule'): + storage_key = 'rule' + else: + storage_key = key + self.assertEqual(getattr(alarm, storage_key), json[key]) def test_put_alarm_wrong_field(self): # Note: wsme will ignore unknown fields so will just not appear in # the Alarm. json = { - 'name': 'renamed_alarm', 'this_can_not_be_correct': 'ha', + 'enabled': False, + 'name': 'name1', + 'state': 'ok', + 'type': 'threshold', + 'ok_actions': ['http://something/ok'], + 'alarm_actions': ['http://something/alarm'], + 'insufficient_data_actions': ['http://something/no'], + 'repeat_actions': True, + 'threshold_rule': { + 'meter_name': 'ameter', + 'query': [{'field': 'metadata.field', + 'op': 'eq', + 'value': '5', + 'type': 'string'}], + 'comparison_operator': 'le', + 'statistic': 'count', + 'threshold': 50, + 'evaluation_periods': 3, + 'period': 180, + } } data = self.get_json('/alarms', q=[{'field': 'name', 'value': 'name1', - }], - headers=self.auth_headers) + }]) self.assertEqual(1, len(data)) + alarm_id = data[0]['alarm_id'] - resp = self.put_json('/alarms/%s' % data[0]['alarm_id'], - params=json, + resp = self.put_json('/alarms/%s' % alarm_id, expect_errors=True, + params=json, headers=self.auth_headers) self.assertEqual(resp.status_code, 200) @@ -202,7 +418,9 @@ class TestAlarms(FunctionalTest, self.assertEqual(resp.status_code, status) return resp - def _update_alarm(self, alarm, data, auth_headers=None): + def _update_alarm(self, alarm, updated_data, auth_headers=None): + data = self._get_alarm(alarm['alarm_id']) + data.update(updated_data) self.put_json('/alarms/%s' % alarm['alarm_id'], params=data, headers=auth_headers or self.auth_headers) @@ -237,11 +455,19 @@ class TestAlarms(FunctionalTest, self.assertEqual(1, len(history)) def test_get_recorded_alarm_history_on_create(self): - new_alarm = dict(name='new_alarm', - meter_name='other_meter', - comparison_operator='le', - threshold=42.0, - statistic='max') + new_alarm = { + 'name': 'new_alarm', + 'type': 'threshold', + 'threshold_rule': { + 'meter_name': 'ameter', + 'query': [], + 'comparison_operator': 'le', + 'statistic': 'max', + 'threshold': 42.0, + 'period': 60, + 'evaluation_periods': 1, + } + } self.post_json('/alarms', params=new_alarm, status=201, headers=self.auth_headers) alarm = self.get_json('/alarms')[3] @@ -253,6 +479,11 @@ class TestAlarms(FunctionalTest, type='creation', user_id=alarm['user_id']), history[0]) + new_alarm['rule'] = new_alarm['threshold_rule'] + del new_alarm['threshold_rule'] + new_alarm['rule']['query'].append({ + 'field': 'project_id', 'op': 'eq', + 'value': self.auth_headers['X-Project-Id']}) self._assert_in_json(new_alarm, history[0]['detail']) def _do_test_get_recorded_alarm_history_on_update(self, @@ -277,20 +508,12 @@ class TestAlarms(FunctionalTest, history[0]) def test_get_recorded_alarm_history_rule_change(self): - now = datetime.datetime.utcnow().isoformat() - data = dict(name='renamed', timestamp=now) - detail = '{"timestamp": "%s", "name": "renamed"}' % now + data = dict(name='renamed') + detail = '{"name": "renamed"}' self._do_test_get_recorded_alarm_history_on_update(data, 'rule change', detail) - def test_get_recorded_alarm_history_state_transition(self): - data = dict(state='alarm') - detail = '{"state": "alarm"}' - self._do_test_get_recorded_alarm_history_on_update(data, - 'state transition', - detail) - def test_get_recorded_alarm_history_state_transition_on_behalf_of(self): # credentials for new non-admin user, on who's behalf the alarm # is created @@ -299,11 +522,22 @@ class TestAlarms(FunctionalTest, member_auth = {'X-Roles': 'member', 'X-User-Id': member_user, 'X-Project-Id': member_project} - new_alarm = dict(name='new_alarm', - meter_name='other_meter', - comparison_operator='le', - threshold=42.0, - statistic='max') + new_alarm = { + 'name': 'new_alarm', + 'type': 'threshold', + 'state': 'ok', + 'threshold_rule': { + 'meter_name': 'other_meter', + 'query': [{'field': 'project_id', + 'op': 'eq', + 'value': member_project}], + 'comparison_operator': 'le', + 'statistic': 'max', + 'threshold': 42.0, + 'evaluation_periods': 1, + 'period': 60 + } + } self.post_json('/alarms', params=new_alarm, status=201, headers=member_auth) alarm = self.get_json('/alarms', headers=member_auth)[0] @@ -317,16 +551,19 @@ class TestAlarms(FunctionalTest, data = dict(state='alarm') self._update_alarm(alarm, data, auth_headers=admin_auth) + new_alarm['rule'] = new_alarm['threshold_rule'] + del new_alarm['threshold_rule'] + # ensure that both the creation event and state transition # are visible to the non-admin alarm owner and admin user alike for auth in [member_auth, admin_auth]: history = self._get_alarm_history(alarm, auth_headers=auth) - self.assertEqual(2, len(history)) + self.assertEqual(2, len(history), 'hist: %s' % history) self._assert_is_subset(dict(alarm_id=alarm['alarm_id'], detail='{"state": "alarm"}', on_behalf_of=alarm['project_id'], project_id=admin_project, - type='state transition', + type='rule change', user_id=admin_user), history[0]) self._assert_is_subset(dict(alarm_id=alarm['alarm_id'], @@ -375,9 +612,12 @@ class TestAlarms(FunctionalTest, type='deletion', user_id=alarm['user_id']), history[0]) + alarm['rule'] = alarm['threshold_rule'] + del alarm['threshold_rule'] self._assert_in_json(alarm, history[0]['detail']) + detail = '{"name": "renamed"}' self._assert_is_subset(dict(alarm_id=alarm['alarm_id'], - detail='{"name": "renamed"}', + detail=detail, on_behalf_of=alarm['project_id'], project_id=alarm['project_id'], type='rule change', @@ -395,6 +635,8 @@ class TestAlarms(FunctionalTest, self._assert_is_subset(dict(alarm_id=alarm['alarm_id'], type='deletion'), history[0]) + alarm['rule'] = alarm['threshold_rule'] + del alarm['threshold_rule'] self._assert_in_json(alarm, history[0]['detail']) for i in xrange(1, 10): detail = '{"name": "%s"}' % (10 - i) @@ -434,6 +676,8 @@ class TestAlarms(FunctionalTest, type='deletion', user_id=alarm['user_id']), history[0]) + alarm['rule'] = alarm['threshold_rule'] + del alarm['threshold_rule'] self._assert_in_json(alarm, history[0]['detail']) def test_get_nonexistent_alarm_history(self): diff --git a/tests/api/v2/test_wsme_custom_type.py b/tests/api/v2/test_wsme_custom_type.py new file mode 100644 index 00000000..87f62f3c --- /dev/null +++ b/tests/api/v2/test_wsme_custom_type.py @@ -0,0 +1,58 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2013 eNovance +# +# Author: Mehdi Abaakouk +# +# 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 mock +import pecan +import wsme + +from ceilometer.api.controllers import v2 +from ceilometer.tests import base + + +class TestWsmeCustomType(base.TestCase): + def setUp(self): + super(TestWsmeCustomType, self).setUp() + pecan.response = mock.MagicMock() + + def test_bounded_int_maxmin(self): + bi = v2.BoundedInt(1, 5) + self.assertRaises(wsme.exc.ClientSideError, bi.validate, -1) + self.assertRaises(wsme.exc.ClientSideError, bi.validate, 7) + self.assertEqual(bi.validate(2), 2) + + def test_bounded_int_max(self): + bi = v2.BoundedInt(max=5) + self.assertEqual(bi.validate(-1), -1) + self.assertRaises(wsme.exc.ClientSideError, bi.validate, 7) + + def test_bounded_int_min(self): + bi = v2.BoundedInt(min=5) + self.assertEqual(bi.validate(7), 7) + self.assertRaises(wsme.exc.ClientSideError, bi.validate, -1) + + def test_advenum_default(self): + class dummybase(wsme.types.Base): + ae = v2.AdvEnum("name", str, "one", "other", default="other") + + obj = dummybase() + self.assertEqual(obj.ae, "other") + + obj = dummybase(ae="one") + self.assertEqual(obj.ae, "one") + + self.assertRaises(ValueError, dummybase, ae="not exists") diff --git a/tests/storage/test_impl_mongodb.py b/tests/storage/test_impl_mongodb.py index e2d89a5f..7c440b34 100644 --- a/tests/storage/test_impl_mongodb.py +++ b/tests/storage/test_impl_mongodb.py @@ -26,14 +26,12 @@ import copy import datetime -import uuid from oslo.config import cfg from ceilometer.publisher import rpc from ceilometer import sample from ceilometer.storage import impl_mongodb -from ceilometer.storage import models from ceilometer.storage.base import NoResultFound from ceilometer.storage.base import MultipleResultsFound from ceilometer.tests import db as tests_db @@ -194,23 +192,74 @@ class CompatibilityTest(test_storage_scenarios.DBTestBase, # Create the old format alarm with a dict instead of a # array for matching_metadata - alarm = models.Alarm('0ld-4l3rt', 'old-alert', - 'test.one', 'eq', 36, 'count', - 'me', 'and-da-boys', - evaluation_periods=1, - period=60, - alarm_actions=['http://nowhere/alarms'], - matching_metadata={'key': 'value'}) - alarm.alarm_id = str(uuid.uuid1()) - data = alarm.as_dict() + alarm = dict(alarm_id='0ld-4l3rt', + enabled=True, + name='old-alert', + description='old-alert', + timestamp=None, + meter_name='cpu', + user_id='me', + project_id='and-da-boys', + comparison_operator='lt', + threshold=36, + statistic='count', + evaluation_periods=1, + period=60, + state="insufficient data", + state_timestamp=None, + ok_actions=[], + alarm_actions=['http://nowhere/alarms'], + insufficient_data_actions=[], + repeat_actions=False, + matching_metadata={'key': 'value'}) + self.conn.db.alarm.update( - {'alarm_id': alarm.alarm_id}, - {'$set': data}, + {'alarm_id': alarm['alarm_id']}, + {'$set': alarm}, upsert=True) - def test_alarm_get_old_matching_metadata_format(self): + alarm['alarm_id'] = 'other-kind-of-0ld-4l3rt' + alarm['name'] = 'other-old-alaert' + alarm['matching_metadata'] = [{'key': 'key1', 'value': 'value1'}, + {'key': 'key2', 'value': 'value2'}] + self.conn.db.alarm.update( + {'alarm_id': alarm['alarm_id']}, + {'$set': alarm}, + upsert=True) + + def test_alarm_get_old_format_matching_metadata_dict(self): old = list(self.conn.get_alarms(name='old-alert'))[0] - self.assertEqual(old.matching_metadata, {'key': 'value'}) + self.assertEqual(old.type, 'threshold') + self.assertEqual(old.rule['query'], + [{'field': 'key', + 'op': 'eq', + 'value': 'value', + 'type': 'string'}]) + self.assertEqual(old.rule['period'], 60) + self.assertEqual(old.rule['meter_name'], 'cpu') + self.assertEqual(old.rule['evaluation_periods'], 1) + self.assertEqual(old.rule['statistic'], 'count') + self.assertEqual(old.rule['comparison_operator'], 'lt') + self.assertEqual(old.rule['threshold'], 36) + + def test_alarm_get_old_format_matching_metadata_array(self): + old = list(self.conn.get_alarms(name='other-old-alaert'))[0] + self.assertEqual(old.type, 'threshold') + self.assertEqual(sorted(old.rule['query']), + sorted([{'field': 'key1', + 'op': 'eq', + 'value': 'value1', + 'type': 'string'}, + {'field': 'key2', + 'op': 'eq', + 'value': 'value2', + 'type': 'string'}])) + self.assertEqual(old.rule['meter_name'], 'cpu') + self.assertEqual(old.rule['period'], 60) + self.assertEqual(old.rule['evaluation_periods'], 1) + self.assertEqual(old.rule['statistic'], 'count') + self.assertEqual(old.rule['comparison_operator'], 'lt') + self.assertEqual(old.rule['threshold'], 36) def test_counter_unit(self): meters = list(self.conn.get_meters()) @@ -224,7 +273,7 @@ class AlarmTestPagination(test_storage_scenarios.AlarmTestBase, marker_pairs = {'name': 'red-alert'} ret = impl_mongodb.Connection._get_marker(self.conn.db.alarm, marker_pairs=marker_pairs) - self.assertEqual(ret['meter_name'], 'test.one') + self.assertEqual(ret['rule']['meter_name'], 'test.one') def test_alarm_get_marker_None(self): self.add_some_alarms() @@ -232,7 +281,8 @@ class AlarmTestPagination(test_storage_scenarios.AlarmTestBase, marker_pairs = {'name': 'user-id-foo'} ret = impl_mongodb.Connection._get_marker(self.conn.db.alarm, marker_pairs) - self.assertEqual(ret['meter_name'], 'meter_name-foo') + self.assertEqual(ret['rule']['meter_name'], + 'meter_name-foo') except NoResultFound: self.assertTrue(True) @@ -242,6 +292,7 @@ class AlarmTestPagination(test_storage_scenarios.AlarmTestBase, marker_pairs = {'user_id': 'me'} ret = impl_mongodb.Connection._get_marker(self.conn.db.alarm, marker_pairs) - self.assertEqual(ret['meter_name'], 'counter-name-foo') + self.assertEqual(ret['rule']['meter_name'], + 'counter-name-foo') except MultipleResultsFound: self.assertTrue(True) diff --git a/tests/storage/test_storage_scenarios.py b/tests/storage/test_storage_scenarios.py index df3d52b8..adf45365 100644 --- a/tests/storage/test_storage_scenarios.py +++ b/tests/storage/test_storage_scenarios.py @@ -1852,26 +1852,87 @@ class CounterDataTypeTest(DBTestBase, class AlarmTestBase(DBTestBase): def add_some_alarms(self): - alarms = [models.Alarm('r3d', 'red-alert', - 'test.one', 'eq', 36, 'count', - 'me', 'and-da-boys', - evaluation_periods=1, - period=60, + alarms = [models.Alarm(alarm_id='r3d', + enabled=True, + type='threshold', + name='red-alert', + description='my red-alert', + timestamp=None, + user_id='me', + project_id='and-da-boys', + state="insufficient data", + state_timestamp=None, + ok_actions=[], alarm_actions=['http://nowhere/alarms'], - matching_metadata={'key': 'value'}), - models.Alarm('0r4ng3', 'orange-alert', - 'test.fourty', 'gt', 75, 'avg', - 'me', 'and-da-boys', - period=60, + insufficient_data_actions=[], + repeat_actions=False, + rule=dict(comparison_operator='eq', + threshold=36, + statistic='count', + evaluation_periods=1, + period=60, + meter_name='test.one', + query=[{'field': 'key', + 'op': 'eq', + 'value': 'value', + 'type': 'string'}]), + ), + models.Alarm(alarm_id='0r4ng3', + enabled=True, + type='threshold', + name='orange-alert', + description='a orange', + timestamp=None, + user_id='me', + project_id='and-da-boys', + state="insufficient data", + state_timestamp=None, + ok_actions=[], alarm_actions=['http://nowhere/alarms'], - matching_metadata={'key2': 'value2'}), - models.Alarm('y3ll0w', 'yellow-alert', - 'test.five', 'lt', 10, 'min', - 'me', 'and-da-boys', + insufficient_data_actions=[], + repeat_actions=False, + rule=dict(comparison_operator='gt', + threshold=75, + statistic='avg', + evaluation_periods=1, + period=60, + meter_name='test.fourty', + query=[{'field': 'key2', + 'op': 'eq', + 'value': 'value2', + 'type': 'string'}]), + ), + models.Alarm(alarm_id='y3ll0w', + enabled=True, + type='threshold', + name='yellow-alert', + description='yellow', + timestamp=None, + user_id='me', + project_id='and-da-boys', + state="insufficient data", + state_timestamp=None, + ok_actions=[], alarm_actions=['http://nowhere/alarms'], - matching_metadata= - {'key2': 'value2', - 'user_metadata.key3': 'value3'})] + insufficient_data_actions=[], + repeat_actions=False, + rule=dict(comparison_operator='lt', + threshold=10, + statistic='min', + evaluation_periods=1, + period=60, + meter_name='test.five', + query=[{'field': 'key2', + 'op': 'eq', + 'value': 'value2', + 'type': 'string'}, + {'field': + 'user_metadata.key3', + 'op': 'eq', + 'value': 'value3', + 'type': 'string'}]), + )] + for a in alarms: self.conn.create_alarm(a) @@ -1887,40 +1948,50 @@ class AlarmTest(AlarmTestBase, self.add_some_alarms() alarms = list(self.conn.get_alarms()) self.assertEqual(len(alarms), 3) - - def test_defaults(self): - self.add_some_alarms() - yellow = list(self.conn.get_alarms(name='yellow-alert'))[0] - - self.assertEqual(yellow.evaluation_periods, 1) - self.assertEqual(yellow.period, 60) - self.assertEqual(yellow.enabled, True) - self.assertEqual(yellow.description, - 'Alarm when test.five is lt ' - 'a min of 10 over 60 seconds') - self.assertEqual(yellow.state, models.Alarm.ALARM_INSUFFICIENT_DATA) - self.assertEqual(yellow.ok_actions, []) - self.assertEqual(yellow.insufficient_data_actions, []) - self.assertEqual(yellow.matching_metadata, - {'key2': 'value2', 'user_metadata.key3': 'value3'}) + self.assertEqual(alarms[0].rule['meter_name'], 'test.one') + self.assertEqual(alarms[1].rule['meter_name'], 'test.fourty') + self.assertEqual(alarms[2].rule['meter_name'], 'test.five') def test_update(self): self.add_some_alarms() orange = list(self.conn.get_alarms(name='orange-alert'))[0] orange.enabled = False orange.state = models.Alarm.ALARM_INSUFFICIENT_DATA - orange.matching_metadata = {'new': 'value', - 'user_metadata.new2': 'value4'} + query = [{'field': 'metadata.group', + 'op': 'eq', + 'value': 'test.updated', + 'type': 'string'}] + orange.rule['query'] = query + orange.rule['meter_name'] = 'new_meter_name' updated = self.conn.update_alarm(orange) self.assertEqual(updated.enabled, False) self.assertEqual(updated.state, models.Alarm.ALARM_INSUFFICIENT_DATA) - self.assertEqual(updated.matching_metadata, - {'new': 'value', 'user_metadata.new2': 'value4'}) + self.assertEqual(updated.rule['query'], query) + self.assertEqual(updated.rule['meter_name'], 'new_meter_name') def test_update_llu(self): - llu = models.Alarm('llu', 'llu', - 'meter_name', 'lt', 34, 'max', - 'bla', 'ffo') + llu = models.Alarm(alarm_id='llu', + enabled=True, + type='threshold', + name='llu', + description='llu', + timestamp=None, + user_id='bla', + project_id='ffo', + state="insufficient data", + state_timestamp=None, + ok_actions=[], + alarm_actions=[], + insufficient_data_actions=[], + repeat_actions=False, + rule=dict(comparison_operator='lt', + threshold=34, + statistic='max', + evaluation_periods=1, + period=60, + meter_name='llt', + query=[]) + ) updated = self.conn.update_alarm(llu) updated.state = models.Alarm.ALARM_OK updated.description = ':)'