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 = ':)'