diff options
41 files changed, 1010 insertions, 84 deletions
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 87335db..80ea3b5 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,13 +1,13 @@ If you would like to contribute to the development of OpenStack, you must follow the steps in this page: - http://docs.openstack.org/infra/manual/developers.html + https://docs.openstack.org/infra/manual/developers.html Once those steps have been completed, changes to OpenStack should be submitted for review via the Gerrit tool, following the workflow documented at: - http://docs.openstack.org/infra/manual/developers.html#development-workflow + https://docs.openstack.org/infra/manual/developers.html#development-workflow Pull requests submitted through GitHub will be ignored. diff --git a/HACKING.rst b/HACKING.rst index 03844f1..fed3611 100644 --- a/HACKING.rst +++ b/HACKING.rst @@ -2,7 +2,7 @@ Cinder Client Style Commandments ================================ - Step 1: Read the OpenStack Style Commandments - http://docs.openstack.org/developer/hacking/ + https://docs.openstack.org/hacking/latest/ - Step 2: Read on Cinder Client Specific Commandments @@ -29,7 +29,7 @@ Release Notes - Cinder Client uses Reno for release notes management. See the `Reno Documentation`_ for more details on its usage. -.. _Reno Documentation: http://docs.openstack.org/developer/reno/ +.. _Reno Documentation: https://docs.openstack.org/reno/latest/ - As a quick example, when adding a new shell command for Awesome Storage Feature, one could perform the following steps to include a release note for the new feature: @@ -26,15 +26,15 @@ See the `OpenStack CLI Reference`_ for information on how to use the ``cinder`` command-line tool. You may also want to look at the `OpenStack API documentation`_. -.. _OpenStack CLI Reference: http://docs.openstack.org/cli-reference/overview.html -.. _OpenStack API documentation: http://developer.openstack.org/api-ref.html +.. _OpenStack CLI Reference: https://docs.openstack.org/python-openstackclient/latest/cli/ +.. _OpenStack API documentation: https://developer.openstack.org/api-guide/quick-start/ The project is hosted on `Launchpad`_, where bugs can be filed. The code is hosted on `OpenStack`_. Patches must be submitted using `Gerrit`_. .. _OpenStack: https://git.openstack.org/cgit/openstack/python-cinderclient .. _Launchpad: https://launchpad.net/python-cinderclient -.. _Gerrit: http://docs.openstack.org/infra/manual/developers.html#development-workflow +.. _Gerrit: https://docs.openstack.org/infra/manual/developers.html#development-workflow This code is a fork of `Jacobian's python-cloudservers`__. If you need API support for the Rackspace API solely or the BSD license, you should use that repository. @@ -52,12 +52,12 @@ __ https://github.com/jacobian-archive/python-cloudservers * `How to Contribute`_ .. _PyPi: https://pypi.python.org/pypi/python-cinderclient -.. _Online Documentation: http://docs.openstack.org/developer/python-cinderclient +.. _Online Documentation: https://docs.openstack.org/python-cinderclient/latest/ .. _Blueprints: https://blueprints.launchpad.net/python-cinderclient .. _Bugs: https://bugs.launchpad.net/python-cinderclient .. _Source: https://git.openstack.org/cgit/openstack/python-cinderclient -.. _How to Contribute: http://docs.openstack.org/infra/manual/developers.html -.. _Specs: http://specs.openstack.org/openstack/cinder-specs/ +.. _How to Contribute: https://docs.openstack.org/infra/manual/developers.html +.. _Specs: https://specs.openstack.org/openstack/cinder-specs/ .. contents:: Contents: @@ -366,4 +366,4 @@ Quick-start using keystone:: >>> nt.volumes.list() [...] -See release notes and more at `<http://docs.openstack.org/developer/python-cinderclient/>`_. +See release notes and more at `<https://docs.openstack.org/python-cinderclient/latest/>`_. diff --git a/cinderclient/_i18n.py b/cinderclient/_i18n.py index 1653da7..96c9246 100644 --- a/cinderclient/_i18n.py +++ b/cinderclient/_i18n.py @@ -14,7 +14,7 @@ """oslo.i18n integration module. -See http://docs.openstack.org/developer/oslo.i18n/usage.html . +See https://docs.openstack.org/oslo.i18n/latest/user/usage.html . """ diff --git a/cinderclient/api_versions.py b/cinderclient/api_versions.py index d1bb1e1..537b779 100644 --- a/cinderclient/api_versions.py +++ b/cinderclient/api_versions.py @@ -29,7 +29,7 @@ LOG = logging.getLogger(__name__) # key is a deprecated version and value is an alternative version. DEPRECATED_VERSIONS = {"1": "2"} DEPRECATED_VERSION = "2.0" -MAX_VERSION = "3.33" +MAX_VERSION = "3.40" MIN_VERSION = "3.0" _SUBSTITUTIONS = {} @@ -235,7 +235,14 @@ def get_api_version(version_string): def _get_server_version_range(client): - versions = client.services.server_api_version() + try: + versions = client.services.server_api_version() + except AttributeError: + # Wrong client was used, translate to something helpful. + raise exceptions.UnsupportedVersion( + _('Invalid client version %s to get server version range. Only ' + 'the v3 client is supported for this operation.') % + client.version) if not versions: return APIVersion(), APIVersion() diff --git a/cinderclient/base.py b/cinderclient/base.py index 88ab951..83f8731 100644 --- a/cinderclient/base.py +++ b/cinderclient/base.py @@ -115,12 +115,13 @@ class Manager(common_base.HookableMixin): # than osapi_max_limit, so we have to retrieve multiple times to # get the complete list. next = None - if 'volumes_links' in body: - volumes_links = body['volumes_links'] - if volumes_links: - for volumes_link in volumes_links: - if 'rel' in volumes_link and 'next' == volumes_link['rel']: - next = volumes_link['href'] + link_name = response_key + '_links' + if link_name in body: + links = body[link_name] + if links: + for link in links: + if 'rel' in link and 'next' == link['rel']: + next = link['href'] break if next: # As long as the 'next' link is not empty, keep requesting it diff --git a/cinderclient/client.py b/cinderclient/client.py index 485be9f..dabfcd3 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -78,6 +78,8 @@ def get_server_version(url): :returns: APIVersion object for min and max version supported by the server """ + min_version = "2.0" + current_version = "2.0" logger = logging.getLogger(__name__) try: @@ -87,19 +89,21 @@ def get_server_version(url): versions = data['versions'] for version in versions: if '3.' in version['version']: - return (api_versions.APIVersion(version['min_version']), - api_versions.APIVersion(version['version'])) + min_version = version['min_version'] + current_version = version['version'] + break except exceptions.ClientException as e: logger.warning("Error in server version query:%s\n" "Returning APIVersion 2.0", six.text_type(e.message)) - return api_versions.APIVersion("2.0"), api_versions.APIVersion("2.0") + return (api_versions.APIVersion(min_version), + api_versions.APIVersion(current_version)) def get_highest_client_server_version(url): + """Returns highest supported version by client and server as a string.""" min_server, max_server = get_server_version(url) - max_server_version = api_versions.APIVersion.get_string(max_server) - - return min(float(max_server_version), float(api_versions.MAX_VERSION)) + max_client = api_versions.APIVersion(api_versions.MAX_VERSION) + return min(max_server, max_client).get_string() def get_volume_api_from_url(url): @@ -149,6 +153,9 @@ class SessionClient(adapter.LegacyJsonAdapter): if raise_exc and resp.status_code >= 400: raise exceptions.from_response(resp, body) + if not self.global_request_id: + self.global_request_id = resp.headers.get('x-openstack-request-id') + return resp, body def _cs_request(self, url, method, **kwargs): diff --git a/cinderclient/exceptions.py b/cinderclient/exceptions.py index 43ae5a4..4f0227e 100644 --- a/cinderclient/exceptions.py +++ b/cinderclient/exceptions.py @@ -21,6 +21,29 @@ from datetime import datetime from oslo_utils import timeutils +class ResourceInErrorState(Exception): + """When resource is in Error state""" + def __init__(self, obj, fault_msg): + msg = "'%s' resource is in the error state" % obj.__class__.__name__ + if fault_msg: + msg += " due to '%s'" % fault_msg + self.message = "%s." % msg + + def __str__(self): + return self.message + + +class TimeoutException(Exception): + """When an action exceeds the timeout period to complete the action""" + def __init__(self, obj, action): + self.message = ("The '%(action)s' of the '%(object_name)s' exceeded " + "the timeout period." % {"action": action, + "object_name": obj.__class__.__name__}) + + def __str__(self): + return self.message + + class UnsupportedVersion(Exception): """Indicates that the user is trying to use an unsupported version of the API. diff --git a/cinderclient/shell_utils.py b/cinderclient/shell_utils.py index 4cb676b..e01b0cf 100644 --- a/cinderclient/shell_utils.py +++ b/cinderclient/shell_utils.py @@ -18,11 +18,11 @@ import sys import time from cinderclient import utils +from cinderclient import exceptions _quota_resources = ['volumes', 'snapshots', 'gigabytes', 'backups', 'backup_gigabytes', - 'consistencygroups', 'per_volume_gigabytes', - 'groups', ] + 'per_volume_gigabytes', 'groups', ] _quota_infos = ['Type', 'In_use', 'Reserved', 'Limit'] @@ -151,7 +151,9 @@ def extract_filters(args): (key, value) = f.split('=', 1) if value.startswith('{') and value.endswith('}'): value = _build_internal_dict(value[1:-1]) - filters[key] = value + filters[key] = value + else: + print("WARNING: Ignoring the filter %s while showing result." % f) return filters @@ -275,3 +277,36 @@ def print_qos_specs_and_associations_list(q_specs): def print_associations_list(associations): utils.print_list(associations, ['Association_Type', 'Name', 'ID']) + + +def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states, + timeout_period, global_request_id=None, messages=None, + poll_period=2, status_field="status"): + """Block while an action is being performed.""" + time_elapsed = 0 + while True: + time.sleep(poll_period) + time_elapsed += poll_period + obj = poll_fn(obj_id) + status = getattr(obj, status_field) + info[status_field] = status + if status: + status = status.lower() + + if status in final_ok_states: + break + elif status == "error": + utils.print_dict(info) + if global_request_id: + search_opts = { + 'request_id': global_request_id + } + message_list = messages.list(search_opts=search_opts) + try: + fault_msg = message_list[0].user_message + except IndexError: + fault_msg = "Unknown error. Operation failed." + raise exceptions.ResourceInErrorState(obj, fault_msg) + elif time_elapsed == timeout_period: + utils.print_dict(info) + raise exceptions.TimeoutException(obj, action) diff --git a/cinderclient/tests/functional/test_cli.py b/cinderclient/tests/functional/test_cli.py index 01083c4..8047459 100644 --- a/cinderclient/tests/functional/test_cli.py +++ b/cinderclient/tests/functional/test_cli.py @@ -17,7 +17,8 @@ from cinderclient.tests.functional import base class CinderVolumeTests(base.ClientTestBase): """Check of base cinder volume commands.""" - VOLUME_PROPERTY = ('attachments', 'availability_zone', 'bootable', + VOLUME_PROPERTY = ('attachment_ids', 'attached_servers', + 'availability_zone', 'bootable', 'created_at', 'description', 'encrypted', 'id', 'metadata', 'name', 'size', 'status', 'user_id', 'volume_type') diff --git a/cinderclient/tests/unit/test_api_versions.py b/cinderclient/tests/unit/test_api_versions.py index ef8f311..5f14c3e 100644 --- a/cinderclient/tests/unit/test_api_versions.py +++ b/cinderclient/tests/unit/test_api_versions.py @@ -15,8 +15,10 @@ import ddt import mock +import six from cinderclient import api_versions +from cinderclient import client as base_client from cinderclient import exceptions from cinderclient.v3 import client from cinderclient.tests.unit import utils @@ -259,3 +261,10 @@ class DiscoverVersionTestCase(utils.TestCase): highest_version = api_versions.get_highest_version(self.fake_client) self.assertEqual("3.14", highest_version.get_string()) self.assertTrue(self.fake_client.services.server_api_version.called) + + def test_get_highest_version_bad_client(self): + """Tests that we gracefully handle the wrong version of client.""" + v2_client = base_client.Client('2.0') + ex = self.assertRaises(exceptions.UnsupportedVersion, + api_versions.get_highest_version, v2_client) + self.assertIn('Invalid client version 2.0 to get', six.text_type(ex)) diff --git a/cinderclient/tests/unit/test_base.py b/cinderclient/tests/unit/test_base.py index ce8d9e4..d4dd517 100644 --- a/cinderclient/tests/unit/test_base.py +++ b/cinderclient/tests/unit/test_base.py @@ -126,6 +126,37 @@ class BaseTest(utils.TestCase): manager._build_list_url, **arguments) + def test__list_no_link(self): + api = mock.Mock() + api.client.get.return_value = (mock.sentinel.resp, + {'resp_keys': [{'name': '1'}]}) + manager = test_utils.FakeManager(api) + res = manager._list(mock.sentinel.url, 'resp_keys') + api.client.get.assert_called_once_with(mock.sentinel.url) + result = [r.name for r in res] + self.assertListEqual(['1'], result) + + def test__list_with_link(self): + api = mock.Mock() + api.client.get.side_effect = [ + (mock.sentinel.resp, + {'resp_keys': [{'name': '1'}], + 'resp_keys_links': [{'rel': 'next', 'href': mock.sentinel.u2}]}), + (mock.sentinel.resp, + {'resp_keys': [{'name': '2'}], + 'resp_keys_links': [{'rel': 'next', 'href': mock.sentinel.u3}]}), + (mock.sentinel.resp, + {'resp_keys': [{'name': '3'}], + 'resp_keys_links': [{'rel': 'next', 'href': None}]}), + ] + manager = test_utils.FakeManager(api) + res = manager._list(mock.sentinel.url, 'resp_keys') + api.client.get.assert_has_calls([mock.call(mock.sentinel.url), + mock.call(mock.sentinel.u2), + mock.call(mock.sentinel.u3)]) + result = [r.name for r in res] + self.assertListEqual(['1', '2', '3'], result) + class ListWithMetaTest(utils.TestCase): def test_list_with_meta(self): diff --git a/cinderclient/tests/unit/test_client.py b/cinderclient/tests/unit/test_client.py index 8048180..bd3b3f6 100644 --- a/cinderclient/tests/unit/test_client.py +++ b/cinderclient/tests/unit/test_client.py @@ -14,6 +14,7 @@ import json import logging +import ddt import fixtures from keystoneauth1 import adapter from keystoneauth1 import exceptions as keystone_exception @@ -130,9 +131,11 @@ class ClientTest(utils.TestCase): "code": 202 } + request_id = "req-f551871a-4950-4225-9b2c-29a14c8f075e" mock_response = utils.TestResponse({ "status_code": 202, "text": six.b(json.dumps(resp)), + "headers": {"x-openstack-request-id": request_id}, }) # 'request' method of Adaptor will return 202 response @@ -309,9 +312,26 @@ class ClientTestSensitiveInfo(utils.TestCase): self.assertNotIn(auth_password, output[1], output) +@ddt.ddt class GetAPIVersionTestCase(utils.TestCase): @mock.patch('cinderclient.client.requests.get') + def test_get_server_version_v2(self, mock_request): + + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get_no_v3()) + }) + + mock_request.return_value = mock_response + + url = "http://192.168.122.127:8776/v2/e5526285ebd741b1819393f772f11fc3" + + min_version, max_version = cinderclient.client.get_server_version(url) + self.assertEqual(api_versions.APIVersion('2.0'), min_version) + self.assertEqual(api_versions.APIVersion('2.0'), max_version) + + @mock.patch('cinderclient.client.requests.get') def test_get_server_version(self, mock_request): mock_response = utils.TestResponse({ @@ -334,7 +354,8 @@ class GetAPIVersionTestCase(utils.TestCase): self.assertEqual(max_version, api_versions.APIVersion('3.16')) @mock.patch('cinderclient.client.requests.get') - def test_get_highest_client_server_version(self, mock_request): + @ddt.data('3.12', '3.40') + def test_get_highest_client_server_version(self, version, mock_request): mock_response = utils.TestResponse({ "status_code": 200, @@ -345,9 +366,8 @@ class GetAPIVersionTestCase(utils.TestCase): url = "http://192.168.122.127:8776/v3/e5526285ebd741b1819393f772f11fc3" - highest = cinderclient.client.get_highest_client_server_version(url) - current_client_MAX_VERSION = float(api_versions.MAX_VERSION) - if current_client_MAX_VERSION > 3.16: - self.assertEqual(3.16, highest) - else: - self.assertEqual(current_client_MAX_VERSION, highest) + with mock.patch.object(api_versions, 'MAX_VERSION', version): + highest = ( + cinderclient.client.get_highest_client_server_version(url)) + expected = version if version == '3.12' else '3.16' + self.assertEqual(expected, highest) diff --git a/cinderclient/tests/unit/test_utils.py b/cinderclient/tests/unit/test_utils.py index a2d2256..7f39f06 100644 --- a/cinderclient/tests/unit/test_utils.py +++ b/cinderclient/tests/unit/test_utils.py @@ -34,7 +34,7 @@ UUID = '8e8ec658-c7b0-4243-bdf8-6f7f2952c0d0' class FakeResource(object): NAME_ATTR = 'name' - def __init__(self, _id, properties): + def __init__(self, _id, properties, **kwargs): self.id = _id try: self.name = properties['name'] diff --git a/cinderclient/tests/unit/v2/fakes.py b/cinderclient/tests/unit/v2/fakes.py index a21b7b2..a4c5d39 100644 --- a/cinderclient/tests/unit/v2/fakes.py +++ b/cinderclient/tests/unit/v2/fakes.py @@ -28,7 +28,8 @@ REQUEST_ID = 'req-test-request-id' def _stub_volume(*args, **kwargs): volume = { "migration_status": None, - "attachments": [{u'server_id': u'1234'}], + "attachments": [{u'server_id': u'1234', u'id': + u'3f88836f-adde-4296-9f6b-2c59a0bcda9a'}], "links": [ { "href": "http://localhost/v2/fake/volumes/1234", @@ -535,6 +536,8 @@ class FakeHTTPClient(base_client.HTTPClient): elif action == 'os-volume_upload_image': assert 'image_name' in body[action] _body = body + elif action == 'revert': + assert 'snapshot_id' in body[action] else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) @@ -625,7 +628,6 @@ class FakeHTTPClient(base_client.HTTPClient): 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 1, 'per_volume_gigabytes': 1, }}) def get_os_quota_sets_test_defaults(self): @@ -637,7 +639,6 @@ class FakeHTTPClient(base_client.HTTPClient): 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 1, 'per_volume_gigabytes': 1, }}) def put_os_quota_sets_test(self, body, **kw): @@ -652,7 +653,6 @@ class FakeHTTPClient(base_client.HTTPClient): 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 2, 'per_volume_gigabytes': 1, }}) def delete_os_quota_sets_1234(self, **kw): diff --git a/cinderclient/tests/unit/v2/test_quotas.py b/cinderclient/tests/unit/v2/test_quotas.py index 6dd20b0..bb29e4d 100644 --- a/cinderclient/tests/unit/v2/test_quotas.py +++ b/cinderclient/tests/unit/v2/test_quotas.py @@ -41,7 +41,6 @@ class QuotaSetsTest(utils.TestCase): q.update(gigabytes=2000) q.update(backups=2) q.update(backup_gigabytes=2000) - q.update(consistencygroups=2) q.update(per_volume_gigabytes=100) cs.assert_called('PUT', '/os-quota-sets/test') self._assert_request_id(q) @@ -54,7 +53,6 @@ class QuotaSetsTest(utils.TestCase): self.assertEqual(q.gigabytes, q2.gigabytes) self.assertEqual(q.backups, q2.backups) self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) - self.assertEqual(q.consistencygroups, q2.consistencygroups) self.assertEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) q2.volumes = 0 self.assertNotEqual(q.volumes, q2.volumes) @@ -66,8 +64,6 @@ class QuotaSetsTest(utils.TestCase): self.assertNotEqual(q.backups, q2.backups) q2.backup_gigabytes = 0 self.assertNotEqual(q.backup_gigabytes, q2.backup_gigabytes) - q2.consistencygroups = 0 - self.assertNotEqual(q.consistencygroups, q2.consistencygroups) q2.per_volume_gigabytes = 0 self.assertNotEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) q2.get() @@ -76,7 +72,6 @@ class QuotaSetsTest(utils.TestCase): self.assertEqual(q.gigabytes, q2.gigabytes) self.assertEqual(q.backups, q2.backups) self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) - self.assertEqual(q.consistencygroups, q2.consistencygroups) self.assertEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) self._assert_request_id(q) self._assert_request_id(q2) diff --git a/cinderclient/tests/unit/v3/fakes.py b/cinderclient/tests/unit/v3/fakes.py index 25a8151..04c5aa0 100644 --- a/cinderclient/tests/unit/v3/fakes.py +++ b/cinderclient/tests/unit/v3/fakes.py @@ -77,8 +77,9 @@ class FakeClient(fakes.FakeClient, client.Client): 'project_id', 'auth_url', extensions=kwargs.get('extensions')) self.api_version = api_version + global_id = "req-f551871a-4950-4225-9b2c-29a14c8f075e" self.client = FakeHTTPClient(api_version=api_version, - **kwargs) + global_request_id=global_id, **kwargs) def get_volume_api_version_from_endpoint(self): return self.client.get_volume_api_version_from_endpoint() @@ -369,6 +370,9 @@ class FakeHTTPClient(fake_v2.FakeHTTPClient): action = list(body)[0] if action == 'delete': assert 'delete-volumes' in body[action] + elif action in ('enable_replication', 'disable_replication', + 'failover_replication', 'list_replication_targets'): + assert action in body else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, {}) @@ -544,6 +548,25 @@ class FakeHTTPClient(fake_v2.FakeHTTPClient): } return 200, {}, {'message': message} + def put_os_services_set_log(self, body): + return (202, {}, {}) + + def put_os_services_get_log(self, body): + levels = [{'binary': 'cinder-api', 'host': 'host1', + 'levels': {'prefix1': 'DEBUG', 'prefix2': 'INFO'}}, + {'binary': 'cinder-volume', 'host': 'host@backend#pool', + 'levels': {'prefix3': 'WARNING', 'prefix4': 'ERROR'}}] + return (200, {}, {'log_levels': levels}) + + def get_volumes_summary(self, **kw): + return 200, {}, {"volume-summary": {'total_size': 5, + 'total_count': 5, + 'metadata': { + "test_key": ["test_value"] + } + } + } + # # resource filters # @@ -589,3 +612,31 @@ def fake_request_get(): 'updated': '2016-02-08T12:20:21Z', 'version': '3.16'}]} return versions + + +def fake_request_get_no_v3(): + versions = {'versions': [{'id': 'v1.0', + 'links': [{'href': 'http://docs.openstack.org/', + 'rel': 'describedby', + 'type': 'text/html'}, + {'href': 'http://192.168.122.197/v1/', + 'rel': 'self'}], + 'media-types': [{'base': 'application/json', + 'type': 'application/'}], + 'min_version': '', + 'status': 'DEPRECATED', + 'updated': '2016-05-02T20:25:19Z', + 'version': ''}, + {'id': 'v2.0', + 'links': [{'href': 'http://docs.openstack.org/', + 'rel': 'describedby', + 'type': 'text/html'}, + {'href': 'http://192.168.122.197/v2/', + 'rel': 'self'}], + 'media-types': [{'base': 'application/json', + 'type': 'application/'}], + 'min_version': '', + 'status': 'SUPPORTED', + 'updated': '2014-06-28T12:20:21Z', + 'version': ''}]} + return versions diff --git a/cinderclient/tests/unit/v3/test_groups.py b/cinderclient/tests/unit/v3/test_groups.py index 72e72a0..d74e2a0 100644 --- a/cinderclient/tests/unit/v3/test_groups.py +++ b/cinderclient/tests/unit/v3/test_groups.py @@ -158,3 +158,57 @@ class GroupsTest(utils.TestCase): cs.assert_called('POST', '/groups/action', body=expected) self._assert_request_id(grp) + + def test_enable_replication_group(self): + expected = {'enable_replication': {}} + g0 = cs.groups.list()[0] + grp = g0.enable_replication() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.enable_replication('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.enable_replication(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + + def test_disable_replication_group(self): + expected = {'disable_replication': {}} + g0 = cs.groups.list()[0] + grp = g0.disable_replication() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.disable_replication('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.disable_replication(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + + def test_failover_replication_group(self): + expected = {'failover_replication': + {'allow_attached_volume': False, + 'secondary_backend_id': None}} + g0 = cs.groups.list()[0] + grp = g0.failover_replication() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.failover_replication('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.failover_replication(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + + def test_list_replication_targets(self): + expected = {'list_replication_targets': {}} + g0 = cs.groups.list()[0] + grp = g0.list_replication_targets() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.list_replication_targets('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.list_replication_targets(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) diff --git a/cinderclient/tests/unit/v3/test_services.py b/cinderclient/tests/unit/v3/test_services.py index ac45298..92f7903 100644 --- a/cinderclient/tests/unit/v3/test_services.py +++ b/cinderclient/tests/unit/v3/test_services.py @@ -41,3 +41,45 @@ class ServicesTest(utils.TestCase): client = fakes.FakeClient(version_header='3.0') svs = client.services.server_api_version() [self.assertIsInstance(s, services.Service) for s in svs] + + def test_set_log_levels(self): + expected = {'level': 'debug', 'binary': 'cinder-api', + 'server': 'host1', 'prefix': 'sqlalchemy.'} + + cs = fakes.FakeClient(version_header='3.32') + cs.services.set_log_levels(expected['level'], expected['binary'], + expected['server'], expected['prefix']) + + cs.assert_called('PUT', '/os-services/set-log', body=expected) + + def test_get_log_levels(self): + expected = {'binary': 'cinder-api', 'server': 'host1', + 'prefix': 'sqlalchemy.'} + + cs = fakes.FakeClient(version_header='3.32') + result = cs.services.get_log_levels(expected['binary'], + expected['server'], + expected['prefix']) + + cs.assert_called('PUT', '/os-services/get-log', body=expected) + expected = [services.LogLevel(cs.services, + {'binary': 'cinder-api', 'host': 'host1', + 'prefix': 'prefix1', 'level': 'DEBUG'}, + loaded=True), + services.LogLevel(cs.services, + {'binary': 'cinder-api', 'host': 'host1', + 'prefix': 'prefix2', 'level': 'INFO'}, + loaded=True), + services.LogLevel(cs.services, + {'binary': 'cinder-volume', + 'host': 'host@backend#pool', + 'prefix': 'prefix3', + 'level': 'WARNING'}, + loaded=True), + services.LogLevel(cs.services, + {'binary': 'cinder-volume', + 'host': 'host@backend#pool', + 'prefix': 'prefix4', 'level': 'ERROR'}, + loaded=True)] + # Since it will be sorted by the prefix we can compare them directly + self.assertListEqual(expected, result) diff --git a/cinderclient/tests/unit/v3/test_shell.py b/cinderclient/tests/unit/v3/test_shell.py index 0b9431b..9221af8 100644 --- a/cinderclient/tests/unit/v3/test_shell.py +++ b/cinderclient/tests/unit/v3/test_shell.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # @@ -13,15 +14,44 @@ # License for the specific language governing permissions and limitations # under the License. + +# NOTE(geguileo): For v3 we cannot mock any of the following methods +# - utils.find_volume +# - shell_utils.find_backup +# - shell_utils.find_volume_snapshot +# - shell_utils.find_group +# - shell_utils.find_group_snapshot +# because we are caching them in cinderclient.v3.shell:RESET_STATE_RESOURCES +# which means that our tests could fail depending on the mocking and loading +# order. +# +# Alternatives are: +# - Mock utils.find_resource when we have only 1 call to that method +# - Use an auxiliary method that will call original method for irrelevant +# calls. Example from test_revert_to_snapshot: +# original = client_utils.find_resource +# +# def find_resource(manager, name_or_id, **kwargs): +# if isinstance(manager, volume_snapshots.SnapshotManager): +# return volume_snapshots.Snapshot(self, +# {'id': '5678', +# 'volume_id': '1234'}) +# return original(manager, name_or_id, **kwargs) + import ddt import fixtures import mock from requests_mock.contrib import fixture as requests_mock_fixture +import six +import cinderclient from cinderclient import client from cinderclient import exceptions from cinderclient import shell +from cinderclient import utils as cinderclient_utils +from cinderclient import base from cinderclient.v3 import volumes +from cinderclient.v3 import volume_snapshots from cinderclient.tests.unit import utils from cinderclient.tests.unit.v3 import fakes from cinderclient.tests.unit.fixture_data import keystone_client @@ -99,6 +129,10 @@ class ShellTest(utils.TestCase): 'list --filters name~=456', 'expected': '/volumes/detail?name%7E=456'}, + {'command': + u'list --filters name~=Σ', + 'expected': + '/volumes/detail?name%7E=%CE%A3'}, # testcases for list group {'command': 'group-list --filters name=456', @@ -197,6 +231,10 @@ class ShellTest(utils.TestCase): # NOTE(jdg): we default to detail currently self.assert_called('GET', '/volumes/detail') + def test_summary(self): + self.run_command('--os-volume-api-version 3.12 summary') + self.assert_called('GET', '/volumes/summary') + def test_list_with_group_id_before_3_10(self): self.assertRaises(exceptions.UnsupportedAttribute, self.run_command, @@ -241,15 +279,36 @@ class ShellTest(utils.TestCase): 'mountpoint': '/123', 'initiator': 'aabbccdd', 'platform': 'x86_xx'}, + 'volume_uuid': '1234'}}, + {'cmd': 'abc 1233', + 'body': {'instance_uuid': '1233', + 'connector': {}, 'volume_uuid': '1234'}}) + @mock.patch('cinderclient.utils.find_resource') @ddt.unpack - def test_attachment_create(self, cmd, body): + def test_attachment_create(self, mock_find_volume, cmd, body): + mock_find_volume.return_value = volumes.Volume(self, + {'id': '1234'}, + loaded=True) command = '--os-volume-api-version 3.27 attachment-create ' command += cmd self.run_command(command) expected = {'attachment': body} + self.assertTrue(mock_find_volume.called) self.assert_called('POST', '/attachments', body=expected) + @mock.patch.object(volumes.VolumeManager, 'findall') + def test_attachment_create_duplicate_name_vol(self, mock_findall): + found = [volumes.Volume(self, {'id': '7654', 'name': 'abc'}, + loaded=True), + volumes.Volume(self, {'id': '9876', 'name': 'abc'}, + loaded=True)] + mock_findall.return_value = found + self.assertRaises(exceptions.CommandError, + self.run_command, + '--os-volume-api-version 3.27 ' + 'attachment-create abc 789') + @ddt.data({'cmd': '', 'expected': ''}, {'cmd': '--volume-id 1234', @@ -273,6 +332,24 @@ class ShellTest(utils.TestCase): self.run_command(command) self.assert_called('GET', '/attachments%s' % expected) + def test_revert_to_snapshot(self): + original = cinderclient_utils.find_resource + + def find_resource(manager, name_or_id, **kwargs): + if isinstance(manager, volume_snapshots.SnapshotManager): + return volume_snapshots.Snapshot(self, + {'id': '5678', + 'volume_id': '1234'}) + return original(manager, name_or_id, **kwargs) + + with mock.patch('cinderclient.utils.find_resource', + side_effect=find_resource): + self.run_command( + '--os-volume-api-version 3.40 revert-to-snapshot 5678') + + self.assert_called('POST', '/volumes/1234/action', + body={'revert': {'snapshot_id': '5678'}}) + def test_attachment_show(self): self.run_command('--os-volume-api-version 3.27 attachment-show 1234') self.assert_called('GET', '/attachments/1234') @@ -712,7 +789,7 @@ class ShellTest(utils.TestCase): self.assert_called_anytime('DELETE', '/messages/1234') self.assert_called_anytime('DELETE', '/messages/12345') - @mock.patch('cinderclient.utils.find_volume') + @mock.patch('cinderclient.utils.find_resource') def test_delete_metadata(self, mock_find_volume): mock_find_volume.return_value = volumes.Volume(self, {'id': '1234', @@ -736,3 +813,180 @@ class ShellTest(utils.TestCase): command += ' --withreplication %s' % replication self.run_command(command) self.assert_called('GET', '/os-services') + + def test_group_enable_replication(self): + cmd = '--os-volume-api-version 3.38 group-enable-replication 1234' + self.run_command(cmd) + expected = {'enable_replication': {}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + def test_group_disable_replication(self): + cmd = '--os-volume-api-version 3.38 group-disable-replication 1234' + self.run_command(cmd) + expected = {'disable_replication': {}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + @ddt.data((False, None), (True, None), + (False, "backend1"), (True, "backend1"), + (False, "default"), (True, "default")) + @ddt.unpack + def test_group_failover_replication(self, attach_vol, backend): + attach = '--allow-attached-volume ' if attach_vol else '' + backend_id = ('--secondary-backend-id ' + backend) if backend else '' + cmd = ('--os-volume-api-version 3.38 group-failover-replication 1234 ' + + attach + backend_id) + self.run_command(cmd) + expected = {'failover_replication': + {'allow_attached_volume': attach_vol, + 'secondary_backend_id': backend if backend else None}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + def test_group_list_replication_targets(self): + cmd = ('--os-volume-api-version 3.38 group-list-replication-targets' + ' 1234') + self.run_command(cmd) + expected = {'list_replication_targets': {}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') + def test_service_get_log_before_3_32(self, get_levels_mock): + self.assertRaises(SystemExit, + self.run_command, '--os-volume-api-version 3.28 ' + 'service-get-log') + get_levels_mock.assert_not_called() + + @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') + @mock.patch('cinderclient.utils.print_list') + def test_service_get_log_no_params(self, print_mock, get_levels_mock): + self.run_command('--os-volume-api-version 3.32 service-get-log') + get_levels_mock.assert_called_once_with('', '', '') + print_mock.assert_called_once_with(get_levels_mock.return_value, + ('Binary', 'Host', 'Prefix', + 'Level')) + + @ddt.data('*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup') + @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') + @mock.patch('cinderclient.utils.print_list') + def test_service_get_log(self, binary, print_mock, get_levels_mock): + server = 'host1' + prefix = 'sqlalchemy' + + self.run_command('--os-volume-api-version 3.32 service-get-log ' + '--binary %s --server %s --prefix %s' % ( + binary, server, prefix)) + get_levels_mock.assert_called_once_with(binary, server, prefix) + print_mock.assert_called_once_with(get_levels_mock.return_value, + ('Binary', 'Host', 'Prefix', + 'Level')) + + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + def test_service_set_log_before_3_32(self, set_levels_mock): + self.assertRaises(SystemExit, + self.run_command, '--os-volume-api-version 3.28 ' + 'service-set-log debug') + set_levels_mock.assert_not_called() + + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + @mock.patch('cinderclient.shell.CinderClientArgumentParser.error') + def test_service_set_log_missing_required(self, error_mock, + set_levels_mock): + error_mock.side_effect = SystemExit + self.assertRaises(SystemExit, + self.run_command, '--os-volume-api-version 3.32 ' + 'service-set-log') + set_levels_mock.assert_not_called() + # Different error message from argparse library in Python 2 and 3 + if six.PY3: + msg = 'the following arguments are required: <log-level>' + else: + msg = 'too few arguments' + error_mock.assert_called_once_with(msg) + + @ddt.data('debug', 'DEBUG', 'info', 'INFO', 'warning', 'WARNING', 'error', + 'ERROR') + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + def test_service_set_log_min_params(self, level, set_levels_mock): + self.run_command('--os-volume-api-version 3.32 ' + 'service-set-log %s' % level) + set_levels_mock.assert_called_once_with(level, '', '', '') + + @ddt.data('*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup') + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + def test_service_set_log_levels(self, binary, set_levels_mock): + level = 'debug' + server = 'host1' + prefix = 'sqlalchemy.' + self.run_command('--os-volume-api-version 3.32 ' + 'service-set-log %s --binary %s --server %s ' + '--prefix %s' % (level, binary, server, prefix)) + set_levels_mock.assert_called_once_with(level, binary, server, prefix) + + @mock.patch('cinderclient.shell_utils._poll_for_status') + def test_create_with_poll(self, poll_method): + self.run_command('create --poll 1') + self.assert_called_anytime('GET', '/volumes/1234') + volume = self.shell.cs.volumes.get('1234') + info = dict() + info.update(volume._info) + info.pop('links', None) + self.assertEqual(1, poll_method.call_count) + timeout_period = 3600 + poll_method.assert_has_calls([mock.call(self.shell.cs.volumes.get, + 1234, info, 'creating', ['available'], timeout_period, + self.shell.cs.client.global_request_id, + self.shell.cs.messages)]) + + @mock.patch('cinderclient.shell_utils.time') + def test_poll_for_status(self, mock_time): + poll_period = 2 + some_id = "some-id" + global_request_id = "req-someid" + action = "some" + updated_objects = ( + base.Resource(None, info={"not_default_field": "creating"}), + base.Resource(None, info={"not_default_field": "available"})) + poll_fn = mock.MagicMock(side_effect=updated_objects) + cinderclient.shell_utils._poll_for_status( + poll_fn = poll_fn, + obj_id = some_id, + global_request_id = global_request_id, + messages = base.Resource(None, {}), + info = {}, + action = action, + status_field = "not_default_field", + final_ok_states = ['available'], + timeout_period=3600) + self.assertEqual([mock.call(poll_period)] * 2, + mock_time.sleep.call_args_list) + self.assertEqual([mock.call(some_id)] * 2, poll_fn.call_args_list) + + @mock.patch('cinderclient.v3.messages.MessageManager.list') + @mock.patch('cinderclient.shell_utils.time') + def test_poll_for_status_error(self, mock_time, mock_message_list): + poll_period = 2 + some_id = "some_id" + global_request_id = "req-someid" + action = "some" + updated_objects = ( + base.Resource(None, info={"not_default_field": "creating"}), + base.Resource(None, info={"not_default_field": "error"})) + poll_fn = mock.MagicMock(side_effect=updated_objects) + msg_object = base.Resource(cinderclient.v3.messages.MessageManager, + info = {"user_message": "ERROR!"}) + mock_message_list.return_value = (msg_object,) + self.assertRaises(exceptions.ResourceInErrorState, + cinderclient.shell_utils._poll_for_status, + poll_fn=poll_fn, + obj_id=some_id, + global_request_id=global_request_id, + messages=cinderclient.v3.messages.MessageManager(api=3.34), + info=dict(), + action=action, + final_ok_states=['available'], + status_field="not_default_field", + timeout_period=3600) + self.assertEqual([mock.call(poll_period)] * 2, + mock_time.sleep.call_args_list) + self.assertEqual([mock.call(some_id)] * 2, poll_fn.call_args_list) diff --git a/cinderclient/tests/unit/v3/test_volumes.py b/cinderclient/tests/unit/v3/test_volumes.py index 3a84bf3..72ef916 100644 --- a/cinderclient/tests/unit/v3/test_volumes.py +++ b/cinderclient/tests/unit/v3/test_volumes.py @@ -18,9 +18,11 @@ import ddt from cinderclient import api_versions +from cinderclient import exceptions from cinderclient.tests.unit import utils from cinderclient.tests.unit.v3 import fakes from cinderclient.v3 import volumes +from cinderclient.v3 import volume_snapshots from six.moves.urllib import parse @@ -48,6 +50,28 @@ class VolumesTest(utils.TestCase): visibility='public', protected=True) cs.assert_called_anytime('POST', '/volumes/1234/action', body=expected) + @ddt.data('3.39', '3.40') + def test_revert_to_snapshot(self, version): + + api_version = api_versions.APIVersion(version) + cs = fakes.FakeClient(api_version) + manager = volumes.VolumeManager(cs) + fake_snapshot = volume_snapshots.Snapshot( + manager, {'id': 12345, 'name': 'fake-snapshot'}, loaded=True) + fake_volume = volumes.Volume(manager, + {'id': 1234, 'name': 'sample-volume'}, + loaded=True) + expected = {'revert': {'snapshot_id': 12345}} + + if version == '3.40': + fake_volume.revert_to_snapshot(fake_snapshot) + + cs.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + else: + self.assertRaises(exceptions.VersionNotFoundForAPIMethod, + fake_volume.revert_to_snapshot, fake_snapshot) + def test_create_volume(self): vol = cs.volumes.create(1, group_id='1234', volume_type='5678') expected = {'volume': {'status': 'creating', @@ -70,6 +94,14 @@ class VolumesTest(utils.TestCase): cs.assert_called('POST', '/volumes', body=expected) self._assert_request_id(vol) + @ddt.data((False, '/volumes/summary'), + (True, '/volumes/summary?all_tenants=True')) + def test_volume_summary(self, all_tenants_input): + all_tenants, url = all_tenants_input + cs = fakes.FakeClient(api_versions.APIVersion('3.12')) + cs.volumes.summary(all_tenants=all_tenants) + cs.assert_called('GET', url) + def test_volume_list_manageable(self): cs = fakes.FakeClient(api_versions.APIVersion('3.8')) cs.volumes.list_manageable('host1', detailed=False) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 2c68d6d..e1b757f 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -32,6 +32,18 @@ from cinderclient import utils from cinderclient.v2 import availability_zones +def _translate_attachments(info): + attachments = [] + attached_servers = [] + for attachment in info['attachments']: + attachments.append(attachment['id']) + attached_servers.append(attachment['server_id']) + info.pop('attachments', None) + info['attachment_ids'] = attachments + info['attached_servers'] = attached_servers + return info + + @utils.arg('--all-tenants', dest='all_tenants', metavar='<0|1>', @@ -188,9 +200,10 @@ def do_show(cs, args): info['readonly'] = info['metadata']['readonly'] info.pop('links', None) + info = _translate_attachments(info) utils.print_dict(info, formatters=['metadata', 'volume_image_metadata', - 'attachments']) + 'attachment_ids', 'attached_servers']) class CheckSizeArgForCreate(argparse.Action): @@ -340,6 +353,7 @@ def do_create(cs, args): info['readonly'] = info['metadata']['readonly'] info.pop('links', None) + info = _translate_attachments(info) utils.print_dict(info) @@ -717,6 +731,8 @@ def do_snapshot_rename(cs, args): raise exceptions.ClientException(code=1, message=msg) shell_utils.find_volume_snapshot(cs, args.snapshot).update(**kwargs) + print("Request to rename snapshot '%s' has been accepted." % ( + args.snapshot)) @utils.arg('snapshot', metavar='<snapshot>', nargs='+', @@ -976,10 +992,6 @@ def do_quota_defaults(cs, args): metavar='<backup_gigabytes>', type=int, default=None, help='The new "backup_gigabytes" quota value. Default=None.') -@utils.arg('--consistencygroups', - metavar='<consistencygroups>', - type=int, default=None, - help='The new "consistencygroups" quota value. Default=None.') @utils.arg('--volume-type', metavar='<volume_type_name>', default=None, @@ -1397,6 +1409,7 @@ def do_backup_reset_state(cs, args): for backup in args.backup: try: shell_utils.find_backup(cs, backup).reset_state(args.state) + print("Request to update backup '%s' has been accepted." % backup) except Exception as e: failure_count += 1 msg = "Reset state for backup %s failed: %s" % (backup, e) @@ -2236,6 +2249,8 @@ def do_consisgroup_update(cs, args): shell_utils.find_consistencygroup( cs, args.consistencygroup).update(**kwargs) + print("Request to update consistency group '%s' has been accepted." % ( + args.consistencygroup)) @utils.arg('--all-tenants', diff --git a/cinderclient/v2/volumes.py b/cinderclient/v2/volumes.py index 02b86ee..b4e950b 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v2/volumes.py @@ -33,7 +33,17 @@ class Volume(base.Resource): return self.manager.update(self, **kwargs) def attach(self, instance_uuid, mountpoint, mode='rw', host_name=None): - """Set attachment metadata. + """Inform Cinder that the given volume is attached to the given instance. + + Calling this method will not actually ask Cinder to attach + a volume, but to mark it on the DB as attached. If the volume + is not actually attached to the given instance, inconsistent + data will result. + + The right flow of calls is : + 1- call reserve + 2- call initialize_connection + 3- call attach :param instance_uuid: uuid of the attaching instance. :param mountpoint: mountpoint on the attaching instance or host. @@ -44,7 +54,18 @@ class Volume(base.Resource): host_name) def detach(self): - """Clear attachment metadata.""" + """Inform Cinder that the given volume is detached from the given instance. + + Calling this method will not actually ask Cinder to detach + a volume, but to mark it on the DB as detached. If the volume + is not actually detached from the given instance, inconsistent + data will result. + + The right flow of calls is : + 1- call reserve + 2- call initialize_connection + 3- call detach + """ return self.manager.detach(self) def reserve(self, volume): diff --git a/cinderclient/v3/groups.py b/cinderclient/v3/groups.py index 386a6a3..6bc298a 100644 --- a/cinderclient/v3/groups.py +++ b/cinderclient/v3/groups.py @@ -39,6 +39,25 @@ class Group(base.Resource): """Reset the group's state with specified one""" return self.manager.reset_state(self, state) + def enable_replication(self): + """Enables replication for this group.""" + return self.manager.enable_replication(self) + + def disable_replication(self): + """Disables replication for this group.""" + return self.manager.disable_replication(self) + + def failover_replication(self, allow_attached_volume=False, + secondary_backend_id=None): + """Fails over replication for this group.""" + return self.manager.failover_replication(self, + allow_attached_volume, + secondary_backend_id) + + def list_replication_targets(self): + """Lists replication targets for this group.""" + return self.manager.list_replication_targets(self) + class GroupManager(base.ManagerWithFind): """Manage :class:`Group` resources.""" @@ -180,3 +199,55 @@ class GroupManager(base.ManagerWithFind): url = '/groups/%s/action' % base.getid(group) resp, body = self.api.client.post(url, body=body) return common_base.TupleWithMeta((resp, body), resp) + + def enable_replication(self, group): + """Enables replication for a group. + + :param group: the :class:`Group` to enable replication. + """ + body = {'enable_replication': {}} + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + def disable_replication(self, group): + """disables replication for a group. + + :param group: the :class:`Group` to disable replication. + """ + body = {'disable_replication': {}} + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + def failover_replication(self, group, allow_attached_volume=False, + secondary_backend_id=None): + """fails over replication for a group. + + :param group: the :class:`Group` to failover. + :param allow attached volumes: allow attached volumes in the group. + :param secondary_backend_id: secondary backend id. + """ + body = { + 'failover_replication': { + 'allow_attached_volume': allow_attached_volume, + 'secondary_backend_id': secondary_backend_id + } + } + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + def list_replication_targets(self, group): + """List replication targets for a group. + + :param group: the :class:`Group` to list replication targets. + """ + body = {'list_replication_targets': {}} + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v3/services.py b/cinderclient/v3/services.py index bd8a6c4..55d3ee4 100644 --- a/cinderclient/v3/services.py +++ b/cinderclient/v3/services.py @@ -18,11 +18,18 @@ service interface """ from cinderclient import api_versions +from cinderclient import base from cinderclient.v2 import services Service = services.Service +class LogLevel(base.Resource): + def __repr__(self): + return '<LogLevel: binary=%s host=%s prefix=%s level=%s>' % ( + self.binary, self.host, self.prefix, self.level) + + class ServiceManager(services.ServiceManager): @api_versions.wraps("3.0") def server_api_version(self): @@ -36,3 +43,25 @@ class ServiceManager(services.ServiceManager): return self._get_with_base_url("", response_key='versions') except LookupError: return [] + + @api_versions.wraps("3.32") + def set_log_levels(self, level, binary, server, prefix): + """Set log level for services.""" + body = {'level': level, 'binary': binary, 'server': server, + 'prefix': prefix} + return self._update("/os-services/set-log", body) + + @api_versions.wraps("3.32") + def get_log_levels(self, binary, server, prefix): + """Get log levels for services.""" + body = {'binary': binary, 'server': server, 'prefix': prefix} + response = self._update("/os-services/get-log", body) + + log_levels = [] + for entry in response['log_levels']: + entry_levels = sorted(entry['levels'].items(), key=lambda x: x[0]) + for prefix, level in entry_levels: + log_dict = {'binary': entry['binary'], 'host': entry['host'], + 'prefix': prefix, 'level': level} + log_levels.append(LogLevel(self, log_dict, loaded=True)) + return log_levels diff --git a/cinderclient/v3/shell.py b/cinderclient/v3/shell.py index 6a1e3b9..f47b83c 100644 --- a/cinderclient/v3/shell.py +++ b/cinderclient/v3/shell.py @@ -19,6 +19,7 @@ from __future__ import print_function import argparse import collections import os +import sys from oslo_utils import strutils import six @@ -98,7 +99,7 @@ def do_list_filters(cs, args): 'Valid keys: %s. ' 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -139,7 +140,7 @@ def do_backup_list(cs, args): action='store_true', help='Show detailed information about pools.') @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -273,7 +274,7 @@ RESET_STATE_RESOURCES = {'volume': utils.find_volume, metavar='<tenant>', help='Display information from single tenant (Admin only).') @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -503,6 +504,9 @@ def do_reset_state(cs, args): help=('Allow volume to be attached more than once.' ' Default=False'), default=False) +@utils.arg('--poll', + action="store_true", + help=('Wait for volume creation until it completes.')) def do_create(cs, args): """Creates a volume.""" @@ -563,6 +567,12 @@ def do_create(cs, args): info['readonly'] = info['metadata']['readonly'] info.pop('links', None) + + if args.poll: + timeout_period = os.environ.get("POLL_TIMEOUT_PERIOD", 3600) + shell_utils._poll_for_status(cs.volumes.get, volume.id, info, 'creating', ['available'], + timeout_period, cs.client.global_request_id, cs.messages) + utils.print_dict(info) @@ -600,6 +610,27 @@ def do_metadata(cs, args): reverse=True)) +@api_versions.wraps('3.12') +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=utils.env('ALL_TENANTS', default=0), + help='Shows details for all tenants. Admin only.') +def do_summary(cs, args): + """Get volumes summary.""" + all_tenants = args.all_tenants + info = cs.volumes.summary(all_tenants) + + formatters = ['total_size', 'total_count'] + if cs.api_version >= api_versions.APIVersion("3.36"): + formatters.append('metadata') + + utils.print_dict(info['volume-summary'], formatters=formatters) + + @api_versions.wraps('3.11') def do_group_type_list(cs, args): """Lists available 'group types'. (Admin only will see private types)""" @@ -743,10 +774,6 @@ def do_group_type_key(cs, args): metavar='<backup_gigabytes>', type=int, default=None, help='The new "backup_gigabytes" quota value. Default=None.') -@utils.arg('--consistencygroups', - metavar='<consistencygroups>', - type=int, default=None, - help='The new "consistencygroups" quota value. Default=None.') @utils.arg('--groups', metavar='<groups>', type=int, default=None, @@ -849,6 +876,7 @@ def do_backup_update(cs, args): raise exceptions.ClientException(code=1, message=msg) shell_utils.find_backup(cs, args.backup).update(**kwargs) + print("Request to update backup '%s' has been accepted." % args.backup) @api_versions.wraps('3.7') @@ -976,7 +1004,7 @@ def do_manageable_list(cs, args): default=utils.env('ALL_TENANTS', default=0), help='Shows details for all tenants. Admin only.') @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -1170,9 +1198,77 @@ def do_group_update(cs, args): raise exceptions.ClientException(code=1, message=msg) shell_utils.find_group(cs, args.group).update(**kwargs) + print("Request to update group '%s' has been accepted." % args.group) + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='<group>', + help='Name or ID of the group.') +def do_group_enable_replication(cs, args): + """Enables replication for group.""" + + shell_utils.find_group(cs, args.group).enable_replication() + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='<group>', + help='Name or ID of the group.') +def do_group_disable_replication(cs, args): + """Disables replication for group.""" + + shell_utils.find_group(cs, args.group).disable_replication() + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='<group>', + help='Name or ID of the group.') +@utils.arg('--allow-attached-volume', + action='store_true', + default=False, + help='Allows or disallows group with ' + 'attached volumes to be failed over.') +@utils.arg('--secondary-backend-id', + metavar='<secondary_backend_id>', + help='Secondary backend id. Default=None.') +def do_group_failover_replication(cs, args): + """Fails over replication for group.""" + + shell_utils.find_group(cs, args.group).failover_replication( + allow_attached_volume=args.allow_attached_volume, + secondary_backend_id=args.secondary_backend_id) + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='<group>', + help='Name or ID of the group.') +def do_group_list_replication_targets(cs, args): + """Lists replication targets for group. + + Example value for replication_targets: + + .. code-block: json + + { + 'replication_targets': [{'backend_id': 'vendor-id-1', + 'unique_key': 'val1', + ......}, + {'backend_id': 'vendor-id-2', + 'unique_key': 'val2', + ......}] + } + """ + + rc, replication_targets = shell_utils.find_group( + cs, args.group).list_replication_targets() + rep_targets = replication_targets.get('replication_targets') + if rep_targets and len(rep_targets) > 0: + utils.print_list(rep_targets, [key for key in rep_targets[0].keys()]) -@api_versions.wraps('3.14') @utils.arg('--all-tenants', dest='all_tenants', metavar='<0|1>', @@ -1192,7 +1288,7 @@ def do_group_update(cs, args): help="Filters results by a group ID. Default=None. " "%s" % FILTER_DEPRECATED) @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -1371,6 +1467,19 @@ def do_api_version(cs, args): utils.print_list(response, columns) +@api_versions.wraps("3.40") +@utils.arg( + 'snapshot', + metavar='<snapshot>', + help='Name or ID of the snapshot to restore. The snapshot must be the ' + 'most recent one known to cinder.') +def do_revert_to_snapshot(cs, args): + """Revert a volume to the specified snapshot.""" + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + volume = utils.find_volume(cs, snapshot.volume_id) + volume.revert_to_snapshot(snapshot) + + @api_versions.wraps("3.3") @utils.arg('--marker', metavar='<marker>', @@ -1418,7 +1527,7 @@ def do_api_version(cs, args): help="Filters results by the message level. Default=None. " "%s" % FILTER_DEPRECATED) @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -1556,7 +1665,7 @@ def do_message_delete(cs, args): "volume api version >=3.22. Default=None. " "%s" % FILTER_DEPRECATED) @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -1647,7 +1756,7 @@ def do_snapshot_list(cs, args): metavar='<tenant>', help='Display information from single tenant (Admin only).') @utils.arg('--filters', - type=str, + type=six.text_type, nargs='*', start_version='3.33', metavar='<key=value>', @@ -1750,7 +1859,8 @@ def do_attachment_create(cs, args): 'os_type': args.ostype, 'multipath': args.multipath, 'mountpoint': args.mountpoint} - attachment = cs.attachments.create(args.volume, + volume = utils.find_volume(cs, args.volume) + attachment = cs.attachments.create(volume.id, connector, args.server_id) connector_dict = attachment.pop('connection_info', None) @@ -1840,3 +1950,44 @@ def do_version_list(cs, args): print("\nServer supported API versions:") utils.print_list(result, columns) + + +@api_versions.wraps('3.32') +@utils.arg('level', + metavar='<log-level>', + choices=('INFO', 'WARNING', 'ERROR', 'DEBUG', + 'info', 'warning', 'error', 'debug'), + help='Desired log level.') +@utils.arg('--binary', + choices=('', '*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup'), + default='', + help='Binary to change.') +@utils.arg('--server', + default='', + help='Host or cluster value for service.') +@utils.arg('--prefix', + default='', + help='Prefix for the log. ie: "cinder.volume.drivers.".') +def do_service_set_log(cs, args): + cs.services.set_log_levels(args.level, args.binary, args.server, + args.prefix) + + +@api_versions.wraps('3.32') +@utils.arg('--binary', + choices=('', '*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup'), + default='', + help='Binary to query.') +@utils.arg('--server', + default='', + help='Host or cluster value for service.') +@utils.arg('--prefix', + default='', + help='Prefix for the log. ie: "sqlalchemy.".') +def do_service_get_log(cs, args): + log_levels = cs.services.get_log_levels(args.binary, args.server, + args.prefix) + columns = ('Binary', 'Host', 'Prefix', 'Level') + utils.print_list(log_levels, columns) diff --git a/cinderclient/v3/volumes.py b/cinderclient/v3/volumes.py index 2224489..2e4eadc 100644 --- a/cinderclient/v3/volumes.py +++ b/cinderclient/v3/volumes.py @@ -45,6 +45,10 @@ class Volume(volumes.Volume): return self.manager.upload_to_image(self, force, image_name, container_format, disk_format) + def revert_to_snapshot(self, snapshot): + """Revert a volume to a snapshot.""" + self.manager.revert_to_snapshot(self, snapshot) + class VolumeManager(volumes.VolumeManager): resource_class = Volume @@ -109,6 +113,25 @@ class VolumeManager(volumes.VolumeManager): return self._create('/volumes', body, 'volume') + @api_versions.wraps('3.40') + def revert_to_snapshot(self, volume, snapshot): + """Revert a volume to a snapshot. + + The snapshot must be the most recent one known to cinder. + :param volume: volume object. + :param snapshot: snapshot object. + """ + return self._action('revert', volume, + info={'snapshot_id': base.getid(snapshot.id)}) + + def summary(self, all_tenants): + """Get volumes summary.""" + url = "/volumes/summary" + if all_tenants: + url += "?all_tenants=True" + _, body = self.api.client.get(url) + return body + @api_versions.wraps("3.0") def delete_metadata(self, volume, keys): """Delete specified keys from volumes metadata. @@ -131,6 +154,7 @@ class VolumeManager(volumes.VolumeManager): :param volume: The :class:`Volume`. :param keys: A list of keys to be removed. """ + # pylint: disable=function-redefined data = self._get("/volumes/%s/metadata" % base.getid(volume)) metadata = data._info.get("metadata", {}) if set(keys).issubset(metadata.keys()): @@ -160,6 +184,7 @@ class VolumeManager(volumes.VolumeManager): """Upload volume to image service as image. :param volume: The :class:`Volume` to upload. """ + # pylint: disable=function-redefined return self._action('os-volume_upload_image', volume, {'force': force, diff --git a/doc/source/conf.py b/doc/source/conf.py index e19f38b..1af8db5 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -43,7 +43,7 @@ sys.path.insert(0, ROOT) # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', - 'oslosphinx', + 'openstackdocstheme', 'reno.sphinxext', ] @@ -120,6 +120,7 @@ man_pages = [ # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. # html_theme = 'nature' +html_theme = 'openstackdocs' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -188,6 +189,10 @@ html_static_path = ['_static'] # Output file base name for HTML help builder. htmlhelp_basename = 'python-cinderclientdoc' +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = '%Y-%m-%d %H:%M' # -- Options for LaTeX output ------------------------------------------------- @@ -220,4 +225,9 @@ latex_documents = [ # latex_appendices = [] # If false, no module index is generated. -# latex_use_modindex = True
\ No newline at end of file +# latex_use_modindex = True + +# -- Options for openstackdocstheme ------------------------------------------- +repository_name = 'openstack/python-cinderclient' +bug_project = 'cinderclient' +bug_tag = '' diff --git a/doc/source/functional_tests.rst b/doc/source/functional_tests.rst index 3c90a40..6af85ba 100644 --- a/doc/source/functional_tests.rst +++ b/doc/source/functional_tests.rst @@ -38,7 +38,7 @@ Or all tests in the test_readonly_clitest_readonly_cli.py file:: tox -e functional -- -n cinderclient.tests.functional.test_readonly_cli For more information on these options and how to run tests, please see the -`ostestr documentation <http://docs.openstack.org/developer/os-testr/>`_. +`ostestr documentation <https://docs.openstack.org/os-testr/latest/>`_. Gotchas ------- diff --git a/doc/source/index.rst b/doc/source/index.rst index a87f414..4aaf66b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -47,7 +47,7 @@ Release Notes All python-cinderclient release notes can now be found on the `release notes`_ page. -.. _`release notes`: http://docs.openstack.org/releasenotes/python-cinderclient/ +.. _`release notes`: https://docs.openstack.org/releasenotes/python-cinderclient/ The following are kept for historical purposes. diff --git a/doc/source/unit_tests.rst b/doc/source/unit_tests.rst index 38fb4e2..988740d 100644 --- a/doc/source/unit_tests.rst +++ b/doc/source/unit_tests.rst @@ -39,7 +39,7 @@ Or all tests in the test_volumes.py file:: tox -epy27 -- -n cinderclient.tests.unit.v2.test_volumes For more information on these options and how to run tests, please see the -`ostestr documentation <http://docs.openstack.org/developer/os-testr/>`_. +`ostestr documentation <https://docs.openstack.org/os-testr/latest/>`_. Run tests wrapper script ------------------------ @@ -94,7 +94,7 @@ This will show the following help information:: Because ``run_tests.sh`` is a wrapper around testr, it also accepts the same flags as testr. See the documentation for details about these additional flags: -`ostestr documentation <http://docs.openstack.org/developer/os-testr/>`_. +`ostestr documentation <https://docs.openstack.org/os-testr/latest/>`_. .. _nose options documentation: http://readthedocs.org/docs/nose/en/latest/usage.html#options diff --git a/releasenotes/notes/bug-1705093-9bc782d44018c27d.yaml b/releasenotes/notes/bug-1705093-9bc782d44018c27d.yaml new file mode 100644 index 0000000..ee7ef73 --- /dev/null +++ b/releasenotes/notes/bug-1705093-9bc782d44018c27d.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + Fixes `bug 1705093`_ by having the + ``cinderclient.client.get_highest_client_server_version`` method return a + string rather than a float. The problem with returning a float is when a + user of that method would cast the float result to a str which turns 3.40, + for example, into "3.4" which is wrong. + + .. _bug 1705093: https://bugs.launchpad.net/python-cinderclient/+bug/1705093 diff --git a/releasenotes/notes/cinder-poll-4f92694cc7eb657a.yaml b/releasenotes/notes/cinder-poll-4f92694cc7eb657a.yaml new file mode 100644 index 0000000..5d6a106 --- /dev/null +++ b/releasenotes/notes/cinder-poll-4f92694cc7eb657a.yaml @@ -0,0 +1,4 @@ +features: + - | + Support to wait for volume creation until it completes. + The command is: ``cinder create --poll <volume_size>`` diff --git a/releasenotes/notes/remove-cg-quota-9d4120b62f09cc5c.yaml b/releasenotes/notes/remove-cg-quota-9d4120b62f09cc5c.yaml new file mode 100644 index 0000000..6f4d4d4 --- /dev/null +++ b/releasenotes/notes/remove-cg-quota-9d4120b62f09cc5c.yaml @@ -0,0 +1,3 @@ +--- +other: + - The useless consistencygroup quota operation has been removed. diff --git a/releasenotes/notes/replication-group-v3-api-022900ce6bf8feba.yaml b/releasenotes/notes/replication-group-v3-api-022900ce6bf8feba.yaml new file mode 100644 index 0000000..43e0cd0 --- /dev/null +++ b/releasenotes/notes/replication-group-v3-api-022900ce6bf8feba.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added support for replication group APIs ``enable_replication``, + ``disable_replication``, ``failover_replication`` and + ``list_replication_targets``. diff --git a/releasenotes/notes/revert-to-snapshot-er4598df88aq5918.yaml b/releasenotes/notes/revert-to-snapshot-er4598df88aq5918.yaml new file mode 100644 index 0000000..395fab3 --- /dev/null +++ b/releasenotes/notes/revert-to-snapshot-er4598df88aq5918.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added support for the revert-to-snapshot feature. diff --git a/releasenotes/notes/service_dynamic_log-bd81d93c73fc1570.yaml b/releasenotes/notes/service_dynamic_log-bd81d93c73fc1570.yaml new file mode 100644 index 0000000..e71c7db --- /dev/null +++ b/releasenotes/notes/service_dynamic_log-bd81d93c73fc1570.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Support microversion 3.32 that allows dynamically changing and querying + Cinder services' log levels with ``service-set-log`` and + ``service-get-log`` commands. diff --git a/releasenotes/notes/support-volume-summary-d6d5bb2acfef6ad5.yaml b/releasenotes/notes/support-volume-summary-d6d5bb2acfef6ad5.yaml new file mode 100644 index 0000000..a9856e1 --- /dev/null +++ b/releasenotes/notes/support-volume-summary-d6d5bb2acfef6ad5.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Support get volume summary command in V3.12. diff --git a/releasenotes/source/conf.py b/releasenotes/source/conf.py index ffd5111..2623c9e 100644 --- a/releasenotes/source/conf.py +++ b/releasenotes/source/conf.py @@ -38,7 +38,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'oslosphinx', + 'openstackdocstheme', 'reno.sphinxext', ] @@ -112,7 +112,7 @@ pygments_style = 'sphinx' # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'openstackdocs' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -151,6 +151,7 @@ html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = '%Y-%m-%d %H:%M' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. @@ -277,3 +278,8 @@ texinfo_documents = [ # -- Options for Internationalization output ------------------------------ locale_dirs = ['locale/'] + +# -- Options for openstackdocstheme ------------------------------------------- +repository_name = 'openstack/python-cinderclient' +bug_project = 'cinderclient' +bug_tag = '' diff --git a/requirements.txt b/requirements.txt index 9fab454..5e72d7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ # process, which may cause wedges in the gate later. pbr!=2.1.0,>=2.0.0 # Apache-2.0 PrettyTable<0.8,>=0.7.1 # BSD -keystoneauth1>=2.21.0 # Apache-2.0 +keystoneauth1>=3.0.1 # Apache-2.0 simplejson>=2.2.0 # MIT Babel!=2.4.0,>=2.3.4 # BSD six>=1.9.0 # MIT diff --git a/test-requirements.txt b/test-requirements.txt index df6cdcb..3226d7d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -7,13 +7,13 @@ coverage!=4.4,>=4.0 # Apache-2.0 ddt>=1.0.1 # MIT fixtures>=3.0.0 # Apache-2.0/BSD mock>=2.0 # BSD -oslosphinx>=4.7.0 # Apache-2.0 +openstackdocstheme>=1.11.0 # Apache-2.0 python-subunit>=0.0.18 # Apache-2.0/BSD reno!=2.3.1,>=1.8.0 # Apache-2.0 requests-mock>=1.1 # Apache-2.0 -sphinx!=1.6.1,>=1.5.1 # BSD -tempest>=14.0.0 # Apache-2.0 +sphinx>=1.6.2 # BSD +tempest>=16.1.0 # Apache-2.0 testtools>=1.4.0 # MIT testrepository>=0.0.18 # Apache-2.0/BSD os-testr>=0.8.0 # Apache-2.0 -oslo.serialization>=1.10.0 # Apache-2.0 +oslo.serialization!=2.19.1,>=1.10.0 # Apache-2.0 |