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