From 061f1758bb269297f4e462a834e3a6f4c8127f4a Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Mon, 17 Mar 2025 07:54:54 +0000 Subject: [PATCH 01/37] Update .gitreview for stable/2025.1 Change-Id: I49354ad04d71be99f19b52d9d84ebf8b0e8bdc57 --- .gitreview | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitreview b/.gitreview index 99feb9c2668..a2f251b256e 100644 --- a/.gitreview +++ b/.gitreview @@ -2,3 +2,4 @@ host=review.opendev.org port=29418 project=openstack/cinder.git +defaultbranch=stable/2025.1 From 6d33ff2cad4a96ae6db67cf99018c20e18c37de5 Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Mon, 17 Mar 2025 07:54:56 +0000 Subject: [PATCH 02/37] Update TOX_CONSTRAINTS_FILE for stable/2025.1 Update the URL to the upper-constraints file to point to the redirect rule on releases.openstack.org so that anyone working on this branch will switch to the correct upper-constraints list automatically when the requirements repository branches. Until the requirements repository has as stable/2025.1 branch, tests will continue to use the upper-constraints list on master. Change-Id: I858a97174ab8f701bac58b116592fcd3b51b0b47 --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index b10162a27da..67a217a1615 100644 --- a/tox.ini +++ b/tox.ini @@ -55,7 +55,7 @@ passenv = # With constraints in the install_command tox will always honor our # constraints. install_command = - python -m pip install -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} {opts} {packages} + python -m pip install -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2025.1} {opts} {packages} [testenv:functional] install_command = {[testenv:py3]install_command} @@ -148,7 +148,7 @@ commands = {posargs} # we intentionally put the constraints in the install_command, not the # deps ... see comment near the top of this file install_command = - python -m pip install -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} {opts} {packages} + python -m pip install -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2025.1} {opts} {packages} allowlist_externals = rm deps = doc8 From 3074be4c3b7547dc9422f53542105e41724228b0 Mon Sep 17 00:00:00 2001 From: Thomas Goirand Date: Mon, 6 Jan 2025 16:38:53 +0100 Subject: [PATCH 03/37] Implements cgroupsv2 Currently, Cinder only does cgroups v1. Let's use the cgroups v2 command line, when it's available in /sys. This avoids having to boot with compatibility kernel command line options: systemd.unified_cgroup_hierarchy=false systemd.legacy_systemd_cgroup_controller=false and just work as expected, provided cgroup-tools >= 2.0.0 is installed in the system. Change-Id: Ifdfcd480b72727ec182d5a6954c706f365247edc (cherry picked from commit d41586a115aabf01a756f6ee6ef58a813f87b546) --- cinder/privsep/cgroup.py | 24 +++++++++++++++---- .../notes/cgroupsv2-75476a8e1ea88b5f.yaml | 5 ++++ 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/cgroupsv2-75476a8e1ea88b5f.yaml diff --git a/cinder/privsep/cgroup.py b/cinder/privsep/cgroup.py index 15d47e0cf69..dcc384acab5 100644 --- a/cinder/privsep/cgroup.py +++ b/cinder/privsep/cgroup.py @@ -18,6 +18,8 @@ Helpers for cgroup related routines. """ +import os.path + from oslo_concurrency import processutils import cinder.privsep @@ -25,11 +27,25 @@ @cinder.privsep.sys_admin_pctxt.entrypoint def cgroup_create(name): - processutils.execute('cgcreate', '-g', 'blkio:%s' % name) + # If this path exists, it means we have support for cgroups v2 + if os.path.isfile('/sys/fs/cgroup/cgroup.controllers'): + # cgroups v2 doesn't support io, but blkio instead. + processutils.execute('cgcreate', '-g', 'io:%s' % name) + else: + processutils.execute('cgcreate', '-g', 'blkio:%s' % name) @cinder.privsep.sys_admin_pctxt.entrypoint def cgroup_limit(name, rw, dev, bps): - processutils.execute('cgset', '-r', - 'blkio.throttle.%s_bps_device=%s %d' % (rw, dev, bps), - name) + if os.path.isfile('/sys/fs/cgroup/cgroup.controllers'): + if rw == 'read': + cgset_arg = 'rbps' + else: + cgset_arg = 'wbps' + processutils.execute('cgset', '-r', + 'io.max=%s %s=%s' % (dev, cgset_arg, bps), name) + else: + processutils.execute('cgset', '-r', + 'blkio.throttle.%s_bps_device=%s %d' % (rw, dev, + bps), + name) diff --git a/releasenotes/notes/cgroupsv2-75476a8e1ea88b5f.yaml b/releasenotes/notes/cgroupsv2-75476a8e1ea88b5f.yaml new file mode 100644 index 00000000000..a0bb5c85601 --- /dev/null +++ b/releasenotes/notes/cgroupsv2-75476a8e1ea88b5f.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Cinder now supports setting-up cgroups with the cgroups v2 API, which is + used when doing migration of block device with the LVM backend. From bb8ad77e60098c49f2937b13463e695103587e1c Mon Sep 17 00:00:00 2001 From: Rajat Dhasmana Date: Thu, 26 Dec 2024 20:20:19 +0530 Subject: [PATCH 04/37] RBD: Fix upload volume with different format With change[1], we introduced an optimized path to upload an RBD volume to image. However, this does not work well when the image format is not 'raw' and conversion is required. Currently there are 2 places where we need handling of the RBDVolumeIOWrapper object: 1. format inspector 2. qemu-img commands While testing, I found out the following issues that needs to be addressed to fix the upload path with conversion: 1. Passing RBDVolumeIOWrapper to format inspector We fail when calling privsep since RPC cannot serialize the RBDVolumeIOWrapper object and fails with the following error ERROR oslo_messaging.rpc.server TypeError: can not serialize 'RBDVolumeIOWrapper' object 2. Handling in format inspector Either we need to pass the volume_fd var to several methods in format inspector code or introduce a condition to find out if we get volume path or RBDVolumeIOWrapper and open it if it's a path 3. Handling in qemu-img commands We need to use rbd:{pool-name}/{image-name} format instead of file path when executing qemu-img commands like convert[2] otherwise we will fail with the below error. Stderr: "qemu-img: Could not open '': Could not open '': No such file or directory\n" Not to mention passing the volume_fd object to all the qemu related methods and handling cases when volume_path is none and volume_fd contains the RBD IO object. Even after fixing all the issues, it's still not sure that the optimized path offers better performance compared to the traditional workflow as qemu-img will use librbd to read the volume file and convert it to a local path. Due to the above reasons, we only use the optimized path when the image format is same as volume format (i.e. raw) and container format is not 'compressed'. [1] https://review.opendev.org/c/openstack/cinder/+/928024 [2] https://docs.ceph.com/en/latest/rbd/qemu-rbd/#running-qemu-with-rbd Closes-Bug: #2092534 Change-Id: I32b505aa69c71b62e7e3a52d65d38165d34e97d8 (cherry picked from commit 7d320764980a0e33a9abad14db41a4cdee71d57a) --- cinder/tests/unit/volume/drivers/test_rbd.py | 57 +++++++++++++++---- cinder/volume/drivers/rbd.py | 37 +++++++++++- ...d-upload-diff-format-38fc4ef24d7145ba.yaml | 7 +++ 3 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 releasenotes/notes/fix-rbd-upload-diff-format-38fc4ef24d7145ba.yaml diff --git a/cinder/tests/unit/volume/drivers/test_rbd.py b/cinder/tests/unit/volume/drivers/test_rbd.py index 2a98cbdd5c3..4d92afdcc03 100644 --- a/cinder/tests/unit/volume/drivers/test_rbd.py +++ b/cinder/tests/unit/volume/drivers/test_rbd.py @@ -26,6 +26,7 @@ import castellan import ddt +from oslo_utils import fileutils from oslo_utils import imageutils from oslo_utils import units @@ -3501,24 +3502,56 @@ def test_disable_multiattach_no_features(self): self.assertEqual({'provider_location': None}, ret) + @ddt.data(('bare', 'raw'), + ('bare', 'qcow2'), + ('compressed', 'raw'), + ('compressed', 'qcow2')) + @ddt.unpack @common_mocks - def test_copy_volume_to_image(self): + def test_copy_volume_to_image(self, container_format, disk_format): + fake_image_meta = { + 'id': 'e105244f-4cb8-447b-8452-6f1da459e3ab', + 'container_format': container_format, + 'disk_format': disk_format, + } mock_uv = self.mock_object(cinder.volume.volume_utils, 'upload_volume') mock_get_rbd_handle = self.mock_object( self.driver, '_get_rbd_handle', return_value=mock.sentinel.rbd_handle) - self.driver.copy_volume_to_image(mock.sentinel.context, - mock.sentinel.volume, - mock.sentinel.image_service, - mock.sentinel.image_meta) - mock_get_rbd_handle.assert_called_once_with(mock.sentinel.volume) - mock_uv.assert_called_once_with(mock.sentinel.context, - mock.sentinel.image_service, - mock.sentinel.image_meta, - None, - mock.sentinel.volume, - volume_fd=mock.sentinel.rbd_handle) + if container_format != 'compressed' and disk_format == 'raw': + self.driver.copy_volume_to_image(mock.sentinel.context, + mock.sentinel.volume, + mock.sentinel.image_service, + fake_image_meta) + mock_get_rbd_handle.assert_called_once_with(mock.sentinel.volume) + mock_uv.assert_called_once_with(mock.sentinel.context, + mock.sentinel.image_service, + fake_image_meta, + None, + mock.sentinel.volume, + volume_fd= + mock.sentinel.rbd_handle) + else: + with mock.patch.object(self.driver, '_execute'), \ + mock.patch.object(fileutils, 'remove_path_on_error'), \ + mock.patch.object(os, 'unlink'), \ + mock.patch.object( + volume_utils, 'image_conversion_dir') as fake_dir: + fake_path = 'fake_path' + fake_vol = 'volume-' + fake_image_meta['id'] + fake_dir.return_value = fake_path + fake_vol_path = os.path.join(fake_path, fake_vol) + self.driver.copy_volume_to_image(mock.sentinel.context, + mock.sentinel.volume, + mock.sentinel.image_service, + fake_image_meta) + mock_get_rbd_handle.assert_not_called() + mock_uv.assert_called_once_with(mock.sentinel.context, + mock.sentinel.image_service, + fake_image_meta, + fake_vol_path, + mock.sentinel.volume) class ManagedRBDTestCase(test_driver.BaseDriverTestCase): diff --git a/cinder/volume/drivers/rbd.py b/cinder/volume/drivers/rbd.py index 03069cdf58e..7c44203ac21 100644 --- a/cinder/volume/drivers/rbd.py +++ b/cinder/volume/drivers/rbd.py @@ -2089,10 +2089,41 @@ def _get_rbd_handle(self, volume: Volume): return connector._get_rbd_handle(conn['data']) def copy_volume_to_image(self, context, volume, image_service, image_meta): - source_handle = self._get_rbd_handle(volume) + if image_meta.get('container_format') != 'compressed' and ( + image_meta['disk_format'] == 'raw'): + source_handle = self._get_rbd_handle(volume) - volume_utils.upload_volume(context, image_service, image_meta, None, - volume, volume_fd=source_handle) + volume_utils.upload_volume(context, image_service, image_meta, + None, volume, volume_fd=source_handle) + else: + # When the image format is different from volume format, we will + # fallback to the old workflow because of the following issues: + # 1. Passing RBDVolumeIOWrapper to format inspector + # We fail when calling privsep since RPC cannot serialize the + # RBDVolumeIOWrapper object + # 2. Handling in format inspector + # Determine if it's RBD file descriptor and only open the volume + # file if it's not + # 3. Handling in qemu-img commands + # Use rbd:{pool-name}/{image-name} format instead of file path + # https://docs.ceph.com/en/latest/rbd/qemu-rbd/#running-qemu-with-rbd # noqa + # + # Even if above issues are addressed, qemu-img convert will create + # a local copy of converted volume file so will need to determine + # the performance vs this workflow. + tmp_dir = volume_utils.image_conversion_dir() + tmp_file = os.path.join(tmp_dir, + volume.name + '-' + image_meta['id']) + with fileutils.remove_path_on_error(tmp_file): + args = ['rbd', 'export', + '--pool', self.configuration.rbd_pool, + volume.name, tmp_file] + args.extend(self._ceph_args()) + self._try_execute(*args) + volume_utils.upload_volume(context, image_service, + image_meta, tmp_file, + volume) + os.unlink(tmp_file) def extend_volume(self, volume: Volume, new_size: str) -> None: """Extend an existing volume.""" diff --git a/releasenotes/notes/fix-rbd-upload-diff-format-38fc4ef24d7145ba.yaml b/releasenotes/notes/fix-rbd-upload-diff-format-38fc4ef24d7145ba.yaml new file mode 100644 index 00000000000..511478d9ce7 --- /dev/null +++ b/releasenotes/notes/fix-rbd-upload-diff-format-38fc4ef24d7145ba.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + RBD driver `bug #2092534 + `_: Fixed + uploading a volume to image when image has different format + than volume. From 25a2970124d8be372ae366ed8bb9bbacc604271f Mon Sep 17 00:00:00 2001 From: Rajat Dhasmana Date: Tue, 9 Apr 2024 08:54:55 +0530 Subject: [PATCH 05/37] Fix reimage with snapshot backed image When we create a snapshot of a server that is volume-backed, glance creates a zero sized metadata entry of the image which is backed by the volume snapshot. When we try to reimage a volume with that given image, we need to fetch the volume snapshot details from the image metadata. Now to reimage, we need to create a new volume from snapshot and copy the data from our snapshot-volume to our original volume for which we use the generic revert to snapshot mechanism as it performs the same steps. Closes-Bug: #2062539 Change-Id: Ic4bf44c320ad53b514178ecd4d5f57f037169bfe (cherry picked from commit ae22195df7d674ba017f5e301868522ccb21c5b4) --- cinder/api/contrib/volume_actions.py | 41 ++++++++- .../unit/api/contrib/test_volume_actions.py | 88 ++++++++++++++++++- cinder/tests/unit/volume/test_rpcapi.py | 9 +- .../tests/unit/volume/test_volume_reimage.py | 37 +++++++- cinder/volume/api.py | 17 ++-- cinder/volume/manager.py | 30 ++++--- cinder/volume/rpcapi.py | 14 ++- ...x-reimage-image-snap-15ecd5fce9973d5d.yaml | 5 ++ 8 files changed, 214 insertions(+), 27 deletions(-) create mode 100644 releasenotes/notes/fix-reimage-image-snap-15ecd5fce9973d5d.yaml diff --git a/cinder/api/contrib/volume_actions.py b/cinder/api/contrib/volume_actions.py index 0bb7ed1b875..6943cbc8e52 100644 --- a/cinder/api/contrib/volume_actions.py +++ b/cinder/api/contrib/volume_actions.py @@ -26,6 +26,7 @@ from cinder.api import validation from cinder import exception from cinder.i18n import _ +from cinder.image import glance from cinder.policies import volume_actions as policy from cinder import volume from cinder.volume import volume_utils @@ -327,6 +328,42 @@ def _set_bootable(self, req, id, body): self.volume_api.update(context, volume, update_dict) + def _get_image_snapshot_and_check_size(self, context, image_uuid, + volume_size): + image_snapshot = None + if image_uuid: + image_service = glance.get_default_image_service() + image_meta = image_service.show(context, image_uuid) + if image_meta is not None: + bdms = image_meta.get('properties', {}).get( + 'block_device_mapping', []) + if bdms: + boot_bdm = [bdm for bdm in bdms if ( + bdm.get('source_type') == 'snapshot' and + bdm.get('boot_index') == 0)] + if boot_bdm: + try: + # validate size + image_snap_size = boot_bdm[0].get('volume_size') + if image_snap_size > volume_size: + msg = (_( + "Volume size must be greater than the " + "image size. (Image: %(img_size)s, " + "Volume: %(vol_size)s).") % { + 'img_size': image_snap_size, + 'vol_size': volume_size}) + raise webob.exc.HTTPBadRequest(explanation=msg) + image_snapshot = self.volume_api.get_snapshot( + context, boot_bdm[0].get('snapshot_id')) + except exception.NotFound: + explanation = _( + 'Nova specific image is found, but boot ' + 'volume snapshot id:%s not found.' + ) % boot_bdm[0].get('snapshot_id') + raise webob.exc.HTTPNotFound( + explanation=explanation) + return image_snapshot + @wsgi.Controller.api_version(mv.SUPPORT_REIMAGE_VOLUME) @wsgi.response(HTTPStatus.ACCEPTED) @wsgi.action('os-reimage') @@ -341,9 +378,11 @@ def _reimage(self, req, id, body): reimage_reserved = strutils.bool_from_string(reimage_reserved, strict=True) image_id = params['image_id'] + image_snap = self._get_image_snapshot_and_check_size( + context, image_id, volume.size) try: self.volume_api.reimage(context, volume, image_id, - reimage_reserved) + reimage_reserved, image_snap) except exception.InvalidVolume as error: raise webob.exc.HTTPBadRequest(explanation=error.msg) diff --git a/cinder/tests/unit/api/contrib/test_volume_actions.py b/cinder/tests/unit/api/contrib/test_volume_actions.py index acba239deed..fcec6d40e25 100644 --- a/cinder/tests/unit/api/contrib/test_volume_actions.py +++ b/cinder/tests/unit/api/contrib/test_volume_actions.py @@ -1592,8 +1592,11 @@ def _build_reimage_req(self, body, vol_id, return req @ddt.data(None, False, True) + @mock.patch.object(volume_actions.VolumeActionsController, + '_get_image_snapshot_and_check_size') @mock.patch.object(volume_api.API, "reimage") - def test_volume_reimage(self, reimage_reserved, mock_image): + def test_volume_reimage( + self, reimage_reserved, mock_image, mock_get_img_snap): vol = utils.create_volume(self.context) body = {"os-reimage": {"image_id": fake.IMAGE_ID}} if reimage_reserved is not None: @@ -1619,7 +1622,9 @@ def test_volume_reimage_before_3_68(self): self.assertRaises(exception.VersionNotFoundForAPIMethod, self.controller._reimage, req, vol.id, body=body) - def test_reimage_volume_invalid_status(self): + @mock.patch.object(volume_actions.VolumeActionsController, + '_get_image_snapshot_and_check_size') + def test_reimage_volume_invalid_status(self, mock_get_img_snap): def fake_reimage_volume(*args, **kwargs): msg = "Volume status must be available." raise exception.InvalidVolume(reason=msg) @@ -1633,8 +1638,11 @@ def fake_reimage_volume(*args, **kwargs): self.controller._reimage, req, vol.id, body=body) + @mock.patch.object(volume_actions.VolumeActionsController, + '_get_image_snapshot_and_check_size') @mock.patch('cinder.context.RequestContext.authorize') - def test_reimage_volume_attach_more_than_one_server(self, mock_authorize): + def test_reimage_volume_attach_more_than_one_server(self, mock_authorize, + mock_get_img_snap): vol = utils.create_volume(self.context) va_objs = [objects.VolumeAttachment(context=self.context, id=i) for i in [fake.OBJECT_ID, fake.OBJECT2_ID, fake.OBJECT3_ID]] @@ -1646,3 +1654,77 @@ def test_reimage_volume_attach_more_than_one_server(self, mock_authorize): req = self._build_reimage_req(body, vol) self.assertRaises(webob.exc.HTTPConflict, self.controller._reimage, req, vol.id, body=body) + + @mock.patch.object(volume_api.API, 'get_snapshot') + @mock.patch.object(volume_api.API, 'get', fake_volume_get_obj) + @mock.patch.object(glance, 'get_default_image_service') + @mock.patch.object(volume_api.API, "reimage") + def test_volume_reimage_image_snapshot( + self, mock_image, mock_image_service, mock_get_snap): + vol = utils.create_volume(self.context) + image_meta = { + 'properties': { + 'block_device_mapping': [ + { + 'source_type': 'snapshot', + 'boot_index': 0, + 'volume_size': 1, + } + ] + } + } + mock_image_service.return_value = mock.MagicMock() + mock_image_service.return_value.show.return_value = image_meta + body = {"os-reimage": {"image_id": fake.IMAGE_ID}} + req = self._build_reimage_req(body, vol.id) + self.controller._reimage(req, vol.id, body=body) + + @mock.patch.object(volume_api.API, 'get', fake_volume_get_obj) + @mock.patch.object(glance, 'get_default_image_service') + @mock.patch.object(volume_api.API, "reimage") + def test_volume_reimage_image_snapshot_size_mismatch( + self, mock_image, mock_image_service): + vol = utils.create_volume(self.context) + image_meta = { + 'properties': { + 'block_device_mapping': [ + { + 'source_type': 'snapshot', + 'boot_index': 0, + 'volume_size': 2, + } + ] + } + } + mock_image_service.return_value = mock.MagicMock() + mock_image_service.return_value.show.return_value = image_meta + body = {"os-reimage": {"image_id": fake.IMAGE_ID}} + req = self._build_reimage_req(body, vol.id) + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller._reimage, req, vol.id, body=body) + + @mock.patch.object(volume_api.API, 'get_snapshot') + @mock.patch.object(volume_api.API, 'get', fake_volume_get_obj) + @mock.patch.object(glance, 'get_default_image_service') + @mock.patch.object(volume_api.API, "reimage") + def test_volume_reimage_image_snapshot_snap_not_found( + self, mock_image, mock_image_service, mock_get_snap): + vol = utils.create_volume(self.context) + image_meta = { + 'properties': { + 'block_device_mapping': [ + { + 'source_type': 'snapshot', + 'boot_index': 0, + 'volume_size': 1, + } + ] + } + } + mock_image_service.return_value = mock.MagicMock() + mock_image_service.return_value.show.return_value = image_meta + mock_get_snap.side_effect = exception.NotFound + body = {"os-reimage": {"image_id": fake.IMAGE_ID}} + req = self._build_reimage_req(body, vol.id) + self.assertRaises(webob.exc.HTTPNotFound, + self.controller._reimage, req, vol.id, body=body) diff --git a/cinder/tests/unit/volume/test_rpcapi.py b/cinder/tests/unit/volume/test_rpcapi.py index 9840738cbb0..254250b9b42 100644 --- a/cinder/tests/unit/volume/test_rpcapi.py +++ b/cinder/tests/unit/volume/test_rpcapi.py @@ -677,11 +677,16 @@ def test_list_replication_targets(self): group=self.fake_group, version='3.14') - def test_reimage(self): + @ddt.data('3.18', '3.20') + def test_reimage(self, version): + if version == '3.18': + self.can_send_version_mock.side_effect = ( + True, True, False, False) self._test_rpc_api('reimage', rpc_method='cast', server=self.fake_volume_obj.host, volume=self.fake_volume_obj, image_meta={'id': fake.IMAGE_ID, 'container_format': 'fake_type', 'disk_format': 'fake_format'}, - version='3.18') + image_snap='fake_snap', + version=version) diff --git a/cinder/tests/unit/volume/test_volume_reimage.py b/cinder/tests/unit/volume/test_volume_reimage.py index ca19539b5f9..1a76d72876f 100644 --- a/cinder/tests/unit/volume/test_volume_reimage.py +++ b/cinder/tests/unit/volume/test_volume_reimage.py @@ -50,6 +50,26 @@ def test_volume_reimage(self): disable_sparse=True) self.assertEqual(volume.status, 'available') + def test_volume_reimage_image_snapshot(self): + volume = tests_utils.create_volume(self.context, status='downloading', + previous_status='available') + self.assertEqual(volume.status, 'downloading') + self.assertEqual(volume.previous_status, 'available') + self.volume.create_volume(self.context, volume) + + with mock.patch.object(self.volume.driver, 'copy_image_to_volume' + ) as mock_cp_img, \ + mock.patch.object(self.volume, '_revert_to_snapshot_generic' + ) as generic_revert: + fake_snap = mock.MagicMock( + id='08f850d7-8b43-4656-a71c-647c864a3599') + self.volume.reimage( + self.context, volume, self.image_meta, image_snap=fake_snap) + mock_cp_img.assert_not_called() + generic_revert.assert_called_once_with( + self.context, volume, fake_snap) + self.assertEqual(volume.status, 'available') + def test_volume_reimage_raise_exception(self): volume = tests_utils.create_volume(self.context) self.volume.create_volume(self.context, volume) @@ -107,7 +127,7 @@ def test_volume_reimage_api(self, status, mock_reimage, mock_check): self.volume_api.reimage(self.context, volume, self.image_meta['id']) mock_check.assert_called_once_with(self.image_meta, volume.size) mock_reimage.assert_called_once_with(self.context, volume, - self.image_meta) + self.image_meta, image_snap=None) @mock.patch('cinder.volume.volume_utils.check_image_metadata') @mock.patch('cinder.volume.rpcapi.VolumeAPI.reimage') @@ -139,7 +159,7 @@ def test_volume_reimage_api_with_reimage_reserved(self, mock_reimage, reimage_reserved=True) mock_check.assert_called_once_with(self.image_meta, volume.size) mock_reimage.assert_called_once_with(self.context, volume, - self.image_meta) + self.image_meta, image_snap=None) def test_volume_reimage_api_with_invaild_status(self): volume = tests_utils.create_volume(self.context) @@ -167,3 +187,16 @@ def test_volume_reimage_api_with_invaild_status(self): self.assertIn("status must be " "available or error or reserved", str(ex)) + + @mock.patch('cinder.volume.volume_utils.check_image_metadata') + @mock.patch('cinder.volume.rpcapi.VolumeAPI.reimage') + def test_volume_reimage_api_image_snapshot( + self, mock_reimage, mock_check): + volume = tests_utils.create_volume(self.context) + self.volume_api.reimage( + self.context, volume, self.image_meta['id'], + image_snap='fake_snap') + mock_check.assert_called_once_with(self.image_meta, volume['size']) + mock_reimage.assert_called_once_with(self.context, volume, + self.image_meta, + image_snap='fake_snap') diff --git a/cinder/volume/api.py b/cinder/volume/api.py index 0e20baff0f5..00adf73425c 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -2689,7 +2689,8 @@ def attachment_delete(self, volume_utils.notify_about_volume_usage(ctxt, volume, "detach.end") return volume.volume_attachment - def reimage(self, context, volume, image_id, reimage_reserved=False): + def reimage(self, context, volume, image_id, reimage_reserved=False, + image_snap=None): if volume.status in ['reserved']: context.authorize(vol_action_policy.REIMAGE_RESERVED_POLICY, target_obj=volume) @@ -2717,6 +2718,10 @@ def reimage(self, context, volume, image_id, reimage_reserved=False): raise exception.InvalidVolume(reason=msg) image_meta = self.image_service.show(context, image_id) try: + # If the source of the image is a volume snapshot + # (image_snap is not None), we will get image 'size' as 0 and + # 'virtual_size' as None but at least we will verify the image + # 'status' and 'min_disk' properties. volume_utils.check_image_metadata(image_meta, volume['size']) # Currently we only raise InvalidInput and ImageUnacceptable # exceptions in the check_image_metadata call but having Exception @@ -2725,16 +2730,18 @@ def reimage(self, context, volume, image_id, reimage_reserved=False): # Also this helps makes adding new exceptions easier in the future. except Exception: with excutils.save_and_reraise_exception(): - LOG.exception("Failed to reimage volume %(volume_id)s with " - "image %(image_id)s", - {'volume_id': volume.id, 'image_id': image_id}) + LOG.exception("Failed to reimage volume %(volume_id)s " + "with image %(image_id)s", + {'volume_id': volume.id, + 'image_id': image_id}) volume.conditional_update( {'status': volume.model.previous_status, 'previous_status': None}, {'status': 'downloading'}) self.volume_rpcapi.reimage(context, volume, - image_meta) + image_meta, + image_snap=image_snap) class HostAPI(base.Base): diff --git a/cinder/volume/manager.py b/cinder/volume/manager.py index d691710ddd1..d78129c004d 100644 --- a/cinder/volume/manager.py +++ b/cinder/volume/manager.py @@ -5350,19 +5350,29 @@ def _refresh_volume_glance_meta(self, context, volume, image_meta): self.db.volume_glance_metadata_bulk_create(context, volume.id, volume_meta) - def reimage(self, context, volume, image_meta): + def reimage(self, context, volume, image_meta, image_snap=None): """Reimage a volume with specific image.""" image_id = None try: - image_id = image_meta['id'] - image_service, _ = glance.get_remote_image_service( - context, image_meta['id']) - image_location = image_service.get_location(context, image_id) - - volume_utils.copy_image_to_volume(self.driver, context, volume, - image_meta, image_location, - image_service, - disable_sparse=True) + if image_snap: + # We are not calling the driver method here since the snapshot + # could belong to a different volume. + # Even if the snapshot belongs to a different volume, we are + # doing generic revert where we create a volume out of the + # snapshot and do a copy so we are safe here. + # Size checks are already done on the API layer so we don't + # need to worry about the image fitting into the volume. + self._revert_to_snapshot_generic(context, volume, image_snap) + else: + image_id = image_meta['id'] + image_service, _ = glance.get_remote_image_service( + context, image_meta['id']) + image_location = image_service.get_location(context, image_id) + + volume_utils.copy_image_to_volume(self.driver, context, volume, + image_meta, image_location, + image_service, + disable_sparse=True) self._refresh_volume_glance_meta(context, volume, image_meta) volume.status = volume.previous_status diff --git a/cinder/volume/rpcapi.py b/cinder/volume/rpcapi.py index 58de69c1c6c..15c7e8552c5 100644 --- a/cinder/volume/rpcapi.py +++ b/cinder/volume/rpcapi.py @@ -139,9 +139,10 @@ class VolumeAPI(rpc.RPCAPI): 3.17 - Make get_backup_device a cast (async) 3.18 - Add reimage method 3.19 - Add extend_volume_completion method + 3.20 - Add image_snap parameter to reimage method """ - RPC_API_VERSION = '3.19' + RPC_API_VERSION = '3.20' RPC_DEFAULT_VERSION = '3.0' TOPIC = constants.VOLUME_TOPIC BINARY = constants.VOLUME_BINARY @@ -544,6 +545,11 @@ def list_replication_targets(self, ctxt, group): group=group) @rpc.assert_min_rpc_version('3.18') - def reimage(self, ctxt, volume, image_meta): - cctxt = self._get_cctxt(volume.service_topic_queue, version='3.18') - cctxt.cast(ctxt, 'reimage', volume=volume, image_meta=image_meta) + def reimage(self, ctxt, volume, image_meta, image_snap=None): + cctxt = self._get_cctxt( + volume.service_topic_queue, version=('3.20', '3.18')) + if cctxt.can_send_version('3.20'): + cctxt.cast(ctxt, 'reimage', volume=volume, image_meta=image_meta, + image_snap=image_snap) + else: + cctxt.cast(ctxt, 'reimage', volume=volume, image_meta=image_meta) diff --git a/releasenotes/notes/fix-reimage-image-snap-15ecd5fce9973d5d.yaml b/releasenotes/notes/fix-reimage-image-snap-15ecd5fce9973d5d.yaml new file mode 100644 index 00000000000..a34de0a343b --- /dev/null +++ b/releasenotes/notes/fix-reimage-image-snap-15ecd5fce9973d5d.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + `Bug #2062539 `_: Fixed + reimage operation when the image is backed by a volume snapshot. From d5da037e6c012312d12d224df5a19385c29d7c66 Mon Sep 17 00:00:00 2001 From: Simon Dodsley Date: Mon, 10 Mar 2025 13:31:56 -0400 Subject: [PATCH 06/37] [Pure Storage] Fix issue with LACP ports not being identified When using iSCSI or NVMe as dataplanes target ports are identified directly from the backend. When an LACP bond is created this was not being correctly identified as valid. This patch resolves this issue. Closes-Bug: #2101859 Change-Id: I5b56c590a6c9b82ab2e08e4211bfd3d187afdf8e (cherry picked from commit 5070eeaecf2837c8f5109c63518d87a08f9c40b4) --- cinder/tests/unit/volume/drivers/test_pure.py | 143 ++++++++++++++++-- cinder/volume/drivers/pure.py | 26 +++- .../pure_lacp_iscsi-34678bdb98fa6bab.yaml | 7 + 3 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 releasenotes/notes/pure_lacp_iscsi-34678bdb98fa6bab.yaml diff --git a/cinder/tests/unit/volume/drivers/test_pure.py b/cinder/tests/unit/volume/drivers/test_pure.py index 2af9bb4c3c1..e2ad961f96e 100644 --- a/cinder/tests/unit/volume/drivers/test_pure.py +++ b/cinder/tests/unit/volume/drivers/test_pure.py @@ -590,7 +590,34 @@ def __deepcopy__(self, memo=None): "portal": None, "wwn": "5001500150015081", } -NVME_PORTS_WITH = NVME_PORTS + [NON_ISCSI_PORT] +ISCSI_LACP_PORTS = [ + { + "name": "lacp2", + "iqn": TARGET_IQN, + "nqn": None, + "portal": None, + "wwn": None, + }, +] +NVME_LACP_PORTS = [ + { + "name": "lacp0", + "iqn": None, + "nqn": TARGET_NQN, + "portal": None, + "wwn": None, + }, + { + "name": "lacp1", + "iqn": None, + "nqn": TARGET_NQN, + "portal": None, + "wwn": None, + }, +] +NVME_PORTS_WITH = NVME_PORTS + [NON_ISCSI_PORT] + NVME_LACP_PORTS +ISCSI_PORTS_WITH = ISCSI_PORTS + ISCSI_LACP_PORTS +PORTS_WITH = ISCSI_PORTS + [NON_ISCSI_PORT] + ISCSI_LACP_PORTS PORTS_WITH = ISCSI_PORTS + [NON_ISCSI_PORT] PORTS_WITHOUT = [NON_ISCSI_PORT] TOTAL_CAPACITY = 50.0 @@ -1186,6 +1213,56 @@ def __deepcopy__(self, memo=None): ARRAY_RESPONSE = { 'status_code': 200 } +INTERFACES = [ + { + 'name': 'ct0.eth4', + 'services': ['nvme-tcp'], + 'eth': {'address': '1.1.1.1', + 'subtype': 'physical'}, + }, + { + 'name': 'ct0.eth5', + 'services': ['iscsi'], + 'eth': {'address': '2.2.2.2', + 'subtype': 'physical'}, + }, + { + 'name': 'ct0.eth20', + 'services': ['nvme-roce'], + 'eth': {'address': '3.3.3.3', + 'subtype': 'physical'} + }, + { + 'name': 'ct0.fc4', + 'services': ['nvme-fc'], + 'eth': {'address': None, + 'subtype': 'physical'}, + }, + { + 'name': 'lacp0', + 'services': ['nvme-roce'], + 'eth': {'address': '4.4.4.4', + 'subtype': 'lacp_bond'}, + }, + { + 'name': 'lacp1', + 'services': ['nvme-tcp'], + 'eth': {'address': '5.5.5.5', + 'subtype': 'lacp_bond'}, + }, + { + 'name': 'lacp2', + 'services': ['iscsi'], + 'eth': {'address': '6.6.6.6', + 'subtype': 'lacp_bond'}, + }, + { + 'name': 'ct0.fc1', + 'services': ['scsi-fc'], + 'eth': {'address': None, + 'subtype': 'physical'}, + } +] class PureDriverTestCase(test.TestCase): @@ -4746,21 +4823,27 @@ def test_initialize_connection_multipath(self, def test_get_target_iscsi_ports(self): self.array.get_controllers.return_value = CTRL_OBJ self.array.get_ports.return_value = VALID_ISCSI_PORTS + self.array.get_network_interfaces.return_value = ValidResponse( + 200, None, 1, [DotNotation(INTERFACES[1])], {}) ret = self.driver._get_target_iscsi_ports(self.array) - self.assertEqual(ISCSI_PORTS[0:4], ret) + self.assertEqual(ISCSI_PORTS[0:4], ret[0:4]) def test_get_target_iscsi_ports_with_iscsi_and_fc(self): self.array.get_controllers.return_value = CTRL_OBJ - PORTS_DATA = [DotNotation(i) for i in PORTS_WITH] + PORTS_DATA = [DotNotation(i) for i in ISCSI_PORTS_WITH] ifc_ports = ValidResponse(200, None, 1, PORTS_DATA, {}) self.array.get_ports.return_value = ifc_ports + self.array.get_network_interfaces.return_value = ValidResponse( + 200, None, 1, [DotNotation(INTERFACES[0])], {}) ret = self.driver._get_target_iscsi_ports(self.array) - self.assertEqual(ISCSI_PORTS, ret) + self.assertEqual(ISCSI_PORTS_WITH[0:9], ret[0:9]) def test_get_target_iscsi_ports_with_no_ports(self): # Should raise an exception if there are no ports self.array.get_controllers.return_value = CTRL_OBJ no_ports = ValidResponse(200, None, 1, [], {}) + self.array.get_network_interfaces.return_value = ValidResponse( + 200, None, 1, [], {}) self.array.get_ports.return_value = no_ports self.assertRaises(pure.PureDriverException, self.driver._get_target_iscsi_ports, @@ -4770,6 +4853,8 @@ def test_get_target_iscsi_ports_with_only_fc_ports(self): # Should raise an exception of there are no iscsi ports self.array.get_controllers.return_value = CTRL_OBJ PORTS_NOISCSI = [DotNotation(i) for i in PORTS_WITHOUT] + self.array.get_network_interfaces.return_value = ValidResponse( + 200, None, 1, [DotNotation(INTERFACES[3])], {}) self.array.get_ports.\ return_value = ValidResponse(200, None, 1, PORTS_NOISCSI, {}) self.assertRaises(pure.PureDriverException, @@ -5922,18 +6007,20 @@ def test_get_target_nvme_ports(self): {'name': 'CT0.FC4', 'wwn': TARGET_WWN, 'iqn': None, + 'nqn': TARGET_NQN}, + {'name': 'LACP0', + 'wwn': None, + 'iqn': None, + 'nqn': TARGET_NQN}, + {'name': 'LACP1', + 'wwn': None, + 'iqn': None, 'nqn': TARGET_NQN}] - interfaces = [ - {'name': 'ct0.eth4', 'services': ['nvme-tcp']}, - {'name': 'ct0.eth5', 'services': ['iscsi']}, - {'name': 'ct0.eth20', 'services': ['nvme-roce']}, - {'name': 'ct0.fc4', 'services': ['nvme-fc']} - ] # Test for the nvme-tcp port self.driver.configuration.pure_nvme_transport = "tcp" self.array.get_controllers.return_value = CTRL_OBJ nvme_interfaces = ValidResponse(200, None, 4, - [DotNotation(interfaces[x]) + [DotNotation(INTERFACES[x]) for x in range(4)], {}) self.array.get_network_interfaces.return_value = nvme_interfaces nvme_ports = ValidResponse(200, None, 4, @@ -5956,17 +6043,37 @@ def test_get_target_nvme_ports(self): # Test for the nvme-roce port self.driver.configuration.pure_nvme_transport = "roce" nvme_roce_interface = ValidResponse(200, None, 1, - [DotNotation(interfaces[2])], {}) + [DotNotation(INTERFACES[2])], {}) self.array.get_network_interfaces.return_value = nvme_roce_interface nvme_roce_ports = ValidResponse(200, None, 1, [DotNotation(ports[2])], {}) self.array.get_ports.return_value = nvme_roce_ports ret = self.driver._get_target_nvme_ports(self.array) - self.assertEqual([ports[2]], ret) + self.assertEqual([ports[2]], [ret[0]]) + # Test for the nvme-roce LACP port + self.driver.configuration.pure_nvme_transport = "roce" + nvme_roce_interface = ValidResponse(200, None, 1, + [DotNotation(INTERFACES[4])], {}) + self.array.get_network_interfaces.return_value = nvme_roce_interface + nvme_roce_ports = ValidResponse(200, None, 1, + [DotNotation(ports[4])], {}) + self.array.get_ports.return_value = nvme_roce_ports + ret = self.driver._get_target_nvme_ports(self.array) + self.assertEqual([ports[4]], [ret[0]]) + # Test for the nvme-tcp LACP port + self.driver.configuration.pure_nvme_transport = "tcp" + nvme_roce_interface = ValidResponse(200, None, 1, + [DotNotation(INTERFACES[5])], {}) + self.array.get_network_interfaces.return_value = nvme_roce_interface + nvme_roce_ports = ValidResponse(200, None, 1, + [DotNotation(ports[5])], {}) + self.array.get_ports.return_value = nvme_roce_ports + ret = self.driver._get_target_nvme_ports(self.array) + self.assertEqual([ports[5]], [ret[0]]) # Test for empty dict if only nvme-fc port self.driver.configuration.pure_nvme_transport = "roce" nvme_fc_interface = ValidResponse(200, None, 1, - [DotNotation(interfaces[3])], {}) + [DotNotation(INTERFACES[3])], {}) self.array.get_network_interfaces.return_value = nvme_fc_interface nvme_fc_ports = ValidResponse(200, None, 1, [DotNotation(ports[3])], {}) @@ -5978,7 +6085,9 @@ def test_get_target_nvme_ports_with_no_ports(self): # Should raise an exception if there are no ports self.array.get_controllers.return_value = CTRL_OBJ nvme_no_ports = ValidResponse(200, None, 1, [], {}) + nvme_no_interfaces = ValidResponse(200, None, 1, [], {}) self.array.get_ports.return_value = nvme_no_ports + self.array.get_network_interfaces.return_value = nvme_no_interfaces self.assertRaises( pure.PureDriverException, self.driver._get_target_nvme_ports, @@ -5988,8 +6097,12 @@ def test_get_target_nvme_ports_with_no_ports(self): def test_get_target_nvme_ports_with_only_fc_ports(self): # Should raise an exception of there are no nvme ports self.array.get_controllers.return_value = CTRL_OBJ - nvme_noports = ValidResponse(200, None, 1, [PORTS_WITHOUT], {}) + PORTS_NONVME = [DotNotation(i) for i in PORTS_WITHOUT] + nvme_noports = ValidResponse(200, None, 1, PORTS_NONVME, {}) + nvme_nointerfaces = ValidResponse(200, None, 1, + [DotNotation(INTERFACES[3])], {}) self.array.get_ports.return_value = nvme_noports + self.array.get_network_interfaces.return_value = nvme_nointerfaces self.assertRaises( pure.PureDriverException, self.driver._get_target_nvme_ports, diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py index ae98e6a151d..bfd545266ff 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -3554,6 +3554,18 @@ def _get_valid_ports(self, array): ports += list( array.get_ports(filter="name='" + controller + ".*'").items ) + lacps = list( + array.get_network_interfaces( + filter="eth.subtype='lacp_bond'" + ).items + ) + if lacps: + for lacp in range(0, len(lacps)): + ports += list( + array.get_ports( + names=[lacps[lacp].name.upper()] + ).items + ) return ports @@ -4202,13 +4214,13 @@ def _get_target_nvme_ports(self, array): valid_nvme_ports = [] nvme_ports = [port for port in ports if getattr(port, "nqn", None)] for port in range(0, len(nvme_ports)): - if "ETH" in nvme_ports[port].name: - port_detail = list(array.get_network_interfaces( - names=[nvme_ports[port].name] - ).items)[0] - if port_detail.services[0] == "nvme-" + \ - self.configuration.pure_nvme_transport: - valid_nvme_ports.append(nvme_ports[port]) + port_detail = list(array.get_network_interfaces( + names=[nvme_ports[port].name.lower()] + ).items)[0] + if hasattr(port_detail.eth, "address") and ( + port_detail.services[0] == "nvme-" + + self.configuration.pure_nvme_transport): + valid_nvme_ports.append(nvme_ports[port]) if not nvme_ports: raise PureDriverException( reason=_("No %(type)s enabled ports on target array.") % diff --git a/releasenotes/notes/pure_lacp_iscsi-34678bdb98fa6bab.yaml b/releasenotes/notes/pure_lacp_iscsi-34678bdb98fa6bab.yaml new file mode 100644 index 00000000000..bdfea7ba8f7 --- /dev/null +++ b/releasenotes/notes/pure_lacp_iscsi-34678bdb98fa6bab.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Pure Storage `bug #2101859 + `_: Fixed issue where + LACP bonds were not been correctly identified as iSCSI and NVMe + targets. From 4acf0a3b6ea89c98ad3f1052c7345b0b67a5aafa Mon Sep 17 00:00:00 2001 From: Gorka Eguileor Date: Thu, 9 Jan 2025 09:45:27 -0800 Subject: [PATCH 07/37] Driver assisted migration on retype when it's safe This is a revision of I2532cfc9b98788a1a1e765f07d0c9f8c98bc77f6 that corrects the issue that forced it to be reverted by I893105cbd270300be9ec48b3127e66022f739314. The revised code avoids attempting driver assisted migration when the volume has attachments. When doing a retype of a volume that requires a migration, the manager only uses driver assisted migration when the source and the destination belong to the same backend (different pools). As long as the volume has no attachments, driver assisted migration should also be tried for other cases, just like when we do a normal migration. One case were this would be beneficial is when doing migrations from one pool to another on the same storage system on single pool drivers (such as RBD/Ceph). This patch checks what are the changes between the types to see if it is safe to use driver assisted migration (from the perspective of keeping the resulting volume consistent with the volume type) and when it is it tries to use it. If driver assisted migration indicates that it couldn't move the volume, then we go with the generic volume migration like we used to. Co-Authored-By: Alan Bishop Closes-Bug: #1886543 Change-Id: I0c6b2c584e8e0053ad740ee25f29ca1d442bdeea (cherry picked from commit b8610a01b61cf86db2f31e26eb0d4dab4a812ace) --- .../unit/volume/test_volume_migration.py | 83 +++++++++++++++++++ cinder/volume/manager.py | 36 +++++++- ...e-assisted-migration-6cdc7f9b21beb859.yaml | 7 ++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/retype-assisted-migration-6cdc7f9b21beb859.yaml diff --git a/cinder/tests/unit/volume/test_volume_migration.py b/cinder/tests/unit/volume/test_volume_migration.py index f60698495c3..232f0e205bf 100644 --- a/cinder/tests/unit/volume/test_volume_migration.py +++ b/cinder/tests/unit/volume/test_volume_migration.py @@ -105,6 +105,71 @@ def test_migrate_volume_driver(self): self.assertEqual('newhost', volume.host) self.assertEqual('success', volume.migration_status) + @mock.patch('cinder.volume.manager.VolumeManager.' + '_can_use_driver_migration') + def test_migrate_volume_driver_for_retype(self, mock_can_use): + """Test volume migration done by driver on a retype.""" + # Mock driver and rpc functions + mock_driver = self.mock_object(self.volume.driver, 'migrate_volume', + return_value=(True, {})) + + volume = tests_utils.create_volume(self.context, size=0, + host=CONF.host, + migration_status='migrating') + host_obj = {'host': 'newhost', 'capabilities': {}} + self.volume.migrate_volume(self.context, volume, host_obj, False, + fake.VOLUME_TYPE2_ID, mock.sentinel.diff) + + mock_can_use.assert_called_once_with(mock.sentinel.diff) + mock_driver.assert_called_once_with(self.context, volume, host_obj) + # check volume properties + volume = objects.Volume.get_by_id(context.get_admin_context(), + volume.id) + self.assertEqual('newhost', volume.host) + self.assertEqual('success', volume.migration_status) + self.assertEqual(fake.VOLUME_TYPE2_ID, volume.volume_type_id) + + @mock.patch('cinder.volume.manager.VolumeManager._migrate_volume_generic') + @mock.patch('cinder.volume.manager.VolumeManager.' + '_can_use_driver_migration') + def test_migrate_volume_driver_for_retype_generic(self, mock_can_use, + mock_generic): + """Test generic volume migration on a retype after driver can't.""" + # Mock driver and rpc functions + mock_driver = self.mock_object(self.volume.driver, 'migrate_volume', + return_value=(False, None)) + + volume = tests_utils.create_volume(self.context, size=0, + host=CONF.host, + migration_status='migrating') + host_obj = {'host': 'newhost', 'capabilities': {}} + self.volume.migrate_volume(self.context, volume, host_obj, False, + fake.VOLUME_TYPE2_ID, mock.sentinel.diff) + + mock_can_use.assert_called_once_with(mock.sentinel.diff) + mock_driver.assert_called_once_with(self.context, volume, host_obj) + mock_generic.assert_called_once_with(self.context, volume, host_obj, + fake.VOLUME_TYPE2_ID) + + @mock.patch('cinder.volume.manager.VolumeManager._migrate_volume_generic') + def test_migrate_volume_driver_attached_volume(self, mock_generic): + """Test driver volume migration with an attachment.""" + mock_driver = self.mock_object(self.volume.driver, 'migrate_volume', + return_value=(False, None)) + volume = tests_utils.create_volume(self.context, size=0, + host=CONF.host, + migration_status='migrating') + volume = tests_utils.attach_volume( + self.context, volume, fake.INSTANCE_ID, 'host', '/dev/vda') + host_obj = {'host': 'newhost', 'capabilities': {}} + self.volume.migrate_volume(self.context, volume, host_obj, False, + fake.VOLUME_TYPE2_ID) + # Driver assisted migration should not be attempted when the volume + # has attachments. + mock_driver.assert_not_called() + mock_generic.assert_called_once_with(self.context, volume, host_obj, + fake.VOLUME_TYPE2_ID) + def test_migrate_volume_driver_cross_az(self): """Test volume migration done by driver.""" # Mock driver and rpc functions @@ -1024,3 +1089,21 @@ def test_retype_volume_not_capable_to_replica(self): self.volume.retype(self.context, volume, new_vol_type.id, host_obj, migration_policy='on-demand') vt_get.assert_not_called() + + @ddt.data( + (None, True), + ({'encryption': {'cipher': ('v1', 'v2')}}, False), + ({'qos_specs': {'key1': ('v1', 'v2')}}, False), + ({'encryption': {}, 'qos_specs': {}, 'extra_specs': {}}, True), + ({'encryption': {}, 'qos_specs': {}, + 'extra_specs': {'volume_backend_name': ('ceph1', 'ceph2'), + 'RESKEY:availability_zones': ('nova', 'nova2')}}, + True), + ({'encryption': {}, 'qos_specs': {}, + 'extra_specs': {'thin_provisioning_support': (' True', None)}}, + False), + ) + @ddt.unpack + def test__can_use_driver_migration(self, diff, expected): + res = self.volume._can_use_driver_migration(diff) + self.assertEqual(expected, res) diff --git a/cinder/volume/manager.py b/cinder/volume/manager.py index d691710ddd1..7ee0a562556 100644 --- a/cinder/volume/manager.py +++ b/cinder/volume/manager.py @@ -2613,12 +2613,35 @@ def migrate_volume_completion(self, resource=volume) return volume.id + def _can_use_driver_migration(self, diff): + """Return when we can use driver assisted migration on a retype.""" + # We can if there's no retype or there are no difference in the types + if not diff: + return True + + extra_specs = diff.get('extra_specs') + qos = diff.get('qos_specs') + enc = diff.get('encryption') + + # We cant' if QoS or Encryption changes and we can if there are no + # extra specs changes. + if qos or enc or not extra_specs: + return not (qos or enc) + + # We can use driver assisted migration if we only change the backend + # name, and the AZ. + extra_specs = extra_specs.copy() + extra_specs.pop('volume_backend_name', None) + extra_specs.pop('RESKEY:availability_zones', None) + return not extra_specs + def migrate_volume(self, ctxt: context.RequestContext, volume, host, force_host_copy: bool = False, - new_type_id=None) -> None: + new_type_id=None, + diff=None) -> None: """Migrate the volume to the specified host (called on source host).""" try: volume_utils.require_driver_initialized(self.driver) @@ -2636,7 +2659,12 @@ def migrate_volume(self, volume.migration_status = 'migrating' volume.save() - if not force_host_copy and new_type_id is None: + # Do not attempt driver assisted migration when the volume has + # attachments. Nova must be involved when migrating an attached + # volume, and that's handled by the generic migration code. + if (len(volume.volume_attachment) == 0 and + not force_host_copy and + self._can_use_driver_migration(diff)): try: LOG.debug("Issue driver.migrate_volume.", resource=volume) moved, model_update = self.driver.migrate_volume(ctxt, @@ -2655,6 +2683,8 @@ def migrate_volume(self, updates.update(status_update) if model_update: updates.update(model_update) + if new_type_id: + updates['volume_type_id'] = new_type_id volume.update(updates) volume.save() except Exception: @@ -3129,7 +3159,7 @@ def _retype_error(context, volume, old_reservations, try: self.migrate_volume(context, volume, host, - new_type_id=new_type_id) + new_type_id=new_type_id, diff=diff) except Exception: with excutils.save_and_reraise_exception(): _retype_error(context, volume, old_reservations, diff --git a/releasenotes/notes/retype-assisted-migration-6cdc7f9b21beb859.yaml b/releasenotes/notes/retype-assisted-migration-6cdc7f9b21beb859.yaml new file mode 100644 index 00000000000..54e513d8a17 --- /dev/null +++ b/releasenotes/notes/retype-assisted-migration-6cdc7f9b21beb859.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + `Bug #1886543 `_: + On retypes requiring a migration, try to use the driver assisted mechanism + when moving from one backend to another when we know it's safe from the + volume type perspective. From 2889ee1299a9db75e7acbe4cbfb4601a725741a0 Mon Sep 17 00:00:00 2001 From: Eric Harney Date: Thu, 27 Mar 2025 10:48:57 -0400 Subject: [PATCH 08/37] zuul: cinder-plugin-ceph-tempest: raise swap size This is set to 8GB in devstack-plugin-ceph, unset this here so that takes effect. Change-Id: Icfc28c884a44ec5124261de71061c38ed1d13679 (cherry picked from commit b05608e30399df92197962efef72ffe8de9794a3) --- .zuul.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.zuul.yaml b/.zuul.yaml index b126d293fdf..4ea001e99ad 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -199,7 +199,6 @@ # bypasses nova's checks. Until the nova team decides on a strategy to handle # this issue, we skip these tests. tempest_exclude_regex: (tempest.api.image.v2.test_images_formats.ImagesFormatTest.test_compute_rejects) - configure_swap_size: 4096 devstack_localrc: CEPH_MIN_CLIENT_VERSION: "mimic" # NOTE: if jobs are having memory problems, may want From ca82907201bd79a0d3bb172b52da3689ba541396 Mon Sep 17 00:00:00 2001 From: Fernando Ferraz Date: Fri, 11 Apr 2025 10:06:26 -0300 Subject: [PATCH 09/37] NFS driver: Fix fail creating volume with multiple snapshots The NFS driver uses qcow2 images with backing files to represent volume snapshots, which is not allowed for qcow2 disk images downloaded from glance. The driver uses cinder.image_utils to convert a qcow2 snapshot to a raw volume; this was not a problem for the first snapshot, whose backing file is raw, and hence passed the image format inspector, but the second snapshot has a qcow2 backing file, which the image_utils were rejecting as a security risk. Thus we now pass the qemu_img_info from the backing image as an additional parameter to the image convert call, which indicates that the file has already been screened and allows the conversion to occur. Co-authored-by: Fernando Ferraz Co-authored-by: Brian Rosmaita Closes-bug: #2074377 Change-Id: I49404e87eb0c77b4ed92918404f86c073fbfd713 (cherry picked from commit 9687fbac79b0af266dcefb89b8ecc5c2940f6c80) --- cinder/image/image_utils.py | 64 ++++++++++------ cinder/tests/unit/test_image_utils.py | 74 +++++++++++++++++++ cinder/tests/unit/volume/drivers/test_nfs.py | 31 +++++--- cinder/volume/drivers/nfs.py | 8 +- ...fs-vol-from-snapshot-654a07d25a33bf7d.yaml | 8 ++ 5 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 releasenotes/notes/fix-nfs-vol-from-snapshot-654a07d25a33bf7d.yaml diff --git a/cinder/image/image_utils.py b/cinder/image/image_utils.py index cdd7b850227..b467380845a 100644 --- a/cinder/image/image_utils.py +++ b/cinder/image/image_utils.py @@ -376,18 +376,20 @@ def check_qemu_img_version(minimum_version: str) -> None: raise exception.VolumeBackendAPIException(data=_msg) -def _convert_image(prefix: tuple, - source: str, - dest: str, - out_format: str, - out_subformat: Optional[str] = None, - src_format: Optional[str] = None, - run_as_root: bool = True, - cipher_spec: Optional[dict] = None, - passphrase_file: Optional[str] = None, - compress: bool = False, - src_passphrase_file: Optional[str] = None, - disable_sparse: bool = False) -> None: +def _convert_image( + prefix: tuple, + source: str, + dest: str, + out_format: str, + out_subformat: Optional[str] = None, + src_format: Optional[str] = None, + run_as_root: bool = True, + cipher_spec: Optional[dict] = None, + passphrase_file: Optional[str] = None, + compress: bool = False, + src_passphrase_file: Optional[str] = None, + disable_sparse: bool = False, + src_img_info: Optional[imageutils.QemuImgInfo] = None) -> None: """Convert image to other format. NOTE: If the qemu-img convert command fails and this function raises an @@ -406,6 +408,8 @@ def _convert_image(prefix: tuple, :param compress: compress w/ qemu-img when possible (best effort) :param src_passphrase_file: filename containing source volume's luks passphrase + :param src_img_info: a imageutils.QemuImgInfo object from this image, + or None """ # Check whether O_DIRECT is supported and set '-t none' if it is @@ -462,17 +466,34 @@ def _convert_image(prefix: tuple, # some incredible event this is 0 (cirros image?) don't barf if duration < 1: duration = 1 - try: - image_size = qemu_img_info(source, - run_as_root=run_as_root).virtual_size - except ValueError as e: + + image_info = src_img_info + if not image_info: + try: + image_info = qemu_img_info(source, run_as_root=run_as_root) + except Exception: + # NOTE: at this point, the image conversion has already + # happened, and all that's left is some performance logging. + # So ignoring an exception from qemu_img_info here is not a + # security risk. I'm afraid that if we are too strict here + # we will cause a regression, given that the converted image + # source could be cinder glance_store, Glance, or one of + # cinder's own backend drivers. The nfs driver knows + # to pass in a src_img_info object, but others may not. + # + # We are catching the most general Exception here for a + # similar reason: the image conversion has already happened. + # If the conversion raised a ProcessExecutionError, we would + # never have reached this point. But a PEE now is meaningless, + # so we ignore it. + pass + if not image_info or image_info.virtual_size is None: msg = ("The image was successfully converted, but image size " - "is unavailable. src %(src)s, dest %(dest)s. %(error)s") - LOG.info(msg, {"src": source, - "dest": dest, - "error": e}) + "is unavailable. src %(src)s, dest %(dest)s") + LOG.info(msg, {"src": source, "dest": dest}) return + image_size = image_info.virtual_size fsz_mb = image_size / units.Mi mbps = (fsz_mb / duration) msg = ("Image conversion details: src %(src)s, size %(sz).2f MB, " @@ -539,7 +560,8 @@ def convert_image(source: str, passphrase_file=passphrase_file, compress=compress, src_passphrase_file=src_passphrase_file, - disable_sparse=disable_sparse) + disable_sparse=disable_sparse, + src_img_info=data) def resize_image(source: str, diff --git a/cinder/tests/unit/test_image_utils.py b/cinder/tests/unit/test_image_utils.py index 4bd073c5d30..d87bc250d63 100644 --- a/cinder/tests/unit/test_image_utils.py +++ b/cinder/tests/unit/test_image_utils.py @@ -496,6 +496,80 @@ def test_not_enough_conversion_space(self, mock_log.assert_called_with('Insufficient free space on fakedir for' ' image conversion.') + @mock.patch('cinder.image.image_utils.qemu_img_info') + @mock.patch('cinder.utils.execute') + @mock.patch('cinder.image.image_utils._get_qemu_convert_cmd') + @mock.patch('cinder.utils.is_blk_device', return_value=False) + @mock.patch.object(image_utils.LOG, 'info') + @mock.patch.object(image_utils.LOG, 'debug') + def test__convert_image_no_virt_size(self, + mock_debug_log, + mock_info_log, + mock_isblk, + mock_cmd, + mock_execute, + mock_info): + """Make sure we don't try to do math with a None value""" + prefix = ('cgexec', '-g', 'blkio:cg') + source = '/source' + dest = '/dest' + out_format = 'unspecified' + + # 1. no qemu_img_info passed in and qemu_img_info() raises exc + mock_info.side_effect = processutils.ProcessExecutionError + image_utils._convert_image(prefix, source, dest, out_format) + mock_debug_log.assert_not_called() + log_msg = mock_info_log.call_args.args[0] + self.assertIn("image size is unavailable", log_msg) + + mock_info.reset_mock(side_effect=True) + mock_info_log.reset_mock() + + # 2. no qemu_img_info passed in, returned obj has no virtual_size + mock_info.return_value = imageutils.QemuImgInfo() + image_utils._convert_image(prefix, source, dest, out_format) + mock_debug_log.assert_not_called() + log_msg = mock_info_log.call_args.args[0] + self.assertIn("image size is unavailable", log_msg) + + mock_info.reset_mock(return_value=True) + mock_info_log.reset_mock() + + # 3. no qemu_img_info passed in, returned obj has virtual_size + mock_info.return_value = imageutils.QemuImgInfo( + '{"virtual-size": 1073741824}', format='json') + image_utils._convert_image(prefix, source, dest, out_format) + log_msg = mock_debug_log.call_args.args[0] + self.assertIn("Image conversion details", log_msg) + log_msg = mock_info_log.call_args.args[0] + self.assertIn("Converted", log_msg) + + mock_info.reset_mock() + mock_debug_log.reset_mock() + mock_info_log.reset_mock() + + # 4. qemu_img_info passed in but without virtual_size + src_img_info = imageutils.QemuImgInfo() + image_utils._convert_image(prefix, source, dest, out_format, + src_img_info=src_img_info) + mock_info.assert_not_called() + mock_debug_log.assert_not_called() + log_msg = mock_info_log.call_args.args[0] + self.assertIn("image size is unavailable", log_msg) + + mock_info_log.reset_mock() + + # 5. qemu_img_info passed in with virtual_size + src_img_info = imageutils.QemuImgInfo('{"virtual-size": 1073741824}', + format='json') + image_utils._convert_image(prefix, source, dest, out_format, + src_img_info=src_img_info) + mock_info.assert_not_called() + log_msg = mock_debug_log.call_args.args[0] + self.assertIn("Image conversion details", log_msg) + log_msg = mock_info_log.call_args.args[0] + self.assertIn("Converted", log_msg) + @ddt.ddt class TestResizeImage(test.TestCase): diff --git a/cinder/tests/unit/volume/drivers/test_nfs.py b/cinder/tests/unit/volume/drivers/test_nfs.py index 5208e38c95f..43be3fcf940 100644 --- a/cinder/tests/unit/volume/drivers/test_nfs.py +++ b/cinder/tests/unit/volume/drivers/test_nfs.py @@ -1327,17 +1327,22 @@ def __init__(self, d): dest_volume = self._simple_volume() src_volume = self._simple_volume() + # snapshot img_info fake_snap = fake_snapshot.fake_snapshot_obj(self.context) fake_snap.volume = src_volume - img_out = qemu_img_info % {'volid': src_volume.id, 'snapid': fake_snap.id, 'size_gb': src_volume.size, 'size_b': src_volume.size * units.Gi} - img_info = imageutils.QemuImgInfo(img_out, format='json') + + # backing file img_info + img_out = QEMU_IMG_INFO_OUT1 % {'volid': src_volume.id, + 'size_b': src_volume.size * units.Gi} + bk_img_info = imageutils.QemuImgInfo(img_out, format='json') + mock_img_info = self.mock_object(image_utils, 'qemu_img_info') - mock_img_info.return_value = img_info + mock_img_info.side_effect = [img_info, bk_img_info] mock_convert_image = self.mock_object(image_utils, 'convert_image') vol_dir = os.path.join(self.TEST_MNT_POINT_BASE, @@ -1361,21 +1366,27 @@ def __init__(self, d): dest_encryption_key_id) mock_read_info_file.assert_called_once_with(info_path) - mock_img_info.assert_called_once_with(snap_path, - force_share=True, - run_as_root=True, - allow_qcow2_backing_file=True) + snap_info_call = mock.call(snap_path, + force_share=True, run_as_root=True, + allow_qcow2_backing_file=True) + src_info_call = mock.call(src_vol_path, + force_share=True, run_as_root=True, + allow_qcow2_backing_file=True) + self.assertEqual(2, mock_img_info.call_count) + mock_img_info.assert_has_calls([snap_info_call, src_info_call]) used_qcow = nfs_conf['nfs_qcow2_volumes'] if encryption: mock_convert_image.assert_called_once_with( src_vol_path, dest_vol_path, 'luks', passphrase_file='/tmp/passfile', run_as_root=True, - src_passphrase_file='/tmp/imgfile') + src_passphrase_file='/tmp/imgfile', + data=bk_img_info) else: mock_convert_image.assert_called_once_with( src_vol_path, dest_vol_path, 'qcow2' if used_qcow else 'raw', - run_as_root=True) + run_as_root=True, + data=bk_img_info) mock_permission.assert_called_once_with(dest_vol_path) @ddt.data([NFS_CONFIG1, QEMU_IMG_INFO_OUT3, 'available'], @@ -1443,7 +1454,7 @@ def test_create_volume_from_snapshot(self, nfs_conf, qemu_img_info, used_qcow = nfs_conf['nfs_qcow2_volumes'] mock_convert_image.assert_called_once_with( src_volume_path, new_volume_path, 'qcow2' if used_qcow else 'raw', - run_as_root=True) + run_as_root=True, data=img_info) mock_ensure.assert_called_once() mock_find_share.assert_called_once_with(new_volume) diff --git a/cinder/volume/drivers/nfs.py b/cinder/volume/drivers/nfs.py index a4151c3408c..04141f51ff7 100644 --- a/cinder/volume/drivers/nfs.py +++ b/cinder/volume/drivers/nfs.py @@ -642,6 +642,8 @@ def _copy_volume_from_snapshot(self, snapshot, volume, volume_size, # when this snapshot was created. img_info = self._qemu_img_info(forward_path, snapshot.volume.name) path_to_snap_img = os.path.join(vol_path, img_info.backing_file) + snap_backing_file_img_info = self._qemu_img_info(path_to_snap_img, + snapshot.volume.name) path_to_new_vol = self._local_path_volume(volume) @@ -687,11 +689,13 @@ def _copy_volume_from_snapshot(self, snapshot, volume, volume_size, 'luks', passphrase_file=new_pass_file.name, src_passphrase_file=src_pass_file.name, - run_as_root=self._execute_as_root) + run_as_root=self._execute_as_root, + data=snap_backing_file_img_info) else: image_utils.convert_image(path_to_snap_img, path_to_new_vol, out_format, - run_as_root=self._execute_as_root) + run_as_root=self._execute_as_root, + data=snap_backing_file_img_info) self._set_rw_permissions_for_all(path_to_new_vol) diff --git a/releasenotes/notes/fix-nfs-vol-from-snapshot-654a07d25a33bf7d.yaml b/releasenotes/notes/fix-nfs-vol-from-snapshot-654a07d25a33bf7d.yaml new file mode 100644 index 00000000000..6fa2e04c586 --- /dev/null +++ b/releasenotes/notes/fix-nfs-vol-from-snapshot-654a07d25a33bf7d.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + NFS driver `bug #2074377 + `_: Fixed regression + caused by change I65857288b797 (the mitigation for CVE-2024-32498) + that was preventing the creation of a new volume from the second and + subsequent snapshots of an existing volume. From 1f9d111f847cd2b117c36f6433059c65fc488c03 Mon Sep 17 00:00:00 2001 From: Bertrand Lanson Date: Fri, 27 Sep 2024 09:51:16 +0200 Subject: [PATCH 10/37] Fix type passed to write function during backup restoration Cinder backup throws a TypeError when trying to restore to a new volume, because slicing a memoryview object does not return a byte string. Closes-Bug: #2082587 Change-Id: Ibaf87af25dc33c59660be6e112ca78b993592385 (cherry picked from commit b5ac261e6a8f19b0bfbf93de0522f15b2cb79baf) --- cinder/backup/chunkeddriver.py | 2 +- ...ug-2082587-fix-type-passed-during-backup-restoration.yaml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml diff --git a/cinder/backup/chunkeddriver.py b/cinder/backup/chunkeddriver.py index 490baf84458..8f003329bc8 100644 --- a/cinder/backup/chunkeddriver.py +++ b/cinder/backup/chunkeddriver.py @@ -78,7 +78,7 @@ def _write_nonzero(volume_file, volume_offset, content): # The len(chunk) may be smaller than chunk_length. It's okay. if not volume_utils.is_all_zero(chunk): volume_file.seek(volume_offset + chunk_offset) - volume_file.write(chunk) + volume_file.write(chunk.tobytes()) def _write_volume(volume_is_new, volume_file, volume_offset, content): diff --git a/releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml b/releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml new file mode 100644 index 00000000000..20b6c40027d --- /dev/null +++ b/releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + `Bug #2082587 ` _: Fixed + backup restoration throwing TypeError on new volume. From 8df631859da80f224f45d2b1aeb866d4e12eb33b Mon Sep 17 00:00:00 2001 From: Brian Rosmaita Date: Thu, 17 Apr 2025 10:33:43 -0400 Subject: [PATCH 11/37] [docs] Extra spaces breaking rst->html rendering Two extra spaces in this release note are causing: (1) the bug link to be displayed as text instead of converted to a hyperlink, and (2) the list item to be displayed as a description list entry instead of a regular bulleted list entry. Change-Id: I516211d9328c4bacd627f152aa5fb62e3ab56734 (cherry picked from commit d52e0532f978507a742504d4ab1dec3fa8473b4e) --- ...bug-2082587-fix-type-passed-during-backup-restoration.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml b/releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml index 20b6c40027d..956e85e30b2 100644 --- a/releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml +++ b/releasenotes/notes/bug-2082587-fix-type-passed-during-backup-restoration.yaml @@ -1,5 +1,5 @@ --- fixes: - | - `Bug #2082587 ` _: Fixed - backup restoration throwing TypeError on new volume. + `Bug #2082587 `_: Fixed + backup restoration throwing TypeError on new volume. From dbf1034bb0bdfdc1fdb5c7f7605136410b46dff6 Mon Sep 17 00:00:00 2001 From: Eric Harney Date: Fri, 11 Apr 2025 12:17:24 -0400 Subject: [PATCH 12/37] tgt target: Provide unique scsi_sn and scsi_id Provide unique scsi_sn and scsi_id fields so that attachments can more reliably locate the correct block device for a volume. Closes-Bug: #1917750 Depends-On: I91cd5e262513b5427377ce1892e9acfe29e22b21 Change-Id: Ifc17afa115c669ab5aa8dfc063638b9dfd929942 (cherry picked from commit 67b063e9200d33f813b6ba3938ffdf37e9d3497e) --- cinder/volume/targets/tgt.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cinder/volume/targets/tgt.py b/cinder/volume/targets/tgt.py index ceef5d42e12..c01dceaa0ff 100644 --- a/cinder/volume/targets/tgt.py +++ b/cinder/volume/targets/tgt.py @@ -42,6 +42,8 @@ class TgtAdm(iscsi.ISCSITarget): %(chap_auth)s %(target_flags)s write-cache %(write_cache)s + scsi_sn %(scsi_sn)s + scsi_id %(scsi_id)s """) @@ -139,10 +141,16 @@ def create_iscsi_target(self, name, tid, lun, path, if target_flags: target_flags = 'bsoflags ' + target_flags + # Create unique scsi_sn and scsi_id fields based on the volume id + scsi_sn = vol_id + scsi_id = vol_id + volume_conf = self.VOLUME_CONF % { 'name': name, 'path': path, 'driver': driver, 'chap_auth': chap_str, 'target_flags': target_flags, - 'write_cache': write_cache} + 'write_cache': write_cache, + 'scsi_sn': scsi_sn, + 'scsi_id': scsi_id} LOG.debug('Creating iscsi_target for Volume ID: %s', vol_id) volumes_dir = self.volumes_dir From df190f7f4c720a8fa920f0ef55536f1181d78951 Mon Sep 17 00:00:00 2001 From: raghavendrat Date: Tue, 20 May 2025 09:05:55 +0000 Subject: [PATCH 13/37] HPE 3par: Ignore duplicate IP in iSCSI/vlan ip As seen in output of showport command below, there is possibility of same ip address (172.28.50.151) being used for trunk iSCSI ip and vlan ip. s0452 cli% showport -iscsi N:S:P State IPAddr Netmask/PrefixLen Gateway TPGT . . . 0:4:2 ready 172.28.50.151 255.255.0.0 0.0.0.0 42 . . . --------------------------------------------------------- 1 s0452 cli% s0452 cli% showport -iscsivlans N:S:P VLAN IPAddr Netmask/PrefixLen Gateway . . . 0:4:2 untagged 172.28.50.151 255.255.0.0 0.0.0.0 . . . - 5 172.28.50.240 255.255.0.0 0.0.0.0 . . . ------------------------------------------------------ 2 s0452 cli% This patch checks for duplicate IP and ignores them; thus avoiding multiple calls (to create lun) for same IP. Why this change is needed: Below is the use case. 1. initialize connection function is invoked. 2. using trunk iSCSI ip 172.28.50.151, create lun invoked. 3. With vlan ip 172.28.50.151, it again tries to create lun. 4. And this fails because LUN is already created (with same ip) in step 2. Extract from cinder-volume log: Driver initialize connection failed (error: Conflict (HTTP 409) 18 - LUN exists). Solution: While processing vlan ip, check if its same as iSCSI ip. If so, skip the lun creation. Closes-Bug: #2112433 Depends-On: I91cd5e262513b5427377ce1892e9acfe29e22b21 Change-Id: I582c27d8d8a8e22d03b2d68152cb467a6db606bc (cherry picked from commit 255ccd6c6cc6c64b154d9fea7fc9020e80bb4766) --- .../unit/volume/drivers/hpe/test_hpe3par.py | 11 +++++-- cinder/volume/drivers/hpe/hpe_3par_iscsi.py | 30 ++++++++++++------- ...-ignore-duplicate-ip-7e67260ee1cab40e.yaml | 5 ++++ 3 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 releasenotes/notes/hpe-3par-ignore-duplicate-ip-7e67260ee1cab40e.yaml diff --git a/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py b/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py index 78475a3f01c..b60a2d31cf1 100644 --- a/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py +++ b/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py @@ -9314,7 +9314,14 @@ def test_initialize_connection_peer_persistence(self, _mock_volume_types): self.assertDictEqual(self.multipath_properties, result) - def test_initialize_connection_multipath_vlan_ip(self): + # iscsi_ip is 1.1.1.2 + # two cases: + # (i) vlan_ip is different from iscsi_ip + # (ii) vlan_ip is same as iscsi_ip + @ddt.data({'vlan_ip': '192.168.100.1'}, + {'vlan_ip': '1.1.1.2'}) + @ddt.unpack + def test_initialize_connection_multipath_vlan_ip(self, vlan_ip): # setup_mock_client drive with default configuration # and return the mock HTTP 3PAR client mock_client = self.setup_driver() @@ -9346,7 +9353,7 @@ def test_initialize_connection_multipath_vlan_ip(self): mock_client.getiSCSIPorts.return_value = [{ 'IPAddr': '1.1.1.2', 'iSCSIName': self.TARGET_IQN, - 'iSCSIVlans': [{'IPAddr': '192.168.100.1', + 'iSCSIVlans': [{'IPAddr': vlan_ip, 'iSCSIName': self.TARGET_IQN}] }] diff --git a/cinder/volume/drivers/hpe/hpe_3par_iscsi.py b/cinder/volume/drivers/hpe/hpe_3par_iscsi.py index 24e48cb24e6..9c0438e77e7 100644 --- a/cinder/volume/drivers/hpe/hpe_3par_iscsi.py +++ b/cinder/volume/drivers/hpe/hpe_3par_iscsi.py @@ -132,10 +132,11 @@ class HPE3PARISCSIDriver(hpebasedriver.HPE3PARDriverBase): 4.0.7 - Use vlan iscsi ips. Bug #2015034 4.0.8 - Add ipv6 support. Bug #2045411 4.0.9 - getWsApiVersion now requires login + 4.0.10 - Ignore duplicate IP address in iSCSI/vlan ip """ - VERSION = "4.0.9" + VERSION = "4.0.10" # The name of the CI wiki page. CI_WIKI_NAME = "HPE_Storage_CI" @@ -330,6 +331,8 @@ def _initialize_connection_common(self, volume, connector, common, for port in ready_ports: iscsi_ip = port['IPAddr'] if iscsi_ip in target_portal_ips: + LOG.debug("for iscsi ip: %(ip)s, create vlun or use existing", + {'ip': iscsi_ip}) lun_id = ( self._vlun_create_or_use_existing( volume, common, host, iscsi_ips, @@ -338,27 +341,32 @@ def _initialize_connection_common(self, volume, connector, common, target_portal_ips, existing_vluns, iscsi_ip, lun_id, port)) + else: + LOG.debug("iscsi ip: %(ip)s was not found in " + "hpe3par_iscsi_ips list defined in " + "cinder.conf.", {'ip': iscsi_ip}) if 'iSCSIVlans' in port: + LOG.debug("for port IPAddr: %(ip)s, the iSCSIVlans are: " + "%(vlans)s", + {'ip': iscsi_ip, 'vlans': port['iSCSIVlans']}) for vip in port['iSCSIVlans']: - iscsi_ip = vip['IPAddr'] - if iscsi_ip in target_portal_ips: - LOG.debug("vlan ip: %(ip)s", {'ip': iscsi_ip}) - + vlan_ip = vip['IPAddr'] + # if vlan_ip is in cinder.conf and + # vlan_ip is not same as iscsi_ip + # only then proceed with lun creation + if vlan_ip in target_portal_ips and vlan_ip != iscsi_ip: + LOG.debug("for vlan ip: %(ip)s, create vlun or use " + "existing", {'ip': vlan_ip}) lun_id = ( self._vlun_create_or_use_existing( volume, common, host, iscsi_ips, target_portals, target_iqns, target_luns, remote_client, target_portal_ips, - existing_vluns, iscsi_ip, + existing_vluns, vlan_ip, lun_id, port)) - else: - LOG.warning("iSCSI IP: '%s' was not found in " - "hpe3par_iscsi_ips list defined in " - "cinder.conf.", iscsi_ip) - @volume_utils.trace @coordination.synchronized('3par-{volume.id}') def initialize_connection(self, volume, connector): diff --git a/releasenotes/notes/hpe-3par-ignore-duplicate-ip-7e67260ee1cab40e.yaml b/releasenotes/notes/hpe-3par-ignore-duplicate-ip-7e67260ee1cab40e.yaml new file mode 100644 index 00000000000..a4463aa65ff --- /dev/null +++ b/releasenotes/notes/hpe-3par-ignore-duplicate-ip-7e67260ee1cab40e.yaml @@ -0,0 +1,5 @@ +fixes: + - | + HPE 3par driver `bug #2112433 + `_: Fixed failure + observed when vlan ip is same as iSCSI ip by ignoring the duplicate ip From 2da9df9405c0459d543f3119b1bda45e3ac59d8c Mon Sep 17 00:00:00 2001 From: Rajat Dhasmana Date: Mon, 23 Jun 2025 14:06:44 +0000 Subject: [PATCH 14/37] Pin flake8-import-order<0.19.0 flake8-import-order has a new release[1] on June 20th that breaks Cinder in a different way. ./cinder/api/api_utils.py:28:1: I300 TYPE_CHECKING block should have one newline above. ./cinder/api/common.py:32:1: I300 TYPE_CHECKING block should have one newline above. ./cinder/cmd/backup.py:48:1: I300 TYPE_CHECKING block should have one newline above. ./cinder/cmd/volume.py:52:1: I300 TYPE_CHECKING block should have one newline above. It would be good to fix the changes but for now, it's best to pin the flake8-import-order to <0.19.X where the jobs were stable. We can pin it to a higher version when needed but we want to unblock the gate as a priority for now. [1] https://pypi.org/project/flake8-import-order/0.19.1/ Change-Id: Ic99814a61c93a9249ae9fbe5ecd5c510cb6e31ed (cherry picked from commit 4b96a9a88e350b6c1192f192b07c9af1f0f31747) Conflicts: test-requirements.txt --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 0b0e20c6d60..70e647ed5eb 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,7 +4,7 @@ # Install bounded pep8/pyflakes first, then let flake8 install hacking>=7.0.0,<7.1.0 # Apache-2.0 -flake8-import-order # LGPLv3 +flake8-import-order<0.19.0 # LGPLv3 flake8-logging-format>=0.6.0 # Apache-2.0 stestr>=3.2.1 # Apache-2.0 From 3057efc92300b5debcbdb96a0e8f721ee84f3463 Mon Sep 17 00:00:00 2001 From: Fernando Ferraz Date: Wed, 2 Apr 2025 00:58:36 -0300 Subject: [PATCH 15/37] NVMe-oF Target: Fix incorrect check for initiator in connector data Drivers using the Cinder NVMe-oF target may fail if the `initiator` (from iSCSI devices) isn't part of the connector data populated by the os-brick. The `initiator` information comes from the iSCSI connector and is unrelated to NVMe targets, so validating such information isn't correct. When iSCSI isn't properly configured in the host and `/etc/iscsi/initiatorname.iscsi` is missing, the iSCSI connector fails to gather the `initiator` information and the property isn't added to the connector data, exposing the issue. This patch fixes this issue by checking only for NVMe-oF related fields in the connector properties instead of iSCSI `initiator` data. Closes-Bug: #2105961 Change-Id: Ieab023f9df04779a33e535ca3b502f9a48f3ea13 (cherry picked from commit 210d488654af0235e1a3bb926e9dff4b2b55d847) --- cinder/tests/unit/targets/test_nvmeof_driver.py | 9 +++++---- cinder/tests/unit/volume/drivers/test_spdk.py | 2 +- cinder/volume/targets/nvmeof.py | 9 +++++---- ...e-to-initiator-property-missing-db8315541f94447f.yaml | 7 +++++++ 4 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 releasenotes/notes/bug-2105961-fix-nvmeof-fail-due-to-initiator-property-missing-db8315541f94447f.yaml diff --git a/cinder/tests/unit/targets/test_nvmeof_driver.py b/cinder/tests/unit/targets/test_nvmeof_driver.py index 21d30b7392b..06bd10bdf50 100644 --- a/cinder/tests/unit/targets/test_nvmeof_driver.py +++ b/cinder/tests/unit/targets/test_nvmeof_driver.py @@ -189,12 +189,13 @@ def test__get_connection_properties_new( mock.sentinel.uuid) self.assertEqual(expected_return, res) - def test_validate_connector(self): - mock_connector = {'initiator': 'fake_init'} + @ddt.data({'nqn': 'fake-nqn'}, + {'nqn': 'fake-nqn', 'initiator': 'fake-iqn'}) + def test_validate_connector(self, mock_connector): self.assertTrue(self.target.validate_connector(mock_connector)) - def test_validate_connector_not_found(self): - mock_connector = {'fake_init'} + @ddt.data({'initiator': 'fake-iqn'}, {}) + def test_validate_connector_not_found(self, mock_connector): self.assertRaises(exception.InvalidConnectorException, self.target.validate_connector, mock_connector) diff --git a/cinder/tests/unit/volume/drivers/test_spdk.py b/cinder/tests/unit/volume/drivers/test_spdk.py index cf725ffba4a..d7887c1fdf7 100644 --- a/cinder/tests/unit/volume/drivers/test_spdk.py +++ b/cinder/tests/unit/volume/drivers/test_spdk.py @@ -835,7 +835,7 @@ def test_initialize_connection(self): self.driver.initialize_connection(db_volume, target_connector) def test_validate_connector(self): - mock_connector = {'initiator': 'fake_init'} + mock_connector = {'nqn': 'fake-nqn'} self.assertTrue(self.driver.validate_connector(mock_connector)) def test_terminate_connection(self): diff --git a/cinder/volume/targets/nvmeof.py b/cinder/volume/targets/nvmeof.py index 5e11acba32f..6c734fa7303 100644 --- a/cinder/volume/targets/nvmeof.py +++ b/cinder/volume/targets/nvmeof.py @@ -219,11 +219,12 @@ def remove_export(self, context, volume): return self.delete_nvmeof_target(volume) def validate_connector(self, connector): - if 'initiator' not in connector: - LOG.error('The volume driver requires the NVMe initiator ' - 'name in the connector.') + required = 'nqn' + if required not in connector: + LOG.error('Required information %(required)s not found in ' + 'connector data.', {"required": required}) raise exception.InvalidConnectorException( - missing='initiator') + missing=required) return True def create_nvmeof_target(self, diff --git a/releasenotes/notes/bug-2105961-fix-nvmeof-fail-due-to-initiator-property-missing-db8315541f94447f.yaml b/releasenotes/notes/bug-2105961-fix-nvmeof-fail-due-to-initiator-property-missing-db8315541f94447f.yaml new file mode 100644 index 00000000000..299435f5df8 --- /dev/null +++ b/releasenotes/notes/bug-2105961-fix-nvmeof-fail-due-to-initiator-property-missing-db8315541f94447f.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + `Bug #2105961 `_: Fixed + issue in NVMe-oF target driver to validate the ``nqn`` property (NVMe-oF) + instead of the ``initiator`` property (iSCSI) in the connector which caused + attachment failures in non-iSCSI environments. From b83934da5c09af2ac0b12612d352a39d7737df19 Mon Sep 17 00:00:00 2001 From: Amit Zauber Date: Mon, 9 Jun 2025 21:49:18 +0300 Subject: [PATCH 16/37] Update PowerMax driver doc support matrix for Caracal Dalmatian Epoxy Change-Id: I8baa77bbd3f2e282609f8faafa8b5688e78c3b2a (cherry picked from commit 27373d61fe54e55afa91f1e93cc65d3dd0582f9f) --- .../drivers/dell-emc-powermax-driver.rst | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/doc/source/configuration/block-storage/drivers/dell-emc-powermax-driver.rst b/doc/source/configuration/block-storage/drivers/dell-emc-powermax-driver.rst index 9e6aaa5c2ac..cf5ee17400f 100644 --- a/doc/source/configuration/block-storage/drivers/dell-emc-powermax-driver.rst +++ b/doc/source/configuration/block-storage/drivers/dell-emc-powermax-driver.rst @@ -59,21 +59,30 @@ Guide` at the `Dell Support`_ site. | OpenStack | Unisphere | PowerMax OS | Supported Arrays | | release | for PowerMax | | | +===========+==============+=============+================================+ - | Epoxy | 10.1.0 | 10.1.0 | PowerMax 2500,8500 | + | Epoxy | 10.2.0 | 10.2.0 | PowerMax 2500,8500 | + | | | (6079.275) | | + | +--------------+-------------+--------------------------------+ + | | 10.1.0 | 10.1.0 | PowerMax 2500,8500 | | | | (6079.225) | | - | | +-------------+--------------------------------+ + | +--------------+-------------+--------------------------------+ | | | 5978.711 | PowerMax 2000,8000 | | | | | VMAX 250F, 450F, 850F, 950F | +-----------+--------------+-------------+--------------------------------+ - | Dalmatian | 10.1.0 | 10.1.0 | PowerMax 2500,8500 | + | Dalmatian | 10.2.0 | 10.2.0 | PowerMax 2500,8500 | + | | | (6079.275) | | + | +--------------+-------------+--------------------------------+ + | | 10.1.0 | 10.1.0 | PowerMax 2500,8500 | | | | (6079.225) | | - | | +-------------+--------------------------------+ + | +--------------+-------------+--------------------------------+ | | | 5978.711 | PowerMax 2000,8000 | | | | | VMAX 250F, 450F, 850F, 950F | +-----------+--------------+-------------+--------------------------------+ - | Caracal | 10.1.0 | 10.1.0 | PowerMax 2500,8500 | + | Caracal | 10.2.0 | 10.2.0 | PowerMax 2500,8500 | + | | | (6079.275) | | + | +--------------+-------------+--------------------------------+ + | | 10.1.0 | 10.1.0 | PowerMax 2500,8500 | | | | (6079.225) | | - | | +-------------+--------------------------------+ + | +--------------+-------------+--------------------------------+ | | | 5978.711 | PowerMax 2000,8000 | | | | | VMAX 250F, 450F, 850F, 950F | +-----------+--------------+-------------+--------------------------------+ From 79aaa7dcfc6e0846e4051a79bd8e9fe66cfe22d3 Mon Sep 17 00:00:00 2001 From: Fernando Ferraz Date: Wed, 21 May 2025 19:39:57 -0300 Subject: [PATCH 17/37] cinder-manage: Use same timestamp for purging deleted rows The ``cinder-manage db purge `` command currently recalculates the timetamp for deleting rows for each table it processes. This can lead to foreign key constraint errors, as secondary (dependent) tables may be deleted before their corresponding primary (parent) tables, each using slightly different timestamps. This patch addresses the issue by calculating the timestamp once and reusing it across all bulk delete operations, ensuring that all tables are purged relative to the same point in time. Closes-Bug: #2111461 Change-Id: I2aa881936b85b3876d6c9c9cfe3b26932f65241a (cherry picked from commit 3370f905796b0e2e1cfd7a121290e5313bc8f6b6) Signed-off-by: Fernando Ferraz --- cinder/db/sqlalchemy/api.py | 2 +- ...o-foreign-key-constraint-errors-8a60db1f0158b36e.yaml | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/bug-2111461-fix-db-purge-fails-due-to-foreign-key-constraint-errors-8a60db1f0158b36e.yaml diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index b054022e277..a3fd2c6f261 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -8100,6 +8100,7 @@ def purge_deleted_rows(context, age_in_days): metadata = MetaData() metadata.reflect(engine) + deleted_age = timeutils.utcnow() - dt.timedelta(days=age_in_days) for table in reversed(metadata.sorted_tables): if 'deleted' not in table.columns.keys(): continue @@ -8110,7 +8111,6 @@ def purge_deleted_rows(context, age_in_days): {'age': age_in_days, 'table': table}, ) - deleted_age = timeutils.utcnow() - dt.timedelta(days=age_in_days) try: # Delete child records first from quality_of_service_specs # table to avoid FK constraints diff --git a/releasenotes/notes/bug-2111461-fix-db-purge-fails-due-to-foreign-key-constraint-errors-8a60db1f0158b36e.yaml b/releasenotes/notes/bug-2111461-fix-db-purge-fails-due-to-foreign-key-constraint-errors-8a60db1f0158b36e.yaml new file mode 100644 index 00000000000..4465946fb4c --- /dev/null +++ b/releasenotes/notes/bug-2111461-fix-db-purge-fails-due-to-foreign-key-constraint-errors-8a60db1f0158b36e.yaml @@ -0,0 +1,9 @@ +--- +fixes: + - | + `Bug #2111461 `_: Fixed + issue preventing cinder-manage command to purge deleted rows due to + foreign key constraint errors. This happened as timestamp for bulk + delete operations were recalculated per table resulting in slighty + different intervals for deleting rows on primary and dependents + tables. From 7dbeffea8f557c7d7a2cb27b4e828891b78b5c1d Mon Sep 17 00:00:00 2001 From: Eric Harney Date: Thu, 17 Jul 2025 10:15:49 -0400 Subject: [PATCH 18/37] RBD unit tests: Set cfg.rados_connect_timeout Unit test jobs occasionally fail because this option is not set - always set it in the cfg initialization to match the options used in the driver. Change-Id: I29a3df4e1f50cbfbaf6758953eb350f00f447704 Signed-off-by: Eric Harney (cherry picked from commit 94c7ca0dad318177e5b15312f5d0b2a9e3a22929) --- cinder/tests/unit/volume/drivers/test_rbd.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cinder/tests/unit/volume/drivers/test_rbd.py b/cinder/tests/unit/volume/drivers/test_rbd.py index 4d92afdcc03..1c205195bd9 100644 --- a/cinder/tests/unit/volume/drivers/test_rbd.py +++ b/cinder/tests/unit/volume/drivers/test_rbd.py @@ -196,6 +196,7 @@ def _make_configuration(cls, conf_in=None): cfg.volume_backend_name = None cfg.volume_dd_blocksize = '1M' cfg.rbd_store_chunk_size = 4 + cfg.rados_connect_timeout = -1 cfg.rados_connection_retries = 3 cfg.rados_connection_interval = 5 cfg.backup_use_temp_snapshot = False From ecb27b7554280374d38bacfbe55f0c7161050c18 Mon Sep 17 00:00:00 2001 From: Rajat Dhasmana Date: Tue, 20 Feb 2024 14:37:25 +0530 Subject: [PATCH 19/37] Add support for glance new location APIs This patch adds the call for ``add_image_location`` API which triggers the new location API workflow in glance that addresses OSSN-0065. It is more secure and robust compared to the old location workflow. This call will be made when glance is using cinder as a backend and we want to perform an optimized ``upload volume to image`` operation. Signed-off-by: Rajat Dhasmana Change-Id: Ib70219a9d085257c90a75ddcfcb935b4659fd28d (cherry picked from commit a39fa8fb2876a98b83570295504c0f5e46da7d6a) --- cinder/image/glance.py | 25 +++++++++++++++++++----- cinder/tests/unit/image/test_glance.py | 27 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/cinder/image/glance.py b/cinder/image/glance.py index 9002afb0973..bdcc91d4b4b 100644 --- a/cinder/image/glance.py +++ b/cinder/image/glance.py @@ -377,11 +377,26 @@ def add_location(self, Returns a dict containing image metadata on success. """ client = GlanceClientWrapper() - try: - return client.call(context, 'add_location', - image_id, url, metadata) - except Exception: - _reraise_translated_image_exception(image_id) + # The ``add_image_location`` API was added to address + # OSSN-0065, however to keep backward compatibility, + # we need to try with the old ``add_location`` call + # if we are using an older version of glance. + # TODO: Remove the ``add_location`` API call when 2024.1 + # trasitions to unmaintained. (``add_image_location`` + # was added in 2024.2). + try_methods = ('add_image_location', 'add_location') + for method in try_methods: + try: + return client.call(context, method, + image_id, url, metadata) + except glanceclient.exc.HTTPNotImplemented: + LOG.debug('Glance method %s not available', method) + except Exception: + _reraise_translated_image_exception(image_id) + # If both method return HTTPNotImplemented exception + raise exception.ProgrammingError( + reason='unwarranted assumption about available glanceclient ' + 'methods.') def download(self, context: context.RequestContext, diff --git a/cinder/tests/unit/image/test_glance.py b/cinder/tests/unit/image/test_glance.py index b04d59b5475..9f4d09ac9cc 100644 --- a/cinder/tests/unit/image/test_glance.py +++ b/cinder/tests/unit/image/test_glance.py @@ -773,6 +773,33 @@ def test_detail_makes_datetimes(self): self.assertEqual(self.NOW_DATETIME, image_meta['created_at']) self.assertEqual(self.NOW_DATETIME, image_meta['updated_at']) + @mock.patch.object(glance.GlanceClientWrapper, 'call') + def test_add_location(self, mock_call): + image_id = mock.sentinel.image_id + service = glance.GlanceImageService(client=mock_call) + url = 'cinder://fake-store/c984be2b-8789-4b9e-bf71-19164f537e63' + metadata = {'store': 'fake-store'} + + service.add_location(self.context, image_id, url, metadata) + mock_call.assert_called_once_with( + self.context, 'add_image_location', image_id, url, metadata) + + @mock.patch.object(glance.GlanceClientWrapper, 'call') + def test_add_location_old(self, mock_call): + mock_call.side_effect = [glanceclient.exc.HTTPNotImplemented, None] + image_id = mock.sentinel.image_id + service = glance.GlanceImageService(client=mock_call) + url = 'cinder://fake-store/c984be2b-8789-4b9e-bf71-19164f537e63' + metadata = {'store': 'fake-store'} + + service.add_location(self.context, image_id, url, metadata) + calls = [ + mock.call.call( + self.context, 'add_image_location', image_id, url, metadata), + mock.call.call( + self.context, 'add_location', image_id, url, metadata)] + mock_call.assert_has_calls(calls) + def test_download_with_retries(self): tries = [0] From 9b51bcdc842f55a11c8add85faabe191ad3d1e84 Mon Sep 17 00:00:00 2001 From: Rajat Dhasmana Date: Thu, 22 Feb 2024 14:38:30 +0530 Subject: [PATCH 20/37] Add testing for optimized volume upload When using cinder as glance backend, we have an optimization for upload volume to image operation. It was recently found that the glance location API was broken for the upload volume optimization. The current glance cinder job doesn't run the upload volume test hence the issue was not detected. This patch adds the volume action tests (that includes the upload volume test) in the glance cinder job. It also requires configuring some parameters that are set up in devstack in the depends-on patch. Depends-On: https://review.opendev.org/c/openstack/cinder/+/909513 Signed-off-by: Rajat Dhasmana Change-Id: I4918027ed641a90aafe44d815a4a3dbc1dc7ddfc (cherry picked from commit 787ca3db096fca034fb070fc77a2c55630bcea80) --- .zuul.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.zuul.yaml b/.zuul.yaml index 4ea001e99ad..cd30ab7f9b9 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -357,6 +357,10 @@ GLANCE_SHOW_DIRECT_URL: True GLANCE_SHOW_MULTIPLE_LOCATIONS: True CINDER_ALLOWED_DIRECT_URL_SCHEMES: cinder + CINDER_UPLOAD_OPTIMIZED: True + CINDER_UPLOAD_INTERNAL_TENANT: True + CINDER_USE_SERVICE_TOKEN: True + tempest_test_regex: '(cinder_tempest_plugin|tempest.api.volume.test_volumes_actions)' - job: name: cinder-multibackend-matrix-migration From 45ae44aab55be4a2ba21152c51592b85928a025e Mon Sep 17 00:00:00 2001 From: Rajat Dhasmana Date: Fri, 4 Jul 2025 13:59:58 +0000 Subject: [PATCH 21/37] RBD: Fix issue with managing volume with type properties Certain properties like multiattach, replication etc. were not inherited by the volume when we manage it using a volume type. This patch enables it by calling the ``_setup_volume`` method in the manage workflow to apply the volume type properties to the RBD image. Closes-Bug: #2115985 Signed-off-by: Rajat Dhasmana Change-Id: Ia58b12a48ff853c82a3fc0a9a3f8ad8a54e284f0 (cherry picked from commit dc8a59c918981cd94c9e80eab299d93079bdfff6) --- cinder/tests/unit/volume/drivers/test_rbd.py | 104 ++++++++++++++++-- cinder/volume/drivers/rbd.py | 16 ++- ...plicated-multiattach-9bc258d349e0f5a6.yaml | 7 ++ 3 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 releasenotes/notes/fix-manage-replicated-multiattach-9bc258d349e0f5a6.yaml diff --git a/cinder/tests/unit/volume/drivers/test_rbd.py b/cinder/tests/unit/volume/drivers/test_rbd.py index 4d92afdcc03..dca373f23b4 100644 --- a/cinder/tests/unit/volume/drivers/test_rbd.py +++ b/cinder/tests/unit/volume/drivers/test_rbd.py @@ -294,6 +294,18 @@ def setUp(self): self.qos_policy_b = {"read_iops_sec": "500", "write_iops_sec": "200"} + # For tests involving multiattach volume type + MULTIATTACH_FULL_FEATURES = ( + driver.RBDDriver.RBD_FEATURE_LAYERING | + driver.RBDDriver.RBD_FEATURE_EXCLUSIVE_LOCK | + driver.RBDDriver.RBD_FEATURE_OBJECT_MAP | + driver.RBDDriver.RBD_FEATURE_FAST_DIFF | + driver.RBDDriver.RBD_FEATURE_JOURNALING) + + MULTIATTACH_REDUCED_FEATURES = ( + driver.RBDDriver.RBD_FEATURE_LAYERING | + driver.RBDDriver.RBD_FEATURE_EXCLUSIVE_LOCK) + @ddt.data({'cluster_name': None, 'pool_name': 'rbd'}, {'cluster_name': 'volumes', 'pool_name': None}) @ddt.unpack @@ -737,6 +749,87 @@ def test_manage_existing_with_invalid_rbd_image(self): self.assertEqual([self.mock_rbd.ImageNotFound], RAISED_EXCEPTIONS) + @common_mocks + def test_manage_existing_replicated_type(self): + client = self.mock_client.return_value + client.__enter__.return_value = client + + self.volume_a.volume_type = fake_volume.fake_volume_type_obj( + self.context, + id=fake.VOLUME_TYPE_ID, + extra_specs={'replication_enabled': ' True'}) + + with mock.patch.object(self.driver.rbd.RBD(), 'rename') as \ + mock_rbd_image_rename: + exist_volume = 'vol-exist' + existing_ref = {'source-name': exist_volume} + mock_rbd_image_rename.return_value = 0 + res = self.driver.manage_existing(self.volume_a, existing_ref) + mock_rbd_image_rename.assert_called_with( + client.ioctx, + exist_volume, + self.volume_a.name) + self.assertEqual('enabled', res['replication_status']) + + @common_mocks + def test_manage_existing_multiattach_type(self): + client = self.mock_client.return_value + client.__enter__.return_value = client + image = self.mock_proxy.return_value.__enter__.return_value + image_features = self.MULTIATTACH_FULL_FEATURES + image.features.return_value = image_features + expected_res = { + 'provider_location': "{\"saved_features\":%s}" % image_features} + + self.volume_a.volume_type = fake_volume.fake_volume_type_obj( + self.context, + id=fake.VOLUME_TYPE_ID, + extra_specs={'multiattach': ' True'}) + + with mock.patch.object(self.driver.rbd.RBD(), 'rename') as \ + mock_rbd_image_rename: + exist_volume = 'vol-exist' + existing_ref = {'source-name': exist_volume} + mock_rbd_image_rename.return_value = 0 + res = self.driver.manage_existing(self.volume_a, existing_ref) + mock_rbd_image_rename.assert_called_with( + client.ioctx, + exist_volume, + self.volume_a.name) + self.assertEqual(expected_res, res) + + @common_mocks + def test_manage_existing_invalid_type(self): + client = self.mock_client.return_value + client.__enter__.return_value = client + # Replication and multiattach are mutually exclusive + extra_specs = { + 'replication_enabled': ' True', + 'multiattach': ' True' + } + + self.volume_a.volume_type = fake_volume.fake_volume_type_obj( + self.context, + id=fake.VOLUME_TYPE_ID, + extra_specs=extra_specs) + + with mock.patch.object(self.driver.rbd.RBD(), 'rename') as \ + mock_rbd_image_rename: + exist_volume = 'vol-exist' + existing_ref = {'source-name': exist_volume} + mock_rbd_image_rename.return_value = 0 + res = self.assertRaises( + exception.ManageExistingVolumeTypeMismatch, + self.driver.manage_existing, self.volume_a, existing_ref) + self.assertIn( + "Manage existing volume failed due to volume type mismatch", + str(res)) + self.assertIn( + "Replication and Multiattach are mutually exclusive.", + str(res)) + # Ensure rename is not called + mock_rbd_image_rename.assert_not_called() + @common_mocks @mock.patch.object(driver.RBDDriver, '_get_image_status') def test_get_manageable_volumes(self, mock_get_image_status): @@ -3439,17 +3532,6 @@ def test_multiattach_exclusions(self): self.driver.RBD_FEATURE_EXCLUSIVE_LOCK, self.driver.MULTIATTACH_EXCLUSIONS) - MULTIATTACH_FULL_FEATURES = ( - driver.RBDDriver.RBD_FEATURE_LAYERING | - driver.RBDDriver.RBD_FEATURE_EXCLUSIVE_LOCK | - driver.RBDDriver.RBD_FEATURE_OBJECT_MAP | - driver.RBDDriver.RBD_FEATURE_FAST_DIFF | - driver.RBDDriver.RBD_FEATURE_JOURNALING) - - MULTIATTACH_REDUCED_FEATURES = ( - driver.RBDDriver.RBD_FEATURE_LAYERING | - driver.RBDDriver.RBD_FEATURE_EXCLUSIVE_LOCK) - @ddt.data(MULTIATTACH_FULL_FEATURES, MULTIATTACH_REDUCED_FEATURES) @common_mocks def test_enable_multiattach(self, features): diff --git a/cinder/volume/drivers/rbd.py b/cinder/volume/drivers/rbd.py index 7c44203ac21..294278033b0 100644 --- a/cinder/volume/drivers/rbd.py +++ b/cinder/volume/drivers/rbd.py @@ -2141,8 +2141,17 @@ def extend_volume(self, volume: Volume, new_size: str) -> None: LOG.debug("Extend volume from %(old_size)s GB to %(new_size)s GB.", {'old_size': old_size, 'new_size': new_size}) + def _is_valid_type(self, volume_type): + want_replication = self._is_replicated_type(volume_type) + want_multiattach = self._is_multiattach_type(volume_type) + + if want_replication and want_multiattach: + return False + return True + def manage_existing(self, - volume: Volume, existing_ref: dict[str, str]) -> None: + volume: Volume, + existing_ref: dict[str, str]) -> dict[str, Any]: """Manages an existing image. Renames the image name to match the expected name for the volume. @@ -2154,12 +2163,17 @@ def manage_existing(self, existing_ref is a dictionary of the form: {'source-name': } """ + # check if the volume type is valid and fail fast if not + if not self._is_valid_type(volume.volume_type): + msg = _('Replication and Multiattach are mutually exclusive.') + raise exception.ManageExistingVolumeTypeMismatch(reason=msg) # Raise an exception if we didn't find a suitable rbd image. with RADOSClient(self) as client: rbd_name = existing_ref['source-name'] self.RBDProxy().rename(client.ioctx, utils.convert_str(rbd_name), volume.name) + return self._setup_volume(volume) def manage_existing_get_size(self, volume: Volume, diff --git a/releasenotes/notes/fix-manage-replicated-multiattach-9bc258d349e0f5a6.yaml b/releasenotes/notes/fix-manage-replicated-multiattach-9bc258d349e0f5a6.yaml new file mode 100644 index 00000000000..f3a6e53a99b --- /dev/null +++ b/releasenotes/notes/fix-manage-replicated-multiattach-9bc258d349e0f5a6.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + RBD `bug #2115985 + `_: Fixed + issue when managing a volume with ``multiattach`` or + ``replication_enabled`` properties in volume type. From ae31f0edfa305bedb1d47a0c22c375fc27c83087 Mon Sep 17 00:00:00 2001 From: agireesh Date: Tue, 27 May 2025 00:55:17 -0400 Subject: [PATCH 22/37] NetApp - Fixed detach issue for multi-attached volume Volume with multi-attached type can be attached to multiple instances. Added the logic for FCP/NVMe protocols to handle the removing of cinder volume from multiple instances. Closes-Bug: #2110274 Change-Id: Ibb4d71868106226b5513e4c825f769008a446727 Signed-off-by: Saikumar Pulluri Signed-off-by: agireesh (cherry picked from commit fb8349c2e0c4b9d8610651318ccfd53e07ff84e1) --- .../volume/drivers/netapp/dataontap/fakes.py | 15 ++++ .../netapp/dataontap/test_block_base.py | 89 +++++++++++++++++-- .../netapp/dataontap/test_nvme_library.py | 44 ++++++++- .../unit/volume/drivers/netapp/test_utils.py | 52 ++++++++++- .../drivers/netapp/dataontap/block_base.py | 14 ++- .../drivers/netapp/dataontap/nvme_library.py | 7 ++ cinder/volume/drivers/netapp/utils.py | 19 ++++ ...multiattached-volume-7202cecaeed5ecd0.yaml | 8 ++ 8 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 releasenotes/notes/bug-2110274-fix-detach-issue-for-multiattached-volume-7202cecaeed5ecd0.yaml diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py index 47e1bf11ae0..a05774a0980 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/fakes.py @@ -768,6 +768,21 @@ def __getitem__(self, key): test_volume.host = 'fakehost@backbackend#fakepool' test_volume.name = 'fakename' test_volume.size = SIZE +test_volume.multiattach = False + + +class test_namespace_volume(object): + + def __getitem__(self, key): + return getattr(self, key) + + +test_namespace_volume = test_namespace_volume() +test_namespace_volume.name = NAMESPACE_NAME +test_namespace_volume.size = SIZE +test_namespace_volume.id = VOLUME_ID +test_namespace_volume.host = HOST_STRING +test_namespace_volume.attach_status = DETACHED class test_snapshot(object): diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py index 81e297a077c..28abffd7d10 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_base.py @@ -19,7 +19,7 @@ # License for the specific language governing permissions and limitations # under the License. """Mock unit tests for the NetApp block storage library""" - +from concurrent.futures import ThreadPoolExecutor import copy import itertools from unittest import mock @@ -544,8 +544,8 @@ def test_terminate_connection_fc(self, mock_get_lun_attr, mock_unmap_lun, mock_get_lun_attr.return_value = {'Path': fake.LUN_PATH} mock_unmap_lun.return_value = None mock_has_luns_mapped_to_initiators.return_value = True - - target_info = self.library.terminate_connection_fc(fake.FC_VOLUME, + volume = copy.deepcopy(fake.test_volume) + target_info = self.library.terminate_connection_fc(volume, fake.FC_CONNECTOR) self.assertDictEqual(target_info, fake.FC_TARGET_INFO_EMPTY) @@ -570,12 +570,91 @@ def test_terminate_connection_fc_no_more_luns( mock_has_luns_mapped_to_initiators.return_value = False mock_build_initiator_target_map.return_value = (fake.FC_TARGET_WWPNS, fake.FC_I_T_MAP, 4) - - target_info = self.library.terminate_connection_fc(fake.FC_VOLUME, + volume = copy.deepcopy(fake.test_volume) + target_info = self.library.terminate_connection_fc(volume, fake.FC_CONNECTOR) self.assertDictEqual(target_info, fake.FC_TARGET_INFO_UNMAP) + @mock.patch.object(block_base.NetAppBlockStorageLibrary, + '_has_luns_mapped_to_initiators') + @mock.patch.object(block_base.NetAppBlockStorageLibrary, + '_unmap_lun') + @mock.patch.object(block_base.NetAppBlockStorageLibrary, + '_get_lun_attr') + def test_terminate_connection_fc_multiattach( + self, + mock_get_lun_attr, + mock_unmap_lun, + mock_has_luns_mapped_to_initiators): + + volume = copy.deepcopy(fake.test_volume) + volume.multiattach = True + volume.volume_attachment = [ + {'attach_status': fake.ATTACHED, 'attached_host': fake.HOST_NAME}, + {'attach_status': fake.ATTACHED, 'attached_host': fake.HOST_NAME}, + ] + mock_get_lun_attr.return_value = {'Path': fake.LUN_PATH} + mock_unmap_lun.return_value = None + mock_has_luns_mapped_to_initiators.return_value = True + self.library.terminate_connection_fc(volume, fake.FC_CONNECTOR) + mock_unmap_lun.assert_called_once_with(fake.LUN_PATH, + fake.FC_FORMATTED_INITIATORS) + + @mock.patch.object(block_base.NetAppBlockStorageLibrary, + '_has_luns_mapped_to_initiators') + @mock.patch.object(block_base.NetAppBlockStorageLibrary, + '_unmap_lun') + @mock.patch.object(block_base.NetAppBlockStorageLibrary, + '_get_lun_attr') + def test_terminate_connection_fc_last_attachment( + self, + mock_get_lun_attr, + mock_unmap_lun, + mock_has_luns_mapped_to_initiators): + + volume = copy.deepcopy(fake.test_volume) + volume.multiattach = True + volume.volume_attachment = [ + {'attach_status': fake.ATTACHED, 'attached_host': fake.HOST_NAME}, + ] + mock_get_lun_attr.return_value = {'Path': fake.LUN_PATH} + mock_unmap_lun.return_value = None + mock_has_luns_mapped_to_initiators.return_value = True + self.library.terminate_connection_fc(volume, fake.FC_CONNECTOR) + mock_unmap_lun.assert_called_once_with(fake.LUN_PATH, + fake.FC_FORMATTED_INITIATORS) + + @mock.patch.object(block_base.NetAppBlockStorageLibrary, + '_has_luns_mapped_to_initiators') + @mock.patch.object(block_base.NetAppBlockStorageLibrary, '_unmap_lun') + @mock.patch.object(block_base.NetAppBlockStorageLibrary, '_get_lun_attr') + def test_terminate_connection_fc_multiattach_cleanup( + self, mock_get_lun_attr, mock_unmap_lun, + mock_has_luns_mapped_to_initiators): + volume = copy.deepcopy(fake.test_volume) + volume.multiattach = True + volume.volume_attachment = [ + {'attach_status': fake.ATTACHED, 'attached_host': fake.HOST_NAME}, + {'attach_status': fake.ATTACHED, 'attached_host': fake.HOST_NAME}, + ] + connector = fake.FC_CONNECTOR + + mock_get_lun_attr.return_value = {'Path': fake.LUN_PATH} + mock_unmap_lun.return_value = None + mock_has_luns_mapped_to_initiators.return_value = True + + def terminate_connection(*args, **kwargs): + self.library.terminate_connection_fc(volume, connector) + + # Run the termination operation in parallel using ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(terminate_connection, range(2))) + + # Ensure that the LUN maps are cleaned up correctly for both + # parallel operations + self.assertEqual(mock_unmap_lun.call_count, 2) + @mock.patch.object(block_base.NetAppBlockStorageLibrary, '_get_fc_target_wwpns') def test_build_initiator_target_map_no_lookup_service( diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py index 367c974d3c9..51f3ddc877e 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py @@ -12,9 +12,10 @@ # License for the specific language governing permissions and limitations # under the License. """Mock unit tests for the NetApp block storage library""" - +from concurrent.futures import ThreadPoolExecutor import copy from unittest import mock +from unittest.mock import patch import uuid import ddt @@ -921,10 +922,45 @@ def test_terminate_connection(self, connector): self.mock_object(self.library, '_get_namespace_attr', return_value=fake.NAMESPACE_METADATA) self.mock_object(self.library, '_unmap_namespace') - - self.library.terminate_connection(fake.NAMESPACE_VOLUME, connector) + self.mock_object(na_utils, 'is_multiattach_to_host', + return_value=False) + namespace_volume = copy.deepcopy(fake.test_namespace_volume) + self.library.terminate_connection(namespace_volume, connector) self.library._get_namespace_attr.assert_called_once_with( - fake.NAMESPACE_NAME, 'metadata') + namespace_volume.name, 'metadata') host = connector['nqn'] if connector else None self.library._unmap_namespace(fake.PATH_NAMESPACE, host) + + if connector: + na_utils.is_multiattach_to_host.assert_called_once_with( + namespace_volume, connector) + + @mock.patch.object(na_utils, 'is_multiattach_to_host', + return_value=False) + def test_terminate_connection_parallel(self, + mock_is_multiattach_to_host): + def execute_terminate_connection(connector): + mock_log = patch('self.library.LOG').start() + self.library.terminate_connection(fake.NAMESPACE_VOLUME, + connector) + self.library._get_namespace_attr.assert_called_once_with( + fake.NAMESPACE_NAME, + 'metadata') + host = connector['nqn'] if connector else None + self.library._unmap_namespace.assert_called_once_with( + fake.PATH_NAMESPACE, host) + + if connector: + mock_is_multiattach_to_host.assert_called_once_with( + fake.NAMESPACE_VOLUME, connector) + else: + mock_log.debug.assert_called_with('Unmapping namespace ' + '%(name)s from all hosts.', + {'name': fake. + NAMESPACE_VOLUME['name']}) + mock_log.stop() + + connector_list = [None, {'nqn': fake.HOST_NQN}] + with ThreadPoolExecutor(max_workers=2) as executor: + executor.map(execute_terminate_connection, connector_list) diff --git a/cinder/tests/unit/volume/drivers/netapp/test_utils.py b/cinder/tests/unit/volume/drivers/netapp/test_utils.py index 3bd316c2847..61db5a80d1a 100644 --- a/cinder/tests/unit/volume/drivers/netapp/test_utils.py +++ b/cinder/tests/unit/volume/drivers/netapp/test_utils.py @@ -30,6 +30,7 @@ from cinder.tests.unit import test from cinder.tests.unit.volume.drivers.netapp.dataontap.client import ( fakes as zapi_fakes) +from cinder.tests.unit.volume.drivers.netapp.dataontap import fakes import cinder.tests.unit.volume.drivers.netapp.fakes as fake from cinder import version from cinder.volume.drivers.netapp.dataontap.client import api as netapp_api @@ -40,7 +41,6 @@ @ddt.ddt class NetAppDriverUtilsTestCase(test.TestCase): - @mock.patch.object(na_utils, 'LOG', mock.Mock()) def test_validate_instantiation_proxy(self): kwargs = {'netapp_mode': 'proxy'} @@ -840,6 +840,56 @@ def test_qos_min_feature_name(self, is_nfs): self.assertEqual('QOS_MIN_BLOCK_', na_utils.qos_min_feature_name(False, None)) + def test__is_multiattach_to_host_no_attachments(self): + volume = copy.deepcopy(fakes.test_volume) + volume.multiattach = True + volume.volume_attachment = [] + result = na_utils.is_multiattach_to_host(volume, + {'host': fakes.HOST_NAME}) + self.assertFalse(result) + + def test__is_multiattach_to_host_multiattach_disabled(self): + volume = copy.deepcopy(fakes.test_volume) + result = na_utils.is_multiattach_to_host(volume, + {'host': fakes.HOST_NAME}) + self.assertFalse(result) + + def test__is_multiattach_to_host_single_attachment(self): + volume = copy.deepcopy(fakes.test_volume) + volume.multiattach = True + volume.volume_attachment = [ + {'attach_status': fakes.ATTACHED, + 'attached_host': fakes.HOST_NAME} + ] + result = na_utils.is_multiattach_to_host(volume, fakes.FC_CONNECTOR) + self.assertFalse(result) + + def test__is_multiattach_to_host_on_same_host(self): + volume = copy.deepcopy(fakes.test_volume) + volume.multiattach = True + volume.volume_attachment = [ + {'attach_status': fakes.ATTACHED, + 'attached_host': fakes.HOST_NAME + }, + {'attach_status': fakes.ATTACHED, + 'attached_host': fakes.HOST_NAME + } + ] + result = na_utils.is_multiattach_to_host(volume, + {'host': fakes.HOST_NAME}) + self.assertTrue(result) + + def test__is_multiattach_to_host_on_different_host(self): + volume = copy.deepcopy(fakes.test_volume) + volume.multiattach = True + volume.volume_attachment = [ + {'attach_status': fakes.ATTACHED, 'attached_host': "fake_host1"}, + {'attach_status': fakes.ATTACHED, 'attached_host': "fake_host2"}, + ] + result = na_utils.is_multiattach_to_host(volume, + {'host': "fake_host1"}) + self.assertFalse(result) + class OpenStackInfoTestCase(test.TestCase): diff --git a/cinder/volume/drivers/netapp/dataontap/block_base.py b/cinder/volume/drivers/netapp/dataontap/block_base.py index 0efdabeb6b3..a0f6c67910d 100644 --- a/cinder/volume/drivers/netapp/dataontap/block_base.py +++ b/cinder/volume/drivers/netapp/dataontap/block_base.py @@ -34,6 +34,7 @@ from oslo_utils import excutils from oslo_utils import units +from cinder import coordination from cinder import exception from cinder.i18n import _ from cinder.volume.drivers.netapp.dataontap.client import api as netapp_api @@ -1089,6 +1090,7 @@ def initialize_connection_fc(self, volume, connector): return target_info + @coordination.synchronized('netapp-terminate-fc-connection-{volume.id}') def terminate_connection_fc(self, volume, connector, **kwargs): """Disallow connection from connector. @@ -1102,6 +1104,11 @@ def terminate_connection_fc(self, volume, connector, **kwargs): an empty dict for the 'data' key """ + if connector and na_utils.is_multiattach_to_host( + volume, + connector + ): + return name = volume['name'] if connector is None: initiators = [] @@ -1122,16 +1129,17 @@ def terminate_connection_fc(self, volume, connector, **kwargs): info = {'driver_volume_type': 'fibre_channel', 'data': {}} - if connector and not self._has_luns_mapped_to_initiators(initiators): + if (connector and + not self._has_luns_mapped_to_initiators(initiators)): # No more exports for this host, so tear down zone. - LOG.info("Need to remove FC Zone, building initiator target map") + LOG.info("Need to remove FC Zone, " + "building initiator target map") target_wwpns, initiator_target_map, num_paths = ( self._build_initiator_target_map(connector)) info['data'] = {'target_wwn': target_wwpns, 'initiator_target_map': initiator_target_map} - return info def _build_initiator_target_map(self, connector): diff --git a/cinder/volume/drivers/netapp/dataontap/nvme_library.py b/cinder/volume/drivers/netapp/dataontap/nvme_library.py index e8852696cbd..0de826f92e8 100644 --- a/cinder/volume/drivers/netapp/dataontap/nvme_library.py +++ b/cinder/volume/drivers/netapp/dataontap/nvme_library.py @@ -21,6 +21,7 @@ from oslo_utils import excutils from oslo_utils import units +from cinder import coordination from cinder import exception from cinder.i18n import _ from cinder.volume.drivers.netapp.dataontap.client import api as netapp_api @@ -749,6 +750,7 @@ def _unmap_namespace(self, path, host_nqn): for _path, _subsystem in namespace_unmap_list: self.client.unmap_namespace(_path, _subsystem) + @coordination.synchronized('netapp-terminate-nvme-connection-{volume.id}') def terminate_connection(self, volume, connector, **kwargs): """Driver entry point to unattach a volume from an instance. @@ -756,6 +758,11 @@ def terminate_connection(self, volume, connector, **kwargs): no longer access it. """ + if connector and na_utils.is_multiattach_to_host( + volume, + connector + ): + return name = volume['name'] host_nqn = None if connector is None: diff --git a/cinder/volume/drivers/netapp/utils.py b/cinder/volume/drivers/netapp/utils.py index 7186ce757d0..f331ffd1455 100644 --- a/cinder/volume/drivers/netapp/utils.py +++ b/cinder/volume/drivers/netapp/utils.py @@ -34,6 +34,7 @@ from cinder import context from cinder import exception from cinder.i18n import _ +from cinder.objects import fields from cinder import utils from cinder import version from cinder.volume import qos_specs @@ -561,6 +562,24 @@ def qos_min_feature_name(is_nfs, node_name): return 'QOS_MIN_BLOCK_' + node_name +def is_multiattach_to_host(volume, connector): + # With multi-attach enabled, a single volume can be attached to multiple + # instances. If multiple instances are running on the same nova host, the + # volume should remain attached to the nova host until it is detached + # from the last instance on that host. + + if not volume.multiattach or not volume.volume_attachment: + return False + attachment = [ + attach_info + for attach_info in volume.volume_attachment + if attach_info['attach_status'] == fields.VolumeAttachStatus.ATTACHED + and attach_info['attached_host'] == connector.get('host') + ] + LOG.debug('is_multiattach_to_host: attachment %s.', attachment) + return len(attachment) > 1 + + class hashabledict(dict): """A hashable dictionary that is comparable (i.e. in unit tests, etc.)""" def __hash__(self): diff --git a/releasenotes/notes/bug-2110274-fix-detach-issue-for-multiattached-volume-7202cecaeed5ecd0.yaml b/releasenotes/notes/bug-2110274-fix-detach-issue-for-multiattached-volume-7202cecaeed5ecd0.yaml new file mode 100644 index 00000000000..a517ebeefb3 --- /dev/null +++ b/releasenotes/notes/bug-2110274-fix-detach-issue-for-multiattached-volume-7202cecaeed5ecd0.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + Volumes with multi-attach type can be connected to multiple instances. + Additional logic has been implemented for FCP/NVMe protocols to handle + the removal of cinder volumes from multiple instances. For more details, + please check + `Launchpad bug #2110274 `_ From d288bc9c025bb9e7a890524ec1d866002a8861f5 Mon Sep 17 00:00:00 2001 From: Simon Dodsley Date: Tue, 26 Aug 2025 12:12:56 -0400 Subject: [PATCH 23/37] [Pure Storage] Fix volume reconnect error When trying to connect a volume to a host when it is already connected will result in an AttributeError. This patch resolves that issue. Closes-Bug: #2121464 Change-Id: Ia251f9fa808a77e046da5726bb225ac66bacfc61 Signed-off-by: Simon Dodsley (cherry picked from commit 75469ffcb05e9082c8b5e18cf446528851533921) --- cinder/volume/drivers/pure.py | 2 +- .../notes/pure_reconnect_failure-7bbc135eecc77695.yaml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/pure_reconnect_failure-7bbc135eecc77695.yaml diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py index bfd545266ff..f678998acf0 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -3632,7 +3632,7 @@ def initialize_connection(self, volume, connector): for array in target_arrays: connection = self._connect(array, pure_vol_name, connector, chap_username, chap_password) - if not connection[0].lun: + if not connection[0]['lun']: # Swallow any exception, just warn and continue LOG.warning("self._connect failed.") continue diff --git a/releasenotes/notes/pure_reconnect_failure-7bbc135eecc77695.yaml b/releasenotes/notes/pure_reconnect_failure-7bbc135eecc77695.yaml new file mode 100644 index 00000000000..f010d197e05 --- /dev/null +++ b/releasenotes/notes/pure_reconnect_failure-7bbc135eecc77695.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Pure Storage `bug #2121464 + `_: Fixed + ``AttributeError`` when trying to connect a volume to a host when + the volume is already connected to the host. From 9af33980e6e67d86f418ce7b4254162faa804be7 Mon Sep 17 00:00:00 2001 From: Simon Dodsley Date: Thu, 31 Jul 2025 09:47:49 -0400 Subject: [PATCH 24/37] [Pure Storage] Resolve EG1 arrays not reporting DRR FlashArrays using the EG1 subscription model do not report array data reduction rates, which caused a failure in reporting array stats back to cinder. This patch fixes this by forcing EG1 arrays to use the classic non-dynamic oversubscription calculations in Cinder. Closes-Bug: #2119222 Change-Id: Id6bfa990bc5f302b39807e7ea90d38007a5b327a Signed-off-by: Simon Dodsley (cherry picked from commit daf3dc245758da5ce09f91fd6f2e8ba747e5ebbb) --- cinder/volume/drivers/pure.py | 8 +++++++- releasenotes/notes/pure_eg1_dr-f08544454cfd105e.yaml | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/pure_eg1_dr-f08544454cfd105e.yaml diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py index bfd545266ff..f05afd1cb48 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -1243,7 +1243,13 @@ def _update_volume_stats(self): except AttributeError: provisioned_space = float(space_info.space. used_provisioned) / units.Gi - total_reduction = float(space_info.space.total_reduction) + # If array uses Evergreen/One model then data reduction values + # are not reported so we must force the driver to use the old + # cinder non-dynamic oversubscription calculations + try: + total_reduction = float(space_info.space.total_reduction) + except AttributeError: + total_reduction = 999 total_vols = len(volumes) total_hosts = len(hosts) total_snaps = len(snaps) diff --git a/releasenotes/notes/pure_eg1_dr-f08544454cfd105e.yaml b/releasenotes/notes/pure_eg1_dr-f08544454cfd105e.yaml new file mode 100644 index 00000000000..8b21744ac0b --- /dev/null +++ b/releasenotes/notes/pure_eg1_dr-f08544454cfd105e.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Pure Storage driver `Bug #2119222 + `_: Fixed + issue with EG1 subscription-based FlashArrays not reporting + data reduction rates. From 9aa63c2db00d2e9d5e9d0bd82e3b1f77b7109533 Mon Sep 17 00:00:00 2001 From: Simon Dodsley Date: Tue, 29 Jul 2025 21:54:32 -0400 Subject: [PATCH 25/37] [Pure Storage] Cinder manage quota breach deletion fix This patch fixes an issue where when a tenant attempts to manage a volume that exceeds their storage quota, the clenup will cause an error due to attempting to delete a non-existant volume on the backend. Closes-Bug: #2119059 Change-Id: I57acda3d94af703b52bfc923a4928b089475b476 Signed-off-by: Simon Dodsley (cherry picked from commit 3aad12a6c3201cde711a01e673da0ec8995e08bf) --- cinder/volume/drivers/pure.py | 10 ++++++---- .../pure_manage_quota_delete-dd24495e883498e7.yaml | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/pure_manage_quota_delete-dd24495e883498e7.yaml diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py index bfd545266ff..2b88ba30006 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -956,8 +956,10 @@ def delete_volume(self, volume): current_array = self._get_current_array() # Do a pass over remaining connections on the current array, if # we can try and remove any remote connections too. - hosts = list(current_array.get_connections( - volume_names=[vol_name]).items) + hosts = [] + res = current_array.get_connections(volume_names=[vol_name]) + if res.status_code == 200: + hosts = list(res.items) for host_info in range(0, len(hosts)): host_name = hosts[host_info].host.name self._disconnect_host(current_array, host_name, vol_name) @@ -966,8 +968,6 @@ def delete_volume(self, volume): res = current_array.patch_volumes(names=[vol_name], volume=flasharray.VolumePatch( destroyed=True)) - if self.configuration.pure_eradicate_on_delete: - current_array.delete_volumes(names=[vol_name]) if res.status_code == 400: with excutils.save_and_reraise_exception() as ctxt: if ERR_MSG_NOT_EXIST in res.errors[0].message: @@ -975,6 +975,8 @@ def delete_volume(self, volume): ctxt.reraise = False LOG.warning("Volume deletion failed with message: %s", res.errors[0].message) + if self.configuration.pure_eradicate_on_delete: + current_array.delete_volumes(names=[vol_name]) # Now check to see if deleting this volume left an empty volume # group. If so, we delete / eradicate the volume group if "/" in vol_name: diff --git a/releasenotes/notes/pure_manage_quota_delete-dd24495e883498e7.yaml b/releasenotes/notes/pure_manage_quota_delete-dd24495e883498e7.yaml new file mode 100644 index 00000000000..ab61fade910 --- /dev/null +++ b/releasenotes/notes/pure_manage_quota_delete-dd24495e883498e7.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Pure Storage driver `Bug #21119059 `_: Fixed + volume deletion issue when attempting to manage a new volume that exceeds the tenants + storage quota. From 9f197dad7b4243b5e11b2cb3e8a8a8095c4eb595 Mon Sep 17 00:00:00 2001 From: agireesh Date: Wed, 9 Jul 2025 11:12:41 -0400 Subject: [PATCH 26/37] NetApp - Extended Consistency group support for NVMe/TCP driver NetApp already support the consistency group for NFS/iSCSI/FCP protocol. Extend the same support for NVMe/TCP protocol. Change-Id: I9285e051743a2745e82198e2ee0534f823b6c538 Signed-off-by: agireesh Closes-Bug: #2116261 (cherry picked from commit 2b44c385bc45e515987ccd6cbcbb652294ff131e) --- .../netapp/dataontap/test_nvme_library.py | 166 +++++++++++++++++- .../drivers/netapp/dataontap/nvme_cmode.py | 24 +++ .../drivers/netapp/dataontap/nvme_library.py | 153 +++++++++++++++- ...port-for-nvme-driver-102c67c706afc25c.yaml | 7 + 4 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/bug-2116261-fix-consistency-group-support-for-nvme-driver-102c67c706afc25c.yaml diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py index 51f3ddc877e..83f3b1e2cb4 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py @@ -594,8 +594,8 @@ def test_get_pool_stats(self, cluster_credentials, expected = [{ 'pool_name': 'vola', 'QoS_support': False, - 'consistencygroup_support': False, - 'consistent_group_snapshot_enabled': False, + 'consistencygroup_support': True, + 'consistent_group_snapshot_enabled': True, 'reserved_percentage': 5, 'max_over_subscription_ratio': 10, 'multiattach': False, @@ -964,3 +964,165 @@ def execute_terminate_connection(connector): connector_list = [None, {'nqn': fake.HOST_NQN}] with ThreadPoolExecutor(max_workers=2) as executor: executor.map(execute_terminate_connection, connector_list) + + def test_create_group(self): + model_update = self.library.create_group( + fake.VOLUME_GROUP) + self.assertEqual('available', model_update['status']) + + def test_delete_group_volume_delete_failure(self): + self.mock_object(nvme_library, 'LOG') + self.mock_object(self.library, '_delete_namespace', + side_effect=Exception) + + model_update, volumes = self.library.delete_group( + fake.VOLUME_GROUP, [fake.VG_VOLUME]) + + self.assertEqual('deleted', model_update['status']) + self.assertEqual('error_deleting', volumes[0]['status']) + self.assertEqual(1, nvme_library.LOG.exception.call_count) + + def test_update_group(self): + model_update, add_volumes_update, remove_volumes_update = ( + self.library.update_group(fake.VOLUME_GROUP)) + + self.assertIsNone(model_update) + self.assertIsNone(add_volumes_update) + self.assertIsNone(remove_volumes_update) + + def test_delete_group_not_found(self): + self.mock_object(nvme_library, 'LOG') + self.mock_object(self.library, '_get_namespace_attr', + return_value=None) + + model_update, volumes = self.library.delete_group( + fake.VOLUME_GROUP, [fake.VG_VOLUME]) + + self.assertEqual(0, nvme_library.LOG.error.call_count) + self.assertEqual(0, nvme_library.LOG.info.call_count) + + self.assertEqual('deleted', model_update['status']) + self.assertEqual('deleted', volumes[0]['status']) + + def test_create_group_snapshot_raise_exception(self): + self.mock_object(volume_utils, 'is_group_a_cg_snapshot_type', + return_value=True) + + mock_extract_host = self.mock_object( + volume_utils, 'extract_host', return_value=fake.POOL_NAME) + + self.mock_object(self.client, 'create_cg_snapshot', + side_effect=netapp_api.NaApiError) + + self.assertRaises(na_utils.NetAppDriverException, + self.library.create_group_snapshot, + fake.VOLUME_GROUP, + [fake.VG_SNAPSHOT]) + + mock_extract_host.assert_called_once_with( + fake.VG_SNAPSHOT['volume']['host'], level='pool') + + def test_create_group_snapshot(self): + self.mock_object(volume_utils, 'is_group_a_cg_snapshot_type', + return_value=False) + self.mock_object(self.library, + '_get_namespace_from_table', + return_value=self.fake_namespace) + mock_clone_namespace = self.mock_object(self.library, + '_clone_namespace') + + model_update, snapshots_model_update = ( + self.library.create_group_snapshot(fake.VOLUME_GROUP, + [fake.SNAPSHOT])) + + self.assertIsNone(model_update) + self.assertIsNone(snapshots_model_update) + mock_clone_namespace.assert_called_once_with(self.fake_namespace.name, + fake.SNAPSHOT['name']) + + def test_create_consistent_group_snapshot(self): + self.mock_object(volume_utils, 'is_group_a_cg_snapshot_type', + return_value=True) + + self.mock_object(volume_utils, 'extract_host', + return_value=fake.POOL_NAME) + mock_create_cg_snapshot = self.mock_object( + self.client, 'create_cg_snapshot') + mock_clone_namespace = self.mock_object(self.library, + '_clone_namespace') + mock_wait_for_busy_snapshot = self.mock_object( + self.client, 'wait_for_busy_snapshot') + mock_delete_snapshot = self.mock_object( + self.client, 'delete_snapshot') + + model_update, snapshots_model_update = ( + self.library.create_group_snapshot(fake.VOLUME_GROUP, + [fake.VG_SNAPSHOT])) + + self.assertIsNone(model_update) + self.assertIsNone(snapshots_model_update) + + mock_create_cg_snapshot.assert_called_once_with( + set([fake.POOL_NAME]), fake.VOLUME_GROUP['id']) + mock_clone_namespace.assert_called_once_with( + fake.VG_SNAPSHOT['volume']['name'], + fake.VG_SNAPSHOT['name'], + ) + mock_wait_for_busy_snapshot.assert_called_once_with( + fake.POOL_NAME, fake.VOLUME_GROUP['id']) + mock_delete_snapshot.assert_called_once_with( + fake.POOL_NAME, fake.VOLUME_GROUP['id']) + + def test_create_group_from_src_snapshot(self): + mock_clone_source_to_destination = self.mock_object( + self.library, '_clone_source_to_destination') + + actual_return_value = self.library.create_group_from_src( + fake.VOLUME_GROUP, [fake.VOLUME], group_snapshot=fake.VG_SNAPSHOT, + snapshots=[fake.VG_VOLUME_SNAPSHOT]) + + clone_source_to_destination_args = { + 'name': fake.VG_SNAPSHOT['name'], + 'size': fake.VG_SNAPSHOT['volume_size'], + } + mock_clone_source_to_destination.assert_called_once_with( + clone_source_to_destination_args, fake.VOLUME) + expected_return_value = (None, []) + self.assertEqual(expected_return_value, actual_return_value) + + def test_create_group_from_src_group(self): + namespace_name = fake.SOURCE_VG_VOLUME['name'] + mock_namespace = nvme_library.NetAppNamespace( + namespace_name, namespace_name, '3', {'UUID': 'fake_uuid'}) + self.mock_object(self.library, '_get_namespace_from_table', + return_value=mock_namespace) + mock_clone_source_to_destination = self.mock_object( + self.library, '_clone_source_to_destination') + + actual_return_value = self.library.create_group_from_src( + fake.VOLUME_GROUP, [fake.VOLUME], + source_group=fake.SOURCE_VOLUME_GROUP, + source_vols=[fake.SOURCE_VG_VOLUME]) + + clone_source_to_destination_args = { + 'name': fake.SOURCE_VG_VOLUME['name'], + 'size': fake.SOURCE_VG_VOLUME['size'], + } + expected_return_value = (None, []) + + mock_clone_source_to_destination.assert_called_once_with( + clone_source_to_destination_args, fake.VOLUME) + self.assertEqual(expected_return_value, actual_return_value) + + def test_delete_group_snapshot(self): + mock_delete_namespace = self.mock_object(self.library, + '_delete_namespace') + + model_update, snapshots_model_update = ( + self.library.delete_group_snapshot(fake.VOLUME_GROUP, + [fake.VG_SNAPSHOT])) + + self.assertIsNone(model_update) + self.assertIsNone(snapshots_model_update) + + mock_delete_namespace.assert_called_once_with(fake.VG_SNAPSHOT['name']) diff --git a/cinder/volume/drivers/netapp/dataontap/nvme_cmode.py b/cinder/volume/drivers/netapp/dataontap/nvme_cmode.py index eaae8979013..aab749191ea 100644 --- a/cinder/volume/drivers/netapp/dataontap/nvme_cmode.py +++ b/cinder/volume/drivers/netapp/dataontap/nvme_cmode.py @@ -107,3 +107,27 @@ def terminate_connection(self, volume, connector, **kwargs): def get_pool(self, volume): return self.library.get_pool(volume) + + def create_group(self, context, group): + return self.library.create_group(group) + + def delete_group(self, context, group, volumes): + return self.library.delete_group(group, volumes) + + def update_group(self, context, group, add_volumes=None, + remove_volumes=None): + return self.library.update_group(group, add_volumes=None, + remove_volumes=None) + + def create_group_snapshot(self, context, group_snapshot, snapshots): + return self.library.create_group_snapshot(group_snapshot, snapshots) + + def delete_group_snapshot(self, context, group_snapshot, snapshots): + return self.library.delete_group_snapshot(group_snapshot, snapshots) + + def create_group_from_src(self, context, group, volumes, + group_snapshot=None, snapshots=None, + source_group=None, source_vols=None): + return self.library.create_group_from_src( + group, volumes, group_snapshot=group_snapshot, snapshots=snapshots, + source_group=source_group, source_vols=source_vols) diff --git a/cinder/volume/drivers/netapp/dataontap/nvme_library.py b/cinder/volume/drivers/netapp/dataontap/nvme_library.py index 0de826f92e8..49f05ad76b0 100644 --- a/cinder/volume/drivers/netapp/dataontap/nvme_library.py +++ b/cinder/volume/drivers/netapp/dataontap/nvme_library.py @@ -24,6 +24,7 @@ from cinder import coordination from cinder import exception from cinder.i18n import _ +from cinder.objects import fields from cinder.volume.drivers.netapp.dataontap.client import api as netapp_api from cinder.volume.drivers.netapp.dataontap.performance import perf_cmode from cinder.volume.drivers.netapp.dataontap.utils import capabilities @@ -526,8 +527,8 @@ def _get_pool_stats(self, filter_function=None, goodness_function=None): pool['QoS_support'] = False pool['multiattach'] = False pool['online_extend_support'] = False - pool['consistencygroup_support'] = False - pool['consistent_group_snapshot_enabled'] = False + pool['consistencygroup_support'] = True + pool['consistent_group_snapshot_enabled'] = True pool['reserved_percentage'] = self.reserved_percentage pool['max_over_subscription_ratio'] = ( self.max_over_subscription_ratio) @@ -776,3 +777,151 @@ def terminate_connection(self, volume, connector, **kwargs): metadata = self._get_namespace_attr(name, 'metadata') path = metadata['Path'] self._unmap_namespace(path, host_nqn) + + def create_group(self, group): + """Driver entry point for creating a generic volume group. + + ONTAP does not maintain an actual Group construct. As a result, no + communication to the backend is necessary for generic volume group + creation. + + :returns: Hard-coded model update for generic volume group model. + """ + model_update = {'status': fields.GroupStatus.AVAILABLE} + return model_update + + def delete_group(self, group, volumes): + """Driver entry point for deleting a group. + + :returns: Updated group model and list of volume models + for the volumes that were deleted. + """ + model_update = {'status': fields.GroupStatus.DELETED} + volumes_model_update = [] + for volume in volumes: + try: + self.delete_volume(volume) + volumes_model_update.append( + {'id': volume['id'], 'status': 'deleted'}) + except Exception: + volumes_model_update.append( + {'id': volume['id'], + 'status': 'error_deleting'}) + LOG.exception("Volume %(vol)s in the group could not be " + "deleted.", {'vol': volume}) + return model_update, volumes_model_update + + def update_group(self, group, add_volumes=None, remove_volumes=None): + """Driver entry point for updating a generic volume group. + + Since no actual group construct is ever created in ONTAP, it is not + necessary to update any metadata on the backend. Since this is a NO-OP, + there is guaranteed to be no change in any of the volumes' statuses. + """ + return None, None, None + + def create_group_snapshot(self, group_snapshot, snapshots): + """Creates a Cinder group snapshot object. + + The Cinder group snapshot object is created by making use of an + ephemeral ONTAP consistency group snapshot in order to provide + write-order consistency for a set of flexvol snapshots. First, a list + of the flexvols backing the given Cinder group must be gathered. An + ONTAP group-snapshot of these flexvols will create a snapshot copy of + all the Cinder volumes in the generic volume group. For each Cinder + volume in the group, it is then necessary to clone its backing + namespace from the ONTAP cg-snapshot. The naming convention used for + the clones is what indicates the clone's role as a Cinder snapshot + and its inclusion in a Cinder group. The ONTAP cg-snapshot of the + flexvols is no longer required after having cloned the namespaces + backing the Cinder volumes in the Cinder group. + + :returns: An implicit update for group snapshot and snapshots models + that is interpreted by the manager to set their models to + available. + """ + try: + if volume_utils.is_group_a_cg_snapshot_type(group_snapshot): + self._create_consistent_group_snapshot(group_snapshot, + snapshots) + else: + for snapshot in snapshots: + self._create_snapshot(snapshot) + except Exception as ex: + err_msg = (_("Create group snapshot failed (%s).") % ex) + LOG.exception(err_msg, resource=group_snapshot) + raise na_utils.NetAppDriverException(err_msg) + + return None, None + + def _create_consistent_group_snapshot(self, group_snapshot, snapshots): + flexvols = set() + for snapshot in snapshots: + flexvols.add(volume_utils.extract_host( + snapshot['volume']['host'], level='pool')) + + self.client.create_cg_snapshot(flexvols, group_snapshot['id']) + + for snapshot in snapshots: + self._clone_namespace(snapshot['volume']['name'], snapshot['name']) + + for flexvol in flexvols: + try: + self.client.wait_for_busy_snapshot( + flexvol, group_snapshot['id']) + self.client.delete_snapshot( + flexvol, group_snapshot['id']) + except exception.SnapshotIsBusy: + self.client.mark_snapshot_for_deletion( + flexvol, group_snapshot['id']) + + def delete_group_snapshot(self, group_snapshot, snapshots): + """Delete namespaces backing each snapshot in the group snapshot. + + :returns: An implicit update for snapshots models that is interpreted + by the manager to set their models to delete. + """ + for snapshot in snapshots: + self._delete_namespace(snapshot['name']) + LOG.debug("Snapshot %s deletion successful", snapshot['name']) + + return None, None + + def create_group_from_src(self, group, volumes, group_snapshot=None, + snapshots=None, source_group=None, + source_vols=None): + """Creates a group from a group snapshot or a group of cinder vols. + + :returns: An implicit update for the volumes model that is + interpreted by the manager as a successful operation. + """ + LOG.debug("VOLUMES %s ", ', '.join([vol['id'] for vol in volumes])) + volume_model_updates = [] + + if group_snapshot: + vols = zip(volumes, snapshots) + + for volume, snapshot in vols: + source = { + 'name': snapshot['name'], + 'size': snapshot['volume_size'], + } + self._clone_source_to_destination(source, volume) + '''if volume_model_update is not None: + volume_model_update['id'] = volume['id'] + volume_model_updates.append(volume_model_update)''' + + else: + vols = zip(volumes, source_vols) + + for volume, old_src_vref in vols: + src_namespace = self._get_namespace_from_table( + old_src_vref['name']) + source = {'name': src_namespace.name, + 'size': old_src_vref['size']} + self._clone_source_to_destination(source, volume) + '''if volume_model_update is not None: + volume_model_update['id'] = volume['id'] + volume_model_updates.append(volume_model_update)''' + + return None, volume_model_updates diff --git a/releasenotes/notes/bug-2116261-fix-consistency-group-support-for-nvme-driver-102c67c706afc25c.yaml b/releasenotes/notes/bug-2116261-fix-consistency-group-support-for-nvme-driver-102c67c706afc25c.yaml new file mode 100644 index 00000000000..4672d060576 --- /dev/null +++ b/releasenotes/notes/bug-2116261-fix-consistency-group-support-for-nvme-driver-102c67c706afc25c.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + NetApp Driver `bug #2116261 + `_: NetApp already + support the consistency group for NFS/iSCSI/FCP protocol. Extend + the same support for NVMe/TCP protocol. From 93d36f446755d2aa40043671cdb0d3687418a5ce Mon Sep 17 00:00:00 2001 From: raghavendrat Date: Tue, 5 Aug 2025 12:34:58 +0000 Subject: [PATCH 27/37] HPE 3par - skip license check for new wsapi For new wsapi version (of 2025), all licenses are enabled by default. The wsapi does not explicitly list all erstwhile licenses (like Thin Provisioning, Priority Optimization, Remote Copy, etc) in wsapi output. Thus, minor code changes required in driver code to skip license check. Closes-Bug: #2119709 Change-Id: I9db121ac8ea631ae2043461929af0ee02ca05e85 Signed-off-by: raghavendrat (cherry picked from commit 94cd0e815fa3b00e3546a1986a9ce481749353b5) --- .../unit/volume/drivers/hpe/test_hpe3par.py | 136 +++++++++++++----- cinder/volume/drivers/hpe/hpe_3par_common.py | 7 +- ...anges-for-wsapi-2025-75a9fda5d994504c.yaml | 4 + 3 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 releasenotes/notes/hpe-3par-code-changes-for-wsapi-2025-75a9fda5d994504c.yaml diff --git a/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py b/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py index b60a2d31cf1..f0e47b30c46 100644 --- a/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py +++ b/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py @@ -691,6 +691,11 @@ class HPE3PARBaseDriver(test.TestCase): 'minor': 10, 'revision': 0} + wsapi_version_2025 = {'major': 1, + 'build': 100500031, + 'minor': 15, + 'revision': 0} + wsapi_version_clone = {'major': 1, 'build': 40600052, 'minor': 10, @@ -1525,12 +1530,18 @@ def test_create_volume_replicated_peer_persistence( mock_client.assert_has_calls(expected) self.assertEqual(return_model['replication_status'], 'enabled') + # (i) wsapi version is old/default + # (ii) wsapi version is 2025, then all licenses are enabled + @ddt.data({'wsapi_version': None}, + {'wsapi_version': HPE3PARBaseDriver.wsapi_version_2025}) + @ddt.unpack @mock.patch.object(volume_types, 'get_volume_type') - def test_create_volume_dedup_compression(self, _mock_volume_types): + def test_create_volume_dedup_compression(self, _mock_volume_types, + wsapi_version): # setup_mock_client drive with default configuration # and return the mock HTTP 3PAR client - mock_client = self.setup_driver() + mock_client = self.setup_driver(wsapi_version=wsapi_version) _mock_volume_types.return_value = { 'name': 'dedup_compression', @@ -1542,21 +1553,41 @@ def test_create_volume_dedup_compression(self, _mock_volume_types): 'hpe3par:provisioning': 'dedup', 'hpe3par:compression': 'True', 'volume_type': self.volume_type_dedup_compression}} - mock_client.getStorageSystemInfo.return_value = { - 'id': self.CLIENT_ID, - 'serialNumber': '1234', - 'licenseInfo': { - 'licenses': [{'name': 'Compression'}, - {'name': 'Thin Provisioning (102400G)'}, - {'name': 'System Reporter'}] + if not wsapi_version: + mock_client.getStorageSystemInfo.return_value = { + 'id': self.CLIENT_ID, + 'serialNumber': '1234', + 'licenseInfo': { + 'licenses': [{'name': 'Compression'}, + {'name': 'Thin Provisioning (102400G)'}, + {'name': 'System Reporter'}] + } + } + else: + mock_client.getStorageSystemInfo.return_value = { + 'id': self.CLIENT_ID, + 'serialNumber': '1234', + 'licenseInfo': { + # all licenses are enabled + 'licenses': [{'name': 'FIPS Encryption'}, + {'name': 'Owned'}, + {'name': 'Software and Support SaaS'}] + } } - } with mock.patch.object(hpecommon.HPE3PARCommon, '_create_client') as mock_create_client: mock_create_client.return_value = mock_client - return_model = self.driver.create_volume( - self.volume_dedup_compression) + if not wsapi_version: + # (i) old/default + return_model = self.driver.create_volume( + self.volume_dedup_compression) + else: + # (ii) wsapi version 2025 + common = self.driver._login() + return_model = common.create_volume( + self.volume_dedup_compression) + comment = Comment({ "volume_type_name": "dedup_compression", "display_name": "Foo Volume", @@ -1565,18 +1596,24 @@ def test_create_volume_dedup_compression(self, _mock_volume_types): "volume_id": "d03338a9-9115-48a3-8dfc-35cdfcdc15a7", "qos": {}, "type": "OpenStack"}) + optional = {'comment': comment, + 'tpvv': False, + 'tdvv': True, + 'compression': True} + if not wsapi_version: + optional['snapCPG'] = HPE3PAR_CPG_SNAP expected = [ mock.call.getCPG(HPE3PAR_CPG), mock.call.getStorageSystemInfo(), mock.call.createVolume( self.VOLUME_3PAR_NAME, HPE3PAR_CPG, - 16384, { - 'comment': comment, - 'tpvv': False, - 'tdvv': True, - 'compression': True, - 'snapCPG': HPE3PAR_CPG_SNAP})] + 16384, optional)] + if wsapi_version == HPE3PARBaseDriver.wsapi_version_2025: + extras = (self.get_id_login + + self.standard_logout + + self.standard_login) + expected = extras + expected mock_client.assert_has_calls(expected) self.assertIsNone(return_model) @@ -8173,25 +8210,43 @@ def test_get_3par_host_from_wwn_iqn(self): iqns=None) self.assertIsNotNone(hostname) - def test_get_volume_stats1(self): + # (i) wsapi version is old/default + # (ii) wsapi version is 2025, then all licenses are enabled + @ddt.data({'wsapi_version': None}, + {'wsapi_version': HPE3PARBaseDriver.wsapi_version_2025}) + @ddt.unpack + def test_get_volume_stats1(self, wsapi_version): # setup_mock_client drive with the configuration # and return the mock HTTP 3PAR client config = self.setup_configuration() config.filter_function = FILTER_FUNCTION config.goodness_function = GOODNESS_FUNCTION - mock_client = self.setup_driver(config=config) + mock_client = self.setup_driver(config=config, + wsapi_version=wsapi_version) mock_client.getCPG.return_value = self.cpgs[0] - # Purposely left out the Priority Optimization license in - # getStorageSystemInfo to test that QoS_support returns False. - mock_client.getStorageSystemInfo.return_value = { - 'id': self.CLIENT_ID, - 'serialNumber': '1234', - 'licenseInfo': { - 'licenses': [{'name': 'Remote Copy'}, - {'name': 'Thin Provisioning (102400G)'}, - {'name': 'System Reporter'}] + if not wsapi_version: + # Purposely left out the Priority Optimization license in + # getStorageSystemInfo to test that QoS_support returns False. + mock_client.getStorageSystemInfo.return_value = { + 'id': self.CLIENT_ID, + 'serialNumber': '1234', + 'licenseInfo': { + 'licenses': [{'name': 'Remote Copy'}, + {'name': 'Thin Provisioning (102400G)'}, + {'name': 'System Reporter'}] + } + } + else: + mock_client.getStorageSystemInfo.return_value = { + 'id': self.CLIENT_ID, + 'serialNumber': '1234', + 'licenseInfo': { + # all licenses are enabled + 'licenses': [{'name': 'FIPS Encryption'}, + {'name': 'Owned'}, + {'name': 'Software and Support SaaS'}] + } } - } # cpg has no limit mock_client.getCPGAvailableSpace.return_value = { @@ -8221,7 +8276,12 @@ def test_get_volume_stats1(self): self.assertEqual('12345', stats['array_id']) self.assertTrue(stats['pools'][0]['thin_provisioning_support']) self.assertTrue(stats['pools'][0]['thick_provisioning_support']) - self.assertFalse(stats['pools'][0]['QoS_support']) + if not wsapi_version: + # (i) old/default + self.assertFalse(stats['pools'][0]['QoS_support']) + else: + # (ii) wsapi version 2025 + self.assertTrue(stats['pools'][0]['QoS_support']) self.assertEqual(86.0, stats['pools'][0]['provisioned_capacity_gb']) self.assertEqual(100.0, stats['pools'][0]['total_capacity_gb']) @@ -8261,7 +8321,12 @@ def test_get_volume_stats1(self): self.assertEqual('12345', stats['array_id']) self.assertTrue(stats['pools'][0]['thin_provisioning_support']) self.assertTrue(stats['pools'][0]['thick_provisioning_support']) - self.assertFalse(stats['pools'][0]['QoS_support']) + if not wsapi_version: + # (i) old/default + self.assertFalse(stats['pools'][0]['QoS_support']) + else: + # (ii) wsapi version 2025 + self.assertTrue(stats['pools'][0]['QoS_support']) self.assertEqual(86.0, stats['pools'][0]['provisioned_capacity_gb']) self.assertEqual(100.0, stats['pools'][0]['total_capacity_gb']) @@ -8294,7 +8359,12 @@ def test_get_volume_stats1(self): self.assertEqual('12345', stats['array_id']) self.assertTrue(stats['pools'][0]['thin_provisioning_support']) self.assertTrue(stats['pools'][0]['thick_provisioning_support']) - self.assertFalse(stats['pools'][0]['QoS_support']) + if not wsapi_version: + # (i) old/default + self.assertFalse(stats['pools'][0]['QoS_support']) + else: + # (ii) wsapi version 2025 + self.assertTrue(stats['pools'][0]['QoS_support']) total_capacity_gb = 200 * 1024 * const self.assertEqual(total_capacity_gb, stats['pools'][0]['total_capacity_gb']) diff --git a/cinder/volume/drivers/hpe/hpe_3par_common.py b/cinder/volume/drivers/hpe/hpe_3par_common.py index 6d58af7e3bb..8bc84840ac5 100644 --- a/cinder/volume/drivers/hpe/hpe_3par_common.py +++ b/cinder/volume/drivers/hpe/hpe_3par_common.py @@ -81,6 +81,7 @@ SRSTATLD_API_VERSION = 30201200 REMOTE_COPY_API_VERSION = 30202290 API_VERSION_2023 = 100000000 +API_VERSION_2025 = 100500000 hpe3par_opts = [ cfg.StrOpt('hpe3par_api_url', @@ -314,11 +315,12 @@ class HPE3PARCommon(object): 4.0.24 - Fixed retype volume - thin to deco. Bug #2080927 4.0.25 - Update the calculation of free_capacity 4.0.26 - Added comment for cloned volumes. Bug #2062524 + 4.0.27 - Skip license check for new WSAPI (of 2025). Bug #2119709 """ - VERSION = "4.0.26" + VERSION = "4.0.27" stats = {} @@ -1826,6 +1828,9 @@ def _check_license_enabled(self, valid_licenses, license_to_check, capability): """Check a license against valid licenses on the array.""" if valid_licenses: + if self.API_VERSION >= API_VERSION_2025: + # with new wsapi, all licenses are enabled + return True for license in valid_licenses: if license_to_check in license.get('name'): return True diff --git a/releasenotes/notes/hpe-3par-code-changes-for-wsapi-2025-75a9fda5d994504c.yaml b/releasenotes/notes/hpe-3par-code-changes-for-wsapi-2025-75a9fda5d994504c.yaml new file mode 100644 index 00000000000..5acd1b08f50 --- /dev/null +++ b/releasenotes/notes/hpe-3par-code-changes-for-wsapi-2025-75a9fda5d994504c.yaml @@ -0,0 +1,4 @@ +fixes: + - | + HPE 3PAR driver `Bug #2119709 `_: + Fixed: skip license check to work with new wsapi (of 2025). From b1a0af2969be2d8e3c640033d7a2c2db6fd42142 Mon Sep 17 00:00:00 2001 From: Saikumar Pulluri Date: Wed, 3 Sep 2025 05:09:52 -0400 Subject: [PATCH 28/37] [NetApp] Enabling total_volumes capability support Currently, NetApp driver doesn't have a way to filter out the backends at scheduler level once maximum number of volumes is reached per pool. The total_volumes capability support is added and default function is updated for iscsi/nvme drivers to filter out the backends once the pool reaches maximum number of volumes w.r.t driver limits which is 1024. Closes-Bug: #2117263 Change-Id: I40263682af4735406d341e77ca90ee37cb361994 Signed-off-by: Saikumar Pulluri (cherry picked from commit 9c63da02a4573e51aefc9f629d3553d60518a394) --- .../dataontap/client/test_client_cmode.py | 1 + .../client/test_client_cmode_rest.py | 5 ++ .../netapp/dataontap/test_block_cmode.py | 1 + .../netapp/dataontap/test_nvme_library.py | 1 + .../drivers/netapp/dataontap/utils/fakes.py | 31 ++++++++++++ .../dataontap/utils/test_capabilities.py | 50 ++++++++++++++++++- .../drivers/netapp/dataontap/block_base.py | 3 +- .../drivers/netapp/dataontap/block_cmode.py | 5 +- .../netapp/dataontap/client/client_cmode.py | 3 +- .../dataontap/client/client_cmode_rest.py | 2 + .../drivers/netapp/dataontap/nvme_library.py | 8 +-- .../netapp/dataontap/utils/capabilities.py | 18 +++++++ ...p-iscsi-nvme-drivers-79da99111b086161.yaml | 24 +++++++++ 13 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 releasenotes/notes/bug-2117263-adding-total-volumes-capability-for-netapp-iscsi-nvme-drivers-79da99111b086161.yaml diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py index 3fe9fcad6bc..f53e26af2b8 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode.py @@ -3480,6 +3480,7 @@ def test_get_lun_sizes_by_volume(self): 'query': { 'lun-info': { 'volume': fake.NETAPP_VOLUME, + 'vserver': fake_client.VSERVER_NAME } }, 'desired-attributes': { diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode_rest.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode_rest.py index 9e5402c2f55..3ab132d74b6 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode_rest.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/client/test_client_cmode_rest.py @@ -800,6 +800,7 @@ def test_get_lun_sizes_by_volume(self): volume_name = fake_client.VOLUME_NAME query = { 'location.volume.name': volume_name, + 'svm.name': fake_client.VSERVER_NAME, 'fields': 'space.size,name' } response = fake_client.LUN_GET_ITER_REST @@ -823,8 +824,10 @@ def test_get_lun_sizes_by_volume(self): def test_get_lun_sizes_by_volume_no_records(self): volume_name = fake_client.VOLUME_NAME + vserver = fake_client.VSERVER_NAME query = { 'location.volume.name': volume_name, + 'svm.name': vserver, 'fields': 'space.size,name' } response = fake_client.NO_RECORDS_RESPONSE_REST @@ -4022,6 +4025,7 @@ def test_get_namespace_sizes_by_volume(self): fake_query = { 'location.volume.name': 'fake_volume', + 'svm.name': fake_client.VSERVER_NAME, 'fields': 'space.size,name' } @@ -4049,6 +4053,7 @@ def test_get_namespace_sizes_by_volume_no_response(self): fake_query = { 'location.volume.name': 'fake_volume', + 'svm.name': fake_client.VSERVER_NAME, 'fields': 'space.size,name' } diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py index 90851da7cbf..f3caf432c2d 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_block_cmode.py @@ -499,6 +499,7 @@ def test_get_pool_stats(self, replication_backends, cluster_credentials, 'replication_enabled': False, 'online_extend_support': True, 'netapp_is_flexgroup': 'false', + 'total_volumes': 2, }] if report_provisioned_capacity: expected[0].update({'provisioned_capacity_gb': 5.0}) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py index 51f3ddc877e..e510a6223c4 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nvme_library.py @@ -617,6 +617,7 @@ def test_get_pool_stats(self, cluster_credentials, 'netapp_disk_type': 'SSD', 'online_extend_support': False, 'netapp_is_flexgroup': 'false', + 'total_volumes': 2, }] if report_provisioned_capacity: expected[0].update({'provisioned_capacity_gb': 5.0}) diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py index cd4caae7c90..c050f03877a 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py @@ -110,6 +110,37 @@ }, } +SSC_VOLUME_COUNT_INFO = { + 'volume1': { + 'total_volumes': 3, + }, + 'volume2': { + 'total_volumes': 2, + }, +} + +SSC_LUNS_BY_SIZES = [ + { + 'path': '/vol/volume-ae947c9b-2392-4956-b373-aaac4521f37e', + 'size': 5368709120.0 + }, + { + 'path': '/vol/snapshot-527eedad-a431-483d-b0ca-18995dd65b66', + 'size': 1073741824.0 + } +] + +SSC_NAMESPACES_BY_SIZES = [ + { + 'path': '/vol/namespace-ae947c9b-2392-4956-b373-aaac4521f37e', + 'size': 5379821234.0 + }, + { + 'path': '/vol/namespace-527eedad-a431-483d-b0ca-18995dd65b66', + 'size': 4673741874.0 + } +] + SSC_MIRROR_INFO = { 'volume1': { 'netapp_mirrored': 'false', diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py index 520bd115c60..816a4749829 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py @@ -38,6 +38,8 @@ def setUp(self): self.ssc_library = capabilities.CapabilitiesLibrary( 'iSCSI', fake.SSC_VSERVER, self.zapi_client, self.configuration) self.ssc_library.ssc = fake.SSC + self.ssc_library_nvme = capabilities.CapabilitiesLibrary( + 'NVMe', fake.SSC_VSERVER, self.zapi_client, self.configuration) def get_config_cmode(self): config = na_fakes.create_configuration_cmode() @@ -88,7 +90,8 @@ def test_is_qos_min_supported_not_found(self): self.assertFalse(result) - def test_update_ssc(self): + @ddt.data('nfs', 'iscsi') + def test_update_ssc(self, protocol): mock_get_ssc_flexvol_info = self.mock_object( self.ssc_library, '_get_ssc_flexvol_info', @@ -114,6 +117,15 @@ def test_update_ssc(self): self.ssc_library, '_get_ssc_qos_min_info', side_effect=[fake.SSC_QOS_MIN_INFO['volume1'], fake.SSC_QOS_MIN_INFO['volume2']]) + if protocol != 'nfs': + mock_get_ssc_volume_count_info = self.mock_object( + self.ssc_library, '_get_ssc_volume_count_info', + side_effect=[fake.SSC_QOS_MIN_INFO['volume1'], + fake.SSC_QOS_MIN_INFO['volume2']]) + else: + mock_get_ssc_volume_count_info = self.mock_object( + self.ssc_library, '_get_ssc_volume_count_info', + side_effect=None) ordered_ssc = collections.OrderedDict() ordered_ssc['volume1'] = fake.SSC_VOLUME_MAP['volume1'] @@ -121,6 +133,13 @@ def test_update_ssc(self): result = self.ssc_library.update_ssc(ordered_ssc) + if protocol != 'nfs': + mock_get_ssc_volume_count_info.assert_has_calls([ + mock.call('volume1'), mock.call('volume2')]) + else: + self.ssc_library._get_ssc_volume_count_info(fake.SSC_VOLUMES[0]).\ + assert_not_called() + self.assertIsNone(result) self.assertEqual(fake.SSC, self.ssc_library.ssc) mock_get_ssc_flexvol_info.assert_has_calls([ @@ -543,6 +562,35 @@ def test_get_ssc_qos_min_info_flexgroup(self, qos_min_support): self.zapi_client.is_qos_min_supported.assert_called_once_with(False, 'node') + @ddt.data('iscsi', 'fc', 'nvme') + def test_get_ssc_volume_count_info(self, protocol): + + self.ssc_library = self.ssc_library_nvme if protocol == 'nvme' else \ + self.ssc_library + + self.mock_object(self.ssc_library.zapi_client, + 'get_namespace_sizes_by_volume', + return_value=fake.SSC_NAMESPACES_BY_SIZES) + + self.mock_object(self.ssc_library.zapi_client, + 'get_lun_sizes_by_volume', + return_value=fake.SSC_LUNS_BY_SIZES) + + result = self.ssc_library._get_ssc_volume_count_info( + fake_client.VOLUME_NAMES[0]) + + expected = {'total_volumes': 2} + self.assertEqual(expected, result) + + if protocol != 'nvme': + self.zapi_client.get_lun_sizes_by_volume.\ + assert_called_once_with(fake_client.VOLUME_NAMES[0]) + self.zapi_client.get_namespace_sizes_by_volume.assert_not_called() + else: + self.zapi_client.get_namespace_sizes_by_volume.\ + assert_called_once_with(fake_client.VOLUME_NAMES[0]) + self.zapi_client.get_lun_sizes_by_volume.assert_not_called() + @ddt.data(True, False) def test_is_flexgroup(self, is_fg): pool_name = 'fake_pool' diff --git a/cinder/volume/drivers/netapp/dataontap/block_base.py b/cinder/volume/drivers/netapp/dataontap/block_base.py index a0f6c67910d..fa5cf367e98 100644 --- a/cinder/volume/drivers/netapp/dataontap/block_base.py +++ b/cinder/volume/drivers/netapp/dataontap/block_base.py @@ -87,7 +87,8 @@ class NetAppBlockStorageLibrary( 'xen', 'hyper_v'] DEFAULT_LUN_OS = 'linux' DEFAULT_HOST_TYPE = 'linux' - DEFAULT_FILTER_FUNCTION = 'capabilities.utilization < 70' + DEFAULT_FILTER_FUNCTION = ('capabilities.utilization < 70 and ' + 'capabilities.total_volumes < 1024') DEFAULT_GOODNESS_FUNCTION = '100 - capabilities.utilization' def __init__(self, driver_name, driver_protocol, **kwargs): diff --git a/cinder/volume/drivers/netapp/dataontap/block_cmode.py b/cinder/volume/drivers/netapp/dataontap/block_cmode.py index fb3e67ca070..879d4274ecc 100644 --- a/cinder/volume/drivers/netapp/dataontap/block_cmode.py +++ b/cinder/volume/drivers/netapp/dataontap/block_cmode.py @@ -338,9 +338,10 @@ def _get_pool_stats(self, filter_function=None, goodness_function=None): size_available_gb = capacity['size-available'] / units.Gi pool['free_capacity_gb'] = na_utils.round_down(size_available_gb) + luns = self.zapi_client.get_lun_sizes_by_volume( + ssc_vol_name) + pool['total_volumes'] = len(luns) if self.configuration.netapp_driver_reports_provisioned_capacity: - luns = self.zapi_client.get_lun_sizes_by_volume( - ssc_vol_name) provisioned_cap = 0 for lun in luns: lun_name = lun['path'].split('/')[-1] diff --git a/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py b/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py index 715ad7ca4a0..1c301c5a30c 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py +++ b/cinder/volume/drivers/netapp/dataontap/client/client_cmode.py @@ -441,7 +441,8 @@ def get_lun_sizes_by_volume(self, volume_name): api_args = { 'query': { 'lun-info': { - 'volume': volume_name + 'volume': volume_name, + 'vserver': self.vserver } }, 'desired-attributes': { diff --git a/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py b/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py index edd6300308d..776329e4c19 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py +++ b/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py @@ -768,6 +768,7 @@ def get_lun_sizes_by_volume(self, volume_name): query = { 'location.volume.name': volume_name, + 'svm.name': self.vserver, 'fields': 'space.size,name' } @@ -2770,6 +2771,7 @@ def get_namespace_sizes_by_volume(self, volume_name): query = { 'location.volume.name': volume_name, + 'svm.name': self.vserver, 'fields': 'space.size,name' } response = self.send_request('/storage/namespaces', 'get', query=query) diff --git a/cinder/volume/drivers/netapp/dataontap/nvme_library.py b/cinder/volume/drivers/netapp/dataontap/nvme_library.py index 0de826f92e8..90c377aed3f 100644 --- a/cinder/volume/drivers/netapp/dataontap/nvme_library.py +++ b/cinder/volume/drivers/netapp/dataontap/nvme_library.py @@ -72,7 +72,8 @@ class NetAppNVMeStorageLibrary( ALLOWED_SUBSYSTEM_HOST_TYPES = ['aix', 'linux', 'vmware', 'windows'] DEFAULT_NAMESPACE_OS = 'linux' DEFAULT_HOST_TYPE = 'linux' - DEFAULT_FILTER_FUNCTION = 'capabilities.utilization < 70' + DEFAULT_FILTER_FUNCTION = 'capabilities.utilization < 70 and ' \ + 'capabilities.total_volumes < 1024' DEFAULT_GOODNESS_FUNCTION = '100 - capabilities.utilization' REQUIRED_CMODE_FLAGS = ['netapp_vserver'] NVME_PORT = 4420 @@ -542,9 +543,10 @@ def _get_pool_stats(self, filter_function=None, goodness_function=None): size_available_gb = capacity['size-available'] / units.Gi pool['free_capacity_gb'] = na_utils.round_down(size_available_gb) + namespaces = self.client.get_namespace_sizes_by_volume( + ssc_vol_name) + pool['total_volumes'] = len(namespaces) if self.configuration.netapp_driver_reports_provisioned_capacity: - namespaces = self.client.get_namespace_sizes_by_volume( - ssc_vol_name) provisioned_cap = 0 for namespace in namespaces: namespace_name = namespace['path'].split('/')[-1] diff --git a/cinder/volume/drivers/netapp/dataontap/utils/capabilities.py b/cinder/volume/drivers/netapp/dataontap/utils/capabilities.py index 00384327729..dd29b9ab37e 100644 --- a/cinder/volume/drivers/netapp/dataontap/utils/capabilities.py +++ b/cinder/volume/drivers/netapp/dataontap/utils/capabilities.py @@ -115,6 +115,9 @@ def update_ssc(self, flexvol_map): ssc_volume.update(self._get_ssc_qos_min_info(node_name)) + if self.protocol.casefold() != 'nfs': + ssc_volume.update + (self._get_ssc_volume_count_info(flexvol_name)) ssc[flexvol_name] = ssc_volume self.ssc = ssc @@ -256,6 +259,21 @@ def _get_ssc_aggregate_info(self, aggregate_name, is_flexgroup=False): 'netapp_node_name': node_name, } + def _get_ssc_volume_count_info(self, flexvol_name): + """Gather volume count info and recast into SSC-style volume stats.""" + + if self.protocol.casefold() == 'nvme': + namespaces = self.zapi_client.get_namespace_sizes_by_volume( + flexvol_name) + volume_count = len(namespaces) + else: + luns = self.zapi_client.get_lun_sizes_by_volume(flexvol_name) + volume_count = len(luns) + + return { + 'total_volumes': volume_count, + } + def get_matching_flexvols_for_extra_specs(self, extra_specs): """Return a list of flexvol names that match a set of extra specs.""" diff --git a/releasenotes/notes/bug-2117263-adding-total-volumes-capability-for-netapp-iscsi-nvme-drivers-79da99111b086161.yaml b/releasenotes/notes/bug-2117263-adding-total-volumes-capability-for-netapp-iscsi-nvme-drivers-79da99111b086161.yaml new file mode 100644 index 00000000000..72086a1e423 --- /dev/null +++ b/releasenotes/notes/bug-2117263-adding-total-volumes-capability-for-netapp-iscsi-nvme-drivers-79da99111b086161.yaml @@ -0,0 +1,24 @@ +--- +fixes: + - | + NetApp driver `bug #2117263 + `_: Fixed + the issue where the driver does not account for storage limits when + provisioning volumes. +features: + - | + The NetApp driver now supports the capability "total_volumes" and the + default filter function is updated to filter the backends once the pool + reaches maximum number of volumes which is 1024 and is due to the + limitations from ONTAP FlexVolume. + + The "total_volumes" can be used in netapp driver backend stanza to restrict + the number of volumes per pool, like in below example we are restricting + maximum number of volumes per a pool to 10. + Example: filter_function="capabilities.total_volumes < 10" + + Note: The admin needs to configure the scheduler_default_filters to include + the DriverFilter as well under [DEFAULT] stanza as part of cinder.conf, + please refer [1] for the default filter list. + + [1] https://docs.openstack.org/cinder/latest/configuration/block-storage/samples/cinder.conf.html From 8096fef8210ecc32fb5c19044d1137a25bcb0bb4 Mon Sep 17 00:00:00 2001 From: Saikumar Pulluri Date: Thu, 7 Aug 2025 04:36:52 -0400 Subject: [PATCH 29/37] [NetApp-ZAPI] Enabling snapshot creation for flexgroup pool Currently, we don't have provision to create a snapshot for flexgroup pool through ZAPI, with this change we will have the support for the same. So after these changes, this is how it looks. For a FlexGroup pool, the ZAPI operation uses the NFS generic driver. When it comes to REST, if the ONTAP version is below 9.14, the operation depends on the NFS generic driver. However, for ONTAP versions 9.14 and above, it relies on the ONTAP file clone API. Closes-Bug: #2119644 Change-Id: Iec73a2fd95716e30f18315759328423076d7c824 Signed-off-by: Saikumar Pulluri (cherry picked from commit 2322ceb1999ad794f5af06bfd7fc8f0cb0fc829a) --- .../drivers/netapp/dataontap/test_nfs_base.py | 15 +++++++++++---- .../netapp/dataontap/client/client_cmode_rest.py | 6 ++++-- .../volume/drivers/netapp/dataontap/nfs_base.py | 10 +++++++--- ...-for-flexgroup-pool-zapi-4a6af85888a99a02.yaml | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 releasenotes/notes/bug-2119644-enable-snapshot-creation-for-flexgroup-pool-zapi-4a6af85888a99a02.yaml diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nfs_base.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nfs_base.py index fbe44f0df6d..a267d6cdf47 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nfs_base.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/test_nfs_base.py @@ -367,12 +367,17 @@ def test_do_qos_for_volume(self): fake.NFS_VOLUME, fake.EXTRA_SPECS) - @ddt.data(True, False) - def test_create_snapshot(self, is_flexgroup): + @ddt.data((True, False), + (False, True), + (True, True), + (False, False)) + @ddt.unpack + def test_create_snapshot(self, is_flexgroup, + is_flexgroup_clone_file_supported): self.mock_object(self.driver, '_is_flexgroup', return_value=is_flexgroup) self.mock_object(self.driver, '_is_flexgroup_clone_file_supported', - return_value=not is_flexgroup) + return_value=is_flexgroup_clone_file_supported) mock_clone_backing_file_for_volume = self.mock_object( self.driver, '_clone_backing_file_for_volume') mock_snap_flexgroup = self.mock_object( @@ -380,7 +385,9 @@ def test_create_snapshot(self, is_flexgroup): self.driver.create_snapshot(fake.SNAPSHOT) - if is_flexgroup: + if (is_flexgroup and (self.driver.configuration.safe_get + ('netapp_use_legacy_client') or + not is_flexgroup_clone_file_supported)): mock_snap_flexgroup.assert_called_once_with(fake.SNAPSHOT) mock_clone_backing_file_for_volume.assert_not_called() else: diff --git a/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py b/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py index edd6300308d..6b5f09f654a 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py +++ b/cinder/volume/drivers/netapp/dataontap/client/client_cmode_rest.py @@ -140,7 +140,7 @@ def _init_features(self): ontap_9_5 = ontap_version >= (9, 5) ontap_9_6 = ontap_version >= (9, 6) ontap_9_8 = ontap_version >= (9, 8) - ontap_9_9 = ontap_version >= (9, 9) + ontap_9_14 = ontap_version >= (9, 14) nodes_info = self._get_cluster_nodes_info() for node in nodes_info: @@ -175,8 +175,10 @@ def _init_features(self): self.features.add_feature('CLUSTER_PEER_POLICY', supported=ontap_9_0) self.features.add_feature('FLEXVOL_ENCRYPTION', supported=ontap_9_0) self.features.add_feature('FLEXGROUP', supported=ontap_9_8) + # Flex group file clone is supported for ONTAP 9.14 and above versions + # so updating this from 9.9 to 9.14. self.features.add_feature('FLEXGROUP_CLONE_FILE', - supported=ontap_9_9) + supported=ontap_9_14) self.features.add_feature('ADAPTIVE_QOS', supported=ontap_9_4) self.features.add_feature('ADAPTIVE_QOS_BLOCK_SIZE', diff --git a/cinder/volume/drivers/netapp/dataontap/nfs_base.py b/cinder/volume/drivers/netapp/dataontap/nfs_base.py index 2d3d59f0efd..3a80826f702 100644 --- a/cinder/volume/drivers/netapp/dataontap/nfs_base.py +++ b/cinder/volume/drivers/netapp/dataontap/nfs_base.py @@ -319,11 +319,15 @@ def _get_volume_model_update(self, volume): def create_snapshot(self, snapshot): """Creates a snapshot. - For a FlexGroup pool, the operation relies on the NFS generic driver - because the ONTAP clone file is not supported by FlexGroup yet. + For a FlexGroup pool, the ZAPI operation uses the NFS generic + driver. When it comes to REST, if the ONTAP version is below + 9.14, the operation depends on the NFS generic driver. However, + for ONTAP versions 9.14 and above, it relies on the ONTAP file + clone API. """ if (self._is_flexgroup(vol_id=snapshot['volume_id']) and - not self._is_flexgroup_clone_file_supported()): + (self.configuration.safe_get('netapp_use_legacy_client') or + not self._is_flexgroup_clone_file_supported())): self._create_snapshot_for_flexgroup(snapshot) else: self._clone_backing_file_for_volume(snapshot['volume_name'], diff --git a/releasenotes/notes/bug-2119644-enable-snapshot-creation-for-flexgroup-pool-zapi-4a6af85888a99a02.yaml b/releasenotes/notes/bug-2119644-enable-snapshot-creation-for-flexgroup-pool-zapi-4a6af85888a99a02.yaml new file mode 100644 index 00000000000..93daa31456c --- /dev/null +++ b/releasenotes/notes/bug-2119644-enable-snapshot-creation-for-flexgroup-pool-zapi-4a6af85888a99a02.yaml @@ -0,0 +1,14 @@ +--- +fixes: + - | + NetApp driver `bug #2119644 + `_: Fixed + unable to create snapshots for Cinder volume that belongs + to FlexGroup pool. +features: + - | + The NetApp driver now supports creating snapshots for flexgroup pools + through ZAPI client and it utilizes the NFS generic driver. When it + comes to REST, if the ONTAP version is below 9.14, the operation + depends on the NFS generic driver. However, for ONTAP versions 9.14 + and above, it relies on the ONTAP file clone API. From 324204e890bcdbe025c1583e15c2bf12c69fcd89 Mon Sep 17 00:00:00 2001 From: flelain Date: Wed, 11 Dec 2024 10:47:58 +0100 Subject: [PATCH 30/37] Respond with HTTP 409 on resource conflict When raised, 'cinder.exception.InvalidVolume' with reason 'Invalid volume: duplicate connectors detected on volume' or 'volume state in error' comes along with a 500 HTTP error. This change aims at returning a 409 HTTP error instead. It takes over patch https://review.opendev.org/c/openstack/cinder/+/856041, taking comments into account. Signed-off-by: David Moreau-Simard Change-Id: I22daff4c806a7c896d2a838b3e2ac0a81113b3e8 (cherry picked from commit 67ac0fca89d77c63958df0d3f2a9d13e26bef8eb) --- api-ref/source/v3/attachments.inc | 1 + cinder/api/v3/attachments.py | 4 +- cinder/exception.py | 5 + cinder/tests/unit/api/v3/test_attachments.py | 108 ++++++++++++++++++ .../unit/attachments/test_attachments_api.py | 22 ++-- cinder/volume/api.py | 4 +- ...x-500-http-error-on-resource-conflict.yaml | 8 ++ 7 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 releasenotes/notes/fix-500-http-error-on-resource-conflict.yaml diff --git a/api-ref/source/v3/attachments.inc b/api-ref/source/v3/attachments.inc index 613ab4f7bfb..825403b6878 100644 --- a/api-ref/source/v3/attachments.inc +++ b/api-ref/source/v3/attachments.inc @@ -326,6 +326,7 @@ Response codes - 400 - 404 + - 409 Request ------- diff --git a/cinder/api/v3/attachments.py b/cinder/api/v3/attachments.py index f0c4110ccb5..a0cc86ac58c 100644 --- a/cinder/api/v3/attachments.py +++ b/cinder/api/v3/attachments.py @@ -250,11 +250,11 @@ def update(self, req, id, body): self.volume_api.attachment_update(context, attachment_ref, connector)) - except exception.NotAuthorized: + except (exception.NotAuthorized, exception.Invalid): raise except exception.CinderException as ex: err_msg = ( - _("Unable to update attachment.(%s).") % ex.msg) + _("Unable to update attachment (%s).") % ex.msg) LOG.exception(err_msg) except Exception: err_msg = _("Unable to update the attachment.") diff --git a/cinder/exception.py b/cinder/exception.py index e971e1c7c02..65cf50698e8 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -220,6 +220,11 @@ class InvalidVolume(Invalid): message = _("Invalid volume: %(reason)s") +class ResourceConflict(Invalid): + message = _("Resource conflict: %(reason)s") + code = 409 + + class InvalidContentType(Invalid): message = _("Invalid content type %(content_type)s.") diff --git a/cinder/tests/unit/api/v3/test_attachments.py b/cinder/tests/unit/api/v3/test_attachments.py index 35251357a2c..b4bd7019a14 100644 --- a/cinder/tests/unit/api/v3/test_attachments.py +++ b/cinder/tests/unit/api/v3/test_attachments.py @@ -18,6 +18,7 @@ from unittest import mock import ddt +import webob from cinder.api import microversions as mv from cinder.api.v3 import attachments as v3_attachments @@ -140,6 +141,113 @@ def test_update_attachment_with_empty_connector_object(self): self.controller.update, req, self.attachment1.id, body=body) + @mock.patch.object(volume_api.API, 'attachment_update') + def test_update_attachment_not_authorized(self, mock_update): + exc = exception.NotAuthorized(reason='Operation is not authorized.') + mock_update.side_effect = exc + req = fakes.HTTPRequest.blank('/v3/%s/attachments/%s' % + (fake.PROJECT_ID, self.attachment1.id), + version=mv.NEW_ATTACH, + use_admin_context=True) + body = { + "attachment": + { + "connector": {'fake_key': 'fake_value', + 'host': 'somehost', + 'connection_info': 'a'}, + }, + } + + self.assertRaises(exception.NotAuthorized, + self.controller.update, req, + self.attachment1.id, body=body) + + @mock.patch('cinder.volume.api.API.attachment_update') + def test_update_attachment_invalid_volume_conflict(self, mock_update): + exc = exception.ResourceConflict( + reason='Duplicate connectors or improper volume status') + mock_update.side_effect = exc + + req = fakes.HTTPRequest.blank('/v3/%s/attachments/%s' % + (fake.PROJECT_ID, self.attachment1.id), + version=mv.NEW_ATTACH, + use_admin_context=True) + body = { + "attachment": + { + "connector": {'fake_key': 'fake_value', + 'host': 'somehost', + 'connection_info': 'a'}, + }, + } + + self.assertRaises(exception.ResourceConflict, + self.controller.update, req, + self.attachment1.id, body=body) + + @mock.patch.object(volume_api.API, 'attachment_update') + def test_update_attachment_generic_exception_invalid(self, mock_update): + exc = exception.Invalid(reason='Invalid class generic Exception') + mock_update.side_effect = exc + req = fakes.HTTPRequest.blank('/v3/%s/attachments/%s' % + (fake.PROJECT_ID, self.attachment1.id), + version=mv.NEW_ATTACH, + use_admin_context=True) + body = { + "attachment": + { + "connector": {'fake_key': 'fake_value', + 'host': 'somehost', + 'connection_info': 'a'}, + }, + } + + self.assertRaises(exception.Invalid, + self.controller.update, req, + self.attachment1.id, body=body) + + @mock.patch.object(volume_api.API, 'attachment_update') + def test_update_attachment_cinder_exception(self, mock_update): + exc = exception.CinderException(reason='Generic Cinder Exception') + mock_update.side_effect = exc + req = fakes.HTTPRequest.blank('/v3/%s/attachments/%s' % + (fake.PROJECT_ID, self.attachment1.id), + version=mv.NEW_ATTACH, + use_admin_context=True) + body = { + "attachment": + { + "connector": {'fake_key': 'fake_value', + 'host': 'somehost', + 'connection_info': 'a'}, + }, + } + + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.update, req, + self.attachment1.id, body=body) + + @mock.patch.object(volume_api.API, 'attachment_update') + def test_update_attachment_all_other_exceptions(self, mock_update): + exc = Exception('The most generic Exception') + mock_update.side_effect = exc + req = fakes.HTTPRequest.blank('/v3/%s/attachments/%s' % + (fake.PROJECT_ID, self.attachment1.id), + version=mv.NEW_ATTACH, + use_admin_context=True) + body = { + "attachment": + { + "connector": {'fake_key': 'fake_value', + 'host': 'somehost', + 'connection_info': 'a'}, + }, + } + + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.update, req, + self.attachment1.id, body=body) + @ddt.data(mv.get_prior_version(mv.RESOURCE_FILTER), mv.RESOURCE_FILTER, mv.LIKE_FILTER) @mock.patch('cinder.api.common.reject_invalid_filters') diff --git a/cinder/tests/unit/attachments/test_attachments_api.py b/cinder/tests/unit/attachments/test_attachments_api.py index df5c0d8cfc6..beadb7b653c 100644 --- a/cinder/tests/unit/attachments/test_attachments_api.py +++ b/cinder/tests/unit/attachments/test_attachments_api.py @@ -302,11 +302,11 @@ def test_attachment_update_volume_in_error_state(self): vref.save() connector = {'fake': 'connector', 'host': 'somehost'} - self.assertRaises(exception.InvalidVolume, - self.volume_api.attachment_update, - self.context, - aref, - connector) + caught_exc = self.assertRaises( + exception.ResourceConflict, + self.volume_api.attachment_update, + self.context, aref, connector) + self.assertEqual(409, caught_exc.code) @mock.patch('cinder.db.sqlalchemy.api.volume_attachment_update', return_value={}) @@ -342,11 +342,13 @@ def test_attachment_update_duplicate(self, mock_va_update, mock_db_upd): with mock.patch('cinder.objects.Volume.get_by_id', return_value=vref): with mock.patch.object(self.volume_api.volume_rpcapi, 'attachment_update') as m_au: - self.assertRaises(exception.InvalidVolume, - self.volume_api.attachment_update, - self.context, - vref.volume_attachment[1], - connector) + caught_exc = self.assertRaises( + exception.ResourceConflict, + self.volume_api.attachment_update, + self.context, + vref.volume_attachment[1], + connector) + self.assertEqual(409, caught_exc.code) m_au.assert_not_called() mock_va_update.assert_not_called() mock_db_upd.assert_not_called() diff --git a/cinder/volume/api.py b/cinder/volume/api.py index 00adf73425c..da6f7527703 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -2548,7 +2548,7 @@ def attachment_update(self, 'volume_id': volume_ref.id, 'volume_status': volume_ref.status} LOG.error(msg) - raise exception.InvalidVolume(reason=msg) + raise exception.ResourceConflict(reason=msg) if (len(volume_ref.volume_attachment) > 1 and not (volume_ref.multiattach or @@ -2570,7 +2570,7 @@ def attachment_update(self, msg = _('duplicate connectors detected on volume ' '%(vol)s') % {'vol': volume_ref.id} - raise exception.InvalidVolume(reason=msg) + raise exception.ResourceConflict(reason=msg) connection_info = ( self.volume_rpcapi.attachment_update(ctxt, diff --git a/releasenotes/notes/fix-500-http-error-on-resource-conflict.yaml b/releasenotes/notes/fix-500-http-error-on-resource-conflict.yaml new file mode 100644 index 00000000000..bf87b1af6c2 --- /dev/null +++ b/releasenotes/notes/fix-500-http-error-on-resource-conflict.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + `Bug #1907295 `_: Fixed + When a volume was not in the correct status to accept an attachment update + (e.g.: volume in error or duplicate connectors), the REST API was returning + a 500 (Internal Server Error). It now correctly returns the response code + 409 (Conflict) in this situation. From f53d61ef0a02e9072318087d98fe63aa34b4f78d Mon Sep 17 00:00:00 2001 From: Rajat Dhasmana Date: Fri, 26 Sep 2025 21:32:31 +0000 Subject: [PATCH 31/37] Show volume attachment host information for services When fetching the attachments associated with a volume, Cinder only return the host information (host_name) if the request is coming from an admin otherwise returns None. This is the correct behavior but with a little caveat. When Glance is configured to use Cinder as it's backend, it also requires the volume attachment information to find out which attachments exists for a specific host. This helps make smart decisions on wheather we want to disconnect a volume or not[1]. The problem currently is, the ``host_name`` field is not exposed to other openstack services, like glance. This patch enables services like Glance API to be able to fetch the ``host_name`` information for attachments by checking the ``service`` role in the context of the request. This helps ensures any non-admin user, apart from the admin or service category shouldn't be able to view the ``host_name`` information for attachments. [1] https://review.opendev.org/c/openstack/glance_store/+/962408 Change-Id: Icea868bf6619f3ab0a00c4e72185f07e66e4a932 Signed-off-by: Rajat Dhasmana (cherry picked from commit abfb2c6061ecb7480f8c1937230e382d238e7cd9) Signed-off-by: Bence Romsics (cherry picked from commit 1d59848eb6ea960cdd52c0698c84c251b0c4b788) --- cinder/api/v2/views/volumes.py | 15 ++++++++++----- cinder/tests/unit/api/v3/test_volumes.py | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/cinder/api/v2/views/volumes.py b/cinder/api/v2/views/volumes.py index 32a9a7f05c3..ee19a96d5d1 100644 --- a/cinder/api/v2/views/volumes.py +++ b/cinder/api/v2/views/volumes.py @@ -90,7 +90,7 @@ def detail(self, request, volume): } ctxt = request.environ['cinder.context'] - attachments = self._get_attachments(volume, ctxt.is_admin) + attachments = self._get_attachments(volume, ctxt) volume_ref['volume']['attachments'] = attachments if ctxt.is_admin: @@ -113,7 +113,7 @@ def _is_volume_encrypted(self, volume): """Determine if volume is encrypted.""" return volume.get('encryption_key_id') is not None - def _get_attachments(self, volume, is_admin): + def _get_attachments(self, volume, ctxt): """Retrieve the attachments of the volume object.""" attachments = [] @@ -124,12 +124,17 @@ def _get_attachments(self, volume, is_admin): 'attachment_id': attachment.get('id'), 'volume_id': attachment.get('volume_id'), 'server_id': attachment.get('instance_uuid'), - 'host_name': attachment.get('attached_host'), + 'host_name': None, 'device': attachment.get('mountpoint'), 'attached_at': attachment.get('attach_time'), } - if not is_admin: - a['host_name'] = None + # When glance is cinder backed, we require the + # host_name to determine when to detach a multiattach + # volume. Glance always uses service credentials to + # request Cinder so we are not exposing the host value + # to end users (non-admin). + if ctxt.is_admin or 'service' in ctxt.roles: + a['host_name'] = attachment.get('attached_host') attachments.append(a) return attachments diff --git a/cinder/tests/unit/api/v3/test_volumes.py b/cinder/tests/unit/api/v3/test_volumes.py index 72045da7c11..ba379372147 100644 --- a/cinder/tests/unit/api/v3/test_volumes.py +++ b/cinder/tests/unit/api/v3/test_volumes.py @@ -1086,6 +1086,8 @@ def test_volume_revert_with_not_equal_size(self, mock_volume, {'revert': {'snapshot_id': fake_snapshot['id']}}) def test_view_get_attachments(self): + req = fakes.HTTPRequest.blank('/v3/volumes') + context = req.environ['cinder.context'] fake_volume = self._fake_create_volume() fake_volume['attach_status'] = fields.VolumeAttachStatus.ATTACHING att_time = datetime.datetime(2017, 8, 31, 21, 55, 7, @@ -1117,7 +1119,8 @@ def test_view_get_attachments(self): # get_attachments should only return attachments with the # attached status = ATTACHED - attachments = ViewBuilder()._get_attachments(fake_volume, True) + context.is_admin = True + attachments = ViewBuilder()._get_attachments(fake_volume, context) self.assertEqual(1, len(attachments)) self.assertEqual(fake.UUID3, attachments[0]['attachment_id']) @@ -1128,9 +1131,16 @@ def test_view_get_attachments(self): self.assertEqual(att_time, attachments[0]['attached_at']) # When admin context is false (non-admin), host_name will be None - attachments = ViewBuilder()._get_attachments(fake_volume, False) + context.is_admin = False + attachments = ViewBuilder()._get_attachments(fake_volume, context) self.assertIsNone(attachments[0]['host_name']) + # When the request is coming from another service (glance), + # We should be able to see 'host_name' + context.roles.append('service') + attachments = ViewBuilder()._get_attachments(fake_volume, context) + self.assertEqual('host1', attachments[0]['host_name']) + @ddt.data(('created_at=gt:', 0), ('created_at=lt:', 2)) @ddt.unpack def test_volume_index_filter_by_created_at_with_gt_and_lt(self, change, From ac1ee9bb8eba4d03d4594ee91a540a65cd9b4ad9 Mon Sep 17 00:00:00 2001 From: anthony gamboa Date: Mon, 2 Jun 2025 11:20:46 -0700 Subject: [PATCH 32/37] Hitachi: Add support for Hitachi VSP One B20 Change-Id: Ica909518b9a0e4e58ad757bc9373ffc5e52e5a14 Signed-off-by: Sa Pham (cherry picked from commit bc5591a1f9ceba64eaf5e5200b0bed60f79914ab) (cherry picked from commit 606dc17cd35e4ba0d14edbc796cfdb31d2ffc14f) --- .../hitachi/test_hitachi_hbsd_mirror_fc.py | 32 +- .../hitachi/test_hitachi_hbsd_rest_fc.py | 649 +++++++++++++++++- .../hitachi/test_hitachi_hbsd_rest_iscsi.py | 30 +- .../drivers/hpe/xp/test_hpe_xp_rest_fc.py | 21 +- .../drivers/hpe/xp/test_hpe_xp_rest_iscsi.py | 21 +- .../nec/v/test_internal_nec_rest_fc.py | 21 +- .../nec/v/test_internal_nec_rest_iscsi.py | 21 +- .../volume/drivers/nec/v/test_nec_rest_fc.py | 3 + .../drivers/nec/v/test_nec_rest_iscsi.py | 3 + cinder/volume/drivers/hitachi/hbsd_common.py | 115 ++++ cinder/volume/drivers/hitachi/hbsd_rest.py | 176 ++++- .../volume/drivers/hitachi/hbsd_rest_api.py | 23 + cinder/volume/drivers/hitachi/hbsd_utils.py | 20 +- cinder/volume/drivers/hpe/xp/hpe_xp_rest.py | 9 + cinder/volume/drivers/hpe/xp/hpe_xp_utils.py | 3 + cinder/volume/drivers/nec/v/nec_v_rest.py | 7 + .../drivers/hitachi-vsp-driver.rst | 74 +- .../notes/B20-support-8c2baf5f781efffd.yaml | 7 + 18 files changed, 1180 insertions(+), 55 deletions(-) create mode 100644 releasenotes/notes/B20-support-8c2baf5f781efffd.yaml diff --git a/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_mirror_fc.py b/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_mirror_fc.py index 43df2e611d5..b3a66e3c647 100644 --- a/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_mirror_fc.py +++ b/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_mirror_fc.py @@ -1,4 +1,5 @@ -# Copyright (C) 2022, 2024, Hitachi, Ltd. +# Copyright (C) 2020, 2024, Hitachi, Ltd. +# Copyright (C) 2025, Hitachi Vantara # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -606,6 +607,7 @@ def _setup_config(self): self.configuration.hitachi_copy_speed = 3 self.configuration.hitachi_copy_check_interval = 3 self.configuration.hitachi_async_copy_check_interval = 10 + self.configuration.hitachi_manage_drs_volumes = False self.configuration.san_login = CONFIG_MAP['user_id'] self.configuration.san_password = CONFIG_MAP['user_pass'] @@ -1018,11 +1020,13 @@ def test_delete_volume_secondary_is_invalid_ldev(self, request): @mock.patch.object(requests.Session, "request") def test_extend_volume(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.extend_volume(TEST_VOLUME[0], 256) - self.assertEqual(4, request.call_count) + self.assertEqual(6, request.call_count) @mock.patch.object(requests.Session, "request") def test_extend_volume_replication(self, request): @@ -1064,7 +1068,7 @@ def _request_side_effect( 500, ERROR_RESULT, headers={'Content-Type': 'json'}) request.side_effect = _request_side_effect self.driver.extend_volume(TEST_VOLUME[4], 256) - self.assertEqual(23, request.call_count) + self.assertEqual(27, request.call_count) @mock.patch.object(driver.FibreChannelDriver, "get_goodness_function") @mock.patch.object(driver.FibreChannelDriver, "get_filter_function") @@ -1155,6 +1159,8 @@ def test_create_cloned_volume( get_volume_type_qos_specs.return_value = {'qos_specs': None} request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] @@ -1167,7 +1173,7 @@ def test_create_cloned_volume( ret = self.driver.create_cloned_volume(TEST_VOLUME[0], TEST_VOLUME[1]) actual = {'provider_location': '1'} self.assertEqual(actual, ret) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -1220,7 +1226,7 @@ def _request_side_effect( {'pldev': 1, 'sldev': 2, 'remote-copy': hbsd_utils.MIRROR_ATTR})} self.assertEqual(actual, ret) - self.assertEqual(23, request.call_count) + self.assertEqual(25, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -1233,6 +1239,8 @@ def test_create_volume_from_snapshot( get_volume_type_qos_specs.return_value = {'qos_specs': None} request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] @@ -1246,7 +1254,7 @@ def test_create_volume_from_snapshot( TEST_VOLUME[0], TEST_SNAPSHOT[0]) actual = {'provider_location': '1'} self.assertEqual(actual, ret) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(fczm_utils, "add_fc_zone") @mock.patch.object(requests.Session, "request") @@ -1555,6 +1563,8 @@ def test_create_group_from_src_volume( get_volume_type_qos_specs.return_value = {'qos_specs': None} request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] @@ -1568,7 +1578,7 @@ def test_create_group_from_src_volume( self.ctxt, TEST_GROUP[1], [TEST_VOLUME[1]], source_group=TEST_GROUP[0], source_vols=[TEST_VOLUME[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[1]['id'], @@ -1607,7 +1617,7 @@ def test_create_group_from_src_Exception( source_group=TEST_GROUP[0], source_vols=[TEST_VOLUME[0], TEST_VOLUME[3]] ) - self.assertEqual(10, request.call_count) + self.assertEqual(11, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -1620,6 +1630,8 @@ def test_create_group_from_src_snapshot( get_volume_type_qos_specs.return_value = {'qos_specs': None} request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] @@ -1633,7 +1645,7 @@ def test_create_group_from_src_snapshot( self.ctxt, TEST_GROUP[0], [TEST_VOLUME[0]], group_snapshot=TEST_GROUP_SNAP[0], snapshots=[TEST_SNAPSHOT[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], @@ -1765,4 +1777,4 @@ def _request_side_effect( TEST_VOLUME[5]) self.assertEqual(2, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(14, request.call_count) + self.assertEqual(16, request.call_count) diff --git a/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_fc.py b/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_fc.py index 4c78d6918d1..b17f5d2b32d 100644 --- a/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_fc.py +++ b/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_fc.py @@ -1,4 +1,5 @@ # Copyright (C) 2020, 2024, Hitachi, Ltd. +# Copyright (C) 2025, Hitachi Vantara # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -17,6 +18,7 @@ import functools from unittest import mock +import ddt from oslo_config import cfg from oslo_utils import units import requests @@ -213,6 +215,17 @@ def _volume_get(context, volume_id): }, } +COMPLETED_FAILED_RESULT = { + "status": "Completed", + "state": "Failed", + "error": { + "errorCode": { + "SSB1": "1111", + "SSB2": "2222", + }, + }, +} + GET_LDEV_RESULT = { "emulationType": "OPEN-V-CVS", "blockCapacity": 2097152, @@ -295,6 +308,100 @@ def _volume_get(context, volume_id): "dataReductionMode": "disabled" } +GET_LDEV_RESULT_DRS = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 2097152, + "attributes": ["CVS", "HDP", "DRS"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "00000000000000000000000000000000", +} + +GET_LDEV_RESULT_DRS_WITH_PARENT = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 2097152, + "attributes": ["CVS", "HDP", "DRS"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "00000000000000000000000000000000", + "parentLdevId": 10, +} + +GET_LDEV_RESULT_DRS_MANAGED_PARENT = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 2097152, + "attributes": ["CVS", "HDP", "DRS"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "HBSD-VCP", +} + +GET_LDEV_RESULT_VCP_MANAGED_PARENT = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 2097152, + "attributes": ["CVS", "HDP", "DRS", "VCP"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "HBSD-VCP", + "parentLdevId": 10, +} + +GET_LDEV_RESULT_VCP_MANAGED_PARENT_LARGE = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 137438953472, + "attributes": ["CVS", "HDP", "DRS", "VCP"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "HBSD-VCP", + "parentLdevId": 10, +} + +GET_LDEV_RESULT_VCP_LARGE = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 137438953472, + "attributes": ["CVS", "HDP", "DRS", "VCP"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "00000000000000000000000000000000", + "parentLdevId": 10, +} + +GET_LDEV_RESULT_VC = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 2097152, + "attributes": ["CVS", "HDP", "DRS", "VC"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "00000000000000000000000000000000", + "parentLdevId": 10, +} + +GET_LDEV_RESULT_VCP = { + "emulationType": "OPEN-V-CVS", + "blockCapacity": 2097152, + "attributes": ["CVS", "HDP", "DRS", "VCP"], + "status": "NML", + "poolId": 30, + "dataReductionStatus": "ENABLED", + "dataReductionMode": "compression_deduplication", + "label": "00000000000000000000000000000000", + "parentLdevId": 10, +} + GET_POOL_RESULT = { "availableVolumeCapacity": 480144, "totalPoolCapacity": 507780, @@ -309,6 +416,7 @@ def _volume_get(context, volume_id): "pvolLdevId": 0, "muNumber": 1, "svolLdevId": 1, + "snapshotId": "0,1", }, ], } @@ -321,6 +429,7 @@ def _volume_get(context, volume_id): "pvolLdevId": 0, "muNumber": 1, "svolLdevId": 1, + "snapshotId": "0,1", }, ], } @@ -333,6 +442,7 @@ def _volume_get(context, volume_id): "pvolLdevId": 0, "muNumber": 1, "svolLdevId": 1, + "snapshotId": "0,1", }, ], } @@ -501,6 +611,7 @@ def json(self): return self.data +@ddt.ddt class HBSDRESTFCDriverTest(test.TestCase): """Unit test class for HBSD REST interface fibre channel module.""" @@ -555,6 +666,7 @@ def _setup_config(self): self.configuration.hitachi_copy_speed = 3 self.configuration.hitachi_copy_check_interval = 3 self.configuration.hitachi_async_copy_check_interval = 10 + self.configuration.hitachi_manage_drs_volumes = False self.configuration.hitachi_port_scheduler = False self.configuration.hitachi_group_name_format = None @@ -863,6 +975,116 @@ def test_create_volume_deduplication_compression( self.assertEqual(1, get_volume_type_qos_specs.call_count) self.assertEqual(2, request.call_count) + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_create_volume_drs( + self, get_volume_type_qos_specs, get_volume_type_extra_specs, + request): + self.override_config('hitachi_manage_drs_volumes', False, + group=conf.SHARED_CONF_GROUP) + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.return_value = FakeResponse(202, COMPLETED_SUCCEEDED_RESULT) + self.driver.common._stats = {} + self.driver.common._stats['pools'] = [ + {'location_info': {'pool_id': 30}}] + ret = self.driver.create_volume(TEST_VOLUME[3]) + args, kwargs = request.call_args_list[0] + body = kwargs['json'] + self.assertEqual(body.get('dataReductionMode'), + 'compression_deduplication') + self.assertEqual(body.get('isDataReductionSharedVolumeEnabled'), + True) + self.assertEqual('1', ret['provider_location']) + get_volume_type_extra_specs.assert_called_once_with(TEST_VOLUME[3].id) + get_volume_type_qos_specs.assert_called_once_with( + TEST_VOLUME[3].volume_type.id) + self.assertEqual(2, request.call_count) + + @ddt.data(' False', False, 'False', 'Sheep', None) + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_create_volume_drs_explicit_false_or_invalid( + self, false_drs_setting, get_volume_type_qos_specs, + get_volume_type_extra_specs, request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': false_drs_setting, + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.return_value = FakeResponse(202, COMPLETED_SUCCEEDED_RESULT) + self.driver.common._stats = {} + self.driver.common._stats['pools'] = [ + {'location_info': {'pool_id': 30}}] + self.assertRaises(exception.VolumeDriverException, + self.driver.create_volume, + TEST_VOLUME[3]) + get_volume_type_extra_specs.assert_called_once_with(TEST_VOLUME[3].id) + get_volume_type_qos_specs.assert_called_once_with( + TEST_VOLUME[3].volume_type.id) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_create_volume_drs_managed( + self, get_volume_type_qos_specs, get_volume_type_extra_specs, + request): + self.driver.common.conf.hitachi_manage_drs_volumes = True + # Inexplicably, the below does not work. + # self.override_config('hitachi_manage_drs_volumes', True, + # group=conf.SHARED_CONF_GROUP) + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_SNAPSHOTS_RESULT_PAIR), + FakeResponse(200, GET_SNAPSHOTS_RESULT_PAIR), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + self.driver.common._stats = {} + self.driver.common._stats['pools'] = [ + {'location_info': {'pool_id': 30}}] + ret = self.driver.create_volume(TEST_VOLUME[3]) + args, kwargs = request.call_args_list[0] + body = kwargs['json'] + self.assertEqual(body.get('dataReductionMode'), + 'compression_deduplication') + self.assertEqual(body.get('isDataReductionSharedVolumeEnabled'), + True) + args, kwargs = request.call_args_list[1] + body = kwargs['json'] + self.assertEqual(body.get('label'), 'HBSD-VCP') + args, kwargs = request.call_args_list[3] + body = kwargs['json'] + self.assertEqual(body.get('dataReductionMode'), + 'compression_deduplication') + self.assertEqual(body.get('isDataReductionSharedVolumeEnabled'), + True) + args, kwargs = request.call_args_list[10] + body = kwargs['json'] + self.assertEqual(body.get('label'), '00000000000000000000000000000003') + self.assertEqual('1', ret['provider_location']) + self.assertEqual(2, get_volume_type_extra_specs.call_count) + get_volume_type_qos_specs.assert_called_once_with( + TEST_VOLUME[3].volume_type.id) + self.assertEqual(11, request.call_count) + @reduce_retrying_time @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -973,13 +1195,59 @@ def test_delete_volume_is_invalid_ldev(self, request): self.driver.delete_volume(TEST_VOLUME[0]) self.assertEqual(1, request.call_count) + @mock.patch.object(requests.Session, "request") + def test_delete_volume_drs(self, request): + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + self.driver.delete_volume(TEST_VOLUME[0]) + self.assertEqual(4, request.call_count) + + @mock.patch.object(requests.Session, "request") + def test_delete_volume_drs_managed_last_vclone(self, request): + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT_DRS_MANAGED_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + self.driver.delete_volume(TEST_VOLUME[0]) + self.assertEqual(6, request.call_count) + + @mock.patch.object(requests.Session, "request") + def test_delete_volume_drs_unmanaged_last_vclone_with_parent(self, + request): + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT_DRS)] + self.driver.delete_volume(TEST_VOLUME[0]) + self.assertEqual(5, request.call_count) + + @mock.patch.object(requests.Session, "request") + def test_delete_volume_drs_managed_parent_has_more_vclones(self, request): + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT_VCP_MANAGED_PARENT)] + self.driver.delete_volume(TEST_VOLUME[0]) + self.assertEqual(5, request.call_count) + @mock.patch.object(requests.Session, "request") def test_extend_volume(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.extend_volume(TEST_VOLUME[0], 256) - self.assertEqual(3, request.call_count) + self.assertEqual(5, request.call_count) @mock.patch.object(driver.FibreChannelDriver, "get_goodness_function") @mock.patch.object(driver.FibreChannelDriver, "get_filter_function") @@ -1046,6 +1314,134 @@ def test_get_volume_stats_error( self.assertEqual(1, get_filter_function.call_count) self.assertEqual(1, get_goodness_function.call_count) + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_extend_volume_drs(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + self.driver.extend_volume(TEST_VOLUME[0], 256) + self.assertEqual(5, request.call_count) + body = request.call_args_list[4][1]['json'] + self.assertIn('enhancedExpansion', body['parameters']) + self.assertEqual(body['parameters']['enhancedExpansion'], True) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_extend_volume_drs_mngd_parent(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, + request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_VCP_MANAGED_PARENT), + FakeResponse(200, GET_LDEV_RESULT_VCP_MANAGED_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + self.driver.extend_volume(TEST_VOLUME[0], 256) + self.assertEqual(8, request.call_count) + body = request.call_args_list[5][1]['json'] + self.assertIn('enhancedExpansion', body['parameters']) + self.assertEqual(body['parameters']['enhancedExpansion'], True) + body = request.call_args_list[7][1]['json'] + self.assertIn('enhancedExpansion', body['parameters']) + self.assertEqual(body['parameters']['enhancedExpansion'], True) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_extend_volume_drs_lg_mngd_parent(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, + request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_VCP_MANAGED_PARENT_LARGE), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + self.driver.extend_volume(TEST_VOLUME[0], 256) + self.assertEqual(6, request.call_count) + body = request.call_args_list[5][1]['json'] + self.assertIn('enhancedExpansion', body['parameters']) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_extend_volume_drs_lg_unmngd_parent(self, + get_volume_type_qos_specs, + get_volume_type_extra_specs, + request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_VCP_LARGE), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + self.driver.extend_volume(TEST_VOLUME[0], 256) + self.assertEqual(6, request.call_count) + body = request.call_args_list[5][1]['json'] + self.assertIn('enhancedExpansion', body['parameters']) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_extend_volume_drs_unmngd_parent(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, + request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT), + FakeResponse(200, GET_LDEV_RESULT_VCP), + FakeResponse(200, GET_LDEV_RESULT_DRS_WITH_PARENT)] + self.assertRaises(exception.VolumeDriverException, + self.driver.extend_volume, + TEST_VOLUME[0], + 256) + self.assertEqual(6, request.call_count) + body = request.call_args_list[5][1]['json'] + self.assertIn('enhancedExpansion', body['parameters']) + @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @mock.patch.object(sqlalchemy_api, 'volume_get', side_effect=_volume_get) @@ -1128,6 +1524,8 @@ def test_create_cloned_volume(self, get_volume_type_qos_specs, request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -1139,7 +1537,7 @@ def test_create_cloned_volume(self, get_volume_type_qos_specs, self.assertEqual('1', vol['provider_location']) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -1150,6 +1548,8 @@ def test_create_volume_from_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.common._stats = {} @@ -1162,7 +1562,35 @@ def test_create_volume_from_snapshot( self.assertEqual('1', vol['provider_location']) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_create_vcloned_volume(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, request): + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_SNAPSHOTS_RESULT_PAIR), + FakeResponse(200, GET_SNAPSHOTS_RESULT_PAIR), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] + extra_specs = {"hbsd:drs": " True", + "hbsd:capacity_saving": "deduplication_compression"} + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + self.driver.common._stats = {} + self.driver.common._stats['pools'] = [ + {'location_info': {'pool_id': 30}}] + vol = self.driver.create_cloned_volume(TEST_VOLUME[0], TEST_VOLUME[1]) + self.assertEqual('1', vol['provider_location']) + self.assertEqual(1, get_volume_type_extra_specs.call_count) + self.assertEqual(1, get_volume_type_qos_specs.call_count) + self.assertEqual(9, request.call_count) + self.assertIn('virtual-clone', request.call_args_list[7][0][1]) @mock.patch.object(fczm_utils, "add_fc_zone") @mock.patch.object(requests.Session, "request") @@ -1392,6 +1820,64 @@ def test_manage_existing_get_size_name(self, request): TEST_VOLUME[0], self.test_existing_ref_name) self.assertEqual(2, request.call_count) + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_manage_existing_drs(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEVS_RESULT)] + ret = self.driver.manage_existing( + TEST_VOLUME[0], self.test_existing_ref) + self.assertEqual('1', ret['provider_location']) + self.assertEqual(1, get_volume_type_qos_specs.call_count) + self.assertEqual(3, request.call_count) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_manage_existing_vc(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT_VC), + FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEVS_RESULT)] + ret = self.driver.manage_existing( + TEST_VOLUME[0], self.test_existing_ref) + self.assertEqual('1', ret['provider_location']) + self.assertEqual(1, get_volume_type_qos_specs.call_count) + self.assertEqual(3, request.call_count) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_extra_specs') + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_manage_existing_vcp(self, get_volume_type_qos_specs, + get_volume_type_extra_specs, request): + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + get_volume_type_extra_specs.return_value = extra_specs + get_volume_type_qos_specs.return_value = {'qos_specs': None} + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT_VCP)] + self.assertRaises(exception.ManageExistingInvalidReference, + self.driver.manage_existing, + TEST_VOLUME[1], + self.test_existing_ref) + self.assertEqual(1, request.call_count) + @mock.patch.object(requests.Session, "request") def test_unmanage(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), @@ -1485,6 +1971,151 @@ def test_retype(self, get_volume_type_qos_specs, request): self.assertEqual(4, request.call_count) self.assertTrue(ret) + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_retype_drs_removed(self, get_volume_type_qos_specs, request): + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT_DRS)] + get_volume_type_qos_specs.return_value = {'qos_specs': None} + host = { + 'capabilities': { + 'location_info': { + 'pool_id': 30, + }, + }, + } + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + } + new_type = fake_volume.fake_volume_type_obj( + CTXT, id='00000000-0000-0000-0000-{0:012d}'.format(0), + extra_specs=extra_specs) + old_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + new_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + } + old_type_ref = volume_types.create(self.ctxt, 'old', old_specs) + new_type_ref = volume_types.create(self.ctxt, 'new', new_specs) + diff = volume_types.volume_types_diff(self.ctxt, old_type_ref['id'], + new_type_ref['id'])[0] + + self.assertRaises(exception.VolumeDriverException, + self.driver.retype, self.ctxt, TEST_VOLUME[0], + new_type, diff, host) + self.assertEqual(1, request.call_count) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_retype_drs_with_csv_removed(self, get_volume_type_qos_specs, + request): + request.side_effect = [ + FakeResponse(200, GET_LDEV_RESULT_DRS), + ] + get_volume_type_qos_specs.return_value = {'qos_specs': None} + host = { + 'capabilities': { + 'location_info': { + 'pool_id': 30, + }, + }, + } + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + } + new_type = fake_volume.fake_volume_type_obj( + CTXT, id='00000000-0000-0000-0000-{0:012d}'.format(0), + extra_specs=extra_specs) + old_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + new_specs = { + 'hbsd:drs': ' True', + } + old_type_ref = volume_types.create(self.ctxt, 'old', old_specs) + new_type_ref = volume_types.create(self.ctxt, 'new', new_specs) + diff = volume_types.volume_types_diff(self.ctxt, old_type_ref['id'], + new_type_ref['id'])[0] + self.assertRaises(exception.VolumeDriverException, + self.driver.retype, self.ctxt, TEST_VOLUME[0], + new_type, diff, host) + self.assertEqual(1, request.call_count) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_retype_drs_with_csv_disabled(self, get_volume_type_qos_specs, + request): + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT_DRS), + FakeResponse(200, GET_LDEV_RESULT_DRS)] + get_volume_type_qos_specs.return_value = {'qos_specs': None} + host = { + 'capabilities': { + 'location_info': { + 'pool_id': 30, + }, + }, + } + extra_specs = { + 'hbsd:capacity_saving': 'disable', + 'hbsd:drs': ' True', + } + new_type = fake_volume.fake_volume_type_obj( + CTXT, id='00000000-0000-0000-0000-{0:012d}'.format(0), + extra_specs=extra_specs) + old_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + new_specs = { + 'hbsd:capacity_saving': 'disable', + 'hbsd:drs': ' True', + } + old_type_ref = volume_types.create(self.ctxt, 'old', old_specs) + new_type_ref = volume_types.create(self.ctxt, 'new', new_specs) + diff = volume_types.volume_types_diff(self.ctxt, old_type_ref['id'], + new_type_ref['id'])[0] + ret = self.driver.retype(self.ctxt, TEST_VOLUME[0], + new_type, diff, host) + self.assertEqual(2, request.call_count) + self.assertEqual(ret, False) + + @mock.patch.object(requests.Session, "request") + @mock.patch.object(volume_types, 'get_volume_type_qos_specs') + def test_retype_drs_added(self, get_volume_type_qos_specs, request): + request.side_effect = [FakeResponse(200, GET_LDEV_RESULT)] + get_volume_type_qos_specs.return_value = {'qos_specs': None} + host = { + 'capabilities': { + 'location_info': { + 'pool_id': 30, + }, + }, + } + extra_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + new_type = fake_volume.fake_volume_type_obj( + CTXT, id='00000000-0000-0000-0000-{0:012d}'.format(0), + extra_specs=extra_specs) + old_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + } + new_specs = { + 'hbsd:capacity_saving': 'deduplication_compression', + 'hbsd:drs': ' True', + } + old_type_ref = volume_types.create(self.ctxt, 'old', old_specs) + new_type_ref = volume_types.create(self.ctxt, 'new', new_specs) + diff = volume_types.volume_types_diff(self.ctxt, old_type_ref['id'], + new_type_ref['id'])[0] + self.assertRaises(exception.VolumeDriverException, + self.driver.retype, self.ctxt, TEST_VOLUME[0], + new_type, diff, host) + self.assertEqual(1, request.call_count) + @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_qos_specs') def test_retype_qos(self, get_volume_type_qos_specs, request): @@ -1562,7 +2193,7 @@ def test_retype_migrate_qos( ret = self.driver.retype( self.ctxt, TEST_VOLUME[0], new_type, diff, host) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(16, request.call_count) + self.assertEqual(17, request.call_count) actual = (True, {'provider_location': '1'}) self.assertTupleEqual(actual, ret) @@ -1615,7 +2246,7 @@ def test_migrate_volume_diff_pool(self, get_volume_type_qos_specs, ret = self.driver.migrate_volume(self.ctxt, TEST_VOLUME[0], host) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(15, request.call_count) + self.assertEqual(16, request.call_count) actual = (True, {'provider_location': '1'}) self.assertTupleEqual(actual, ret) @@ -1668,6 +2299,8 @@ def test_create_group_from_src_volume( get_volume_type_qos_specs.return_value = {'qos_specs': None} request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] @@ -1680,7 +2313,7 @@ def test_create_group_from_src_volume( ) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[1]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) @@ -1695,6 +2328,8 @@ def test_create_group_from_src_snapshot( get_volume_type_qos_specs.return_value = {'qos_specs': None} request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] @@ -1707,7 +2342,7 @@ def test_create_group_from_src_snapshot( ) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) diff --git a/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_iscsi.py b/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_iscsi.py index 9f573a76881..db28bc058fb 100644 --- a/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_iscsi.py +++ b/cinder/tests/unit/volume/drivers/hitachi/test_hitachi_hbsd_rest_iscsi.py @@ -1,4 +1,5 @@ # Copyright (C) 2020, 2024, Hitachi, Ltd. +# Copyright (C) 2025, Hitachi Vantara # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -374,6 +375,7 @@ def _setup_config(self): self.configuration.hitachi_copy_speed = 3 self.configuration.hitachi_copy_check_interval = 3 self.configuration.hitachi_async_copy_check_interval = 10 + self.configuration.hitachi_manage_drs_volumes = False self.configuration.hitachi_port_scheduler = False self.configuration.hitachi_group_name_format = None @@ -599,10 +601,12 @@ def test_do_setup_create_hg_format_error( @mock.patch.object(requests.Session, "request") def test_extend_volume(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.extend_volume(TEST_VOLUME[0], 256) - self.assertEqual(3, request.call_count) + self.assertEqual(5, request.call_count) @mock.patch.object(driver.ISCSIDriver, "get_goodness_function") @mock.patch.object(driver.ISCSIDriver, "get_filter_function") @@ -716,6 +720,8 @@ def test_create_cloned_volume(self, get_volume_type_qos_specs, get_volume_type_extra_specs, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] @@ -728,7 +734,7 @@ def test_create_cloned_volume(self, get_volume_type_qos_specs, self.assertEqual('1', vol['provider_location']) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -739,6 +745,8 @@ def test_create_volume_from_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -751,7 +759,7 @@ def test_create_volume_from_snapshot( self.assertEqual('1', vol['provider_location']) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -763,6 +771,8 @@ def test_create_volume_from_snapshot_qos( FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] input_qos_specs = { @@ -779,7 +789,7 @@ def test_create_volume_from_snapshot_qos( self.assertEqual('1', vol['provider_location']) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(6, request.call_count) + self.assertEqual(8, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -1069,6 +1079,8 @@ def test_create_group_from_src_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.common._stats = {} @@ -1080,7 +1092,7 @@ def test_create_group_from_src_volume( ) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[1]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) @@ -1096,6 +1108,8 @@ def test_create_group_from_src_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.common._stats = {} @@ -1107,7 +1121,7 @@ def test_create_group_from_src_snapshot( ) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) @@ -1126,6 +1140,8 @@ def test_create_group_from_src_snapshot_qos( get_volume_type_extra_specs.return_value = {} request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), @@ -1139,7 +1155,7 @@ def test_create_group_from_src_snapshot_qos( ) self.assertEqual(1, get_volume_type_extra_specs.call_count) self.assertEqual(1, get_volume_type_qos_specs.call_count) - self.assertEqual(6, request.call_count) + self.assertEqual(8, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) diff --git a/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_fc.py b/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_fc.py index 8091c0a20f8..bef1960dd39 100644 --- a/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_fc.py +++ b/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_fc.py @@ -460,6 +460,7 @@ def _setup_config(self): self.configuration.hpexp_copy_speed = 3 self.configuration.hpexp_copy_check_interval = 3 self.configuration.hpexp_async_copy_check_interval = 10 + self.configuration.hpexp_manage_drs_volumes = False self.configuration.san_login = CONFIG_MAP['user_id'] self.configuration.san_password = CONFIG_MAP['user_pass'] @@ -709,10 +710,12 @@ def test_delete_volume_busy_timeout(self, request): @mock.patch.object(requests.Session, "request") def test_extend_volume(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.extend_volume(TEST_VOLUME[0], 256) - self.assertEqual(3, request.call_count) + self.assertEqual(5, request.call_count) @mock.patch.object(driver.FibreChannelDriver, "get_goodness_function") @mock.patch.object(driver.FibreChannelDriver, "get_filter_function") @@ -785,6 +788,8 @@ def test_create_cloned_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -794,7 +799,7 @@ def test_create_cloned_volume( {'location_info': {'pool_id': 30}}] vol = self.driver.create_cloned_volume(TEST_VOLUME[0], TEST_VOLUME[1]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -805,6 +810,8 @@ def test_create_volume_from_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -815,7 +822,7 @@ def test_create_volume_from_snapshot( vol = self.driver.create_volume_from_snapshot( TEST_VOLUME[0], TEST_SNAPSHOT[0]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(fczm_utils, "add_fc_zone") @mock.patch.object(requests.Session, "request") @@ -1098,6 +1105,8 @@ def test_create_group_from_src_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -1109,7 +1118,7 @@ def test_create_group_from_src_volume( self.ctxt, TEST_GROUP[1], [TEST_VOLUME[1]], source_group=TEST_GROUP[0], source_vols=[TEST_VOLUME[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[1]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) @@ -1123,6 +1132,8 @@ def test_create_group_from_src_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -1134,7 +1145,7 @@ def test_create_group_from_src_snapshot( self.ctxt, TEST_GROUP[0], [TEST_VOLUME[0]], group_snapshot=TEST_GROUP_SNAP[0], snapshots=[TEST_SNAPSHOT[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) diff --git a/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_iscsi.py b/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_iscsi.py index 513d0c153a2..789b0179be3 100644 --- a/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_iscsi.py +++ b/cinder/tests/unit/volume/drivers/hpe/xp/test_hpe_xp_rest_iscsi.py @@ -356,6 +356,7 @@ def _setup_config(self): self.configuration.hpexp_copy_speed = 3 self.configuration.hpexp_copy_check_interval = 3 self.configuration.hpexp_async_copy_check_interval = 10 + self.configuration.hpexp_manage_drs_volumes = False self.configuration.san_login = CONFIG_MAP['user_id'] self.configuration.san_password = CONFIG_MAP['user_pass'] @@ -517,10 +518,12 @@ def test_do_setup_create_hg(self, brick_get_connector_properties, request): @mock.patch.object(requests.Session, "request") def test_extend_volume(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.extend_volume(TEST_VOLUME[0], 256) - self.assertEqual(3, request.call_count) + self.assertEqual(5, request.call_count) @mock.patch.object(driver.ISCSIDriver, "get_goodness_function") @mock.patch.object(driver.ISCSIDriver, "get_filter_function") @@ -602,6 +605,8 @@ def test_create_cloned_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -611,7 +616,7 @@ def test_create_cloned_volume( {'location_info': {'pool_id': 30}}] vol = self.driver.create_cloned_volume(TEST_VOLUME[0], TEST_VOLUME[1]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -622,6 +627,8 @@ def test_create_volume_from_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -632,7 +639,7 @@ def test_create_volume_from_snapshot( vol = self.driver.create_volume_from_snapshot( TEST_VOLUME[0], TEST_SNAPSHOT[0]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -888,6 +895,8 @@ def test_create_group_from_src_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -899,7 +908,7 @@ def test_create_group_from_src_volume( self.ctxt, TEST_GROUP[1], [TEST_VOLUME[1]], source_group=TEST_GROUP[0], source_vols=[TEST_VOLUME[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[1]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) @@ -913,6 +922,8 @@ def test_create_group_from_src_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -924,7 +935,7 @@ def test_create_group_from_src_snapshot( self.ctxt, TEST_GROUP[0], [TEST_VOLUME[0]], group_snapshot=TEST_GROUP_SNAP[0], snapshots=[TEST_SNAPSHOT[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) diff --git a/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_fc.py b/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_fc.py index 38c195c4b98..1baafe413f1 100644 --- a/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_fc.py +++ b/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_fc.py @@ -454,6 +454,7 @@ def _setup_config(self): self.configuration.nec_v_copy_speed = 3 self.configuration.nec_v_copy_check_interval = 3 self.configuration.nec_v_async_copy_check_interval = 10 + self.configuration.nec_v_manage_drs_volumes = False self.configuration.san_login = CONFIG_MAP['user_id'] self.configuration.san_password = CONFIG_MAP['user_pass'] @@ -699,10 +700,12 @@ def test_delete_volume_busy_timeout(self, request): @mock.patch.object(requests.Session, "request") def test_extend_volume(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.extend_volume(TEST_VOLUME[0], 256) - self.assertEqual(3, request.call_count) + self.assertEqual(5, request.call_count) @mock.patch.object(driver.FibreChannelDriver, "get_goodness_function") @mock.patch.object(driver.FibreChannelDriver, "get_filter_function") @@ -775,6 +778,8 @@ def test_create_cloned_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -784,7 +789,7 @@ def test_create_cloned_volume( {'location_info': {'pool_id': 30}}] vol = self.driver.create_cloned_volume(TEST_VOLUME[0], TEST_VOLUME[1]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -795,6 +800,8 @@ def test_create_volume_from_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -805,7 +812,7 @@ def test_create_volume_from_snapshot( vol = self.driver.create_volume_from_snapshot( TEST_VOLUME[0], TEST_SNAPSHOT[0]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(fczm_utils, "add_fc_zone") @mock.patch.object(requests.Session, "request") @@ -1093,6 +1100,8 @@ def test_create_group_from_src_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.common._stats = {} @@ -1102,7 +1111,7 @@ def test_create_group_from_src_volume( self.ctxt, TEST_GROUP[1], [TEST_VOLUME[1]], source_group=TEST_GROUP[0], source_vols=[TEST_VOLUME[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[1]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) @@ -1116,6 +1125,8 @@ def test_create_group_from_src_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -1127,7 +1138,7 @@ def test_create_group_from_src_snapshot( self.ctxt, TEST_GROUP[0], [TEST_VOLUME[0]], group_snapshot=TEST_GROUP_SNAP[0], snapshots=[TEST_SNAPSHOT[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) diff --git a/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_iscsi.py b/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_iscsi.py index b36a977a286..c526b3577bd 100644 --- a/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_iscsi.py +++ b/cinder/tests/unit/volume/drivers/nec/v/test_internal_nec_rest_iscsi.py @@ -365,6 +365,7 @@ def _setup_config(self): self.configuration.nec_v_copy_speed = 3 self.configuration.nec_v_copy_check_interval = 3 self.configuration.nec_v_async_copy_check_interval = 10 + self.configuration.nec_v_manage_drs_volumes = False self.configuration.san_login = CONFIG_MAP['user_id'] self.configuration.san_password = CONFIG_MAP['user_pass'] @@ -561,10 +562,12 @@ def test_do_setup_create_hg(self, brick_get_connector_properties, request): @mock.patch.object(requests.Session, "request") def test_extend_volume(self, request): request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] self.driver.extend_volume(TEST_VOLUME[0], 256) - self.assertEqual(3, request.call_count) + self.assertEqual(5, request.call_count) @mock.patch.object(driver.ISCSIDriver, "get_goodness_function") @mock.patch.object(driver.ISCSIDriver, "get_filter_function") @@ -646,6 +649,8 @@ def test_create_cloned_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -655,7 +660,7 @@ def test_create_cloned_volume( {'location_info': {'pool_id': 30}}] vol = self.driver.create_cloned_volume(TEST_VOLUME[0], TEST_VOLUME[1]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -666,6 +671,8 @@ def test_create_volume_from_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -676,7 +683,7 @@ def test_create_volume_from_snapshot( vol = self.driver.create_volume_from_snapshot( TEST_VOLUME[0], TEST_SNAPSHOT[0]) self.assertEqual('1', vol['provider_location']) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) @mock.patch.object(requests.Session, "request") @mock.patch.object(volume_types, 'get_volume_type_extra_specs') @@ -932,6 +939,8 @@ def test_create_group_from_src_volume( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -943,7 +952,7 @@ def test_create_group_from_src_volume( self.ctxt, TEST_GROUP[1], [TEST_VOLUME[1]], source_group=TEST_GROUP[0], source_vols=[TEST_VOLUME[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[1]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) @@ -957,6 +966,8 @@ def test_create_group_from_src_snapshot( request.side_effect = [FakeResponse(200, GET_LDEV_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT), + FakeResponse(200, GET_LDEV_RESULT), + FakeResponse(200, GET_LDEV_RESULT), FakeResponse(200, GET_SNAPSHOTS_RESULT), FakeResponse(202, COMPLETED_SUCCEEDED_RESULT)] get_volume_type_extra_specs.return_value = {} @@ -968,7 +979,7 @@ def test_create_group_from_src_snapshot( self.ctxt, TEST_GROUP[0], [TEST_VOLUME[0]], group_snapshot=TEST_GROUP_SNAP[0], snapshots=[TEST_SNAPSHOT[0]] ) - self.assertEqual(5, request.call_count) + self.assertEqual(7, request.call_count) actual = ( None, [{'id': TEST_VOLUME[0]['id'], 'provider_location': '1'}]) self.assertTupleEqual(actual, ret) diff --git a/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_fc.py b/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_fc.py index 2598638f422..4f753e315ae 100644 --- a/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_fc.py +++ b/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_fc.py @@ -181,6 +181,7 @@ def _setup_config(self): self.configuration.nec_v_copy_speed = 3 self.configuration.nec_v_copy_check_interval = 3 self.configuration.nec_v_async_copy_check_interval = 10 + self.configuration.nec_v_manage_drs_volumes = False self.configuration.san_login = CONFIG_MAP['user_id'] self.configuration.san_password = CONFIG_MAP['user_pass'] @@ -300,6 +301,8 @@ def test_configuration(self): drv.configuration.nec_v_copy_check_interval) self.assertEqual(drv.configuration.hitachi_async_copy_check_interval, drv.configuration.nec_v_async_copy_check_interval) + self.assertEqual(drv.configuration.hitachi_manage_drs_volumes, + drv.configuration.nec_v_manage_drs_volumes) self.assertEqual(drv.configuration.hitachi_rest_disable_io_wait, drv.configuration.nec_v_rest_disable_io_wait) self.assertEqual(drv.configuration.hitachi_rest_tcp_keepalive, diff --git a/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_iscsi.py b/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_iscsi.py index b51225f3b3d..c2c19d623ad 100644 --- a/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_iscsi.py +++ b/cinder/tests/unit/volume/drivers/nec/v/test_nec_rest_iscsi.py @@ -203,6 +203,7 @@ def _setup_config(self): self.configuration.nec_v_copy_speed = 3 self.configuration.nec_v_copy_check_interval = 3 self.configuration.nec_v_async_copy_check_interval = 10 + self.configuration.nec_v_manage_drs_volumes = False self.configuration.san_login = CONFIG_MAP['user_id'] self.configuration.san_password = CONFIG_MAP['user_pass'] @@ -322,6 +323,8 @@ def test_configuration(self): drv.configuration.nec_v_copy_check_interval) self.assertEqual(drv.configuration.hitachi_async_copy_check_interval, drv.configuration.nec_v_async_copy_check_interval) + self.assertEqual(drv.configuration.hitachi_manage_drs_volumes, + drv.configuration.nec_v_manage_drs_volumes) self.assertEqual(drv.configuration.hitachi_rest_disable_io_wait, drv.configuration.nec_v_rest_disable_io_wait) self.assertEqual(drv.configuration.hitachi_rest_tcp_keepalive, diff --git a/cinder/volume/drivers/hitachi/hbsd_common.py b/cinder/volume/drivers/hitachi/hbsd_common.py index 1def1176934..a7e38803d29 100644 --- a/cinder/volume/drivers/hitachi/hbsd_common.py +++ b/cinder/volume/drivers/hitachi/hbsd_common.py @@ -1,4 +1,5 @@ # Copyright (C) 2020, 2024, Hitachi, Ltd. +# Copyright (C) 2025, Hitachi Vantara # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -48,8 +49,15 @@ STR_VOLUME = 'volume' STR_SNAPSHOT = 'snapshot' +STR_MANAGED_VCP_LDEV_NAME = 'HBSD-VCP' + _UUID_PATTERN = re.compile(r'^[\da-f]{32}$') +DRS_MODE = { + ' True': True, + ' False': False, +} + _INHERITED_VOLUME_OPTS = [ 'volume_backend_name', 'volume_driver', @@ -124,6 +132,11 @@ min=1, max=600, help='Interval in seconds to check asynchronous copying status during ' 'a copy pair deletion or data restoration.'), + cfg.BoolOpt( + 'hitachi_manage_drs_volumes', + default=False, + help='If true, the driver will create a driver managed vClone parent ' + 'for each non-cloned DRS volume it creates.'), ] COMMON_PORT_OPTS = [ @@ -263,7 +276,14 @@ def modify_ldev_name(self, ldev, name): def create_volume(self, volume): """Create a volume and return its properties.""" + extra_specs = self.get_volume_extra_specs(volume) + + # If we're a managed DRS volume, we need to call + # create_managed_drs_volume. + if self.is_managed_drs_volume(extra_specs): + return self.create_managed_drs_volume(volume) + pool_id = self.get_pool_id_of_volume(volume) ldev_range = self.storage_info['ldev_range'] qos_specs = utils.get_qos_specs_from_volume(volume) @@ -278,6 +298,80 @@ def create_volume(self, volume): 'provider_location': str(ldev), } + def is_managed_drs_volume(self, extra_specs): + + is_managed_drs = False + if (self.conf.hitachi_manage_drs_volumes and + self.driver_info.get('driver_dir_name')): + + extra_specs_drs = (self.driver_info['driver_dir_name'] + + ':drs') + drs = extra_specs.get(extra_specs_drs) + + is_managed_drs = DRS_MODE.get(drs, False) + + return is_managed_drs + + def get_drs_parent_extra_specs(self, extra_specs): + """Build subset of extra specs for a DRS vClone parent.""" + + extra_specs_parent = {} + + extra_specs_drs = (self.driver_info['driver_dir_name'] + + ':drs') + drs = extra_specs.get(extra_specs_drs) + extra_specs_csv = (self.driver_info['driver_dir_name'] + + ':capacity_saving') + capacity_saving = extra_specs.get(extra_specs_csv) + + extra_specs_parent[extra_specs_drs] = drs + extra_specs_parent[extra_specs_csv] = capacity_saving + + LOG.debug("Managed parent extra specs: %s", extra_specs_parent) + + return extra_specs_parent + + def create_managed_drs_volume(self, volume): + """Create a managed DRS volume and return its properties.""" + + LOG.debug("Creating managed DRS volume.") + + extra_specs = self.get_volume_extra_specs(volume) + pool_id = self.get_pool_id_of_volume(volume) + ldev_range = self.storage_info['ldev_range'] + qos_specs = utils.get_qos_specs_from_volume(volume) + size = volume['size'] + + # Create our parent volume using only the DRS-related + # specs. + try: + + parent = self.create_ldev(size, + self.get_drs_parent_extra_specs( + extra_specs), + pool_id, ldev_range) + except Exception: + with excutils.save_and_reraise_exception(): + self.output_log(MSG.CREATE_LDEV_FAILED) + self.modify_ldev_name(parent, STR_MANAGED_VCP_LDEV_NAME) + + # Create a clone using our parent volume and the + # given extra specs. + try: + ldev = self.copy_on_storage(parent, size, extra_specs, + pool_id, + pool_id, ldev_range, + qos_specs=qos_specs) + except Exception: + self.delete_ldev(parent) + with excutils.save_and_reraise_exception(): + self.output_log(MSG.CREATE_LDEV_FAILED) + self.modify_ldev_name(ldev, volume['id'].replace("-", "")) + + return { + 'provider_location': str(ldev), + } + def get_ldev_info(self, keys, ldev, **kwargs): """Return a dictionary of LDEV-related items.""" raise NotImplementedError() @@ -616,6 +710,27 @@ def extend_volume(self, volume, new_size): volume_id=volume['id']) self.raise_error(msg) self.delete_pair(ldev) + + # Extend a Managed parent if we have one and it's necessary. + ldev_info = self.get_ldev_info(['parentLdevId'], ldev) + if ldev_info['parentLdevId']: + parent_ldev = int(ldev_info['parentLdevId']) + parent_ldev_info = self.get_ldev_info( + ['blockCapacity', 'label'], parent_ldev) + + if (parent_ldev_info['label'] and + parent_ldev_info['label'] == STR_MANAGED_VCP_LDEV_NAME and + (parent_ldev_info['blockCapacity'] / + utils.GIGABYTE_PER_BLOCK_SIZE < new_size)): + + LOG.debug("Resizing Managed parent volume %d.", + parent_ldev) + self.extend_ldev(parent_ldev, + int(parent_ldev_info['blockCapacity'] / + utils.GIGABYTE_PER_BLOCK_SIZE), + new_size) + + # Finally, extend our LDEV self.extend_ldev(ldev, volume['size'], new_size) def get_ldev_by_name(self, name): diff --git a/cinder/volume/drivers/hitachi/hbsd_rest.py b/cinder/volume/drivers/hitachi/hbsd_rest.py index eb1356a215e..fd8a3bb77c1 100644 --- a/cinder/volume/drivers/hitachi/hbsd_rest.py +++ b/cinder/volume/drivers/hitachi/hbsd_rest.py @@ -1,4 +1,5 @@ # Copyright (C) 2020, 2024, Hitachi, Ltd. +# Copyright (C) 2025, Hitachi Vantara # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -112,6 +113,7 @@ '': 'disabled', None: 'disabled', } +_DRS_MODE = common.DRS_MODE REST_VOLUME_OPTS = [ cfg.BoolOpt( @@ -253,7 +255,8 @@ def _check_ldev_manageability(self, ldev_info, ldev, existing_ref): if (not ldev_info['emulationType'].startswith('OPEN-V') or len(attributes) < 2 or not attributes.issubset( - set(['CVS', self.driver_info['hdp_vol_attr'], + set(['CVS', utils.DRS_VOL_ATTR, utils.VC_VOL_ATTR, + self.driver_info['hdp_vol_attr'], self.driver_info['hdt_vol_attr']]))): msg = self.output_log(MSG.INVALID_LDEV_ATTR_FOR_MANAGE, ldev=ldev, ldevtype=self.driver_info['nvol_ldev_type']) @@ -341,6 +344,16 @@ def _set_dr_mode(self, body, capacity_saving): self.raise_error(msg) body['dataReductionMode'] = dr_mode + def _set_drs_mode(self, body, drs): + drs_mode = _DRS_MODE.get(drs, False) + if not drs_mode: + msg = self.output_log( + MSG.INVALID_EXTRA_SPEC_KEY, + key=self.driver_info['driver_dir_name'] + ':drs', + value=drs) + self.raise_error(msg) + body['isDataReductionSharedVolumeEnabled'] = drs_mode + def _create_ldev_on_storage(self, size, extra_specs, pool_id, ldev_range): """Create an LDEV on the storage system.""" body = { @@ -349,11 +362,17 @@ def _create_ldev_on_storage(self, size, extra_specs, pool_id, ldev_range): 'isParallelExecutionEnabled': True, } capacity_saving = None + has_drs = False if self.driver_info.get('driver_dir_name'): capacity_saving = extra_specs.get( self.driver_info['driver_dir_name'] + ':capacity_saving') + drs_spec_name = self.driver_info['driver_dir_name'] + ':drs' + has_drs = drs_spec_name in extra_specs + drs = extra_specs.get(drs_spec_name) if capacity_saving: self._set_dr_mode(body, capacity_saving) + if has_drs: + self._set_drs_mode(body, drs) if self.storage_info['ldev_range']: min_ldev, max_ldev = self.storage_info['ldev_range'][:2] body['startLdevId'] = min_ldev @@ -389,7 +408,8 @@ def delete_ldev_from_storage(self, ldev): """Delete the specified LDEV from the storage.""" result = self.get_ldev_info(['emulationType', 'dataReductionMode', - 'dataReductionStatus'], ldev) + 'dataReductionStatus', + 'parentLdevId'], ldev) if result['dataReductionStatus'] == 'FAILED': msg = self.output_log( MSG.CONSISTENCY_NOT_GUARANTEE, ldev=ldev) @@ -406,6 +426,22 @@ def delete_ldev_from_storage(self, ldev): ldev, body, timeout_message=(MSG.LDEV_DELETION_WAIT_TIMEOUT, {'ldev': ldev})) + # If we have a managed parent that is no longer a parent, + # delete it. + if result['parentLdevId']: + parent_ldev = int(result['parentLdevId']) + parent_info = self.get_ldev_info(['attributes', 'label'], + parent_ldev) + if ((not parent_info['attributes'] or + utils.VCP_VOL_ATTR not in parent_info['attributes']) and + (parent_info['label'] and + parent_info['label'] == common.STR_MANAGED_VCP_LDEV_NAME)): + LOG.debug("Deleting managed VCP LDEV %d.", parent_ldev) + self.client.delete_ldev( + parent_ldev, body, + timeout_message=(MSG.LDEV_DELETION_WAIT_TIMEOUT, + {'ldev': parent_ldev})) + def _get_snap_pool_id(self, pvol): return ( self.storage_info['snap_pool_id'] @@ -484,7 +520,7 @@ def _create_snap_pair(self, pvol, svol): self.output_log( MSG.DELETE_PAIR_FAILED, pvol=pvol, svol=svol) - def _create_clone_pair(self, pvol, svol, snap_pool_id): + def _create_regular_clone_pair(self, pvol, svol, snap_pool_id): """Create a clone copy pair on the storage.""" snapshot_name = '%(prefix)s%(svol)s' % { 'prefix': self.driver_info['driver_prefix'] + '-clone', @@ -526,6 +562,87 @@ def _create_clone_pair(self, pvol, svol, snap_pool_id): self.output_log( MSG.DELETE_PAIR_FAILED, pvol=pvol, svol=svol) + def _can_config_vclone(self, pvol, svol, snap_pool_id): + """Determine if we can configure vClone (=DRS + matching pool)""" + chk_list = ['dataReductionStatus', 'poolId', 'attributes'] + pinfo = self.get_ldev_info(chk_list, pvol) + sinfo = self.get_ldev_info(chk_list, svol) + LOG.debug("vclone-chk.Pinfo=%s, Sinfo=%s", repr(pinfo), repr(sinfo)) + if (not pinfo['attributes'] or + utils.DRS_VOL_ATTR not in pinfo['attributes'] or + not sinfo['attributes'] or + utils.DRS_VOL_ATTR not in sinfo['attributes']): + return False + if (pinfo['poolId'] != snap_pool_id or + sinfo['poolId'] != snap_pool_id): + return False + return True + + def _create_vclone_pair(self, pvol, svol, snap_pool_id): + """Create a copy pair, then convert to vClone.""" + snapshot_name = '%(prefix)s%(svol)s' % { + 'prefix': self.driver_info['driver_prefix'] + '-vclone', + 'svol': svol % _SNAP_HASH_SIZE, + } + ss_result = None + try: + body = {"snapshotGroupName": snapshot_name, + "snapshotPoolId": self._get_snap_pool_id(pvol), + "pvolLdevId": pvol, + "svolLdevId": svol, + "isConsistencyGroup": False, + "isDataReductionForceCopy": True, + "canCascade": True} + self.client.add_snapshot(body) + except exception.VolumeDriverException as ex: + if (utils.safe_get_err_code(ex.kwargs.get('errobj')) == + rest_api.INVALID_SNAPSHOT_POOL and + not self.conf.hitachi_snap_pool): + msg = self.output_log( + MSG.INVALID_PARAMETER, + param=self.driver_info['param_prefix'] + '_snap_pool') + self.raise_error(msg) + else: + raise + try: + self._wait_copy_pair_status(svol, set([PAIR])) + LOG.debug("_wait_copy_pair_status[PAIR] done.svol=%d", svol) + except Exception: + with excutils.save_and_reraise_exception(): + try: + self._delete_pair_from_storage(pvol, svol) + except exception.VolumeDriverException: + self.output_log( + MSG.DELETE_PAIR_FAILED, pvol=pvol, svol=svol) + try: + ss_result = self.client.get_snapshot_by_svol(svol) + LOG.debug("snapshot result=%s,svol=%d", repr(ss_result), svol) + except Exception: + with excutils.save_and_reraise_exception(): + msg = self.output_log( + MSG.GET_SNAPSHOT_FROM_SVOL_FAILURE, svol=repr(svol)) + LOG.error(msg) + self._delete_pair_from_storage(pvol, svol) + self.raise_error(msg) + try: + ss_id = ss_result['data'][0]['snapshotId'] + self.client.snapshot_pair_to_vclone(ss_id) + LOG.debug("ss2vclone svol=%d", svol) + except Exception: + with excutils.save_and_reraise_exception(): + msg = self.output_log( + MSG.VCLONE_PAIR_FAILED, pvol=repr(pvol), svol=repr(svol)) + LOG.error(msg) + self._delete_pair_from_storage(pvol, svol) + self.raise_error(msg) + + def _create_clone_pair(self, pvol, svol, snap_pool_id): + """Check on the new pair configuration to see if it is TIA(=vClone).""" + if self._can_config_vclone(pvol, svol, snap_pool_id): + self._create_vclone_pair(pvol, svol, snap_pool_id) + else: + self._create_regular_clone_pair(pvol, svol, snap_pool_id) + def create_pair_on_storage( self, pvol, svol, snap_pool_id, is_snapshot=False): """Create a copy pair on the storage.""" @@ -847,7 +964,8 @@ def detach_ldev(self, volume, ldev, connector): targets['list'], mapped_targets['list']) unmap_targets['list'].sort( reverse=True, - key=lambda port: (port.get('portId'), port.get('hostGroupNumber'))) + key=lambda port: (port.get('portId'), + port.get('hostGroupNumber'))) self.unmap_ldev(unmap_targets, ldev) if self.conf.hitachi_group_delete: @@ -861,10 +979,23 @@ def find_all_mapped_targets_from_storage(self, targets, ldev): for port in ldev_info['ports']: targets['list'].append(port) + def is_ldev_drs(self, ldev): + """Determine if the given LDEV is a DRS volume.""" + ldev_info = self.get_ldev_info(['attributes'], ldev) + + if (ldev_info['attributes'] and + utils.DRS_VOL_ATTR in ldev_info['attributes']): + return True + + return False + def extend_ldev(self, ldev, old_size, new_size): """Extend the specified LDEV to the specified new size.""" body = {"parameters": {"additionalByteFormatCapacity": '%sG' % (new_size - old_size)}} + if self.is_ldev_drs(ldev): + body['parameters']['enhancedExpansion'] = True + self.client.extend_ldev(ldev, body) def get_pool_info(self, pool_id, result=None): @@ -1528,7 +1659,8 @@ def migrate_volume(self, volume, host, new_type=None): return True, None - def _is_modifiable_dr_value(self, dr_mode, dr_status, new_dr_mode, volume): + def _is_modifiable_dr_value(self, dr_mode, dr_status, + new_dr_mode, is_drs, volume): if (dr_status == 'REHYDRATING' and new_dr_mode == 'compression_deduplication'): self.output_log(MSG.VOLUME_IS_BEING_REHYDRATED, @@ -1541,6 +1673,8 @@ def _is_modifiable_dr_value(self, dr_mode, dr_status, new_dr_mode, volume): volume_type=volume['volume_type']['name']) return False elif new_dr_mode == 'disabled': + if is_drs: + return False return dr_status in _DISABLE_ABLE_DR_STATUS.get(dr_mode, ()) elif new_dr_mode == 'compression_deduplication': return dr_status in _DEDUPCOMP_ABLE_DR_STATUS.get(dr_mode, ()) @@ -1573,6 +1707,8 @@ def _check_specs_diff(diff, allowed_extra_specs): extra_specs_capacity_saving = None new_capacity_saving = None + extra_specs_drs = False + new_drs = None allowed_extra_specs = [] if self.driver_info.get('driver_dir_name'): extra_specs_capacity_saving = ( @@ -1580,6 +1716,12 @@ def _check_specs_diff(diff, allowed_extra_specs): new_capacity_saving = ( new_type['extra_specs'].get(extra_specs_capacity_saving)) allowed_extra_specs.append(extra_specs_capacity_saving) + + extra_specs_drs = ( + self.driver_info['driver_dir_name'] + ':drs') + new_drs = ( + new_type['extra_specs'].get(extra_specs_drs)) + new_dr_mode = _CAPACITY_SAVING_DR_MODE.get(new_capacity_saving) if not new_dr_mode: msg = self.output_log( @@ -1587,6 +1729,7 @@ def _check_specs_diff(diff, allowed_extra_specs): key=extra_specs_capacity_saving, value=new_capacity_saving) self.raise_error(msg) + ldev = self.get_ldev(volume) if ldev is None: msg = self.output_log( @@ -1594,7 +1737,19 @@ def _check_specs_diff(diff, allowed_extra_specs): id=volume['id']) self.raise_error(msg) ldev_info = self.get_ldev_info( - ['dataReductionMode', 'dataReductionStatus', 'poolId'], ldev) + ['dataReductionMode', 'dataReductionStatus', + 'poolId', 'attributes'], ldev) + + # The DRS mode is not allowed to change. + is_current_drs = ldev_info['attributes'] and ( + utils.DRS_VOL_ATTR in ldev_info['attributes']) + if _DRS_MODE.get(new_drs, False) is not is_current_drs: + msg = self.output_log( + MSG.FAILED_CHANGE_VOLUME_TYPE, + key=extra_specs_drs, + value=new_drs) + self.raise_error(msg) + old_pool_id = ldev_info['poolId'] new_pool_id = host['capabilities']['location_info'].get('pool_id') if (not _check_specs_diff(diff, allowed_extra_specs) @@ -1610,7 +1765,10 @@ def _check_specs_diff(diff, allowed_extra_specs): ['dataReductionMode', 'dataReductionStatus'], ldev) if not self._is_modifiable_dr_value( ldev_info['dataReductionMode'], - ldev_info['dataReductionStatus'], new_dr_mode, volume): + ldev_info['dataReductionStatus'], + new_dr_mode, + _DRS_MODE.get(new_drs, False), + volume): return False self._modify_capacity_saving(ldev, new_dr_mode) @@ -1628,7 +1786,9 @@ def wait_copy_completion(self, pvol, svol): self._wait_copy_pair_status(svol, set([SMPL, PSUE])) status = self._get_copy_pair_status(svol) if status == PSUE: - msg = self.output_log(MSG.VOLUME_COPY_FAILED, pvol=pvol, svol=svol) + msg = self.output_log(MSG.VOLUME_COPY_FAILED, + pvol=pvol, + svol=svol) self.raise_error(msg) def create_target_name(self, connector): diff --git a/cinder/volume/drivers/hitachi/hbsd_rest_api.py b/cinder/volume/drivers/hitachi/hbsd_rest_api.py index e7081666d02..d6ba03c34c5 100644 --- a/cinder/volume/drivers/hitachi/hbsd_rest_api.py +++ b/cinder/volume/drivers/hitachi/hbsd_rest_api.py @@ -1,4 +1,5 @@ # Copyright (C) 2020, 2024, Hitachi, Ltd. +# Copyright (C) 2025, Hitachi Vantara # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -835,6 +836,15 @@ def delete_lun(self, port_id, host_group_number, lun, **kwargs): } self._delete_object(url, **kwargs) + def get_snapshot_by_svol(self, svolLdevId): + """Get a snapshot information by using svolLdevId.""" + url = '%(url)s/snapshots?svolLdevId=%(ldevId)d' % { + 'url': self.object_url, + 'ldevId': svolLdevId, + } + + return self._get_object(url) + def get_snapshots(self, params=None): """Get a list of snapshot information.""" url = '%(url)s/snapshots' % { @@ -886,6 +896,19 @@ def split_snapshotgroup(self, snapshot_group_id): } self._invoke(url) + def snapshot_pair_to_vclone(self, snapshotId): + """convert snapshot(TIA) to vClone.""" + url = '%(url)s/snapshots/%(ssid)s/actions/virtual-clone/invoke' % { + 'url': self.object_url, + 'ssid': snapshotId, + } + body = { + "parameters": { + "operationType": "create" + } + } + self._add_object(url, body=body) + def discard_zero_page(self, ldev_id): """Return the ldev's no-data pages to the storage pool.""" url = '%(url)s/ldevs/%(id)s/actions/%(action)s/invoke' % { diff --git a/cinder/volume/drivers/hitachi/hbsd_utils.py b/cinder/volume/drivers/hitachi/hbsd_utils.py index b16092d87e3..6e283b96341 100644 --- a/cinder/volume/drivers/hitachi/hbsd_utils.py +++ b/cinder/volume/drivers/hitachi/hbsd_utils.py @@ -1,4 +1,5 @@ # Copyright (C) 2020, 2024, Hitachi, Ltd. +# Copyright (C) 2025, Hitachi Vantara # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -26,8 +27,8 @@ from cinder import utils as cinder_utils from cinder.volume import volume_types -VERSION = '2.4.0' -CI_WIKI_NAME = 'Hitachi_VSP_CI' +VERSION = '2.5.0' +CI_WIKI_NAME = 'Hitachi_CI' PARAM_PREFIX = 'hitachi' VENDOR_NAME = 'Hitachi' DRIVER_DIR_NAME = 'hbsd' @@ -36,6 +37,9 @@ TARGET_PREFIX = 'HBSD-' HDP_VOL_ATTR = 'HDP' HDT_VOL_ATTR = 'HDT' +DRS_VOL_ATTR = 'DRS' +VCP_VOL_ATTR = 'VCP' +VC_VOL_ATTR = 'VC' NVOL_LDEV_TYPE = 'DP-VOL' TARGET_IQN_SUFFIX = '.hbsd-target' PAIR_ATTR = 'HTI' @@ -662,6 +666,18 @@ class HBSDMsg(enum.Enum): 'Cinder object. (%(obj)s: %(obj_id)s)', 'suffix': ERROR_SUFFIX, } + GET_SNAPSHOT_FROM_SVOL_FAILURE = { + 'msg_id': 772, + 'loglevel': base_logging.ERROR, + 'msg': 'Failed to get snapshot from s-vol %(svol)s. ', + 'suffix': ERROR_SUFFIX, + } + VCLONE_PAIR_FAILED = { + 'msg_id': 773, + 'loglevel': base_logging.ERROR, + 'msg': 'Failed to ss2vclone. p-vol=%(pvol)s,s-vol=%(svol)s', + 'suffix': ERROR_SUFFIX, + } def __init__(self, error_info): """Initialize Enum attributes.""" diff --git a/cinder/volume/drivers/hpe/xp/hpe_xp_rest.py b/cinder/volume/drivers/hpe/xp/hpe_xp_rest.py index 6f42435d02b..b43a1a00319 100644 --- a/cinder/volume/drivers/hpe/xp/hpe_xp_rest.py +++ b/cinder/volume/drivers/hpe/xp/hpe_xp_rest.py @@ -82,6 +82,11 @@ default=10, min=1, max=600, help='Interval in seconds to check copy asynchronously'), + cfg.BoolOpt( + 'hpexp_manage_drs_volumes', + default=False, + help='If true, the driver will create a driver managed vClone parent ' + 'for each non-cloned DRS volume it creates.'), ] REST_VOLUME_OPTS = [ @@ -226,6 +231,8 @@ def _update_conf(self): self.conf.hpexp_copy_check_interval) self.conf.hitachi_async_copy_check_interval = ( self.conf.hpexp_async_copy_check_interval) + self.conf.hitachi_manage_drs_volumes = ( + self.conf.hpexp_manage_drs_volumes) # REST_VOLUME_OPTS self.conf.hitachi_rest_disable_io_wait = ( @@ -297,6 +304,8 @@ def _update_conf(self): self.conf.hpexp_copy_check_interval) self.conf.hitachi_async_copy_check_interval = ( self.conf.hpexp_async_copy_check_interval) + self.conf.hitachi_manage_drs_volumes = ( + self.conf.hpexp_manage_drs_volumes) # REST_VOLUME_OPTS self.conf.hitachi_rest_disable_io_wait = ( diff --git a/cinder/volume/drivers/hpe/xp/hpe_xp_utils.py b/cinder/volume/drivers/hpe/xp/hpe_xp_utils.py index 2a8477307f2..bdd227b9369 100644 --- a/cinder/volume/drivers/hpe/xp/hpe_xp_utils.py +++ b/cinder/volume/drivers/hpe/xp/hpe_xp_utils.py @@ -23,6 +23,9 @@ TARGET_PREFIX = 'HPEXP-' HDP_VOL_ATTR = 'THP' HDT_VOL_ATTR = 'ST' +DRS_VOL_ATTR = 'DRS' +VCP_VOL_ATTR = 'VCP' +VC_VOL_ATTR = 'VC' NVOL_LDEV_TYPE = 'THP V-VOL' TARGET_IQN_SUFFIX = '.hpexp-target' PAIR_ATTR = 'FS' diff --git a/cinder/volume/drivers/nec/v/nec_v_rest.py b/cinder/volume/drivers/nec/v/nec_v_rest.py index 27560dd3aad..cf1ad52eb87 100644 --- a/cinder/volume/drivers/nec/v/nec_v_rest.py +++ b/cinder/volume/drivers/nec/v/nec_v_rest.py @@ -84,6 +84,11 @@ min=1, max=600, help='Interval in seconds to check asynchronous copying status during ' 'a copy pair deletion or data restoration.'), + cfg.BoolOpt( + 'nec_v_manage_drs_volumes', + default=False, + help='If true, the driver will create a driver managed vClone parent ' + 'for each non-cloned DRS volume it creates.'), ] REST_VOLUME_OPTS = [ @@ -212,6 +217,8 @@ def update_conf(conf): conf.nec_v_copy_check_interval) conf.hitachi_async_copy_check_interval = ( conf.nec_v_async_copy_check_interval) + conf.hitachi_manage_drs_volumes = ( + conf.nec_v_manage_drs_volumes) # REST_VOLUME_OPTS conf.hitachi_rest_disable_io_wait = ( diff --git a/doc/source/configuration/block-storage/drivers/hitachi-vsp-driver.rst b/doc/source/configuration/block-storage/drivers/hitachi-vsp-driver.rst index d3c916c6174..696f6ef10ab 100644 --- a/doc/source/configuration/block-storage/drivers/hitachi-vsp-driver.rst +++ b/doc/source/configuration/block-storage/drivers/hitachi-vsp-driver.rst @@ -59,6 +59,10 @@ Supported storages: | VSP G1000, | | | VSP G1500 | | +-----------------+------------------------+ +| VSP One B24, | A3-04-20 or later | +| B26, | | +| B28 | | ++-----------------+------------------------+ Required storage licenses: @@ -68,9 +72,11 @@ Required storage licenses: - Hitachi Dynamic Provisioning * Hitachi Local Replication (Hitachi Thin Image) +* Deduplication and compression (VSP One Block) + Optional storage licenses: -* Deduplication and compression +* Deduplication and compression (non-VSP One Block) * Global-Active Device @@ -99,6 +105,7 @@ Hitachi block storage driver also supports the following additional features: * Global-Active Device * Maximum number of copy pairs and consistency groups * Data deduplication and compression +* DRS volumes * Port scheduler * Port assignment using extra spec * Configuring Quality of Service (QoS) settings @@ -623,6 +630,71 @@ The cinder delete command finishes when the storage system starts the LDEV deletion process. The LDEV cannot be reused until the LDEV deletion process is completed on the storage system. +DRS volumes +---------------------------------- + +Use DRS volumes to improve storage utilization using data +reduction and data sharing. + +DRS volumes are required for VSP One Block series storage +when performing Clone operations. + +DRS volumes may not have the DRS or deduplication and +compression configuration modified or removed through +retyping. + +For details, +see `Capacity saving function: data deduplication and compression`_ +in the `Provisioning Guide`_. + +**Enabling DRS** + +To use the DRS functionality on the storage models, your storage +administrator must first enable the deduplication and compression for the DP +pool. + +For details about how to enable this setting, see the description of pool +management in the +`Hitachi Command Suite Configuration Manager REST API Reference Guide`_ or the +`Hitachi Ops Center API Configuration Manager REST API Reference Guide`_. + +.. note:: + + * Do not set a subscription limit (virtualVolumeCapacityRate) for the DP + pool. + +Creating a volume with DRS enabled +<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +To create a volume with the DRS setting enabled, +enable deduplication and compression and DRS for the relevant volume type. + +**Procedure** + +1. To enable the deduplication and compression setting, specify the value +``deduplication_compression`` for ``hbsd:capacity_saving`` in the extra specs +for the volume type. + +2. To enable the DRS setting, speciy the value `` True`` for ``hbsd:drs`` +in the extra specs for the volume type. + +3. When creating a volume of the volume type created in the previous steps, +you can create a volume with the deduplication and compression function and +DRS function enabled. + +Deleting a volume with deduplication and compression enabled +<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +The cinder delete command finishes when the storage system starts the LDEV +deletion process. The LDEV cannot be reused until the LDEV deletion process is +completed on the storage system. + +.. note:: + + * When deleting a volume that has been cloned using Thin Image Advanced and + vClone (DRS volumes + same pool), the vClone parent volume cannot be deleted + until all children have been deleted. + Port scheduler -------------- diff --git a/releasenotes/notes/B20-support-8c2baf5f781efffd.yaml b/releasenotes/notes/B20-support-8c2baf5f781efffd.yaml new file mode 100644 index 00000000000..461b5143971 --- /dev/null +++ b/releasenotes/notes/B20-support-8c2baf5f781efffd.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Hitachi driver: Enable support for VSP One B20. VSP One B20 supports ADR + functionality that offers up to 4:1 data saving, and Thin Image Advanced that supports + superior ROW functionality. In addition, the B20 supports vClone technology that allows + for instantaneous cloning and shared data between clones. From ad5ecc4b30bfe5d5b7b06605062076cc4b5ee853 Mon Sep 17 00:00:00 2001 From: Brian Rosmaita Date: Fri, 27 Feb 2026 11:42:55 -0500 Subject: [PATCH 33/37] [stable-only] pep8, unit tests, doc fixes, more This is a squash of two commits that are having difficulty passing the check/gate individually. Without the first, none of the jobs run; the second is required to address intermittent failures in the unit test jobs that are preventing the first from passing. But there's more! The grenade-skip-level-always job is broken in the epoxy branch, failing during the "upgrade nova" phase. The nova project addressed this with Change-Id: I12e03b8aa1330d6396017e2dcbface798b6be7b1 by making that job non-voting in stable/2025.1, so we do that also as a temporary change to unblock the gate. 1. [stable-only] constrain setuptools Because the windows support in current stable branches depends on the no-longer-maintained os-win library, we need to constrain setuptools to a version that contains pkg_resources. 2. tests: Remove use of mutable fakes This was causing somewhat frequent failures in CI. We could use deepcopy but most of these had a single user so it's easier move them inline. Closes-bug: #2125159 Change-Id: I50c45a0b21db7f36f1ff08f27fb434587a80210f Signed-off-by: Stephen Finucane (cherry picked from commit cc981d81b60143735fe569b240396fdf63bf6564) --- Change-Id: Ie42735874366581c13249f81dc362dd3ca4cfa1c Signed-off-by: Brian Rosmaita (cherry picked from commit 7faebca9b54d4ad8f4c186058522d8f83f6d113f) Changes: - make grenade-skip-level-always job non-voting (see above) --- .zuul.yaml | 8 + .../drivers/netapp/dataontap/utils/fakes.py | 113 ------------ .../dataontap/utils/test_capabilities.py | 172 ++++++++++++++---- tox.ini | 7 + 4 files changed, 154 insertions(+), 146 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index cd30ab7f9b9..3e21bc9af3f 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -103,6 +103,10 @@ irrelevant-files: *gate-irrelevant-files - grenade-skip-level: irrelevant-files: *gate-irrelevant-files + # make this template job non-voting until it gets fixed + - grenade-skip-level-always: + irrelevant-files: *gate-irrelevant-files + voting: false - tempest-ipv6-only: irrelevant-files: *gate-irrelevant-files - openstacksdk-functional-devstack: @@ -123,6 +127,10 @@ irrelevant-files: *gate-irrelevant-files - openstacksdk-functional-devstack: irrelevant-files: *gate-irrelevant-files + # make this template job non-voting until it gets fixed + - grenade-skip-level-always: + irrelevant-files: *gate-irrelevant-files + voting: false experimental: jobs: - cinder-multibackend-matrix-migration: diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py index c050f03877a..bc0fedf8323 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/fakes.py @@ -17,18 +17,6 @@ from cinder.volume import driver from cinder.volume.drivers.netapp import options as na_opts -SSC_VSERVER = 'fake_vserver' -SSC_VOLUMES = ('volume1', 'volume2') -SSC_VOLUME_MAP = { - SSC_VOLUMES[0]: { - 'pool_name': SSC_VOLUMES[0], - }, - SSC_VOLUMES[1]: { - 'pool_name': SSC_VOLUMES[1], - }, -} -SSC_AGGREGATES = ('aggr1', 'aggr2') - SSC = { 'volume1': { 'thick_provisioning_support': True, @@ -64,107 +52,6 @@ }, } -SSC_FLEXVOL_INFO = { - 'volume1': { - 'thick_provisioning_support': True, - 'thin_provisioning_support': False, - 'netapp_thin_provisioned': 'false', - 'netapp_aggregate': 'aggr1', - 'netapp_is_flexgroup': 'false', - }, - 'volume2': { - 'thick_provisioning_support': False, - 'thin_provisioning_support': True, - 'netapp_thin_provisioned': 'true', - 'netapp_aggregate': 'aggr2', - 'netapp_is_flexgroup': 'false', - }, -} - -SSC_DEDUPE_INFO = { - 'volume1': { - 'netapp_dedup': 'true', - 'netapp_compression': 'false', - }, - 'volume2': { - 'netapp_dedup': 'true', - 'netapp_compression': 'true', - }, -} - -SSC_ENCRYPTION_INFO = { - 'volume1': { - 'netapp_flexvol_encryption': 'true', - }, - 'volume2': { - 'netapp_flexvol_encryption': 'false', - }, -} - -SSC_QOS_MIN_INFO = { - 'volume1': { - 'netapp_qos_min_support': 'true', - }, - 'volume2': { - 'netapp_qos_min_support': 'false', - }, -} - -SSC_VOLUME_COUNT_INFO = { - 'volume1': { - 'total_volumes': 3, - }, - 'volume2': { - 'total_volumes': 2, - }, -} - -SSC_LUNS_BY_SIZES = [ - { - 'path': '/vol/volume-ae947c9b-2392-4956-b373-aaac4521f37e', - 'size': 5368709120.0 - }, - { - 'path': '/vol/snapshot-527eedad-a431-483d-b0ca-18995dd65b66', - 'size': 1073741824.0 - } -] - -SSC_NAMESPACES_BY_SIZES = [ - { - 'path': '/vol/namespace-ae947c9b-2392-4956-b373-aaac4521f37e', - 'size': 5379821234.0 - }, - { - 'path': '/vol/namespace-527eedad-a431-483d-b0ca-18995dd65b66', - 'size': 4673741874.0 - } -] - -SSC_MIRROR_INFO = { - 'volume1': { - 'netapp_mirrored': 'false', - }, - 'volume2': { - 'netapp_mirrored': 'true', - }, -} - -SSC_AGGREGATE_INFO = { - 'volume1': { - 'netapp_disk_type': ['SSD'], - 'netapp_raid_type': 'raid_dp', - 'netapp_hybrid_aggregate': 'false', - 'netapp_node_name': 'node1', - }, - 'volume2': { - 'netapp_disk_type': ['FCAL', 'SSD'], - 'netapp_raid_type': 'raid_dp', - 'netapp_hybrid_aggregate': 'true', - 'netapp_node_name': 'node2', - }, -} - PROVISIONING_OPTS_FLEXGROUP = { 'aggregate': ['fake_aggregate'], 'thin_provisioned': True, diff --git a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py index 816a4749829..52d3918d7bc 100644 --- a/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py +++ b/cinder/tests/unit/volume/drivers/netapp/dataontap/utils/test_capabilities.py @@ -33,13 +33,23 @@ class CapabilitiesLibraryTestCase(test.TestCase): def setUp(self): super(CapabilitiesLibraryTestCase, self).setUp() + self.SSC_VSERVER = 'fake_vserver' + self.SSC_VOLUMES = ('volume1', 'volume2') + self.SSC_VOLUME_MAP = { + self.SSC_VOLUMES[0]: { + 'pool_name': self.SSC_VOLUMES[0], + }, + self.SSC_VOLUMES[1]: { + 'pool_name': self.SSC_VOLUMES[1], + }, + } self.zapi_client = mock.Mock() self.configuration = self.get_config_cmode() self.ssc_library = capabilities.CapabilitiesLibrary( - 'iSCSI', fake.SSC_VSERVER, self.zapi_client, self.configuration) + 'iSCSI', self.SSC_VSERVER, self.zapi_client, self.configuration) self.ssc_library.ssc = fake.SSC self.ssc_library_nvme = capabilities.CapabilitiesLibrary( - 'NVMe', fake.SSC_VSERVER, self.zapi_client, self.configuration) + 'NVMe', self.SSC_VSERVER, self.zapi_client, self.configuration) def get_config_cmode(self): config = na_fakes.create_configuration_cmode() @@ -57,14 +67,14 @@ def test_get_ssc_flexvol_names(self): result = self.ssc_library.get_ssc_flexvol_names() - self.assertCountEqual(fake.SSC_VOLUMES, result) + self.assertCountEqual(self.SSC_VOLUMES, result) def test_get_ssc_for_flexvol(self): - result = self.ssc_library.get_ssc_for_flexvol(fake.SSC_VOLUMES[0]) + result = self.ssc_library.get_ssc_for_flexvol(self.SSC_VOLUMES[0]) - self.assertEqual(fake.SSC.get(fake.SSC_VOLUMES[0]), result) - self.assertIsNot(fake.SSC.get(fake.SSC_VOLUMES[0]), result) + self.assertEqual(fake.SSC.get(self.SSC_VOLUMES[0]), result) + self.assertIsNot(fake.SSC.get(self.SSC_VOLUMES[0]), result) def test_get_ssc_for_flexvol_not_found(self): @@ -76,10 +86,10 @@ def test_get_ssc_aggregates(self): result = self.ssc_library.get_ssc_aggregates() - self.assertCountEqual(list(fake.SSC_AGGREGATES), result) + self.assertCountEqual(['aggr1', 'aggr2'], result) def test_is_qos_min_supported(self): - ssc_pool = fake.SSC.get(fake.SSC_VOLUMES[0]) + ssc_pool = fake.SSC.get(self.SSC_VOLUMES[0]) is_qos_min = ssc_pool['netapp_qos_min_support'] == 'true' result = self.ssc_library.is_qos_min_supported(ssc_pool['pool_name']) @@ -92,44 +102,109 @@ def test_is_qos_min_supported_not_found(self): @ddt.data('nfs', 'iscsi') def test_update_ssc(self, protocol): + SSC_FLEXVOL_INFO = { + 'volume1': { + 'thick_provisioning_support': True, + 'thin_provisioning_support': False, + 'netapp_thin_provisioned': 'false', + 'netapp_aggregate': 'aggr1', + 'netapp_is_flexgroup': 'false', + }, + 'volume2': { + 'thick_provisioning_support': False, + 'thin_provisioning_support': True, + 'netapp_thin_provisioned': 'true', + 'netapp_aggregate': 'aggr2', + 'netapp_is_flexgroup': 'false', + }, + } + + SSC_DEDUPE_INFO = { + 'volume1': { + 'netapp_dedup': 'true', + 'netapp_compression': 'false', + }, + 'volume2': { + 'netapp_dedup': 'true', + 'netapp_compression': 'true', + }, + } + SSC_MIRROR_INFO = { + 'volume1': { + 'netapp_mirrored': 'false', + }, + 'volume2': { + 'netapp_mirrored': 'true', + }, + } + SSC_AGGREGATE_INFO = { + 'volume1': { + 'netapp_disk_type': ['SSD'], + 'netapp_raid_type': 'raid_dp', + 'netapp_hybrid_aggregate': 'false', + 'netapp_node_name': 'node1', + }, + 'volume2': { + 'netapp_disk_type': ['FCAL', 'SSD'], + 'netapp_raid_type': 'raid_dp', + 'netapp_hybrid_aggregate': 'true', + 'netapp_node_name': 'node2', + }, + } + SSC_ENCRYPTION_INFO = { + 'volume1': { + 'netapp_flexvol_encryption': 'true', + }, + 'volume2': { + 'netapp_flexvol_encryption': 'false', + }, + } + SSC_QOS_MIN_INFO = { + 'volume1': { + 'netapp_qos_min_support': 'true', + }, + 'volume2': { + 'netapp_qos_min_support': 'false', + }, + } mock_get_ssc_flexvol_info = self.mock_object( self.ssc_library, '_get_ssc_flexvol_info', - side_effect=[fake.SSC_FLEXVOL_INFO['volume1'], - fake.SSC_FLEXVOL_INFO['volume2']]) + side_effect=[SSC_FLEXVOL_INFO['volume1'], + SSC_FLEXVOL_INFO['volume2']]) mock_get_ssc_dedupe_info = self.mock_object( self.ssc_library, '_get_ssc_dedupe_info', - side_effect=[fake.SSC_DEDUPE_INFO['volume1'], - fake.SSC_DEDUPE_INFO['volume2']]) + side_effect=[SSC_DEDUPE_INFO['volume1'], + SSC_DEDUPE_INFO['volume2']]) mock_get_ssc_mirror_info = self.mock_object( self.ssc_library, '_get_ssc_mirror_info', - side_effect=[fake.SSC_MIRROR_INFO['volume1'], - fake.SSC_MIRROR_INFO['volume2']]) + side_effect=[SSC_MIRROR_INFO['volume1'], + SSC_MIRROR_INFO['volume2']]) mock_get_ssc_aggregate_info = self.mock_object( self.ssc_library, '_get_ssc_aggregate_info', - side_effect=[fake.SSC_AGGREGATE_INFO['volume1'], - fake.SSC_AGGREGATE_INFO['volume2']]) + side_effect=[SSC_AGGREGATE_INFO['volume1'], + SSC_AGGREGATE_INFO['volume2']]) mock_get_ssc_encryption_info = self.mock_object( self.ssc_library, '_get_ssc_encryption_info', - side_effect=[fake.SSC_ENCRYPTION_INFO['volume1'], - fake.SSC_ENCRYPTION_INFO['volume2']]) + side_effect=[SSC_ENCRYPTION_INFO['volume1'], + SSC_ENCRYPTION_INFO['volume2']]) mock_get_ssc_qos_min_info = self.mock_object( self.ssc_library, '_get_ssc_qos_min_info', - side_effect=[fake.SSC_QOS_MIN_INFO['volume1'], - fake.SSC_QOS_MIN_INFO['volume2']]) + side_effect=[SSC_QOS_MIN_INFO['volume1'], + SSC_QOS_MIN_INFO['volume2']]) if protocol != 'nfs': mock_get_ssc_volume_count_info = self.mock_object( self.ssc_library, '_get_ssc_volume_count_info', - side_effect=[fake.SSC_QOS_MIN_INFO['volume1'], - fake.SSC_QOS_MIN_INFO['volume2']]) + side_effect=[SSC_QOS_MIN_INFO['volume1'], + SSC_QOS_MIN_INFO['volume2']]) else: mock_get_ssc_volume_count_info = self.mock_object( self.ssc_library, '_get_ssc_volume_count_info', side_effect=None) ordered_ssc = collections.OrderedDict() - ordered_ssc['volume1'] = fake.SSC_VOLUME_MAP['volume1'] - ordered_ssc['volume2'] = fake.SSC_VOLUME_MAP['volume2'] + ordered_ssc['volume1'] = self.SSC_VOLUME_MAP['volume1'] + ordered_ssc['volume2'] = self.SSC_VOLUME_MAP['volume2'] result = self.ssc_library.update_ssc(ordered_ssc) @@ -137,7 +212,7 @@ def test_update_ssc(self, protocol): mock_get_ssc_volume_count_info.assert_has_calls([ mock.call('volume1'), mock.call('volume2')]) else: - self.ssc_library._get_ssc_volume_count_info(fake.SSC_VOLUMES[0]).\ + self.ssc_library._get_ssc_volume_count_info(self.SSC_VOLUMES[0]).\ assert_not_called() self.assertIsNone(result) @@ -158,7 +233,7 @@ def test_update_ssc(self, protocol): def test__update_for_failover(self): self.mock_object(self.ssc_library, 'update_ssc') - flexvol_map = {'volume1': fake.SSC_VOLUME_MAP['volume1']} + flexvol_map = {'volume1': self.SSC_VOLUME_MAP['volume1']} mock_client = mock.Mock(name='FAKE_ZAPI_CLIENT') self.ssc_library._update_for_failover(mock_client, flexvol_map) @@ -328,7 +403,7 @@ def test_get_ssc_mirror_info(self, mirrored): expected = {'netapp_mirrored': 'true' if mirrored else 'false'} self.assertEqual(expected, result) self.zapi_client.is_flexvol_mirrored.assert_called_once_with( - fake_client.VOLUME_NAMES[0], fake.SSC_VSERVER) + fake_client.VOLUME_NAMES[0], self.SSC_VSERVER) @ddt.data({'invalid_extra_specs': [], 'is_fg': False}, {'invalid_extra_specs': ['netapp_raid_type'], @@ -431,14 +506,20 @@ def test_get_matching_flexvols_for_extra_specs(self): } }, { - 'flexvol_info': fake.SSC['volume1'], + 'flexvol_info': { + 'netapp_disk_type': ['SSD'], + 'pool_name': 'volume1', + }, 'extra_specs': { 'netapp_disk_type': 'SSD', 'pool_name': 'volume1', } }, { - 'flexvol_info': fake.SSC['volume2'], + 'flexvol_info': { + 'netapp_disk_type': ['FCAL', 'SSD'], + 'netapp_hybrid_aggregate': 'true', + }, 'extra_specs': { 'netapp_disk_type': 'SSD', 'netapp_hybrid_aggregate': 'true', @@ -464,14 +545,19 @@ def test_flexvol_matches_extra_specs(self, flexvol_info, extra_specs): } }, { - 'flexvol_info': fake.SSC['volume2'], + 'flexvol_info': { + 'netapp_disk_type': ['SSD'], + 'pool_name': 'volume2', + }, 'extra_specs': { 'netapp_disk_type': 'SSD', 'pool_name': 'volume1', } }, { - 'flexvol_info': fake.SSC['volume2'], + 'flexvol_info': { + 'netapp_disk_type': ['SSD'], + }, 'extra_specs': { 'netapp_disk_type': 'SATA', } @@ -568,13 +654,33 @@ def test_get_ssc_volume_count_info(self, protocol): self.ssc_library = self.ssc_library_nvme if protocol == 'nvme' else \ self.ssc_library + SSC_NAMESPACES_BY_SIZES = [ + { + 'path': '/vol/namespace-ae947c9b-2392-4956-b373-aaac4521f37e', + 'size': 5379821234.0 + }, + { + 'path': '/vol/namespace-527eedad-a431-483d-b0ca-18995dd65b66', + 'size': 4673741874.0 + } + ] self.mock_object(self.ssc_library.zapi_client, 'get_namespace_sizes_by_volume', - return_value=fake.SSC_NAMESPACES_BY_SIZES) + return_value=SSC_NAMESPACES_BY_SIZES) + SSC_LUNS_BY_SIZES = [ + { + 'path': '/vol/volume-ae947c9b-2392-4956-b373-aaac4521f37e', + 'size': 5368709120.0 + }, + { + 'path': '/vol/snapshot-527eedad-a431-483d-b0ca-18995dd65b66', + 'size': 1073741824.0 + } + ] self.mock_object(self.ssc_library.zapi_client, 'get_lun_sizes_by_volume', - return_value=fake.SSC_LUNS_BY_SIZES) + return_value=SSC_LUNS_BY_SIZES) result = self.ssc_library._get_ssc_volume_count_info( fake_client.VOLUME_NAMES[0]) diff --git a/tox.ini b/tox.ini index 67a217a1615..4f8a027fc52 100644 --- a/tox.ini +++ b/tox.ini @@ -27,6 +27,8 @@ setenv = deps = -r{toxinidir}/test-requirements.txt -r{toxinidir}/requirements.txt + # we need pkg_resources because of os-win + setuptools<82 # By default stestr will set concurrency # to ncpu, to specify something else use @@ -107,6 +109,9 @@ install_command = {[testenv:py3]install_command} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt pylint==3.0.2 + # we need pkg_resources because of os-win + setuptools<82 + commands = {toxinidir}/tools/coding-checks.sh --pylint {posargs:all} @@ -153,6 +158,8 @@ allowlist_externals = rm deps = doc8 -r{toxinidir}/doc/requirements.txt + # we need pkg_resources because of os-win + setuptools<82 commands = doc8 rm -rf doc/source/contributor/api doc/build/html doc/build/doctrees From 03d317864b67ac6bccf7a9c894d1b829cb4fbe71 Mon Sep 17 00:00:00 2001 From: Simon Dodsley Date: Thu, 30 Oct 2025 07:22:49 -0400 Subject: [PATCH 34/37] [Pure Storage] Fix performance stats collection error In arrays that are very new and/or with very little or no workload, the existing call to gather the last 30 seconds of performance telemetry to send to cinder scheduler is returning an empty dictionary. This patch changes the collection parameters to increase the chance of getting a correct response, and also stops the failure should any empty response still be obtained. Closes-Bug: #2123855 Change-Id: I21ee5118eacba9c39b8b637dc54f5a6a4abc7b55 Signed-off-by: Simon Dodsley (cherry picked from commit 76053ce9aea48d3cbfa8dc8eff6c0bfba5493f9f) (cherry picked from commit aa17a6c6694b7eee01a7b2b65f3a1ff0b7874769) --- cinder/volume/drivers/pure.py | 28 ++++++++++++++++--- .../pure_perf_error-ed042fa2d16cd3ed.yaml | 4 +++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/pure_perf_error-ed042fa2d16cd3ed.yaml diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py index acbe274d30c..fc73edd2aca 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -181,6 +181,8 @@ MAX_IOPS = 100000000 # 100M MIN_BWS = 1048576 # 1 MB/s MAX_BWS = 549755813888 # 512 GB/s +ONE_HOUR = 3600000 +THIRTY_SEC = 30000 class PureDriverException(exception.VolumeDriverException): @@ -1222,11 +1224,29 @@ def _update_volume_stats(self): """Set self._stats with relevant information.""" current_array = self._get_current_array() space_info = list(current_array.get_arrays_space().items)[0] - perf_info = list(current_array.get_arrays_performance( + perf_data = current_array.get_arrays_performance( end_time=int(time.time()) * 1000, - start_time=(int(time.time()) * 1000) - 30000, - resolution=30000 - ).items)[0] + start_time=(int(time.time()) * 1000) - ONE_HOUR, + resolution=THIRTY_SEC, + total_item_count=True + ) + if perf_data.total_item_count != 0: + perf_info = list(perf_data.items)[0] + else: + class _ZeroPerf: + writes_per_sec = 0 + reads_per_sec = 0 + write_bytes_per_sec = 0 + read_bytes_per_sec = 0 + usec_per_read_op = 0 + usec_per_write_op = 0 + queue_depth = 0 + queue_usec_per_mirrored_write_op = 0 + queue_usec_per_read_op = 0 + queue_usec_per_write_op = 0 + perf_info = _ZeroPerf() + LOG.warning("No performance samples returned from array for the " + "requested interval; reporting zeroed metrics.") hosts = list(current_array.get_hosts().items) volumes = list(current_array.get_volumes().items) snaps = list(current_array.get_volume_snapshots().items) diff --git a/releasenotes/notes/pure_perf_error-ed042fa2d16cd3ed.yaml b/releasenotes/notes/pure_perf_error-ed042fa2d16cd3ed.yaml new file mode 100644 index 00000000000..a1d128bc8d4 --- /dev/null +++ b/releasenotes/notes/pure_perf_error-ed042fa2d16cd3ed.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + [Pure Storage] `bug #2123855 `_: Fixed From ab37c8517798e36793a262b0e17863f1ad0c05bb Mon Sep 17 00:00:00 2001 From: Silvan Kaiser Date: Tue, 31 Oct 2023 15:02:02 +0100 Subject: [PATCH 35/37] Fix missing encryption params in Quobyte driver Adds & handles src_encryption_key_id and new_encryption_key_id parameters in the _copy_volume_from_snapshot method of the Quobyte driver. Related-Bug: #2042102 Signed-off-by: Silvan Kaiser Change-Id: I0cbb1a432ea7ed4fa676547501a36991ce7e5e1b (cherry picked from commit 85bc28f155ec4f2e92dd3ddfbded5fe89f5e59e1) (cherry picked from commit 190c91f776364ef91ec0c27ae5f26c87a02a1d46) --- .../tests/unit/volume/drivers/test_quobyte.py | 49 +++++++++++++++---- cinder/volume/drivers/quobyte.py | 13 ++++- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/cinder/tests/unit/volume/drivers/test_quobyte.py b/cinder/tests/unit/volume/drivers/test_quobyte.py index 744e31e8e32..83f4f146945 100644 --- a/cinder/tests/unit/volume/drivers/test_quobyte.py +++ b/cinder/tests/unit/volume/drivers/test_quobyte.py @@ -1021,7 +1021,9 @@ def test_copy_volume_from_snapshot(self): self.mock_object(image_utils, 'qemu_img_info', return_value=img_info) drv._set_rw_permissions = mock.Mock() - drv._copy_volume_from_snapshot(snapshot, dest_volume, size) + drv._copy_volume_from_snapshot(snapshot, dest_volume, size, + src_encryption_key_id=None, + new_encryption_key_id=None) drv._read_info_file.assert_called_once_with(info_path) image_utils.qemu_img_info.assert_called_once_with( @@ -1074,14 +1076,17 @@ def test_copy_volume_from_snapshot_cached(self, os_ac_mock, # mocking and testing starts here mock_convert = self.mock_object(image_utils, 'convert_image') - drv._read_info_file = mock.Mock(return_value= - {'active': snap_file, - snapshot['id']: snap_file}) + drv._read_info_file = mock.Mock(return_value={ + 'active': snap_file, + snapshot['id']: snap_file + }) self.mock_object(image_utils, 'qemu_img_info', return_value=img_info) drv._set_rw_permissions = mock.Mock() shutil.copyfile = mock.Mock() - drv._copy_volume_from_snapshot(snapshot, dest_volume, size) + drv._copy_volume_from_snapshot(snapshot, dest_volume, size, + src_encryption_key_id=None, + new_encryption_key_id=None) drv._read_info_file.assert_called_once_with(info_path) image_utils.qemu_img_info.assert_called_once_with( @@ -1138,14 +1143,17 @@ def test_copy_volume_from_snapshot_not_cached_overlay(self, os_ac_mock, # mocking and testing starts here mock_convert = self.mock_object(image_utils, 'convert_image') - drv._read_info_file = mock.Mock(return_value= - {'active': snap_file, - snapshot['id']: snap_file}) + drv._read_info_file = mock.Mock(return_value={ + 'active': snap_file, + snapshot['id']: snap_file + }) self.mock_object(image_utils, 'qemu_img_info', return_value=img_info) drv._set_rw_permissions = mock.Mock() drv._create_overlay_volume_from_snapshot = mock.Mock() - drv._copy_volume_from_snapshot(snapshot, dest_volume, size) + drv._copy_volume_from_snapshot(snapshot, dest_volume, size, + src_encryption_key_id=None, + new_encryption_key_id=None) drv._read_info_file.assert_called_once_with(info_path) os_ac_mock.assert_called_once_with( @@ -1212,7 +1220,9 @@ def test_copy_volume_from_snapshot_not_cached(self, qb_falloc_mock): drv._set_rw_permissions = mock.Mock() self.mock_object(shutil, 'copyfile') - drv._copy_volume_from_snapshot(snapshot, dest_volume, size) + drv._copy_volume_from_snapshot(snapshot, dest_volume, size, + src_encryption_key_id=None, + new_encryption_key_id=None) drv._read_info_file.assert_called_once_with(info_path) image_utils.qemu_img_info.assert_called_once_with( @@ -1229,6 +1239,25 @@ def test_copy_volume_from_snapshot_not_cached(self, qb_falloc_mock): shutil.copyfile.assert_called_once_with(cache_path, dest_vol_path) drv._set_rw_permissions.assert_called_once_with(dest_vol_path) + def test_copy_volume_from_snapshot_with_encr(self): + # setup vars + drv = self._driver + src_volume = self._simple_volume() + snapshot = self._get_fake_snapshot(src_volume) + dest_volume = self._simple_volume( + id='c1073000-0000-0000-0000-0000000c1073') + size = dest_volume['size'] + + # run test + self.assertRaises(exception.NotSupportedOperation, + drv._copy_volume_from_snapshot, + snapshot, + dest_volume, + size, + src_encryption_key_id=mock.sentinel.src_key, + new_encryption_key_id=mock.sentinel.dest_key + ) + @ddt.data(['available', True], ['backing-up', True], ['creating', False], ['deleting', False]) @ddt.unpack diff --git a/cinder/volume/drivers/quobyte.py b/cinder/volume/drivers/quobyte.py index d7f3bc22e17..0e6f4d4164c 100644 --- a/cinder/volume/drivers/quobyte.py +++ b/cinder/volume/drivers/quobyte.py @@ -38,7 +38,7 @@ from cinder.volume import configuration from cinder.volume.drivers import remotefs as remotefs_drv -VERSION = '1.1.13' +VERSION = '1.1.14' LOG = logging.getLogger(__name__) @@ -119,6 +119,7 @@ class QuobyteDriver(remotefs_drv.RemoteFSSnapDriverDistributed): 1.1.11 - NAS secure ownership & permissions are now False by default 1.1.12 - Ensure the currently configured volume url is always used 1.1.13 - Allow creating volumes from snapshots in state 'backing-up' + 1.1.14 - Fixes regression from encryption being added in parent class """ @@ -383,7 +384,9 @@ def create_volume_from_snapshot(self, volume, snapshot): return self._create_volume_from_snapshot(volume, snapshot) @coordination.synchronized('{self.driver_prefix}-{volume.id}') - def _copy_volume_from_snapshot(self, snapshot, volume, volume_size): + def _copy_volume_from_snapshot(self, snapshot, volume, volume_size, + src_encryption_key_id=None, + new_encryption_key_id=None): """Copy data from snapshot to destination volume. This is done with a qemu-img convert to raw/qcow2 from the snapshot @@ -392,6 +395,12 @@ def _copy_volume_from_snapshot(self, snapshot, volume, volume_size): snapshot id are created directly from the cache. """ + if new_encryption_key_id: + msg = _("Encryption key %s was requested. Volume " + "encryption is not supported.") + raise exception.NotSupportedOperation( + message=msg % new_encryption_key_id) + LOG.debug("snapshot: %(snap)s, volume: %(vol)s, ", {'snap': snapshot.id, 'vol': volume.id, From 9c8b14cbccc960d67d0fc7d53930eb6eec1a7302 Mon Sep 17 00:00:00 2001 From: Ghanshyam Maan Date: Wed, 3 Jun 2026 15:27:16 +0000 Subject: [PATCH 36/37] Fix tempest-integrated-storage-ubuntu-jammy tempest-integrated-storage-ubuntu-jammy job run on python 3.10 which is not supported in tempest as it fails due to constraints - https://lists.openstack.org/archives/list/openstack-discuss@lists.openstack.org/thread/ONCJY4H64SQZWHJ6CXCVVJFFS6OGYDPC/ Fixing it by pinning tempest and cinder-tempest-plugin python3.10 compatible tag to test it. Removing the broken grenade jobs as they test upgrade from the EOL 2024.2 release - https://lists.openstack.org/archives/list/openstack-discuss@lists.openstack.org/thread/O6BSP3MDWOL7JQL7PN3LSBITIQ6MNUQX/ Change-Id: If96e707860cf500121601fbcdfae535b52880401 Signed-off-by: Ghanshyam Maan --- .zuul.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index 3e21bc9af3f..9f9ad0b71f3 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -80,8 +80,6 @@ irrelevant-files: *gate-irrelevant-files - cinder-tempest-plugin-protection-functional: irrelevant-files: *gate-irrelevant-files - - cinder-grenade-mn-sub-volbak: - irrelevant-files: *gate-irrelevant-files - cinder-tempest-lvm-multibackend: voting: false irrelevant-files: *gate-irrelevant-files @@ -99,8 +97,6 @@ irrelevant-files: *gate-irrelevant-files - tempest-integrated-storage-ubuntu-jammy: irrelevant-files: *gate-irrelevant-files - - grenade: - irrelevant-files: *gate-irrelevant-files - grenade-skip-level: irrelevant-files: *gate-irrelevant-files # make this template job non-voting until it gets fixed @@ -113,16 +109,12 @@ irrelevant-files: *gate-irrelevant-files gate: jobs: - - cinder-grenade-mn-sub-volbak: - irrelevant-files: *gate-irrelevant-files - cinder-plugin-ceph-tempest: irrelevant-files: *gate-irrelevant-files - tempest-integrated-storage: irrelevant-files: *gate-irrelevant-files - tempest-integrated-storage-ubuntu-jammy: irrelevant-files: *gate-irrelevant-files - - grenade: - irrelevant-files: *gate-irrelevant-files - tempest-ipv6-only: irrelevant-files: *gate-irrelevant-files - openstacksdk-functional-devstack: @@ -152,6 +144,14 @@ description: This is integrated storage job testing on Ubuntu jammy(22.04) parent: tempest-integrated-storage nodeset: openstack-single-node-jammy + required-projects: + - name: openstack/cinder-tempest-plugin + override-checkout: 1.17.0 + - name: openstack/tempest + override-checkout: 46.3.0 + vars: + devstack_localrc: + TEMPEST_VENV_UPPER_CONSTRAINTS: '/opt/stack/requirements/upper-constraints.txt' - job: # Security testing for known issues From 50dc286c6c90356cf7f2dda7132328e61482ff66 Mon Sep 17 00:00:00 2001 From: Alejandro Santoyo Date: Mon, 16 Mar 2026 12:55:23 +0100 Subject: [PATCH 37/37] Fix properties={} being sent to Glance The changes to fix LP#1527324 and LP#1823445 have as side effect that 'properties': {} may be sent to Glance when creating an image from a volume which fails. This change ensures that the code in _translate_to_glance() effectively removes the 'properties' key from the metadata dict if it is empty. Closes-Bug: #2144550 Change-Id: Ifd632433ce5759494172b74ce311816e10728d21 Signed-off-by: Alejandro Santoyo (cherry picked from commit 2096a784695d5b7304822693b595d9238b66209d) (cherry picked from commit 37aec4236a4e74b866cc71b80424300e679ec90c) (cherry picked from commit 5800c8a0820adfa285a2d93efc950491cb32a516) --- cinder/image/glance.py | 11 +++++--- cinder/tests/unit/image/test_glance.py | 12 ++++----- cinder/tests/unit/volume/test_image.py | 26 +++++++++++++++++++ .../notes/bug_2144550-aa8e6e085f6507aa.yaml | 6 +++++ 4 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/bug_2144550-aa8e6e085f6507aa.yaml diff --git a/cinder/image/glance.py b/cinder/image/glance.py index bdcc91d4b4b..baf3d3875c5 100644 --- a/cinder/image/glance.py +++ b/cinder/image/glance.py @@ -556,9 +556,12 @@ def _translate_to_glance(image_meta: dict[str, Any]) -> dict[str, Any]: # NOTE(tsekiyama): From the Image API v2, custom properties must # be stored in image_meta directly, instead of the 'properties' key. - properties = image_meta.get('properties') - if properties: - image_meta.update(properties) + # NOTE(al3jandro) ensure key deletion even when properties={} + # (LP#2144550) + if 'properties' in image_meta: + properties = image_meta['properties'] + if properties: + image_meta.update(properties) del image_meta['properties'] return image_meta @@ -583,7 +586,7 @@ def _is_image_available(self, getattr(image, 'visibility', 'private') == 'public'): return True - properties = image.properties + properties = getattr(image, 'properties', {}) if context.project_id and ('owner_id' in properties): return str(properties['owner_id']) == str(context.project_id) diff --git a/cinder/tests/unit/image/test_glance.py b/cinder/tests/unit/image/test_glance.py index 9f4d09ac9cc..52ea3ec6a8f 100644 --- a/cinder/tests/unit/image/test_glance.py +++ b/cinder/tests/unit/image/test_glance.py @@ -445,7 +445,7 @@ def test_detail_marker(self): 'status': None, 'protected': None, 'name': 'TestImage %d' % (i), - 'properties': {'properties': {}}, + 'properties': {}, 'size': None, 'min_disk': None, 'min_ram': None, @@ -502,7 +502,7 @@ def test_detail_marker_and_limit(self): 'status': None, 'protected': None, 'name': 'TestImage %d' % (i), - 'properties': {'properties': {}}, + 'properties': {}, 'size': None, 'min_disk': None, 'min_ram': None, @@ -637,7 +637,7 @@ def test_show_passes_through_to_client(self): 'updated_at': self.NOW_DATETIME, 'deleted': None, 'status': None, - 'properties': {'is_public': True, 'properties': {}}, + 'properties': {'is_public': True}, 'owner': None, 'visibility': None } @@ -664,7 +664,7 @@ def test_show_passes_when_is_admin_in_the_context(self): 'updated_at': self.NOW_DATETIME, 'deleted': None, 'status': None, - 'properties': {'properties': {}}, + 'properties': {}, 'owner': None, 'visibility': None } @@ -692,7 +692,7 @@ def test_show_passes_when_is_public_in_visibility_param(self): 'updated_at': self.NOW_DATETIME, 'deleted': None, 'status': None, - 'properties': {'properties': {}}, + 'properties': {}, 'owner': None, 'visibility': 'public' } @@ -752,7 +752,7 @@ def test_detail_passes_through_to_client(self): 'updated_at': self.NOW_DATETIME, 'deleted': None, 'status': None, - 'properties': {'is_public': True, 'properties': {}}, + 'properties': {'is_public': True}, 'owner': None, 'visibility': None }, diff --git a/cinder/tests/unit/volume/test_image.py b/cinder/tests/unit/volume/test_image.py index 979a0e3be2c..011f9b0cdba 100644 --- a/cinder/tests/unit/volume/test_image.py +++ b/cinder/tests/unit/volume/test_image.py @@ -25,6 +25,7 @@ from cinder import db from cinder import exception +from cinder.image import image_utils from cinder.message import message_field from cinder import objects from cinder.objects import fields @@ -844,3 +845,28 @@ def test_merge_volume_image_meta(self, mock_get_img_meta): # correct key_id self.assertEqual(image_meta['cinder_encryption_key_id'], sent_to_glance['cinder_encryption_key_id']) + + @mock.patch('cinder.volume.api.API.get_volume_image_metadata', + return_value={'image_id': 'base-image-id', + 'signature_verified': 'False'}) + def test_filter_reserved_namespaces_metadata(self, mock_get_img_meta): + # testing the fix for LP#2144550 + image_meta = { + 'container_format': 'bare', + 'disk_format': 'raw', + } + + volume_api = cinder.volume.api.API() + volume_api._merge_volume_image_meta(None, None, image_meta) + + self.assertIn('properties', image_meta) + self.assertIn('signature_verified', image_meta.get('properties')) + + filtered = image_utils.filter_out_reserved_namespaces_metadata( + image_meta) + self.assertEqual({}, filtered.get('properties')) + + translate = cinder.image.glance.GlanceImageService._translate_to_glance + sent_to_glance = translate(filtered) + + self.assertNotIn('properties', sent_to_glance) diff --git a/releasenotes/notes/bug_2144550-aa8e6e085f6507aa.yaml b/releasenotes/notes/bug_2144550-aa8e6e085f6507aa.yaml new file mode 100644 index 00000000000..a3fa6b48a76 --- /dev/null +++ b/releasenotes/notes/bug_2144550-aa8e6e085f6507aa.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + `Bug #2144550 `_: Fixed + a bug that could cause image creation from volumes to fail due to + incorrect handling of the volume metadata passed to Glance.