diff --git a/ceilometerclient/client.py b/ceilometerclient/client.py index c65c214..e65c627 100644 --- a/ceilometerclient/client.py +++ b/ceilometerclient/client.py @@ -21,6 +21,7 @@ def _get_ksclient(**kwargs): * username: name of user * password: user's password * auth_url: endpoint to authenticate against + * cacert: path of CA TLS certificate * insecure: allow insecure SSL (no cert verification) * tenant_{name|id}: name or ID of tenant """ @@ -30,6 +31,7 @@ def _get_ksclient(**kwargs): tenant_name=kwargs.get('tenant_name'), auth_url=kwargs.get('auth_url'), region_name=kwargs.get('region_name'), + cacert=kwargs.get('cacert'), insecure=kwargs.get('insecure')) @@ -52,6 +54,7 @@ def get_client(api_version, **kwargs): * os_username: name of user * os_password: user's password * os_auth_url: endpoint to authenticate against + * os_cacert: path of CA TLS certificate * insecure: allow insecure SSL (no cert verification) * os_tenant_{name|id}: name or ID of tenant """ @@ -72,6 +75,7 @@ def get_client(api_version, **kwargs): 'region_name': kwargs.get('os_region_name'), 'service_type': kwargs.get('os_service_type'), 'endpoint_type': kwargs.get('os_endpoint_type'), + 'cacert': kwargs.get('os_cacert'), 'insecure': kwargs.get('insecure'), } _ksclient = _get_ksclient(**ks_kwargs) @@ -86,7 +90,7 @@ def get_client(api_version, **kwargs): 'token': token, 'insecure': kwargs.get('insecure'), 'timeout': kwargs.get('timeout'), - 'ca_file': kwargs.get('ca_file'), + 'cacert': kwargs.get('cacert'), 'cert_file': kwargs.get('cert_file'), 'key_file': kwargs.get('key_file'), } diff --git a/ceilometerclient/common/http.py b/ceilometerclient/common/http.py index a0e03f4..b53bb98 100644 --- a/ceilometerclient/common/http.py +++ b/ceilometerclient/common/http.py @@ -63,7 +63,7 @@ class HTTPClient(object): if parts.scheme == 'https': _class = VerifiedHTTPSConnection - _kwargs['ca_file'] = kwargs.get('ca_file', None) + _kwargs['ca_cert'] = kwargs.get('cacert', None) _kwargs['cert_file'] = kwargs.get('cert_file', None) _kwargs['key_file'] = kwargs.get('key_file', None) _kwargs['insecure'] = kwargs.get('insecure', False) @@ -93,7 +93,7 @@ class HTTPClient(object): conn_params_fmt = [ ('key_file', '--key %s'), ('cert_file', '--cert %s'), - ('ca_file', '--cacert %s'), + ('cacert', '--cacert %s'), ] for (key, fmt) in conn_params_fmt: value = self.connection_params[2].get(key) @@ -215,21 +215,21 @@ class VerifiedHTTPSConnection(httplib.HTTPSConnection): """ def __init__(self, host, port, key_file=None, cert_file=None, - ca_file=None, timeout=None, insecure=False): + cacert=None, timeout=None, insecure=False): httplib.HTTPSConnection.__init__(self, host, port, key_file=key_file, cert_file=cert_file) self.key_file = key_file self.cert_file = cert_file - if ca_file is not None: - self.ca_file = ca_file + if cacert is not None: + self.cacert = cacert else: - self.ca_file = self.get_system_ca_file() + self.cacert = self.get_system_ca_file() self.timeout = timeout self.insecure = insecure def connect(self): """Connect to a host on a given (SSL) port. - If ca_file is pointing somewhere, use it to check Server Certificate. + If cacert is pointing somewhere, use it to check Server Certificate. Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter to @@ -245,7 +245,7 @@ class VerifiedHTTPSConnection(httplib.HTTPSConnection): if self.insecure is True: kwargs = {'cert_reqs': ssl.CERT_NONE} else: - kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.ca_file} + kwargs = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': self.cacert} if self.cert_file: kwargs['certfile'] = self.cert_file diff --git a/ceilometerclient/common/utils.py b/ceilometerclient/common/utils.py index ea79dbf..f28a3ea 100644 --- a/ceilometerclient/common/utils.py +++ b/ceilometerclient/common/utils.py @@ -27,6 +27,13 @@ from ceilometerclient.openstack.common import importutils # Decorator for cli-args def arg(*args, **kwargs): def _decorator(func): + if 'help' in kwargs: + if 'default' in kwargs: + kwargs['help'] += " Defaults to %s." % kwargs['default'] + required = kwargs.get('required', False) + if required: + kwargs['help'] += " Required." + # Because of the sematics of decorator composition if we just append # to the options list positional options will appear to be backwards. func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs)) @@ -63,17 +70,19 @@ def print_dict(d, dict_property="Property", wrap=0): # convert dict to str to check length if isinstance(v, dict): v = str(v) - if wrap > 0: - v = textwrap.fill(str(v), wrap) # if value has a newline, add in multiple rows # e.g. fault with stacktrace if v and isinstance(v, basestring) and r'\n' in v: lines = v.strip().split(r'\n') col1 = k for line in lines: + if wrap > 0: + line = textwrap.fill(str(line), wrap) pt.add_row([col1, line]) col1 = '' else: + if wrap > 0: + v = textwrap.fill(str(v), wrap) pt.add_row([k, v]) print pt.get_string() @@ -151,6 +160,15 @@ def key_with_slash_to_nested_dict(kwargs): return kwargs +def merge_nested_dict(dest, source, depth=0): + for (key, value) in source.iteritems(): + if isinstance(value, dict) and depth: + merge_nested_dict(dest[key], value, + depth=(depth - 1)) + else: + dest[key] = value + + def exit(msg=''): if msg: print >> sys.stderr, msg diff --git a/ceilometerclient/shell.py b/ceilometerclient/shell.py index 3ea531a..b6d3939 100644 --- a/ceilometerclient/shell.py +++ b/ceilometerclient/shell.py @@ -76,11 +76,17 @@ class CeilometerShell(object): ' This option is not necessary if your key is ' 'prepended to your cert file.') - parser.add_argument('--ca-file', - help='Path of CA SSL certificate(s) used to verify' - ' the remote server certificate. Without this ' + parser.add_argument('--os-cacert', + metavar='', + dest='os_cacert', + default=utils.env('OS_CACERT'), + help='Path of CA TLS certificate(s) used to verify' + 'the remote server\'s certificate. Without this ' 'option ceilometer looks for the default system ' 'CA certificates.') + parser.add_argument('--ca-file', + dest='os_cacert', + help='DEPRECATED! Use --os-cacert.') parser.add_argument('--timeout', default=600, diff --git a/ceilometerclient/tests/test_utils.py b/ceilometerclient/tests/test_utils.py index c002dc6..e0626af 100644 --- a/ceilometerclient/tests/test_utils.py +++ b/ceilometerclient/tests/test_utils.py @@ -74,3 +74,53 @@ class UtilsTest(test_utils.BaseTestCase): 'statictic': 'avg', 'comparison_operator': 'or'}, }) + + def test_arg(self): + @utils.arg(help="not_required_no_default.") + def not_required_no_default(): + pass + _, args = not_required_no_default.__dict__['arguments'][0] + self.assertEqual(args['help'], "not_required_no_default.") + + @utils.arg(required=True, help="required_no_default.") + def required_no_default(): + pass + _, args = required_no_default.__dict__['arguments'][0] + self.assertEqual(args['help'], "required_no_default. Required.") + + @utils.arg(default=42, help="not_required_default.") + def not_required_default(): + pass + _, args = not_required_default.__dict__['arguments'][0] + self.assertEqual(args['help'], "not_required_default. Defaults to 42.") + + def test_merge_nested_dict(self): + dest = {'key': 'value', + 'nested': {'key2': 'value2', + 'key3': 'value3', + 'nested2': {'key': 'value', + 'some': 'thing'}}} + source = {'key': 'modified', + 'nested': {'key3': 'modified3', + 'nested2': {'key5': 'value5'}}} + utils.merge_nested_dict(dest, source, depth=1) + + self.assertEqual(dest, {'key': 'modified', + 'nested': {'key2': 'value2', + 'key3': 'modified3', + 'nested2': {'key5': 'value5'}}}) + + def test_merge_nested_dict_no_depth(self): + dest = {'key': 'value', + 'nested': {'key2': 'value2', + 'key3': 'value3', + 'nested2': {'key': 'value', + 'some': 'thing'}}} + source = {'key': 'modified', + 'nested': {'key3': 'modified3', + 'nested2': {'key5': 'value5'}}} + utils.merge_nested_dict(dest, source) + + self.assertEqual(dest, {'key': 'modified', + 'nested': {'key3': 'modified3', + 'nested2': {'key5': 'value5'}}}) diff --git a/ceilometerclient/tests/v2/test_alarms.py b/ceilometerclient/tests/v2/test_alarms.py index 82279f5..0c2fa72 100644 --- a/ceilometerclient/tests/v2/test_alarms.py +++ b/ceilometerclient/tests/v2/test_alarms.py @@ -20,7 +20,7 @@ import copy import testtools from ceilometerclient.tests import utils -import ceilometerclient.v2.alarms +from ceilometerclient.v2 import alarms AN_ALARM = {u'alarm_actions': [u'http://site:8000/alarm'], u'ok_actions': [u'http://site:8000/ok'], @@ -61,6 +61,7 @@ DELTA_ALARM_RULE = {u'comparison_operator': u'lt', UPDATED_ALARM = copy.deepcopy(AN_ALARM) UPDATED_ALARM.update(DELTA_ALARM) UPDATED_ALARM['threshold_rule'].update(DELTA_ALARM_RULE) +DELTA_ALARM['threshold_rule'] = DELTA_ALARM_RULE UPDATE_ALARM = copy.deepcopy(UPDATED_ALARM) del UPDATE_ALARM['user_id'] del UPDATE_ALARM['project_id'] @@ -107,6 +108,47 @@ del UPDATE_LEGACY_ALARM['alarm_id'] del UPDATE_LEGACY_ALARM['timestamp'] del UPDATE_LEGACY_ALARM['state_timestamp'] +FULL_DETAIL = ('{"alarm_actions": [], ' + '"user_id": "8185aa72421a4fd396d4122cba50e1b5", ' + '"name": "scombo", ' + '"timestamp": "2013-10-03T08:58:33.647912", ' + '"enabled": true, ' + '"state_timestamp": "2013-10-03T08:58:33.647912", ' + '"rule": {"operator": "or", "alarm_ids": ' + '["062cc907-3a9f-4867-ab3b-fa83212b39f7"]}, ' + '"alarm_id": "alarm-id, ' + '"state": "insufficient data", ' + '"insufficient_data_actions": [], ' + '"repeat_actions": false, ' + '"ok_actions": [], ' + '"project_id": "57d04f24d0824b78b1ea9bcecedbda8f", ' + '"type": "combination", ' + '"description": "Combined state of alarms ' + '062cc907-3a9f-4867-ab3b-fa83212b39f7"}') +ALARM_HISTORY = [{'on_behalf_of': '57d04f24d0824b78b1ea9bcecedbda8f', + 'user_id': '8185aa72421a4fd396d4122cba50e1b5', + 'event_id': 'c74a8611-6553-4764-a860-c15a6aabb5d0', + 'timestamp': '2013-10-03T08:59:28.326000', + 'detail': '{"state": "alarm"}', + 'alarm_id': 'alarm-id', + 'project_id': '57d04f24d0824b78b1ea9bcecedbda8f', + 'type': 'state transition'}, + {'on_behalf_of': '57d04f24d0824b78b1ea9bcecedbda8f', + 'user_id': '8185aa72421a4fd396d4122cba50e1b5', + 'event_id': 'c74a8611-6553-4764-a860-c15a6aabb5d0', + 'timestamp': '2013-10-03T08:59:28.326000', + 'detail': '{"description": "combination of one"}', + 'alarm_id': 'alarm-id', + 'project_id': '57d04f24d0824b78b1ea9bcecedbda8f', + 'type': 'rule change'}, + {'on_behalf_of': '57d04f24d0824b78b1ea9bcecedbda8f', + 'user_id': '8185aa72421a4fd396d4122cba50e1b5', + 'event_id': '4fd7df9e-190d-4471-8884-dc5a33d5d4bb', + 'timestamp': '2013-10-03T08:58:33.647000', + 'detail': FULL_DETAIL, + 'alarm_id': 'alarm-id', + 'project_id': '57d04f24d0824b78b1ea9bcecedbda8f', + 'type': 'creation'}] fixtures = { '/v2/alarms': @@ -158,6 +200,20 @@ fixtures = { None, ), }, + '/v2/alarms/alarm-id/history': + { + 'GET': ( + {}, + ALARM_HISTORY, + ), + }, + '/v2/alarms/alarm-id/history?q.op=&q.value=NOW&q.field=timestamp': + { + 'GET': ( + {}, + ALARM_HISTORY, + ), + }, } @@ -166,7 +222,7 @@ class AlarmManagerTest(testtools.TestCase): def setUp(self): super(AlarmManagerTest, self).setUp() self.api = utils.FakeAPI(fixtures) - self.mgr = ceilometerclient.v2.alarms.AlarmManager(self.api) + self.mgr = alarms.AlarmManager(self.api) def test_list_all(self): alarms = list(self.mgr.list()) @@ -215,7 +271,20 @@ class AlarmManagerTest(testtools.TestCase): def test_update(self): alarm = self.mgr.update(alarm_id='alarm-id', **UPDATE_ALARM) expect = [ - ('PUT', '/v2/alarms/alarm-id', {}, UPDATE_ALARM), + ('GET', '/v2/alarms/alarm-id', {}, None), + ('PUT', '/v2/alarms/alarm-id', {}, UPDATED_ALARM), + ] + self.assertEqual(self.api.calls, expect) + self.assertTrue(alarm) + self.assertEqual(alarm.alarm_id, 'alarm-id') + for (key, value) in UPDATED_ALARM.iteritems(): + self.assertEqual(getattr(alarm, key), value) + + def test_update_delta(self): + alarm = self.mgr.update(alarm_id='alarm-id', **DELTA_ALARM) + expect = [ + ('GET', '/v2/alarms/alarm-id', {}, None), + ('PUT', '/v2/alarms/alarm-id', {}, UPDATED_ALARM), ] self.assertEqual(self.api.calls, expect) self.assertTrue(alarm) @@ -247,13 +316,33 @@ class AlarmManagerTest(testtools.TestCase): self.assertEqual(self.api.calls, expect) self.assertTrue(deleted is None) + def _do_test_get_history(self, q, url): + history = self.mgr.get_history(q=q, alarm_id='alarm-id') + expect = [('GET', url, {}, None)] + self.assertEqual(self.api.calls, expect) + for i in xrange(len(history)): + change = history[i] + self.assertTrue(isinstance(change, alarms.AlarmChange)) + for k, v in ALARM_HISTORY[i].iteritems(): + self.assertEqual(getattr(change, k), v) + + def test_get_all_history(self): + url = '/v2/alarms/alarm-id/history' + self._do_test_get_history(None, url) + + def test_get_constrained_history(self): + q = [dict(field='timestamp', value='NOW')] + url = ('/v2/alarms/alarm-id/history' + '?q.op=&q.value=NOW&q.field=timestamp') + self._do_test_get_history(q, url) + class AlarmLegacyManagerTest(testtools.TestCase): def setUp(self): super(AlarmLegacyManagerTest, self).setUp() self.api = utils.FakeAPI(fixtures) - self.mgr = ceilometerclient.v2.alarms.AlarmManager(self.api) + self.mgr = alarms.AlarmManager(self.api) def test_create(self): alarm = self.mgr.create(**CREATE_LEGACY_ALARM) @@ -276,9 +365,10 @@ class AlarmLegacyManagerTest(testtools.TestCase): self.assertTrue(alarm) def test_update(self): - alarm = self.mgr.update(alarm_id='alarm-id', **UPDATE_LEGACY_ALARM) + alarm = self.mgr.update(alarm_id='alarm-id', **DELTA_LEGACY_ALARM) expect = [ - ('PUT', '/v2/alarms/alarm-id', {}, UPDATE_ALARM), + ('GET', '/v2/alarms/alarm-id', {}, None), + ('PUT', '/v2/alarms/alarm-id', {}, UPDATED_ALARM), ] self.assertEqual(self.api.calls, expect) self.assertTrue(alarm) @@ -293,7 +383,8 @@ class AlarmLegacyManagerTest(testtools.TestCase): del updated['meter_name'] alarm = self.mgr.update(alarm_id='alarm-id', **updated) expect = [ - ('PUT', '/v2/alarms/alarm-id', {}, UPDATE_ALARM), + ('GET', '/v2/alarms/alarm-id', {}, None), + ('PUT', '/v2/alarms/alarm-id', {}, UPDATED_ALARM), ] self.assertEqual(self.api.calls, expect) self.assertTrue(alarm) diff --git a/ceilometerclient/tests/v2/test_shell.py b/ceilometerclient/tests/v2/test_shell.py new file mode 100644 index 0000000..402343b --- /dev/null +++ b/ceilometerclient/tests/v2/test_shell.py @@ -0,0 +1,137 @@ +# 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 cStringIO +import mock +import re +import sys + +from testtools import matchers + +from ceilometerclient.tests import utils +from ceilometerclient.v2 import alarms +from ceilometerclient.v2 import shell as ceilometer_shell + + +class ShellAlarmStateCommandsTest(utils.BaseTestCase): + + ALARM_ID = 'foobar' + + def setUp(self): + super(ShellAlarmStateCommandsTest, self).setUp() + self.cc = mock.Mock() + self.cc.alarms = mock.Mock() + self.args = mock.Mock() + self.args.alarm_id = self.ALARM_ID + + def test_alarm_state_get(self): + ceilometer_shell.do_alarm_state_get(self.cc, self.args) + self.cc.alarms.get_state.assert_called_once_with(self.ALARM_ID) + self.assertFalse(self.cc.alarms.set_state.called) + + def test_alarm_state_set(self): + self.args.state = 'ok' + ceilometer_shell.do_alarm_state_set(self.cc, self.args) + self.cc.alarms.set_state.assert_called_once_with(self.ALARM_ID, 'ok') + self.assertFalse(self.cc.alarms.get_state.called) + + +class ShellAlarmHistoryCommandTest(utils.BaseTestCase): + + ALARM_ID = '768ff714-8cfb-4db9-9753-d484cb33a1cc' + FULL_DETAIL = ('{"alarm_actions": [], ' + '"user_id": "8185aa72421a4fd396d4122cba50e1b5", ' + '"name": "scombo", ' + '"timestamp": "2013-10-03T08:58:33.647912", ' + '"enabled": true, ' + '"state_timestamp": "2013-10-03T08:58:33.647912", ' + '"rule": {"operator": "or", "alarm_ids": ' + '["062cc907-3a9f-4867-ab3b-fa83212b39f7"]}, ' + '"alarm_id": "768ff714-8cfb-4db9-9753-d484cb33a1cc", ' + '"state": "insufficient data", ' + '"insufficient_data_actions": [], ' + '"repeat_actions": false, ' + '"ok_actions": [], ' + '"project_id": "57d04f24d0824b78b1ea9bcecedbda8f", ' + '"type": "combination", ' + '"description": "Combined state of alarms ' + '062cc907-3a9f-4867-ab3b-fa83212b39f7"}') + ALARM_HISTORY = [{'on_behalf_of': '57d04f24d0824b78b1ea9bcecedbda8f', + 'user_id': '8185aa72421a4fd396d4122cba50e1b5', + 'event_id': 'c74a8611-6553-4764-a860-c15a6aabb5d0', + 'timestamp': '2013-10-03T08:59:28.326000', + 'detail': '{"state": "alarm"}', + 'alarm_id': '768ff714-8cfb-4db9-9753-d484cb33a1cc', + 'project_id': '57d04f24d0824b78b1ea9bcecedbda8f', + 'type': 'state transition'}, + {'on_behalf_of': '57d04f24d0824b78b1ea9bcecedbda8f', + 'user_id': '8185aa72421a4fd396d4122cba50e1b5', + 'event_id': 'c74a8611-6553-4764-a860-c15a6aabb5d0', + 'timestamp': '2013-10-03T08:59:28.326000', + 'detail': '{"description": "combination of one"}', + 'alarm_id': '768ff714-8cfb-4db9-9753-d484cb33a1cc', + 'project_id': '57d04f24d0824b78b1ea9bcecedbda8f', + 'type': 'rule change'}, + {'on_behalf_of': '57d04f24d0824b78b1ea9bcecedbda8f', + 'user_id': '8185aa72421a4fd396d4122cba50e1b5', + 'event_id': '4fd7df9e-190d-4471-8884-dc5a33d5d4bb', + 'timestamp': '2013-10-03T08:58:33.647000', + 'detail': FULL_DETAIL, + 'alarm_id': '768ff714-8cfb-4db9-9753-d484cb33a1cc', + 'project_id': '57d04f24d0824b78b1ea9bcecedbda8f', + 'type': 'creation'}] + TIMESTAMP_RE = (' +\| (\d{4})-(\d{2})-(\d{2})T' + '(\d{2})\:(\d{2})\:(\d{2})\.(\d{6}) \| +') + + def setUp(self): + super(ShellAlarmHistoryCommandTest, self).setUp() + self.cc = mock.Mock() + self.cc.alarms = mock.Mock() + self.args = mock.Mock() + self.args.alarm_id = self.ALARM_ID + + def _do_test_alarm_history(self, raw_query=None, parsed_query=None): + self.args.query = raw_query + orig = sys.stdout + sys.stdout = cStringIO.StringIO() + history = [alarms.AlarmChange(mock.Mock(), change) + for change in self.ALARM_HISTORY] + self.cc.alarms.get_history.return_value = history + + try: + ceilometer_shell.do_alarm_history(self.cc, self.args) + self.cc.alarms.get_history.assert_called_once_with( + q=parsed_query, + alarm_id=self.ALARM_ID + ) + out = sys.stdout.getvalue() + required = [ + '.*creation%sname: scombo.*' % self.TIMESTAMP_RE, + '.*rule change%sdescription: combination of one.*' % + self.TIMESTAMP_RE, + '.*state transition%sstate: alarm.*' % self.TIMESTAMP_RE, + ] + for r in required: + self.assertThat(out, matchers.MatchesRegex(r, re.DOTALL)) + finally: + sys.stdout.close() + sys.stdout = orig + + def test_alarm_all_history(self): + self._do_test_alarm_history() + + def test_alarm_constrained_history(self): + parsed_query = [dict(field='timestamp', + value='2013-10-03T08:59:28', + op='gt')] + self._do_test_alarm_history(raw_query='timestamp>2013-10-03T08:59:28', + parsed_query=parsed_query) diff --git a/ceilometerclient/v2/alarms.py b/ceilometerclient/v2/alarms.py index d1ce7c6..42f398e 100644 --- a/ceilometerclient/v2/alarms.py +++ b/ceilometerclient/v2/alarms.py @@ -19,6 +19,7 @@ import warnings from ceilometerclient.common import base +from ceilometerclient.common import utils from ceilometerclient.v2 import options @@ -34,7 +35,7 @@ UPDATABLE_ATTRIBUTES = [ 'repeat_actions', 'threshold_rule', 'combination_rule', - ] +] CREATION_ATTRIBUTES = UPDATABLE_ATTRIBUTES + ['project_id', 'user_id'] @@ -50,6 +51,14 @@ class Alarm(base.Resource): return super(Alarm, self).__getattr__(k) +class AlarmChange(base.Resource): + def __repr__(self): + return "" % self._info + + def __getattr__(self, k): + return super(AlarmChange, self).__getattr__(k) + + class AlarmManager(base.Manager): resource_class = Alarm @@ -67,12 +76,12 @@ class AlarmManager(base.Manager): return None @classmethod - def _compat_legacy_alarm_kwargs(cls, kwargs): - cls._compat_counter_rename_kwargs(kwargs) - cls._compat_alarm_before_rule_type_kwargs(kwargs) + def _compat_legacy_alarm_kwargs(cls, kwargs, create=False): + cls._compat_counter_rename_kwargs(kwargs, create) + cls._compat_alarm_before_rule_type_kwargs(kwargs, create) @staticmethod - def _compat_counter_rename_kwargs(kwargs): + def _compat_counter_rename_kwargs(kwargs, create=False): # NOTE(jd) Compatibility with Havana-2 API if 'counter_name' in kwargs: warnings.warn("counter_name has been renamed to meter_name", @@ -80,40 +89,40 @@ class AlarmManager(base.Manager): kwargs['meter_name'] = kwargs['counter_name'] @staticmethod - def _compat_alarm_before_rule_type_kwargs(kwargs): + def _compat_alarm_before_rule_type_kwargs(kwargs, create=False): # NOTE(sileht) Compatibility with Havana-3 API - if kwargs.get('type'): - return - warnings.warn("alarm without type set is deprecated", - DeprecationWarning) + if create and 'type' not in kwargs: + warnings.warn("alarm without type set is deprecated", + DeprecationWarning) + kwargs['type'] = 'threshold' - kwargs['type'] = 'threshold' - kwargs['threshold_rule'] = {} for field in ['period', 'evaluation_periods', 'threshold', 'statistic', 'comparison_operator', 'meter_name']: if field in kwargs: - kwargs['threshold_rule'][field] = kwargs[field] + kwargs.setdefault('threshold_rule', {})[field] = kwargs[field] del kwargs[field] - query = [] if 'matching_metadata' in kwargs: + query = [] for key in kwargs['matching_metadata']: query.append({'field': key, 'op': 'eq', 'value': kwargs['matching_metadata'][key]}) del kwargs['matching_metadata'] - kwargs['threshold_rule']['query'] = query + kwargs['threshold_rule']['query'] = query def create(self, **kwargs): - self._compat_legacy_alarm_kwargs(kwargs) + self._compat_legacy_alarm_kwargs(kwargs, create=True) new = dict((key, value) for (key, value) in kwargs.items() if key in CREATION_ATTRIBUTES) return self._create(self._path(), new) def update(self, alarm_id, **kwargs): self._compat_legacy_alarm_kwargs(kwargs) - updated = dict((key, value) for (key, value) in kwargs.items() - if key in UPDATABLE_ATTRIBUTES) + updated = self.get(alarm_id).to_dict() + 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) return self._update(self._path(alarm_id), updated) def delete(self, alarm_id): @@ -129,3 +138,8 @@ class AlarmManager(base.Manager): resp, body = self.api.json_request('GET', "%s/state" % self._path(alarm_id)) return body + + def get_history(self, alarm_id, q=None): + path = '%s/history' % self._path(alarm_id) + url = options.build_url(path, q) + return self._list(url, obj_class=AlarmChange) diff --git a/ceilometerclient/v2/shell.py b/ceilometerclient/v2/shell.py index 05f7f96..24b15b9 100644 --- a/ceilometerclient/v2/shell.py +++ b/ceilometerclient/v2/shell.py @@ -28,11 +28,14 @@ ALARM_STATES = ['ok', 'alarm', 'insufficient_data'] ALARM_OPERATORS = ['lt', 'le', 'eq', 'ne', 'ge', 'gt'] ALARM_COMBINATION_OPERATORS = ['and', 'or'] STATISTICS = ['max', 'min', 'avg', 'sum', 'count'] +OPERATORS_STRING = dict(gt='>', ge='>=', + lt='<', le="<=", + eq='==', ne='!=') @utils.arg('-q', '--query', metavar='', help='key[op]value; list.') -@utils.arg('-m', '--meter', metavar='', +@utils.arg('-m', '--meter', metavar='', required=True, help='Name of meter to show samples for.') @utils.arg('-p', '--period', metavar='', help='Period in seconds over which to group samples.') @@ -41,8 +44,6 @@ def do_statistics(cc, args): fields = {'meter_name': args.meter, 'q': options.cli_to_array(args.query), 'period': args.period} - if args.meter is None: - raise exc.CommandError('Meter name not provided (-m )') try: statistics = cc.statistics.list(**fields) except exc.HTTPNotFound: @@ -59,14 +60,12 @@ def do_statistics(cc, args): @utils.arg('-q', '--query', metavar='', help='key[op]value; list.') -@utils.arg('-m', '--meter', metavar='', +@utils.arg('-m', '--meter', metavar='', required=True, help='Name of meter to show samples for.') def do_sample_list(cc, args): '''List the samples for this meters.''' fields = {'meter_name': args.meter, 'q': options.cli_to_array(args.query)} - if args.meter is None: - raise exc.CommandError('Meter name not provided (-m )') try: samples = cc.samples.list(**fields) except exc.HTTPNotFound: @@ -86,15 +85,15 @@ def do_sample_list(cc, args): @utils.arg('--user-id', metavar='', help='User to associate with sample ' '(only settable by admin users)') -@utils.arg('-r', '--resource-id', metavar='', +@utils.arg('-r', '--resource-id', metavar='', required=True, help='ID of the resource.') @utils.arg('-m', '--meter-name', metavar='', help='the meter name') -@utils.arg('--meter-type', metavar='', +@utils.arg('--meter-type', metavar='', required=True, help='the meter type') -@utils.arg('--meter-unit', metavar='', +@utils.arg('--meter-unit', metavar='', required=True, help='the meter unit') -@utils.arg('--sample-volume', metavar='', +@utils.arg('--sample-volume', metavar='', required=True, help='The sample volume') @utils.arg('--resource-metadata', metavar='', help='resource metadata') @@ -128,6 +127,65 @@ def do_meter_list(cc, args={}): sortby=0) +def _display_rule(type, rule): + if type == 'threshold': + return ('%(meter_name)s %(comparison_operator)s ' + '%(threshold)s during %(evaluation_periods)s x %(period)ss' % + { + 'meter_name': rule['meter_name'], + 'threshold': rule['threshold'], + 'evaluation_periods': rule['evaluation_periods'], + 'period': rule['period'], + 'comparison_operator': OPERATORS_STRING.get( + rule['comparison_operator']) + }) + elif type == 'combination': + return ('combinated states (%(operator)s) of %(alarms)s' % { + 'operator': rule['operator'].upper(), + 'alarms': ", ".join(rule['alarm_ids'])}) + else: + # just dump all + return "\n".join(["%s: %s" % (f, v) + for f, v in rule.iteritems()]) + + +def alarm_rule_formatter(alarm): + return _display_rule(alarm.type, alarm.rule) + + +def _infer_type(detail): + if 'type' in detail: + return detail['type'] + elif 'meter_name' in detail['rule']: + return 'threshold' + elif 'alarms' in detail['rule']: + return 'combination' + else: + return 'unknown' + + +def alarm_change_detail_formatter(change): + detail = json.loads(change.detail) + fields = [] + if change.type == 'state transition': + fields.append('state: %s' % detail['state']) + elif change.type == 'creation' or change.type == 'deletion': + for k in ['name', 'description', 'type', 'rule']: + if k == 'rule': + fields.append('rule: %s' % _display_rule(detail['type'], + detail[k])) + else: + fields.append('%s: %s' % (k, detail[k])) + elif change.type == 'rule change': + for k, v in detail.iteritems(): + if k == 'rule': + fields.append('rule: %s' % _display_rule(_infer_type(detail), + v)) + else: + fields.append('%s: %s' % (k, v)) + return '\n'.join(fields) + + @utils.arg('-q', '--query', metavar='', help='key[op]value; list.') def do_alarm_list(cc, args={}): @@ -135,29 +193,38 @@ def do_alarm_list(cc, args={}): alarms = cc.alarms.list(q=options.cli_to_array(args.query)) # omit action initially to keep output width sane # (can switch over to vertical formatting when available from CLIFF) - field_labels = ['Name', 'Description', 'State', 'Enabled', 'Continuous', - 'Alarm ID', 'User ID', 'Project ID'] - fields = ['name', 'description', 'state', 'enabled', 'repeat_actions', - 'alarm_id', 'user_id', 'project_id'] + field_labels = ['Alarm ID', 'Name', 'State', 'Enabled', 'Continuous', + 'Alarm condition'] + fields = ['alarm_id', 'name', 'state', 'enabled', 'repeat_actions', + 'rule'] utils.print_list(alarms, fields, field_labels, - sortby=0) + formatters={'rule': alarm_rule_formatter}, sortby=0) + + +def alarm_query_formater(alarm): + qs = [] + for q in alarm.rule['query']: + qs.append('%s %s %s' % ( + q['field'], OPERATORS_STRING.get(q['op']), q['value'])) + return r' AND\n'.join(qs) def _display_alarm(alarm): - fields = ['name', 'description', 'type', 'rule', + fields = ['name', 'description', 'type', 'state', 'enabled', 'alarm_id', 'user_id', 'project_id', 'alarm_actions', 'ok_actions', 'insufficient_data_actions', 'repeat_actions'] data = dict([(f, getattr(alarm, f, '')) for f in fields]) + data.update(alarm.rule) + if alarm.type == 'threshold': + data['query'] = alarm_query_formater(alarm) utils.print_dict(data, wrap=72) -@utils.arg('-a', '--alarm_id', metavar='', +@utils.arg('-a', '--alarm_id', metavar='', required=True, help='ID of the alarm to show.') def do_alarm_show(cc, args={}): '''Show an alarm.''' - if args.alarm_id is None: - raise exc.CommandError('Alarm ID not provided (-a )') try: alarm = cc.alarms.get(args.alarm_id) except exc.HTTPNotFound: @@ -166,45 +233,49 @@ def do_alarm_show(cc, args={}): _display_alarm(alarm) -def common_alarm_arguments(func): - @utils.arg('--name', metavar='', required=True, - help='Name of the alarm (must be unique per tenant)') - @utils.arg('--project-id', metavar='', - help='Tenant to associate with alarm ' - '(only settable by admin users)') - @utils.arg('--user-id', metavar='', - help='User to associate with alarm ' - '(only settable by admin users)') - @utils.arg('--description', metavar='', - help='Free text description of the alarm') - @utils.arg('--state', metavar='', - help='State of the alarm, one of: ' + str(ALARM_STATES)) - @utils.arg('--enabled', type=utils.string_to_bool, metavar='{True|False}', - help='True if alarm evaluation/actioning is enabled') - @utils.arg('--alarm-action', dest='alarm_actions', - metavar='', action='append', default=None, - help=('URL to invoke when state transitions to alarm. ' - 'May be used multiple times.')) - @utils.arg('--ok-action', dest='ok_actions', - metavar='', action='append', default=None, - help=('URL to invoke when state transitions to OK. ' - 'May be used multiple times.')) - @utils.arg('--insufficient-data-action', dest='insufficient_data_actions', - metavar='', action='append', default=None, - help=('URL to invoke when state transitions to unkown. ' - 'May be used multiple times.')) - @utils.arg('--repeat-actions', dest='repeat_actions', - metavar='{True|False}', type=utils.string_to_bool, - default=False, - help=('True if actions should be repeatedly notified ' - 'while alarm remains in target state')) - @functools.wraps(func) - def _wrapper(*args, **kwargs): - return func(*args, **kwargs) +def common_alarm_arguments(create=False): + def _wrapper(func): + @utils.arg('--name', metavar='', required=create, + help='Name of the alarm (must be unique per tenant)') + @utils.arg('--project-id', metavar='', + help='Tenant to associate with alarm ' + '(only settable by admin users)') + @utils.arg('--user-id', metavar='', + help='User to associate with alarm ' + '(only settable by admin users)') + @utils.arg('--description', metavar='', + help='Free text description of the alarm') + @utils.arg('--state', metavar='', + help='State of the alarm, one of: ' + str(ALARM_STATES)) + @utils.arg('--enabled', type=utils.string_to_bool, + metavar='{True|False}', + help='True if alarm evaluation/actioning is enabled') + @utils.arg('--alarm-action', dest='alarm_actions', + metavar='', action='append', default=None, + help=('URL to invoke when state transitions to alarm. ' + 'May be used multiple times.')) + @utils.arg('--ok-action', dest='ok_actions', + metavar='', action='append', default=None, + help=('URL to invoke when state transitions to OK. ' + 'May be used multiple times.')) + @utils.arg('--insufficient-data-action', + dest='insufficient_data_actions', + metavar='', action='append', default=None, + help=('URL to invoke when state transitions to unkown. ' + 'May be used multiple times.')) + @utils.arg('--repeat-actions', dest='repeat_actions', + metavar='{True|False}', type=utils.string_to_bool, + default=False, + help=('True if actions should be repeatedly notified ' + 'while alarm remains in target state')) + @functools.wraps(func) + def _wrapped(*args, **kwargs): + return func(*args, **kwargs) + return _wrapped return _wrapper -@common_alarm_arguments +@common_alarm_arguments(create=True) @utils.arg('--period', type=int, metavar='', help='Length of each period (seconds) to evaluate over') @utils.arg('--evaluation-periods', type=int, metavar='', @@ -229,7 +300,7 @@ def do_alarm_create(cc, args={}): _display_alarm(alarm) -@common_alarm_arguments +@common_alarm_arguments(create=True) @utils.arg('--meter-name', metavar='', required=True, dest='threshold_rule/meter_name', help='Metric to evaluate against') @@ -264,7 +335,7 @@ def do_alarm_threshold_create(cc, args={}): _display_alarm(alarm) -@common_alarm_arguments +@common_alarm_arguments(create=True) @utils.arg('--alarm_ids', action='append', metavar='', required=True, dest='combination_rule/alarm_ids', help='List of alarm id') @@ -283,18 +354,18 @@ def do_alarm_combination_create(cc, args={}): @utils.arg('-a', '--alarm_id', metavar='', required=True, help='ID of the alarm to update.') -@common_alarm_arguments +@common_alarm_arguments() @utils.arg('--period', type=int, metavar='', help='Length of each period (seconds) to evaluate over') @utils.arg('--evaluation-periods', type=int, metavar='', help='Number of periods to evaluate over') -@utils.arg('--meter-name', metavar='', required=True, +@utils.arg('--meter-name', metavar='', help='Metric to evaluate against') @utils.arg('--statistic', metavar='', help='Statistic to evaluate, one of: ' + str(STATISTICS)) @utils.arg('--comparison-operator', metavar='', help='Operator to compare with, one of: ' + str(ALARM_OPERATORS)) -@utils.arg('--threshold', type=float, metavar='', required=True, +@utils.arg('--threshold', type=float, metavar='', help='Threshold to evaluate against') @utils.arg('--matching-metadata', dest='matching_metadata', metavar='', action='append', default=None, @@ -314,9 +385,9 @@ def do_alarm_update(cc, args={}): @utils.arg('-a', '--alarm_id', metavar='', required=True, help='ID of the alarm to update.') -@common_alarm_arguments +@common_alarm_arguments() @utils.arg('--meter-name', metavar='', - dest='threshold_rule/meter_name', required=True, + dest='threshold_rule/meter_name', help='Metric to evaluate against') @utils.arg('--period', type=int, metavar='', dest='threshold_rule/period', @@ -330,7 +401,7 @@ def do_alarm_update(cc, args={}): @utils.arg('--comparison-operator', metavar='', dest='threshold_rule/comparison_operator', help='Operator to compare with, one of: ' + str(ALARM_OPERATORS)) -@utils.arg('--threshold', type=float, metavar='', required=True, +@utils.arg('--threshold', type=float, metavar='', dest='threshold_rule/threshold', help='Threshold to evaluate against') @utils.arg('-q', '--query', metavar='', @@ -355,9 +426,9 @@ def do_alarm_threshold_update(cc, args={}): @utils.arg('-a', '--alarm_id', metavar='', required=True, help='ID of the alarm to update.') -@common_alarm_arguments +@common_alarm_arguments() @utils.arg('--alarm_ids', action='append', metavar='', - dest='combination_rule/alarm_ids', required=True, + dest='combination_rule/alarm_ids', help='List of alarm id') @utils.arg('---operator', metavar='', dest='combination_rule/operator', @@ -380,8 +451,6 @@ def do_alarm_combination_update(cc, args={}): help='ID of the alarm to delete.') def do_alarm_delete(cc, args={}): '''Delete an alarm.''' - if args.alarm_id is None: - raise exc.CommandError('Alarm ID not provided (-a )') try: cc.alarms.delete(args.alarm_id) except exc.HTTPNotFound: @@ -392,28 +461,45 @@ def do_alarm_delete(cc, args={}): help='ID of the alarm state to set.') @utils.arg('--state', metavar='', required=True, help='State of the alarm, one of: ' + str(ALARM_STATES)) -def do_alarm_set_state(cc, args={}): +def do_alarm_state_set(cc, args={}): '''Set the state of an alarm.''' try: - cc.alarms.set_state(args.alarm_id, args.state) + state = cc.alarms.set_state(args.alarm_id, args.state) except exc.HTTPNotFound: raise exc.CommandError('Alarm not found: %s' % args.alarm_id) - state = cc.alarms.get_state(args.alarm_id) utils.print_dict({'state': state}, wrap=72) @utils.arg('-a', '--alarm_id', metavar='', required=True, help='ID of the alarm state to show.') -def do_alarm_get_state(cc, args={}): +def do_alarm_state_get(cc, args={}): '''Get the state of an alarm.''' try: - cc.alarms.set_state(args.alarm_id, args.state) + state = cc.alarms.get_state(args.alarm_id) except exc.HTTPNotFound: raise exc.CommandError('Alarm not found: %s' % args.alarm_id) - state = cc.alarms.get_state(args.alarm_id) utils.print_dict({'state': state}, wrap=72) +@utils.arg('-a', '--alarm_id', metavar='', required=True, + help='ID of the alarm for which history is shown.') +@utils.arg('-q', '--query', metavar='', + help='key[op]value; list.') +def do_alarm_history(cc, args={}): + '''Display the change history of an alarm.''' + kwargs = dict(alarm_id=args.alarm_id, + q=options.cli_to_array(args.query)) + try: + history = cc.alarms.get_history(**kwargs) + except exc.HTTPNotFound: + raise exc.CommandError('Alarm not found: %s' % args.alarm_id) + field_labels = ['Type', 'Timestamp', 'Detail'] + fields = ['type', 'timestamp', 'detail'] + utils.print_list(history, fields, field_labels, + formatters={'detail': alarm_change_detail_formatter}, + sortby=1) + + @utils.arg('-q', '--query', metavar='', help='key[op]value; list.') def do_resource_list(cc, args={}): @@ -426,12 +512,10 @@ def do_resource_list(cc, args={}): sortby=1) -@utils.arg('-r', '--resource_id', metavar='', +@utils.arg('-r', '--resource_id', metavar='', required=True, help='ID of the resource to show.') def do_resource_show(cc, args={}): '''Show the resource.''' - if args.resource_id is None: - raise exc.CommandError('Resource id not provided (-r )') try: resource = cc.resources.get(args.resource_id) except exc.HTTPNotFound: diff --git a/setup.py b/setup.py index 2a0786a..70c2b3f 100644 --- a/setup.py +++ b/setup.py @@ -18,5 +18,5 @@ import setuptools setuptools.setup( - setup_requires=['pbr>=0.5.21,<1.0'], + setup_requires=['pbr'], pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt index a3e4bfc..26843ce 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -6,6 +6,7 @@ hacking>=0.5.6,<0.8 coverage>=3.6 discover fixtures>=0.3.14 +mock>=1.0 mox>=0.5.3 python-subunit sphinx>=1.1.2