From 7146571b702579fa1367598ab278ca2f8ec1e156 Mon Sep 17 00:00:00 2001 From: Uros Jovanovic Date: Wed, 5 Mar 2014 18:49:14 +0000 Subject: [PATCH] Adds alarm time constraint support to ceilometer CLI Time constraints can be specified for create and update families of commands with the following format: --time-constraint name=constraint1;start='0 11 * * *';duration=300 This switch can be specified multiple times in the case of multiple time constraints. With update commands, time constraints are updated by name, e.g. --time-constraint name=constraint1;duration=500 updates the constraint 'constraint1' with a new duration 500. Time constraints can be removed with update commands using the switch --remove-time-constraint=constraint1,constraint2 . Example of display outputs: > ceilometer alarm-list +--------------------------------------+-------+-------------------+---------+------------+----------------------------+--------------------------------------------------------------+ | Alarm ID | Name | State | Enabled | Continuous | Alarm condition | Time constraints | +--------------------------------------+-------+-------------------+---------+------------+----------------------------+--------------------------------------------------------------+ | 2ead776d-2fc7-47a2-b0bb-0f88dcefa457 | test2 | insufficient data | True | False | cpu == 50.0 during 1 x 60s | cons1 at 0 11 * * * for 300s, cons2 at 0 23 * * * for 600s | +--------------------------------------+-------+-------------------+---------+------------+----------------------------+--------------------------------------------------------------+ > ceilometer alarm-show -a +---------------------------+-----------------------------------------------------------------------+ | Property | Value | +---------------------------+-----------------------------------------------------------------------+ | alarm_actions | [] | | alarm_id | 2ead776d-2fc7-47a2-b0bb-0f88dcefa457 | | comparison_operator | eq | | description | Alarm when cpu is eq a avg of 50.0 over 60 seconds | | enabled | True | | evaluation_periods | 1 | | exclude_outliers | False | | insufficient_data_actions | [] | | meter_name | cpu | | name | test2 | | ok_actions | [] | | period | 60 | | project_id | 962f75ad22c24cbf99d40d7b82718505 | | query | | | repeat_actions | False | | state | insufficient data | | statistic | avg | | threshold | 50.0 | | time_constraints | [{name: cons1, | | | description: Time constraint at 0 11 * * * lasting for 300 seconds, | | | start: 0 11 * * *, | | | duration: 300}, | | | {name: cons2, | | | description: Time constraint at 0 23 * * * lasting for 600 seconds, | | | start: 0 23 * * *, | | | duration: 600}] | | type | threshold | | user_id | 76f335df8e2f4c7e9e8185e26ea85759 | +---------------------------+-----------------------------------------------------------------------+ > ceilometer alarm-history -a 2ead776d-2fc7-47a2-b0bb-0f88dcefa457 +----------+----------------------------+--------------------------------------------------------------------------------+ | Type | Timestamp | Detail | +----------+----------------------------+--------------------------------------------------------------------------------+ | creation | 2014-03-06T07:41:35.362050 | name: test2 | | | | description: Alarm when cpu is eq a avg of 50.0 over 60 seconds | | | | type: threshold | | | | rule: cpu == 50.0 during 1 x 60s | | | | time_constraints: cons1 at 0 11 * * * for 300s, cons2 at 0 23 * * * for 600s | +----------+----------------------------+--------------------------------------------------------------------------------+ Change-Id: I3953276537b4526e46e5e6d229d6fa154f8ab0fc Closes-Bug: #1288246 --- ceilometerclient/common/utils.py | 21 +++++++ ceilometerclient/tests/test_utils.py | 28 +++++++++ ceilometerclient/tests/v2/test_alarms.py | 67 ++++++++++++++++++++- ceilometerclient/tests/v2/test_shell.py | 40 +++++++++++++ ceilometerclient/v2/alarms.py | 25 +++++++- ceilometerclient/v2/shell.py | 75 ++++++++++++++++++++++-- 6 files changed, 249 insertions(+), 7 deletions(-) diff --git a/ceilometerclient/common/utils.py b/ceilometerclient/common/utils.py index 2628ca0b..db7b0428 100644 --- a/ceilometerclient/common/utils.py +++ b/ceilometerclient/common/utils.py @@ -154,6 +154,27 @@ def args_array_to_dict(kwargs, key_to_convert): return kwargs +def args_array_to_list_of_dicts(kwargs, key_to_convert): + """Converts ['a=1;b=2','c=3;d=4'] to [{a:1,b:2},{c:3,d:4}] + """ + values_to_convert = kwargs.get(key_to_convert) + if values_to_convert: + try: + kwargs[key_to_convert] = [] + for lst in values_to_convert: + pairs = lst.split(";") + dct = dict() + for pair in pairs: + kv = pair.split("=", 1) + dct[kv[0]] = kv[1].strip(" \"'") # strip spaces and quotes + kwargs[key_to_convert].append(dct) + except Exception: + raise exc.CommandError( + '%s must be a list of key1=value1;key2=value2;... not "%s"' % ( + key_to_convert, values_to_convert)) + return kwargs + + def key_with_slash_to_nested_dict(kwargs): nested_kwargs = {} for k in list(kwargs): diff --git a/ceilometerclient/tests/test_utils.py b/ceilometerclient/tests/test_utils.py index 1f3074b2..9082328e 100644 --- a/ceilometerclient/tests/test_utils.py +++ b/ceilometerclient/tests/test_utils.py @@ -14,6 +14,7 @@ # under the License. +import itertools import mock import six import sys @@ -117,6 +118,33 @@ class UtilsTest(test_utils.BaseTestCase): 'other': 'value' }) + def test_args_array_to_list_of_dicts(self): + starts = ['0 11 * * *', '"0 11 * * *"', '\'0 11 * * *\''] + timezones = [None, 'US/Eastern', '"US/Eastern"', '\'US/Eastern\''] + descs = [None, 'de sc', '"de sc"', '\'de sc\''] + for start, tz, desc in itertools.product(starts, timezones, descs): + my_args = { + 'time_constraints': ['name=const1;start=%s;duration=1' + % start], + 'other': 'value' + } + expected = { + 'time_constraints': [dict(name='const1', + start='0 11 * * *', + duration='1')], + 'other': 'value' + } + if tz: + my_args['time_constraints'][0] += ';timezone=%s' % tz + expected['time_constraints'][0]['timezone'] = 'US/Eastern' + if desc: + my_args['time_constraints'][0] += ';description=%s' % desc + expected['time_constraints'][0]['description'] = 'de sc' + + cleaned = utils.args_array_to_list_of_dicts(my_args, + 'time_constraints') + self.assertEqual(expected, cleaned) + def test_key_with_slash_to_nested_dict(self): my_args = { 'combination_rule/alarm_ids': ['id1', 'id2'], diff --git a/ceilometerclient/tests/v2/test_alarms.py b/ceilometerclient/tests/v2/test_alarms.py index edccb4bc..1644f206 100644 --- a/ceilometerclient/tests/v2/test_alarms.py +++ b/ceilometerclient/tests/v2/test_alarms.py @@ -40,6 +40,16 @@ AN_ALARM = {u'alarm_actions': [u'http://site:8000/alarm'], u'threshold': 200.0, u'comparison_operator': 'gt', }, + u'time_constraints': [{u'name': u'cons1', + u'description': u'desc1', + u'start': u'0 11 * * *', + u'duration': 300, + u'timezone': u''}, + {u'name': u'cons2', + u'description': u'desc2', + u'start': u'0 23 * * *', + u'duration': 600, + u'timezone': ''}], u'timestamp': u'2013-05-09T13:41:23.085000', u'enabled': True, u'alarm_id': u'alarm-id', @@ -54,6 +64,8 @@ CREATE_ALARM = copy.deepcopy(AN_ALARM) del CREATE_ALARM['timestamp'] del CREATE_ALARM['state_timestamp'] del CREATE_ALARM['alarm_id'] +CREATE_ALARM_WITHOUT_TC = copy.deepcopy(CREATE_ALARM) +del CREATE_ALARM_WITHOUT_TC['time_constraints'] DELTA_ALARM = {u'alarm_actions': ['url1', 'url2']} DELTA_ALARM_RULE = {u'comparison_operator': u'lt', u'threshold': 42.1, @@ -61,11 +73,21 @@ DELTA_ALARM_RULE = {u'comparison_operator': u'lt', u'query': [{u'field': u'key_name', u'op': u'eq', u'value': u'key_value'}]} +DELTA_ALARM_TC = [{u'name': u'cons1', + u'duration': 500}] +DELTA_ALARM['time_constraints'] = DELTA_ALARM_TC UPDATED_ALARM = copy.deepcopy(AN_ALARM) UPDATED_ALARM.update(DELTA_ALARM) UPDATED_ALARM['threshold_rule'].update(DELTA_ALARM_RULE) +DELTA_ALARM['remove_time_constraints'] = 'cons2' +UPDATED_ALARM['time_constraints'] = [{u'name': u'cons1', + u'description': u'desc1', + u'start': u'0 11 * * *', + u'duration': 500, + u'timezone': u''}] DELTA_ALARM['threshold_rule'] = DELTA_ALARM_RULE UPDATE_ALARM = copy.deepcopy(UPDATED_ALARM) +UPDATE_ALARM['remove_time_constraints'] = 'cons2' del UPDATE_ALARM['user_id'] del UPDATE_ALARM['project_id'] del UPDATE_ALARM['name'] @@ -101,6 +123,9 @@ DELTA_LEGACY_ALARM = {u'alarm_actions': ['url1', 'url2'], u'comparison_operator': u'lt', u'meter_name': u'foobar', u'threshold': 42.1} +DELTA_LEGACY_ALARM['time_constraints'] = [{u'name': u'cons1', + u'duration': 500}] +DELTA_LEGACY_ALARM['remove_time_constraints'] = 'cons2' UPDATED_LEGACY_ALARM = copy.deepcopy(AN_LEGACY_ALARM) UPDATED_LEGACY_ALARM.update(DELTA_LEGACY_ALARM) UPDATE_LEGACY_ALARM = copy.deepcopy(UPDATED_LEGACY_ALARM) @@ -348,7 +373,7 @@ class AlarmLegacyManagerTest(testtools.TestCase): def test_create(self): alarm = self.mgr.create(**CREATE_LEGACY_ALARM) expect = [ - ('POST', '/v2/alarms', {}, CREATE_ALARM), + ('POST', '/v2/alarms', {}, CREATE_ALARM_WITHOUT_TC), ] self.assertEqual(self.api.calls, expect) self.assertTrue(alarm) @@ -360,7 +385,7 @@ class AlarmLegacyManagerTest(testtools.TestCase): del create['meter_name'] alarm = self.mgr.create(**create) expect = [ - ('POST', '/v2/alarms', {}, CREATE_ALARM), + ('POST', '/v2/alarms', {}, CREATE_ALARM_WITHOUT_TC), ] self.assertEqual(self.api.calls, expect) self.assertTrue(alarm) @@ -392,3 +417,41 @@ class AlarmLegacyManagerTest(testtools.TestCase): self.assertEqual(alarm.alarm_id, 'alarm-id') for (key, value) in six.iteritems(UPDATED_ALARM): self.assertEqual(getattr(alarm, key), value) + + +class AlarmTimeConstraintTest(testtools.TestCase): + + def setUp(self): + super(AlarmTimeConstraintTest, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.mgr = alarms.AlarmManager(self.api) + + def test_add_new(self): + new_constraint = dict(name='cons3', + start='0 0 * * *', + duration=500) + kwargs = dict(time_constraints=[new_constraint]) + self.mgr.update(alarm_id='alarm-id', **kwargs) + actual = self.api.calls[1][3]['time_constraints'] + expected = AN_ALARM[u'time_constraints'] + [new_constraint] + self.assertEqual(expected, actual) + + def test_update_existing(self): + updated_constraint = dict(name='cons2', + duration=500) + kwargs = dict(time_constraints=[updated_constraint]) + self.mgr.update(alarm_id='alarm-id', **kwargs) + actual = self.api.calls[1][3]['time_constraints'] + expected = [AN_ALARM[u'time_constraints'][0], dict(name='cons2', + description='desc2', + start='0 23 * * *', + duration=500, + timezone='')] + self.assertEqual(expected, actual) + + def test_remove(self): + kwargs = dict(remove_time_constraints=['cons2']) + self.mgr.update(alarm_id='alarm-id', **kwargs) + actual = self.api.calls[1][3]['time_constraints'] + expected = [AN_ALARM[u'time_constraints'][0]] + self.assertEqual(expected, actual) diff --git a/ceilometerclient/tests/v2/test_shell.py b/ceilometerclient/tests/v2/test_shell.py index 4d45a0b3..37dc09e6 100644 --- a/ceilometerclient/tests/v2/test_shell.py +++ b/ceilometerclient/tests/v2/test_shell.py @@ -158,6 +158,16 @@ class ShellAlarmCommandTest(utils.BaseTestCase): "value": "INSTANCE_ID", "op": "eq"}], "comparison_operator": "gt"}, + "time_constraints": [{"name": "cons1", + "description": "desc1", + "start": "0 11 * * *", + "duration": 300, + "timezone": ""}, + {"name": "cons2", + "description": "desc2", + "start": "0 23 * * *", + "duration": 600, + "timezone": ""}], "alarm_id": ALARM_ID, "state": "insufficient data", "insufficient_data_actions": [], @@ -276,6 +286,36 @@ class ShellAlarmCommandTest(utils.BaseTestCase): sys.stdout.close() sys.stdout = orig + def test_alarm_create_time_constraints(self): + shell = base_shell.CeilometerShell() + argv = ['alarm-threshold-create', + '--name', 'cpu_high', + '--meter-name', 'cpu_util', + '--threshold', '70.0', + '--time-constraint', + 'name=cons1;start="0 11 * * *";duration=300', + '--time-constraint', + 'name=cons2;start="0 23 * * *";duration=600', + ] + _, args = shell.parse_args(argv) + + orig = sys.stdout + sys.stdout = six.StringIO() + alarm = alarms.Alarm(mock.Mock(), self.ALARM) + self.cc.alarms.create.return_value = alarm + + try: + ceilometer_shell.do_alarm_threshold_create(self.cc, args) + _, kwargs = self.cc.alarms.create.call_args + time_constraints = [dict(name='cons1', start='0 11 * * *', + duration='300'), + dict(name='cons2', start='0 23 * * *', + duration='600')] + self.assertEqual(time_constraints, kwargs['time_constraints']) + finally: + sys.stdout.close() + sys.stdout = orig + class ShellSampleListCommandTest(utils.BaseTestCase): diff --git a/ceilometerclient/v2/alarms.py b/ceilometerclient/v2/alarms.py index 42f398ed..837904cd 100644 --- a/ceilometerclient/v2/alarms.py +++ b/ceilometerclient/v2/alarms.py @@ -36,7 +36,8 @@ UPDATABLE_ATTRIBUTES = [ 'threshold_rule', 'combination_rule', ] -CREATION_ATTRIBUTES = UPDATABLE_ATTRIBUTES + ['project_id', 'user_id'] +CREATION_ATTRIBUTES = UPDATABLE_ATTRIBUTES + ['project_id', 'user_id', + 'time_constraints'] class Alarm(base.Resource): @@ -111,6 +112,26 @@ class AlarmManager(base.Manager): del kwargs['matching_metadata'] kwargs['threshold_rule']['query'] = query + @staticmethod + def _merge_time_constraints(existing_tcs, kwargs): + new_tcs = kwargs.get('time_constraints', []) + if not existing_tcs: + updated_tcs = new_tcs + else: + updated_tcs = [dict(tc) for tc in existing_tcs] + for tc in new_tcs: + for i, old_tc in enumerate(updated_tcs): + if old_tc['name'] == tc['name']: # if names match, merge + utils.merge_nested_dict(updated_tcs[i], tc) + break + else: + updated_tcs.append(tc) + tcs_to_remove = kwargs.get('remove_time_constraints', []) + for tc in updated_tcs: + if tc['name'] in tcs_to_remove: + updated_tcs.remove(tc) + return updated_tcs + def create(self, **kwargs): self._compat_legacy_alarm_kwargs(kwargs, create=True) new = dict((key, value) for (key, value) in kwargs.items() @@ -120,6 +141,8 @@ class AlarmManager(base.Manager): def update(self, alarm_id, **kwargs): self._compat_legacy_alarm_kwargs(kwargs) updated = self.get(alarm_id).to_dict() + updated['time_constraints'] = self._merge_time_constraints( + updated.get('time_constraints', []), kwargs) kwargs = dict((k, v) for k, v in kwargs.items() if k in updated and k in UPDATABLE_ATTRIBUTES) utils.merge_nested_dict(updated, kwargs, depth=1) diff --git a/ceilometerclient/v2/shell.py b/ceilometerclient/v2/shell.py index 628a3d99..81fe566a 100644 --- a/ceilometerclient/v2/shell.py +++ b/ceilometerclient/v2/shell.py @@ -176,6 +176,24 @@ def alarm_rule_formatter(alarm): return _display_rule(alarm.type, alarm.rule) +def _display_time_constraints(time_constraints): + if time_constraints: + return ', '.join('%(name)s at %(start)s %(timezone)s for %(duration)ss' + % { + 'name': tc['name'], + 'start': tc['start'], + 'duration': tc['duration'], + 'timezone': tc.get('timezone', '') + } + for tc in time_constraints) + else: + return 'None' + + +def time_constraints_formatter(alarm): + return _display_time_constraints(alarm.time_constraints) + + def _infer_type(detail): if 'type' in detail: return detail['type'] @@ -199,6 +217,10 @@ def alarm_change_detail_formatter(change): detail[k])) else: fields.append('%s: %s' % (k, detail[k])) + if 'time_constraints' in detail: + fields.append('time_constraints: %s' % + _display_time_constraints( + detail['time_constraints'])) elif change.type == 'rule change': for k, v in six.iteritems(detail): if k == 'rule': @@ -218,11 +240,13 @@ def do_alarm_list(cc, args={}): # omit action initially to keep output width sane # (can switch over to vertical formatting when available from CLIFF) field_labels = ['Alarm ID', 'Name', 'State', 'Enabled', 'Continuous', - 'Alarm condition'] + 'Alarm condition', 'Time constraints'] fields = ['alarm_id', 'name', 'state', 'enabled', 'repeat_actions', - 'rule'] - utils.print_list(alarms, fields, field_labels, - formatters={'rule': alarm_rule_formatter}, sortby=0) + 'rule', 'time_constraints'] + utils.print_list( + alarms, fields, field_labels, + formatters={'rule': alarm_rule_formatter, + 'time_constraints': time_constraints_formatter}, sortby=0) def alarm_query_formater(alarm): @@ -233,6 +257,17 @@ def alarm_query_formater(alarm): return r' AND\n'.join(qs) +def alarm_time_constraints_formatter(alarm): + time_constraints = [] + for tc in alarm.time_constraints: + lines = [] + for k in ['name', 'description', 'start', 'duration', 'timezone']: + if k in tc and tc[k]: + lines.append(r'%s: %s' % (k, tc[k])) + time_constraints.append('{' + r',\n '.join(lines) + '}') + return '[' + r',\n '.join(time_constraints) + ']' + + def _display_alarm(alarm): fields = ['name', 'description', 'type', 'state', 'enabled', 'alarm_id', 'user_id', 'project_id', @@ -242,6 +277,8 @@ def _display_alarm(alarm): data.update(alarm.rule) if alarm.type == 'threshold': data['query'] = alarm_query_formater(alarm) + if alarm.time_constraints: + data['time_constraints'] = alarm_time_constraints_formatter(alarm) utils.print_dict(data, wrap=72) @@ -287,6 +324,18 @@ def common_alarm_arguments(create=False): metavar='', action='append', default=None, help=('URL to invoke when state transitions to ' 'insufficient_data. May be used multiple times.')) + @utils.arg('--time-constraint', dest='time_constraints', + metavar='