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/PyPowerFlex/objects/gen1/replication_consistency_group.py b/PyPowerFlex/objects/gen1/replication_consistency_group.py index 23e5256..2d3a326 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. @@ -213,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") 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): """