Merge tag '1.4.0' into debian/liberty

python-cinderclient 1.4.0 release
This commit is contained in:
Thomas Goirand
2015-09-22 08:37:28 +00:00
30 changed files with 976 additions and 148 deletions

View File

@@ -238,14 +238,12 @@ class HTTPClient(object):
**kwargs)
self.http_log_resp(resp)
body = None
if resp.text:
try:
body = json.loads(resp.text)
except ValueError:
pass
body = None
else:
body = None
except ValueError as e:
self._logger.debug("Load http response text error: %s", e)
if resp.status_code >= 400:
raise exceptions.from_response(resp, body)

View File

@@ -82,7 +82,9 @@ class ClientException(Exception):
"""
def __init__(self, code, message=None, details=None, request_id=None):
self.code = code
self.message = message or self.__class__.message
# NOTE(mriedem): Use getattr on self.__class__.message since
# BaseException.message was dropped in python 3, see PEP 0352.
self.message = message or getattr(self.__class__, 'message', None)
self.details = details
self.request_id = request_id
@@ -176,8 +178,8 @@ def from_response(response, body):
details = "n/a"
if hasattr(body, 'keys'):
error = body[list(body)[0]]
message = error.get('message', None)
details = error.get('details', None)
message = error.get('message', message)
details = error.get('details', details)
return cls(code=response.status_code, message=message, details=details,
request_id=request_id)
else:

View File

@@ -52,7 +52,7 @@ import six.moves.urllib.parse as urlparse
osprofiler_profiler = importutils.try_import("osprofiler.profiler")
DEFAULT_OS_VOLUME_API_VERSION = "1"
DEFAULT_OS_VOLUME_API_VERSION = "2"
DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL'
DEFAULT_CINDER_SERVICE_TYPE = 'volume'
@@ -360,18 +360,6 @@ class OpenStackCinderShell(object):
default=utils.env('OS_PROJECT_DOMAIN_NAME'),
help='Defaults to env[OS_PROJECT_DOMAIN_NAME].')
parser.add_argument(
'--os-cert',
metavar='<certificate>',
default=utils.env('OS_CERT'),
help='Defaults to env[OS_CERT].')
parser.add_argument(
'--os-key',
metavar='<key>',
default=utils.env('OS_KEY'),
help='Defaults to env[OS_KEY].')
parser.add_argument('--os-region-name',
metavar='<region-name>',
default=utils.env('OS_REGION_NAME',
@@ -397,19 +385,10 @@ class OpenStackCinderShell(object):
'--os_url',
help=argparse.SUPPRESS)
parser.add_argument(
'--os-cacert',
metavar='<ca-certificate>',
default=utils.env('OS_CACERT', default=None),
help=_("Specify a CA bundle file to use in "
"verifying a TLS (https) server certificate. "
"Defaults to env[OS_CACERT]"))
parser.add_argument('--insecure',
default=utils.env('CINDERCLIENT_INSECURE',
default=False),
action='store_true',
help=argparse.SUPPRESS)
# Register the CLI arguments that have moved to the session object.
session.Session.register_cli_options(parser)
parser.set_defaults(insecure=utils.env('CINDERCLIENT_INSECURE',
default=False))
def get_subcommand_parser(self, version):
parser = self.get_base_parser()
@@ -611,6 +590,14 @@ class OpenStackCinderShell(object):
# FIXME(usrleon): Here should be restrict for project id same as
# for os_username or os_password but for compatibility it is not.
# V3 stuff
project_info_provided = ((self.options.os_tenant_name or
self.options.os_tenant_id) or
(self.options.os_project_name and
(self.options.os_project_domain_name or
self.options.os_project_domain_id)) or
self.options.os_project_id)
if not utils.isunauthenticated(args.func):
if auth_plugin:
auth_plugin.parse_opts(args)
@@ -637,32 +624,20 @@ class OpenStackCinderShell(object):
"env[OS_PASSWORD] "
"or, prompted response.")
if not (os_tenant_name or os_tenant_id):
raise exc.CommandError("You must provide a tenant ID "
"through --os-tenant-id or "
"env[OS_TENANT_ID].")
# V3 stuff
project_info_provided = self.options.os_tenant_name or \
self.options.os_tenant_id or \
(self.options.os_project_name and
(self.options.project_domain_name or
self.options.project_domain_id)) or \
self.options.os_project_id
if (not project_info_provided):
raise exc.CommandError(
_("You must provide a tenant_name, tenant_id, "
"project_id or project_name (with "
"project_domain_name or project_domain_id) via "
" --os-tenant-name (env[OS_TENANT_NAME]),"
" --os-tenant-id (env[OS_TENANT_ID]),"
" --os-project-id (env[OS_PROJECT_ID])"
" --os-project-name (env[OS_PROJECT_NAME]),"
" --os-project-domain-id "
"(env[OS_PROJECT_DOMAIN_ID])"
" --os-project-domain-name "
"(env[OS_PROJECT_DOMAIN_NAME])"))
if not project_info_provided:
raise exc.CommandError(_(
"You must provide a tenant_name, tenant_id, "
"project_id or project_name (with "
"project_domain_name or project_domain_id) via "
" --os-tenant-name (env[OS_TENANT_NAME]),"
" --os-tenant-id (env[OS_TENANT_ID]),"
" --os-project-id (env[OS_PROJECT_ID])"
" --os-project-name (env[OS_PROJECT_NAME]),"
" --os-project-domain-id "
"(env[OS_PROJECT_DOMAIN_ID])"
" --os-project-domain-name "
"(env[OS_PROJECT_DOMAIN_NAME])"
))
if not os_auth_url:
if os_auth_system and os_auth_system != 'keystone':
@@ -673,10 +648,20 @@ class OpenStackCinderShell(object):
"You must provide an authentication URL "
"through --os-auth-url or env[OS_AUTH_URL].")
if not (os_tenant_name or os_tenant_id):
raise exc.CommandError(
"You must provide a tenant ID "
"through --os-tenant-id or env[OS_TENANT_ID].")
if not project_info_provided:
raise exc.CommandError(_(
"You must provide a tenant_name, tenant_id, "
"project_id or project_name (with "
"project_domain_name or project_domain_id) via "
" --os-tenant-name (env[OS_TENANT_NAME]),"
" --os-tenant-id (env[OS_TENANT_ID]),"
" --os-project-id (env[OS_PROJECT_ID])"
" --os-project-name (env[OS_PROJECT_NAME]),"
" --os-project-domain-id "
"(env[OS_PROJECT_DOMAIN_ID])"
" --os-project-domain-name "
"(env[OS_PROJECT_DOMAIN_NAME])"
))
if not os_auth_url:
raise exc.CommandError(

View File

@@ -11,11 +11,12 @@
# under the License.
import os
import time
from six.moves import configparser
import six
from tempest_lib.cli import base
from tempest_lib.cli import output_parser
from tempest_lib import exceptions
_CREDS_FILE = 'functional_creds.conf'
@@ -36,7 +37,7 @@ def credentials():
tenant_name = os.environ.get('OS_TENANT_NAME')
auth_url = os.environ.get('OS_AUTH_URL')
config = configparser.RawConfigParser()
config = six.moves.configparser.RawConfigParser()
if config.read(_CREDS_FILE):
username = username or config.get('admin', 'user')
password = password or config.get('admin', 'pass')
@@ -95,3 +96,148 @@ class ClientTestBase(base.ClientTestBase):
for item in items:
for field in field_names:
self.assertIn(field, item)
def assert_volume_details(self, items):
"""Check presence of common volume properties.
:param items: volume properties
"""
values = ('attachments', 'availability_zone', 'bootable', 'created_at',
'description', 'encrypted', 'id', 'metadata', 'name', 'size',
'status', 'user_id', 'volume_type')
for value in values:
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
:param output: the output of the cmd
"""
obj = {}
items = self.parser.listing(output)
for item in items:
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.
:param snapshot_id: uuid4 id of given volume
:param status: expected snapshot's status
:param timeout: timeout in seconds
"""
start_time = time.time()
while time.time() - start_time < timeout:
if status in self.cinder('snapshot-show', params=snapshot_id):
break
else:
self.fail("Snapshot %s did not reach status %s after %d seconds."
% (snapshot_id, status, timeout))
def check_snapshot_deleted(self, snapshot_id, timeout=60):
"""Check that snapshot deleted successfully.
:param snapshot_id: the given snapshot id
:param timeout: timeout in seconds
"""
try:
start_time = time.time()
while time.time() - start_time < timeout:
if snapshot_id not in self.cinder('snapshot-show',
params=snapshot_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')
for value in values:
self.assertIn(value, items)
def snapshot_create(self, volume_id):
"""Create a volume snapshot from the volume id.
:param volume_id: the given volume id to create a snapshot
"""
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
"""
if snapshot_id in self.cinder('snapshot-list'):
self.cinder('snapshot-delete', params=snapshot_id)

