diff options
-rw-r--r-- | CONTRIBUTING.md | 11 | ||||
-rw-r--r-- | CONTRIBUTING.rst | 16 | ||||
-rw-r--r-- | cinderclient/base.py | 120 | ||||
-rw-r--r-- | cinderclient/client.py | 2 | ||||
-rw-r--r-- | cinderclient/extension.py | 3 | ||||
-rw-r--r-- | cinderclient/shell.py | 5 | ||||
-rw-r--r-- | cinderclient/tests/functional/base.py | 154 | ||||
-rw-r--r-- | cinderclient/tests/functional/test_cli.py | 62 | ||||
-rw-r--r-- | cinderclient/tests/unit/fixture_data/snapshots.py | 9 | ||||
-rw-r--r-- | cinderclient/tests/unit/v2/fakes.py | 25 | ||||
-rw-r--r-- | cinderclient/tests/unit/v2/test_shell.py | 34 | ||||
-rw-r--r-- | cinderclient/tests/unit/v2/test_snapshot_actions.py | 8 | ||||
-rw-r--r-- | cinderclient/tests/unit/v2/test_volume_backups.py | 19 | ||||
-rw-r--r-- | cinderclient/utils.py | 36 | ||||
-rw-r--r-- | cinderclient/v1/shell.py | 6 | ||||
-rw-r--r-- | cinderclient/v2/shell.py | 117 | ||||
-rw-r--r-- | cinderclient/v2/volume_backups.py | 34 | ||||
-rw-r--r-- | cinderclient/v2/volume_snapshots.py | 38 | ||||
-rw-r--r-- | cinderclient/v2/volume_transfers.py | 1 | ||||
-rw-r--r-- | cinderclient/v2/volumes.py | 133 | ||||
-rw-r--r-- | doc/source/index.rst | 39 | ||||
-rw-r--r-- | requirements.txt | 5 | ||||
-rw-r--r-- | test-requirements.txt | 4 | ||||
-rw-r--r-- | tox.ini | 4 |
24 files changed, 531 insertions, 354 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 1b4db29..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,11 +0,0 @@ -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](http://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](http://docs.openstack.org/infra/manual/developers.html#development-workflow). - -Pull requests submitted through GitHub will be ignored. - -Bugs should be filed [on Launchpad](https://bugs.launchpad.net/python-cinderclient), -not in GitHub's issue tracker. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..87335db --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,16 @@ +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 + +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 + +Pull requests submitted through GitHub will be ignored. + +Bugs should be filed on Launchpad, not in GitHub's issue tracker: + + https://bugs.launchpad.net/python-cinderclient diff --git a/cinderclient/base.py b/cinderclient/base.py index c6a22b2..b975f11 100644 --- a/cinderclient/base.py +++ b/cinderclient/base.py @@ -24,12 +24,20 @@ import hashlib import os import six +from six.moves.urllib import parse from cinderclient import exceptions from cinderclient.openstack.common.apiclient import base as common_base from cinderclient import utils +# Valid sort directions and client sort keys +SORT_DIR_VALUES = ('asc', 'desc') +SORT_KEY_VALUES = ('id', 'status', 'size', 'availability_zone', 'name', + 'bootable', 'created_at') +# Mapping of client keys to actual sort keys +SORT_KEY_MAPPINGS = {'name': 'display_name'} + Resource = common_base.Resource @@ -44,7 +52,7 @@ def getid(obj): return obj -class Manager(utils.HookableMixin): +class Manager(common_base.HookableMixin): """ Managers interact with a particular type of API (servers, flavors, images, etc.) and provide CRUD operations for them. @@ -110,6 +118,116 @@ class Manager(utils.HookableMixin): limit, items) return items + def _build_list_url(self, resource_type, detailed=True, search_opts=None, + marker=None, limit=None, sort_key=None, sort_dir=None, + sort=None): + + if search_opts is None: + search_opts = {} + + query_params = {} + for key, val in search_opts.items(): + if val: + query_params[key] = val + + if marker: + query_params['marker'] = marker + + if limit: + query_params['limit'] = limit + + if sort: + query_params['sort'] = self._format_sort_param(sort) + else: + # sort_key and sort_dir deprecated in kilo, prefer sort + if sort_key: + query_params['sort_key'] = self._format_sort_key_param( + sort_key) + + if sort_dir: + query_params['sort_dir'] = self._format_sort_dir_param( + sort_dir) + + # Transform the dict to a sequence of two-element tuples in fixed + # order, then the encoded string will be consistent in Python 2&3. + query_string = "" + if query_params: + params = sorted(query_params.items(), key=lambda x: x[0]) + query_string = "?%s" % parse.urlencode(params) + + detail = "" + if detailed: + detail = "/detail" + + return ("/%(resource_type)s%(detail)s%(query_string)s" % + {"resource_type": resource_type, "detail": detail, + "query_string": query_string}) + + def _format_sort_param(self, sort): + '''Formats the sort information into the sort query string parameter. + + The input sort information can be any of the following: + - Comma-separated string in the form of <key[:dir]> + - List of strings in the form of <key[:dir]> + - List of either string keys, or tuples of (key, dir) + + For example, the following import sort values are valid: + - 'key1:dir1,key2,key3:dir3' + - ['key1:dir1', 'key2', 'key3:dir3'] + - [('key1', 'dir1'), 'key2', ('key3', dir3')] + + :param sort: Input sort information + :returns: Formatted query string parameter or None + :raise ValueError: If an invalid sort direction or invalid sort key is + given + ''' + if not sort: + return None + + if isinstance(sort, six.string_types): + # Convert the string into a list for consistent validation + sort = [s for s in sort.split(',') if s] + + sort_array = [] + for sort_item in sort: + if isinstance(sort_item, tuple): + sort_key = sort_item[0] + sort_dir = sort_item[1] + else: + sort_key, _sep, sort_dir = sort_item.partition(':') + sort_key = sort_key.strip() + if sort_key in SORT_KEY_VALUES: + sort_key = SORT_KEY_MAPPINGS.get(sort_key, sort_key) + else: + raise ValueError('sort_key must be one of the following: %s.' + % ', '.join(SORT_KEY_VALUES)) + if sort_dir: + sort_dir = sort_dir.strip() + if sort_dir not in SORT_DIR_VALUES: + msg = ('sort_dir must be one of the following: %s.' + % ', '.join(SORT_DIR_VALUES)) + raise ValueError(msg) + sort_array.append('%s:%s' % (sort_key, sort_dir)) + else: + sort_array.append(sort_key) + return ','.join(sort_array) + + def _format_sort_key_param(self, sort_key): + if sort_key in SORT_KEY_VALUES: + return SORT_KEY_MAPPINGS.get(sort_key, sort_key) + + msg = ('sort_key must be one of the following: %s.' % + ', '.join(SORT_KEY_VALUES)) + raise ValueError(msg) + + def _format_sort_dir_param(self, sort_dir): + if sort_dir in SORT_DIR_VALUES: + return sort_dir + + msg = ('sort_dir must be one of the following: %s.' + % ', '.join(SORT_DIR_VALUES)) + raise ValueError(msg) + @contextlib.contextmanager def completion_cache(self, cache_type, obj_class, mode): """ diff --git a/cinderclient/client.py b/cinderclient/client.py index 6689b5e..3dac516 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -32,8 +32,8 @@ import requests from cinderclient import exceptions from cinderclient.openstack.common import importutils -from cinderclient.openstack.common import strutils from cinderclient.openstack.common.gettextutils import _ +from oslo_utils import strutils osprofiler_web = importutils.try_import("osprofiler.web") diff --git a/cinderclient/extension.py b/cinderclient/extension.py index 84c67e9..1ea062f 100644 --- a/cinderclient/extension.py +++ b/cinderclient/extension.py @@ -14,10 +14,11 @@ # under the License. from cinderclient import base +from cinderclient.openstack.common.apiclient import base as common_base from cinderclient import utils -class Extension(utils.HookableMixin): +class Extension(common_base.HookableMixin): """Extension descriptor.""" SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') diff --git a/cinderclient/shell.py b/cinderclient/shell.py index e770320..6d60132 100644 --- a/cinderclient/shell.py +++ b/cinderclient/shell.py @@ -38,7 +38,6 @@ from cinderclient import utils import cinderclient.auth_plugin import cinderclient.extension from cinderclient.openstack.common import importutils -from cinderclient.openstack.common import strutils from cinderclient.openstack.common.gettextutils import _ from cinderclient.v1 import shell as shell_v1 from cinderclient.v2 import shell as shell_v2 @@ -49,6 +48,8 @@ from keystoneclient.auth.identity import v2 as v2_auth from keystoneclient.auth.identity import v3 as v3_auth from keystoneclient.exceptions import DiscoveryFailure import six.moves.urllib.parse as urlparse +from oslo_utils import encodeutils +from oslo_utils import strutils osprofiler_profiler = importutils.try_import("osprofiler.profiler") @@ -902,7 +903,7 @@ def main(): if sys.version_info >= (3, 0): OpenStackCinderShell().main(sys.argv[1:]) else: - OpenStackCinderShell().main(map(strutils.safe_decode, + OpenStackCinderShell().main(map(encodeutils.safe_decode, sys.argv[1:])) except KeyboardInterrupt: print("... terminating cinder client", file=sys.stderr) diff --git a/cinderclient/tests/functional/base.py b/cinderclient/tests/functional/base.py index 11e3072..fefa180 100644 --- a/cinderclient/tests/functional/base.py +++ b/cinderclient/tests/functional/base.py @@ -97,77 +97,15 @@ class ClientTestBase(base.ClientTestBase): for field in field_names: self.assertIn(field, item) - def assert_volume_details(self, items): - """Check presence of common volume properties. + def assert_object_details(self, expected, items): + """Check presence of common object properties. - :param items: volume properties + :param expected: expected object properties + :param items: object properties """ - values = ('attachments', 'availability_zone', 'bootable', 'created_at', - 'description', 'encrypted', 'id', 'metadata', 'name', 'size', - 'status', 'user_id', 'volume_type') - - for value in values: + for value in expected: self.assertIn(value, items) - def wait_for_volume_status(self, volume_id, status, timeout=60): - """Wait until volume reaches given status. - - :param volume_id: uuid4 id of given volume - :param status: expected status of volume - :param timeout: timeout in seconds - """ - start_time = time.time() - while time.time() - start_time < timeout: - if status in self.cinder('show', params=volume_id): - break - else: - self.fail("Volume %s did not reach status %s after %d seconds." - % (volume_id, status, timeout)) - - def check_volume_not_deleted(self, volume_id): - """Check that volume exists. - - :param volume_id: uuid4 id of given volume - """ - self.assertTrue(self.cinder('show', params=volume_id)) - - def check_volume_deleted(self, volume_id, timeout=60): - """Check that volume deleted successfully. - - :param volume_id: uuid4 id of given volume - :param timeout: timeout in seconds - """ - try: - start_time = time.time() - while time.time() - start_time < timeout: - if volume_id not in self.cinder('show', params=volume_id): - break - except exceptions.CommandFailed: - pass - else: - self.fail("Volume %s not deleted after %d seconds." - % (volume_id, timeout)) - - def volume_create(self, params): - """Create volume. - - :param params: parameters to cinder command - :return: volume dictionary - """ - output = self.cinder('create', params=params) - volume = self._get_property_from_output(output) - self.addCleanup(self.volume_delete, volume['id']) - self.wait_for_volume_status(volume['id'], 'available') - return volume - - def volume_delete(self, volume_id): - """Delete specified volume by ID. - - :param volume_id: uuid4 id of given volume - """ - if volume_id in self.cinder('list'): - self.cinder('delete', params=volume_id) - def _get_property_from_output(self, output): """Create a dictionary from an output @@ -179,65 +117,67 @@ class ClientTestBase(base.ClientTestBase): obj[item['Property']] = six.text_type(item['Value']) return obj - def wait_for_snapshot_status(self, snapshot_id, status, timeout=60): - """Wait until snapshot reaches given status. + def object_cmd(self, object_name, cmd): + return (object_name + '-' + cmd if object_name != 'volume' else cmd) - :param snapshot_id: uuid4 id of given volume - :param status: expected snapshot's status + def wait_for_object_status(self, object_name, object_id, status, + timeout=60): + """Wait until object reaches given status. + + :param object_name: object name + :param object_id: uuid4 id of an object + :param status: expected status of an object :param timeout: timeout in seconds """ + cmd = self.object_cmd(object_name, 'show') start_time = time.time() while time.time() - start_time < timeout: - if status in self.cinder('snapshot-show', params=snapshot_id): + if status in self.cinder(cmd, params=object_id): break else: - self.fail("Snapshot %s did not reach status %s after %d seconds." - % (snapshot_id, status, timeout)) + self.fail("%s %s did not reach status %s after %d seconds." + % (object_name, object_id, status, timeout)) - def check_snapshot_deleted(self, snapshot_id, timeout=60): - """Check that snapshot deleted successfully. + def check_object_deleted(self, object_name, object_id, timeout=60): + """Check that volume deleted successfully. - :param snapshot_id: the given snapshot id + :param object_name: object name + :param object_id: uuid4 id of an object :param timeout: timeout in seconds """ + cmd = self.object_cmd(object_name, 'show') try: start_time = time.time() while time.time() - start_time < timeout: - if snapshot_id not in self.cinder('snapshot-show', - params=snapshot_id): + if object_id not in self.cinder(cmd, params=object_id): break except exceptions.CommandFailed: pass else: - self.fail("Snapshot %s has not deleted after %d seconds." - % (snapshot_id, timeout)) - - def assert_snapshot_details(self, items): - """Check presence of common volume snapshot properties. - - :param items: volume snapshot properties - """ - values = ('created_at', 'description', 'id', 'metadata', 'name', - 'size', 'status', 'volume_id') + self.fail("%s %s not deleted after %d seconds." + % (object_name, object_id, timeout)) - for value in values: - self.assertIn(value, items) - - def snapshot_create(self, volume_id): - """Create a volume snapshot from the volume id. + def object_create(self, object_name, params): + """Create an object. - :param volume_id: the given volume id to create a snapshot + :param object_name: object name + :param params: parameters to cinder command + :return: object dictionary """ - output = self.cinder('snapshot-create', params=volume_id) - snapshot = self._get_property_from_output(output) - self.addCleanup(self.snapshot_delete, snapshot['id']) - self.wait_for_snapshot_status(snapshot['id'], 'available') - return snapshot - - def snapshot_delete(self, snapshot_id): - """Delete specified snapshot by ID. - - :param snapshot_id: the given snapshot id + cmd = self.object_cmd(object_name, 'create') + output = self.cinder(cmd, params=params) + object = self._get_property_from_output(output) + self.addCleanup(self.object_delete, object_name, object['id']) + self.wait_for_object_status(object_name, object['id'], 'available') + return object + + def object_delete(self, object_name, object_id): + """Delete specified object by ID. + + :param object_name: object name + :param object_id: uuid4 id of an object """ - if snapshot_id in self.cinder('snapshot-list'): - self.cinder('snapshot-delete', params=snapshot_id) + cmd = self.object_cmd(object_name, 'list') + cmd_delete = self.object_cmd(object_name, 'delete') + if object_id in self.cinder(cmd): + self.cinder(cmd_delete, params=object_id) diff --git a/cinderclient/tests/functional/test_cli.py b/cinderclient/tests/functional/test_cli.py index 4767d9c..1e3a4d7 100644 --- a/cinderclient/tests/functional/test_cli.py +++ b/cinderclient/tests/functional/test_cli.py @@ -14,54 +14,66 @@ from cinderclient.tests.functional import base -class CinderClientTests(base.ClientTestBase): - """Basic test for cinder client. +class CinderVolumeTests(base.ClientTestBase): + """Check of base cinder volume commands.""" + + VOLUME_PROPERTY = ('attachments', 'availability_zone', 'bootable', + 'created_at', 'description', 'encrypted', 'id', + 'metadata', 'name', 'size', 'status', + 'user_id', 'volume_type') - Check of base cinder commands. - """ def test_volume_create_delete_id(self): """Create and delete a volume by ID.""" - volume = self.volume_create(params='1') - self.assert_volume_details(volume.keys()) - self.volume_delete(volume['id']) - self.check_volume_deleted(volume['id']) + volume = self.object_create('volume', params='1') + self.assert_object_details(self.VOLUME_PROPERTY, volume.keys()) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) def test_volume_create_delete_name(self): """Create and delete a volume by name.""" - volume = self.volume_create(params='1 --name TestVolumeNamedCreate') + volume = self.object_create('volume', + params='1 --name TestVolumeNamedCreate') self.cinder('delete', params='TestVolumeNamedCreate') - self.check_volume_deleted(volume['id']) + self.check_object_deleted('volume', volume['id']) def test_volume_show(self): """Show volume details.""" - volume = self.volume_create(params='1 --name TestVolumeShow') + volume = self.object_create('volume', params='1 --name TestVolumeShow') output = self.cinder('show', params='TestVolumeShow') volume = self._get_property_from_output(output) self.assertEqual('TestVolumeShow', volume['name']) - self.assert_volume_details(volume.keys()) + self.assert_object_details(self.VOLUME_PROPERTY, volume.keys()) - self.volume_delete(volume['id']) - self.check_volume_deleted(volume['id']) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) def test_volume_extend(self): """Extend a volume size.""" - volume = self.volume_create(params='1 --name TestVolumeExtend') + volume = self.object_create('volume', + params='1 --name TestVolumeExtend') self.cinder('extend', params="%s %s" % (volume['id'], 2)) - self.wait_for_volume_status(volume['id'], 'available') + self.wait_for_object_status('volume', volume['id'], 'available') output = self.cinder('show', params=volume['id']) volume = self._get_property_from_output(output) self.assertEqual('2', volume['size']) - self.volume_delete(volume['id']) - self.check_volume_deleted(volume['id']) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) + + +class CinderSnapshotTests(base.ClientTestBase): + """Check of base cinder snapshot commands.""" + + SNAPSHOT_PROPERTY = ('created_at', 'description', 'metadata', 'id', + 'name', 'size', 'status', 'volume_id') def test_snapshot_create_and_delete(self): """Create a volume snapshot and then delete.""" - volume = self.volume_create(params='1') - snapshot = self.snapshot_create(volume['id']) - self.assert_snapshot_details(snapshot.keys()) - self.snapshot_delete(snapshot['id']) - self.check_snapshot_deleted(snapshot['id']) - self.volume_delete(volume['id']) - self.check_volume_deleted(volume['id']) + volume = self.object_create('volume', params='1') + snapshot = self.object_create('snapshot', params=volume['id']) + self.assert_object_details(self.SNAPSHOT_PROPERTY, snapshot.keys()) + self.object_delete('snapshot', snapshot['id']) + self.check_object_deleted('snapshot', snapshot['id']) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) diff --git a/cinderclient/tests/unit/fixture_data/snapshots.py b/cinderclient/tests/unit/fixture_data/snapshots.py index ec4d3b3..e9f77ca 100644 --- a/cinderclient/tests/unit/fixture_data/snapshots.py +++ b/cinderclient/tests/unit/fixture_data/snapshots.py @@ -52,5 +52,14 @@ class Fixture(base.Fixture): else: raise AssertionError("Unexpected action: %s" % action) return '' + self.requests.register_uri('POST', self.url('1234', 'action'), text=action_1234, status_code=202) + + self.requests.register_uri('GET', + self.url('detail?limit=2&marker=1234'), + status_code=200, json={'snapshots': []}) + + self.requests.register_uri('GET', + self.url('detail?sort=id'), + status_code=200, json={'snapshots': []}) diff --git a/cinderclient/tests/unit/v2/fakes.py b/cinderclient/tests/unit/v2/fakes.py index dd2488a..b0c78b9 100644 --- a/cinderclient/tests/unit/v2/fakes.py +++ b/cinderclient/tests/unit/v2/fakes.py @@ -461,6 +461,8 @@ class FakeHTTPClient(base_client.HTTPClient): assert list(body[action]) == ['metadata'] elif action == 'os-unset_image_metadata': assert 'key' in body[action] + elif action == 'os-show_image_metadata': + assert body[action] is None else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) @@ -772,6 +774,20 @@ class FakeHTTPClient(base_client.HTTPClient): return (200, {}, {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + def get_backups_1234(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '1234' + return (200, {}, + {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + + def get_backups_5678(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '5678' + return (200, {}, + {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + def get_backups_detail(self, **kw): base_uri = 'http://localhost:8776' tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' @@ -800,6 +816,15 @@ class FakeHTTPClient(base_client.HTTPClient): return (200, {}, {'restore': _stub_restore()}) + def post_backups_76a17945_3c6f_435c_975b_b5685db10b62_action(self, **kw): + return(200, {}, None) + + def post_backups_1234_action(self, **kw): + return(200, {}, None) + + def post_backups_5678_action(self, **kw): + return(200, {}, None) + def get_backups_76a17945_3c6f_435c_975b_b5685db10b62_export_record(self, **kw): return (200, diff --git a/cinderclient/tests/unit/v2/test_shell.py b/cinderclient/tests/unit/v2/test_shell.py index 0cd44de..1c140cb 100644 --- a/cinderclient/tests/unit/v2/test_shell.py +++ b/cinderclient/tests/unit/v2/test_shell.py @@ -552,10 +552,32 @@ class ShellTest(utils.TestCase): self.assert_called_anytime('POST', '/snapshots/5678/action', body=expected) + def test_backup_reset_state(self): + self.run_command('backup-reset-state 1234') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called('POST', '/backups/1234/action', body=expected) + + def test_backup_reset_state_with_flag(self): + self.run_command('backup-reset-state --state error 1234') + expected = {'os-reset_status': {'status': 'error'}} + self.assert_called('POST', '/backups/1234/action', body=expected) + + def test_backup_reset_state_multiple(self): + self.run_command('backup-reset-state 1234 5678') + expected = {'os-reset_status': {'status': 'available'}} + self.assert_called_anytime('POST', '/backups/1234/action', + body=expected) + self.assert_called_anytime('POST', '/backups/5678/action', + body=expected) + def test_type_list(self): self.run_command('type-list') self.assert_called_anytime('GET', '/types?is_public=None') + def test_type_show(self): + self.run_command('type-show 1') + self.assert_called('GET', '/types/1') + def test_type_create(self): self.run_command('type-create test-type-1') self.assert_called('POST', '/types') @@ -1117,3 +1139,15 @@ class ShellTest(utils.TestCase): def test_get_capabilities(self): self.run_command('get-capabilities host') self.assert_called('GET', '/capabilities/host') + + def test_image_metadata_show(self): + # since the request is not actually sent to cinder API but is + # calling the method in :class:`v2.fakes.FakeHTTPClient` instead. + # Thus, ignore any exception which is false negative compare + # with real API call. + try: + self.run_command('image-metadata-show 1234') + except Exception: + pass + expected = {"os-show_image_metadata": None} + self.assert_called('POST', '/volumes/1234/action', body=expected) diff --git a/cinderclient/tests/unit/v2/test_snapshot_actions.py b/cinderclient/tests/unit/v2/test_snapshot_actions.py index 87cc9c8..cc84885 100644 --- a/cinderclient/tests/unit/v2/test_snapshot_actions.py +++ b/cinderclient/tests/unit/v2/test_snapshot_actions.py @@ -34,3 +34,11 @@ class SnapshotActionsTest(utils.FixturedTestCase): stat = {'status': 'available', 'progress': '73%'} self.cs.volume_snapshots.update_snapshot_status(s, stat) self.assert_called('POST', '/snapshots/1234/action') + + def test_list_snapshots_with_marker_limit(self): + self.cs.volume_snapshots.list(marker=1234, limit=2) + self.assert_called('GET', '/snapshots/detail?limit=2&marker=1234') + + def test_list_snapshots_with_sort(self): + self.cs.volume_snapshots.list(sort="id") + self.assert_called('GET', '/snapshots/detail?sort=id') diff --git a/cinderclient/tests/unit/v2/test_volume_backups.py b/cinderclient/tests/unit/v2/test_volume_backups.py index a093111..296305f 100644 --- a/cinderclient/tests/unit/v2/test_volume_backups.py +++ b/cinderclient/tests/unit/v2/test_volume_backups.py @@ -51,6 +51,14 @@ class VolumeBackupsTest(utils.TestCase): cs.backups.list() cs.assert_called('GET', '/backups/detail') + def test_list_with_pagination(self): + cs.backups.list(limit=2, marker=100) + cs.assert_called('GET', '/backups/detail?limit=2&marker=100') + + def test_sorted_list(self): + cs.backups.list(sort="id") + cs.assert_called('GET', '/backups/detail?sort=id') + def test_delete(self): b = cs.backups.list()[0] b.delete() @@ -70,6 +78,17 @@ class VolumeBackupsTest(utils.TestCase): self.assertIsInstance(info, volume_backups_restore.VolumeBackupsRestore) + def test_reset_state(self): + b = cs.backups.list()[0] + api = '/backups/76a17945-3c6f-435c-975b-b5685db10b62/action' + b.reset_state(state='error') + cs.assert_called('POST', api) + cs.backups.reset_state('76a17945-3c6f-435c-975b-b5685db10b62', + state='error') + cs.assert_called('POST', api) + cs.backups.reset_state(b, state='error') + cs.assert_called('POST', api) + def test_record_export(self): backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' cs.backups.export_record(backup_id) diff --git a/cinderclient/utils.py b/cinderclient/utils.py index 13baf11..24ea134 100644 --- a/cinderclient/utils.py +++ b/cinderclient/utils.py @@ -24,7 +24,7 @@ import six import prettytable from cinderclient import exceptions -from cinderclient.openstack.common import strutils +from oslo_utils import encodeutils def arg(*args, **kwargs): @@ -107,7 +107,7 @@ def _print(pt, order): if sys.version_info >= (3, 0): print(pt.get_string(sortby=order)) else: - print(strutils.safe_encode(pt.get_string(sortby=order))) + print(encodeutils.safe_encode(pt.get_string(sortby=order))) def print_list(objs, fields, exclude_unavailable=False, formatters=None, @@ -198,7 +198,7 @@ def find_resource(manager, name_or_id): pass if sys.version_info <= (3, 0): - name_or_id = strutils.safe_decode(name_or_id) + name_or_id = encodeutils.safe_decode(name_or_id) try: try: @@ -228,36 +228,6 @@ def find_volume(cs, volume): return find_resource(cs.volumes, volume) -def _format_servers_list_networks(server): - output = [] - for (network, addresses) in list(server.networks.items()): - if len(addresses) == 0: - continue - addresses_csv = ', '.join(addresses) - group = "%s=%s" % (network, addresses_csv) - output.append(group) - - return '; '.join(output) - - -class HookableMixin(object): - """Mixin so classes can register and run hooks.""" - _hooks_map = {} - - @classmethod - def add_hook(cls, hook_type, hook_func): - if hook_type not in cls._hooks_map: - cls._hooks_map[hook_type] = [] - - cls._hooks_map[hook_type].append(hook_func) - - @classmethod - def run_hooks(cls, hook_type, *args, **kwargs): - hook_funcs = cls._hooks_map.get(hook_type) or [] - for hook_func in hook_funcs: - hook_func(*args, **kwargs) - - def safe_issubclass(*args): """Like issubclass, but will just return False if not a class.""" diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 3896ac3..9925b69 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -25,9 +25,9 @@ import sys import time from cinderclient import exceptions -from cinderclient.openstack.common import strutils from cinderclient import utils from cinderclient.v1 import availability_zones +from oslo_utils import strutils def _poll_for_status(poll_fn, obj_id, action, final_ok_states, @@ -214,7 +214,7 @@ def do_show(cs, args): @utils.arg('size', metavar='<size>', type=int, - help='Volume size, in GBs.') + help='Volume size, in GiBs.') @utils.arg( '--snapshot-id', metavar='<snapshot-id>', @@ -1039,7 +1039,7 @@ def do_transfer_show(cs, args): @utils.arg('new_size', metavar='<new-size>', type=int, - help='Size of volume, in GBs.') + help='Size of volume, in GiBs.') @utils.service_type('volume') def do_extend(cs, args): """Attempts to extend size of an existing volume.""" diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 2f1cea7..eb489a9 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -24,11 +24,11 @@ import time import six +from cinderclient import base from cinderclient import exceptions from cinderclient import utils -from cinderclient.openstack.common import strutils from cinderclient.v2 import availability_zones -from cinderclient.v2 import volumes +from oslo_utils import strutils def _poll_for_status(poll_fn, obj_id, action, final_ok_states, @@ -66,6 +66,11 @@ def _find_volume_snapshot(cs, snapshot): return utils.find_resource(cs.volume_snapshots, snapshot) +def _find_vtype(cs, vtype): + """Gets a volume type by name or ID.""" + return utils.find_resource(cs.volume_types, vtype) + + def _find_backup(cs, backup): """Gets a backup by name or ID.""" return utils.find_resource(cs.backups, backup) @@ -196,7 +201,7 @@ def _extract_metadata(args): help=(('Comma-separated list of sort keys and directions in the ' 'form of <key>[:<asc|desc>]. ' 'Valid keys: %s. ' - 'Default=None.') % ', '.join(volumes.SORT_KEY_VALUES))) + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) @utils.arg('--tenant', type=str, dest='tenant', @@ -282,7 +287,7 @@ class CheckSizeArgForCreate(argparse.Action): nargs='?', type=int, action=CheckSizeArgForCreate, - help='Size of volume, in GBs. (Required unless ' + help='Size of volume, in GiBs. (Required unless ' 'snapshot-id/source-volid is specified).') @utils.arg('--consisgroup-id', metavar='<consistencygroup-id>', @@ -619,6 +624,23 @@ def do_image_metadata(cs, args): help='Filters results by a volume ID. Default=None.') @utils.arg('--volume_id', help=argparse.SUPPRESS) +@utils.arg('--marker', + metavar='<marker>', + default=None, + help='Begin returning snapshots that appear later in the snapshot ' + 'list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='<limit>', + default=None, + help='Maximum number of snapshots to return. Default=None.') +@utils.arg('--sort', + metavar='<key>[:<direction>]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of <key>[:<asc|desc>]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) @utils.service_type('volumev2') def do_snapshot_list(cs, args): """Lists all snapshots.""" @@ -634,7 +656,10 @@ def do_snapshot_list(cs, args): 'volume_id': args.volume_id, } - snapshots = cs.volume_snapshots.list(search_opts=search_opts) + snapshots = cs.volume_snapshots.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) _translate_volume_snapshot_keys(snapshots) utils.print_list(snapshots, ['ID', 'Volume ID', 'Status', 'Name', 'Size']) @@ -808,6 +833,20 @@ def do_type_default(cs, args): _print_volume_type_list([vtype]) +@utils.arg('volume_type', + metavar='<volume_type>', + help='Name or ID of the volume type.') +@utils.service_type('volumev2') +def do_type_show(cs, args): + """Show volume type details.""" + vtype = _find_vtype(cs, args.volume_type) + info = dict() + info.update(vtype._info) + + info.pop('links', None) + utils.print_dict(info) + + @utils.arg('id', metavar='<id>', help='ID of the volume type.') @@ -1169,7 +1208,8 @@ def do_upload_to_image(cs, args): @utils.arg('volume', metavar='<volume>', help='ID of volume to migrate.') -@utils.arg('host', metavar='<host>', help='Destination host.') +@utils.arg('host', metavar='<host>', help='Destination host. Takes the form: ' + 'host@backend-name#pool') @utils.arg('--force-host-copy', metavar='<True|False>', choices=['True', 'False'], required=False, @@ -1366,6 +1406,23 @@ def do_backup_show(cs, args): help='Filters results by a volume ID. Default=None.') @utils.arg('--volume_id', help=argparse.SUPPRESS) +@utils.arg('--marker', + metavar='<marker>', + default=None, + help='Begin returning backups that appear later in the backup ' + 'list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='<limit>', + default=None, + help='Maximum number of backups to return. Default=None.') +@utils.arg('--sort', + metavar='<key>[:<direction>]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of <key>[:<asc|desc>]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) @utils.service_type('volumev2') def do_backup_list(cs, args): """Lists all backups.""" @@ -1377,7 +1434,10 @@ def do_backup_list(cs, args): 'volume_id': args.volume_id, } - backups = cs.backups.list(search_opts=search_opts) + backups = cs.backups.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) _translate_volume_snapshot_keys(backups) columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', 'Object Count', 'Container'] @@ -1443,6 +1503,36 @@ def do_backup_import(cs, args): utils.print_dict(info) +@utils.arg('backup', metavar='<backup>', nargs='+', + help='Name or ID of the backup to modify.') +@utils.arg('--state', metavar='<state>', + default='available', + help='The state to assign to the backup. Valid values are ' + '"available", "error", "creating", "deleting", and ' + '"error_deleting". Default=available.') +@utils.service_type('volumev2') +def do_backup_reset_state(cs, args): + """Explicitly updates the backup state.""" + failure_count = 0 + + single = (len(args.backup) == 1) + + for backup in args.backup: + try: + _find_backup(cs, backup).reset_state(args.state) + except Exception as e: + failure_count += 1 + msg = "Reset state for backup %s failed: %s" % (backup, e) + if not single: + print(msg) + + if failure_count == len(args.backup): + if not single: + msg = ("Unable to reset the state for any of the specified " + "backups.") + raise exceptions.CommandError(msg) + + @utils.arg('volume', metavar='<volume>', help='Name or ID of volume to transfer.') @utils.arg('--name', @@ -1534,7 +1624,7 @@ def do_transfer_show(cs, args): @utils.arg('new_size', metavar='<new_size>', type=int, - help='New size of volume, in GBs.') + help='New size of volume, in GiBs.') @utils.service_type('volumev2') def do_extend(cs, args): """Attempts to extend size of an existing volume.""" @@ -1906,6 +1996,7 @@ def do_qos_disassociate_all(cs, args): default=[], help='Metadata key and value pair to set or unset. ' 'For unset, specify only the key.') +@utils.service_type('volumev2') def do_qos_key(cs, args): """Sets or unsets specifications for a qos spec.""" keypair = _extract_metadata(args) @@ -1969,6 +2060,16 @@ def do_metadata_show(cs, args): utils.print_dict(volume._info['metadata'], 'Metadata-property') +@utils.arg('volume', metavar='<volume>', + help='ID of volume.') +@utils.service_type('volumev2') +def do_image_metadata_show(cs, args): + """Shows volume image metadata.""" + volume = utils.find_volume(cs, args.volume) + resp, body = volume.show_image_metadata(volume) + utils.print_dict(body['metadata'], 'Metadata-property') + + @utils.arg('volume', metavar='<volume>', help='ID of volume for which to update metadata.') diff --git a/cinderclient/v2/volume_backups.py b/cinderclient/v2/volume_backups.py index 70fda60..d97d846 100644 --- a/cinderclient/v2/volume_backups.py +++ b/cinderclient/v2/volume_backups.py @@ -16,13 +16,11 @@ """ Volume Backups interface (1.1 extension). """ -from six.moves.urllib.parse import urlencode from cinderclient import base class VolumeBackup(base.Resource): """A volume backup is a block level backup of a volume.""" - NAME_ATTR = "display_name" def __repr__(self): return "<VolumeBackup: %s>" % self.id @@ -31,6 +29,9 @@ class VolumeBackup(base.Resource): """Delete this volume backup.""" return self.manager.delete(self) + def reset_state(self, state): + self.manager.reset_state(self, state) + class VolumeBackupManager(base.ManagerWithFind): """Manage :class:`VolumeBackup` resources.""" @@ -65,21 +66,17 @@ class VolumeBackupManager(base.ManagerWithFind): """ return self._get("/backups/%s" % backup_id, "backup") - def list(self, detailed=True, search_opts=None): + def list(self, detailed=True, search_opts=None, marker=None, limit=None, + sort=None): """Get a list of all volume backups. :rtype: list of :class:`VolumeBackup` """ - search_opts = search_opts or {} - - qparams = dict((key, val) for key, val in search_opts.items() if val) - - query_string = ("?%s" % urlencode(qparams)) if qparams else "" - - detail = '/detail' if detailed else '' - - return self._list("/backups%s%s" % (detail, query_string), - "backups") + resource_type = "backups" + url = self._build_list_url(resource_type, detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, sort=sort) + return self._list(url, resource_type, limit=limit) def delete(self, backup): """Delete a volume backup. @@ -88,6 +85,17 @@ class VolumeBackupManager(base.ManagerWithFind): """ self._delete("/backups/%s" % base.getid(backup)) + def reset_state(self, backup, state): + """Update the specified volume backup with the provided state.""" + return self._action('os-reset_status', backup, {'status': state}) + + def _action(self, action, backup, info=None, **kwargs): + """Perform a volume backup action.""" + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/backups/%s/action' % base.getid(backup) + return self.api.client.post(url, body=body) + def export_record(self, backup_id): """Export volume backup metadata record. diff --git a/cinderclient/v2/volume_snapshots.py b/cinderclient/v2/volume_snapshots.py index 21ce245..c08ab39 100644 --- a/cinderclient/v2/volume_snapshots.py +++ b/cinderclient/v2/volume_snapshots.py @@ -15,18 +15,11 @@ """Volume snapshot interface (1.1 extension).""" -import six -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode - from cinderclient import base class Snapshot(base.Resource): """A Snapshot is a point-in-time snapshot of an openstack volume.""" - NAME_ATTR = "display_name" def __repr__(self): return "<Snapshot: %s>" % self.id @@ -102,34 +95,17 @@ class SnapshotManager(base.ManagerWithFind): """ return self._get("/snapshots/%s" % snapshot_id, "snapshot") - def list(self, detailed=True, search_opts=None): + def list(self, detailed=True, search_opts=None, marker=None, limit=None, + sort=None): """Get a list of all snapshots. :rtype: list of :class:`Snapshot` """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - # Transform the dict to a sequence of two-element tuples in fixed - # order, then the encoded string will be consistent in Python 2&3. - if qparams: - new_qparams = sorted(qparams.items(), key=lambda x: x[0]) - query_string = "?%s" % urlencode(new_qparams) - else: - query_string = "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/snapshots%s%s" % (detail, query_string), - "snapshots") + resource_type = "snapshots" + url = self._build_list_url(resource_type, detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, sort=sort) + return self._list(url, resource_type, limit=limit) def delete(self, snapshot): """Delete a snapshot. diff --git a/cinderclient/v2/volume_transfers.py b/cinderclient/v2/volume_transfers.py index a562c15..633f2ac 100644 --- a/cinderclient/v2/volume_transfers.py +++ b/cinderclient/v2/volume_transfers.py @@ -27,7 +27,6 @@ from cinderclient import base class VolumeTransfer(base.Resource): """Transfer a volume from one tenant to another""" - NAME_ATTR = "display_name" def __repr__(self): return "<VolumeTransfer: %s>" % self.id diff --git a/cinderclient/v2/volumes.py b/cinderclient/v2/volumes.py index 7d19e3a..19bec81 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v2/volumes.py @@ -15,23 +15,9 @@ """Volume interface (v2 extension).""" -import six -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode - from cinderclient import base -# Valid sort directions and client sort keys -SORT_DIR_VALUES = ('asc', 'desc') -SORT_KEY_VALUES = ('id', 'status', 'size', 'availability_zone', 'name', - 'bootable', 'created_at') -# Mapping of client keys to actual sort keys -SORT_KEY_MAPPINGS = {'name': 'display_name'} - - class Volume(base.Resource): """A volume is an extra block level storage to the OpenStack instances.""" def __repr__(self): @@ -114,6 +100,14 @@ class Volume(base.Resource): """ return self.manager.delete_image_metadata(self, volume, keys) + def show_image_metadata(self, volume): + """Show a volume's image metadata. + + :param volume : The :class: `Volume` where the image metadata + associated. + """ + return self.manager.show_image_metadata(self) + def upload_to_image(self, force, image_name, container_format, disk_format): """Upload a volume to image service as an image.""" @@ -276,55 +270,6 @@ class VolumeManager(base.ManagerWithFind): """ return self._get("/volumes/%s" % volume_id, "volume") - def _format_sort_param(self, sort): - '''Formats the sort information into the sort query string parameter. - - The input sort information can be any of the following: - - Comma-separated string in the form of <key[:dir]> - - List of strings in the form of <key[:dir]> - - List of either string keys, or tuples of (key, dir) - - For example, the following import sort values are valid: - - 'key1:dir1,key2,key3:dir3' - - ['key1:dir1', 'key2', 'key3:dir3'] - - [('key1', 'dir1'), 'key2', ('key3', dir3')] - - :param sort: Input sort information - :returns: Formatted query string parameter or None - :raise ValueError: If an invalid sort direction or invalid sort key is - given - ''' - if not sort: - return None - - if isinstance(sort, six.string_types): - # Convert the string into a list for consistent validation - sort = [s for s in sort.split(',') if s] - - sort_array = [] - for sort_item in sort: - if isinstance(sort_item, tuple): - sort_key = sort_item[0] - sort_dir = sort_item[1] - else: - sort_key, _sep, sort_dir = sort_item.partition(':') - sort_key = sort_key.strip() - if sort_key in SORT_KEY_VALUES: - sort_key = SORT_KEY_MAPPINGS.get(sort_key, sort_key) - else: - raise ValueError('sort_key must be one of the following: %s.' - % ', '.join(SORT_KEY_VALUES)) - if sort_dir: - sort_dir = sort_dir.strip() - if sort_dir not in SORT_DIR_VALUES: - msg = ('sort_dir must be one of the following: %s.' - % ', '.join(SORT_DIR_VALUES)) - raise ValueError(msg) - sort_array.append('%s:%s' % (sort_key, sort_dir)) - else: - sort_array.append(sort_key) - return ','.join(sort_array) - def list(self, detailed=True, search_opts=None, marker=None, limit=None, sort_key=None, sort_dir=None, sort=None): """Lists all volumes. @@ -340,55 +285,13 @@ class VolumeManager(base.ManagerWithFind): :param sort: Sort information :rtype: list of :class:`Volume` """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - if marker: - qparams['marker'] = marker - - if limit: - qparams['limit'] = limit - - # sort_key and sort_dir deprecated in kilo, prefer sort - if sort: - qparams['sort'] = self._format_sort_param(sort) - else: - if sort_key is not None: - if sort_key in SORT_KEY_VALUES: - qparams['sort_key'] = SORT_KEY_MAPPINGS.get(sort_key, - sort_key) - else: - msg = ('sort_key must be one of the following: %s.' - % ', '.join(SORT_KEY_VALUES)) - raise ValueError(msg) - if sort_dir is not None: - if sort_dir in SORT_DIR_VALUES: - qparams['sort_dir'] = sort_dir - else: - msg = ('sort_dir must be one of the following: %s.' - % ', '.join(SORT_DIR_VALUES)) - raise ValueError(msg) - - # Transform the dict to a sequence of two-element tuples in fixed - # order, then the encoded string will be consistent in Python 2&3. - if qparams: - new_qparams = sorted(qparams.items(), key=lambda x: x[0]) - query_string = "?%s" % urlencode(new_qparams) - else: - query_string = "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/volumes%s%s" % (detail, query_string), - "volumes", limit=limit) + resource_type = "volumes" + url = self._build_list_url(resource_type, detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, sort_key=sort_key, + sort_dir=sort_dir, sort=sort) + return self._list(url, resource_type, limit=limit) def delete(self, volume): """Delete a volume. @@ -534,6 +437,14 @@ class VolumeManager(base.ManagerWithFind): self._action("os-unset_image_metadata", volume, {'key': key}) + def show_image_metadata(self, volume): + """Show a volume's image metadata. + + :param volume : The :class: `Volume` where the image metadata + associated. + """ + return self._action("os-show_image_metadata", volume) + def upload_to_image(self, volume, force, image_name, container_format, disk_format): """Upload volume to image service as image. diff --git a/doc/source/index.rst b/doc/source/index.rst index c7b50d7..d613093 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -33,6 +33,37 @@ Release Notes MASTER ----- +1.4.0 +----- + +* Improved error reporting on reaching quota. +* Volume status management for volume migration. +* Added command to fetch specified backend capabilities. +* Added commands for modifying image metadata. +* Support for non-disruptive backup. +* Support for cloning consistency groups. + +.. _1493612 https://bugs.launchpad.net/python-cinderclient/+bug/1493612 +.. _1482988 https://bugs.launchpad.net/python-cinderclient/+bug/1482988 +.. _1422046 https://bugs.launchpad.net/python-cinderclient/+bug/1422046 +.. _1481478 https://bugs.launchpad.net/python-cinderclient/+bug/1481478 +.. _1475430 https://bugs.launchpad.net/python-cinderclient/+bug/1475430 + +1.3.1 +----- + +* Fixed usage of the --debug option. +* Documentation and API example improvements. +* Set max volume size limit for the tenant. +* Added encryption-type-update to cinderclient. +* Added volume multi attach support. +* Support host-attach of volumes. + +.. _1467628 https://bugs.launchpad.net/python-cinderclient/+bug/1467628 +.. _1454436 https://bugs.launchpad.net/cinder/+bug/1454436 +.. _1423884 https://bugs.launchpad.net/python-cinderclient/+bug/1423884 +.. _1462104 https://bugs.launchpad.net/cinder/+bug/1462104 + 1.3.0 ----- @@ -48,16 +79,18 @@ MASTER .. _1418580 http://bugs.launchpad.net/python-cinderclient/+bug/1418580 .. _1464160 http://bugs.launchpad.net/python-cinderclient/+bug/1464160 - - 1.2.2 ----- +* IMPORTANT: version discovery breaks deployments using proxies and has been + reverted in v1.3.0 . Do not use this version. * Update requirements to resolve conflicts with other OpenStack projects 1.2.1 ----- +* IMPORTANT: version discovery breaks deployments using proxies and has been + reverted in v1.3.0 . Do not use this version. * Remove warnings about Keystone unable to contact endpoint for discovery. * backup-create subcommand allows specifying --incremental to do an incremental backup. @@ -77,6 +110,8 @@ MASTER 1.2.0 ----- +* IMPORTANT: version discovery breaks deployments using proxies and has been + reverted in v1.3.0 . Do not use this version. * Add metadata during snapshot create. * Add TTY password entry when no password is environment vars or --os-password. * Ability to set backup quota in quota-update subcommand. diff --git a/requirements.txt b/requirements.txt index e3a08e0..fb4a32f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,9 @@ pbr>=1.6 argparse PrettyTable<0.8,>=0.7 -python-keystoneclient>=1.6.0 -requests>=2.5.2 +python-keystoneclient!=1.8.0,>=1.6.0 +requests!=2.8.0,>=2.5.2 simplejson>=2.2.0 Babel>=1.3 six>=1.9.0 +oslo.utils>=2.8.0 # Apache-2.0 diff --git a/test-requirements.txt b/test-requirements.txt index a339c48..a1fe049 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -7,10 +7,10 @@ coverage>=3.6 discover fixtures>=1.3.1 mock>=1.2 -oslosphinx>=2.5.0 # Apache-2.0 +oslosphinx!=3.4.0,>=2.5.0 # Apache-2.0 python-subunit>=0.0.18 requests-mock>=0.6.0 # Apache-2.0 sphinx!=1.2.0,!=1.3b1,<1.3,>=1.1.2 -tempest-lib>=0.9.0 +tempest-lib>=0.10.0 testtools>=1.4.0 testrepository>=0.0.18 @@ -30,6 +30,10 @@ commands= setenv = OS_TEST_PATH = ./cinderclient/tests/functional OS_VOLUME_API_VERSION = 2 +# The OS_CACERT environment variable should be passed to the test +# environments to specify a CA bundle file to use in verifying a +# TLS (https) server certificate. +passenv = OS_CACERT [tox:jenkins] downloadcache = ~/cache/pip |