From a2b6aae46ce9db540fd1f45da0ff939464ed5116 Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Tue, 17 Mar 2026 09:32:29 +0000 Subject: [PATCH 1/9] Update .gitreview for stable/2026.1 Change-Id: I43a04fa8820bb3c1578f574d286cc1b9ca87ad30 Signed-off-by: OpenStack Release Bot Generated-By: openstack/project-config:roles/copy-release-tools-scripts/files/release-tools/functions --- .gitreview | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitreview b/.gitreview index 99feb9c2668..9d9b34e1a19 100644 --- a/.gitreview +++ b/.gitreview @@ -2,3 +2,4 @@ host=review.opendev.org port=29418 project=openstack/cinder.git +defaultbranch=stable/2026.1 From f578bc76a802cee05f34e26d4c8fc2b287665427 Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Tue, 17 Mar 2026 09:32:32 +0000 Subject: [PATCH 2/9] Update TOX_CONSTRAINTS_FILE for stable/2026.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/2026.1 branch, tests will continue to use the upper-constraints list on master. Change-Id: I3c1a9310782ac2968403e94998f87855807552d5 Signed-off-by: OpenStack Release Bot Generated-By: openstack/project-config:roles/copy-release-tools-scripts/files/release-tools/functions --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 5dabb7b3c31..0190cb729f9 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/2026.1} {opts} {packages} [testenv:functional] install_command = {[testenv:py3]install_command} @@ -150,7 +150,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/2026.1} {opts} {packages} allowlist_externals = rm deps = doc8 From e83f4cba8722dc06ab7396ce2239a272b0345da8 Mon Sep 17 00:00:00 2001 From: raghavendra-uddhav-tilay Date: Wed, 25 Mar 2026 16:13:31 +0000 Subject: [PATCH 3/9] HPE: Skip UT that fails intermittently The UT test_thread_local_sessions_are_isolated fails intermittently and causes zuul to fail unit test job failures are observed due to below test: cinder.tests.unit.volume.drivers.hpe.test_hpe3par. TestHPE3PARDriverBase.test_thread_local_sessions_are_isolated Failures are intermittent and seen mostly on the openstack-tox-py313 job test is introduced by change If3deb4a2791a05b21aa8 https://review.opendev.org/c/openstack/cinder/+/976596 This patch skips the test "test_thread_local_sessions_are_isolated". Related-Bug: #2146339 Change-Id: I11be960fc8d95e7e193bfb7bc0ef6277c54f62fe Signed-off-by: Raghavendra Tilay (cherry picked from commit 9d290e85216793eab2f79a69dd02e8d2366df0e2) --- cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py b/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py index 379a96ea6fc..a17a8f5b6d1 100644 --- a/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py +++ b/cinder/tests/unit/volume/drivers/hpe/test_hpe3par.py @@ -7163,6 +7163,7 @@ def test_driver_login_with_wrong_credential_and_replication_enabled(self): mock_client.assert_has_calls(expected) self.assertTrue(common._replication_enabled) + @test.testtools.skip("launchpad bug #2146339") def test_thread_local_sessions_are_isolated(self): self.setup_driver() session_counter = [0] From 37aec4236a4e74b866cc71b80424300e679ec90c Mon Sep 17 00:00:00 2001 From: Alejandro Santoyo Date: Mon, 16 Mar 2026 12:55:23 +0100 Subject: [PATCH 4/9] 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) --- 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 6ccfcbe05da..aaebdadaae7 100644 --- a/cinder/image/glance.py +++ b/cinder/image/glance.py @@ -643,9 +643,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 @@ -670,7 +673,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 2f4385476cf..b61261ecc6f 100644 --- a/cinder/tests/unit/image/test_glance.py +++ b/cinder/tests/unit/image/test_glance.py @@ -479,7 +479,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, @@ -536,7 +536,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, @@ -671,7 +671,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 } @@ -698,7 +698,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 } @@ -726,7 +726,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' } @@ -786,7 +786,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 72c16799a99..4a81cf3b33e 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 @@ -852,3 +853,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. From deeed8f3ff97c5a9ba37f2c46056c97e75d7fc1c Mon Sep 17 00:00:00 2001 From: hongp Date: Sat, 9 May 2026 18:56:22 +0900 Subject: [PATCH 5/9] NetApp ONTAP: honor ONTAP 9.15.1+ default for LUN space-allocation ONTAP 9.15.1 changed the default LUN space-allocation setting from disabled to enabled. The block driver was unconditionally creating LUNs with space-allocation disabled (unless the netapp:space_allocation extra spec was set), causing inconsistency between Cinder-managed volumes and ONTAP's own defaults. The driver now checks the ONTAP version when no extra spec is present: - ONTAP >= 9.15.1: space-allocation enabled (matches ONTAP default) - ONTAP < 9.15.1: space-allocation disabled (preserves existing behaviour) The explicit netapp:space_allocation extra spec continues to take precedence over version-based defaults. Closes-Bug: #2152031 Change-Id: I4dd3bbb1d634befe02cdcde0a20fab7f2f4c046a Signed-off-by: hongp (cherry picked from commit fd7d0560180ffc5bed008541c2224aa4b1cfca99) --- .../netapp/dataontap/test_block_base.py | 53 +++++++++++++++++++ .../drivers/netapp/dataontap/block_base.py | 16 ++++-- ...llocation-ontap-9151-a3f7c2d18b4e5091.yaml | 22 ++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/netapp-lun-space-allocation-ontap-9151-a3f7c2d18b4e5091.yaml 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 df14991ea75..29d163506c0 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 @@ -130,6 +130,7 @@ def test_create_volume(self): self.mock_object(self.library, '_add_lun_to_table') self.mock_object(self.library, '_mark_qos_policy_group_for_deletion') self.mock_object(self.library, '_get_volume_model_update') + self.zapi_client.get_ontap_version.return_value = (9, 14, 0) self.library.create_volume(fake.VOLUME) @@ -159,6 +160,7 @@ def test_create_volume_asar2(self): self.mock_object(self.library, '_add_lun_to_table') self.mock_object(self.library, '_mark_qos_policy_group_for_deletion') self.mock_object(self.library, '_get_volume_model_update') + self.zapi_client.get_ontap_version.return_value = (9, 14, 0) self.library.create_volume(fake.VOLUME) @@ -234,6 +236,56 @@ def test_create_volume_space_allocation_extra_spec_true(self): 0, self.library._mark_qos_policy_group_for_deletion.call_count) self.assertEqual(0, block_base.LOG.error.call_count) + def test_create_volume_space_allocation_ontap_pre_9_15_1(self): + """No extra spec + ONTAP < 9.15.1 → space_allocation disabled.""" + volume_size_in_bytes = int(fake.SIZE) * units.Gi + self.mock_object(na_utils, 'get_volume_extra_specs', + return_value={}) + self.mock_object(na_utils, 'log_extra_spec_warnings') + self.mock_object(block_base, 'LOG') + self.mock_object(volume_utils, 'extract_host', + return_value=fake.POOL_NAME) + self.mock_object(self.library, '_setup_qos_for_volume', + return_value=fake.QOS_POLICY_GROUP_INFO) + self.mock_object(self.library, '_create_lun') + self.mock_object(self.library, '_create_lun_handle') + self.mock_object(self.library, '_add_lun_to_table') + self.mock_object(self.library, '_mark_qos_policy_group_for_deletion') + self.mock_object(self.library, '_get_volume_model_update') + self.zapi_client.get_ontap_version.return_value = (9, 14, 0) + + self.library.create_volume(fake.VOLUME) + + self.library._create_lun.assert_called_once_with( + fake.POOL_NAME, fake.LUN_NAME, volume_size_in_bytes, + fake.LUN_METADATA, + fake.QOS_POLICY_GROUP_NAME, False) + + def test_create_volume_space_allocation_ontap_9_15_1(self): + """No extra spec + ONTAP >= 9.15.1 → space_allocation enabled.""" + volume_size_in_bytes = int(fake.SIZE) * units.Gi + self.mock_object(na_utils, 'get_volume_extra_specs', + return_value={}) + self.mock_object(na_utils, 'log_extra_spec_warnings') + self.mock_object(block_base, 'LOG') + self.mock_object(volume_utils, 'extract_host', + return_value=fake.POOL_NAME) + self.mock_object(self.library, '_setup_qos_for_volume', + return_value=fake.QOS_POLICY_GROUP_INFO) + self.mock_object(self.library, '_create_lun') + self.mock_object(self.library, '_create_lun_handle') + self.mock_object(self.library, '_add_lun_to_table') + self.mock_object(self.library, '_mark_qos_policy_group_for_deletion') + self.mock_object(self.library, '_get_volume_model_update') + self.zapi_client.get_ontap_version.return_value = (9, 15, 1) + + self.library.create_volume(fake.VOLUME) + + self.library._create_lun.assert_called_once_with( + fake.POOL_NAME, fake.LUN_NAME, volume_size_in_bytes, + fake.LUN_METADATA_WITH_SPACE_ALLOCATION, + fake.QOS_POLICY_GROUP_NAME, False) + def test_create_volume_no_pool(self): self.mock_object(volume_utils, 'extract_host', return_value=None) @@ -259,6 +311,7 @@ def test_create_volume_exception_path(self): return_value=fake.QOS_POLICY_GROUP_INFO) self.mock_object(self.library, '_create_lun', side_effect=Exception) self.mock_object(self.library, '_mark_qos_policy_group_for_deletion') + self.zapi_client.get_ontap_version.return_value = (9, 14, 0) self.assertRaises(exception.VolumeBackendAPIException, self.library.create_volume, fake.VOLUME) diff --git a/cinder/volume/drivers/netapp/dataontap/block_base.py b/cinder/volume/drivers/netapp/dataontap/block_base.py index 32213a299c1..7e1a855a20b 100644 --- a/cinder/volume/drivers/netapp/dataontap/block_base.py +++ b/cinder/volume/drivers/netapp/dataontap/block_base.py @@ -238,9 +238,19 @@ def create_volume(self, volume): extra_specs = na_utils.get_volume_extra_specs(volume) - space_allocation = volume_utils.is_boolean_str( - extra_specs.get('netapp:space_allocation') - ) + ontap_version = self.zapi_client.get_ontap_version(cached=True) + space_allocation_spec = extra_specs.get('netapp:space_allocation') + if space_allocation_spec is not None: + space_allocation = volume_utils.is_boolean_str( + space_allocation_spec + ) + elif ontap_version >= (9, 15, 1): + # ONTAP 9.15.1+ enables space-allocation by default for new LUNs + # Ref: https://docs.netapp.com/us-en/ontap/release-notes/ + # defaults-limits.html + space_allocation = True + else: + space_allocation = False LOG.debug('create_volume space_allocation %r', space_allocation) lun_name = volume['name'] diff --git a/releasenotes/notes/netapp-lun-space-allocation-ontap-9151-a3f7c2d18b4e5091.yaml b/releasenotes/notes/netapp-lun-space-allocation-ontap-9151-a3f7c2d18b4e5091.yaml new file mode 100644 index 00000000000..219c6daaac6 --- /dev/null +++ b/releasenotes/notes/netapp-lun-space-allocation-ontap-9151-a3f7c2d18b4e5091.yaml @@ -0,0 +1,22 @@ +--- +fixes: + - | + NetApp ONTAP block driver + `bug #2152031 `_: + Fixed an inconsistency in the NetApp ONTAP block driver where LUN + space-allocation (thin-provisioning SCSI UNMAP support) was always + disabled when creating volumes, regardless of the ONTAP version in + use. + + Starting with ONTAP 9.15.1, NetApp changed the default LUN + space-allocation setting from disabled to enabled. The driver now + reflects this: when the ``netapp:space_allocation`` extra spec is + not set, the driver checks the ONTAP version and enables + space-allocation by default for ONTAP 9.15.1 and later, preserving + the previous disabled default for older versions. + + The explicit ``netapp:space_allocation`` extra spec continues to + take precedence over version-based defaults. + + `ONTAP defaults and limits + `_ \ No newline at end of file From 4c63df3db31e6f7c954b17be28413fc465dbedd9 Mon Sep 17 00:00:00 2001 From: kurtyoon Date: Fri, 5 Jun 2026 13:18:19 +0900 Subject: [PATCH 6/9] ceph: advance dest offset when discarding zero chunks Restoring a backup to a new volume sets discard_zeros, so _transfer_data() skips all-zero chunks. The Ceph driver skipped them without moving the destination offset, so every following non-zero chunk was written too early and the restored volume came back shifted and corrupted. An encrypted (LUKS) volume hits this reliably: it is sparse, the restore takes the full non-diff path, and a freshly created target sets discard_zeros=True. The restore reports success and the corruption only shows up later when the guest cannot mount the volume. Advance the destination offset by the number of bytes just read whenever a zero chunk is discarded, the same bookkeeping the chunked driver already does on its non-zero writes. Closes-Bug: #2155612 Change-Id: Idb7b709ba3f6c42ab237e717b0e58db365a7567c Signed-off-by: kurtyoon (cherry picked from commit 8d4a9e90242b5ab29d7aefc2c63ec3ae68d1e63f) --- cinder/backup/drivers/ceph.py | 4 +++ .../unit/backup/drivers/test_backup_ceph.py | 33 +++++++++++++++++++ ...restore-corruption-2155612a1b2c3d4e5f.yaml | 11 +++++++ 3 files changed, 48 insertions(+) create mode 100644 releasenotes/notes/fix-ceph-backup-sparse-restore-corruption-2155612a1b2c3d4e5f.yaml diff --git a/cinder/backup/drivers/ceph.py b/cinder/backup/drivers/ceph.py index 21e0664569c..ab7807ce074 100644 --- a/cinder/backup/drivers/ceph.py +++ b/cinder/backup/drivers/ceph.py @@ -458,6 +458,10 @@ def _transfer_data(self, return if (discard_zeros and volume_utils.is_all_zero(data)): + # Skip writing the zero chunk but still move the destination + # forward, otherwise the next chunk overwrites it and the + # restored volume ends up shifted (bug #2155612). + dest.seek(len(data), os.SEEK_CUR) action = "Discarded" else: dest.write(data) diff --git a/cinder/tests/unit/backup/drivers/test_backup_ceph.py b/cinder/tests/unit/backup/drivers/test_backup_ceph.py index a16042b751b..347a488f0a3 100644 --- a/cinder/tests/unit/backup/drivers/test_backup_ceph.py +++ b/cinder/tests/unit/backup/drivers/test_backup_ceph.py @@ -425,6 +425,39 @@ def test_transfer_data_from_file_to_file(self): # Ensure the files are equal self.assertEqual(checksum.digest(), self.checksum.digest()) + @common_mocks + def test_transfer_data_discard_zeros_advances_offset(self): + # bug #2155612: a discarded zero chunk must still advance the + # destination offset, else later chunks land at the wrong place and + # a sparse volume restored to a new volume comes back shifted. + chunk = self.chunk_size + self.service.chunk_size = chunk + src_data = (b'\xab' * chunk + b'\x00' * chunk + b'\xcd' * chunk + + b'\x00' * chunk + b'\xef' * chunk) + dest_data = bytearray(len(src_data)) + + def fake_read(offset, length): + return src_data[offset:offset + length] + + def fake_write(data, offset): + dest_data[offset:offset + len(data)] = data + + src_rbd = mock.Mock() + src_rbd.read.side_effect = fake_read + src_rbd.size.return_value = len(src_data) + dest_rbd = mock.Mock() + dest_rbd.write.side_effect = fake_write + dest_rbd.size.return_value = len(src_data) + + src_io = self._get_wrapped_rbd_io(src_rbd) + dest_io = self._get_wrapped_rbd_io(dest_rbd) + + with mock.patch.object(ceph.time, 'time', self.time_inc): + self.service._transfer_data(src_io, 'src_foo', dest_io, 'dest_foo', + len(src_data), discard_zeros=True) + + self.assertEqual(src_data, bytes(dest_data)) + @common_mocks def test_backup_volume_from_file(self): checksum = hashlib.sha256() diff --git a/releasenotes/notes/fix-ceph-backup-sparse-restore-corruption-2155612a1b2c3d4e5f.yaml b/releasenotes/notes/fix-ceph-backup-sparse-restore-corruption-2155612a1b2c3d4e5f.yaml new file mode 100644 index 00000000000..c22851d59d2 --- /dev/null +++ b/releasenotes/notes/fix-ceph-backup-sparse-restore-corruption-2155612a1b2c3d4e5f.yaml @@ -0,0 +1,11 @@ +--- +fixes: + - | + Ceph backup driver `bug #2155612 + `_: Fixed data corruption + when restoring a sparse volume (such as a LUKS-encrypted one) to a new + volume. The driver skipped all-zero chunks without advancing the + destination offset, so every subsequent non-zero chunk was written at the + wrong location and the restored volume was silently truncated even though + the restore reported success. The destination offset is now advanced when + a zero chunk is discarded, so the restored layout matches the source. From c3d5787c2c621b20d6ffad7b205b66f041cd8664 Mon Sep 17 00:00:00 2001 From: Simon Dodsley Date: Thu, 25 Jun 2026 14:02:37 -0400 Subject: [PATCH 7/9] [Everpure] Fix QoS for py-pure-client 1.86.0+ py-pure-client 1.86.0 (FlashArray REST API 2.52) changed the Patch models so that ``VolumePatch.qos`` and ``VolumeGroupPatch.qos`` no longer accept a flat ``Qos`` object with integer ``iops_limit``/``bandwidth_limit`` values. They now require a ``QosPatch`` whose limits are wrapped in ``QosIopsLimitPatch`` and ``QosBandwidthLimitPatch`` objects. As a result, any operation that set QoS through a volume *patch* -- most visibly creating a volume from a snapshot, which always patches the new volume's QoS -- failed with:: pydantic.v1.error_wrappers.ValidationError: 2 validation errors for VolumePatch qos -> bandwidth_limit value is not a valid dict (type=type_error.dict) qos -> iops_limit value is not a valid dict (type=type_error.dict) leaving the volume in ERROR state. Build the QoS object for the ``*Patch`` requests with the new ``QosPatch``/``QosIopsLimitPatch``/``QosBandwidthLimitPatch`` models via a small helper. The ``*Post`` models (``VolumePost``/``VolumeGroupPost``) still take a flat ``Qos`` with integer limits in all supported SDK releases, so those call sites are unchanged. The minimum py-pure-client version is raised to 1.89.0, which provides the new models. Add a unit test that exercises ``set_qos`` across the four IOPS/bandwidth combinations and asserts the ``QosPatch``/``QosIopsLimitPatch``/ ``QosBandwidthLimitPatch`` models are used (and the flat ``Qos`` is not), so a regression back to the flat ``Qos`` form is caught. This test was contributed by Jon Bernard as a follow-up change and squashed in here. Closes-Bug: #2158313 Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Jon Bernard Change-Id: Ife6a7f42778c065a70af4752e12661cec34bf7b2 Signed-off-by: Simon Dodsley (cherry picked from commit 635e41dfc66d7257192a9c693af201797e010d99) --- cinder/tests/unit/volume/drivers/test_pure.py | 52 +++++++++++++++++++ cinder/volume/drivers/pure.py | 34 ++++++++---- pyproject.toml | 4 +- ...fix-qos-patch-models-9b1c0c1f0a3d4e5b.yaml | 10 ++++ 4 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 releasenotes/notes/pure-fix-qos-patch-models-9b1c0c1f0a3d4e5b.yaml diff --git a/cinder/tests/unit/volume/drivers/test_pure.py b/cinder/tests/unit/volume/drivers/test_pure.py index 4e1867a54a7..a008e2977bb 100644 --- a/cinder/tests/unit/volume/drivers/test_pure.py +++ b/cinder/tests/unit/volume/drivers/test_pure.py @@ -5003,6 +5003,58 @@ def test_retype_qos_reset_iops(self, mock_fa): self.assertTrue(did_retype) self.assertIsNone(model_update) + @ddt.data( + # maxIOPS and maxBWS both set -> use both, untouched. + {"qos": {"maxIOPS": 100, "maxBWS": 1048576, + "maxIOPS_per_GB": 0, "maxBWS_per_GB": 0}, + "exp_iops": 100, "exp_bws": 1048576}, + # maxIOPS unset -> falls back to MAX_IOPS. + {"qos": {"maxIOPS": 0, "maxBWS": 1048576, + "maxIOPS_per_GB": 0, "maxBWS_per_GB": 0}, + "exp_iops": MAX_IOPS, "exp_bws": 1048576}, + # maxBWS unset -> falls back to MAX_BWS. + {"qos": {"maxIOPS": 100, "maxBWS": 0, + "maxIOPS_per_GB": 0, "maxBWS_per_GB": 0}, + "exp_iops": 100, "exp_bws": MAX_BWS}, + # neither set -> both fall back to the maximums. + {"qos": {"maxIOPS": 0, "maxBWS": 0, + "maxIOPS_per_GB": 0, "maxBWS_per_GB": 0}, + "exp_iops": MAX_IOPS, "exp_bws": MAX_BWS}, + ) + # Patch the individual models so each gets a fresh, isolated mock. The + # ``flasharray`` module is a process-wide Mock (see the module-level + # ``sys.modules['pypureclient']`` stub), so relying on it directly and + # resetting it counts calls from other tests in the same worker. + @mock.patch(DRIVER_PATH + ".flasharray.VolumePatch") + @mock.patch(DRIVER_PATH + ".flasharray.QosPatch") + @mock.patch(DRIVER_PATH + ".flasharray.QosBandwidthLimitPatch") + @mock.patch(DRIVER_PATH + ".flasharray.QosIopsLimitPatch") + @mock.patch(DRIVER_PATH + ".flasharray.Qos") + def test_set_qos_uses_qos_patch_models(self, data, mock_qos, + mock_iops_patch, mock_bws_patch, + mock_qos_patch, mock_vol_patch): + qos = data["qos"] + exp_iops = data["exp_iops"] + exp_bws = data["exp_bws"] + _, vol_name = self.new_fake_vol() + + self.driver.set_qos(self.array, vol_name, 1, qos) + + # The wrapped limit models must be used with the expected values... + mock_iops_patch.assert_called_once_with(exp_iops) + mock_bws_patch.assert_called_once_with(exp_bws) + # ...wrapped inside a QosPatch (not a flat Qos)... + mock_qos_patch.assert_called_once_with( + iops_limit=mock_iops_patch.return_value, + bandwidth_limit=mock_bws_patch.return_value) + mock_qos.assert_not_called() + # ...and applied to the volume via a VolumePatch. + mock_vol_patch.assert_called_once_with( + qos=mock_qos_patch.return_value) + self.array.patch_volumes.assert_called_once_with( + names=[vol_name], + volume=mock_vol_patch.return_value) + class PureISCSIDriverTestCase(PureBaseSharedDriverTestCase): diff --git a/cinder/volume/drivers/pure.py b/cinder/volume/drivers/pure.py index c8c38e56c49..83c2f8b3efd 100644 --- a/cinder/volume/drivers/pure.py +++ b/cinder/volume/drivers/pure.py @@ -199,6 +199,22 @@ TAG_NAMESPACE = "openstack-integration.purestorage.com" +def _build_qos_patch(iops_limit, bandwidth_limit): + """Build the QoS object for a VolumePatch/VolumeGroupPatch request. + + FlashArray REST API 2.52 (py-pure-client>=1.86.0) changed the ``*Patch`` + models so that ``VolumePatch.qos`` and ``VolumeGroupPatch.qos`` take a + ``QosPatch`` whose ``iops_limit``/``bandwidth_limit`` are wrapped objects + (``QosIopsLimitPatch``/``QosBandwidthLimitPatch``) rather than a flat + ``Qos`` with integer limits. The ``*Post`` models (VolumePost/ + VolumeGroupPost) still take a flat ``Qos``, so those call sites are left + unchanged. + """ + return flasharray.QosPatch( + iops_limit=flasharray.QosIopsLimitPatch(iops_limit), + bandwidth_limit=flasharray.QosBandwidthLimitPatch(bandwidth_limit)) + + class PureDriverException(exception.VolumeDriverException): message = _("Pure Storage Cinder driver failure: %(reason)s") @@ -382,25 +398,25 @@ def set_qos(self, array, vol_name, vol_size, qos): if qos['maxIOPS'] == 0 and qos['maxBWS'] == 0: array.patch_volumes(names=[vol_name], volume=flasharray.VolumePatch( - qos=flasharray.Qos( + qos=_build_qos_patch( iops_limit=MAX_IOPS, bandwidth_limit=MAX_BWS))) elif qos['maxIOPS'] == 0: array.patch_volumes(names=[vol_name], volume=flasharray.VolumePatch( - qos=flasharray.Qos( + qos=_build_qos_patch( iops_limit=MAX_IOPS, bandwidth_limit=qos['maxBWS']))) elif qos['maxBWS'] == 0: array.patch_volumes(names=[vol_name], volume=flasharray.VolumePatch( - qos=flasharray.Qos( + qos=_build_qos_patch( iops_limit=qos['maxIOPS'], bandwidth_limit=MAX_BWS))) else: array.patch_volumes(names=[vol_name], volume=flasharray.VolumePatch( - qos=flasharray.Qos( + qos=_build_qos_patch( iops_limit=qos['maxIOPS'], bandwidth_limit=qos['maxBWS']))) return @@ -885,7 +901,7 @@ def create_volume_from_snapshot(self, volume, snapshot, cgsnapshot=False): else: current_array.patch_volumes(names=[vol_name], volume=flasharray.VolumePatch( - qos=flasharray.Qos( + qos=_build_qos_patch( iops_limit=MAX_IOPS, bandwidth_limit=MAX_BWS))) @@ -2153,8 +2169,8 @@ def manage_existing(self, volume, existing_ref): current_array.patch_volumes( names=[new_vol_name], volume=flasharray.VolumePatch( - qos=flasharray.Qos(iops_limit=MAX_IOPS, - bandwidth_limit=MAX_BWS))) + qos=_build_qos_patch(iops_limit=MAX_IOPS, + bandwidth_limit=MAX_BWS))) # If we are managing to a volume type that is a volume group # make sure that the target volume group exists with the # correct QoS settings. @@ -3136,7 +3152,7 @@ def retype(self, context, volume, new_type, diff, host): else: current_array.patch_volumes(names=[vol_name], volume=flasharray.VolumePatch( - qos=flasharray.Qos( + qos=_build_qos_patch( iops_limit=MAX_IOPS, bandwidth_limit=MAX_BWS))) @@ -3625,7 +3641,7 @@ def _create_volume_group_if_not_exist(self, res = source_array.patch_volume_groups( names=[vgname], volume_group=flasharray.VolumeGroupPatch( - qos=flasharray.Qos( + qos=_build_qos_patch( bandwidth_limit=vg_bws, iops_limit=vg_iops))) if res.status_code == 400: diff --git a/pyproject.toml b/pyproject.toml index 4eb0f976efb..8ce5d6d0cd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ all = [ "python-3parclient>=4.2.10", # Apache-2.0 "krest>=1.3.0", # Apache-2.0 "infinisdk>=103.0.1", # BSD-3 - "py-pure-client>=1.47.0", # BSD + "py-pure-client>=1.89.0", # BSD "rsd-lib>=1.1.0", # Apache-2.0 "storpool>=7.1.0", # Apache-2.0 "storpool.spopenstack>=2.2.1", # Apache-2.0 @@ -122,7 +122,7 @@ infinidat = [ "infinisdk>=103.0.1", # BSD-3 ] pure = [ - "py-pure-client>=1.47.0", # BSD + "py-pure-client>=1.89.0", # BSD ] rsd = [ "rsd-lib>=1.1.0", # Apache-2.0 diff --git a/releasenotes/notes/pure-fix-qos-patch-models-9b1c0c1f0a3d4e5b.yaml b/releasenotes/notes/pure-fix-qos-patch-models-9b1c0c1f0a3d4e5b.yaml new file mode 100644 index 00000000000..e58207abe87 --- /dev/null +++ b/releasenotes/notes/pure-fix-qos-patch-models-9b1c0c1f0a3d4e5b.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + Everpure driver `bug #2158313 + `_: Fixed + creating a volume from a snapshot, and + setting QoS limits, with newer ``py-pure-client`` releases. +upgrade: + - | + The Everpure driver now requires ``py-pure-client>=1.89.0``. From b06b7e8d84cd680da53ee95fc34c2b7f1518dfdd Mon Sep 17 00:00:00 2001 From: Eric Harney Date: Fri, 12 Jun 2026 15:58:36 -0400 Subject: [PATCH 8/9] requirements: Fix duplicate "packaging" entry We merged a second entry for "packaging" to requirements.txt, now the requirements-check gate job is failing with: ERROR: Package 'packaging' requirement does not match number of lines (1) in openstack/requirements Change-Id: I3b5c62aa356f6bcffe2d927c18c3b8c24c68ad54 Signed-off-by: Eric Harney (cherry picked from commit cee90b0e496110c48166e0056a908dbb5356ab10) --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7fae990da4b..7f9d5bdf3f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ oslo.upgradecheck>=1.1.1 # Apache-2.0 oslo.utils>=6.0.0 # Apache-2.0 oslo.versionedobjects>=2.4.0 # Apache-2.0 osprofiler>=3.4.0 # Apache-2.0 -packaging>=20.4 +packaging>=20.9 # Apache-2.0 paramiko>=2.7.2 # LGPLv2.1+ Paste>=3.4.3 # MIT PasteDeploy>=2.1.0 # MIT @@ -60,4 +60,3 @@ boto3>=1.18.49 # Apache-2.0 distro>=1.8.0 # Apache-2.0 tzdata>=2022.4 # MIT cachetools>=4.2.1 # MIT -packaging>=20.9 # Apache-2.0 From 429a763449c46e693fe60b95f73022273f1f77d0 Mon Sep 17 00:00:00 2001 From: hongp Date: Mon, 26 Jan 2026 20:30:20 +0900 Subject: [PATCH 9/9] Fix missing volume delete notifications on group delete When deleting a group with the delete-volumes flag set to True, the volumes associated with the group are deleted, but the 'volume.delete.end' notifications are not emitted. This occurs because the group delete flow in the manager does not trigger the standard notification logic used in individual volume deletes. This patch ensures that 'volume.delete.start' and 'volume.delete.end' notifications are correctly sent for each volume processed during a group deletion. Closes-Bug: #2139068 Change-Id: I2abff84798f54f207ce53ab5afedf41e26348648 Signed-off-by: hongp (cherry picked from commit cb63d6f58c6d01fc249e5f77b7f71f9098b315a8) --- cinder/tests/unit/group/test_groups_manager.py | 11 +++++++---- cinder/volume/manager.py | 4 ++-- releasenotes/notes/bug-2139068-b65849ce5771cb1b.yaml | 8 ++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 releasenotes/notes/bug-2139068-b65849ce5771cb1b.yaml diff --git a/cinder/tests/unit/group/test_groups_manager.py b/cinder/tests/unit/group/test_groups_manager.py index d4d6f07a95c..2f1024c1b8c 100644 --- a/cinder/tests/unit/group/test_groups_manager.py +++ b/cinder/tests/unit/group/test_groups_manager.py @@ -504,21 +504,24 @@ def test_create_group_from_src(self, self.notifier.notifications) self.volume.delete_group(self.context, group2) - - if len(self.notifier.notifications) > 9: - self.assertFalse(self.notifier.notifications[10], + if len(self.notifier.notifications) > 10: + self.assertFalse(self.notifier.notifications[11], self.notifier.notifications) - self.assertEqual(9, len(self.notifier.notifications), + self.assertEqual(10, len(self.notifier.notifications), self.notifier.notifications) msg = self.notifier.notifications[6] self.assertEqual('group.delete.start', msg['event_type']) expected['status'] = fields.GroupStatus.AVAILABLE self.assertDictEqual(expected, msg['payload']) + msg = self.notifier.notifications[7] + self.assertEqual('volume.delete.start', msg['event_type']) msg = self.notifier.notifications[8] self.assertEqual('group.delete.end', msg['event_type']) expected['status'] = fields.GroupStatus.DELETED self.assertDictEqual(expected, msg['payload']) + msg = self.notifier.notifications[9] + self.assertEqual('volume.delete.end', msg['event_type']) grp2 = objects.Group.get_by_id( context.get_admin_context(read_deleted='yes'), group2.id) diff --git a/cinder/volume/manager.py b/cinder/volume/manager.py index aa9cfa806c4..84cd98a3beb 100644 --- a/cinder/volume/manager.py +++ b/cinder/volume/manager.py @@ -3803,7 +3803,7 @@ def delete_group(self, self._check_is_our_resource(vol_obj) self._notify_about_group_usage( - context, group, "delete.start") + context, group, "delete.start", volumes=volumes) volumes_model_update = None model_update = None @@ -3906,7 +3906,7 @@ def delete_group(self, group.destroy() self._notify_about_group_usage( - context, group, "delete.end") + context, group, "delete.end", volumes=volumes) self.publish_service_capabilities(context) LOG.info("Delete group " "completed successfully.", diff --git a/releasenotes/notes/bug-2139068-b65849ce5771cb1b.yaml b/releasenotes/notes/bug-2139068-b65849ce5771cb1b.yaml new file mode 100644 index 00000000000..0576944476b --- /dev/null +++ b/releasenotes/notes/bug-2139068-b65849ce5771cb1b.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + `Bug #2139068 `_: Fixed + an issue where ``volume.delete.end`` notifications were not sent when + volumes were deleted as part of a group deletion process. This ensures + delete notifications are properly emitted for every volume removed during + group deletion.