View File

@@ -21,7 +21,7 @@ function generate_testr_results {
if [ -f .testrepository/0 ]; then
sudo .tox/functional/bin/testr last --subunit > $WORKSPACE/testrepository.subunit
sudo mv $WORKSPACE/testrepository.subunit $BASE/logs/testrepository.subunit
sudo .tox/functional/bin/python /usr/local/jenkins/slave_scripts/subunit2html.py $BASE/logs/testrepository.subunit $BASE/logs/testr_results.html
sudo /usr/os-testr-env/bin/subunit2html $BASE/logs/testrepository.subunit $BASE/logs/testr_results.html
sudo gzip -9 $BASE/logs/testrepository.subunit
sudo gzip -9 $BASE/logs/testr_results.html
sudo chown jenkins:jenkins $BASE/logs/testrepository.subunit.gz $BASE/logs/testr_results.html.gz

View File

@@ -0,0 +1,67 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from cinderclient.tests.functional import base
class CinderClientTests(base.ClientTestBase):
"""Basic test for cinder client.
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'])
def test_volume_create_delete_name(self):
"""Create and delete a volume by name."""
volume = self.volume_create(params='1 --name TestVolumeNamedCreate')
self.cinder('delete', params='TestVolumeNamedCreate')
self.check_volume_deleted(volume['id'])
def test_volume_show(self):
"""Show volume details."""
volume = self.volume_create(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.volume_delete(volume['id'])
self.check_volume_deleted(volume['id'])
def test_volume_extend(self):
"""Extend a volume size."""
volume = self.volume_create(params='1 --name TestVolumeExtend')
self.cinder('extend', params="%s %s" % (volume['id'], 2))
self.wait_for_volume_status(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'])
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'])

View File

@@ -53,6 +53,17 @@ def mock_http_request(resp=None):
},
],
},
{
"type": "volumev2",
"endpoints": [
{
"region": "RegionOne",
"adminURL": "http://localhost:8774/v2",
"internalURL": "http://localhost:8774/v2",
"publicURL": "http://localhost:8774/v2/",
},
],
},
],
},
}

View File

@@ -0,0 +1,32 @@
# Copyright 2015 IBM Corp.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Tests the cinderclient.exceptions module."""
import requests
from cinderclient import exceptions
from cinderclient.tests.unit import utils
class ExceptionsTest(utils.TestCase):
def test_from_response_no_body_message(self):
# Tests that we get ClientException back since we don't have 500 mapped
response = requests.Response()
response.status_code = 500
body = {'keys': ({})}
ex = exceptions.from_response(response, body)
self.assertIs(exceptions.ClientException, type(ex))
self.assertEqual('n/a', ex.message)

View File

@@ -113,22 +113,26 @@ class ShellTest(utils.TestCase):
v2_url, v3_url = _shell._discover_auth_versions(
None, auth_url=os_auth_url)
self.assertEqual(v2_url, os_auth_url, "Expected v2 url")
self.assertEqual(v3_url, None, "Expected no v3 url")
self.assertIsNone(v3_url, "Expected no v3 url")
os_auth_url = "https://DiscoveryNotSupported.discovery.com:35357/v3.0"
self.register_keystone_auth_fixture(mocker, os_auth_url)
v2_url, v3_url = _shell._discover_auth_versions(
None, auth_url=os_auth_url)
self.assertEqual(v3_url, os_auth_url, "Expected v3 url")
self.assertEqual(v2_url, None, "Expected no v2 url")
self.assertIsNone(v2_url, "Expected no v2 url")
@mock.patch('keystoneclient.adapter.Adapter.get_token',
side_effect=ks_exc.ConnectionRefused())
@mock.patch('keystoneclient.discover.Discover',
side_effect=ks_exc.ConnectionRefused())
@mock.patch('sys.stdin', side_effect=mock.MagicMock)
@mock.patch('getpass.getpass', return_value='password')
def test_password_prompted(self, mock_getpass, mock_stdin):
def test_password_prompted(self, mock_getpass, mock_stdin, mock_discover,
mock_token):
self.make_env(exclude='OS_PASSWORD')
# We should get a Connection Refused because there is no keystone.
self.assertRaises(ks_exc.ConnectionRefused, self.shell, 'list')
# Make sure we are actually prompted.
_shell = shell.OpenStackCinderShell()
self.assertRaises(ks_exc.ConnectionRefused, _shell.main, ['list'])
mock_getpass.assert_called_with('OS Password: ')
@mock.patch.object(requests, "request")
@@ -167,7 +171,7 @@ class ShellTest(utils.TestCase):
token_url = _shell.cs.client.auth_url + "/tokens"
self.assertEqual(non_keystone_auth_url + "/tokens", token_url)
mock_request.assert_any_called(
mock_request.assert_any_call(
"POST",
token_url,
headers=headers,

View File

@@ -203,4 +203,38 @@ class PrintListTestCase(test_utils.TestCase):
| 1 | 2 |
| 3 | 4 |
+---+---+
""", cso.read())
def test_print_list_with_return(self):
Row = collections.namedtuple('Row', ['a', 'b'])
to_print = [Row(a=3, b='a\r'), Row(a=1, b='c\rd')]
with CaptureStdout() as cso:
utils.print_list(to_print, ['a', 'b'])
# Output should be sorted by the first key (a)
self.assertEqual("""\
+---+-----+
| a | b |
+---+-----+
| 1 | c d |
| 3 | a |
+---+-----+
""", cso.read())
class PrintDictTestCase(test_utils.TestCase):
def test_print_dict_with_return(self):
d = {'a': 'A', 'b': 'B', 'c': 'C', 'd': 'test\rcarriage\n\rreturn'}
with CaptureStdout() as cso:
utils.print_dict(d)
self.assertEqual("""\
+----------+---------------+
| Property | Value |
+----------+---------------+
| a | A |
| b | B |
| c | C |
| d | test carriage |
| | return |
+----------+---------------+
""", cso.read())

View File

@@ -322,8 +322,8 @@ class AuthenticationTests(utils.TestCase):
@mock.patch.object(http_client, 'authenticate')
def test_auth_call(m):
http_client.get('/')
m.assert_called()
mock_request.assert_called()
self.assertTrue(m.called)
self.assertTrue(mock_request.called)
test_auth_call()
@@ -333,6 +333,6 @@ class AuthenticationTests(utils.TestCase):
@mock.patch.object(cs.client, 'authenticate')
def test_auth_call(m):
cs.authenticate()
m.assert_called()
self.assertTrue(m.called)
test_auth_call()

View File

@@ -449,6 +449,10 @@ class FakeHTTPClient(base_client.HTTPClient):
assert body[action] is None
elif action == 'os-reenable-replica':
assert body[action] is None
elif action == 'os-set_image_metadata':
assert list(body[action]) == ['metadata']
elif action == 'os-unset_image_metadata':
assert 'key' in body[action]
else:
raise AssertionError("Unexpected action: %s" % action)
return (resp, {}, _body)
@@ -1063,3 +1067,20 @@ class FakeHTTPClient(base_client.HTTPClient):
},
]
return (200, {}, {"pools": stats})
def get_capabilities_host(self, **kw):
return (200, {},
{
'namespace': 'OS::Storage::Capabilities::fake',
'vendor_name': 'OpenStack',
'volume_backend_name': 'lvm',
'pool_name': 'pool',
'storage_protocol': 'iSCSI',
'properties': {
'compression': {
'title': 'Compression',
'description': 'Enables compression.',
'type': 'boolean'},
}
}
)

View File

@@ -325,8 +325,8 @@ class AuthenticationTests(utils.TestCase):
@mock.patch.object(http_client, 'authenticate')
def test_auth_call(m):
http_client.get('/')
m.assert_called()
mock_request.assert_called()
self.assertTrue(m.called)
self.assertTrue(mock_request.called)
test_auth_call()
@@ -336,6 +336,6 @@ class AuthenticationTests(utils.TestCase):
@mock.patch.object(cs.client, 'authenticate')
def test_auth_call(m):
cs.authenticate()
m.assert_called()
self.assertTrue(m.called)
test_auth_call()

View File

@@ -0,0 +1,41 @@
# Copyright (c) 2015 Hitachi Data Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from cinderclient.tests.unit import utils
from cinderclient.tests.unit.v2 import fakes
cs = fakes.FakeClient()
class CapabilitiesTest(utils.TestCase):
def test_get_capabilities(self):
expected = {
'namespace': 'OS::Storage::Capabilities::fake',
'vendor_name': 'OpenStack',
'volume_backend_name': 'lvm',
'pool_name': 'pool',
'storage_protocol': 'iSCSI',
'properties': {
'compression': {
'title': 'Compression',
'description': 'Enables compression.',
'type': 'boolean'},
}
}
capabilities = cs.capabilities.get('host')
cs.assert_called('GET', '/capabilities/host')
self.assertEqual(expected, capabilities._info)

View File

@@ -89,13 +89,13 @@ class ConsistencygroupsTest(utils.TestCase):
cs.assert_called('PUT', '/consistencygroups/1234', body=expected)
def test_update_consistencygroup_none(self):
self.assertEqual(None, cs.consistencygroups.update('1234'))
self.assertIsNone(cs.consistencygroups.update('1234'))
def test_update_consistencygroup_no_props(self):
cs.consistencygroups.update('1234')
def test_create_consistencygroup_from_src(self):
cs.consistencygroups.create_from_src('5678', name='cg')
def test_create_consistencygroup_from_src_snap(self):
cs.consistencygroups.create_from_src('5678', None, name='cg')
expected = {
'consistencygroup-from-src': {
'status': 'creating',
@@ -103,7 +103,24 @@ class ConsistencygroupsTest(utils.TestCase):
'user_id': None,
'name': 'cg',
'cgsnapshot_id': '5678',
'project_id': None
'project_id': None,
'source_cgid': None
}
}
cs.assert_called('POST', '/consistencygroups/create_from_src',
body=expected)
def test_create_consistencygroup_from_src_cg(self):
cs.consistencygroups.create_from_src(None, '5678', name='cg')
expected = {
'consistencygroup-from-src': {
'status': 'creating',
'description': None,
'user_id': None,
'name': 'cg',
'source_cgid': '5678',
'project_id': None,
'cgsnapshot_id': None
}
}
cs.assert_called('POST', '/consistencygroups/create_from_src',

View File

@@ -21,6 +21,8 @@ from six.moves.urllib import parse
from cinderclient import client
from cinderclient import exceptions
from cinderclient import shell
from cinderclient.v2 import volumes
from cinderclient.v2 import shell as test_shell
from cinderclient.tests.unit import utils
from cinderclient.tests.unit.v2 import fakes
from cinderclient.tests.unit.fixture_data import keystone_client
@@ -55,6 +57,15 @@ class ShellTest(utils.TestCase):
'GET', keystone_client.BASE_URL,
text=keystone_client.keystone_request_callback)
self.cs = mock.Mock()
def _make_args(self, args):
class Args(object):
def __init__(self, entries):
self.__dict__.update(entries)
return Args(args)
def tearDown(self):
# For some methods like test_image_meta_bad_action we are
# testing a SystemExit to be thrown and object self.shell has
@@ -265,7 +276,8 @@ class ShellTest(utils.TestCase):
with mock.patch('cinderclient.utils.print_list') as mock_print:
self.run_command(cmd)
mock_print.assert_called_once_with(
mock.ANY, mock.ANY, sortby_index=None)
mock.ANY, mock.ANY, exclude_unavailable=True,
sortby_index=None)
def test_list_reorder_without_sort(self):
# sortby_index is 0 without sort information
@@ -273,7 +285,8 @@ class ShellTest(utils.TestCase):
with mock.patch('cinderclient.utils.print_list') as mock_print:
self.run_command(cmd)
mock_print.assert_called_once_with(
mock.ANY, mock.ANY, sortby_index=0)
mock.ANY, mock.ANY, exclude_unavailable=True,
sortby_index=0)
def test_list_availability_zone(self):
self.run_command('availability-zone-list')
@@ -359,10 +372,38 @@ class ShellTest(utils.TestCase):
self.run_command('backup-create 1234 --incremental')
self.assert_called('POST', '/backups')
def test_backup_force(self):
self.run_command('backup-create 1234 --force')
self.assert_called('POST', '/backups')
def test_restore(self):
self.run_command('backup-restore 1234')
self.assert_called('POST', '/backups/1234/restore')
@mock.patch('cinderclient.utils.print_dict')
@mock.patch('cinderclient.utils.find_volume')
def test_do_backup_restore(self,
mock_find_volume,
mock_print_dict):
backup_id = '1234'
volume_id = '5678'
input = {
'backup': backup_id,
'volume': volume_id
}
args = self._make_args(input)
with mock.patch.object(self.cs.restores,
'restore') as mocked_restore:
mock_find_volume.return_value = volumes.Volume(self,
{'id': volume_id},
loaded = True)
test_shell.do_backup_restore(self.cs, args)
mocked_restore.assert_called_once_with(
input['backup'],
volume_id)
self.assertTrue(mock_print_dict.called)
def test_record_export(self):
self.run_command('backup-export 1234')
self.assert_called('GET', '/backups/1234/export_record')
@@ -455,6 +496,25 @@ class ShellTest(utils.TestCase):
expected = {'os-reset_status': {'status': 'error'}}
self.assert_called('POST', '/volumes/1234/action', body=expected)
def test_reset_state_with_attach_status(self):
self.run_command('reset-state --attach-status detached 1234')
expected = {'os-reset_status': {'status': 'available',
'attach_status': 'detached'}}
self.assert_called('POST', '/volumes/1234/action', body=expected)
def test_reset_state_with_attach_status_with_flag(self):
self.run_command('reset-state --state in-use '
'--attach-status attached 1234')
expected = {'os-reset_status': {'status': 'in-use',
'attach_status': 'attached'}}
self.assert_called('POST', '/volumes/1234/action', body=expected)
def test_reset_state_with_reset_migration_status(self):
self.run_command('reset-state --reset-migration-status 1234')
expected = {'os-reset_status': {'status': 'available',
'migration_status': 'none'}}
self.assert_called('POST', '/volumes/1234/action', body=expected)
def test_reset_state_multiple(self):
self.run_command('reset-state 1234 5678 --state error')
expected = {'os-reset_status': {'status': 'error'}}
@@ -671,17 +731,38 @@ class ShellTest(utils.TestCase):
self.assert_called_anytime('GET', '/types/1')
def test_migrate_volume(self):
self.run_command('migrate 1234 fakehost --force-host-copy=True')
self.run_command('migrate 1234 fakehost --force-host-copy=True '
'--lock-volume=True')
expected = {'os-migrate_volume': {'force_host_copy': 'True',
'lock_volume': 'True',
'host': 'fakehost'}}
self.assert_called('POST', '/volumes/1234/action', body=expected)
def test_migrate_volume_bool_force(self):
self.run_command('migrate 1234 fakehost --force-host-copy')
self.run_command('migrate 1234 fakehost --force-host-copy '
'--lock-volume')
expected = {'os-migrate_volume': {'force_host_copy': True,
'lock_volume': True,
'host': 'fakehost'}}
self.assert_called('POST', '/volumes/1234/action', body=expected)
def test_migrate_volume_bool_force_false(self):
# Set both --force-host-copy and --lock-volume to False.
self.run_command('migrate 1234 fakehost --force-host-copy=False '
'--lock-volume=False')
expected = {'os-migrate_volume': {'force_host_copy': 'False',
'lock_volume': 'False',
'host': 'fakehost'}}
self.assert_called('POST', '/volumes/1234/action', body=expected)
# Do not set the values to --force-host-copy and --lock-volume.
self.run_command('migrate 1234 fakehost')
expected = {'os-migrate_volume': {'force_host_copy': False,
'lock_volume': False,
'host': 'fakehost'}}
self.assert_called('POST', '/volumes/1234/action',
body=expected)
def test_snapshot_metadata_set(self):
self.run_command('snapshot-metadata 1234 set key1=val1 key2=val2')
self.assert_called('POST', '/snapshots/1234/metadata',
@@ -913,7 +994,7 @@ class ShellTest(utils.TestCase):
self.run_command,
'consisgroup-update 1234')
def test_consistencygroup_create_from_src(self):
def test_consistencygroup_create_from_src_snap(self):
self.run_command('consisgroup-create-from-src '
'--name cg '
'--cgsnapshot 1234')
@@ -924,14 +1005,89 @@ class ShellTest(utils.TestCase):
'description': None,
'user_id': None,
'project_id': None,
'status': 'creating'
'status': 'creating',
'source_cgid': None
}
}
self.assert_called('POST', '/consistencygroups/create_from_src',
expected)
def test_consistencygroup_create_from_src_bad_request(self):
def test_consistencygroup_create_from_src_cg(self):
self.run_command('consisgroup-create-from-src '
'--name cg '
'--source-cg 1234')
expected = {
'consistencygroup-from-src': {
'name': 'cg',
'cgsnapshot_id': None,
'description': None,
'user_id': None,
'project_id': None,
'status': 'creating',
'source_cgid': '1234'
}
}
self.assert_called('POST', '/consistencygroups/create_from_src',
expected)
def test_consistencygroup_create_from_src_fail_no_snap_cg(self):
self.assertRaises(exceptions.BadRequest,
self.run_command,
'consisgroup-create-from-src '
'--name cg')
def test_consistencygroup_create_from_src_fail_both_snap_cg(self):
self.assertRaises(exceptions.BadRequest,
self.run_command,
'consisgroup-create-from-src '
'--name cg '
'--cgsnapshot 1234 '
'--source-cg 5678')
def test_set_image_metadata(self):
self.run_command('image-metadata 1234 set key1=val1')
expected = {"os-set_image_metadata": {"metadata": {"key1": "val1"}}}
self.assert_called('POST', '/volumes/1234/action',
body=expected)
def test_unset_image_metadata(self):
self.run_command('image-metadata 1234 unset key1')
expected = {"os-unset_image_metadata": {"key": "key1"}}
self.assert_called('POST', '/volumes/1234/action',
body=expected)
def _get_params_from_stack(self, pos=-1):
method, url = self.shell.cs.client.callstack[pos][0:2]
path, query = parse.splitquery(url)
params = parse.parse_qs(query)
return path, params
def test_backup_list_all_tenants(self):
self.run_command('backup-list --all-tenants=1 --name=bc '
'--status=available --volume-id=1234')
expected = {
'all_tenants': ['1'],
'name': ['bc'],
'status': ['available'],
'volume_id': ['1234'],
}
path, params = self._get_params_from_stack()
self.assertEqual('/backups/detail', path)
self.assertEqual(4, len(params))
for k in params.keys():
self.assertEqual(expected[k], params[k])
def test_backup_list_volume_id(self):
self.run_command('backup-list --volume-id=1234')
self.assert_called('GET', '/backups/detail?volume_id=1234')
def test_backup_list(self):
self.run_command('backup-list')
self.assert_called('GET', '/backups/detail')
def test_get_capabilities(self):
self.run_command('get-capabilities host')
self.assert_called('GET', '/capabilities/host')

View File

@@ -15,6 +15,7 @@
from cinderclient.tests.unit import utils
from cinderclient.tests.unit.v2 import fakes
from cinderclient.v2 import volume_backups_restore
cs = fakes.FakeClient()
@@ -36,6 +37,11 @@ class VolumeBackupsTest(utils.TestCase):
None, None, True)
cs.assert_called('POST', '/backups')
def test_create_force(self):
cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4',
None, None, False, True)
cs.assert_called('POST', '/backups')
def test_get(self):
backup_id = '76a17945-3c6f-435c-975b-b5685db10b62'
cs.backups.get(backup_id)
@@ -59,8 +65,10 @@ class VolumeBackupsTest(utils.TestCase):
def test_restore(self):
backup_id = '76a17945-3c6f-435c-975b-b5685db10b62'
cs.restores.restore(backup_id)
info = cs.restores.restore(backup_id)
cs.assert_called('POST', '/backups/%s/restore' % backup_id)
self.assertIsInstance(info,
volume_backups_restore.VolumeBackupsRestore)
def test_record_export(self):
backup_id = '76a17945-3c6f-435c-975b-b5685db10b62'

View File

@@ -160,14 +160,31 @@ class VolumesTest(utils.TestCase):
cs.volumes.extend(v, 2)
cs.assert_called('POST', '/volumes/1234/action')
def test_reset_state(self):
v = cs.volumes.get('1234')
cs.volumes.reset_state(v, 'in-use', attach_status='detached',
migration_status='none')
cs.assert_called('POST', '/volumes/1234/action')
def test_get_encryption_metadata(self):
cs.volumes.get_encryption_metadata('1234')
cs.assert_called('GET', '/volumes/1234/encryption')
def test_migrate(self):
v = cs.volumes.get('1234')
cs.volumes.migrate_volume(v, 'dest', False)
cs.assert_called('POST', '/volumes/1234/action')
cs.volumes.migrate_volume(v, 'dest', False, False)
cs.assert_called('POST', '/volumes/1234/action',
{'os-migrate_volume': {'host': 'dest',
'force_host_copy': False,
'lock_volume': False}})
def test_migrate_with_lock_volume(self):
v = cs.volumes.get('1234')
cs.volumes.migrate_volume(v, 'dest', False, True)
cs.assert_called('POST', '/volumes/1234/action',
{'os-migrate_volume': {'host': 'dest',
'force_host_copy': False,
'lock_volume': True}})
def test_metadata_update_all(self):
cs.volumes.update_all_metadata(1234, {'k1': 'v1'})
@@ -235,7 +252,7 @@ class FormatSortParamTestCase(utils.TestCase):
def test_format_sort_empty_input(self):
for s in [None, '', []]:
self.assertEqual(None, cs.volumes._format_sort_param(s))
self.assertIsNone(cs.volumes._format_sort_param(s))
def test_format_sort_string_single_key(self):
s = 'id'

View File

@@ -110,11 +110,14 @@ def _print(pt, order):
print(strutils.safe_encode(pt.get_string(sortby=order)))
def print_list(objs, fields, formatters=None, sortby_index=0):
def print_list(objs, fields, exclude_unavailable=False, formatters=None,
sortby_index=0):
'''Prints a list of objects.
@param objs: Objects to print
@param fields: Fields on each object to be printed
@param exclude_unavailable: Boolean to decide if unavailable fields are
removed
@param formatters: Custom field formatters
@param sortby_index: Results sorted against the key in the fields list at
this index; if None then the object order is not
@@ -122,12 +125,14 @@ def print_list(objs, fields, formatters=None, sortby_index=0):
'''
formatters = formatters or {}
mixed_case_fields = ['serverId']
pt = prettytable.PrettyTable([f for f in fields], caching=False)
pt.aligns = ['l' for f in fields]
removed_fields = []
rows = []
for o in objs:
row = []
for field in fields:
if field in removed_fields:
continue
if field in formatters:
row.append(formatters[field](o))
else:
@@ -138,10 +143,24 @@ def print_list(objs, fields, formatters=None, sortby_index=0):
if type(o) == dict and field in o:
data = o[field]
else:
data = getattr(o, field_name, '')
if not hasattr(o, field_name) and exclude_unavailable:
removed_fields.append(field)
continue
else:
data = getattr(o, field_name, '')
if data is None:
data = '-'
if isinstance(data, six.string_types) and "\r" in data:
data = data.replace("\r", " ")
row.append(data)
rows.append(row)
for f in removed_fields:
fields.remove(f)
pt = prettytable.PrettyTable((f for f in fields), caching=False)
pt.aligns = ['l' for f in fields]
for row in rows:
pt.add_row(row)
if sortby_index is None:
@@ -154,7 +173,11 @@ def print_list(objs, fields, formatters=None, sortby_index=0):
def print_dict(d, property="Property"):
pt = prettytable.PrettyTable([property, 'Value'], caching=False)
pt.aligns = ['l', 'l']
[pt.add_row(list(r)) for r in six.iteritems(d)]
for r in six.iteritems(d):
r = list(r)
if isinstance(r[1], six.string_types) and "\r" in r[1]:
r[1] = r[1].replace("\r", " ")
pt.add_row(r)
_print(pt, property)

View File

@@ -315,6 +315,7 @@ def do_delete(cs, args):
for volume in args.volume:
try:
utils.find_volume(cs, volume).delete()
print("Request to delete volume %s has been accepted." % (volume))
except Exception as e:
failure_count += 1
print("Delete for volume %s failed: %s" % (volume, e))
@@ -346,8 +347,8 @@ def do_force_delete(cs, args):
'Separate multiple volumes with a space.')
@utils.arg('--state', metavar='<state>', default='available',
help=('The state to assign to the volume. Valid values are '
'"available," "error," "creating," "deleting," "in-use," '
'"attaching," "detaching" and "error_deleting." '
'"available", "error", "creating", "deleting", "in-use", '
'"attaching", "detaching" and "error_deleting". '
'NOTE: This command simply changes the state of the '
'Volume in the DataBase with no regard to actual status, '
'exercise caution when using. Default=available.'))
@@ -558,8 +559,8 @@ def do_snapshot_rename(cs, args):
help='Name or ID of snapshot to modify.')
@utils.arg('--state', metavar='<state>', default='available',
help=('The state to assign to the snapshot. Valid values are '
'"available," "error," "creating," "deleting," and '
'"error_deleting." NOTE: This command simply changes '
'"available", "error", "creating", "deleting", and '
'"error_deleting". NOTE: This command simply changes '
'the state of the Snapshot in the DataBase with no regard '
'to actual status, exercise caution when using. '
'Default=available.'))

View File

@@ -0,0 +1,39 @@
# Copyright (c) 2015 Hitachi Data Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Capabilities interface (v2 extension)"""
from cinderclient import base
class Capabilities(base.Resource):
NAME_ATTR = 'name'
def __repr__(self):
return "<Capabilities: %s>" % self.name
class CapabilitiesManager(base.Manager):
"""Manage :class:`Capabilities` resources."""
resource_class = Capabilities
def get(self, host):
"""Show backend volume stats and properties.
:param host: Specified backend to obtain volume stats and properties.
:rtype: :class:`Capabilities`
"""
return self._get('/capabilities/%s' % host, None)

View File

@@ -17,6 +17,7 @@ from cinderclient import client
from cinderclient.v2 import availability_zones
from cinderclient.v2 import cgsnapshots
from cinderclient.v2 import consistencygroups
from cinderclient.v2 import capabilities
from cinderclient.v2 import limits
from cinderclient.v2 import pools
from cinderclient.v2 import qos_specs
@@ -80,6 +81,7 @@ class Client(object):
self.availability_zones = \
availability_zones.AvailabilityZoneManager(self)
self.pools = pools.PoolManager(self)
self.capabilities = capabilities.CapabilitiesManager(self)
# Add in any extensions...
if extensions:

View File

@@ -67,12 +67,13 @@ class ConsistencygroupManager(base.ManagerWithFind):
return self._create('/consistencygroups', body, 'consistencygroup')
def create_from_src(self, cgsnapshot_id, name=None,
def create_from_src(self, cgsnapshot_id, source_cgid, name=None,
description=None, user_id=None,
project_id=None):
"""Creates a consistencygroup from a cgsnapshot.
"""Creates a consistencygroup from a cgsnapshot or a source CG.
:param cgsnapshot_id: UUID of a CGSnapshot
:param source_cgid: UUID of a source CG
:param name: Name of the ConsistencyGroup
:param description: Description of the ConsistencyGroup
:param user_id: User id derived from context
@@ -82,6 +83,7 @@ class ConsistencygroupManager(base.ManagerWithFind):
body = {'consistencygroup-from-src': {'name': name,
'description': description,
'cgsnapshot_id': cgsnapshot_id,
'source_cgid': source_cgid,
'user_id': user_id,
'project_id': project_id,
'status': "creating",

View File

@@ -160,6 +160,11 @@ def _extract_metadata(args):
metavar='<status>',
default=None,
help='Filters results by a status. Default=None.')
@utils.arg('--migration_status',
metavar='<migration_status>',
default=None,
help='Filters results by a migration status. Default=None. '
'Admin only.')
@utils.arg('--metadata',
type=str,
nargs='*',
@@ -212,6 +217,7 @@ def do_list(cs, args):
'project_id': args.tenant,
'name': args.name,
'status': args.status,
'migration_status': args.migration_status,
'metadata': _extract_metadata(args) if args.metadata else None,
}
@@ -233,18 +239,19 @@ def do_list(cs, args):
setattr(vol, 'attached_to', ','.join(map(str, servers)))
if all_tenants:
key_list = ['ID', 'Tenant ID', 'Status', 'Name',
key_list = ['ID', 'Tenant ID', 'Status', 'Migration Status', 'Name',
'Size', 'Volume Type', 'Bootable', 'Multiattach',
'Attached to']
else:
key_list = ['ID', 'Status', 'Name',
key_list = ['ID', 'Status', 'Migration Status', 'Name',
'Size', 'Volume Type', 'Bootable',
'Multiattach', 'Attached to']
if args.sort_key or args.sort_dir or args.sort:
sortby_index = None
else:
sortby_index = 0
utils.print_list(volumes, key_list, sortby_index=sortby_index)
utils.print_list(volumes, key_list, exclude_unavailable=True,
sortby_index=sortby_index)
@utils.arg('volume',
@@ -420,6 +427,7 @@ def do_delete(cs, args):
for volume in args.volume:
try:
utils.find_volume(cs, volume).delete()
print("Request to delete volume %s has been accepted." % (volume))
except Exception as e:
failure_count += 1
print("Delete for volume %s failed: %s" % (volume, e))
@@ -450,11 +458,22 @@ def do_force_delete(cs, args):
help='Name or ID of volume to modify.')
@utils.arg('--state', metavar='<state>', default='available',
help=('The state to assign to the volume. Valid values are '
'"available," "error," "creating," "deleting," "in-use," '
'"attaching," "detaching" and "error_deleting." '
'"available", "error", "creating", "deleting", "in-use", '
'"attaching", "detaching", "error_deleting" and '
'"maintenance". '
'NOTE: This command simply changes the state of the '
'Volume in the DataBase with no regard to actual status, '
'exercise caution when using. Default=available.'))
@utils.arg('--attach-status', metavar='<attach-status>', default=None,
help=('The attach status to assign to the volume in the DataBase, '
'with no regard to the actual status. Valid values are '
'"attached" and "detached". Default=None, that means the '
'status is unchanged.'))
@utils.arg('--reset-migration-status',
action='store_true',
help=('Clears the migration status of the volume in the DataBase '
'that indicates the volume is source or destination of '
'volume migration, with no regard to the actual status.'))
@utils.service_type('volumev2')
def do_reset_state(cs, args):
"""Explicitly updates the volume state in the Cinder database.
@@ -466,10 +485,13 @@ def do_reset_state(cs, args):
unusable in the case of change to the 'available' state.
"""
failure_flag = False
migration_status = 'none' if args.reset_migration_status else None
for volume in args.volume:
try:
utils.find_volume(cs, volume).reset_state(args.state)
utils.find_volume(cs, volume).reset_state(args.state,
args.attach_status,
migration_status)
except Exception as e:
failure_flag = True
msg = "Reset state for volume %s failed: %s" % (volume, e)
@@ -540,6 +562,32 @@ def do_metadata(cs, args):
reverse=True))
@utils.arg('volume',
metavar='<volume>',
help='Name or ID of volume for which to update metadata.')
@utils.arg('action',
metavar='<action>',
choices=['set', 'unset'],
help="The action. Valid values are 'set' or 'unset.'")
@utils.arg('metadata',
metavar='<key=value>',
nargs='+',
default=[],
help='Metadata key and value pair to set or unset. '
'For unset, specify only the key.')
@utils.service_type('volumev2')
def do_image_metadata(cs, args):
"""Sets or deletes volume image metadata."""
volume = utils.find_volume(cs, args.volume)
metadata = _extract_metadata(args)
if args.action == 'set':
cs.volumes.set_image_metadata(volume, metadata)
elif args.action == 'unset':
cs.volumes.delete_image_metadata(volume, sorted(metadata.keys(),
reverse=True))
@utils.arg('--all-tenants',
dest='all_tenants',
metavar='<0|1>',
@@ -714,8 +762,8 @@ def do_snapshot_rename(cs, args):
@utils.arg('--state', metavar='<state>',
default='available',
help=('The state to assign to the snapshot. Valid values are '
'"available," "error," "creating," "deleting," and '
'"error_deleting." NOTE: This command simply changes '
'"available", "error", "creating", "deleting", and '
'"error_deleting". NOTE: This command simply changes '
'the state of the Snapshot in the DataBase with no regard '
'to actual status, exercise caution when using. '
'Default=available.'))
@@ -1126,11 +1174,31 @@ def do_upload_to_image(cs, args):
help='Enables or disables generic host-based '
'force-migration, which bypasses driver '
'optimizations. Default=False.')
@utils.arg('--lock-volume', metavar='<True|False>',
choices=['True', 'False'],
required=False,
const=True,
nargs='?',
default=False,
help='Enables or disables the termination of volume migration '
'caused by other commands. This option applies to the '
'available volume. True means it locks the volume '
'state and does not allow the migration to be aborted. The '
'volume status will be in maintenance during the '
'migration. False means it allows the volume migration '
'to be aborted. The volume status is still in the original '
'status. Default=False.')
@utils.service_type('volumev2')
def do_migrate(cs, args):
"""Migrates volume to a new host."""
volume = utils.find_volume(cs, args.volume)
volume.migrate_volume(args.host, args.force_host_copy)
try:
volume.migrate_volume(args.host, args.force_host_copy,
args.lock_volume)
print("Request to migrate volume %s has been accepted." % (volume))
except Exception as e:
print("Migration for volume %s failed: %s." % (volume,
six.text_type(e)))
@utils.arg('volume', metavar='<volume>',
@@ -1166,6 +1234,15 @@ def do_retype(cs, args):
action='store_true',
help='Incremental backup. Default=False.',
default=False)
@utils.arg('--force',
action='store_true',
help='Allows or disallows backup of a volume '
'when the volume is attached to an instance. '
'If set to True, backs up the volume whether '
'its status is "available" or "in-use". The backup '
'of an "in-use" volume means your data is crash '
'consistent. Default=False.',
default=False)
@utils.service_type('volumev2')
def do_backup_create(cs, args):
"""Creates a volume backup."""
@@ -1180,7 +1257,8 @@ def do_backup_create(cs, args):
args.container,
args.name,
args.description,
args.incremental)
args.incremental,
args.force)
info = {"volume_id": volume.id}
info.update(backup._info)
@@ -1203,10 +1281,45 @@ def do_backup_show(cs, args):
utils.print_dict(info)
@utils.arg('--all-tenants',
metavar='<all_tenants>',
nargs='?',
type=int,
const=1,
default=0,
help='Shows details for all tenants. Admin only.')
@utils.arg('--all_tenants',
nargs='?',
type=int,
const=1,
help=argparse.SUPPRESS)
@utils.arg('--name',
metavar='<name>',
default=None,
help='Filters results by a name. Default=None.')
@utils.arg('--status',
metavar='<status>',
default=None,
help='Filters results by a status. Default=None.')
@utils.arg('--volume-id',
metavar='<volume-id>',
default=None,
help='Filters results by a volume ID. Default=None.')
@utils.arg('--volume_id',
help=argparse.SUPPRESS)
@utils.service_type('volumev2')
def do_backup_list(cs, args):
"""Lists all backups."""
backups = cs.backups.list()
search_opts = {
'all_tenants': args.all_tenants,
'name': args.name,
'status': args.status,
'volume_id': args.volume_id,
}
backups = cs.backups.list(search_opts=search_opts)
_translate_volume_snapshot_keys(backups)
columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', 'Object Count',
'Container']
utils.print_list(backups, columns)
@@ -1238,7 +1351,15 @@ def do_backup_restore(cs, args):
volume_id = utils.find_volume(cs, vol).id
else:
volume_id = None
cs.restores.restore(args.backup, volume_id)
restore = cs.restores.restore(args.backup, volume_id)
info = {"backup_id": args.backup}
info.update(restore._info)
info.pop('links', None)
utils.print_dict(info)
@utils.arg('backup', metavar='<backup>',
@@ -1934,7 +2055,9 @@ def do_unmanage(cs, args):
@utils.arg('volume', metavar='<volume>',
help='Name or ID of the volume to promote.')
help='Name or ID of the volume to promote. '
'The volume should have the replica volume created with '
'source-replica argument.')
@utils.service_type('volumev2')
def do_replication_promote(cs, args):
"""Promote a secondary volume to primary for a relationship."""
@@ -1943,7 +2066,8 @@ def do_replication_promote(cs, args):
@utils.arg('volume', metavar='<volume>',
help='Name or ID of the volume to reenable replication.')
help='Name or ID of the volume to reenable replication. '
'The replication-status of the volume should be inactive.')
@utils.service_type('volumev2')
def do_replication_reenable(cs, args):
"""Sync the secondary volume with primary for a relationship."""
@@ -2017,6 +2141,9 @@ def do_consisgroup_create(cs, args):
@utils.arg('--cgsnapshot',
metavar='<cgsnapshot>',
help='Name or ID of a cgsnapshot. Default=None.')
@utils.arg('--source-cg',
metavar='<source-cg>',
help='Name or ID of a source CG. Default=None.')
@utils.arg('--name',
metavar='<name>',
help='Name of a consistency group. Default=None.')
@@ -2025,14 +2152,24 @@ def do_consisgroup_create(cs, args):
help='Description of a consistency group. Default=None.')
@utils.service_type('volumev2')
def do_consisgroup_create_from_src(cs, args):
"""Creates a consistency group from a cgsnapshot."""
if not args.cgsnapshot:
msg = ('Cannot create consistency group because the source '
'cgsnapshot is not provided.')
"""Creates a consistency group from a cgsnapshot or a source CG."""
if not args.cgsnapshot and not args.source_cg:
msg = ('Cannot create consistency group because neither '
'cgsnapshot nor source CG is provided.')
raise exceptions.BadRequest(code=400, message=msg)
cgsnapshot = _find_cgsnapshot(cs, args.cgsnapshot)
if args.cgsnapshot and args.source_cg:
msg = ('Cannot create consistency group because both '
'cgsnapshot and source CG are provided.')
raise exceptions.BadRequest(code=400, message=msg)
cgsnapshot = None
if args.cgsnapshot:
cgsnapshot = _find_cgsnapshot(cs, args.cgsnapshot)
source_cg = None
if args.source_cg:
source_cg = _find_consistencygroup(cs, args.source_cg)
info = cs.consistencygroups.create_from_src(
cgsnapshot.id,
cgsnapshot.id if cgsnapshot else None,
source_cg.id if source_cg else None,
args.name,
args.description)
@@ -2221,3 +2358,20 @@ def do_get_pools(cs, args):
if args.detail:
backend.update(info['capabilities'])
utils.print_dict(backend)
@utils.arg('host',
metavar='<host>',
help='Cinder host to show backend volume stats and properties; '
'takes the form: host@backend-name')
@utils.service_type('volumev2')
def do_get_capabilities(cs, args):
"""Show backend volume stats and properties. Admin only."""
capabilities = cs.capabilities.get(args.host)
infos = dict()
infos.update(capabilities._info)
prop = infos.pop('properties', None)
utils.print_dict(infos, "Volume stats")
utils.print_dict(prop, "Backend properties")

