diff options
28 files changed, 483 insertions, 263 deletions
@@ -21,13 +21,13 @@ - ^cinderclient/tests/unit/.*$ - job: - name: python-cinderclient-functional-py36 + name: python-cinderclient-functional-py38 parent: python-cinderclient-functional-base - # need to specify a platform that has python 3.6 available - nodeset: devstack-single-node-centos-8-stream + # need to specify a platform that has python 3.8 available + nodeset: openstack-single-node-focal vars: - python_version: 3.6 - tox_envlist: functional-py36 + python_version: 3.8 + tox_envlist: functional-py38 - job: name: python-cinderclient-functional-py39 @@ -38,20 +38,22 @@ tox_envlist: functional-py39 - project: + vars: + ensure_tox_version: '<4' templates: - check-requirements - lib-forward-testing-python3 - openstack-cover-jobs - - openstack-python3-yoga-jobs + - openstack-python3-antelope-jobs - publish-openstack-docs-pti - release-notes-jobs-python3 check: jobs: - - python-cinderclient-functional-py36 + - python-cinderclient-functional-py38 - python-cinderclient-functional-py39 - openstack-tox-pylint: voting: false gate: jobs: - - python-cinderclient-functional-py36 + - python-cinderclient-functional-py38 - python-cinderclient-functional-py39 @@ -6,8 +6,6 @@ libffi-dev [platform:dpkg] libffi-devel [platform:rpm] libssl-dev [platform:ubuntu-xenial] locales [platform:debian] -python-dev [platform:dpkg] -python-devel [platform:rpm !platform:centos-8] python3-all-dev [platform:ubuntu !platform:ubuntu-precise] python3-dev [platform:dpkg] python3-devel [platform:rpm] diff --git a/cinderclient/api_versions.py b/cinderclient/api_versions.py index 48e00b7..5f6ad65 100644 --- a/cinderclient/api_versions.py +++ b/cinderclient/api_versions.py @@ -26,7 +26,7 @@ LOG = logging.getLogger(__name__) # key is unsupported version, value is appropriate supported alternative REPLACEMENT_VERSIONS = {"1": "3", "2": "3"} -MAX_VERSION = "3.66" +MAX_VERSION = "3.70" MIN_VERSION = "3.0" _SUBSTITUTIONS = {} diff --git a/cinderclient/client.py b/cinderclient/client.py index 6beb381..c99a2e7 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -20,6 +20,7 @@ import glob import hashlib import importlib.util import itertools +import json import logging import os import pkgutil @@ -47,11 +48,6 @@ except ImportError: from time import sleep try: - import json -except ImportError: - import simplejson as json - -try: osprofiler_web = importutils.try_import("osprofiler.web") except Exception: pass @@ -797,6 +793,8 @@ def discover_extensions(version): def _discover_via_python_path(): for (module_loader, name, ispkg) in pkgutil.iter_modules(): if name.endswith('cinderclient_ext'): + if not hasattr(module_loader, 'load_module'): + module_loader = module_loader.find_module(name) module = module_loader.load_module(name) yield name, module diff --git a/cinderclient/shell_utils.py b/cinderclient/shell_utils.py index cd8f162..65e8400 100644 --- a/cinderclient/shell_utils.py +++ b/cinderclient/shell_utils.py @@ -15,6 +15,8 @@ import sys import time +import prettytable + from cinderclient import exceptions from cinderclient import utils @@ -24,13 +26,109 @@ _quota_resources = ['volumes', 'snapshots', 'gigabytes', _quota_infos = ['Type', 'In_use', 'Reserved', 'Limit', 'Allocated'] +def _print(pt, order): + print(pt.get_string(sortby=order)) + + +def _pretty_format_dict(data_dict): + formatted_data = [] + + for k in sorted(data_dict): + formatted_data.append("%s : %s" % (k, data_dict[k])) + + return "\n".join(formatted_data) + + +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 + altered + ''' + formatters = formatters or {} + mixed_case_fields = ['serverId'] + 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: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + if isinstance(o, dict) and field in o: + data = o[field] + else: + 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, str) 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.align = 'l' + for row in rows: + count = 0 + # Converts unicode values in dictionary to string + for part in row: + count = count + 1 + if isinstance(part, dict): + row[count - 1] = part + pt.add_row(row) + + if sortby_index is None: + order_by = None + else: + order_by = fields[sortby_index] + _print(pt, order_by) + + +def print_dict(d, property="Property", formatters=None): + pt = prettytable.PrettyTable([property, 'Value'], caching=False) + pt.align = 'l' + formatters = formatters or {} + + for r in d.items(): + r = list(r) + + if r[0] in formatters: + if isinstance(r[1], dict): + r[1] = _pretty_format_dict(r[1]) + if isinstance(r[1], str) and "\r" in r[1]: + r[1] = r[1].replace("\r", " ") + pt.add_row(r) + _print(pt, property) + + def print_volume_image(image_resp_tuple): # image_resp_tuple = tuple (response, body) image = image_resp_tuple[1] vt = image['os-volume_upload_image'].get('volume_type') if vt is not None: image['os-volume_upload_image']['volume_type'] = vt.get('name') - utils.print_dict(image['os-volume_upload_image']) + print_dict(image['os-volume_upload_image']) def poll_for_status(poll_fn, obj_id, action, final_ok_states, @@ -120,7 +218,7 @@ def find_message(cs, message): def print_volume_snapshot(snapshot): - utils.print_dict(snapshot._info) + print_dict(snapshot._info) def translate_keys(collection, convert): @@ -188,16 +286,16 @@ def extract_metadata(args, type='user_metadata'): def print_volume_type_list(vtypes): - utils.print_list(vtypes, ['ID', 'Name', 'Description', 'Is_Public']) + print_list(vtypes, ['ID', 'Name', 'Description', 'Is_Public']) def print_group_type_list(gtypes): - utils.print_list(gtypes, ['ID', 'Name', 'Description']) + print_list(gtypes, ['ID', 'Name', 'Description']) def print_resource_filter_list(filters): formatter = {'Filters': lambda resource: ', '.join(resource.filters)} - utils.print_list(filters, ['Resource', 'Filters'], formatters=formatter) + print_list(filters, ['Resource', 'Filters'], formatters=formatter) def quota_show(quotas): @@ -211,7 +309,7 @@ def quota_show(quotas): if not good_name: continue quota_dict[resource] = getattr(quotas, resource, None) - utils.print_dict(quota_dict) + print_dict(quota_dict) def quota_usage_show(quotas): @@ -228,7 +326,7 @@ def quota_usage_show(quotas): quota_info['Type'] = resource quota_info = dict((k.capitalize(), v) for k, v in quota_info.items()) quota_list.append(quota_info) - utils.print_list(quota_list, _quota_infos) + print_list(quota_list, _quota_infos) def quota_update(manager, identifier, args): @@ -266,26 +364,26 @@ def print_volume_encryption_type_list(encryption_types): :param encryption_types: a list of :class: VolumeEncryptionType instances """ - utils.print_list(encryption_types, ['Volume Type ID', 'Provider', - 'Cipher', 'Key Size', - 'Control Location']) + print_list(encryption_types, ['Volume Type ID', 'Provider', + 'Cipher', 'Key Size', + 'Control Location']) def print_qos_specs(qos_specs): # formatters defines field to be converted from unicode to string - utils.print_dict(qos_specs._info, formatters=['specs']) + print_dict(qos_specs._info, formatters=['specs']) def print_qos_specs_list(q_specs): - utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) def print_qos_specs_and_associations_list(q_specs): - utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) def print_associations_list(associations): - utils.print_list(associations, ['Association_Type', 'Name', 'ID']) + print_list(associations, ['Association_Type', 'Name', 'ID']) def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states, @@ -305,7 +403,7 @@ def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states, if status in final_ok_states: break elif status == "error": - utils.print_dict(info) + print_dict(info) if global_request_id: search_opts = { 'request_id': global_request_id @@ -317,5 +415,5 @@ def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states, fault_msg = "Unknown error. Operation failed." raise exceptions.ResourceInErrorState(obj, fault_msg) elif time_elapsed == timeout_period: - utils.print_dict(info) + print_dict(info) raise exceptions.TimeoutException(obj, action) diff --git a/cinderclient/tests/unit/test_shell.py b/cinderclient/tests/unit/test_shell.py index 682d509..c5d64af 100644 --- a/cinderclient/tests/unit/test_shell.py +++ b/cinderclient/tests/unit/test_shell.py @@ -120,9 +120,9 @@ class ShellTest(utils.TestCase): # Some expected help output, including microversioned commands required = [ r'.*?^usage: ', - r'.*?(?m)^\s+create\s+Creates a volume.', - r'.*?(?m)^\s+summary\s+Get volumes summary.', - r'.*?(?m)^Run "cinder help SUBCOMMAND" for help on a subcommand.', + r'.*?^\s+create\s+Creates a volume.', + r'.*?^\s+summary\s+Get volumes summary.', + r'.*?^Run "cinder help SUBCOMMAND" for help on a subcommand.', ] help_text = self.shell('help') for r in required: @@ -132,7 +132,7 @@ class ShellTest(utils.TestCase): def test_help_on_subcommand(self): required = [ r'.*?^usage: cinder list', - r'.*?(?m)^Lists all volumes.', + r'.*?^Lists all volumes.', ] help_text = self.shell('help list') for r in required: @@ -142,7 +142,7 @@ class ShellTest(utils.TestCase): def test_help_on_subcommand_mv(self): required = [ r'.*?^usage: cinder summary', - r'.*?(?m)^Get volumes summary.', + r'.*?^Get volumes summary.', ] help_text = self.shell('help summary') for r in required: @@ -152,9 +152,9 @@ class ShellTest(utils.TestCase): def test_help_arg_no_subcommand(self): required = [ r'.*?^usage: ', - r'.*?(?m)^\s+create\s+Creates a volume.', - r'.*?(?m)^\s+summary\s+Get volumes summary.', - r'.*?(?m)^Run "cinder help SUBCOMMAND" for help on a subcommand.', + r'.*?^\s+create\s+Creates a volume.', + r'.*?^\s+summary\s+Get volumes summary.', + r'.*?^Run "cinder help SUBCOMMAND" for help on a subcommand.', ] help_text = self.shell('--os-volume-api-version 3.40') for r in required: @@ -376,7 +376,7 @@ class TestLoadVersionedActions(utils.TestCase): self.mock_completion() - def test_load_versioned_actions(self): + def test_load_versioned_actions_v3_0(self): parser = cinderclient.shell.CinderClientArgumentParser() subparsers = parser.add_subparsers(metavar='<subcommand>') shell = cinderclient.shell.OpenStackCinderShell() @@ -388,6 +388,10 @@ class TestLoadVersionedActions(utils.TestCase): "fake_action 3.0 to 3.1", shell.subcommands['fake-action'].get_default('func')()) + def test_load_versioned_actions_v3_2(self): + parser = cinderclient.shell.CinderClientArgumentParser() + subparsers = parser.add_subparsers(metavar='<subcommand>') + shell = cinderclient.shell.OpenStackCinderShell() shell.subcommands = {} shell._find_actions(subparsers, fake_actions_module, api_versions.APIVersion("3.2"), False, []) @@ -521,7 +525,7 @@ class TestLoadVersionedActions(utils.TestCase): @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, 'add_argument') - def test_load_actions_with_versioned_args(self, mock_add_arg): + def test_load_actions_with_versioned_args_v36(self, mock_add_arg): parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) subparsers = parser.add_subparsers(metavar='<subcommand>') shell = cinderclient.shell.OpenStackCinderShell() @@ -533,8 +537,13 @@ class TestLoadVersionedActions(utils.TestCase): self.assertNotIn(mock.call('--foo', help="second foo"), mock_add_arg.call_args_list) - mock_add_arg.reset_mock() - + @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, + 'add_argument') + def test_load_actions_with_versioned_args_v39(self, mock_add_arg): + parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) + subparsers = parser.add_subparsers(metavar='<subcommand>') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} shell._find_actions(subparsers, fake_actions_module, api_versions.APIVersion("3.9"), False, []) self.assertNotIn(mock.call('--foo', help="first foo"), @@ -545,7 +554,7 @@ class TestLoadVersionedActions(utils.TestCase): class ShellUtilsTest(utils.TestCase): - @mock.patch.object(cinderclient.utils, 'print_dict') + @mock.patch.object(cinderclient.shell_utils, 'print_dict') def test_print_volume_image(self, mock_print_dict): response = {'os-volume_upload_image': {'name': 'myimg1'}} image_resp_tuple = (202, response) diff --git a/cinderclient/tests/unit/test_utils.py b/cinderclient/tests/unit/test_utils.py index cce4498..69b0d04 100644 --- a/cinderclient/tests/unit/test_utils.py +++ b/cinderclient/tests/unit/test_utils.py @@ -220,7 +220,7 @@ class PrintListTestCase(test_utils.TestCase): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=3, b=4), Row(a=1, b=2)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b']) + shell_utils.print_list(to_print, ['a', 'b']) # Output should be sorted by the first key (a) self.assertEqual("""\ +---+---+ @@ -235,7 +235,7 @@ class PrintListTestCase(test_utils.TestCase): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=3, b=None), Row(a=1, b=2)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b']) + shell_utils.print_list(to_print, ['a', 'b']) # Output should be sorted by the first key (a) self.assertEqual("""\ +---+---+ @@ -250,7 +250,7 @@ class PrintListTestCase(test_utils.TestCase): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=4, b=3), Row(a=2, b=1)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b'], sortby_index=1) + shell_utils.print_list(to_print, ['a', 'b'], sortby_index=1) # Output should be sorted by the second key (b) self.assertEqual("""\ +---+---+ @@ -265,7 +265,7 @@ class PrintListTestCase(test_utils.TestCase): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=3, b=4), Row(a=1, b=2)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b'], sortby_index=None) + shell_utils.print_list(to_print, ['a', 'b'], sortby_index=None) # Output should be in the order given self.assertEqual("""\ +---+---+ @@ -283,7 +283,7 @@ class PrintListTestCase(test_utils.TestCase): for row in [Row(a=1, b=2), Row(a=3, b=4)]: yield row with CaptureStdout() as cso: - utils.print_list(gen_rows(), ['a', 'b']) + shell_utils.print_list(gen_rows(), ['a', 'b']) self.assertEqual("""\ +---+---+ | a | b | @@ -297,7 +297,7 @@ class PrintListTestCase(test_utils.TestCase): 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']) + shell_utils.print_list(to_print, ['a', 'b']) # Output should be sorted by the first key (a) self.assertEqual("""\ +---+-----+ @@ -314,13 +314,13 @@ class PrintDictTestCase(test_utils.TestCase): def test__pretty_format_dict(self): content = {'key1': 'value1', 'key2': 'value2'} expected = "key1 : value1\nkey2 : value2" - result = utils._pretty_format_dict(content) + result = shell_utils._pretty_format_dict(content) self.assertEqual(expected, result) 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) + shell_utils.print_dict(d) self.assertEqual("""\ +----------+---------------+ | Property | Value | @@ -337,7 +337,7 @@ class PrintDictTestCase(test_utils.TestCase): content = {'a': 'A', 'b': 'B', 'f_key': {'key1': 'value1', 'key2': 'value2'}} with CaptureStdout() as cso: - utils.print_dict(content, formatters='f_key') + shell_utils.print_dict(content, formatters='f_key') self.assertEqual("""\ +----------+---------------+ | Property | Value | diff --git a/cinderclient/tests/unit/v3/fakes.py b/cinderclient/tests/unit/v3/fakes.py index d39dd4b..f21c2ab 100644 --- a/cinderclient/tests/unit/v3/fakes.py +++ b/cinderclient/tests/unit/v3/fakes.py @@ -453,6 +453,8 @@ class FakeHTTPClient(fakes_base.FakeHTTPClient): 'failover_replication', 'list_replication_targets', 'reset_status'): assert action in body + elif action == 'os-reimage': + assert 'image_id' in body[action] else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, {}) diff --git a/cinderclient/tests/unit/v3/fakes_base.py b/cinderclient/tests/unit/v3/fakes_base.py index ec75ff0..b5f2728 100644 --- a/cinderclient/tests/unit/v3/fakes_base.py +++ b/cinderclient/tests/unit/v3/fakes_base.py @@ -550,6 +550,8 @@ class FakeHTTPClient(base_client.HTTPClient): _body = body elif action == 'revert': assert 'snapshot_id' in body[action] + elif action == 'os-reimage': + assert 'image_id' in body[action] else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) diff --git a/cinderclient/tests/unit/v3/test_shell.py b/cinderclient/tests/unit/v3/test_shell.py index 8f00525..58caddc 100644 --- a/cinderclient/tests/unit/v3/test_shell.py +++ b/cinderclient/tests/unit/v3/test_shell.py @@ -99,6 +99,14 @@ class ShellTest(utils.TestCase): api_versions.APIVersion('3.99'))): self.shell.main(cmd.split()) + def run_command_with_server_api_max(self, api_max, cmd): + # version negotiation will use the supplied api_max, which must be + # a string value, as the server's max supported version + with mock.patch('cinderclient.api_versions._get_server_version_range', + return_value=(api_versions.APIVersion('3.0'), + api_versions.APIVersion(api_max))): + self.shell.main(cmd.split()) + def assert_called(self, method, url, body=None, partial_body=None, **kwargs): return self.shell.cs.assert_called(method, url, body, @@ -344,7 +352,7 @@ class ShellTest(utils.TestCase): self.run_command(command) self.assert_called('GET', '/volumes/detail?group_id=fake_id') - @mock.patch("cinderclient.utils.print_list") + @mock.patch("cinderclient.shell_utils.print_list") def test_list_duplicate_fields(self, mock_print): self.run_command('list --field Status,id,Size,status') self.assert_called('GET', '/volumes/detail') @@ -520,7 +528,7 @@ class ShellTest(utils.TestCase): self.run_command(command) self.assert_called('GET', '/attachments%s' % expected) - @mock.patch('cinderclient.utils.print_list') + @mock.patch('cinderclient.shell_utils.print_list') @mock.patch.object(cinderclient.v3.attachments.VolumeAttachmentManager, 'list') def test_attachment_list_setattr(self, mock_list, mock_print): @@ -918,6 +926,41 @@ class ShellTest(utils.TestCase): f'snapshot-create --force {force_value} 123456') self.assert_called_anytime('POST', '/snapshots', body=snap_body_3_65) + @mock.patch('cinderclient.shell.CinderClientArgumentParser.exit') + def test_snapshot_create_pre_3_66_with_naked_force( + self, mock_exit): + mock_exit.side_effect = Exception("mock exit") + try: + self.run_command('--os-volume-api-version 3.65 ' + 'snapshot-create --force 123456') + except Exception as e: + # ignore the exception (it's raised to simulate an exit), + # but make sure it's the exception we expect + self.assertEqual('mock exit', str(e)) + + exit_code = mock_exit.call_args.args[0] + self.assertEqual(2, exit_code) + + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_pre_3_66_with_force_None( + self, mock_find_vol): + """We will let the API detect the problematic value.""" + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + snap_body_3_65 = { + 'snapshot': { + 'volume_id': '123456', + # note: this is a string, NOT None! + 'force': 'None', + 'name': None, + 'description': None, + 'metadata': {} + } + } + self.run_command('--os-volume-api-version 3.65 ' + 'snapshot-create --force None 123456') + self.assert_called_anytime('POST', '/snapshots', body=snap_body_3_65) + SNAP_BODY_3_66 = { 'snapshot': { 'volume_id': '123456', @@ -953,6 +996,17 @@ class ShellTest(utils.TestCase): self.assertIn('not allowed after microversion 3.65', str(uae)) @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_3_66_with_force_None( + self, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + uae = self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + '--os-volume-api-version 3.66 ' + 'snapshot-create --force None 123456') + self.assertIn('not allowed after microversion 3.65', str(uae)) + + @mock.patch('cinderclient.utils.find_resource') def test_snapshot_create_3_66(self, mock_find_vol): mock_find_vol.return_value = volumes.Volume( self, {'id': '123456'}, loaded=True) @@ -961,6 +1015,28 @@ class ShellTest(utils.TestCase): self.assert_called_anytime('POST', '/snapshots', body=self.SNAP_BODY_3_66) + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_3_66_not_supported(self, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + self.run_command_with_server_api_max( + '3.64', + '--os-volume-api-version 3.66 snapshot-create 123456') + # call should be made, but will use the pre-3.66 request body + # because the client in use has been downgraded to 3.64 + pre_3_66_request_body = { + 'snapshot': { + 'volume_id': '123456', + # default value is False + 'force': False, + 'name': None, + 'description': None, + 'metadata': {} + } + } + self.assert_called_anytime('POST', '/snapshots', + body=pre_3_66_request_body) + def test_snapshot_manageable_list(self): self.run_command('--os-volume-api-version 3.8 ' 'snapshot-manageable-list fakehost') @@ -1209,7 +1285,7 @@ class ShellTest(utils.TestCase): get_levels_mock.assert_not_called() @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') - @mock.patch('cinderclient.utils.print_list') + @mock.patch('cinderclient.shell_utils.print_list') def test_service_get_log_no_params(self, print_mock, get_levels_mock): self.run_command('--os-volume-api-version 3.32 service-get-log') get_levels_mock.assert_called_once_with('', '', '') @@ -1220,7 +1296,7 @@ class ShellTest(utils.TestCase): @ddt.data('*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', 'cinder-backup') @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') - @mock.patch('cinderclient.utils.print_list') + @mock.patch('cinderclient.shell_utils.print_list') def test_service_get_log(self, binary, print_mock, get_levels_mock): server = 'host1' prefix = 'sqlalchemy' @@ -1378,7 +1454,7 @@ class ShellTest(utils.TestCase): 'availability_zone': 'AZ2'}} self.assert_called('POST', '/backups', body=expected) - @mock.patch("cinderclient.utils.print_list") + @mock.patch("cinderclient.shell_utils.print_list") def test_snapshot_list(self, mock_print_list): """Ensure we always present all existing fields when listing snaps.""" self.run_command('--os-volume-api-version 3.65 snapshot-list') @@ -1847,7 +1923,7 @@ class ShellTest(utils.TestCase): }, ) @ddt.unpack - @mock.patch('cinderclient.utils.print_dict') + @mock.patch('cinderclient.shell_utils.print_dict') @mock.patch('cinderclient.tests.unit.v3.fakes_base._stub_restore') def test_do_backup_restore(self, mock_stub_restore, @@ -1895,3 +1971,18 @@ class ShellTest(utils.TestCase): 'volume_id': '1234', 'volume_name': volume_name, }) + + def test_reimage(self): + self.run_command('--os-volume-api-version 3.68 reimage 1234 1') + expected = {'os-reimage': {'image_id': '1', + 'reimage_reserved': False}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + @ddt.data('False', 'True') + def test_reimage_reserved(self, reimage_reserved): + self.run_command( + '--os-volume-api-version 3.68 reimage --reimage-reserved %s 1234 1' + % reimage_reserved) + expected = {'os-reimage': {'image_id': '1', + 'reimage_reserved': reimage_reserved}} + self.assert_called('POST', '/volumes/1234/action', body=expected) diff --git a/cinderclient/tests/unit/v3/test_volumes.py b/cinderclient/tests/unit/v3/test_volumes.py index c733a09..1c2f8a2 100644 --- a/cinderclient/tests/unit/v3/test_volumes.py +++ b/cinderclient/tests/unit/v3/test_volumes.py @@ -201,3 +201,15 @@ class VolumesTest(utils.TestCase): 'force_host_copy': False, 'lock_volume': False}}) self._assert_request_id(vol) + + @ddt.data(False, True) + def test_reimage(self, reimage_reserved): + cs = fakes.FakeClient(api_versions.APIVersion('3.68')) + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.reimage(v, '1', reimage_reserved) + cs.assert_called('POST', '/volumes/1234/action', + {'os-reimage': {'image_id': '1', + 'reimage_reserved': + reimage_reserved}}) + self._assert_request_id(vol) diff --git a/cinderclient/utils.py b/cinderclient/utils.py index 99acc03..565c61a 100644 --- a/cinderclient/utils.py +++ b/cinderclient/utils.py @@ -18,7 +18,6 @@ import os from urllib import parse import uuid -import prettytable import stevedore from cinderclient import exceptions @@ -106,76 +105,6 @@ def isunauthenticated(f): return getattr(f, 'unauthenticated', False) -def _print(pt, order): - print(pt.get_string(sortby=order)) - - -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 - altered - ''' - formatters = formatters or {} - mixed_case_fields = ['serverId'] - 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: - if field in mixed_case_fields: - field_name = field.replace(' ', '_') - else: - field_name = field.lower().replace(' ', '_') - if isinstance(o, dict) and field in o: - data = o[field] - else: - 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, str) 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.align = 'l' - for row in rows: - count = 0 - # Converts unicode values in dictionary to string - for part in row: - count = count + 1 - if isinstance(part, dict): - row[count - 1] = part - pt.add_row(row) - - if sortby_index is None: - order_by = None - else: - order_by = fields[sortby_index] - _print(pt, order_by) - - def build_query_param(params, sort=False): """parse list to url query parameters""" @@ -206,32 +135,6 @@ def build_query_param(params, sort=False): return query_string -def _pretty_format_dict(data_dict): - formatted_data = [] - - for k in sorted(data_dict): - formatted_data.append("%s : %s" % (k, data_dict[k])) - - return "\n".join(formatted_data) - - -def print_dict(d, property="Property", formatters=None): - pt = prettytable.PrettyTable([property, 'Value'], caching=False) - pt.align = 'l' - formatters = formatters or {} - - for r in d.items(): - r = list(r) - - if r[0] in formatters: - if isinstance(r[1], dict): - r[1] = _pretty_format_dict(r[1]) - if isinstance(r[1], str) and "\r" in r[1]: - r[1] = r[1].replace("\r", " ") - pt.add_row(r) - _print(pt, property) - - def find_resource(manager, name_or_id, **kwargs): """Helper for the _find_* methods.""" is_group = kwargs.pop('is_group', False) diff --git a/cinderclient/v3/contrib/list_extensions.py b/cinderclient/v3/contrib/list_extensions.py index 937d34b..548cbec 100644 --- a/cinderclient/v3/contrib/list_extensions.py +++ b/cinderclient/v3/contrib/list_extensions.py @@ -14,7 +14,7 @@ # under the License. from cinderclient import base -from cinderclient import utils +from cinderclient import shell_utils class ListExtResource(base.Resource): @@ -41,4 +41,4 @@ def do_list_extensions(client, _args): """Lists all available os-api extensions.""" extensions = client.list_extensions.show_all() fields = ["Name", "Summary", "Alias", "Updated"] - utils.print_list(extensions, fields) + shell_utils.print_list(extensions, fields) diff --git a/cinderclient/v3/shell.py b/cinderclient/v3/shell.py index 27f2c7f..2542530 100644 --- a/cinderclient/v3/shell.py +++ b/cinderclient/v3/shell.py @@ -197,7 +197,7 @@ def do_backup_list(cs, args): sortby_index = None else: sortby_index = 0 - utils.print_list(backups, columns, sortby_index=sortby_index) + shell_utils.print_list(backups, columns, sortby_index=sortby_index) if show_count: print("Backup in total: %s" % total_count) @@ -282,7 +282,7 @@ def do_backup_restore(cs, args): info.update(restore._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--detail', @@ -316,7 +316,7 @@ def do_get_pools(cs, args): backend['name'] = info['name'] if args.detail: backend.update(info['capabilities']) - utils.print_dict(backend) + shell_utils.print_dict(backend) AppendFilters.filters = [] @@ -518,7 +518,7 @@ def do_list(cs, args): sortby_index = None else: sortby_index = 0 - utils.print_list(volumes, key_list, exclude_unavailable=True, + shell_utils.print_list(volumes, key_list, exclude_unavailable=True, sortby_index=sortby_index) if show_count: print("Volume in total: %s" % total_count) @@ -747,7 +747,7 @@ def do_create(cs, args): volume = cs.volumes.get(volume.id) info.update(volume._info) - utils.print_dict(info) + shell_utils.print_dict(info) with cs.volumes.completion_cache('uuid', cinderclient.v3.volumes.Volume, @@ -812,7 +812,7 @@ def do_summary(cs, args): if cs.api_version >= api_versions.APIVersion("3.36"): formatters.append('metadata') - utils.print_dict(info['volume-summary'], formatters=formatters) + shell_utils.print_dict(info['volume-summary'], formatters=formatters) @api_versions.wraps('3.11') @@ -853,7 +853,7 @@ def do_group_type_show(cs, args): info.update(gtype._info) info.pop('links', None) - utils.print_dict(info, formatters=['group_specs']) + shell_utils.print_dict(info, formatters=['group_specs']) @api_versions.wraps('3.11') @@ -881,7 +881,7 @@ def do_group_type_update(cs, args): def do_group_specs_list(cs, args): """Lists current group types and specs.""" gtypes = cs.group_types.list() - utils.print_list(gtypes, ['ID', 'Name', 'group_specs']) + shell_utils.print_list(gtypes, ['ID', 'Name', 'group_specs']) @api_versions.wraps('3.11') @@ -1170,7 +1170,7 @@ def do_cluster_list(cs, args): if args.detailed: columns.extend(('Num Hosts', 'Num Down Hosts', 'Last Heartbeat', 'Disabled Reason', 'Created At', 'Updated at')) - utils.print_list(clusters, columns) + shell_utils.print_list(clusters, columns) @api_versions.wraps('3.7') @@ -1181,7 +1181,7 @@ def do_cluster_list(cs, args): def do_cluster_show(cs, args): """Show detailed information on a clustered service.""" cluster = cs.clusters.show(args.name, args.binary) - utils.print_dict(cluster.to_dict()) + shell_utils.print_dict(cluster.to_dict()) @api_versions.wraps('3.7') @@ -1192,7 +1192,7 @@ def do_cluster_show(cs, args): def do_cluster_enable(cs, args): """Enables clustered services.""" cluster = cs.clusters.update(args.name, args.binary, disabled=False) - utils.print_dict(cluster.to_dict()) + shell_utils.print_dict(cluster.to_dict()) @api_versions.wraps('3.7') @@ -1206,7 +1206,7 @@ def do_cluster_disable(cs, args): """Disables clustered services.""" cluster = cs.clusters.update(args.name, args.binary, disabled=True, disabled_reason=args.reason) - utils.print_dict(cluster.to_dict()) + shell_utils.print_dict(cluster.to_dict()) @api_versions.wraps('3.24') @@ -1251,12 +1251,12 @@ def do_work_cleanup(cs, args): if cleaning: print('Following services will be cleaned:') - utils.print_list(cleaning, columns) + shell_utils.print_list(cleaning, columns) if unavailable: print('There are no alternative nodes to do cleanup for the following ' 'services:') - utils.print_list(unavailable, columns) + shell_utils.print_list(unavailable, columns) if not (cleaning or unavailable): print('No cleanable services matched cleanup criteria.') @@ -1337,7 +1337,7 @@ def do_manage(cs, args): volume = cs.volumes.get(volume.id) info.update(volume._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @api_versions.wraps('3.8') @@ -1394,7 +1394,7 @@ def do_manageable_list(cs, args): columns = ['reference', 'size', 'safe_to_manage'] if detailed: columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) - utils.print_list(volumes, columns, sortby_index=None) + shell_utils.print_list(volumes, columns, sortby_index=None) @api_versions.wraps('3.13') @@ -1427,7 +1427,7 @@ def do_group_list(cs, args): groups = cs.groups.list(search_opts=search_opts) columns = ['ID', 'Status', 'Name'] - utils.print_list(groups, columns) + shell_utils.print_list(groups, columns) with cs.groups.completion_cache( 'uuid', @@ -1469,7 +1469,7 @@ def do_group_show(cs, args): info.update(group._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @api_versions.wraps('3.13') @@ -1505,7 +1505,7 @@ def do_group_create(cs, args): info.update(group._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) with cs.groups.completion_cache('uuid', cinderclient.v3.groups.Group, @@ -1556,7 +1556,7 @@ def do_group_create_from_src(cs, args): args.description) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @api_versions.wraps('3.13') @@ -1696,7 +1696,8 @@ def do_group_list_replication_targets(cs, args): cs, args.group).list_replication_targets() rep_targets = replication_targets.get('replication_targets') if rep_targets and len(rep_targets) > 0: - utils.print_list(rep_targets, [key for key in rep_targets[0].keys()]) + shell_utils.print_list(rep_targets, + [key for key in rep_targets[0].keys()]) @api_versions.wraps('3.14') @@ -1743,7 +1744,7 @@ def do_group_snapshot_list(cs, args): group_snapshots = cs.group_snapshots.list(search_opts=search_opts) columns = ['ID', 'Status', 'Name'] - utils.print_list(group_snapshots, columns) + shell_utils.print_list(group_snapshots, columns) AppendFilters.filters = [] @@ -1758,7 +1759,7 @@ def do_group_snapshot_show(cs, args): info.update(group_snapshot._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @api_versions.wraps('3.14') @@ -1786,7 +1787,7 @@ def do_group_snapshot_create(cs, args): info.update(group_snapshot._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @api_versions.wraps('3.14') @@ -1841,7 +1842,7 @@ def do_service_list(cs, args): columns.append("Disabled Reason") if cs.api_version.matches('3.49'): columns.extend(["Backend State"]) - utils.print_list(result, columns) + shell_utils.print_list(result, columns) @api_versions.wraps('3.8') @@ -1900,7 +1901,7 @@ def do_snapshot_manageable_list(cs, args): columns = ['reference', 'size', 'safe_to_manage', 'source_reference'] if detailed: columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) - utils.print_list(snapshots, columns, sortby_index=None) + shell_utils.print_list(snapshots, columns, sortby_index=None) @api_versions.wraps("3.0") @@ -1908,7 +1909,7 @@ def do_api_version(cs, args): """Display the server API version information.""" columns = ['ID', 'Status', 'Version', 'Min_version'] response = cs.services.server_api_version() - utils.print_list(response, columns) + shell_utils.print_list(response, columns) @api_versions.wraps("3.40") @@ -2010,7 +2011,7 @@ def do_message_list(cs, args): sortby_index = None else: sortby_index = 0 - utils.print_list(messages, columns, sortby_index=sortby_index) + shell_utils.print_list(messages, columns, sortby_index=sortby_index) AppendFilters.filters = [] @@ -2024,7 +2025,7 @@ def do_message_show(cs, args): message = shell_utils.find_message(cs, args.message) info.update(message._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @api_versions.wraps("3.3") @@ -2179,11 +2180,11 @@ def do_snapshot_list(cs, args): # It's the server's responsibility to return the appropriate fields for the # requested microversion, we present all known fields and skip those that # are missing. - utils.print_list(snapshots, - ['ID', 'Volume ID', 'Status', 'Name', 'Size', - 'Consumes Quota', 'User ID'], - exclude_unavailable=True, - sortby_index=sortby_index) + shell_utils.print_list(snapshots, + ['ID', 'Volume ID', 'Status', 'Name', 'Size', + 'Consumes Quota', 'User ID'], + exclude_unavailable=True, + sortby_index=sortby_index) if show_count: print("Snapshot in total: %s" % total_count) @@ -2213,6 +2214,7 @@ def do_snapshot_list(cs, args): 'than forcing it to be available. From microversion 3.66, ' 'all snapshots are "forced" and this option is invalid. ' 'Default=False.') +# FIXME: is this second declaration of --force really necessary? @utils.arg('--force', metavar='<True>', nargs='?', @@ -2253,6 +2255,7 @@ def do_snapshot_create(cs, args): snapshot_metadata = shell_utils.extract_metadata(args) volume = utils.find_volume(cs, args.volume) + snapshot = cs.volume_snapshots.create(volume.id, args.force, args.name, @@ -2409,7 +2412,7 @@ def do_attachment_list(cs, args): sortby_index = None else: sortby_index = 0 - utils.print_list(attachments, columns, sortby_index=sortby_index) + shell_utils.print_list(attachments, columns, sortby_index=sortby_index) AppendFilters.filters = [] @@ -2422,13 +2425,13 @@ def do_attachment_show(cs, args): attachment = cs.attachments.show(args.attachment) attachment_dict = attachment.to_dict() connection_dict = attachment_dict.pop('connection_info', {}) - utils.print_dict(attachment_dict) + shell_utils.print_dict(attachment_dict) # TODO(jdg): Need to add checks here like admin/policy for displaying the # connection_info, this is still experimental so we'll leave it enabled for # now if connection_dict: - utils.print_dict(connection_dict) + shell_utils.print_dict(connection_dict) @api_versions.wraps('3.27') @@ -2501,9 +2504,9 @@ def do_attachment_create(cs, args): mode) connector_dict = attachment.pop('connection_info', None) - utils.print_dict(attachment) + shell_utils.print_dict(attachment) if connector_dict: - utils.print_dict(connector_dict) + shell_utils.print_dict(connector_dict) @api_versions.wraps('3.27') @@ -2555,9 +2558,9 @@ def do_attachment_update(cs, args): connector) attachment_dict = attachment.to_dict() connector_dict = attachment_dict.pop('connection_info', None) - utils.print_dict(attachment_dict) + shell_utils.print_dict(attachment_dict) if connector_dict: - utils.print_dict(connector_dict) + shell_utils.print_dict(connector_dict) @api_versions.wraps('3.27') @@ -2596,7 +2599,7 @@ def do_version_list(cs, args): {'v': api_versions.MAX_VERSION}) print("\nServer supported API versions:") - utils.print_list(result, columns) + shell_utils.print_list(result, columns) @api_versions.wraps('3.32') @@ -2639,7 +2642,7 @@ def do_service_get_log(cs, args): log_levels = cs.services.get_log_levels(args.binary, args.server, args.prefix) columns = ('Binary', 'Host', 'Prefix', 'Level') - utils.print_list(log_levels, columns) + shell_utils.print_list(log_levels, columns) @utils.arg('volume', metavar='<volume>', @@ -2716,7 +2719,7 @@ def do_backup_create(cs, args): if 'links' in info: info.pop('links') - utils.print_dict(info) + shell_utils.print_dict(info) with cs.backups.completion_cache( 'uuid', @@ -2757,7 +2760,7 @@ def do_transfer_create(cs, args): info.update(transfer._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--all-tenants', @@ -2805,7 +2808,7 @@ def do_transfer_list(cs, args): transfers = cs.transfers.list(search_opts=search_opts, sort=sort) columns = ['ID', 'Volume ID', 'Name'] - utils.print_list(transfers, columns) + shell_utils.print_list(transfers, columns) AppendFilters.filters = [] @@ -2822,7 +2825,7 @@ def do_default_type_set(cs, args): project = args.project default_type = cs.default_types.create(volume_type, project) - utils.print_dict(default_type._info) + shell_utils.print_dict(default_type._info) @api_versions.wraps('3.62') @@ -2837,9 +2840,9 @@ def do_default_type_list(cs, args): default_types = cs.default_types.list(project_id) columns = ['Volume Type ID', 'Project ID'] if project_id: - utils.print_dict(default_types._info) + shell_utils.print_dict(default_types._info) else: - utils.print_list(default_types, columns) + shell_utils.print_list(default_types, columns) @api_versions.wraps('3.62') @@ -2858,3 +2861,23 @@ def do_default_type_unset(cs, args): except Exception as e: print("Unset for default volume type for project %s failed: %s" % (project_id, e)) + + +@api_versions.wraps('3.68') +@utils.arg('volume', + metavar='<volume>', + help='Name or ID of volume to reimage') +@utils.arg('image_id', + metavar='<image-id>', + help='The image id of the image that will be used to reimage ' + 'the volume.') +@utils.arg('--reimage-reserved', + metavar='<True|False>', + default=False, + help='Enables or disables reimage for a volume that is in ' + 'reserved state otherwise only volumes in "available" ' + ' or "error" status may be re-imaged. Default=False.') +def do_reimage(cs, args): + """Rebuilds a volume, overwriting all content with the specified image""" + volume = utils.find_volume(cs, args.volume) + volume.reimage(args.image_id, args.reimage_reserved) diff --git a/cinderclient/v3/shell_base.py b/cinderclient/v3/shell_base.py index 0dee47e..56d9df4 100644 --- a/cinderclient/v3/shell_base.py +++ b/cinderclient/v3/shell_base.py @@ -163,7 +163,7 @@ def do_list(cs, args): sortby_index = None else: sortby_index = 0 - utils.print_list(volumes, key_list, exclude_unavailable=True, + shell_utils.print_list(volumes, key_list, exclude_unavailable=True, sortby_index=sortby_index) @@ -181,9 +181,9 @@ def do_show(cs, args): info.pop('links', None) info = _translate_attachments(info) - utils.print_dict(info, - formatters=['metadata', 'volume_image_metadata', - 'attachment_ids', 'attached_servers']) + shell_utils.print_dict(info, + formatters=['metadata', 'volume_image_metadata', + 'attachment_ids', 'attached_servers']) class CheckSizeArgForCreate(argparse.Action): @@ -325,7 +325,7 @@ def do_create(cs, args): info.pop('links', None) info = _translate_attachments(info) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--cascade', @@ -581,9 +581,9 @@ def do_snapshot_list(cs, args): else: sortby_index = 0 - utils.print_list(snapshots, - ['ID', 'Volume ID', 'Status', 'Name', 'Size'], - sortby_index=sortby_index) + shell_utils.print_list(snapshots, + ['ID', 'Volume ID', 'Status', 'Name', 'Size'], + sortby_index=sortby_index) @utils.arg('snapshot', @@ -714,7 +714,7 @@ def do_type_show(cs, args): info.update(vtype._info) info.pop('links', None) - utils.print_dict(info, formatters=['extra_specs']) + shell_utils.print_dict(info, formatters=['extra_specs']) @utils.arg('id', @@ -746,7 +746,7 @@ def do_type_update(cs, args): def do_extra_specs_list(cs, args): """Lists current volume types and extra specs.""" vtypes = cs.volume_types.list() - utils.print_list(vtypes, ['ID', 'Name', 'extra_specs']) + shell_utils.print_list(vtypes, ['ID', 'Name', 'extra_specs']) @utils.arg('name', @@ -821,7 +821,7 @@ def do_type_access_list(cs, args): access_list = cs.volume_type_access.list(volume_type) columns = ['Volume_type_ID', 'Project_ID'] - utils.print_list(access_list, columns) + shell_utils.print_list(access_list, columns) @utils.arg('--volume-type', metavar='<volume_type>', required=True, @@ -972,7 +972,7 @@ def do_absolute_limits(cs, args): """Lists absolute limits for a user.""" limits = cs.limits.get(args.tenant).absolute columns = ['Name', 'Value'] - utils.print_list(limits, columns) + shell_utils.print_list(limits, columns) @utils.arg('tenant', @@ -984,7 +984,7 @@ def do_rate_limits(cs, args): """Lists rate limits for a user.""" limits = cs.limits.get(args.tenant).rate columns = ['Verb', 'URI', 'Value', 'Remain', 'Unit', 'Next_Available'] - utils.print_list(limits, columns) + shell_utils.print_list(limits, columns) @utils.arg('volume', @@ -1133,7 +1133,7 @@ def do_backup_create(cs, args): if 'links' in info: info.pop('links') - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('backup', metavar='<backup>', help='Name or ID of backup.') @@ -1144,7 +1144,7 @@ def do_backup_show(cs, args): info.update(backup._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--all-tenants', @@ -1211,7 +1211,7 @@ def do_backup_list(cs, args): sortby_index = None else: sortby_index = 0 - utils.print_list(backups, columns, sortby_index=sortby_index) + shell_utils.print_list(backups, columns, sortby_index=sortby_index) @utils.arg('--force', @@ -1273,7 +1273,7 @@ def do_backup_restore(cs, args): info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('backup', metavar='<backup>', @@ -1281,7 +1281,7 @@ def do_backup_restore(cs, args): def do_backup_export(cs, args): """Export backup metadata record.""" info = cs.backups.export_record(args.backup) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('backup_service', metavar='<backup_service>', @@ -1293,7 +1293,7 @@ def do_backup_import(cs, args): info = cs.backups.import_record(args.backup_service, args.backup_url) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('backup', metavar='<backup>', nargs='+', @@ -1345,7 +1345,7 @@ def do_transfer_create(cs, args): info.update(transfer._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('transfer', metavar='<transfer>', @@ -1367,7 +1367,7 @@ def do_transfer_accept(cs, args): info.update(transfer._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--all-tenants', @@ -1391,7 +1391,7 @@ def do_transfer_list(cs, args): } transfers = cs.transfers.list(search_opts=search_opts) columns = ['ID', 'Volume ID', 'Name'] - utils.print_list(transfers, columns) + shell_utils.print_list(transfers, columns) @utils.arg('transfer', metavar='<transfer>', @@ -1403,7 +1403,7 @@ def do_transfer_show(cs, args): info.update(transfer._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('volume', metavar='<volume>', @@ -1441,7 +1441,7 @@ def do_service_list(cs, args): # so as not to add the column when the extended ext is not enabled. if result and hasattr(result[0], 'disabled_reason'): columns.append("Disabled Reason") - utils.print_list(result, columns) + shell_utils.print_list(result, columns) @utils.arg('host', metavar='<hostname>', help='Host name.') @@ -1450,7 +1450,7 @@ def do_service_enable(cs, args): """Enables the service.""" result = cs.services.enable(args.host, args.binary) columns = ["Host", "Binary", "Status"] - utils.print_list([result], columns) + shell_utils.print_list([result], columns) @utils.arg('host', metavar='<hostname>', help='Host name.') @@ -1466,7 +1466,7 @@ def do_service_disable(cs, args): args.reason) else: result = cs.services.disable(args.host, args.binary) - utils.print_list([result], columns) + shell_utils.print_list([result], columns) def treeizeAvailabilityZone(zone): @@ -1525,13 +1525,13 @@ def do_availability_zone_list(cs, _args): for zone in availability_zones: result += treeizeAvailabilityZone(zone) shell_utils.translate_availability_zone_keys(result) - utils.print_list(result, ['Name', 'Status']) + shell_utils.print_list(result, ['Name', 'Status']) def do_encryption_type_list(cs, args): """Shows encryption type details for volume types. Admin only.""" result = cs.volume_encryption_types.list() - utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', + shell_utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', 'Key Size', 'Control Location']) @@ -1796,7 +1796,7 @@ def do_snapshot_metadata(cs, args): if args.action == 'set': metadata = snapshot.set_metadata(metadata) - utils.print_dict(metadata._info) + shell_utils.print_dict(metadata._info) elif args.action == 'unset': snapshot.delete_metadata(list(metadata.keys())) @@ -1806,7 +1806,7 @@ def do_snapshot_metadata(cs, args): def do_snapshot_metadata_show(cs, args): """Shows snapshot metadata.""" snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) - utils.print_dict(snapshot._info['metadata'], 'Metadata-property') + shell_utils.print_dict(snapshot._info['metadata'], 'Metadata-property') @utils.arg('volume', metavar='<volume>', @@ -1814,7 +1814,7 @@ def do_snapshot_metadata_show(cs, args): def do_metadata_show(cs, args): """Shows volume metadata.""" volume = utils.find_volume(cs, args.volume) - utils.print_dict(volume._info['metadata'], 'Metadata-property') + shell_utils.print_dict(volume._info['metadata'], 'Metadata-property') @utils.arg('volume', metavar='<volume>', @@ -1823,7 +1823,7 @@ 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') + shell_utils.print_dict(body['metadata'], 'Metadata-property') @utils.arg('volume', @@ -1839,7 +1839,7 @@ def do_metadata_update_all(cs, args): volume = utils.find_volume(cs, args.volume) metadata = shell_utils.extract_metadata(args) metadata = volume.update_all_metadata(metadata) - utils.print_dict(metadata['metadata'], 'Metadata-property') + shell_utils.print_dict(metadata['metadata'], 'Metadata-property') @utils.arg('snapshot', @@ -1855,7 +1855,7 @@ def do_snapshot_metadata_update_all(cs, args): snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) metadata = shell_utils.extract_metadata(args) metadata = snapshot.update_all_metadata(metadata) - utils.print_dict(metadata) + shell_utils.print_dict(metadata) @utils.arg('volume', metavar='<volume>', help='ID of volume to update.') @@ -1954,7 +1954,7 @@ def do_manage(cs, args): volume = cs.volumes.get(volume.id) info.update(volume._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('volume', metavar='<volume>', @@ -1978,7 +1978,7 @@ def do_consisgroup_list(cs, args): consistencygroups = cs.consistencygroups.list() columns = ['ID', 'Status', 'Name'] - utils.print_list(consistencygroups, columns) + shell_utils.print_list(consistencygroups, columns) @utils.arg('consistencygroup', @@ -1992,7 +1992,7 @@ def do_consisgroup_show(cs, args): info.update(consistencygroup._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('volumetypes', @@ -2023,7 +2023,7 @@ def do_consisgroup_create(cs, args): info.update(consistencygroup._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--cgsnapshot', @@ -2061,7 +2061,7 @@ def do_consisgroup_create_from_src(cs, args): args.description) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('consistencygroup', @@ -2166,7 +2166,7 @@ def do_cgsnapshot_list(cs, args): cgsnapshots = cs.cgsnapshots.list(search_opts=search_opts) columns = ['ID', 'Status', 'Name'] - utils.print_list(cgsnapshots, columns) + shell_utils.print_list(cgsnapshots, columns) @utils.arg('cgsnapshot', @@ -2179,7 +2179,7 @@ def do_cgsnapshot_show(cs, args): info.update(cgsnapshot._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('consistencygroup', @@ -2207,7 +2207,7 @@ def do_cgsnapshot_create(cs, args): info.update(cgsnapshot._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('cgsnapshot', @@ -2241,7 +2241,7 @@ def do_get_pools(cs, args): backend['name'] = info['name'] if args.detail: backend.update(info['capabilities']) - utils.print_dict(backend) + shell_utils.print_dict(backend) @utils.arg('host', @@ -2256,9 +2256,9 @@ def do_get_capabilities(cs, args): infos.update(capabilities._info) prop = infos.pop('properties', None) - utils.print_dict(infos, "Volume stats") - utils.print_dict(prop, "Backend properties", - formatters=sorted(prop.keys())) + shell_utils.print_dict(infos, "Volume stats") + shell_utils.print_dict(prop, "Backend properties", + formatters=sorted(prop.keys())) @utils.arg('volume', @@ -2308,7 +2308,7 @@ def do_snapshot_manage(cs, args): snapshot = cs.volume_snapshots.get(snapshot.id) info.update(snapshot._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('snapshot', metavar='<snapshot>', @@ -2378,7 +2378,7 @@ def do_manageable_list(cs, args): columns = ['reference', 'size', 'safe_to_manage'] if detailed: columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) - utils.print_list(volumes, columns, sortby_index=None) + shell_utils.print_list(volumes, columns, sortby_index=None) @utils.arg('host', @@ -2422,4 +2422,4 @@ def do_snapshot_manageable_list(cs, args): columns = ['reference', 'size', 'safe_to_manage', 'source_reference'] if detailed: columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) - utils.print_list(snapshots, columns, sortby_index=None) + shell_utils.print_list(snapshots, columns, sortby_index=None) diff --git a/cinderclient/v3/volume_snapshots.py b/cinderclient/v3/volume_snapshots.py index 9a94422..cb1c3ba 100644 --- a/cinderclient/v3/volume_snapshots.py +++ b/cinderclient/v3/volume_snapshots.py @@ -108,6 +108,20 @@ class SnapshotManager(base.ManagerWithFind): else: snapshot_metadata = metadata + # Bug #1995883: it's possible for the shell to use the user- + # specified 3.66 do_snapshot_create function, but if the server + # only supports < 3.66, the client will have been downgraded and + # will use this function. In that case, the 'force' parameter will + # be None, which means that the user didn't specify a value for it, + # so we set it to the pre-3.66 default value of False. + # + # NOTE: we know this isn't a problem for current client consumers + # because a null value for 'force' has never been allowed by the + # Block Storage API v3, so there's no reason for anyone to directly + # call this method passing force=None. + if force is None: + force = False + body = {'snapshot': {'volume_id': volume_id, 'force': force, 'name': name, diff --git a/cinderclient/v3/volumes.py b/cinderclient/v3/volumes.py index 42527f7..0479dc3 100644 --- a/cinderclient/v3/volumes.py +++ b/cinderclient/v3/volumes.py @@ -67,6 +67,10 @@ class Volume(volumes_base.Volume): metadata=metadata, bootable=bootable, cluster=cluster) + def reimage(self, image_id, reimage_reserved=False): + """Rebuilds the volume with the new specified image""" + self.manager.reimage(self, image_id, reimage_reserved) + class VolumeManager(volumes_base.VolumeManager): resource_class = Volume @@ -282,3 +286,21 @@ class VolumeManager(volumes_base.VolumeManager): search_opts=options) return self._get(url, None) + + @api_versions.wraps('3.68') + def reimage(self, volume, image_id, reimage_reserved=False): + """Reimage a volume + + .. warning:: This is a destructive action and the contents of the + volume will be lost. + + :param volume: Volume to reimage. + :param reimage_reserved: Boolean to enable or disable reimage + of a volume that is in 'reserved' state otherwise only + volumes in 'available' status may be re-imaged. + :param image_id: The image id. + """ + return self._action('os-reimage', + volume, + {'image_id': image_id, + 'reimage_reserved': reimage_reserved}) diff --git a/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml b/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml new file mode 100644 index 0000000..87964d1 --- /dev/null +++ b/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + `Bug #1995883 + <https://bugs.launchpad.net/python-cinderclient/+bug/1995883>`_: + Fixed bad format request body generated for the snapshot-create + action when the client supports mv 3.66 or greater but the Block + Storage API being contacted supports < 3.66. diff --git a/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml b/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml new file mode 100644 index 0000000..5915647 --- /dev/null +++ b/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml @@ -0,0 +1,6 @@ +--- +upgrade: + - | + Python 3.6 & 3.7 support has been dropped. The minimum version of Python now + supported is Python 3.8. + diff --git a/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml b/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml new file mode 100644 index 0000000..a95fb1f --- /dev/null +++ b/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + A new ``cinder reimage`` command and related python API binding has been + added which allows a user to replace the current content of a specified + volume with the data of a specified image supplied by the Image service + (Glance). (Note that this is a destructive action, that is, all data + currently contained in the volume is destroyed when the volume is + re-imaged.) This feature requires Block Storage API microversion 3.68 + or greater. diff --git a/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml b/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml new file mode 100644 index 0000000..70e2e1c --- /dev/null +++ b/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml @@ -0,0 +1,5 @@ +--- +prelude: | + The Yoga release of the python-cinderclient supports Block Storage + API version 3 through microversion 3.68. (The maximum microversion + of the Block Storage API in the Yoga release is 3.68.) diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst index f9f9bfd..340b17f 100644 --- a/releasenotes/source/index.rst +++ b/releasenotes/source/index.rst @@ -6,6 +6,8 @@ :maxdepth: 1 unreleased + zed + yoga xena wallaby victoria diff --git a/releasenotes/source/yoga.rst b/releasenotes/source/yoga.rst new file mode 100644 index 0000000..7cd5e90 --- /dev/null +++ b/releasenotes/source/yoga.rst @@ -0,0 +1,6 @@ +========================= +Yoga Series Release Notes +========================= + +.. release-notes:: + :branch: stable/yoga diff --git a/releasenotes/source/zed.rst b/releasenotes/source/zed.rst new file mode 100644 index 0000000..9608c05 --- /dev/null +++ b/releasenotes/source/zed.rst @@ -0,0 +1,6 @@ +======================== +Zed Series Release Notes +======================== + +.. release-notes:: + :branch: stable/zed diff --git a/requirements.txt b/requirements.txt index 6f8e90b..4c81976 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ pbr>=5.5.0 # Apache-2.0 PrettyTable>=0.7.2 # BSD keystoneauth1>=4.3.1 # Apache-2.0 -simplejson>=3.5.1 # MIT oslo.i18n>=5.0.1 # Apache-2.0 oslo.utils>=4.8.0 # Apache-2.0 requests>=2.25.1 # Apache-2.0 @@ -6,7 +6,7 @@ description_file = author = OpenStack author_email = openstack-discuss@lists.openstack.org home_page = https://docs.openstack.org/python-cinderclient/latest/ -python_requires = >=3.6 +python_requires = >=3.8 classifier = Development Status :: 5 - Production/Stable Environment :: Console @@ -16,9 +16,8 @@ classifier = License :: OSI Approved :: Apache Software License Operating System :: POSIX :: Linux Programming Language :: Python + Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 diff --git a/test-requirements.txt b/test-requirements.txt index e0d7c93..0886bd1 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -10,7 +10,6 @@ coverage>=5.5 # Apache-2.0 ddt>=1.4.1 # MIT fixtures>=3.0.0 # Apache-2.0/BSD requests-mock>=1.2.0 # Apache-2.0 -tempest>=26.0.0 # Apache-2.0 testtools>=2.4.0 # MIT stestr>=3.1.0 # Apache-2.0 oslo.serialization>=4.1.0 # Apache-2.0 @@ -2,6 +2,7 @@ distribute = False envlist = py3,pep8 minversion = 3.18.0 +requires = tox<4 skipsdist = True # this allows tox to infer the base python from the environment name # and override any basepython configured in this file @@ -77,6 +78,9 @@ deps = commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html [testenv:functional] +deps = + {[testenv]deps} + tempest>=26.0.0 commands = stestr run {posargs} setenv = {[testenv]setenv} @@ -95,12 +99,14 @@ setenv = # TLS (https) server certificate. passenv = OS_* -[testenv:functional-py36] +[testenv:functional-py38] +deps = {[testenv:functional]deps} setenv = {[testenv:functional]setenv} passenv = {[testenv:functional]passenv} commands = {[testenv:functional]commands} [testenv:functional-py39] +deps = {[testenv:functional]deps} setenv = {[testenv:functional]setenv} passenv = {[testenv:functional]passenv} commands = {[testenv:functional]commands} |