summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTING.md11
-rw-r--r--CONTRIBUTING.rst16
-rw-r--r--cinderclient/base.py120
-rw-r--r--cinderclient/client.py2
-rw-r--r--cinderclient/extension.py3
-rw-r--r--cinderclient/shell.py5
-rw-r--r--cinderclient/tests/functional/base.py154
-rw-r--r--cinderclient/tests/functional/test_cli.py62
-rw-r--r--cinderclient/tests/unit/fixture_data/snapshots.py9
-rw-r--r--cinderclient/tests/unit/v2/fakes.py25
-rw-r--r--cinderclient/tests/unit/v2/test_shell.py34
-rw-r--r--cinderclient/tests/unit/v2/test_snapshot_actions.py8
-rw-r--r--cinderclient/tests/unit/v2/test_volume_backups.py19
-rw-r--r--cinderclient/utils.py36
-rw-r--r--cinderclient/v1/shell.py6
-rw-r--r--cinderclient/v2/shell.py117
-rw-r--r--cinderclient/v2/volume_backups.py34
-rw-r--r--cinderclient/v2/volume_snapshots.py38
-rw-r--r--cinderclient/v2/volume_transfers.py1
-rw-r--r--cinderclient/v2/volumes.py133
-rw-r--r--doc/source/index.rst39
-rw-r--r--requirements.txt5
-rw-r--r--test-requirements.txt4
-rw-r--r--tox.ini4
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
diff --git a/tox.ini b/tox.ini
index ac6cc8b..3f24d1b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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