View File

@@ -16,7 +16,7 @@
"""
Volume Backups interface (1.1 extension).
"""
from six.moves.urllib.parse import urlencode
from cinderclient import base
@@ -38,7 +38,7 @@ class VolumeBackupManager(base.ManagerWithFind):
def create(self, volume_id, container=None,
name=None, description=None,
incremental=False):
incremental=False, force=False):
"""Creates a volume backup.
:param volume_id: The ID of the volume to backup.
@@ -46,13 +46,15 @@ class VolumeBackupManager(base.ManagerWithFind):
:param name: The name of the backup.
:param description: The description of the backup.
:param incremental: Incremental backup.
:param force: If True, allows an in-use volume to be backed up.
:rtype: :class:`VolumeBackup`
"""
body = {'backup': {'volume_id': volume_id,
'container': container,
'name': name,
'description': description,
'incremental': incremental}}
'incremental': incremental,
'force': force, }}
return self._create('/backups', body, 'backup')
def get(self, backup_id):
@@ -68,10 +70,16 @@ class VolumeBackupManager(base.ManagerWithFind):
:rtype: list of :class:`VolumeBackup`
"""
if detailed is True:
return self._list("/backups/detail", "backups")
else:
return self._list("/backups", "backups")
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")
def delete(self, backup):
"""Delete a volume backup.

