From 8dc8a97da6723a07da967621addb2df254c856a5 Mon Sep 17 00:00:00 2001 From: Nejc Saje Date: Thu, 20 Feb 2014 07:21:28 +0000 Subject: [PATCH] Adds time constraints to alarms This patch allows alarms to have time constraints specified. If one or more time constraints are specified, the alarm is evaluated only if the current time is within at least one time constraint. A field 'time_constraints' is added to the alarm model that holds a list of the time constraints. Each time constraint has the fields - name the name of the constraint - description description, default is auto-generated from start and duration - start starting point(s) of the constraint, in cron format - duration duration of the constraint, in seconds - timezone optional timezone information Change-Id: I2d1bcd6728affc31834d7e2f3a0bdd570b2413bb Blueprint: time-constrained-alarms --- ceilometer/alarm/evaluator/__init__.py | 37 ++++++++ ceilometer/alarm/evaluator/combination.py | 5 + ceilometer/alarm/evaluator/threshold.py | 5 + ceilometer/api/controllers/v2.py | 77 +++++++++++++++- ceilometer/storage/impl_sqlalchemy.py | 1 + ceilometer/storage/models.py | 6 +- ceilometer/storage/pymongo_base.py | 8 ++ .../032_add_alarm_time_constraints.py | 34 +++++++ ceilometer/storage/sqlalchemy/models.py | 1 + ceilometer/tests/alarm/evaluator/test_base.py | 92 +++++++++++++++++++ .../tests/alarm/evaluator/test_combination.py | 71 ++++++++++++++ .../tests/alarm/evaluator/test_threshold.py | 64 +++++++++++++ .../alarm/partition/test_coordination.py | 1 + .../tests/api/v2/test_alarm_scenarios.py | 76 ++++++++++++++- .../api/v2/test_complex_query_scenarios.py | 1 + ceilometer/tests/storage/test_impl_mongodb.py | 4 + ceilometer/tests/storage/test_models.py | 3 +- .../tests/storage/test_storage_scenarios.py | 7 ++ doc/source/webapi/v2.rst | 3 + requirements.txt | 2 + 20 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 ceilometer/storage/sqlalchemy/migrate_repo/versions/032_add_alarm_time_constraints.py diff --git a/ceilometer/alarm/evaluator/__init__.py b/ceilometer/alarm/evaluator/__init__.py index 856b16fb3..cfc0fee21 100644 --- a/ceilometer/alarm/evaluator/__init__.py +++ b/ceilometer/alarm/evaluator/__init__.py @@ -18,6 +18,9 @@ import abc +import croniter +import datetime +import pytz from ceilometerclient import client as ceiloclient from oslo.config import cfg @@ -25,6 +28,7 @@ import six from ceilometer.openstack.common.gettextutils import _ # noqa from ceilometer.openstack.common import log +from ceilometer.openstack.common import timeutils LOG = log.getLogger(__name__) @@ -77,6 +81,39 @@ class Evaluator(object): # cycle (unless alarm state reverts in the meantime) LOG.exception(_('alarm state update failed')) + @classmethod + def within_time_constraint(cls, alarm): + """Check whether the alarm is within at least one of its time + constraints. If there are none, then the answer is yes. + """ + if not alarm.time_constraints: + return True + + now_utc = timeutils.utcnow() + for tc in alarm.time_constraints: + tz = pytz.timezone(tc['timezone']) if tc['timezone'] else None + now_tz = now_utc.astimezone(tz) if tz else now_utc + start_cron = croniter.croniter(tc['start'], now_tz) + if cls._is_exact_match(start_cron, now_tz): + return True + latest_start = start_cron.get_prev(datetime.datetime) + duration = datetime.timedelta(seconds=tc['duration']) + if latest_start <= now_tz <= latest_start + duration: + return True + return False + + @staticmethod + def _is_exact_match(cron, ts): + """Handle edge case where if the timestamp is the same as the + cron point in time to the minute, croniter returns the previous + start, not the current. We can check this by first going one + step back and then one step forward and check if we are + at the original point in time. + """ + cron.get_prev() + diff = timeutils.total_seconds(ts - cron.get_next(datetime.datetime)) + return abs(diff) < 60 # minute precision + @abc.abstractmethod def evaluate(self, alarm): '''interface definition diff --git a/ceilometer/alarm/evaluator/combination.py b/ceilometer/alarm/evaluator/combination.py index 7f0a18881..9fc03d192 100644 --- a/ceilometer/alarm/evaluator/combination.py +++ b/ceilometer/alarm/evaluator/combination.py @@ -96,6 +96,11 @@ class CombinationEvaluator(evaluator.Evaluator): self._refresh(alarm, state, reason, reason_data) def evaluate(self, alarm): + if not self.within_time_constraint(alarm): + LOG.debug(_('Attempted to evaluate alarm %s, but it is not ' + 'within its time constraint.') % alarm.alarm_id) + return + states = zip(alarm.rule['alarm_ids'], itertools.imap(self._get_alarm_state, alarm.rule['alarm_ids'])) diff --git a/ceilometer/alarm/evaluator/threshold.py b/ceilometer/alarm/evaluator/threshold.py index 6902603e5..08bd85375 100644 --- a/ceilometer/alarm/evaluator/threshold.py +++ b/ceilometer/alarm/evaluator/threshold.py @@ -173,6 +173,11 @@ class ThresholdEvaluator(evaluator.Evaluator): self._refresh(alarm, state, reason, reason_data) def evaluate(self, alarm): + if not self.within_time_constraint(alarm): + LOG.debug(_('Attempted to evaluate alarm %s, but it is not ' + 'within its time constraint.') % alarm.alarm_id) + return + query = self._bound_duration( alarm, alarm.rule['query'] diff --git a/ceilometer/api/controllers/v2.py b/ceilometer/api/controllers/v2.py index dc6d74cda..49fb02643 100644 --- a/ceilometer/api/controllers/v2.py +++ b/ceilometer/api/controllers/v2.py @@ -28,11 +28,13 @@ import ast import base64 import copy +import croniter import datetime import functools import inspect import json import jsonschema +import pytz import uuid from oslo.config import cfg @@ -108,6 +110,18 @@ class AdvEnum(wtypes.wsproperty): setattr(parent, self._name, value) +class CronType(wtypes.UserType): + """A user type that represents a cron format.""" + basetype = six.string_types + name = 'cron' + + @staticmethod + def validate(value): + # raises ValueError if invalid + croniter.croniter(value) + return value + + class _Base(wtypes.Base): @classmethod @@ -1479,6 +1493,59 @@ class AlarmCombinationRule(_Base): '153462d0-a9b8-4b5b-8175-9e4b05e9b856']) +class AlarmTimeConstraint(_Base): + """Representation of a time constraint on an alarm.""" + + name = wsme.wsattr(wtypes.text, mandatory=True) + "The name of the constraint" + + _description = None # provide a default + + def get_description(self): + if not self._description: + return 'Time constraint at %s lasting for %s seconds' \ + % (self.start, self.duration) + return self._description + + def set_description(self, value): + self._description = value + + description = wsme.wsproperty(wtypes.text, get_description, + set_description) + "The description of the constraint" + + start = wsme.wsattr(CronType(), mandatory=True) + "Start point of the time constraint, in cron format" + + duration = wsme.wsattr(wtypes.IntegerType(minimum=0), mandatory=True) + "How long the constraint should last, in seconds" + + timezone = wsme.wsattr(wtypes.text, default="") + "Timezone of the constraint" + + def as_dict(self): + return self.as_dict_from_keys(['name', 'description', 'start', + 'duration', 'timezone']) + + @staticmethod + def validate(tc): + if tc.timezone: + try: + pytz.timezone(tc.timezone) + except Exception: + raise ClientSideError(_("Timezone %s is not valid") + % tc.timezone) + return tc + + @classmethod + def sample(cls): + return cls(name='SampleConstraint', + description='nightly build every night at 23h for 3 hours', + start='0 23 * * *', + duration=10800, + timezone='Europe/Ljubljana') + + class Alarm(_Base): """Representation of an alarm. @@ -1534,6 +1601,9 @@ class Alarm(_Base): """Describe when to trigger the alarm based on combining the state of other alarms""" + time_constraints = wtypes.wsattr([AlarmTimeConstraint], default=[]) + """Describe time constraints for the alarm""" + # These settings are ignored in the PUT or POST operations, but are # filled in for GET project_id = wtypes.text @@ -1552,7 +1622,7 @@ class Alarm(_Base): state_timestamp = datetime.datetime "The date of the last alarm state changed" - def __init__(self, rule=None, **kwargs): + def __init__(self, rule=None, time_constraints=None, **kwargs): super(Alarm, self).__init__(**kwargs) if rule: @@ -1560,6 +1630,9 @@ class Alarm(_Base): self.threshold_rule = AlarmThresholdRule(**rule) elif self.type == 'combination': self.combination_rule = AlarmCombinationRule(**rule) + if time_constraints: + self.time_constraints = [AlarmTimeConstraint(**tc) + for tc in time_constraints] @staticmethod def validate(alarm): @@ -1602,6 +1675,7 @@ class Alarm(_Base): type='combination', threshold_rule=None, combination_rule=AlarmCombinationRule.sample(), + time_constraints=[AlarmTimeConstraint.sample().as_dict()], user_id="c96c887c216949acbdfbd8b494863567", project_id="c96c887c216949acbdfbd8b494863567", enabled=True, @@ -1620,6 +1694,7 @@ class Alarm(_Base): if k.endswith('_rule'): del d[k] d['rule'] = getattr(self, "%s_rule" % self.type).as_dict() + d['time_constraints'] = [tc.as_dict() for tc in self.time_constraints] return d diff --git a/ceilometer/storage/impl_sqlalchemy.py b/ceilometer/storage/impl_sqlalchemy.py index 3c1cd8a02..e7881807a 100644 --- a/ceilometer/storage/impl_sqlalchemy.py +++ b/ceilometer/storage/impl_sqlalchemy.py @@ -746,6 +746,7 @@ class Connection(base.Connection): insufficient_data_actions= row.insufficient_data_actions, rule=row.rule, + time_constraints=row.time_constraints, repeat_actions=row.repeat_actions) def _retrieve_alarms(self, query): diff --git a/ceilometer/storage/models.py b/ceilometer/storage/models.py index dbcee3367..7939c07ce 100644 --- a/ceilometer/storage/models.py +++ b/ceilometer/storage/models.py @@ -298,6 +298,7 @@ class Alarm(Model): :param project_id: the project_id of the creator :param evaluation_periods: the number of periods :param period: the time period in seconds + :param time_constraints: the list of the alarm's time constraints, if any :param timestamp: the timestamp when the alarm was last updated :param state_timestamp: the timestamp of the last state change :param ok_actions: the list of webhooks to call when entering the ok state @@ -311,7 +312,7 @@ class Alarm(Model): 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): + repeat_actions, rule, time_constraints): Model.__init__( self, alarm_id=alarm_id, @@ -329,7 +330,8 @@ class Alarm(Model): insufficient_data_actions= insufficient_data_actions, repeat_actions=repeat_actions, - rule=rule) + rule=rule, + time_constraints=time_constraints) class AlarmChange(Model): diff --git a/ceilometer/storage/pymongo_base.py b/ceilometer/storage/pymongo_base.py index 9556d58cb..82fe07933 100644 --- a/ceilometer/storage/pymongo_base.py +++ b/ceilometer/storage/pymongo_base.py @@ -209,6 +209,7 @@ class Connection(base.Connection): stored_alarm = self.db.alarm.find({'alarm_id': alarm.alarm_id})[0] del stored_alarm['_id'] self._ensure_encapsulated_rule_format(stored_alarm) + self._ensure_time_constraints(stored_alarm) return models.Alarm(**stored_alarm) create_alarm = update_alarm @@ -317,6 +318,7 @@ class Connection(base.Connection): a.update(alarm) del a['_id'] self._ensure_encapsulated_rule_format(a) + self._ensure_time_constraints(a) yield models.Alarm(**a) def _retrieve_alarm_changes(self, query_filter, orderby, limit): @@ -397,6 +399,12 @@ class Connection(base.Connection): new_matching_metadata[elem['key']] = elem['value'] return new_matching_metadata + @staticmethod + def _ensure_time_constraints(alarm): + """Ensures the alarm has a time constraints field.""" + if 'time_constraints' not in alarm: + alarm['time_constraints'] = [] + class QueryTransformer(object): diff --git a/ceilometer/storage/sqlalchemy/migrate_repo/versions/032_add_alarm_time_constraints.py b/ceilometer/storage/sqlalchemy/migrate_repo/versions/032_add_alarm_time_constraints.py new file mode 100644 index 000000000..cbc577ee4 --- /dev/null +++ b/ceilometer/storage/sqlalchemy/migrate_repo/versions/032_add_alarm_time_constraints.py @@ -0,0 +1,34 @@ +# -*- encoding: utf-8 -*- +# +# Author: Nejc Saje +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from sqlalchemy import Column +from sqlalchemy import MetaData +from sqlalchemy import Table +from sqlalchemy import Text + + +def upgrade(migrate_engine): + meta = MetaData(bind=migrate_engine) + alarm = Table('alarm', meta, autoload=True) + time_constraints = Column('time_constraints', Text()) + alarm.create_column(time_constraints) + + +def downgrade(migrate_engine): + meta = MetaData(bind=migrate_engine) + alarm = Table('alarm', meta, autoload=True) + time_constraints = Column('time_constraints', Text()) + alarm.drop_column(time_constraints) diff --git a/ceilometer/storage/sqlalchemy/models.py b/ceilometer/storage/sqlalchemy/models.py index 6ce20d6b3..c3ad5c9a4 100644 --- a/ceilometer/storage/sqlalchemy/models.py +++ b/ceilometer/storage/sqlalchemy/models.py @@ -309,6 +309,7 @@ class Alarm(Base): repeat_actions = Column(Boolean) rule = Column(JSONEncodedDict) + time_constraints = Column(JSONEncodedDict) class AlarmChange(Base): diff --git a/ceilometer/tests/alarm/evaluator/test_base.py b/ceilometer/tests/alarm/evaluator/test_base.py index 925eb7f8b..031785fd7 100644 --- a/ceilometer/tests/alarm/evaluator/test_base.py +++ b/ceilometer/tests/alarm/evaluator/test_base.py @@ -17,10 +17,13 @@ # under the License. """class for tests in ceilometer/alarm/evaluator/__init__.py """ +import datetime import mock +import pytz from ceilometer.alarm import evaluator from ceilometer.openstack.common import test +from ceilometer.openstack.common import timeutils class TestEvaluatorBaseClass(test.BaseTestCase): @@ -45,3 +48,92 @@ class TestEvaluatorBaseClass(test.BaseTestCase): ev._refresh(mock.MagicMock(), mock.MagicMock(), mock.MagicMock(), mock.MagicMock()) self.assertTrue(self.called) + + def test_base_time_constraints(self): + alarm = mock.MagicMock() + alarm.time_constraints = [ + {'name': 'test', + 'description': 'test', + 'start': '0 11 * * *', # daily at 11:00 + 'duration': 10800, # 3 hours + 'timezone': ''}, + {'name': 'test2', + 'description': 'test', + 'start': '0 23 * * *', # daily at 23:00 + 'duration': 10800, # 3 hours + 'timezone': ''}, + ] + cls = evaluator.Evaluator + timeutils.set_time_override(datetime.datetime(2014, 1, 1, 12, 0, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + + timeutils.set_time_override(datetime.datetime(2014, 1, 2, 1, 0, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + + timeutils.set_time_override(datetime.datetime(2014, 1, 2, 5, 0, 0)) + self.assertFalse(cls.within_time_constraint(alarm)) + + def test_base_time_constraints_complex(self): + alarm = mock.MagicMock() + alarm.time_constraints = [ + {'name': 'test', + 'description': 'test', + # Every consecutive 2 minutes (from the 3rd to the 57th) past + # every consecutive 2 hours (between 3:00 and 12:59) on every day. + 'start': '3-57/2 3-12/2 * * *', + 'duration': 30, + 'timezone': ''} + ] + cls = evaluator.Evaluator + + # test minutes inside + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 3, 3, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 3, 31, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 3, 57, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + + # test minutes outside + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 3, 2, 0)) + self.assertFalse(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 3, 4, 0)) + self.assertFalse(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 3, 58, 0)) + self.assertFalse(cls.within_time_constraint(alarm)) + + # test hours inside + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 3, 31, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 5, 31, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 11, 31, 0)) + self.assertTrue(cls.within_time_constraint(alarm)) + + # test hours outside + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 1, 31, 0)) + self.assertFalse(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 4, 31, 0)) + self.assertFalse(cls.within_time_constraint(alarm)) + timeutils.set_time_override(datetime.datetime(2014, 1, 5, 12, 31, 0)) + self.assertFalse(cls.within_time_constraint(alarm)) + + def test_base_time_constraints_timezone(self): + alarm = mock.MagicMock() + alarm.time_constraints = [ + {'name': 'test', + 'description': 'test', + 'start': '0 11 * * *', # daily at 11:00 + 'duration': 10800, # 3 hours + 'timezone': 'Europe/Ljubljana'} + ] + cls = evaluator.Evaluator + dt_eu = datetime.datetime(2014, 1, 1, 12, 0, 0, + tzinfo=pytz.timezone('Europe/Ljubljana')) + dt_us = datetime.datetime(2014, 1, 1, 12, 0, 0, + tzinfo=pytz.timezone('US/Eastern')) + timeutils.set_time_override(dt_eu.astimezone(pytz.UTC)) + self.assertTrue(cls.within_time_constraint(alarm)) + + timeutils.set_time_override(dt_us.astimezone(pytz.UTC)) + self.assertFalse(cls.within_time_constraint(alarm)) diff --git a/ceilometer/tests/alarm/evaluator/test_combination.py b/ceilometer/tests/alarm/evaluator/test_combination.py index 8565ec172..34d1b206a 100644 --- a/ceilometer/tests/alarm/evaluator/test_combination.py +++ b/ceilometer/tests/alarm/evaluator/test_combination.py @@ -17,10 +17,13 @@ # under the License. """Tests for ceilometer/alarm/threshold_evaluation.py """ +import datetime import mock +import pytz import uuid from ceilometer.alarm.evaluator import combination +from ceilometer.openstack.common import timeutils from ceilometer.storage import models from ceilometer.tests.alarm.evaluator import base from ceilometerclient import exc @@ -46,6 +49,7 @@ class TestEvaluate(base.TestEvaluatorBase): ok_actions=[], alarm_actions=[], repeat_actions=False, + time_constraints=[], rule=dict( alarm_ids=[ '9cfc3e51-2ff1-4b1d-ac01-c1bd4c6d0d1e', @@ -66,6 +70,7 @@ class TestEvaluate(base.TestEvaluatorBase): ok_actions=[], alarm_actions=[], repeat_actions=False, + time_constraints=[], rule=dict( alarm_ids=[ 'b82734f4-9d06-48f3-8a86-fa59a0c99dc8', @@ -304,3 +309,69 @@ class TestEvaluate(base.TestEvaluatorBase): in zip(self.alarms, reasons, reason_datas)] self.assertEqual(self.notifier.notify.call_args_list, expected) + + def test_state_change_inside_time_constraint(self): + self._set_all_alarms('insufficient data') + self.alarms[0].time_constraints = [ + {'name': 'test', + 'description': 'test', + 'start': '0 11 * * *', # daily at 11:00 + 'duration': 10800, # 3 hours + 'timezone': 'Europe/Ljubljana'} + ] + self.alarms[1].time_constraints = self.alarms[0].time_constraints + dt = datetime.datetime(2014, 1, 1, 12, 0, 0, + tzinfo=pytz.timezone('Europe/Ljubljana')) + with mock.patch('ceilometerclient.client.get_client', + return_value=self.api_client): + timeutils.set_time_override(dt.astimezone(pytz.UTC)) + self.api_client.alarms.get.side_effect = [ + self._get_alarm('ok'), + self._get_alarm('ok'), + self._get_alarm('ok'), + self._get_alarm('ok'), + ] + self._evaluate_all_alarms() + expected = [mock.call(alarm.alarm_id, state='ok') + for alarm in self.alarms] + update_calls = self.api_client.alarms.set_state.call_args_list + self.assertEqual(expected, update_calls, + "Alarm should change state if the current " + "time is inside its time constraint.") + reasons, reason_datas = self._combination_transition_reason( + 'ok', + self.alarms[0].rule['alarm_ids'], + self.alarms[1].rule['alarm_ids']) + expected = [mock.call(alarm, 'insufficient data', + reason, reason_data) + for alarm, reason, reason_data + in zip(self.alarms, reasons, reason_datas)] + self.assertEqual(expected, self.notifier.notify.call_args_list) + + def test_no_state_change_outside_time_constraint(self): + self._set_all_alarms('insufficient data') + self.alarms[0].time_constraints = [ + {'name': 'test', + 'description': 'test', + 'start': '0 11 * * *', # daily at 11:00 + 'duration': 10800, # 3 hours + 'timezone': 'Europe/Ljubljana'} + ] + self.alarms[1].time_constraints = self.alarms[0].time_constraints + dt = datetime.datetime(2014, 1, 1, 15, 0, 0, + tzinfo=pytz.timezone('Europe/Ljubljana')) + with mock.patch('ceilometerclient.client.get_client', + return_value=self.api_client): + timeutils.set_time_override(dt.astimezone(pytz.UTC)) + self.api_client.alarms.get.side_effect = [ + self._get_alarm('ok'), + self._get_alarm('ok'), + self._get_alarm('ok'), + self._get_alarm('ok'), + ] + self._evaluate_all_alarms() + update_calls = self.api_client.alarms.set_state.call_args_list + self.assertEqual([], update_calls, + "Alarm should not change state if the current " + " time is outside its time constraint.") + self.assertEqual([], self.notifier.notify.call_args_list) diff --git a/ceilometer/tests/alarm/evaluator/test_threshold.py b/ceilometer/tests/alarm/evaluator/test_threshold.py index 6abaa2395..ba429eb4d 100644 --- a/ceilometer/tests/alarm/evaluator/test_threshold.py +++ b/ceilometer/tests/alarm/evaluator/test_threshold.py @@ -19,6 +19,7 @@ """ import datetime import mock +import pytz import uuid from six import moves @@ -51,6 +52,7 @@ class TestEvaluate(base.TestEvaluatorBase): ok_actions=[], alarm_actions=[], repeat_actions=False, + time_constraints=[], rule=dict( comparison_operator='gt', threshold=80.0, @@ -79,6 +81,7 @@ class TestEvaluate(base.TestEvaluatorBase): alarm_actions=[], repeat_actions=False, alarm_id=str(uuid.uuid4()), + time_constraints=[], rule=dict( comparison_operator='le', threshold=10.0, @@ -438,3 +441,64 @@ class TestEvaluate(base.TestEvaluatorBase): def test_simple_alarm_no_clear_without_outlier_exclusion(self): self. _do_test_simple_alarm_clear_outlier_exclusion(False) + + def test_state_change_inside_time_constraint(self): + self._set_all_alarms('ok') + self.alarms[0].time_constraints = [ + {'name': 'test', + 'description': 'test', + 'start': '0 11 * * *', # daily at 11:00 + 'duration': 10800, # 3 hours + 'timezone': 'Europe/Ljubljana'} + ] + self.alarms[1].time_constraints = self.alarms[0].time_constraints + dt = datetime.datetime(2014, 1, 1, 12, 0, 0, + tzinfo=pytz.timezone('Europe/Ljubljana')) + with mock.patch('ceilometerclient.client.get_client', + return_value=self.api_client): + timeutils.set_time_override(dt.astimezone(pytz.UTC)) + # the following part based on test_simple_insufficient + self.api_client.statistics.list.return_value = [] + self._evaluate_all_alarms() + self._assert_all_alarms('insufficient data') + expected = [mock.call(alarm.alarm_id, + state='insufficient data') + for alarm in self.alarms] + update_calls = self.api_client.alarms.set_state.call_args_list + self.assertEqual(expected, update_calls, + "Alarm should change state if the current " + "time is inside its time constraint.") + expected = [mock.call( + alarm, + 'ok', + ('%d datapoints are unknown' + % alarm.rule['evaluation_periods']), + self._reason_data('unknown', + alarm.rule['evaluation_periods'], + None)) + for alarm in self.alarms] + self.assertEqual(expected, self.notifier.notify.call_args_list) + + def test_no_state_change_outside_time_constraint(self): + self._set_all_alarms('ok') + self.alarms[0].time_constraints = [ + {'name': 'test', + 'description': 'test', + 'start': '0 11 * * *', # daily at 11:00 + 'duration': 10800, # 3 hours + 'timezone': 'Europe/Ljubljana'} + ] + self.alarms[1].time_constraints = self.alarms[0].time_constraints + dt = datetime.datetime(2014, 1, 1, 15, 0, 0, + tzinfo=pytz.timezone('Europe/Ljubljana')) + with mock.patch('ceilometerclient.client.get_client', + return_value=self.api_client): + timeutils.set_time_override(dt.astimezone(pytz.UTC)) + self.api_client.statistics.list.return_value = [] + self._evaluate_all_alarms() + self._assert_all_alarms('ok') + update_calls = self.api_client.alarms.set_state.call_args_list + self.assertEqual([], update_calls, + "Alarm should not change state if the current " + " time is outside its time constraint.") + self.assertEqual([], self.notifier.notify.call_args_list) diff --git a/ceilometer/tests/alarm/partition/test_coordination.py b/ceilometer/tests/alarm/partition/test_coordination.py index 7f814e47e..174f3d23a 100644 --- a/ceilometer/tests/alarm/partition/test_coordination.py +++ b/ceilometer/tests/alarm/partition/test_coordination.py @@ -100,6 +100,7 @@ class TestCoordinate(test.BaseTestCase): alarm_actions=[], insufficient_data_actions=[], alarm_id=uuid, + time_constraints=[], rule=dict( statistic='avg', comparison_operator='gt', diff --git a/ceilometer/tests/api/v2/test_alarm_scenarios.py b/ceilometer/tests/api/v2/test_alarm_scenarios.py index 65c95dfdb..60920e66f 100644 --- a/ceilometer/tests/api/v2/test_alarm_scenarios.py +++ b/ceilometer/tests/api/v2/test_alarm_scenarios.py @@ -70,6 +70,9 @@ class TestAlarms(FunctionalTest, repeat_actions=True, user_id=self.auth_headers['X-User-Id'], project_id=self.auth_headers['X-Project-Id'], + time_constraints=[dict(name='testcons', + start='0 11 * * *', + duration=300)], rule=dict(comparison_operator='gt', threshold=2.0, statistic='avg', @@ -95,6 +98,7 @@ class TestAlarms(FunctionalTest, repeat_actions=False, user_id=self.auth_headers['X-User-Id'], project_id=self.auth_headers['X-Project-Id'], + time_constraints=[], rule=dict(comparison_operator='gt', threshold=4.0, statistic='avg', @@ -120,6 +124,7 @@ class TestAlarms(FunctionalTest, repeat_actions=False, user_id=self.auth_headers['X-User-Id'], project_id=self.auth_headers['X-Project-Id'], + time_constraints=[], rule=dict(comparison_operator='gt', threshold=3.0, statistic='avg', @@ -145,6 +150,7 @@ class TestAlarms(FunctionalTest, repeat_actions=False, user_id=self.auth_headers['X-User-Id'], project_id=self.auth_headers['X-Project-Id'], + time_constraints=[], rule=dict(alarm_ids=['a', 'b'], operator='or') )]: @@ -215,6 +221,8 @@ class TestAlarms(FunctionalTest, 'meter.test') self.assertEqual(one['alarm_id'], alarms[0]['alarm_id']) self.assertEqual(one['repeat_actions'], alarms[0]['repeat_actions']) + self.assertEqual(one['time_constraints'], + alarms[0]['time_constraints']) def test_get_alarm_disabled(self): alarm = models.Alarm(name='disabled', @@ -231,6 +239,7 @@ class TestAlarms(FunctionalTest, repeat_actions=False, user_id=self.auth_headers['X-User-Id'], project_id=self.auth_headers['X-Project-Id'], + time_constraints=[], rule=dict(alarm_ids=['a', 'b'], operator='or')) self.conn.update_alarm(alarm) @@ -308,6 +317,70 @@ class TestAlarms(FunctionalTest, alarms = list(self.conn.get_alarms()) self.assertEqual(4, len(alarms)) + def test_post_invalid_alarm_time_constraint_start(self): + json = { + 'name': 'added_alarm_invalid_constraint_duration', + 'type': 'threshold', + 'time_constraints': [ + { + 'name': 'testcons', + 'start': '11:00am', + 'duration': 10 + } + ], + 'threshold_rule': { + 'meter_name': 'ameter', + 'threshold': 300.0 + } + } + self.post_json('/alarms', params=json, expect_errors=True, status=400, + headers=self.auth_headers) + alarms = list(self.conn.get_alarms()) + self.assertEqual(4, len(alarms)) + + def test_post_invalid_alarm_time_constraint_duration(self): + json = { + 'name': 'added_alarm_invalid_constraint_duration', + 'type': 'threshold', + 'time_constraints': [ + { + 'name': 'testcons', + 'start': '* 11 * * *', + 'duration': -1, + } + ], + 'threshold_rule': { + 'meter_name': 'ameter', + 'threshold': 300.0 + } + } + self.post_json('/alarms', params=json, expect_errors=True, status=400, + headers=self.auth_headers) + alarms = list(self.conn.get_alarms()) + self.assertEqual(4, len(alarms)) + + def test_post_invalid_alarm_time_constraint_timezone(self): + json = { + 'name': 'added_alarm_invalid_constraint_timezone', + 'type': 'threshold', + 'time_constraints': [ + { + 'name': 'testcons', + 'start': '* 11 * * *', + 'duration': 10, + 'timezone': 'aaaa' + } + ], + 'threshold_rule': { + 'meter_name': 'ameter', + 'threshold': 300.0 + } + } + self.post_json('/alarms', params=json, expect_errors=True, status=400, + headers=self.auth_headers) + alarms = list(self.conn.get_alarms()) + self.assertEqual(4, len(alarms)) + def test_post_invalid_alarm_period(self): json = { 'name': 'added_alarm_invalid_period', @@ -1120,8 +1193,9 @@ class TestAlarms(FunctionalTest, self.assertIsNotNone(actual['event_id']) def _assert_in_json(self, expected, actual): + actual = jsonutils.dumps(jsonutils.loads(actual), sort_keys=True) for k, v in expected.iteritems(): - fragment = jsonutils.dumps({k: v})[1:-1] + fragment = jsonutils.dumps({k: v}, sort_keys=True)[1:-1] self.assertTrue(fragment in actual, '%s not in %s' % (fragment, actual)) diff --git a/ceilometer/tests/api/v2/test_complex_query_scenarios.py b/ceilometer/tests/api/v2/test_complex_query_scenarios.py index 8cf5b8d3a..e38364b49 100644 --- a/ceilometer/tests/api/v2/test_complex_query_scenarios.py +++ b/ceilometer/tests/api/v2/test_complex_query_scenarios.py @@ -301,6 +301,7 @@ class TestQueryAlarmsController(tests_api.FunctionalTest, repeat_actions=True, user_id="user-id%d" % id, project_id=project_id, + time_constraints=[], rule=dict(comparison_operator='gt', threshold=2.0, statistic='avg', diff --git a/ceilometer/tests/storage/test_impl_mongodb.py b/ceilometer/tests/storage/test_impl_mongodb.py index d7d70d595..02709e5bc 100644 --- a/ceilometer/tests/storage/test_impl_mongodb.py +++ b/ceilometer/tests/storage/test_impl_mongodb.py @@ -256,6 +256,10 @@ class CompatibilityTest(test_storage_scenarios.DBTestBase, self.assertEqual(old.rule['comparison_operator'], 'lt') self.assertEqual(old.rule['threshold'], 36) + def test_alarm_without_time_constraints(self): + old = list(self.conn.get_alarms(name='other-old-alaert'))[0] + self.assertEqual([], old.time_constraints) + def test_counter_unit(self): meters = list(self.conn.get_meters()) self.assertEqual(len(meters), 1) diff --git a/ceilometer/tests/storage/test_models.py b/ceilometer/tests/storage/test_models.py index 2aea7561e..dde64806f 100644 --- a/ceilometer/tests/storage/test_models.py +++ b/ceilometer/tests/storage/test_models.py @@ -71,7 +71,8 @@ class ModelTest(test.BaseTestCase): alarm_fields = ["alarm_id", "type", "enabled", "name", "description", "timestamp", "user_id", "project_id", "state", "state_timestamp", "ok_actions", "alarm_actions", - "insufficient_data_actions", "repeat_actions", "rule"] + "insufficient_data_actions", "repeat_actions", "rule", + "time_constraints"] self.assertEqual(set(alarm_fields), set(models.Alarm.get_field_names())) diff --git a/ceilometer/tests/storage/test_storage_scenarios.py b/ceilometer/tests/storage/test_storage_scenarios.py index e173a4133..726bc9c4b 100644 --- a/ceilometer/tests/storage/test_storage_scenarios.py +++ b/ceilometer/tests/storage/test_storage_scenarios.py @@ -696,6 +696,7 @@ class RawSampleTest(DBTestBase, alarm_actions=['http://nowhere/alarms'], insufficient_data_actions=[], repeat_actions=False, + time_constraints=[], rule=dict(comparison_operator='eq', threshold=36, statistic='count', @@ -2312,6 +2313,9 @@ class AlarmTestBase(DBTestBase): alarm_actions=['http://nowhere/alarms'], insufficient_data_actions=[], repeat_actions=False, + time_constraints=[dict(name='testcons', + start='0 11 * * *', + duration=300)], rule=dict(comparison_operator='eq', threshold=36, statistic='count', @@ -2337,6 +2341,7 @@ class AlarmTestBase(DBTestBase): alarm_actions=['http://nowhere/alarms'], insufficient_data_actions=[], repeat_actions=False, + time_constraints=[], rule=dict(comparison_operator='gt', threshold=75, statistic='avg', @@ -2362,6 +2367,7 @@ class AlarmTestBase(DBTestBase): alarm_actions=['http://nowhere/alarms'], insufficient_data_actions=[], repeat_actions=False, + time_constraints=[], rule=dict(comparison_operator='lt', threshold=10, statistic='min', @@ -2446,6 +2452,7 @@ class AlarmTest(AlarmTestBase, alarm_actions=[], insufficient_data_actions=[], repeat_actions=False, + time_constraints=[], rule=dict(comparison_operator='lt', threshold=34, statistic='max', diff --git a/doc/source/webapi/v2.rst b/doc/source/webapi/v2.rst index bae8358b0..89b160e18 100644 --- a/doc/source/webapi/v2.rst +++ b/doc/source/webapi/v2.rst @@ -57,6 +57,9 @@ Alarms .. autotype:: ceilometer.api.controllers.v2.AlarmCombinationRule :members: +.. autotype:: ceilometer.api.controllers.v2.AlarmTimeConstraint + :members: + .. autotype:: ceilometer.api.controllers.v2.AlarmChange :members: diff --git a/requirements.txt b/requirements.txt index 7de306ac8..a869dd83d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ alembic>=0.4.1 anyjson>=0.3.3 argparse +croniter>=0.3.4 eventlet>=0.13.0 Flask>=0.10,<1.0 happybase>=0.4,<=0.6 @@ -20,6 +21,7 @@ python-glanceclient>=0.9.0 python-keystoneclient>=0.6.0 python-novaclient>=2.15.0 python-swiftclient>=1.6 +pytz>=2010h PyYAML>=3.1.0 requests>=1.1 six>=1.5.2