From 65cce3943a26b0275266a1d619cd69a349f3d8e4 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Wed, 12 Jun 2024 16:02:09 +0100 Subject: [PATCH] volume: Add v3-specific volume module This makes testing easier. Change-Id: I6b31026ae3c9dc66d828744534b35bb0a0d2ffbe Signed-off-by: Stephen Finucane --- openstackclient/tests/unit/volume/v2/fakes.py | 38 - .../tests/unit/volume/v2/test_volume.py | 99 +- openstackclient/tests/unit/volume/v3/fakes.py | 140 +- .../tests/unit/volume/v3/test_volume.py | 2025 ++++++++++++++++- openstackclient/volume/v2/volume.py | 51 +- openstackclient/volume/v3/volume.py | 1146 ++++++++-- setup.cfg | 10 +- 7 files changed, 3067 insertions(+), 442 deletions(-) diff --git a/openstackclient/tests/unit/volume/v2/fakes.py b/openstackclient/tests/unit/volume/v2/fakes.py index d39ec5a3c8..1c3a6864e1 100644 --- a/openstackclient/tests/unit/volume/v2/fakes.py +++ b/openstackclient/tests/unit/volume/v2/fakes.py @@ -25,7 +25,6 @@ from openstack.block_storage.v3 import capabilities as _capabilities from openstack.block_storage.v3 import stats as _stats from openstack.block_storage.v3 import volume as _volume from openstack.image.v2 import _proxy as image_v2_proxy -from osc_lib.cli import format_columns from openstackclient.tests.unit import fakes from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes @@ -472,43 +471,6 @@ def get_volumes(volumes=None, count=2): return mock.Mock(side_effect=volumes) -def get_volume_columns(volume=None): - """Get the volume columns from a faked volume object. - - :param volume: - A FakeResource objects faking volume - :return - A tuple which may include the following keys: - ('id', 'name', 'description', 'status', 'size', 'volume_type', - 'metadata', 'snapshot', 'availability_zone', 'attachments') - """ - if volume is not None: - return tuple(k for k in sorted(volume.keys())) - return tuple([]) - - -def get_volume_data(volume=None): - """Get the volume data from a faked volume object. - - :param volume: - A FakeResource objects faking volume - :return - A tuple which may include the following values: - ('ce26708d', 'fake_volume', 'fake description', 'available', - 20, 'fake_lvmdriver-1', "Alpha='a', Beta='b', Gamma='g'", - 1, 'nova', [{'device': '/dev/ice', 'server_id': '1233'}]) - """ - data_list = [] - if volume is not None: - for x in sorted(volume.keys()): - if x == 'tags': - # The 'tags' should be format_list - data_list.append(format_columns.ListColumn(volume.info.get(x))) - else: - data_list.append(volume.info.get(x)) - return tuple(data_list) - - def create_one_backup(attrs=None): """Create a fake backup. diff --git a/openstackclient/tests/unit/volume/v2/test_volume.py b/openstackclient/tests/unit/volume/v2/test_volume.py index 0176994b6c..1667f38cd6 100644 --- a/openstackclient/tests/unit/volume/v2/test_volume.py +++ b/openstackclient/tests/unit/volume/v2/test_volume.py @@ -12,9 +12,7 @@ # under the License. from unittest import mock -from unittest.mock import call -from cinderclient import api_versions from osc_lib.cli import format_columns from osc_lib import exceptions from osc_lib import utils @@ -42,9 +40,6 @@ class TestVolume(volume_fakes.TestVolume): self.snapshots_mock = self.volume_client.volume_snapshots self.snapshots_mock.reset_mock() - self.backups_mock = self.volume_client.backups - self.backups_mock.reset_mock() - self.types_mock = self.volume_client.volume_types self.types_mock.reset_mock() @@ -126,7 +121,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -178,7 +172,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=consistency_group.id, scheduler_hints={'k': 'v'}, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -218,7 +211,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -259,7 +251,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -300,7 +291,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -339,74 +329,11 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) self.assertCountEqual(self.datalist, data) - def test_volume_create_with_backup(self): - backup = volume_fakes.create_one_backup() - self.new_volume.backup_id = backup.id - arglist = [ - '--backup', - self.new_volume.backup_id, - self.new_volume.name, - ] - verifylist = [ - ('backup', self.new_volume.backup_id), - ('name', self.new_volume.name), - ] - parsed_args = self.check_parser(self.cmd, arglist, verifylist) - - self.backups_mock.get.return_value = backup - - self.volume_client.api_version = api_versions.APIVersion('3.47') - - # In base command class ShowOne in cliff, abstract method take_action() - # returns a two-part tuple with a tuple of column names and a tuple of - # data to be shown. - columns, data = self.cmd.take_action(parsed_args) - - self.volumes_mock.create.assert_called_once_with( - size=backup.size, - snapshot_id=None, - name=self.new_volume.name, - description=None, - volume_type=None, - availability_zone=None, - metadata=None, - imageRef=None, - source_volid=None, - consistencygroup_id=None, - scheduler_hints=None, - backup_id=backup.id, - ) - - self.assertEqual(self.columns, columns) - self.assertCountEqual(self.datalist, data) - - def test_volume_create_with_backup_pre_347(self): - backup = volume_fakes.create_one_backup() - self.new_volume.backup_id = backup.id - arglist = [ - '--backup', - self.new_volume.backup_id, - self.new_volume.name, - ] - verifylist = [ - ('backup', self.new_volume.backup_id), - ('name', self.new_volume.name), - ] - parsed_args = self.check_parser(self.cmd, arglist, verifylist) - - self.backups_mock.get.return_value = backup - - exc = self.assertRaises( - exceptions.CommandError, self.cmd.take_action, parsed_args - ) - self.assertIn("--os-volume-api-version 3.47 or greater", str(exc)) - def test_volume_create_with_source_volume(self): source_vol = "source_vol" arglist = [ @@ -439,7 +366,6 @@ class TestVolumeCreate(TestVolume): source_volid=self.new_volume.id, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -479,7 +405,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -525,7 +450,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -580,7 +504,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(2, mock_error.call_count) @@ -632,7 +555,6 @@ class TestVolumeCreate(TestVolume): source_volid=None, consistencygroup_id=None, scheduler_hints=None, - backup_id=None, ) self.assertEqual(2, mock_error.call_count) @@ -742,7 +664,6 @@ class TestVolumeCreate(TestVolume): 'local_to_instance': 'v6', 'different_host': ['v5', 'v7'], }, - backup_id=None, ) self.assertEqual(self.columns, columns) @@ -789,7 +710,7 @@ class TestVolumeDelete(TestVolume): result = self.cmd.take_action(parsed_args) - calls = [call(v.id, cascade=False) for v in volumes] + calls = [mock.call(v.id, cascade=False) for v in volumes] self.volumes_mock.delete.assert_has_calls(calls) self.assertIsNone(result) @@ -1721,11 +1642,23 @@ class TestVolumeShow(TestVolume): self.volumes_mock.get.assert_called_with(self._volume.id) self.assertEqual( - volume_fakes.get_volume_columns(self._volume), + tuple(sorted(self._volume.keys())), columns, ) - self.assertCountEqual( - volume_fakes.get_volume_data(self._volume), + self.assertTupleEqual( + ( + self._volume.attachments, + self._volume.availability_zone, + self._volume.bootable, + self._volume.description, + self._volume.id, + self._volume.name, + format_columns.DictColumn(self._volume.metadata), + self._volume.size, + self._volume.snapshot_id, + self._volume.status, + self._volume.volume_type, + ), data, ) diff --git a/openstackclient/tests/unit/volume/v3/fakes.py b/openstackclient/tests/unit/volume/v3/fakes.py index 54ac747ec3..b8ebe516ae 100644 --- a/openstackclient/tests/unit/volume/v3/fakes.py +++ b/openstackclient/tests/unit/volume/v3/fakes.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +import copy import random from unittest import mock import uuid @@ -21,6 +22,7 @@ from openstack.block_storage.v3 import backup as _backup from openstack.block_storage.v3 import extension as _extension from openstack.block_storage.v3 import resource_filter as _filters from openstack.block_storage.v3 import volume as _volume +from openstack.image.v2 import _proxy as _image_proxy from openstackclient.tests.unit import fakes from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes @@ -34,12 +36,14 @@ class FakeVolumeClient: self.management_url = kwargs['endpoint'] self.api_version = api_versions.APIVersion('3.0') + self.attachments = mock.Mock() + self.attachments.resource_class = fakes.FakeResource(None, {}) self.availability_zones = mock.Mock() self.availability_zones.resource_class = fakes.FakeResource(None, {}) self.backups = mock.Mock() self.backups.resource_class = fakes.FakeResource(None, {}) - self.attachments = mock.Mock() - self.attachments.resource_class = fakes.FakeResource(None, {}) + self.consistencygroups = mock.Mock() + self.consistencygroups.resource_class = fakes.FakeResource(None, {}) self.clusters = mock.Mock() self.clusters.resource_class = fakes.FakeResource(None, {}) self.groups = mock.Mock() @@ -106,10 +110,14 @@ class TestVolume( ) self.compute_client = self.app.client_manager.compute + # avoid circular imports by defining this manually rather than using + # openstackclient.tests.unit.image.v2.fakes.FakeClientMixin + self.app.client_manager.image = mock.Mock(spec=_image_proxy.Proxy) + self.image_client = self.app.client_manager.image + # TODO(stephenfin): Check if the responses are actually the same create_one_snapshot = volume_v2_fakes.create_one_snapshot -create_one_volume = volume_v2_fakes.create_one_volume create_one_volume_type = volume_v2_fakes.create_one_volume_type @@ -153,6 +161,54 @@ def create_availability_zones(attrs=None, count=2): return availability_zones +def create_one_consistency_group(attrs=None): + """Create a fake consistency group. + + :param dict attrs: + A dictionary with all attributes + :return: + A FakeResource object with id, name, description, etc. + """ + attrs = attrs or {} + + # Set default attributes. + consistency_group_info = { + "id": 'backup-id-' + uuid.uuid4().hex, + "name": 'backup-name-' + uuid.uuid4().hex, + "description": 'description-' + uuid.uuid4().hex, + "status": "error", + "availability_zone": 'zone' + uuid.uuid4().hex, + "created_at": 'time-' + uuid.uuid4().hex, + "volume_types": ['volume-type1'], + } + + # Overwrite default attributes. + consistency_group_info.update(attrs) + + consistency_group = fakes.FakeResource( + info=copy.deepcopy(consistency_group_info), loaded=True + ) + return consistency_group + + +def create_consistency_groups(attrs=None, count=2): + """Create multiple fake consistency groups. + + :param dict attrs: + A dictionary with all attributes + :param int count: + The number of consistency groups to fake + :return: + A list of FakeResource objects faking the consistency groups + """ + consistency_groups = [] + for i in range(0, count): + consistency_group = create_one_consistency_group(attrs) + consistency_groups.append(consistency_group) + + return consistency_groups + + def create_one_extension(attrs=None): """Create a fake extension. @@ -349,6 +405,84 @@ def create_resource_filters(attrs=None, count=2): return resource_filters +def create_one_volume(attrs=None): + """Create a fake volume. + + :param dict attrs: + A dictionary with all attributes of volume + :return: + A FakeResource object with id, name, status, etc. + """ + attrs = attrs or {} + + # Set default attribute + volume_info = { + 'id': 'volume-id' + uuid.uuid4().hex, + 'name': 'volume-name' + uuid.uuid4().hex, + 'description': 'description' + uuid.uuid4().hex, + 'status': random.choice(['available', 'in_use']), + 'size': random.randint(1, 20), + 'volume_type': random.choice(['fake_lvmdriver-1', 'fake_lvmdriver-2']), + 'bootable': random.randint(0, 1), + 'metadata': { + 'key' + uuid.uuid4().hex: 'val' + uuid.uuid4().hex, + 'key' + uuid.uuid4().hex: 'val' + uuid.uuid4().hex, + 'key' + uuid.uuid4().hex: 'val' + uuid.uuid4().hex, + }, + 'snapshot_id': random.randint(1, 5), + 'availability_zone': 'zone' + uuid.uuid4().hex, + 'attachments': [ + { + 'device': '/dev/' + uuid.uuid4().hex, + 'server_id': uuid.uuid4().hex, + }, + ], + } + + # Overwrite default attributes if there are some attributes set + volume_info.update(attrs) + + volume = fakes.FakeResource(None, volume_info, loaded=True) + return volume + + +def create_volumes(attrs=None, count=2): + """Create multiple fake volumes. + + :param dict attrs: + A dictionary with all attributes of volume + :param Integer count: + The number of volumes to be faked + :return: + A list of FakeResource objects + """ + volumes = [] + for n in range(0, count): + volumes.append(create_one_volume(attrs)) + + return volumes + + +def get_volumes(volumes=None, count=2): + """Get an iterable MagicMock object with a list of faked volumes. + + If volumes list is provided, then initialize the Mock object with the + list. Otherwise create one. + + :param List volumes: + A list of FakeResource objects faking volumes + :param Integer count: + The number of volumes to be faked + :return + An iterable Mock object with side_effect set to a list of faked + volumes + """ + if volumes is None: + volumes = create_volumes(count) + + return mock.Mock(side_effect=volumes) + + def create_one_sdk_volume(attrs=None): """Create a fake volume. diff --git a/openstackclient/tests/unit/volume/v3/test_volume.py b/openstackclient/tests/unit/volume/v3/test_volume.py index 3aaaae00c5..5b01ac3240 100644 --- a/openstackclient/tests/unit/volume/v3/test_volume.py +++ b/openstackclient/tests/unit/volume/v3/test_volume.py @@ -23,12 +23,16 @@ from openstack.test import fakes as sdk_fakes from openstack import utils as sdk_utils from osc_lib.cli import format_columns from osc_lib import exceptions +from osc_lib import utils -from openstackclient.tests.unit.volume.v3 import fakes +from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes +from openstackclient.tests.unit.image.v2 import fakes as image_fakes +from openstackclient.tests.unit import utils as test_utils +from openstackclient.tests.unit.volume.v3 import fakes as volume_fakes from openstackclient.volume.v3 import volume -class BaseVolumeTest(fakes.TestVolume): +class BaseVolumeTest(volume_fakes.TestVolume): def setUp(self): super().setUp() @@ -50,155 +54,709 @@ class BaseVolumeTest(fakes.TestVolume): ) -class TestVolumeSummary(BaseVolumeTest): - columns = [ - 'Total Count', - 'Total Size', - ] +# TODO(stephenfin): Combine these two test classes +class TestVolumeCreateLegacy(volume_fakes.TestVolume): + project = identity_fakes.FakeProject.create_one_project() + user = identity_fakes.FakeUser.create_one_user() + + columns = ( + 'attachments', + 'availability_zone', + 'bootable', + 'description', + 'id', + 'name', + 'properties', + 'size', + 'snapshot_id', + 'status', + 'type', + ) def setUp(self): super().setUp() - self.volume_a = sdk_fakes.generate_fake_resource(_volume.Volume) - self.volume_b = sdk_fakes.generate_fake_resource(_volume.Volume) - self.summary = sdk_fakes.generate_fake_resource( - _summary.BlockStorageSummary, - total_count=2, - total_size=self.volume_a.size + self.volume_b.size, + self.volumes_mock = self.volume_client.volumes + self.volumes_mock.reset_mock() + + self.consistencygroups_mock = self.volume_client.consistencygroups + self.consistencygroups_mock.reset_mock() + + self.snapshots_mock = self.volume_client.volume_snapshots + self.snapshots_mock.reset_mock() + + self.backups_mock = self.volume_client.backups + self.backups_mock.reset_mock() + + self.new_volume = volume_fakes.create_one_volume() + self.volumes_mock.create.return_value = self.new_volume + + self.datalist = ( + self.new_volume.attachments, + self.new_volume.availability_zone, + self.new_volume.bootable, + self.new_volume.description, + self.new_volume.id, + self.new_volume.name, + format_columns.DictColumn(self.new_volume.metadata), + self.new_volume.size, + self.new_volume.snapshot_id, + self.new_volume.status, + self.new_volume.volume_type, ) - self.volume_sdk_client.summary.return_value = self.summary # Get the command object to test - self.cmd = volume.VolumeSummary(self.app, None) + self.cmd = volume.CreateVolume(self.app, None) - def test_volume_summary(self): - self._set_mock_microversion('3.12') + def test_volume_create_min_options(self): arglist = [ - '--all-projects', + '--size', + str(self.new_volume.size), ] verifylist = [ - ('all_projects', True), + ('size', self.new_volume.size), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. columns, data = self.cmd.take_action(parsed_args) - self.volume_sdk_client.summary.assert_called_once_with(True) + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=None, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) - datalist = (2, self.volume_a.size + self.volume_b.size) - self.assertCountEqual(datalist, tuple(data)) - - def test_volume_summary_pre_312(self): + def test_volume_create_options(self): + consistency_group = volume_fakes.create_one_consistency_group() + self.consistencygroups_mock.get.return_value = consistency_group arglist = [ - '--all-projects', + '--size', + str(self.new_volume.size), + '--description', + self.new_volume.description, + '--type', + self.new_volume.volume_type, + '--availability-zone', + self.new_volume.availability_zone, + '--consistency-group', + consistency_group.id, + '--hint', + 'k=v', + self.new_volume.name, ] verifylist = [ - ('all_projects', True), + ('size', self.new_volume.size), + ('description', self.new_volume.description), + ('type', self.new_volume.volume_type), + ('availability_zone', self.new_volume.availability_zone), + ('consistency_group', consistency_group.id), + ('hint', {'k': 'v'}), + ('name', self.new_volume.name), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=self.new_volume.description, + volume_type=self.new_volume.volume_type, + availability_zone=self.new_volume.availability_zone, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=consistency_group.id, + scheduler_hints={'k': 'v'}, + backup_id=None, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + def test_volume_create_properties(self): + arglist = [ + '--property', + 'Alpha=a', + '--property', + 'Beta=b', + '--size', + str(self.new_volume.size), + self.new_volume.name, + ] + verifylist = [ + ('property', {'Alpha': 'a', 'Beta': 'b'}), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata={'Alpha': 'a', 'Beta': 'b'}, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + def test_volume_create_image_id(self): + image = image_fakes.create_one_image() + self.image_client.find_image.return_value = image + + arglist = [ + '--image', + image.id, + '--size', + str(self.new_volume.size), + self.new_volume.name, + ] + verifylist = [ + ('image', image.id), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=image.id, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + def test_volume_create_image_name(self): + image = image_fakes.create_one_image() + self.image_client.find_image.return_value = image + + arglist = [ + '--image', + image.name, + '--size', + str(self.new_volume.size), + self.new_volume.name, + ] + verifylist = [ + ('image', image.name), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=image.id, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + def test_volume_create_with_snapshot(self): + snapshot = volume_fakes.create_one_snapshot() + self.new_volume.snapshot_id = snapshot.id + arglist = [ + '--snapshot', + self.new_volume.snapshot_id, + self.new_volume.name, + ] + verifylist = [ + ('snapshot', self.new_volume.snapshot_id), + ('name', self.new_volume.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.snapshots_mock.get.return_value = snapshot + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_once_with( + size=snapshot.size, + snapshot_id=snapshot.id, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + def test_volume_create_with_backup(self): + backup = volume_fakes.create_one_backup() + self.new_volume.backup_id = backup.id + arglist = [ + '--backup', + self.new_volume.backup_id, + self.new_volume.name, + ] + verifylist = [ + ('backup', self.new_volume.backup_id), + ('name', self.new_volume.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.backups_mock.get.return_value = backup + + self.volume_client.api_version = api_versions.APIVersion('3.47') + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_once_with( + size=backup.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=backup.id, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + def test_volume_create_with_backup_pre_v347(self): + backup = volume_fakes.create_one_backup() + self.new_volume.backup_id = backup.id + arglist = [ + '--backup', + self.new_volume.backup_id, + self.new_volume.name, + ] + verifylist = [ + ('backup', self.new_volume.backup_id), + ('name', self.new_volume.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.backups_mock.get.return_value = backup + exc = self.assertRaises( exceptions.CommandError, self.cmd.take_action, parsed_args ) - self.assertIn( - '--os-volume-api-version 3.12 or greater is required', str(exc) - ) - - def test_volume_summary_with_metadata(self): - self._set_mock_microversion('3.36') - - metadata = {**self.volume_a.metadata, **self.volume_b.metadata} - self.summary = sdk_fakes.generate_fake_resource( - _summary.BlockStorageSummary, - total_count=2, - total_size=self.volume_a.size + self.volume_b.size, - metadata=metadata, - ) - self.volume_sdk_client.summary.return_value = self.summary - - new_cols = copy.deepcopy(self.columns) - new_cols.extend(['Metadata']) + self.assertIn("--os-volume-api-version 3.47 or greater", str(exc)) + def test_volume_create_with_source_volume(self): + source_vol = "source_vol" arglist = [ - '--all-projects', + '--source', + self.new_volume.id, + source_vol, ] verifylist = [ - ('all_projects', True), + ('source', self.new_volume.id), + ('name', source_vol), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.volumes_mock.get.return_value = self.new_volume + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_once_with( + size=self.new_volume.size, + snapshot_id=None, + name=source_vol, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=self.new_volume.id, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + @mock.patch.object(utils, 'wait_for_status', return_value=True) + def test_volume_create_with_bootable_and_readonly(self, mock_wait): + arglist = [ + '--bootable', + '--read-only', + '--size', + str(self.new_volume.size), + self.new_volume.name, + ] + verifylist = [ + ('bootable', True), + ('non_bootable', False), + ('read_only', True), + ('read_write', False), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) - self.volume_sdk_client.summary.assert_called_once_with(True) - - self.assertEqual(new_cols, columns) - - datalist = ( - 2, - self.volume_a.size + self.volume_b.size, - format_columns.DictColumn(metadata), + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, ) - self.assertCountEqual(datalist, tuple(data)) - -class TestVolumeRevertToSnapshot(BaseVolumeTest): - def setUp(self): - super().setUp() - - self.volume = sdk_fakes.generate_fake_resource(_volume.Volume) - self.snapshot = sdk_fakes.generate_fake_resource( - _snapshot.Snapshot, - volume_id=self.volume.id, + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + self.volumes_mock.set_bootable.assert_called_with( + self.new_volume.id, True + ) + self.volumes_mock.update_readonly_flag.assert_called_with( + self.new_volume.id, True ) - self.volume_sdk_client.find_volume.return_value = self.volume - self.volume_sdk_client.find_snapshot.return_value = self.snapshot - # Get the command object to test - self.cmd = volume.VolumeRevertToSnapshot(self.app, None) - - def test_volume_revert_to_snapshot_pre_340(self): + @mock.patch.object(utils, 'wait_for_status', return_value=True) + def test_volume_create_with_nonbootable_and_readwrite(self, mock_wait): arglist = [ - self.snapshot.id, + '--non-bootable', + '--read-write', + '--size', + str(self.new_volume.size), + self.new_volume.name, ] verifylist = [ - ('snapshot', self.snapshot.id), + ('bootable', False), + ('non_bootable', True), + ('read_only', False), + ('read_write', True), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + self.volumes_mock.set_bootable.assert_called_with( + self.new_volume.id, False + ) + self.volumes_mock.update_readonly_flag.assert_called_with( + self.new_volume.id, False + ) + + @mock.patch.object(volume.LOG, 'error') + @mock.patch.object(utils, 'wait_for_status', return_value=True) + def test_volume_create_with_bootable_and_readonly_fail( + self, mock_wait, mock_error + ): + self.volumes_mock.set_bootable.side_effect = exceptions.CommandError() + + self.volumes_mock.update_readonly_flag.side_effect = ( + exceptions.CommandError() + ) + + arglist = [ + '--bootable', + '--read-only', + '--size', + str(self.new_volume.size), + self.new_volume.name, + ] + verifylist = [ + ('bootable', True), + ('non_bootable', False), + ('read_only', True), + ('read_write', False), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(2, mock_error.call_count) + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + self.volumes_mock.set_bootable.assert_called_with( + self.new_volume.id, True + ) + self.volumes_mock.update_readonly_flag.assert_called_with( + self.new_volume.id, True + ) + + @mock.patch.object(volume.LOG, 'error') + @mock.patch.object(utils, 'wait_for_status', return_value=False) + def test_volume_create_non_available_with_readonly( + self, + mock_wait, + mock_error, + ): + arglist = [ + '--non-bootable', + '--read-only', + '--size', + str(self.new_volume.size), + self.new_volume.name, + ] + verifylist = [ + ('bootable', False), + ('non_bootable', True), + ('read_only', True), + ('read_write', False), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints=None, + backup_id=None, + ) + + self.assertEqual(2, mock_error.call_count) + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + + def test_volume_create_without_size(self): + arglist = [ + self.new_volume.name, + ] + verifylist = [ + ('name', self.new_volume.name), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) - exc = self.assertRaises( + self.assertRaises( exceptions.CommandError, self.cmd.take_action, parsed_args ) - self.assertIn( - '--os-volume-api-version 3.40 or greater is required', str(exc) - ) - def test_volume_revert_to_snapshot(self): - self._set_mock_microversion('3.40') + def test_volume_create_with_multi_source(self): arglist = [ - self.snapshot.id, + '--image', + 'source_image', + '--source', + 'source_volume', + '--snapshot', + 'source_snapshot', + '--size', + str(self.new_volume.size), + self.new_volume.name, ] verifylist = [ - ('snapshot', self.snapshot.id), + ('image', 'source_image'), + ('source', 'source_volume'), + ('snapshot', 'source_snapshot'), + ('size', self.new_volume.size), + ('name', self.new_volume.name), + ] + + self.assertRaises( + test_utils.ParserException, + self.check_parser, + self.cmd, + arglist, + verifylist, + ) + + def test_volume_create_hints(self): + """--hint needs to behave differently based on the given hint + + different_host and same_host need to append to a list if given multiple + times. All other parameter are strings. + """ + arglist = [ + '--size', + str(self.new_volume.size), + '--hint', + 'k=v', + '--hint', + 'k=v2', + '--hint', + 'same_host=v3', + '--hint', + 'same_host=v4', + '--hint', + 'different_host=v5', + '--hint', + 'local_to_instance=v6', + '--hint', + 'different_host=v7', + self.new_volume.name, + ] + verifylist = [ + ('size', self.new_volume.size), + ( + 'hint', + { + 'k': 'v2', + 'same_host': ['v3', 'v4'], + 'local_to_instance': 'v6', + 'different_host': ['v5', 'v7'], + }, + ), + ('name', self.new_volume.name), ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) - self.cmd.take_action(parsed_args) + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) - self.volume_sdk_client.revert_volume_to_snapshot.assert_called_once_with( - self.volume, - self.snapshot, - ) - self.volume_sdk_client.find_volume.assert_called_with( - self.volume.id, - ignore_missing=False, - ) - self.volume_sdk_client.find_snapshot.assert_called_with( - self.snapshot.id, - ignore_missing=False, + self.volumes_mock.create.assert_called_with( + size=self.new_volume.size, + snapshot_id=None, + name=self.new_volume.name, + description=None, + volume_type=None, + availability_zone=None, + metadata=None, + imageRef=None, + source_volid=None, + consistencygroup_id=None, + scheduler_hints={ + 'k': 'v2', + 'same_host': ['v3', 'v4'], + 'local_to_instance': 'v6', + 'different_host': ['v5', 'v7'], + }, + backup_id=None, ) + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.datalist, data) + class TestVolumeCreate(BaseVolumeTest): columns = ( @@ -313,7 +871,7 @@ class TestVolumeCreate(BaseVolumeTest): self.assertEqual(self.columns, columns) self.assertCountEqual(self.datalist, data) - def test_volume_create_remote_source_pre_316(self): + def test_volume_create_remote_source_pre_v316(self): self._set_mock_microversion('3.15') arglist = [ '--remote-source', @@ -429,6 +987,120 @@ class TestVolumeCreate(BaseVolumeTest): ) +class TestVolumeDeleteLegacy(BaseVolumeTest): + def setUp(self): + super().setUp() + + self.volumes_mock = self.volume_client.volumes + self.volumes_mock.reset_mock() + + self.volumes_mock.delete.return_value = None + self.volumes = volume_fakes.create_volumes(count=1) + self.volumes_mock.get = volume_fakes.get_volumes(self.volumes, 0) + + # Get the command object to mock + self.cmd = volume.DeleteVolume(self.app, None) + + def test_volume_delete_one_volume(self): + arglist = [self.volumes[0].id] + verifylist = [ + ("force", False), + ("purge", False), + ("volumes", [self.volumes[0].id]), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.volumes_mock.delete.assert_called_once_with( + self.volumes[0].id, cascade=False + ) + self.assertIsNone(result) + + def test_volume_delete_multi_volumes(self): + arglist = [v.id for v in self.volumes] + verifylist = [ + ('force', False), + ('purge', False), + ('volumes', arglist), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + calls = [mock.call(v.id, cascade=False) for v in self.volumes] + self.volumes_mock.delete.assert_has_calls(calls) + self.assertIsNone(result) + + def test_volume_delete_multi_volumes_with_exception(self): + arglist = [ + self.volumes[0].id, + 'unexist_volume', + ] + verifylist = [ + ('force', False), + ('purge', False), + ('volumes', arglist), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + find_mock_result = [self.volumes[0], exceptions.CommandError] + with mock.patch.object( + utils, 'find_resource', side_effect=find_mock_result + ) as find_mock: + try: + self.cmd.take_action(parsed_args) + self.fail('CommandError should be raised.') + except exceptions.CommandError as e: + self.assertEqual('1 of 2 volumes failed to delete.', str(e)) + + find_mock.assert_any_call(self.volumes_mock, self.volumes[0].id) + find_mock.assert_any_call(self.volumes_mock, 'unexist_volume') + + self.assertEqual(2, find_mock.call_count) + self.volumes_mock.delete.assert_called_once_with( + self.volumes[0].id, cascade=False + ) + + def test_volume_delete_with_purge(self): + arglist = [ + '--purge', + self.volumes[0].id, + ] + verifylist = [ + ('force', False), + ('purge', True), + ('volumes', [self.volumes[0].id]), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.volumes_mock.delete.assert_called_once_with( + self.volumes[0].id, cascade=True + ) + self.assertIsNone(result) + + def test_volume_delete_with_force(self): + arglist = [ + '--force', + self.volumes[0].id, + ] + verifylist = [ + ('force', True), + ('purge', False), + ('volumes', [self.volumes[0].id]), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.volumes_mock.force_delete.assert_called_once_with( + self.volumes[0].id + ) + self.assertIsNone(result) + + class TestVolumeDelete(BaseVolumeTest): def setUp(self): super().setUp() @@ -528,3 +1200,1170 @@ class TestVolumeDelete(BaseVolumeTest): "--remote parameter.", str(exc), ) + + +class TestVolumeList(volume_fakes.TestVolume): + project = identity_fakes.FakeProject.create_one_project() + user = identity_fakes.FakeUser.create_one_user() + + columns = [ + 'ID', + 'Name', + 'Status', + 'Size', + 'Attached to', + ] + + def setUp(self): + super().setUp() + + self.volumes_mock = self.volume_client.volumes + self.volumes_mock.reset_mock() + + self.projects_mock = self.identity_client.projects + self.projects_mock.reset_mock() + + self.users_mock = self.identity_client.users + self.users_mock.reset_mock() + + self.mock_volume = volume_fakes.create_one_volume() + self.volumes_mock.list.return_value = [self.mock_volume] + + self.users_mock.get.return_value = self.user + + self.projects_mock.get.return_value = self.project + + # Get the command object to test + self.cmd = volume.ListVolume(self.app, None) + + def test_volume_list_no_options(self): + arglist = [] + verifylist = [ + ('long', False), + ('all_projects', False), + ('name', None), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': False, + 'project_id': None, + 'user_id': None, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_project(self): + arglist = [ + '--project', + self.project.name, + ] + verifylist = [ + ('project', self.project.name), + ('long', False), + ('all_projects', False), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': True, + 'project_id': self.project.id, + 'user_id': None, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_project_domain(self): + arglist = [ + '--project', + self.project.name, + '--project-domain', + self.project.domain_id, + ] + verifylist = [ + ('project', self.project.name), + ('project_domain', self.project.domain_id), + ('long', False), + ('all_projects', False), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': True, + 'project_id': self.project.id, + 'user_id': None, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_user(self): + arglist = [ + '--user', + self.user.name, + ] + verifylist = [ + ('user', self.user.name), + ('long', False), + ('all_projects', False), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': False, + 'project_id': None, + 'user_id': self.user.id, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_user_domain(self): + arglist = [ + '--user', + self.user.name, + '--user-domain', + self.user.domain_id, + ] + verifylist = [ + ('user', self.user.name), + ('user_domain', self.user.domain_id), + ('long', False), + ('all_projects', False), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': False, + 'project_id': None, + 'user_id': self.user.id, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_name(self): + arglist = [ + '--name', + self.mock_volume.name, + ] + verifylist = [ + ('long', False), + ('all_projects', False), + ('name', self.mock_volume.name), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': False, + 'project_id': None, + 'user_id': None, + 'name': self.mock_volume.name, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_status(self): + arglist = [ + '--status', + self.mock_volume.status, + ] + verifylist = [ + ('long', False), + ('all_projects', False), + ('name', None), + ('status', self.mock_volume.status), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': False, + 'project_id': None, + 'user_id': None, + 'name': None, + 'status': self.mock_volume.status, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_all_projects(self): + arglist = [ + '--all-projects', + ] + verifylist = [ + ('long', False), + ('all_projects', True), + ('name', None), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': True, + 'project_id': None, + 'user_id': None, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_long(self): + arglist = [ + '--long', + ] + verifylist = [ + ('long', True), + ('all_projects', False), + ('name', None), + ('status', None), + ('marker', None), + ('limit', None), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': False, + 'project_id': None, + 'user_id': None, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + collist = [ + 'ID', + 'Name', + 'Status', + 'Size', + 'Type', + 'Bootable', + 'Attached to', + 'Properties', + ] + self.assertEqual(collist, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + self.mock_volume.volume_type, + self.mock_volume.bootable, + volume.AttachmentsColumn(self.mock_volume.attachments), + format_columns.DictColumn(self.mock_volume.metadata), + ), + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_with_marker_and_limit(self): + arglist = [ + "--marker", + self.mock_volume.id, + "--limit", + "2", + ] + verifylist = [ + ('long', False), + ('all_projects', False), + ('name', None), + ('status', None), + ('marker', self.mock_volume.id), + ('limit', 2), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.assertEqual(self.columns, columns) + + datalist = ( + ( + self.mock_volume.id, + self.mock_volume.name, + self.mock_volume.status, + self.mock_volume.size, + volume.AttachmentsColumn(self.mock_volume.attachments), + ), + ) + + self.volumes_mock.list.assert_called_once_with( + marker=self.mock_volume.id, + limit=2, + search_opts={ + 'status': None, + 'project_id': None, + 'user_id': None, + 'name': None, + 'all_tenants': False, + }, + ) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_list_negative_limit(self): + arglist = [ + "--limit", + "-2", + ] + verifylist = [ + ("limit", -2), + ] + self.assertRaises( + test_utils.ParserException, + self.check_parser, + self.cmd, + arglist, + verifylist, + ) + + def test_volume_list_backward_compatibility(self): + arglist = [ + '-c', + 'Display Name', + ] + verifylist = [ + ('columns', ['Display Name']), + ('long', False), + ('all_projects', False), + ('name', None), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'all_tenants': False, + 'project_id': None, + 'user_id': None, + 'name': None, + 'status': None, + } + self.volumes_mock.list.assert_called_once_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + + self.assertIn('Display Name', columns) + self.assertNotIn('Name', columns) + + for each_volume in data: + self.assertIn(self.mock_volume.name, each_volume) + + +class TestVolumeMigrate(volume_fakes.TestVolume): + _volume = volume_fakes.create_one_volume() + + def setUp(self): + super().setUp() + + self.volumes_mock = self.volume_client.volumes + self.volumes_mock.reset_mock() + + self.volumes_mock.get.return_value = self._volume + self.volumes_mock.migrate_volume.return_value = None + # Get the command object to test + self.cmd = volume.MigrateVolume(self.app, None) + + def test_volume_migrate(self): + arglist = [ + "--host", + "host@backend-name#pool", + self._volume.id, + ] + verifylist = [ + ("force_host_copy", False), + ("lock_volume", False), + ("host", "host@backend-name#pool"), + ("volume", self._volume.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.get.assert_called_once_with(self._volume.id) + self.volumes_mock.migrate_volume.assert_called_once_with( + self._volume.id, "host@backend-name#pool", False, False + ) + self.assertIsNone(result) + + def test_volume_migrate_with_option(self): + arglist = [ + "--force-host-copy", + "--lock-volume", + "--host", + "host@backend-name#pool", + self._volume.id, + ] + verifylist = [ + ("force_host_copy", True), + ("lock_volume", True), + ("host", "host@backend-name#pool"), + ("volume", self._volume.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.get.assert_called_once_with(self._volume.id) + self.volumes_mock.migrate_volume.assert_called_once_with( + self._volume.id, "host@backend-name#pool", True, True + ) + self.assertIsNone(result) + + def test_volume_migrate_without_host(self): + arglist = [ + self._volume.id, + ] + verifylist = [ + ("force_host_copy", False), + ("lock_volume", False), + ("volume", self._volume.id), + ] + + self.assertRaises( + test_utils.ParserException, + self.check_parser, + self.cmd, + arglist, + verifylist, + ) + + +class TestVolumeSet(volume_fakes.TestVolume): + volume_type = volume_fakes.create_one_volume_type() + + def setUp(self): + super().setUp() + + self.volumes_mock = self.volume_client.volumes + self.volumes_mock.reset_mock() + + self.types_mock = self.volume_client.volume_types + self.types_mock.reset_mock() + + self.new_volume = volume_fakes.create_one_volume() + self.volumes_mock.get.return_value = self.new_volume + self.types_mock.get.return_value = self.volume_type + + # Get the command object to test + self.cmd = volume.SetVolume(self.app, None) + + def test_volume_set_property(self): + arglist = [ + '--property', + 'a=b', + '--property', + 'c=d', + self.new_volume.id, + ] + verifylist = [ + ('property', {'a': 'b', 'c': 'd'}), + ('volume', self.new_volume.id), + ('bootable', False), + ('non_bootable', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.cmd.take_action(parsed_args) + self.volumes_mock.set_metadata.assert_called_with( + self.new_volume.id, parsed_args.property + ) + + def test_volume_set_image_property(self): + arglist = [ + '--image-property', + 'Alpha=a', + '--image-property', + 'Beta=b', + self.new_volume.id, + ] + verifylist = [ + ('image_property', {'Alpha': 'a', 'Beta': 'b'}), + ('volume', self.new_volume.id), + ('bootable', False), + ('non_bootable', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns nothing + self.cmd.take_action(parsed_args) + self.volumes_mock.set_image_metadata.assert_called_with( + self.new_volume.id, parsed_args.image_property + ) + + def test_volume_set_state(self): + arglist = ['--state', 'error', self.new_volume.id] + verifylist = [ + ('read_only', False), + ('read_write', False), + ('state', 'error'), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.reset_state.assert_called_with( + self.new_volume.id, 'error' + ) + self.volumes_mock.update_readonly_flag.assert_not_called() + self.assertIsNone(result) + + def test_volume_set_state_failed(self): + self.volumes_mock.reset_state.side_effect = exceptions.CommandError() + arglist = ['--state', 'error', self.new_volume.id] + verifylist = [('state', 'error'), ('volume', self.new_volume.id)] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + try: + self.cmd.take_action(parsed_args) + self.fail('CommandError should be raised.') + except exceptions.CommandError as e: + self.assertEqual( + 'One or more of the set operations failed', str(e) + ) + self.volumes_mock.reset_state.assert_called_with( + self.new_volume.id, 'error' + ) + + def test_volume_set_attached(self): + arglist = ['--attached', self.new_volume.id] + verifylist = [ + ('attached', True), + ('detached', False), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.reset_state.assert_called_with( + self.new_volume.id, attach_status='attached', state=None + ) + self.assertIsNone(result) + + def test_volume_set_detached(self): + arglist = ['--detached', self.new_volume.id] + verifylist = [ + ('attached', False), + ('detached', True), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.reset_state.assert_called_with( + self.new_volume.id, attach_status='detached', state=None + ) + self.assertIsNone(result) + + def test_volume_set_bootable(self): + arglist = [ + ['--bootable', self.new_volume.id], + ['--non-bootable', self.new_volume.id], + ] + verifylist = [ + [ + ('bootable', True), + ('non_bootable', False), + ('volume', self.new_volume.id), + ], + [ + ('bootable', False), + ('non_bootable', True), + ('volume', self.new_volume.id), + ], + ] + for index in range(len(arglist)): + parsed_args = self.check_parser( + self.cmd, arglist[index], verifylist[index] + ) + + self.cmd.take_action(parsed_args) + self.volumes_mock.set_bootable.assert_called_with( + self.new_volume.id, verifylist[index][0][1] + ) + + def test_volume_set_readonly(self): + arglist = ['--read-only', self.new_volume.id] + verifylist = [ + ('read_only', True), + ('read_write', False), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.update_readonly_flag.assert_called_once_with( + self.new_volume.id, True + ) + self.assertIsNone(result) + + def test_volume_set_read_write(self): + arglist = ['--read-write', self.new_volume.id] + verifylist = [ + ('read_only', False), + ('read_write', True), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.update_readonly_flag.assert_called_once_with( + self.new_volume.id, False + ) + self.assertIsNone(result) + + def test_volume_set_type(self): + arglist = ['--type', self.volume_type.id, self.new_volume.id] + verifylist = [ + ('retype_policy', None), + ('type', self.volume_type.id), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.retype.assert_called_once_with( + self.new_volume.id, self.volume_type.id, 'never' + ) + self.assertIsNone(result) + + def test_volume_set_type_with_policy(self): + arglist = [ + '--retype-policy', + 'on-demand', + '--type', + self.volume_type.id, + self.new_volume.id, + ] + verifylist = [ + ('retype_policy', 'on-demand'), + ('type', self.volume_type.id), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.retype.assert_called_once_with( + self.new_volume.id, self.volume_type.id, 'on-demand' + ) + self.assertIsNone(result) + + @mock.patch.object(volume.LOG, 'warning') + def test_volume_set_with_only_retype_policy(self, mock_warning): + arglist = ['--retype-policy', 'on-demand', self.new_volume.id] + verifylist = [ + ('retype_policy', 'on-demand'), + ('volume', self.new_volume.id), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + self.volumes_mock.retype.assert_not_called() + mock_warning.assert_called_with( + "'--retype-policy' option will " "not work without '--type' option" + ) + self.assertIsNone(result) + + +class TestVolumeShow(volume_fakes.TestVolume): + def setUp(self): + super().setUp() + + self.volumes_mock = self.volume_client.volumes + self.volumes_mock.reset_mock() + + self._volume = volume_fakes.create_one_volume() + self.volumes_mock.get.return_value = self._volume + # Get the command object to test + self.cmd = volume.ShowVolume(self.app, None) + + def test_volume_show(self): + arglist = [self._volume.id] + verifylist = [("volume", self._volume.id)] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + self.volumes_mock.get.assert_called_with(self._volume.id) + + self.assertEqual( + tuple(sorted(self._volume.keys())), + columns, + ) + self.assertTupleEqual( + ( + self._volume.attachments, + self._volume.availability_zone, + self._volume.bootable, + self._volume.description, + self._volume.id, + self._volume.name, + format_columns.DictColumn(self._volume.metadata), + self._volume.size, + self._volume.snapshot_id, + self._volume.status, + self._volume.volume_type, + ), + data, + ) + + +class TestVolumeUnset(volume_fakes.TestVolume): + def setUp(self): + super().setUp() + + self.volumes_mock = self.volume_client.volumes + self.volumes_mock.reset_mock() + + self.new_volume = volume_fakes.create_one_volume() + self.volumes_mock.get.return_value = self.new_volume + + # Get the command object to set property + self.cmd_set = volume.SetVolume(self.app, None) + + # Get the command object to unset property + self.cmd_unset = volume.UnsetVolume(self.app, None) + + def test_volume_unset_image_property(self): + # Arguments for setting image properties + arglist = [ + '--image-property', + 'Alpha=a', + '--image-property', + 'Beta=b', + self.new_volume.id, + ] + verifylist = [ + ('image_property', {'Alpha': 'a', 'Beta': 'b'}), + ('volume', self.new_volume.id), + ] + parsed_args = self.check_parser(self.cmd_set, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns nothing + self.cmd_set.take_action(parsed_args) + + # Arguments for unsetting image properties + arglist_unset = [ + '--image-property', + 'Alpha', + self.new_volume.id, + ] + verifylist_unset = [ + ('image_property', ['Alpha']), + ('volume', self.new_volume.id), + ] + parsed_args_unset = self.check_parser( + self.cmd_unset, arglist_unset, verifylist_unset + ) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns nothing + self.cmd_unset.take_action(parsed_args_unset) + + self.volumes_mock.delete_image_metadata.assert_called_with( + self.new_volume.id, parsed_args_unset.image_property + ) + + def test_volume_unset_image_property_fail(self): + self.volumes_mock.delete_image_metadata.side_effect = ( + exceptions.CommandError() + ) + arglist = [ + '--image-property', + 'Alpha', + '--property', + 'Beta', + self.new_volume.id, + ] + verifylist = [ + ('image_property', ['Alpha']), + ('property', ['Beta']), + ('volume', self.new_volume.id), + ] + parsed_args = self.check_parser(self.cmd_unset, arglist, verifylist) + + try: + self.cmd_unset.take_action(parsed_args) + self.fail('CommandError should be raised.') + except exceptions.CommandError as e: + self.assertEqual( + 'One or more of the unset operations failed', str(e) + ) + self.volumes_mock.delete_image_metadata.assert_called_with( + self.new_volume.id, parsed_args.image_property + ) + self.volumes_mock.delete_metadata.assert_called_with( + self.new_volume.id, parsed_args.property + ) + + +class TestVolumeSummary(BaseVolumeTest): + columns = [ + 'Total Count', + 'Total Size', + ] + + def setUp(self): + super().setUp() + + self.volume_a = sdk_fakes.generate_fake_resource(_volume.Volume) + self.volume_b = sdk_fakes.generate_fake_resource(_volume.Volume) + self.summary = sdk_fakes.generate_fake_resource( + _summary.BlockStorageSummary, + total_count=2, + total_size=self.volume_a.size + self.volume_b.size, + ) + self.volume_sdk_client.summary.return_value = self.summary + + # Get the command object to test + self.cmd = volume.VolumeSummary(self.app, None) + + def test_volume_summary(self): + self._set_mock_microversion('3.12') + arglist = [ + '--all-projects', + ] + verifylist = [ + ('all_projects', True), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volume_sdk_client.summary.assert_called_once_with(True) + + self.assertEqual(self.columns, columns) + + datalist = (2, self.volume_a.size + self.volume_b.size) + self.assertCountEqual(datalist, tuple(data)) + + def test_volume_summary_pre_v312(self): + arglist = [ + '--all-projects', + ] + verifylist = [ + ('all_projects', True), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + exc = self.assertRaises( + exceptions.CommandError, self.cmd.take_action, parsed_args + ) + self.assertIn( + '--os-volume-api-version 3.12 or greater is required', str(exc) + ) + + def test_volume_summary_with_metadata(self): + self._set_mock_microversion('3.36') + + metadata = {**self.volume_a.metadata, **self.volume_b.metadata} + self.summary = sdk_fakes.generate_fake_resource( + _summary.BlockStorageSummary, + total_count=2, + total_size=self.volume_a.size + self.volume_b.size, + metadata=metadata, + ) + self.volume_sdk_client.summary.return_value = self.summary + + new_cols = copy.deepcopy(self.columns) + new_cols.extend(['Metadata']) + + arglist = [ + '--all-projects', + ] + verifylist = [ + ('all_projects', True), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volume_sdk_client.summary.assert_called_once_with(True) + + self.assertEqual(new_cols, columns) + + datalist = ( + 2, + self.volume_a.size + self.volume_b.size, + format_columns.DictColumn(metadata), + ) + self.assertCountEqual(datalist, tuple(data)) + + +class TestVolumeRevertToSnapshot(BaseVolumeTest): + def setUp(self): + super().setUp() + + self.volume = sdk_fakes.generate_fake_resource(_volume.Volume) + self.snapshot = sdk_fakes.generate_fake_resource( + _snapshot.Snapshot, + volume_id=self.volume.id, + ) + self.volume_sdk_client.find_volume.return_value = self.volume + self.volume_sdk_client.find_snapshot.return_value = self.snapshot + + # Get the command object to test + self.cmd = volume.VolumeRevertToSnapshot(self.app, None) + + def test_volume_revert_to_snapshot_pre_v340(self): + arglist = [ + self.snapshot.id, + ] + verifylist = [ + ('snapshot', self.snapshot.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + exc = self.assertRaises( + exceptions.CommandError, self.cmd.take_action, parsed_args + ) + self.assertIn( + '--os-volume-api-version 3.40 or greater is required', str(exc) + ) + + def test_volume_revert_to_snapshot(self): + self._set_mock_microversion('3.40') + arglist = [ + self.snapshot.id, + ] + verifylist = [ + ('snapshot', self.snapshot.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.cmd.take_action(parsed_args) + + self.volume_sdk_client.revert_volume_to_snapshot.assert_called_once_with( + self.volume, + self.snapshot, + ) + self.volume_sdk_client.find_volume.assert_called_with( + self.volume.id, + ignore_missing=False, + ) + self.volume_sdk_client.find_snapshot.assert_called_with( + self.snapshot.id, + ignore_missing=False, + ) + + +class TestColumns(volume_fakes.TestVolume): + def test_attachments_column_without_server_cache(self): + _volume = volume_fakes.create_one_volume() + server_id = _volume.attachments[0]['server_id'] + device = _volume.attachments[0]['device'] + + col = volume.AttachmentsColumn(_volume.attachments, {}) + self.assertEqual( + f'Attached to {server_id} on {device} ', + col.human_readable(), + ) + self.assertEqual(_volume.attachments, col.machine_readable()) + + def test_attachments_column_with_server_cache(self): + _volume = volume_fakes.create_one_volume() + + server_id = _volume.attachments[0]['server_id'] + device = _volume.attachments[0]['device'] + fake_server = mock.Mock() + fake_server.name = 'fake-server-name' + server_cache = {server_id: fake_server} + + col = volume.AttachmentsColumn(_volume.attachments, server_cache) + self.assertEqual( + 'Attached to {} on {} '.format('fake-server-name', device), + col.human_readable(), + ) + self.assertEqual(_volume.attachments, col.machine_readable()) diff --git a/openstackclient/volume/v2/volume.py b/openstackclient/volume/v2/volume.py index eaaa40a5a5..53a19d4297 100644 --- a/openstackclient/volume/v2/volume.py +++ b/openstackclient/volume/v2/volume.py @@ -99,12 +99,10 @@ class CreateVolume(command.ShowOne): volume is not specified. """ - if ( - args.snapshot or args.source or args.backup - ) is None and args.size is None: + if (args.snapshot or args.source) is None and args.size is None: msg = _( - "--size is a required option if snapshot, backup " - "or source volume are not specified." + "--size is a required option if --snapshot or --source are " + "not specified" ) raise exceptions.CommandError(msg) @@ -121,8 +119,8 @@ class CreateVolume(command.ShowOne): metavar="", type=int, help=_( - "Volume size in GB (required unless --snapshot, " - "--source or --backup is specified)" + "Volume size in GB (required unless --snapshot or " + "--source specified)" ), ) parser.add_argument( @@ -146,14 +144,6 @@ class CreateVolume(command.ShowOne): metavar="", help=_("Volume to clone (name or ID)"), ) - source_group.add_argument( - "--backup", - metavar="", - help=_( - "Restore backup to a volume (name or ID) " - "(supported by --os-volume-api-version 3.47 or later)" - ), - ) source_group.add_argument( "--source-replicated", metavar="", @@ -222,26 +212,17 @@ class CreateVolume(command.ShowOne): parser, _ = self._get_parser(prog_name) return parser - def _take_action(self, parsed_args): + def take_action(self, parsed_args): CreateVolume._check_size_arg(parsed_args) # size is validated in the above call to # _check_size_arg where we check that size # should be passed if we are not creating a - # volume from snapshot, backup or source volume + # volume from snapshot or source volume size = parsed_args.size volume_client = self.app.client_manager.volume image_client = self.app.client_manager.image - if parsed_args.backup and not ( - volume_client.api_version.matches('3.47') - ): - msg = _( - "--os-volume-api-version 3.47 or greater is required " - "to create a volume from backup." - ) - raise exceptions.CommandError(msg) - source_volume = None if parsed_args.source: source_volume_obj = utils.find_resource( @@ -276,15 +257,6 @@ class CreateVolume(command.ShowOne): # snapshot size. size = max(size or 0, snapshot_obj.size) - backup = None - if parsed_args.backup: - backup_obj = utils.find_resource( - volume_client.backups, parsed_args.backup - ) - backup = backup_obj.id - # As above - size = max(size or 0, backup_obj.size) - volume = volume_client.volumes.create( size=size, snapshot_id=snapshot, @@ -297,7 +269,6 @@ class CreateVolume(command.ShowOne): source_volid=source_volume, consistencygroup_id=consistency_group, scheduler_hints=parsed_args.hint, - backup_id=backup, ) if parsed_args.bootable or parsed_args.non_bootable: @@ -359,9 +330,6 @@ class CreateVolume(command.ShowOne): volume._info.pop("links", None) return zip(*sorted(volume._info.items())) - def take_action(self, parsed_args): - return self._take_action(parsed_args) - class DeleteVolume(command.Command): _description = _("Delete volume(s)") @@ -784,10 +752,7 @@ class SetVolume(command.Command): _("New size must be greater than %s GB") % volume.size ) raise exceptions.CommandError(msg) - if ( - volume.status != 'available' - and not volume_client.api_version.matches('3.42') - ): + if volume.status != 'available': msg = ( _( "Volume is in %s state, it must be available " diff --git a/openstackclient/volume/v3/volume.py b/openstackclient/volume/v3/volume.py index 844831839c..e1f025c75a 100644 --- a/openstackclient/volume/v3/volume.py +++ b/openstackclient/volume/v3/volume.py @@ -14,8 +14,12 @@ """Volume V3 Volume action implementations""" +import argparse +import copy +import functools import logging +from cliff import columns as cliff_columns from openstack import utils as sdk_utils from osc_lib.cli import format_columns from osc_lib.cli import parseractions @@ -23,12 +27,977 @@ from osc_lib.command import command from osc_lib import exceptions from osc_lib import utils +from openstackclient.common import pagination from openstackclient.i18n import _ +from openstackclient.identity import common as identity_common from openstackclient.volume.v2 import volume as volume_v2 + LOG = logging.getLogger(__name__) +class KeyValueHintAction(argparse.Action): + """Uses KeyValueAction or KeyValueAppendAction based on the given key""" + + APPEND_KEYS = ('same_host', 'different_host') + + def __init__(self, *args, **kwargs): + self._key_value_action = parseractions.KeyValueAction(*args, **kwargs) + self._key_value_append_action = parseractions.KeyValueAppendAction( + *args, **kwargs + ) + super().__init__(*args, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + if values.startswith(self.APPEND_KEYS): + self._key_value_append_action( + parser, namespace, values, option_string=option_string + ) + else: + self._key_value_action( + parser, namespace, values, option_string=option_string + ) + + +class AttachmentsColumn(cliff_columns.FormattableColumn): + """Formattable column for attachments column. + + Unlike the parent FormattableColumn class, the initializer of the + class takes server_cache as the second argument. + osc_lib.utils.get_item_properties instantiate cliff FormattableColumn + object with a single parameter "column value", so you need to pass + a partially initialized class like + ``functools.partial(AttachmentsColumn, server_cache)``. + """ + + def __init__(self, value, server_cache=None): + super().__init__(value) + self._server_cache = server_cache or {} + + def human_readable(self): + """Return a formatted string of a volume's attached instances + + :rtype: a string of formatted instances + """ + + msg = '' + for attachment in self._value: + server = attachment['server_id'] + if server in self._server_cache.keys(): + server = self._server_cache[server].name + device = attachment['device'] + msg += f'Attached to {server} on {device} ' + return msg + + +class CreateVolume(volume_v2.CreateVolume): + _description = _("Create new volume") + + @staticmethod + def _check_size_arg(args): + """Check whether --size option is required or not. + + Require size parameter in case if any of the following is not + specified: + + * snapshot + * source volume + * backup + * remote source (volume to be managed) + """ + + if ( + args.snapshot or args.source or args.backup or args.remote_source + ) is None and args.size is None: + msg = _( + "--size is a required option if none of --snapshot, " + "--backup, --source, or --remote-source are provided." + ) + raise exceptions.CommandError(msg) + + def get_parser(self, prog_name): + parser, source_group = self._get_parser(prog_name) + + source_group.add_argument( + "--backup", + metavar="", + help=_( + "Restore backup to a volume (name or ID) " + "(supported by --os-volume-api-version 3.47 or later)" + ), + ) + source_group.add_argument( + "--remote-source", + metavar="", + action=parseractions.KeyValueAction, + help=_( + "The attribute(s) of the existing remote volume " + "(admin required) (repeat option to specify multiple " + "attributes, e.g.: '--remote-source source-name=test_name " + "--remote-source source-id=test_id')" + ), + ) + parser.add_argument( + "--host", + metavar="", + help=_( + "Cinder host on which the existing volume resides; " + "takes the form: host@backend-name#pool. This is only " + "used along with the --remote-source option." + ), + ) + parser.add_argument( + "--cluster", + metavar="", + help=_( + "Cinder cluster on which the existing volume resides; " + "takes the form: cluster@backend-name#pool. This is only " + "used along with the --remote-source option. " + "(supported by --os-volume-api-version 3.16 or above)", + ), + ) + return parser + + def take_action(self, parsed_args): + CreateVolume._check_size_arg(parsed_args) + # size is validated in the above call to + # _check_size_arg where we check that size + # should be passed if we are not creating a + # volume from snapshot, backup or source volume + size = parsed_args.size + + volume_client_sdk = self.app.client_manager.sdk_connection.volume + volume_client = self.app.client_manager.volume + image_client = self.app.client_manager.image + + if ( + parsed_args.host or parsed_args.cluster + ) and not parsed_args.remote_source: + msg = _( + "The --host and --cluster options are only supported " + "with --remote-source parameter." + ) + raise exceptions.CommandError(msg) + + if parsed_args.backup and not ( + volume_client.api_version.matches('3.47') + ): + msg = _( + "--os-volume-api-version 3.47 or greater is required " + "to create a volume from backup." + ) + raise exceptions.CommandError(msg) + + if parsed_args.remote_source: + if ( + parsed_args.size + or parsed_args.consistency_group + or parsed_args.hint + or parsed_args.read_only + or parsed_args.read_write + ): + msg = _( + "The --size, --consistency-group, --hint, --read-only " + "and --read-write options are not supported with the " + "--remote-source parameter." + ) + raise exceptions.CommandError(msg) + if parsed_args.cluster: + if not sdk_utils.supports_microversion( + volume_client_sdk, '3.16' + ): + msg = _( + "--os-volume-api-version 3.16 or greater is required " + "to support the cluster parameter." + ) + raise exceptions.CommandError(msg) + if parsed_args.cluster and parsed_args.host: + msg = _( + "Only one of --host or --cluster needs to be specified " + "to manage a volume." + ) + raise exceptions.CommandError(msg) + if not parsed_args.cluster and not parsed_args.host: + msg = _( + "One of --host or --cluster needs to be specified to " + "manage a volume." + ) + raise exceptions.CommandError(msg) + volume = volume_client_sdk.manage_volume( + host=parsed_args.host, + cluster=parsed_args.cluster, + ref=parsed_args.remote_source, + name=parsed_args.name, + description=parsed_args.description, + volume_type=parsed_args.type, + availability_zone=parsed_args.availability_zone, + metadata=parsed_args.property, + bootable=parsed_args.bootable, + ) + return zip(*sorted(volume.items())) + + source_volume = None + if parsed_args.source: + source_volume_obj = utils.find_resource( + volume_client.volumes, parsed_args.source + ) + source_volume = source_volume_obj.id + size = max(size or 0, source_volume_obj.size) + + consistency_group = None + if parsed_args.consistency_group: + consistency_group = utils.find_resource( + volume_client.consistencygroups, parsed_args.consistency_group + ).id + + image = None + if parsed_args.image: + image = image_client.find_image( + parsed_args.image, ignore_missing=False + ).id + + snapshot = None + if parsed_args.snapshot: + snapshot_obj = utils.find_resource( + volume_client.volume_snapshots, parsed_args.snapshot + ) + snapshot = snapshot_obj.id + # Cinder requires a value for size when creating a volume + # even if creating from a snapshot. Cinder will create the + # volume with at least the same size as the snapshot anyway, + # so since we have the object here, just override the size + # value if it's either not given or is smaller than the + # snapshot size. + size = max(size or 0, snapshot_obj.size) + + backup = None + if parsed_args.backup: + backup_obj = utils.find_resource( + volume_client.backups, parsed_args.backup + ) + backup = backup_obj.id + # As above + size = max(size or 0, backup_obj.size) + + volume = volume_client.volumes.create( + size=size, + snapshot_id=snapshot, + name=parsed_args.name, + description=parsed_args.description, + volume_type=parsed_args.type, + availability_zone=parsed_args.availability_zone, + metadata=parsed_args.property, + imageRef=image, + source_volid=source_volume, + consistencygroup_id=consistency_group, + scheduler_hints=parsed_args.hint, + backup_id=backup, + ) + + if parsed_args.bootable or parsed_args.non_bootable: + try: + if utils.wait_for_status( + volume_client.volumes.get, + volume.id, + success_status=['available'], + error_status=['error'], + sleep_time=1, + ): + volume_client.volumes.set_bootable( + volume.id, parsed_args.bootable + ) + else: + msg = _( + "Volume status is not available for setting boot " + "state" + ) + raise exceptions.CommandError(msg) + except Exception as e: + LOG.error(_("Failed to set volume bootable property: %s"), e) + if parsed_args.read_only or parsed_args.read_write: + try: + if utils.wait_for_status( + volume_client.volumes.get, + volume.id, + success_status=['available'], + error_status=['error'], + sleep_time=1, + ): + volume_client.volumes.update_readonly_flag( + volume.id, parsed_args.read_only + ) + else: + msg = _( + "Volume status is not available for setting it" + "read only." + ) + raise exceptions.CommandError(msg) + except Exception as e: + LOG.error( + _( + "Failed to set volume read-only access " + "mode flag: %s" + ), + e, + ) + + # Remove key links from being displayed + volume._info.update( + { + 'properties': format_columns.DictColumn( + volume._info.pop('metadata') + ), + 'type': volume._info.pop('volume_type'), + } + ) + volume._info.pop("links", None) + return zip(*sorted(volume._info.items())) + + +class DeleteVolume(volume_v2.DeleteVolume): + _description = _("Delete volume(s)") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + '--remote', + action='store_true', + help=_("Specify this parameter to unmanage a volume."), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + volume_client_sdk = self.app.client_manager.sdk_connection.volume + result = 0 + + if parsed_args.remote and (parsed_args.force or parsed_args.purge): + msg = _( + "The --force and --purge options are not " + "supported with the --remote parameter." + ) + raise exceptions.CommandError(msg) + + for i in parsed_args.volumes: + try: + volume_obj = utils.find_resource(volume_client.volumes, i) + if parsed_args.remote: + volume_client_sdk.unmanage_volume(volume_obj.id) + elif parsed_args.force: + volume_client.volumes.force_delete(volume_obj.id) + else: + volume_client.volumes.delete( + volume_obj.id, cascade=parsed_args.purge + ) + except Exception as e: + result += 1 + LOG.error( + _( + "Failed to delete volume with " + "name or ID '%(volume)s': %(e)s" + ), + {'volume': i, 'e': e}, + ) + + if result > 0: + total = len(parsed_args.volumes) + msg = _("%(result)s of %(total)s volumes failed " "to delete.") % { + 'result': result, + 'total': total, + } + raise exceptions.CommandError(msg) + + +class ListVolume(command.Lister): + _description = _("List volumes") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + '--project', + metavar='', + help=_('Filter results by project (name or ID) (admin only)'), + ) + identity_common.add_project_domain_option_to_parser(parser) + parser.add_argument( + '--user', + metavar='', + help=_('Filter results by user (name or ID) (admin only)'), + ) + identity_common.add_user_domain_option_to_parser(parser) + parser.add_argument( + '--name', + metavar='', + help=_('Filter results by volume name'), + ) + parser.add_argument( + '--status', + metavar='', + help=_('Filter results by status'), + ) + parser.add_argument( + '--all-projects', + action='store_true', + default=False, + help=_('Include all projects (admin only)'), + ) + parser.add_argument( + '--long', + action='store_true', + default=False, + help=_('List additional fields in output'), + ) + pagination.add_marker_pagination_option_to_parser(parser) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + identity_client = self.app.client_manager.identity + + if parsed_args.long: + columns = [ + 'ID', + 'Name', + 'Status', + 'Size', + 'Volume Type', + 'Bootable', + 'Attachments', + 'Metadata', + ] + column_headers = copy.deepcopy(columns) + column_headers[4] = 'Type' + column_headers[6] = 'Attached to' + column_headers[7] = 'Properties' + else: + columns = [ + 'ID', + 'Name', + 'Status', + 'Size', + 'Attachments', + ] + column_headers = copy.deepcopy(columns) + column_headers[4] = 'Attached to' + + project_id = None + if parsed_args.project: + project_id = identity_common.find_project( + identity_client, + parsed_args.project, + parsed_args.project_domain, + ).id + + user_id = None + if parsed_args.user: + user_id = identity_common.find_user( + identity_client, parsed_args.user, parsed_args.user_domain + ).id + + # set value of 'all_tenants' when using project option + all_projects = bool(parsed_args.project) or parsed_args.all_projects + + search_opts = { + 'all_tenants': all_projects, + 'project_id': project_id, + 'user_id': user_id, + 'name': parsed_args.name, + 'status': parsed_args.status, + } + + data = volume_client.volumes.list( + search_opts=search_opts, + marker=parsed_args.marker, + limit=parsed_args.limit, + ) + + do_server_list = False + + for vol in data: + if vol.status == 'in-use': + do_server_list = True + break + + # Cache the server list + server_cache = {} + if do_server_list: + try: + compute_client = self.app.client_manager.compute + for s in compute_client.servers.list(): + server_cache[s.id] = s + except Exception: + # Just forget it if there's any trouble + pass # nosec: B110 + AttachmentsColumnWithCache = functools.partial( + AttachmentsColumn, server_cache=server_cache + ) + + column_headers = utils.backward_compat_col_lister( + column_headers, parsed_args.columns, {'Display Name': 'Name'} + ) + + return ( + column_headers, + ( + utils.get_item_properties( + s, + columns, + formatters={ + 'Metadata': format_columns.DictColumn, + 'Attachments': AttachmentsColumnWithCache, + }, + ) + for s in data + ), + ) + + +class MigrateVolume(command.Command): + _description = _("Migrate volume to a new host") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'volume', + metavar="", + help=_("Volume to migrate (name or ID)"), + ) + parser.add_argument( + '--host', + metavar="", + required=True, + help=_( + "Destination host (takes the form: host@backend-name#pool)" + ), + ) + parser.add_argument( + '--force-host-copy', + action="store_true", + help=_( + "Enable generic host-based force-migration, " + "which bypasses driver optimizations" + ), + ) + parser.add_argument( + '--lock-volume', + action="store_true", + help=_( + "If specified, the volume state will be locked " + "and will not allow a migration to be aborted " + "(possibly by another operation)" + ), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + volume = utils.find_resource(volume_client.volumes, parsed_args.volume) + volume_client.volumes.migrate_volume( + volume.id, + parsed_args.host, + parsed_args.force_host_copy, + parsed_args.lock_volume, + ) + + +class SetVolume(command.Command): + _description = _("Set volume properties") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'volume', + metavar='', + help=_('Volume to modify (name or ID)'), + ) + parser.add_argument( + '--name', + metavar='', + help=_('New volume name'), + ) + parser.add_argument( + '--size', + metavar='', + type=int, + help=_('Extend volume size in GB'), + ) + parser.add_argument( + '--description', + metavar='', + help=_('New volume description'), + ) + parser.add_argument( + "--no-property", + dest="no_property", + action="store_true", + help=_( + "Remove all properties from " + "(specify both --no-property and --property to " + "remove the current properties before setting " + "new properties.)" + ), + ) + parser.add_argument( + '--property', + metavar='', + action=parseractions.KeyValueAction, + help=_( + 'Set a property on this volume ' + '(repeat option to set multiple properties)' + ), + ) + parser.add_argument( + '--image-property', + metavar='', + action=parseractions.KeyValueAction, + help=_( + 'Set an image property on this volume ' + '(repeat option to set multiple image properties)' + ), + ) + parser.add_argument( + "--state", + metavar="", + choices=[ + 'available', + 'error', + 'creating', + 'deleting', + 'in-use', + 'attaching', + 'detaching', + 'error_deleting', + 'maintenance', + ], + help=_( + 'New volume state ("available", "error", "creating", ' + '"deleting", "in-use", "attaching", "detaching", ' + '"error_deleting" or "maintenance") (admin only) ' + '(This option simply changes the state of the volume ' + 'in the database with no regard to actual status, ' + 'exercise caution when using)' + ), + ) + attached_group = parser.add_mutually_exclusive_group() + attached_group.add_argument( + "--attached", + action="store_true", + help=_( + 'Set volume attachment status to "attached" ' + '(admin only) ' + '(This option simply changes the state of the volume ' + 'in the database with no regard to actual status, ' + 'exercise caution when using)' + ), + ) + attached_group.add_argument( + "--detached", + action="store_true", + help=_( + 'Set volume attachment status to "detached" ' + '(admin only) ' + '(This option simply changes the state of the volume ' + 'in the database with no regard to actual status, ' + 'exercise caution when using)' + ), + ) + parser.add_argument( + '--type', + metavar='', + help=_('New volume type (name or ID)'), + ) + parser.add_argument( + '--retype-policy', + metavar='', + choices=['never', 'on-demand'], + help=argparse.SUPPRESS, + ) + parser.add_argument( + '--migration-policy', + metavar='', + choices=['never', 'on-demand'], + help=_( + 'Migration policy while re-typing volume ' + '("never" or "on-demand", default is "never" ) ' + '(available only when --type option is specified)' + ), + ) + bootable_group = parser.add_mutually_exclusive_group() + bootable_group.add_argument( + "--bootable", + action="store_true", + help=_("Mark volume as bootable"), + ) + bootable_group.add_argument( + "--non-bootable", + action="store_true", + help=_("Mark volume as non-bootable"), + ) + readonly_group = parser.add_mutually_exclusive_group() + readonly_group.add_argument( + "--read-only", + action="store_true", + help=_("Set volume to read-only access mode"), + ) + readonly_group.add_argument( + "--read-write", + action="store_true", + help=_("Set volume to read-write access mode"), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + volume = utils.find_resource(volume_client.volumes, parsed_args.volume) + + result = 0 + if parsed_args.retype_policy: + msg = _( + "The '--retype-policy' option has been deprecated in favor " + "of '--migration-policy' option. The '--retype-policy' option " + "will be removed in a future release. Please use " + "'--migration-policy' instead." + ) + self.log.warning(msg) + + if parsed_args.size: + try: + if parsed_args.size <= volume.size: + msg = ( + _("New size must be greater than %s GB") % volume.size + ) + raise exceptions.CommandError(msg) + if ( + volume.status != 'available' + and not volume_client.api_version.matches('3.42') + ): + msg = ( + _( + "Volume is in %s state, it must be available " + "before size can be extended" + ) + % volume.status + ) + raise exceptions.CommandError(msg) + volume_client.volumes.extend(volume.id, parsed_args.size) + except Exception as e: + LOG.error(_("Failed to set volume size: %s"), e) + result += 1 + + if parsed_args.no_property: + try: + volume_client.volumes.delete_metadata( + volume.id, volume.metadata.keys() + ) + except Exception as e: + LOG.error(_("Failed to clean volume properties: %s"), e) + result += 1 + + if parsed_args.property: + try: + volume_client.volumes.set_metadata( + volume.id, parsed_args.property + ) + except Exception as e: + LOG.error(_("Failed to set volume property: %s"), e) + result += 1 + if parsed_args.image_property: + try: + volume_client.volumes.set_image_metadata( + volume.id, parsed_args.image_property + ) + except Exception as e: + LOG.error(_("Failed to set image property: %s"), e) + result += 1 + if parsed_args.state: + try: + volume_client.volumes.reset_state(volume.id, parsed_args.state) + except Exception as e: + LOG.error(_("Failed to set volume state: %s"), e) + result += 1 + if parsed_args.attached: + try: + volume_client.volumes.reset_state( + volume.id, state=None, attach_status="attached" + ) + except Exception as e: + LOG.error(_("Failed to set volume attach-status: %s"), e) + result += 1 + if parsed_args.detached: + try: + volume_client.volumes.reset_state( + volume.id, state=None, attach_status="detached" + ) + except Exception as e: + LOG.error(_("Failed to set volume attach-status: %s"), e) + result += 1 + if parsed_args.bootable or parsed_args.non_bootable: + try: + volume_client.volumes.set_bootable( + volume.id, parsed_args.bootable + ) + except Exception as e: + LOG.error(_("Failed to set volume bootable property: %s"), e) + result += 1 + if parsed_args.read_only or parsed_args.read_write: + try: + volume_client.volumes.update_readonly_flag( + volume.id, parsed_args.read_only + ) + except Exception as e: + LOG.error( + _( + "Failed to set volume read-only access " + "mode flag: %s" + ), + e, + ) + result += 1 + policy = parsed_args.migration_policy or parsed_args.retype_policy + if parsed_args.type: + # get the migration policy + migration_policy = 'never' + if policy: + migration_policy = policy + try: + # find the volume type + volume_type = utils.find_resource( + volume_client.volume_types, parsed_args.type + ) + # reset to the new volume type + volume_client.volumes.retype( + volume.id, volume_type.id, migration_policy + ) + except Exception as e: + LOG.error(_("Failed to set volume type: %s"), e) + result += 1 + elif policy: + # If the "--migration-policy" is specified without "--type" + LOG.warning( + _("'%s' option will not work without '--type' option") + % ( + '--migration-policy' + if parsed_args.migration_policy + else '--retype-policy' + ) + ) + + kwargs = {} + if parsed_args.name: + kwargs['display_name'] = parsed_args.name + if parsed_args.description: + kwargs['display_description'] = parsed_args.description + if kwargs: + try: + volume_client.volumes.update(volume.id, **kwargs) + except Exception as e: + LOG.error( + _( + "Failed to update volume display name " + "or display description: %s" + ), + e, + ) + result += 1 + + if result > 0: + raise exceptions.CommandError( + _("One or more of the " "set operations failed") + ) + + +class ShowVolume(command.ShowOne): + _description = _("Display volume details") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'volume', + metavar="", + help=_("Volume to display (name or ID)"), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + volume = utils.find_resource(volume_client.volumes, parsed_args.volume) + + # Special mapping for columns to make the output easier to read: + # 'metadata' --> 'properties' + # 'volume_type' --> 'type' + volume._info.update( + { + 'properties': format_columns.DictColumn( + volume._info.pop('metadata') + ), + 'type': volume._info.pop('volume_type'), + }, + ) + + # Remove key links from being displayed + volume._info.pop("links", None) + return zip(*sorted(volume._info.items())) + + +class UnsetVolume(command.Command): + _description = _("Unset volume properties") + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'volume', + metavar='', + help=_('Volume to modify (name or ID)'), + ) + parser.add_argument( + '--property', + metavar='', + action='append', + help=_( + 'Remove a property from volume ' + '(repeat option to remove multiple properties)' + ), + ) + parser.add_argument( + '--image-property', + metavar='', + action='append', + help=_( + 'Remove an image property from volume ' + '(repeat option to remove multiple image properties)' + ), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + volume = utils.find_resource(volume_client.volumes, parsed_args.volume) + + result = 0 + if parsed_args.property: + try: + volume_client.volumes.delete_metadata( + volume.id, parsed_args.property + ) + except Exception as e: + LOG.error(_("Failed to unset volume property: %s"), e) + result += 1 + + if parsed_args.image_property: + try: + volume_client.volumes.delete_image_metadata( + volume.id, parsed_args.image_property + ) + except Exception as e: + LOG.error(_("Failed to unset image property: %s"), e) + result += 1 + + if result > 0: + raise exceptions.CommandError( + _("One or more of the " "unset operations failed") + ) + + class VolumeSummary(command.ShowOne): _description = _("Show a summary of all volumes in this deployment.") @@ -114,180 +1083,3 @@ class VolumeRevertToSnapshot(command.Command): ) volume_client.revert_volume_to_snapshot(volume, snapshot) - - -class CreateVolume(volume_v2.CreateVolume): - _description = _("Create new volume") - - @staticmethod - def _check_size_arg(args): - """Check whether --size option is required or not. - - Require size parameter in case if any of the following is not specified: - * snapshot - * source volume - * backup - * remote source (volume to be managed) - """ - - if ( - args.snapshot or args.source or args.backup or args.remote_source - ) is None and args.size is None: - msg = _( - "--size is a required option if none of --snapshot, " - "--backup, --source, or --remote-source are provided." - ) - raise exceptions.CommandError(msg) - - def get_parser(self, prog_name): - parser, source_group = self._get_parser(prog_name) - - source_group.add_argument( - "--remote-source", - metavar="", - action=parseractions.KeyValueAction, - help=_( - "The attribute(s) of the existing remote volume " - "(admin required) (repeat option to specify multiple " - "attributes) e.g.: '--remote-source source-name=test_name " - "--remote-source source-id=test_id'" - ), - ) - parser.add_argument( - "--host", - metavar="", - help=_( - "Cinder host on which the existing volume resides; " - "takes the form: host@backend-name#pool. This is only " - "used along with the --remote-source option." - ), - ) - parser.add_argument( - "--cluster", - metavar="", - help=_( - "Cinder cluster on which the existing volume resides; " - "takes the form: cluster@backend-name#pool. This is only " - "used along with the --remote-source option. " - "(supported by --os-volume-api-version 3.16 or above)", - ), - ) - return parser - - def take_action(self, parsed_args): - CreateVolume._check_size_arg(parsed_args) - - volume_client_sdk = self.app.client_manager.sdk_connection.volume - - if ( - parsed_args.host or parsed_args.cluster - ) and not parsed_args.remote_source: - msg = _( - "The --host and --cluster options are only supported " - "with --remote-source parameter." - ) - raise exceptions.CommandError(msg) - - if parsed_args.remote_source: - if ( - parsed_args.size - or parsed_args.consistency_group - or parsed_args.hint - or parsed_args.read_only - or parsed_args.read_write - ): - msg = _( - "The --size, --consistency-group, --hint, --read-only " - "and --read-write options are not supported with the " - "--remote-source parameter." - ) - raise exceptions.CommandError(msg) - if parsed_args.cluster: - if not sdk_utils.supports_microversion( - volume_client_sdk, '3.16' - ): - msg = _( - "--os-volume-api-version 3.16 or greater is required " - "to support the cluster parameter." - ) - raise exceptions.CommandError(msg) - if parsed_args.cluster and parsed_args.host: - msg = _( - "Only one of --host or --cluster needs to be specified " - "to manage a volume." - ) - raise exceptions.CommandError(msg) - if not parsed_args.cluster and not parsed_args.host: - msg = _( - "One of --host or --cluster needs to be specified to " - "manage a volume." - ) - raise exceptions.CommandError(msg) - volume = volume_client_sdk.manage_volume( - host=parsed_args.host, - cluster=parsed_args.cluster, - ref=parsed_args.remote_source, - name=parsed_args.name, - description=parsed_args.description, - volume_type=parsed_args.type, - availability_zone=parsed_args.availability_zone, - metadata=parsed_args.property, - bootable=parsed_args.bootable, - ) - return zip(*sorted(volume.items())) - - return self._take_action(parsed_args) - - -class DeleteVolume(volume_v2.DeleteVolume): - _description = _("Delete volume(s)") - - def get_parser(self, prog_name): - parser = super().get_parser(prog_name) - parser.add_argument( - '--remote', - action='store_true', - help=_("Specify this parameter to unmanage a volume."), - ) - return parser - - def take_action(self, parsed_args): - volume_client = self.app.client_manager.volume - volume_client_sdk = self.app.client_manager.sdk_connection.volume - result = 0 - - if parsed_args.remote and (parsed_args.force or parsed_args.purge): - msg = _( - "The --force and --purge options are not " - "supported with the --remote parameter." - ) - raise exceptions.CommandError(msg) - - for i in parsed_args.volumes: - try: - volume_obj = utils.find_resource(volume_client.volumes, i) - if parsed_args.remote: - volume_client_sdk.unmanage_volume(volume_obj.id) - elif parsed_args.force: - volume_client.volumes.force_delete(volume_obj.id) - else: - volume_client.volumes.delete( - volume_obj.id, cascade=parsed_args.purge - ) - except Exception as e: - result += 1 - LOG.error( - _( - "Failed to delete volume with " - "name or ID '%(volume)s': %(e)s" - ), - {'volume': i, 'e': e}, - ) - - if result > 0: - total = len(parsed_args.volumes) - msg = _("%(result)s of %(total)s volumes failed " "to delete.") % { - 'result': result, - 'total': total, - } - raise exceptions.CommandError(msg) diff --git a/setup.cfg b/setup.cfg index 1ee4dd4652..b6f8188dff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -767,11 +767,11 @@ openstack.volume.v3 = volume_create = openstackclient.volume.v3.volume:CreateVolume volume_delete = openstackclient.volume.v3.volume:DeleteVolume - volume_list = openstackclient.volume.v2.volume:ListVolume - volume_migrate = openstackclient.volume.v2.volume:MigrateVolume - volume_set = openstackclient.volume.v2.volume:SetVolume - volume_show = openstackclient.volume.v2.volume:ShowVolume - volume_unset = openstackclient.volume.v2.volume:UnsetVolume + volume_list = openstackclient.volume.v3.volume:ListVolume + volume_migrate = openstackclient.volume.v3.volume:MigrateVolume + volume_set = openstackclient.volume.v3.volume:SetVolume + volume_show = openstackclient.volume.v3.volume:ShowVolume + volume_unset = openstackclient.volume.v3.volume:UnsetVolume volume_attachment_create = openstackclient.volume.v3.volume_attachment:CreateVolumeAttachment volume_attachment_delete = openstackclient.volume.v3.volume_attachment:DeleteVolumeAttachment