From 49694e2ee03c81239d7c16c6f8d554f174ea10f7 Mon Sep 17 00:00:00 2001 From: sapana05 Date: Mon, 24 Aug 2026 05:29:31 -0400 Subject: [PATCH 1/3] Add force parameter to failover method in RCG SDK - Add force parameter to failover method with default value False - Update method signature to match switchover method pattern - Add force parameter to URL parameters for API call - Update docstring to document the new force parameter This change makes the failover method consistent with the switchover method, allowing optional force parameter for failover operations while maintaining backward compatibility with default False value. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../objects/gen1/replication_consistency_group.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/PyPowerFlex/objects/gen1/replication_consistency_group.py b/PyPowerFlex/objects/gen1/replication_consistency_group.py index 23e5256..1fd46c4 100644 --- a/PyPowerFlex/objects/gen1/replication_consistency_group.py +++ b/PyPowerFlex/objects/gen1/replication_consistency_group.py @@ -177,15 +177,18 @@ def resume(self, rcg_id): return self._perform_entity_operation_based_on_action(rcg_id, "resume") - def failover(self, rcg_id): + def failover(self, rcg_id, force=False): """Failover PowerFlex RCG. :param rcg_id: str + :param force: bool :return: dict """ - + url_params = { + 'force': force + } return self._perform_entity_operation_based_on_action( - rcg_id, "failover") + rcg_id, "failover", **url_params) def sync(self, rcg_id): """Synchronize PowerFlex RCG. From c5f1db964346c3f4792dbe2e5778fe37da8d1e96 Mon Sep 17 00:00:00 2001 From: sapana05 Date: Mon, 24 Aug 2026 07:25:00 -0400 Subject: [PATCH 2/3] Add force parameter to reverse method in RCG SDK - Add force parameter to reverse method with default value False - Update method signature to match failover and switchover pattern - Add force parameter to URL parameters for API call - Update docstring to document the new force parameter This change makes the reverse method consistent with failover and switchover methods, allowing optional force parameter for reverse operations while maintaining backward compatibility with default False value. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- PyPowerFlex/objects/gen1/replication_consistency_group.py | 1 - 1 file changed, 1 deletion(-) diff --git a/PyPowerFlex/objects/gen1/replication_consistency_group.py b/PyPowerFlex/objects/gen1/replication_consistency_group.py index 1fd46c4..2d3a326 100644 --- a/PyPowerFlex/objects/gen1/replication_consistency_group.py +++ b/PyPowerFlex/objects/gen1/replication_consistency_group.py @@ -216,7 +216,6 @@ def reverse(self, rcg_id): :param rcg_id: str :return: dict """ - return self._perform_entity_operation_based_on_action( rcg_id, "reverse") From 6df583c79a01d23db9fb869ce91fcf0e2d47425e Mon Sep 17 00:00:00 2001 From: sapana05 Date: Tue, 8 Sep 2026 04:03:22 -0400 Subject: [PATCH 3/3] Detect PowerFlex generation from component version. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- PyPowerFlex/__init__.py | 39 +++++- PyPowerFlex/objects/common/system.py | 65 +++++++++ tests/common/__init__.py | 34 ++++- tests/common/test_generation.py | 196 +++++++++++++++++++++++++++ tests/gen2/test_system.py | 11 +- 5 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 tests/common/test_generation.py diff --git a/PyPowerFlex/__init__.py b/PyPowerFlex/__init__.py index c48fa1f..22e69ad 100644 --- a/PyPowerFlex/__init__.py +++ b/PyPowerFlex/__init__.py @@ -17,6 +17,8 @@ # pylint: disable=invalid-name,too-many-arguments,too-many-positional-arguments +import logging + from packaging import version from PyPowerFlex import configuration @@ -27,6 +29,8 @@ from PyPowerFlex.objects import gen1 from PyPowerFlex.objects import gen2 +LOG = logging.getLogger(__name__) + __all__ = [ 'PowerFlexClient' ] @@ -114,13 +118,40 @@ def initialize(self): '3.0 are not supported.' ) - if version.parse(self.system.api_version()) > version.Version('3.0') and \ - version.parse(self.system.api_version()) < version.Version('5.0'): - self.add_objects_gen1() - elif version.parse(self.system.api_version()) >= version.Version('5.0'): + if self.__is_gen2(): self.add_objects_gen2() + else: + self.add_objects_gen1() self.__is_initialized = True + def __is_gen2(self): + """Check whether the system must be handled as a Gen2 system. + + The API version alone is not sufficient: the REST API and the + PowerFlex components are upgraded independently, so a Gen1 system + running components 4.5.x can already expose API version 5.1. Such a + system must still be driven with the Gen1 objects. + + An API version below 5.0 is only served by Gen1 systems, so it is + conclusive on its own. From API 5.0 onwards the component version + decides, since that is the version of PowerFlex itself. + + :rtype: bool + """ + + api_version = self.system.api_version() + if version.parse(api_version) < version.Version('5.0'): + return False + + component_version = self.system.component_version() + is_gen2 = version.parse(component_version) >= version.Version('5.0') + if not is_gen2: + LOG.info( + "PowerFlex API version %s is served by component version %s, " + "handling the system as Gen1.", api_version, component_version + ) + return is_gen2 + def add_objects_common(self): """Add common objects here.""" self.__add_storage_entity('system', common.System) diff --git a/PyPowerFlex/objects/common/system.py b/PyPowerFlex/objects/common/system.py index 2290f37..2d88c22 100644 --- a/PyPowerFlex/objects/common/system.py +++ b/PyPowerFlex/objects/common/system.py @@ -59,6 +59,7 @@ class System(base_client.EntityRequest): def __init__(self, token, configuration): self.__api_version = None self.__pfmp_version = None + self.__component_version = None super().__init__(token, configuration) def api_version(self, cached=True): @@ -105,6 +106,70 @@ def pfmp_version(self, cached=True): self.__pfmp_version = response.get('clusterVersion') return self.__pfmp_version + def component_version(self, cached=True): + """Get the PowerFlex component (Core/MDM) version. + + The component version is the version of the PowerFlex software itself, + which may differ from the REST API version reported by + :meth:`api_version`. For example, a system can expose API version 5.1 + while still running component version 4.5.x, because the API and the + components are upgraded independently. + + The version is read from the MDM cluster master (``versionInfo``) and + falls back to ``systemVersionName``. Values such as ``R4_5.6000.162`` + or ``DellEMC PowerFlex Version: R4_5.6000.162`` are normalised to + ``4.5.6000.162``. + + If the component version cannot be determined, the API version is + returned so that callers keep the previous API-version based + behaviour instead of guessing a generation. + + :param cached: get component version from cache or send API response + :type cached: bool + :rtype: str + """ + + if self.__component_version and cached: + return self.__component_version + + raw_version = None + try: + system_info = self.get() + if system_info: + system = system_info[0] + raw_version = ( + system.get('mdmCluster', {}).get('master', {}).get('versionInfo') + or system.get('systemVersionName') + ) + except Exception as e: + LOG.debug("Failed to query the component version: %s", e) + + self.__component_version = self.__normalize_version(raw_version) \ + or self.api_version() + return self.__component_version + + @staticmethod + def __normalize_version(raw_version): + """Normalise a PowerFlex version string to a comparable version. + + e.g. ``DellEMC PowerFlex Version: R4_5.6000.162`` -> ``4.5.6000.162`` + + :param raw_version: version string reported by the system + :type raw_version: str + :rtype: str + """ + + if not raw_version: + return None + match = re.search(r'R\s*(\d+)[._](\d+)([\d.]*)', raw_version) + if not match: + LOG.warning( + "Could not determine the component version from '%s'.", + raw_version + ) + return None + return f"{match.group(1)}.{match.group(2)}{match.group(3)}" + def remove_cg_snapshots(self, system_id, cg_id, allow_ext_managed=None): """Remove PowerFlex ConsistencyGroup snapshots. diff --git a/tests/common/__init__.py b/tests/common/__init__.py index 7e4b68b..0f99af0 100644 --- a/tests/common/__init__.py +++ b/tests/common/__init__.py @@ -83,11 +83,39 @@ class PyPowerFlexTestCase(TestCase): Provides a mocked HTTP response for testing. """ VERSION_API_PATH = '/version' + SYSTEM_API_PATH = '/types/System/instances' + + @staticmethod + def system_instances(component_version): + """ + Build a System instances response reporting the given component version. + + Args: + component_version (str): Component version, e.g. '4.5'. + + Returns: + list: The mocked System instances response. + """ + version_info = f"R{component_version.replace('.', '_', 1)}.0.0" + return [ + { + 'id': '1', + 'mdmCluster': {'master': {'versionInfo': version_info}}, + 'systemVersionName': + f'DellEMC PowerFlex Version: {version_info}', + } + ] @classmethod - def version(cls, new_version): + def version(cls, new_version, component_version=None): """ Decorator for mocking the version API version. + + Args: + new_version (str): The REST API version of the system. + component_version (str): Optional component (Core/MDM) version, + when it differs from the API version. Defaults to the API + version, i.e. a system whose API and components are in sync. """ def decorator(subclass): @@ -96,6 +124,10 @@ def decorator(subclass): subclass.DEFAULT_MOCK_RESPONSES[ cls.RESPONSE_MODE.Valid ][cls.VERSION_API_PATH] = new_version + subclass.DEFAULT_MOCK_RESPONSES[ + cls.RESPONSE_MODE.Valid + ][cls.SYSTEM_API_PATH] = cls.system_instances( + component_version or new_version) return subclass return decorator diff --git a/tests/common/test_generation.py b/tests/common/test_generation.py new file mode 100644 index 0000000..205f6c0 --- /dev/null +++ b/tests/common/test_generation.py @@ -0,0 +1,196 @@ +# Copyright (c) 2024 Dell Inc. or its subsidiaries. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Module for testing the Gen1/Gen2 detection done on client initialization.""" + +# pylint: disable=duplicate-code + +from PyPowerFlex.objects import gen1 +from PyPowerFlex.objects import gen2 +from tests.common import PyPowerFlexTestCase + + +@PyPowerFlexTestCase.version('4.5') +class TestGen1Client(PyPowerFlexTestCase): + """ + Test the detection of a Gen1 system, where the API version and the + component version are in sync. + """ + + def setUp(self): + """ + Set up the test environment. + """ + super().setUp() + self.client.initialize() + + def test_gen1_objects_are_used(self): + """ + Test that the Gen1 objects are used. + """ + self.assertIsInstance(self.client.device, gen1.Device) + self.assertIsInstance(self.client.storage_pool, gen1.StoragePool) + + def test_gen2_only_objects_are_not_available(self): + """ + Test that the Gen2 only objects are not available. + """ + self.assertFalse(hasattr(self.client, 'device_group')) + self.assertFalse(hasattr(self.client, 'storage_node')) + + def test_gen1_only_objects_are_available(self): + """ + Test that the Gen1 only objects are available. + """ + self.assertIsInstance(self.client.sds, gen1.Sds) + self.assertIsInstance(self.client.replication_consistency_group, + gen1.ReplicationConsistencyGroup) + + def test_component_version_is_not_queried(self): + """ + Test that an API version below 5.0 is conclusive on its own, so no + component version is queried while initializing the client. + """ + call_count = self.get_mock.call_count + self.client.initialize() + self.assertEqual(call_count * 2, self.get_mock.call_count) + + +@PyPowerFlexTestCase.version('5.0') +class TestGen2Client(PyPowerFlexTestCase): + """ + Test the detection of a Gen2 system, where the API version and the + component version are in sync. + """ + + def setUp(self): + """ + Set up the test environment. + """ + super().setUp() + self.client.initialize() + + def test_gen2_objects_are_used(self): + """ + Test that the Gen2 objects are used. + """ + self.assertIsInstance(self.client.device, gen2.Device) + self.assertIsInstance(self.client.storage_pool, gen2.StoragePool) + + def test_gen2_only_objects_are_available(self): + """ + Test that the Gen2 only objects are available. + """ + self.assertIsInstance(self.client.device_group, gen2.DeviceGroup) + self.assertIsInstance(self.client.storage_node, gen2.StorageNode) + + def test_component_version(self): + """ + Test that the component version is reported. + """ + self.assertEqual('5.0.0.0', self.client.system.component_version()) + + +@PyPowerFlexTestCase.version('5.1', component_version='4.5') +class TestGen1ClientWithGen2Api(PyPowerFlexTestCase): + """ + Test the detection of a Gen1 system that already exposes the 5.x API. + + The API version and the components are upgraded independently, so a + system running components 4.5.x can serve API version 5.1. Such a system + is still a Gen1 system and must be handled with the Gen1 objects. + """ + + def setUp(self): + """ + Set up the test environment. + """ + super().setUp() + self.client.initialize() + + def test_api_and_component_versions_differ(self): + """ + Test that the API version and the component version differ. + """ + self.assertEqual('5.1', self.client.system.api_version()) + self.assertEqual('4.5.0.0', self.client.system.component_version()) + + def test_gen1_objects_are_used(self): + """ + Test that the component version wins over the API version. + """ + self.assertIsInstance(self.client.device, gen1.Device) + self.assertIsInstance(self.client.storage_pool, gen1.StoragePool) + + def test_gen2_only_objects_are_not_available(self): + """ + Test that the Gen2 only objects are not available. + """ + self.assertFalse(hasattr(self.client, 'device_group')) + self.assertFalse(hasattr(self.client, 'storage_node')) + + def test_component_version_is_cached(self): + """ + Test that the component version is cached. + """ + self.client.system.component_version() + call_count = self.get_mock.call_count + self.client.system.component_version() + self.client.system.component_version() + self.assertEqual(call_count, self.get_mock.call_count) + + def test_component_version_not_cached(self): + """ + Test that the cache can be bypassed. + """ + self.client.system.component_version() + call_count = self.get_mock.call_count + self.assertEqual('4.5.0.0', + self.client.system.component_version(cached=False)) + self.assertGreater(self.get_mock.call_count, call_count) + + +@PyPowerFlexTestCase.version('5.1') +class TestComponentVersionFallback(PyPowerFlexTestCase): + """ + Test the behaviour when the component version cannot be determined. + """ + + def setUp(self): + """ + Set up the test environment. + """ + super().setUp() + # A system that does not report any usable version information. + self.MOCK_RESPONSES = { + self.RESPONSE_MODE.Valid: { + self.SYSTEM_API_PATH: [{'id': '1'}], + }, + } + + def test_component_version_falls_back_to_api_version(self): + """ + Test that the API version is used when the component version is + unknown, so the previous behaviour is kept. + """ + self.client.initialize() + self.assertEqual('5.1', self.client.system.component_version()) + + def test_gen2_objects_are_used(self): + """ + Test that the client falls back to the API version based detection. + """ + self.client.initialize() + self.assertIsInstance(self.client.device, gen2.Device) diff --git a/tests/gen2/test_system.py b/tests/gen2/test_system.py index 26bcbab..8866f72 100644 --- a/tests/gen2/test_system.py +++ b/tests/gen2/test_system.py @@ -92,12 +92,18 @@ def setUp(self): }, } + # Initializing a client whose API version is 5.0 or above also queries the + # component version, to tell a Gen2 system apart from a Gen1 system that + # already exposes the 5.x API. + EXPECTED_INITIALIZE_CALL_COUNT = 12 + def test_system_api_version(self): """ Test the API version. """ self.client.system.api_version() - self.assertEqual(8, self.get_mock.call_count) + self.assertEqual(self.EXPECTED_INITIALIZE_CALL_COUNT, + self.get_mock.call_count) def test_system_api_version_bad_status(self): """ @@ -124,7 +130,8 @@ def test_system_api_version_cached(self): self.client.system.api_version() self.client.system.api_version() self.client.system.api_version() - self.assertEqual(8, self.get_mock.call_count) + self.assertEqual(self.EXPECTED_INITIALIZE_CALL_COUNT, + self.get_mock.call_count) def test_system_remove_cg_snapshots(self): """