View File

@@ -98,6 +98,22 @@ class Volume(base.Resource):
"""
return self.manager.set_metadata(self, metadata)
def set_image_metadata(self, volume, metadata):
"""Set a volume's image metadata.
:param volume : The :class: `Volume` to set metadata on
:param metadata: A dict of key/value pairs to set
"""
return self.manager.set_image_metadata(self, volume, metadata)
def delete_image_metadata(self, volume, keys):
"""Delete specified keys from volume's image metadata.
:param volume: The :class:`Volume`.
:param keys: A list of keys to be removed.
"""
return self.manager.delete_image_metadata(self, volume, keys)
def upload_to_image(self, force, image_name, container_format,
disk_format):
"""Upload a volume to image service as an image."""
@@ -111,9 +127,16 @@ class Volume(base.Resource):
"""
self.manager.force_delete(self)
def reset_state(self, state):
"""Update the volume with the provided state."""
self.manager.reset_state(self, state)
def reset_state(self, state, attach_status=None, migration_status=None):
"""Update the volume with the provided state.
:param state: The state of the volume to set.
:param attach_status: The attach_status of the volume to be set,
or None to keep the current status.
:param migration_status: The migration_status of the volume to be set,
or None to keep the current status.
"""
self.manager.reset_state(self, state, attach_status, migration_status)
def extend(self, volume, new_size):
"""Extend the size of the specified volume.
@@ -123,9 +146,9 @@ class Volume(base.Resource):
"""
self.manager.extend(self, new_size)
def migrate_volume(self, host, force_host_copy):
def migrate_volume(self, host, force_host_copy, lock_volume):
"""Migrate the volume to a new host."""
self.manager.migrate_volume(self, host, force_host_copy)
self.manager.migrate_volume(self, host, force_host_copy, lock_volume)
def retype(self, volume_type, policy):
"""Change a volume's type."""
@@ -475,6 +498,26 @@ class VolumeManager(base.ManagerWithFind):
for k in keys:
self._delete("/volumes/%s/metadata/%s" % (base.getid(volume), k))
def set_image_metadata(self, volume, metadata):
"""Set a volume's image metadata.
:param volume: The :class:`Volume`.
:param metadata: keys and the values to be set with.
:type metadata: dict
"""
return self._action("os-set_image_metadata", volume,
{'metadata': metadata})
def delete_image_metadata(self, volume, keys):
"""Delete specified keys from volume's image metadata.
:param volume: The :class:`Volume`.
:param keys: A list of keys to be removed.
"""
for key in keys:
self._action("os-unset_image_metadata", volume,
{'key': key})
def upload_to_image(self, volume, force, image_name, container_format,
disk_format):
"""Upload volume to image service as image.
@@ -495,9 +538,23 @@ class VolumeManager(base.ManagerWithFind):
"""
return self._action('os-force_delete', base.getid(volume))
def reset_state(self, volume, state):
"""Update the provided volume with the provided state."""
return self._action('os-reset_status', volume, {'status': state})
def reset_state(self, volume, state, attach_status=None,
migration_status=None):
"""Update the provided volume with the provided state.
:param volume: The :class:`Volume` to set the state.
:param state: The state of the volume to be set.
:param attach_status: The attach_status of the volume to be set,
or None to keep the current status.
:param migration_status: The migration_status of the volume to be set,
or None to keep the current status.
"""
body = {'status': state}
if attach_status:
body.update({'attach_status': attach_status})
if migration_status:
body.update({'migration_status': migration_status})
return self._action('os-reset_status', volume, body)
def extend(self, volume, new_size):
"""Extend the size of the specified volume.
@@ -518,16 +575,19 @@ class VolumeManager(base.ManagerWithFind):
"""
return self._get("/volumes/%s/encryption" % volume_id)._info
def migrate_volume(self, volume, host, force_host_copy):
def migrate_volume(self, volume, host, force_host_copy, lock_volume):
"""Migrate volume to new host.
:param volume: The :class:`Volume` to migrate
:param host: The destination host
:param force_host_copy: Skip driver optimizations
:param lock_volume: Lock the volume and guarantee the migration
to finish
"""
return self._action('os-migrate_volume',
volume,
{'host': host, 'force_host_copy': force_host_copy})
{'host': host, 'force_host_copy': force_host_copy,
'lock_volume': lock_volume})
def migrate_volume_completion(self, old_volume, new_volume, error):
"""Complete the migration from the old volume to the temp new one.

View File

@@ -1,7 +1,7 @@
# The order of packages is significant, because pip processes them in the order
# of appearance. Changing the order has an impact on the overall integration
# process, which may cause wedges in the gate later.
pbr<2.0,>=0.11
pbr<2.0,>=1.6
argparse
PrettyTable<0.8,>=0.7
python-keystoneclient>=1.6.0

View File

@@ -25,5 +25,5 @@ except ImportError:
pass
setuptools.setup(
setup_requires=['pbr'],
setup_requires=['pbr>=1.3'],
pbr=True)

View File

@@ -6,7 +6,7 @@ hacking<0.11,>=0.10.0
coverage>=3.6
discover
fixtures>=1.3.1
mock>=1.0
mock>=1.2
oslosphinx>=2.5.0 # Apache-2.0
python-subunit>=0.0.18
requests-mock>=0.6.0 # Apache-2.0

View File

@@ -36,5 +36,5 @@ downloadcache = ~/cache/pip
[flake8]
show-source = True
ignore = F811,F821,H302,H306,H404,H405,E122,E123,E128,E251
ignore = F811,F821,H306,H404,H405,E122,E123,E128,E251
exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,tools