diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index abe300c..57f1b53 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -36,7 +36,7 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Run Unit Test and Generate report run: | - coverage run -m pytest -v tests/test_*.py + coverage run -m pytest -v tests/ - name: Upload Coverage to Codecov uses: codecov/codecov-action@v5 if: ${{ matrix.python-version == '3.12' }} diff --git a/.gitignore b/.gitignore index f44edc6..0ae1d88 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ __pycache__ # pytest coverage .coverage htmlcov + +build/ +dist/ +*.egg-info + +test.py diff --git a/PyPowerFlex/__init__.py b/PyPowerFlex/__init__.py index 943951d..630d001 100644 --- a/PyPowerFlex/__init__.py +++ b/PyPowerFlex/__init__.py @@ -21,10 +21,11 @@ from PyPowerFlex import configuration from PyPowerFlex import exceptions -from PyPowerFlex import objects -from PyPowerFlex import token +from PyPowerFlex import powerflex_token from PyPowerFlex import utils - +from PyPowerFlex.objects import common +from PyPowerFlex.objects import gen1 +from PyPowerFlex.objects import gen2 __all__ = [ 'PowerFlexClient' @@ -39,6 +40,7 @@ class PowerFlexClient: access to the various storage entities available in the PowerFlex system. """ __slots__ = ( + # gen1 '__is_initialized', 'configuration', 'token', @@ -60,7 +62,9 @@ class PowerFlexClient: 'managed_device', 'deployment', 'firmware_repository', - 'host' + 'host', + # gen2 + 'storage_node', ) def __init__(self, @@ -80,7 +84,7 @@ def __init__(self, certificate_path, timeout, log_level) - self.token = token.Token() + self.token = powerflex_token.PowerFlexToken() self.__is_initialized = False def __getattr__(self, item): @@ -98,36 +102,60 @@ def initialize(self): Raises: PowerFlexClientException: If the PowerFlex API version is lower than 3.0. """ + # common objects here + self.add_objects_common() self.configuration.validate() - self.__add_storage_entity('device', objects.Device) - self.__add_storage_entity('fault_set', objects.FaultSet) - self.__add_storage_entity('protection_domain', - objects.ProtectionDomain) - self.__add_storage_entity('sdc', objects.Sdc) - self.__add_storage_entity('sds', objects.Sds) - self.__add_storage_entity('sdt', objects.Sdt) - self.__add_storage_entity('snapshot_policy', objects.SnapshotPolicy) - self.__add_storage_entity('storage_pool', objects.StoragePool) - self.__add_storage_entity('acceleration_pool', - objects.AccelerationPool) - self.__add_storage_entity('system', objects.System) - self.__add_storage_entity('volume', objects.Volume) - self.__add_storage_entity('utility', objects.PowerFlexUtility) - self.__add_storage_entity( - 'replication_consistency_group', - objects.ReplicationConsistencyGroup) - self.__add_storage_entity('replication_pair', objects.ReplicationPair) - self.__add_storage_entity('service_template', objects.ServiceTemplate) - self.__add_storage_entity('managed_device', objects.ManagedDevice) - self.__add_storage_entity('deployment', objects.Deployment) - self.__add_storage_entity( - 'firmware_repository', - objects.FirmwareRepository) - self.__add_storage_entity('host', objects.Host) + utils.init_logger(self.configuration.log_level) if version.parse(self.system.api_version()) < version.Version('3.0'): raise exceptions.PowerFlexClientException( 'PowerFlex (VxFlex OS) versions lower than ' '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'): + self.add_objects_gen2() self.__is_initialized = True + + def add_objects_common(self): + """Add common objects here.""" + self.__add_storage_entity('system', common.System) + self.__add_storage_entity('sdc', common.Sdc) + self.__add_storage_entity('sdt', common.Sdt) + self.__add_storage_entity('host', common.Host) + self.__add_storage_entity('utility', common.PowerFlexUtility) + + + def add_objects_gen1(self): + """Add gen1 objects here.""" + self.__add_storage_entity('device', gen1.Device) + self.__add_storage_entity( + 'fault_set', gen1.FaultSet) + self.__add_storage_entity('protection_domain', + gen1.ProtectionDomain) + self.__add_storage_entity('sds', gen1.Sds) + self.__add_storage_entity( + 'snapshot_policy', gen1.SnapshotPolicy) + self.__add_storage_entity('storage_pool', gen1.StoragePool) + self.__add_storage_entity('acceleration_pool', + gen1.AccelerationPool) + self.__add_storage_entity('volume', gen1.Volume) + self.__add_storage_entity( + 'replication_consistency_group', + gen1.ReplicationConsistencyGroup) + self.__add_storage_entity('replication_pair', gen1.ReplicationPair) + self.__add_storage_entity('service_template', gen1.ServiceTemplate) + self.__add_storage_entity('managed_device', gen1.ManagedDevice) + self.__add_storage_entity('deployment', gen1.Deployment) + self.__add_storage_entity( + 'firmware_repository', + gen1.FirmwareRepository) + + def add_objects_gen2(self): + """Add gen2 objects here.""" + self.__add_storage_entity('storage_node', gen2.StorageNode) + self.__add_storage_entity('protection_domain', gen2.ProtectionDomain) + self.__add_storage_entity('storage_pool', gen2.StoragePool) diff --git a/PyPowerFlex/base_client.py b/PyPowerFlex/base_client.py index 79ccb9d..5723413 100644 --- a/PyPowerFlex/base_client.py +++ b/PyPowerFlex/base_client.py @@ -21,10 +21,11 @@ import requests from requests.packages.urllib3.exceptions import InsecureRequestWarning - +from marshmallow import EXCLUDE, Schema from PyPowerFlex import exceptions from PyPowerFlex import utils + requests.packages.urllib3.disable_warnings(InsecureRequestWarning) LOG = logging.getLogger(__name__) @@ -110,13 +111,14 @@ def get_auth_headers(self, request_type=None): 'content-type': 'application/json' } - def send_request(self, method, url, params=None, **url_params): + def send_request(self, method, url, params=None, use_base_url=True, **url_params): """ Send a request to the PowerFlex API. Args: method (str): The HTTP method. url (str): The URL. + use_base_url (bool, optional): Whether to use the base URL. Defaults to True. params (dict): The parameters. url_params (dict): The URL parameters. @@ -124,7 +126,12 @@ def send_request(self, method, url, params=None, **url_params): Response: The response object. """ params = params or {} - request_url = f"{self.base_url}{url.format(**url_params)}" + use_base_url = True if use_base_url is None else use_base_url + if use_base_url: + request_url = f"{self.base_url}{url.format(**url_params)}" + else: + request_url = f"{self.base_url.removesuffix('/api')}{url.format(**url_params)}" + version = self.login() request_params = { 'headers': self.get_auth_headers(method), @@ -157,19 +164,21 @@ def send_get_request(self, url, params=None, **url_params): response = self.send_request(self.GET, url, params, **url_params) return response, response.json() - def send_post_request(self, url, params=None, **url_params): + def send_post_request(self, url, use_base_url=True, params=None, **url_params): """ Send a POST request to the PowerFlex API. Args: url (str): The URL. + use_base_url (bool, optional): Whether to use the base URL. Defaults to True. params (dict): The parameters. url_params (dict): The URL parameters. Returns: tuple: The response object and the response content. """ - response = self.send_request(self.POST, url, params, **url_params) + response = self.send_request( + self.POST, url, params, use_base_url, ** url_params) return response, response.json() def send_put_request(self, url, params=None, **url_params): @@ -373,6 +382,7 @@ class EntityRequest(Request): base_type_special_action_url = '/types/{entity}/instances/action/{action}' query_mdm_cluster_url = '/instances/{entity}/queryMdmCluster' list_statistics_url = '/types/{entity}/instances/action/{action}' + metrics_query_url = '/dtapi/rest/v1/metrics/query' service_template_url = '/V1/ServiceTemplate' managed_device_url = '/V1/ManagedDevice' deployment_url = '/V1/Deployment' @@ -420,6 +430,9 @@ def _delete_entity(self, entity_id, params=None): entity_id (str): The ID of the entity. params (dict, optional): Parameters for the entity. + Returns: + dict: The response from the API. + Raises: PowerFlexFailDeleting: If the entity fails to be deleted. """ @@ -435,6 +448,7 @@ def _delete_entity(self, entity_id, params=None): response) LOG.error(exc.message) raise exc + return response def _rename_entity(self, action, entity_id, params=None): """ @@ -446,7 +460,7 @@ def _rename_entity(self, action, entity_id, params=None): params (dict, optional): Parameters for the entity. Returns: - dict: The renamed entity. + dict: The response from the API. Raises: PowerFlexFailRenaming: If the entity fails to be renamed. @@ -461,8 +475,7 @@ def _rename_entity(self, action, entity_id, params=None): response) LOG.error(exc.message) raise exc - - return self.get(entity_id=entity_id) + return response def get(self, entity_id=None, filter_fields=None, fields=None): """ @@ -576,6 +589,7 @@ def _perform_entity_operation_based_on_action( self.entity, entity_id, action, response) LOG.error(exc.message) raise exc + return response def _query_selected_statistics(self, action, params=None): """ @@ -606,3 +620,50 @@ def _query_selected_statistics(self, action, params=None): LOG.error(exc.message) raise exc return response + + def query_metrics(self, resource_type, ids=None, metrics=None): + """Query PowerFlex resource metrics. + + :param resource_type: str + :param ids: list + :param metrics: list + :return: dict + """ + + params = { + 'resource_type': resource_type + } + if ids is not None: + params['ids'] = ids + if metrics is not None: + params['metrics'] = metrics + + r, response = self.send_post_request(self.metrics_query_url, + use_base_url=False, + params=params) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to query {resource_type} statistics. " + f"Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + return response + +class BaseSchema(Schema): + """Base schema.""" + # pylint: disable=too-few-public-methods + + def on_bind_field(self, field_name, field_obj): + field_obj.data_key = camelcase(field_obj.data_key or field_name) + + class Meta: + """Meta class.""" + unknown = EXCLUDE + + +def camelcase(s): + """Convert snake case to camel case.""" + parts = iter(s.split("_")) + return next(parts) + "".join(i.title() for i in parts) diff --git a/PyPowerFlex/constants.py b/PyPowerFlex/constants.py index ee56ff7..891de6b 100644 --- a/PyPowerFlex/constants.py +++ b/PyPowerFlex/constants.py @@ -17,6 +17,7 @@ # pylint: disable=too-few-public-methods + class StoragePoolConstants: """ This class holds constants related to StoragePool. @@ -240,6 +241,10 @@ class StoragePoolConstants: DEFAULT_STATISTICS_PROPERTIES_ABOVE_3_5 = [ "thinCapacityAllocatedInKm", "thinUserDataCapacityInKb"] + DEFAULT_QUERY_METRICS = [ + "" + ] + class VolumeConstants: """ @@ -365,3 +370,47 @@ class SnapshotPolicyConstants: "numOfExpiredButLockedSnapshots", "numOfSrcVols", "srcVolIds"] + + +class StorageNodeConstants: + """ + This class holds statistics constants related to StorageNode. + """ + DEFAULT_STATISTICS_METRICS = [ + "storage_fe_write_latency", + "device_local_read_bandwidth", + "device_local_read_iops", + "device_remote_write_bandwidth", + "device_remote_write_iops", + "total_device_write_bandwidth", + "total_device_write_iops", + "avg_device_write_io_size", + "storage_fe_trim_latency", + "device_local_write_bandwidth", + "device_local_write_iops", + "avg_device_write_latency", + "storage_fe_trim_bandwidth", + "storage_fe_trim_iops", + "avg_fe_trim_io_size", + "avg_device_pmem_write_latency", + "device_remote_read_bandwidth", + "device_remote_read_iops", + "avg_device_pmem_read_latency", + "storage_fe_write_bandwidth", + "storage_fe_write_iops", + "avg_fe_write_io_size", + "storage_fe_read_bandwidth", + "storage_fe_read_iops", + "avg_fe_read_io_size", + "total_device_pmem_write_bandwidth", + "total_device_pmem_write_iops", + "avg_device_pmem_write_io_size", + "total_device_pmem_read_bandwidth", + "total_device_pmem_read_iops", + "avg_device_pmem_read_io_size", + "total_device_read_bandwidth", + "total_device_read_iops", + "avg_device_read_io_size", + "raw_total", + "storage_fe_read_latency", + "avg_device_read_latency"] diff --git a/PyPowerFlex/exceptions.py b/PyPowerFlex/exceptions.py index 2526e09..77714b3 100644 --- a/PyPowerFlex/exceptions.py +++ b/PyPowerFlex/exceptions.py @@ -130,3 +130,11 @@ def __init__(self, entity, entity_id, action, response=None): self.response = response if response: self.message = f"{self.message} Error: {response}" + + +def nonupdatable_exception(field, entity, entity_id=None): + """Return a PowerFlexClientException for non-updatable fields.""" + msg = ( + f'{field} cannot be updated after creation for PowerFlex {entity} {entity_id}' + ) + return PowerFlexClientException(msg) diff --git a/PyPowerFlex/objects/__init__.py b/PyPowerFlex/objects/__init__.py deleted file mode 100644 index 716fb43..0000000 --- a/PyPowerFlex/objects/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -# 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. - -"""This module contains the objects for interacting with the PowerFlex APIs.""" - -from PyPowerFlex.objects.device import Device -from PyPowerFlex.objects.fault_set import FaultSet -from PyPowerFlex.objects.protection_domain import ProtectionDomain -from PyPowerFlex.objects.sdc import Sdc -from PyPowerFlex.objects.sds import Sds -from PyPowerFlex.objects.sdt import Sdt -from PyPowerFlex.objects.snapshot_policy import SnapshotPolicy -from PyPowerFlex.objects.storage_pool import StoragePool -from PyPowerFlex.objects.acceleration_pool import AccelerationPool -from PyPowerFlex.objects.system import System -from PyPowerFlex.objects.volume import Volume -from PyPowerFlex.objects.utility import PowerFlexUtility -from PyPowerFlex.objects.replication_consistency_group import ReplicationConsistencyGroup -from PyPowerFlex.objects.replication_pair import ReplicationPair -from PyPowerFlex.objects.service_template import ServiceTemplate -from PyPowerFlex.objects.managed_device import ManagedDevice -from PyPowerFlex.objects.deployment import Deployment -from PyPowerFlex.objects.firmware_repository import FirmwareRepository -from PyPowerFlex.objects.host import Host - - -__all__ = [ - 'Device', - 'FaultSet', - 'ProtectionDomain', - 'Sdc', - 'Sds', - 'Sdt', - 'SnapshotPolicy', - 'StoragePool', - 'AccelerationPool', - 'System', - 'Volume', - 'PowerFlexUtility', - 'ReplicationConsistencyGroup', - 'ReplicationPair', - 'ServiceTemplate', - 'ManagedDevice', - 'Deployment', - 'FirmwareRepository', - 'Host', -] diff --git a/PyPowerFlex/objects/common/__init__.py b/PyPowerFlex/objects/common/__init__.py new file mode 100644 index 0000000..a4a378a --- /dev/null +++ b/PyPowerFlex/objects/common/__init__.py @@ -0,0 +1,30 @@ +# 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. + +"""This module contains the objects for interacting with the PowerFlex APIs.""" + +from PyPowerFlex.objects.common.system import System +from PyPowerFlex.objects.common.host import Host +from PyPowerFlex.objects.common.sdc import Sdc +from PyPowerFlex.objects.common.sdt import Sdt +from PyPowerFlex.objects.common.utility import PowerFlexUtility + +__all__ = [ + 'Sdc', + 'Sdt', + 'System', + 'Host', + 'PowerFlexUtility', +] diff --git a/PyPowerFlex/objects/host.py b/PyPowerFlex/objects/common/host.py similarity index 100% rename from PyPowerFlex/objects/host.py rename to PyPowerFlex/objects/common/host.py diff --git a/PyPowerFlex/objects/sdc.py b/PyPowerFlex/objects/common/sdc.py similarity index 100% rename from PyPowerFlex/objects/sdc.py rename to PyPowerFlex/objects/common/sdc.py diff --git a/PyPowerFlex/objects/sdt.py b/PyPowerFlex/objects/common/sdt.py similarity index 100% rename from PyPowerFlex/objects/sdt.py rename to PyPowerFlex/objects/common/sdt.py diff --git a/PyPowerFlex/objects/system.py b/PyPowerFlex/objects/common/system.py similarity index 100% rename from PyPowerFlex/objects/system.py rename to PyPowerFlex/objects/common/system.py diff --git a/PyPowerFlex/objects/utility.py b/PyPowerFlex/objects/common/utility.py similarity index 90% rename from PyPowerFlex/objects/utility.py rename to PyPowerFlex/objects/common/utility.py index db60eea..0ea1c12 100644 --- a/PyPowerFlex/objects/utility.py +++ b/PyPowerFlex/objects/common/utility.py @@ -22,7 +22,13 @@ from PyPowerFlex import base_client from PyPowerFlex import exceptions -from PyPowerFlex.constants import StoragePoolConstants, VolumeConstants, SnapshotPolicyConstants + +from PyPowerFlex.constants import ( + StoragePoolConstants, + VolumeConstants, + SnapshotPolicyConstants, + StorageNodeConstants +) LOG = logging.getLogger(__name__) @@ -30,6 +36,7 @@ class PowerFlexUtility(base_client.EntityRequest): "Utility class for PowerFlex" + def __init__(self, token, configuration): super().__init__(token, configuration) @@ -138,3 +145,13 @@ def get_statistics_for_all_snapshot_policies( raise exceptions.PowerFlexClientException(msg) return response + + def query_metrics_for_all_storage_nodes(self, ids=None, metrics=None): + """list storage node statistics for PowerFlex 5.0+. + + :param ids: list + :param metrics: list + :return: dict + """ + metrics = metrics or StorageNodeConstants.DEFAULT_STATISTICS_METRICS + return self.query_metrics('storage_node', ids, metrics) diff --git a/PyPowerFlex/objects/gen1/__init__.py b/PyPowerFlex/objects/gen1/__init__.py new file mode 100644 index 0000000..5ebde98 --- /dev/null +++ b/PyPowerFlex/objects/gen1/__init__.py @@ -0,0 +1,49 @@ +# 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. + +"""This module contains the objects for interacting with the PowerFlex APIs.""" + +from PyPowerFlex.objects.gen1.device import Device +from PyPowerFlex.objects.gen1.fault_set import FaultSet +from PyPowerFlex.objects.gen1.protection_domain import ProtectionDomain +from PyPowerFlex.objects.gen1.sds import Sds +from PyPowerFlex.objects.gen1.snapshot_policy import SnapshotPolicy +from PyPowerFlex.objects.gen1.storage_pool import StoragePool +from PyPowerFlex.objects.gen1.acceleration_pool import AccelerationPool +from PyPowerFlex.objects.gen1.volume import Volume +from PyPowerFlex.objects.gen1.replication_consistency_group import ReplicationConsistencyGroup +from PyPowerFlex.objects.gen1.replication_pair import ReplicationPair +from PyPowerFlex.objects.gen1.service_template import ServiceTemplate +from PyPowerFlex.objects.gen1.managed_device import ManagedDevice +from PyPowerFlex.objects.gen1.deployment import Deployment +from PyPowerFlex.objects.gen1.firmware_repository import FirmwareRepository + + +__all__ = [ + 'Device', + 'FaultSet', + 'ProtectionDomain', + 'Sds', + 'SnapshotPolicy', + 'StoragePool', + 'AccelerationPool', + 'Volume', + 'ReplicationConsistencyGroup', + 'ReplicationPair', + 'ServiceTemplate', + 'ManagedDevice', + 'Deployment', + 'FirmwareRepository', +] diff --git a/PyPowerFlex/objects/acceleration_pool.py b/PyPowerFlex/objects/gen1/acceleration_pool.py similarity index 100% rename from PyPowerFlex/objects/acceleration_pool.py rename to PyPowerFlex/objects/gen1/acceleration_pool.py diff --git a/PyPowerFlex/objects/deployment.py b/PyPowerFlex/objects/gen1/deployment.py similarity index 100% rename from PyPowerFlex/objects/deployment.py rename to PyPowerFlex/objects/gen1/deployment.py diff --git a/PyPowerFlex/objects/device.py b/PyPowerFlex/objects/gen1/device.py similarity index 100% rename from PyPowerFlex/objects/device.py rename to PyPowerFlex/objects/gen1/device.py diff --git a/PyPowerFlex/objects/fault_set.py b/PyPowerFlex/objects/gen1/fault_set.py similarity index 100% rename from PyPowerFlex/objects/fault_set.py rename to PyPowerFlex/objects/gen1/fault_set.py diff --git a/PyPowerFlex/objects/firmware_repository.py b/PyPowerFlex/objects/gen1/firmware_repository.py similarity index 100% rename from PyPowerFlex/objects/firmware_repository.py rename to PyPowerFlex/objects/gen1/firmware_repository.py diff --git a/PyPowerFlex/objects/managed_device.py b/PyPowerFlex/objects/gen1/managed_device.py similarity index 100% rename from PyPowerFlex/objects/managed_device.py rename to PyPowerFlex/objects/gen1/managed_device.py diff --git a/PyPowerFlex/objects/protection_domain.py b/PyPowerFlex/objects/gen1/protection_domain.py similarity index 100% rename from PyPowerFlex/objects/protection_domain.py rename to PyPowerFlex/objects/gen1/protection_domain.py diff --git a/PyPowerFlex/objects/replication_consistency_group.py b/PyPowerFlex/objects/gen1/replication_consistency_group.py similarity index 100% rename from PyPowerFlex/objects/replication_consistency_group.py rename to PyPowerFlex/objects/gen1/replication_consistency_group.py diff --git a/PyPowerFlex/objects/replication_pair.py b/PyPowerFlex/objects/gen1/replication_pair.py similarity index 100% rename from PyPowerFlex/objects/replication_pair.py rename to PyPowerFlex/objects/gen1/replication_pair.py diff --git a/PyPowerFlex/objects/sds.py b/PyPowerFlex/objects/gen1/sds.py similarity index 100% rename from PyPowerFlex/objects/sds.py rename to PyPowerFlex/objects/gen1/sds.py diff --git a/PyPowerFlex/objects/service_template.py b/PyPowerFlex/objects/gen1/service_template.py similarity index 100% rename from PyPowerFlex/objects/service_template.py rename to PyPowerFlex/objects/gen1/service_template.py diff --git a/PyPowerFlex/objects/snapshot_policy.py b/PyPowerFlex/objects/gen1/snapshot_policy.py similarity index 100% rename from PyPowerFlex/objects/snapshot_policy.py rename to PyPowerFlex/objects/gen1/snapshot_policy.py diff --git a/PyPowerFlex/objects/storage_pool.py b/PyPowerFlex/objects/gen1/storage_pool.py similarity index 99% rename from PyPowerFlex/objects/storage_pool.py rename to PyPowerFlex/objects/gen1/storage_pool.py index 149b19c..54175cf 100644 --- a/PyPowerFlex/objects/storage_pool.py +++ b/PyPowerFlex/objects/gen1/storage_pool.py @@ -23,7 +23,7 @@ from PyPowerFlex import base_client from PyPowerFlex import exceptions -from PyPowerFlex.objects import Sds +from PyPowerFlex.objects.gen1 import Sds LOG = logging.getLogger(__name__) diff --git a/PyPowerFlex/objects/volume.py b/PyPowerFlex/objects/gen1/volume.py similarity index 100% rename from PyPowerFlex/objects/volume.py rename to PyPowerFlex/objects/gen1/volume.py diff --git a/PyPowerFlex/objects/gen2/__init__.py b/PyPowerFlex/objects/gen2/__init__.py new file mode 100644 index 0000000..7ed0cd3 --- /dev/null +++ b/PyPowerFlex/objects/gen2/__init__.py @@ -0,0 +1,26 @@ +# 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. + +"""This module contains the objects for interacting with the PowerFlex 5.0+ APIs.""" + +from PyPowerFlex.objects.gen2.storage_node import StorageNode +from PyPowerFlex.objects.gen2.protection_domain import ProtectionDomain +from PyPowerFlex.objects.gen2.storage_pool import StoragePool + +__all__ = [ + 'StorageNode', + 'ProtectionDomain', + 'StoragePool', +] diff --git a/PyPowerFlex/objects/gen2/protection_domain.py b/PyPowerFlex/objects/gen2/protection_domain.py new file mode 100644 index 0000000..d93f797 --- /dev/null +++ b/PyPowerFlex/objects/gen2/protection_domain.py @@ -0,0 +1,485 @@ +# 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 interacting with protection domain APIs.""" + +# pylint: disable=too-few-public-methods,no-member,too-many-arguments,too-many-positional-arguments,duplicate-code + +import logging +import requests + +from marshmallow import fields, validate +from PyPowerFlex import base_client, exceptions + + +LOG = logging.getLogger(__name__) + + +class ProtectionDomainSchema(base_client.BaseSchema): + """Protection Domain schema.""" + id = fields.Str( + metadata={ + "description": "Protection Domain Id", + } + ) + name = fields.Str( + required=True, + metadata={ + "description": "Protection Domain Name", + "updatable": True, + } + ) + state = fields.Str( + validate=validate.OneOf(["Active", "Inactive"]), + data_key="protectionDomainState", + metadata={ + "description": "Protection Domain State: Active/Inactive, default: Active", + "updatable": True, + } + ) + rebuild_enabled = fields.Boolean( + metadata={ + "description": "Enable rebuild, default: True", + "updatable": True, + } + ) + rebalance_enabled = fields.Boolean( + metadata={ + "description": "Enable rebalance, default: True", + "updatable": True, + } + ) + rebuild_network_throttling_enabled = fields.Boolean( + metadata={ + "description": "Rebuild network throttling enabled", + } + ) + rebalance_network_throttling_enabled = fields.Boolean( + metadata={ + "description": "Rebalance network throttling enabled", + } + ) + gen_type = fields.Str( + metadata={ + "description": "Gen Type: EC or Mirroring", + } + ) + overall_concurrent_io_limit = fields.Integer( + metadata={ + "description": "Overall concurrent IO limit, default: 4", + "updatable": True, + } + ) + bandwidth_limit_overall_ios = fields.Integer( + metadata={ + "description": "Bandwidth limit overall IOs, default: 400", + "updatable": True, + } + ) + bandwidth_limit_bg_dev_scanner = fields.Integer( + metadata={ + "description": "Bandwidth limit background device scanner, default: 10", + "updatable": True, + } + ) + bandwidth_limit_garbage_collector = fields.Integer( + metadata={ + "description": "Bandwidth limit garbage collector, default: 65535", + "updatable": True, + } + ) + bandwidth_limit_singly_impacted_rebuild = fields.Integer( + metadata={ + "description": "Bandwidth limit singly impacted rebuild, default: 400", + "updatable": True, + } + ) + bandwidth_limit_doubly_impacted_rebuild = fields.Integer( + metadata={ + "description": "Bandwidth limit doubly impacted rebuild, default: 400", + "updatable": True, + } + ) + bandwidth_limit_rebalance = fields.Integer( + metadata={ + "description": "Bandwidth limit rebalance, default: 40", + "updatable": True, + } + ) + bandwidth_limit_other = fields.Integer( + metadata={ + "description": "Bandwidth limit rebalance, default: 10", + "updatable": True, + } + ) + bandwidth_limit_node_network = fields.Integer( + metadata={ + "description": "Bandwidth limit node network, default: 25", + "updatable": True, + } + ) + + +def load_protection_domain_schema(obj): + """Load protection domain schema.""" + return ProtectionDomainSchema().load(obj) + + +class ProtectionDomain(base_client.EntityRequest): + """ + A class representing Protection Domain client. + """ + + def list(self): + """List PowerFlex protection domains. + + :rtype: list[dict] + """ + return list(map(load_protection_domain_schema, self.get())) + + def get_by_id(self, protection_domain_id): + """Get PowerFlex protection domain. + + :type protection_domain_id: str + :rtype: dict + """ + return load_protection_domain_schema(self.get(entity_id=protection_domain_id)) + + def get_by_name(self, name): + """Get PowerFlex protection domain. + + :type name: str + :rtype: dict + """ + result = self.get(filter_fields={'name': name}) + if len(result) >= 1: + return load_protection_domain_schema(result[0]) + return None + + def delete(self, protection_domain_id): + """Remove PowerFlex protection domain. + + :type protection_domain_id: str + :rtype: None + """ + return self._delete_entity(protection_domain_id) + + def create(self, pd): + """Create PowerFlex protection domain. + + :type pd: dict + :rtype: dict + """ + pd = load_protection_domain_schema(pd) + params = {"name": pd['name']} + new_pd = load_protection_domain_schema(self._create_entity(params)) + _, pd = self.update(ProtectionDomainSchema().dump(pd), new_pd) + return pd + + def update(self, pd, current_pd=None): + """Update PowerFlex protection domain. + + :type pd: dict + :rtype: dict + """ + current_pd = current_pd if current_pd is not None else self.get_by_id( + pd['id']) + pd = load_protection_domain_schema( + {**ProtectionDomainSchema().dump(current_pd), **pd}) + + has_update = False + + if pd['name'] != current_pd['name']: + has_update = True + self.rename(pd['id'], pd['name']) + + if pd['state'] != current_pd['state']: + has_update = True + if pd['state'] == "Inactive": + self.inactivate(pd['id'], force=True) + else: + self.activate(pd['id'], force=True) + + if pd['rebuild_enabled'] != current_pd['rebuild_enabled']: + has_update = True + self.set_rebuild_enabled(pd['id'], pd['rebuild_enabled']) + if pd['rebalance_enabled'] != current_pd['rebalance_enabled']: + has_update = True + self.set_rebalance_enabled(pd['id'], pd['rebalance_enabled']) + # self.disable_inflight_bandwidth_flow_control(pd['id']) + # self.enable_inflight_bandwidth_flow_control(pd['id']) + + policy = { + # this value may change as the development gose on + # will fix in formal releases + # In additional, this value cannot be validated currently + "policy": "favorApplication", + } + + field_map = { + 'overall_concurrent_io_limit': 'overallConcurrentIoLimit', + 'bandwidth_limit_overall_ios': 'bandwidthLimitOverallIos', + 'bandwidth_limit_bg_dev_scanner': 'bandwidthLimitBgDevScanner', + 'bandwidth_limit_garbage_collector': 'bandwidthLimitGarbageCollector', + 'bandwidth_limit_singly_impacted_rebuild': 'bandwidthLimitSinglyImpactedRebuild', + 'bandwidth_limit_doubly_impacted_rebuild': 'bandwidthLimitDoublyImpactedRebuild', + 'bandwidth_limit_rebalance': 'bandwidthLimitRebalance', + 'bandwidth_limit_other': 'bandwidthLimitOther', + 'bandwidth_limit_node_network': 'bandwidthLimitNodeNetwork', + } + + for py_key, api_key in field_map.items(): + if pd[py_key] != current_pd[py_key]: + policy[api_key] = pd[py_key] + + if len(policy) > 1: + has_update = True + self.set_secondary_io_policy(pd['id'], policy) + + return has_update, self.get_by_id(pd['id']) + + def activate(self, protection_domain_id, force=False): + """Activate PowerFlex protection domain. + + :type protection_domain_id: str + :type force: bool + :rtype: None + """ + + action = 'activateProtectionDomain' + + params = { + "forceActivate": force + } + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=protection_domain_id, + params=params) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to activate PowerFlex {self.entity} " + f"with id {protection_domain_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + def inactivate(self, protection_domain_id, force=False): + """Inactivate PowerFlex protection domain. + + :type protection_domain_id: str + :type force: bool + :rtype: None + """ + + action = 'inactivateProtectionDomain' + + params = { + "forceShutdown": force + } + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=protection_domain_id, + params=params) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to inactivate PowerFlex {self.entity} " + f"with id {protection_domain_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + # def enable_inflight_bandwidth_flow_control(self, id): + # """Enable inflight bandwidth flow control. + + # :type id: str + # :rtype: None + # """ + + # action = 'enableInflightBandwidthFlowControl' + + # r, response = self.send_post_request(self.base_action_url, + # action=action, + # entity=self.entity, + # entity_id=id) + # if r.status_code != requests.codes.ok: + # msg = ( + # f"Failed to enable inflight bandwidth flow control in PowerFlex {self.entity} " + # f"with id {id}. Error: {response}" + # ) + # LOG.error(msg) + # raise exceptions.PowerFlexClientException(msg) + + # def disable_inflight_bandwidth_flow_control(self, id): + # """Disable inflight bandwidth flow control. + + # :type id: str + # :rtype: None + # """ + + # action = 'disableInflightBandwidthFlowControl' + + # r, response = self.send_post_request(self.base_action_url, + # action=action, + # entity=self.entity, + # entity_id=id) + # if r.status_code != requests.codes.ok: + # msg = ( + # f"Failed to disable inflight bandwidth flow control in PowerFlex {self.entity} " + # f"with id {id}. Error: {response}" + # ) + # LOG.error(msg) + # raise exceptions.PowerFlexClientException(msg) + + def set_rebuild_enabled(self, protection_domain_id, enabled): + """Set rebuild state. + + :type protection_domain_id: str + :type enabled: bool + :rtype: None + """ + + action = 'setRebuildEnabled' + params = { + "rebuildEnabled": enabled, + } + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=protection_domain_id, + params=params) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set rebuild state in PowerFlex {self.entity} " + f"with id {protection_domain_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + def set_rebalance_enabled(self, protection_domain_id, enabled): + """Set rebalance state. + + :type protection_domain_id: str + :type enabled: bool + :rtype: None + """ + + action = 'setRebalanceEnabled' + params = { + "rebalanceEnabled": enabled, + } + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=protection_domain_id, + params=params) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set rebalance state in PowerFlex {self.entity} " + f"with id {protection_domain_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + def set_secondary_io_policy(self, protection_domain_id, policy): + """Set secondary I/O policy. + + :type protection_domain_id: str + :type policy: Dict + :rtype: None + """ + + action = 'setSecondaryIoPolicy' + params = { + "policy": policy["policy"], + } + field_map = { + 'overall_concurrent_io_limit': 'overallConcurrentIoLimit', + 'bandwidth_limit_overall_ios': 'bandwidthLimitOverallIos', + 'bandwidth_limit_bg_dev_scanner': 'bandwidthLimitBgDevScanner', + 'bandwidth_limit_garbage_collector': 'bandwidthLimitGarbageCollector', + 'bandwidth_limit_singly_impacted_rebuild': 'bandwidthLimitSinglyImpactedRebuild', + 'bandwidth_limit_doubly_impacted_rebuild': 'bandwidthLimitDoublyImpactedRebuild', + 'bandwidth_limit_rebalance': 'bandwidthLimitRebalance', + 'bandwidth_limit_other': 'bandwidthLimitOther', + 'bandwidth_limit_node_network': 'bandwidthLimitNodeNetwork', + } + + for py_key, api_key in field_map.items(): + if py_key in policy: + params[api_key] = policy[py_key] + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=protection_domain_id, + params=params) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set secondary I/O policy in PowerFlex {self.entity} " + f"with id {protection_domain_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + # def get_storage_nodes(self, protection_domain_id, filter_fields=None, fields=None): + # """Get related PowerFlex Storage Nodes for protection domain. + + # :type protection_domain_id: str + # :type filter_fields: dict + # :type fields: list|tuple + # :rtype: list[dict] + # """ + + # return self.get_related(protection_domain_id, + # 'StorageNode', + # filter_fields, + # fields) + + def get_storage_pools(self, + protection_domain_id, + filter_fields=None, + response_field=None): + """Get related PowerFlex storage pools for protection domain. + + :type protection_domain_id: str + :type filter_fields: dict + :type response_field: list|tuple + :rtype: list[dict] + """ + + return self.get_related(protection_domain_id, + 'StoragePool', + filter_fields, + response_field) + + def rename(self, protection_domain_id, name): + """Rename PowerFlex protection domain. + + :type protection_domain_id: str + :type name: str + :rtype: None + """ + + action = 'setProtectionDomainName' + + params = {"name": name} + + self._rename_entity(action, protection_domain_id, params) diff --git a/PyPowerFlex/objects/gen2/storage_node.py b/PyPowerFlex/objects/gen2/storage_node.py new file mode 100644 index 0000000..b4043e4 --- /dev/null +++ b/PyPowerFlex/objects/gen2/storage_node.py @@ -0,0 +1,192 @@ +# 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 interacting with Storage Node APIs.""" + +# pylint: disable=too-few-public-methods,no-member,too-many-arguments,too-many-positional-arguments,too-many-locals,duplicate-code + +import logging + +import requests + +from PyPowerFlex import base_client +from PyPowerFlex import exceptions +from PyPowerFlex import utils + + +LOG = logging.getLogger(__name__) + + +class StorageNodeIpRoles: + """StorageNode ip roles.""" + + storage = 'Storage' + app = 'App' + storage_and_app = 'StorageAndApp' + + +class StorageNodeIp(dict): + """PowerFlex storage node ip object. + + JSON-serializable, should be used as `ipsList` list item + in `Storage_node.create` method or ipsList item in `Storage_node.add_ip` method. + """ + + def __init__(self, ip, role): + params = utils.prepare_params( + { + 'ip': ip, + 'role': role, + }, + dump=False + ) + super().__init__(**params) + + +class StorageNode(base_client.EntityRequest): + """PowerFlex Storage Node object.""" + @property + def entity(self): + """ + Returns the entity name. + """ + return "Node" + + def add_ip(self, node_id, node_ip): + """Add PowerFlex Storage Node IP-address. + + :type node_id: str + :type node_ip: dict + :rtype: dict + """ + + action = 'addIp' + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=node_id, + params=node_ip) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to add IP for PowerFlex Storage Node " + f"with id {node_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + return self.get(entity_id=node_id) + + def create(self, + name, + node_ips, + protection_domain_id, + ): + """Create PowerFlex Storage Node. + :type name: str + :type protection_domain_id: str + :type node_ips: list[dict] + :rtype: dict + """ + + params = { + "protectionDomainId": protection_domain_id, + "ips": node_ips, + "name": name, + } + + return self._create_entity(params) + + def delete(self, node_id): + """Remove PowerFlex Storage Node. + + :type node_id: str + :type force: bool + :rtype: None + """ + + return self._delete_entity(node_id) + + def rename(self, node_id, name): + """Rename PowerFlex Storage Node. + + :type node_id: str + :type name: str + :rtype: dict + """ + + action = 'renameStorageNode' + + params = {"name": name} + + return self._rename_entity(action, node_id, params) + + def remove_ip(self, node_id, ip): + """Remove PowerFlex Storage Node IP-address. + + :type node_id: str + :type ip: str + :rtype: dict + """ + + action = 'removeIp' + + params = {"ip": ip} + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=node_id, + params=params) + if r.status_code != requests.codes.ok: + msg = f"Failed to remove IP from PowerFlex Storage Node " \ + f"with id {node_id}. Error: {response}" + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + return self.get(entity_id=node_id) + + def set_ip_role(self, node_id, ip, role): + """Set PowerFlex Storage Node IP-address role. + + :type node_id: str + :type ip: str + :param role: one of predefined attributes of StorageNodeIpRoles + :type role: str + :type force: bool + :rtype: dict + """ + + action = 'modifyIpRole' + + params = { + 'ip': ip, + 'newRole': role, + } + + r, response = self.send_post_request(self.base_action_url, + action=action, + entity=self.entity, + entity_id=node_id, + params=params) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set ip role for PowerFlex Storage Node " + f"with id {node_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + return self.get(entity_id=node_id) diff --git a/PyPowerFlex/objects/gen2/storage_pool.py b/PyPowerFlex/objects/gen2/storage_pool.py new file mode 100644 index 0000000..ecc4580 --- /dev/null +++ b/PyPowerFlex/objects/gen2/storage_pool.py @@ -0,0 +1,465 @@ +# 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 interacting with storage pool APIs.""" + +# pylint: disable=too-few-public-methods,too-many-public-methods,no-member,too-many-arguments,too-many-positional-arguments,too-many-locals,cyclic-import,duplicate-code + +import logging +import requests + +from marshmallow import fields, validate, ValidationError +from PyPowerFlex import base_client, exceptions +from PyPowerFlex.objects.gen2.protection_domain import ProtectionDomain + + +LOG = logging.getLogger(__name__) + + +def validate_over_provisioning_factor(value): + """Validate over provisioning factor.""" + if value != 0 and (value < 100 or value > 10000): + raise ValidationError("Not an valid value.") + + +class StoragePoolSchema(base_client.BaseSchema): + """Storage Pool Schema.""" + + id = fields.Str( + metadata={ + "description": "Storage Pool Id", + } + ) + name = fields.Str( + allow_none=True, + metadata={ + "description": "Storage Pool Name", + "updatable": True, + }, + ) + protection_domain_id = fields.Str( + required=True, + metadata={ + "description": "Protection Domain Id", + "updatable": False, + }, + ) + device_group_id = fields.Str( + required=True, + metadata={ + "description": "Device Group Id", + "updatable": False, + }, + ) + wrc_device_group_id = fields.Str( + metadata={ + "description": "Device Group Id", + } + ) + gen_type = fields.Str( + # 5.0.0 only supports EC type, so during creation, just pass EC to gen + # required=True, + metadata={ + "description": "Gen Type, EC or MIRRORING", + } + ) + capacity_alert_high_threshold = fields.Integer( + metadata={ + "description": "Capacity Alert High Threshold, default: 80", + "updatable": True, + } + ) + capacity_alert_critical_threshold = fields.Integer( + metadata={ + "description": "Capacity Alert Critical Threshold, default: 90", + "updatable": True, + } + ) + fragmentation_enabled = fields.Boolean( + metadata={ + "description": "Enable Fragmentation, default: False", + # "updatable": True, + } + ) + over_provisioning_factor = fields.Integer( + validate=validate.And(validate_over_provisioning_factor), + metadata={ + "description": ( + "Over Provisioning Factor, range: 0, 100-10000, " + "set 0 to disable over provisioning. Default: 0" + ), + }, + ) + physical_size_gb = fields.Integer( + required=True, + data_key="physicalSizeGB", + metadata={ + "description": ( + "Physical Size in GB, set -1 to use all available capacity. " + "It only accepts larger value during update." + ), + "updatable": True, + }, + ) + raw_size_gb = fields.Integer( + data_key="rawSizeGB", + metadata={ + "description": "Raw Size in GB", + }, + ) + protection_scheme = fields.Str( + required=True, + validate=validate.OneOf(["TwoPlusTwo", "EightPlusTwo"]), + metadata={ + "description": "Protection Scheme: TwoPlusTwo/EightPlusTwo", + "updatable": False, + }, + ) + compression_method = fields.Str( + validate=validate.OneOf(["None", "Normal"]), + metadata={ + "description": "Compression Method: None/Normal. Default: Normal", + "updatable": True, + }, + ) + zero_padding_enabled = fields.Boolean( + metadata={ + "description": "Zero padding enabled. Default: True", + } + ) + # @validates_schema + # def validate_capacity_alert_threshold(self, data, **kwargs): + # if data["capacity_alert_high_threshold"] >= data["capacity_alert_critical_threshold"]: + # raise ValidationError( + # "capacity_alert_critical_threshold must be greater than " + # "capacity_alert_high_threshold" + # ) + + # class Meta: + # unknown = INCLUDE + + +def load_storage_pool_schema(obj): + """Load storage pool schema.""" + return StoragePoolSchema().load(obj) + + +class StoragePool(base_client.EntityRequest): + """ + A class representing Storage Pool client. + """ + + def list(self): + """List PowerFlex storage pools. + + :rtype: list[dict] + """ + return list(map(load_storage_pool_schema, self.get())) + + def get_by_id(self, storage_pool_id): + """Get PowerFlex storage pool. + + :type storage_pool_id: str + :rtype: dict + """ + return load_storage_pool_schema(self.get(entity_id=storage_pool_id)) + + def get_by_name(self, protion_domain_id, name): + """Get PowerFlex storage pool. + + :type protection_domain_id: str + :type name: str + :rtype: dict + """ + pdo = ProtectionDomain(self.token, self.configuration) + + result = pdo.get_storage_pools( + protion_domain_id, filter_fields={"name": name}) + if len(result) >= 1: + return load_storage_pool_schema(result[0]) + return None + + def create(self, sp): + """Create PowerFlex storage pool. + + :type sp: dict + :rtype: dict + """ + sp = load_storage_pool_schema(sp) + + params = { + "protectionDomainId": sp["protection_domain_id"], + "deviceGroupId": sp["device_group_id"], + "gen": "EC", + } + + if "name" in sp: + params["name"] = sp["name"] + if "compression_method" in sp: + params["compressionMethod"] = sp["compression_method"] + + if sp["protection_scheme"] == "TwoPlusTwo": + params["numDataSlices"] = 2 + params["numProtectionSlices"] = 2 + else: + params["numDataSlices"] = 8 + params["numProtectionSlices"] = 2 + + if sp["physical_size_gb"] == -1: + params["useAllAvailableCapacity"] = True + else: + params["physicalSizeGB"] = sp["physical_size_gb"] + + new_sp = load_storage_pool_schema(self._create_entity(params)) + _, sp = self.update(StoragePoolSchema().dump(sp), new_sp) + + return sp + + def update(self, sp, current_sp=None): + """Update PowerFlex storage pool. + + :type sp: dict + :rtype: dict + """ + current_sp = current_sp if current_sp is not None else self.get_by_id( + sp["id"]) + sp = load_storage_pool_schema( + {**StoragePoolSchema().dump(current_sp), **sp}) + + if sp["protection_domain_id"] != current_sp["protection_domain_id"]: + e = exceptions.nonupdatable_exception( + "protection_domain_id", self.entity, sp["id"] + ) + LOG.error(e.message) + raise e + if sp["device_group_id"] != current_sp["device_group_id"]: + e = exceptions.nonupdatable_exception( + "device_group_id", self.entity, sp["id"] + ) + LOG.error(e.message) + raise e + if sp["protection_scheme"] != current_sp["protection_scheme"]: + e = exceptions.nonupdatable_exception( + "protection_scheme", self.entity, sp["id"] + ) + LOG.error(e.message) + raise e + + has_update = False + + if sp["name"] != current_sp["name"]: + has_update = True + self.rename(sp["id"], sp["name"]) + + high_threshold = None + critical_threshold = None + + if ( + sp["capacity_alert_high_threshold"] + != current_sp["capacity_alert_high_threshold"] + ): + high_threshold = sp["capacity_alert_high_threshold"] + + if ( + sp["capacity_alert_critical_threshold"] + != current_sp["capacity_alert_critical_threshold"] + ): + critical_threshold = sp["capacity_alert_critical_threshold"] + + if high_threshold or critical_threshold: + has_update = True + self.set_capacity_alert_thresholds( + sp["id"], high_threshold, critical_threshold + ) + + if sp["over_provisioning_factor"] != current_sp["over_provisioning_factor"]: + has_update = True + self.set_over_provisioning_factor( + sp["id"], sp["over_provisioning_factor"]) + + if sp["physical_size_gb"] != current_sp["physical_size_gb"]: + has_update = True + self.resize(sp["id"], sp["physical_size_gb"]) + + if sp["compression_method"] != current_sp["compression_method"]: + has_update = True + self.set_compression_method(sp["id"], sp["compression_method"]) + + return has_update, self.get_by_id(sp["id"]) + + def delete(self, storage_pool_id): + """Remove PowerFlex storage pool. + + :type storage_pool_id: str + :rtype: None + """ + + return self._delete_entity(storage_pool_id) + + def rename(self, storage_pool_id, name): + """Rename PowerFlex storage pool. + + :type storage_pool_id: str + :type name: str + :rtype: None + """ + + action = "renameStoragePool" + + params = {"newName": name} + self._rename_entity(action, storage_pool_id, params) + + def set_capacity_alert_thresholds( + self, storage_pool_id, high_threshold, critical_threshold + ): + """Set the capacity alert thresholds for the specified Storage Pool. + + :type high_threshold: int + :type critical_threshold: int + :rtype: None + """ + + action = "setCapacityAlertThresholds" + + params = { + "capacityAlertHighThresholdPercent": high_threshold, + "capacityAlertCriticalThresholdPercent": critical_threshold, + } + + r, response = self.send_post_request( + self.base_action_url, + action=action, + entity=self.entity, + entity_id=storage_pool_id, + params=params, + ) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set the capacity alert thresholds for PowerFlex {self.entity}" + f"with id {storage_pool_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + def set_over_provisioning_factor(self, storage_pool_id, over_provisioning_factor): + """Set the over provisioning factor for PowerFlex storage pool. + + :type storage_pool_id: str + :type over_provisioning_factor: int + :rtype: None + """ + + action = "setOverProvisioningFactor" + + params = {"overProvisioningFactor": over_provisioning_factor} + + r, response = self.send_post_request( + self.base_action_url, + action=action, + entity=self.entity, + entity_id=storage_pool_id, + params=params, + ) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set the over provisioning factor for PowerFlex {self.entity}" + f" with id {storage_pool_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + def resize(self, storage_pool_id, size_in_gb): + """Set the size for PowerFlex storage pool. + + :type storage_pool_id: str + :type size: int + :rtype: None + """ + + action = "modifyStoragePoolSize" + + params = {"physicalSizeGB": size_in_gb} + + if size_in_gb == -1: + params = {"useAllAvailableCapacity": True} + + r, response = self.send_post_request( + self.base_action_url, + action=action, + entity=self.entity, + entity_id=storage_pool_id, + params=params, + ) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to modify the size for PowerFlex {self.entity}" + f" with id {storage_pool_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + def set_compression_method(self, storage_pool_id, compression_method): + """Set compression method for PowerFlex storage pool. + + :type storage_pool_id: str + :type compression_method: str + :rtype: dict + """ + + action = "modifyCompressionMethod" + + params = {"compressionMethod": compression_method} + + r, response = self.send_post_request( + self.base_action_url, + action=action, + entity=self.entity, + entity_id=storage_pool_id, + params=params, + ) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set compression method for PowerFlex {self.entity} " + f"with id {storage_pool_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) + + def set_zero_padding_policy(self, storage_pool_id, zero_padding_enabled): + """Enable/disable zero padding for PowerFlex storage pool. + + :type storage_pool_id: str + :type zero_padding_enabled: bool + :rtype: None + """ + + action = "setZeroPaddingPolicy" + + params = {"zeroPadEnabled": zero_padding_enabled} + + r, response = self.send_post_request( + self.base_action_url, + action=action, + entity=self.entity, + entity_id=storage_pool_id, + params=params, + ) + if r.status_code != requests.codes.ok: + msg = ( + f"Failed to set Zero Padding policy for PowerFlex {self.entity} " + f"with id {storage_pool_id}. Error: {response}" + ) + LOG.error(msg) + raise exceptions.PowerFlexClientException(msg) diff --git a/PyPowerFlex/token.py b/PyPowerFlex/powerflex_token.py similarity index 98% rename from PyPowerFlex/token.py rename to PyPowerFlex/powerflex_token.py index 0538423..c747401 100644 --- a/PyPowerFlex/token.py +++ b/PyPowerFlex/powerflex_token.py @@ -15,7 +15,7 @@ """This module is used for the management of token.""" -class Token: +class PowerFlexToken: """ A class to manage a token. """ diff --git a/requirements.txt b/requirements.txt index 7651129..383b1ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ requests packaging - +marshmallow==4.0.0 diff --git a/setup.py b/setup.py index bf8b340..9020f14 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ setup( name='PyPowerFlex', - version='1.14.1', + version='2.0.0', description='Python library for Dell PowerFlex', author='Ansible Team at Dell', author_email='ansible.team@dell.com', @@ -33,7 +33,9 @@ classifiers=['License :: OSI Approved :: Apache Software License'], packages=[ 'PyPowerFlex', - 'PyPowerFlex.objects', + 'PyPowerFlex.objects.common', + 'PyPowerFlex.objects.gen1', + 'PyPowerFlex.objects.gen2', ], python_requires='>=3.5' ) diff --git a/tests/__init__.py b/tests/__init__.py index 74cebf5..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,233 +0,0 @@ -# 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. - -"""This module is used for the initialization of the test framework.""" - -# pylint: disable=too-many-instance-attributes,keyword-arg-before-vararg,broad-exception-raised,unused-argument - -import collections -import contextlib -import json -import logging -from unittest import mock -from unittest import TestCase - -import requests - -import PyPowerFlex -from PyPowerFlex import utils - - -class MockResponse(requests.Response): - """ - Mock HTTP Response. - - Defines http replies from mocked calls to do_request(). - """ - def __init__(self, content, status_code=200): - """ - Initialize a MockResponse. - - Args: - content (str or dict): The content of the response. - status_code (int): The status code of the response. - """ - super().__init__() - self._content = content - self.request = mock.MagicMock() - self.status_code = status_code - - def json(self, **kwargs): - """ - Return the content of the response as JSON. - - Args: - **kwargs: Additional keyword arguments. - - Returns: - dict: The content of the response. - """ - return self._content - - @property - def text(self): - """ - Return the content of the response as text. - - Returns: - str: The content of the response. - """ - if not isinstance(self._content, bytes): - return json.dumps(self._content) - return super().text - - -class PyPowerFlexTestCase(TestCase): - """ - Base test case for PyPowerFlex. - - Provides a mocked HTTP response for testing. - """ - RESPONSE_MODE = ( - collections.namedtuple('RESPONSE_MODE', 'Valid Invalid BadStatus') - (Valid='Valid', Invalid='Invalid', BadStatus='BadStatus') - ) - BAD_STATUS_RESPONSE = MockResponse( - { - 'errorCode': 500, - 'message': 'Test default bad status', - }, 500 - ) - MOCK_RESPONSES = {} - DEFAULT_MOCK_RESPONSES = { - RESPONSE_MODE.Valid: { - '/login': 'token', - '/version': '3.5', - '/logout': '', - }, - RESPONSE_MODE.Invalid: { - '/version': '2.5', - }, - RESPONSE_MODE.BadStatus: { - '/login': MockResponse( - { - 'errorCode': 1, - 'message': 'Test login bad status', - }, 400 - ), - '/version': MockResponse( - { - 'errorCode': 2, - 'message': 'Test version bad status', - }, 400 - ), - '/logout': MockResponse( - { - 'errorCode': 3, - 'message': 'Test logout bad status', - }, 400 - ) - } - } - __http_response_mode = RESPONSE_MODE.Valid - - def setUp(self): - """ - Set up the test case. - - Creates a PyPowerFlex client and sets up mocked HTTP responses. - """ - self.gateway_address = '1.2.3.4' - self.gateway_port = 443 - self.username = 'admin' - self.password = 'admin' - self.client = PyPowerFlex.PowerFlexClient(self.gateway_address, - self.gateway_port, - self.username, - self.password, - log_level=logging.DEBUG) - requests.request = self.get_mock_response - self.get_mock = self.mock_object(requests, - 'get', - side_effect=self.get_mock_response) - self.post_mock = self.mock_object(requests, - 'post', - side_effect=self.get_mock_response) - utils.is_version_3 = mock.MagicMock(return_value=True) - - def mock_object(self, obj, attr_name, *args, **kwargs): - """Use python mock to mock an object attribute. - - Mocks the specified objects attribute with the given value. - Automatically performs 'addCleanup' for the mock. - """ - patcher = mock.patch.object(obj, attr_name, *args, **kwargs) - result = patcher.start() - self.addCleanup(patcher.stop) - return result - - @contextlib.contextmanager - def http_response_mode(self, mode): - """ - Context manager for setting the HTTP response mode. - - Args: - mode: The HTTP response mode. - - Yields: - None. - """ - previous_response_mode, self.__http_response_mode = ( - self.__http_response_mode, mode - ) - yield - self.__http_response_mode = previous_response_mode - - def get_mock_response(self, url, request_url=None, mode=None, *args, **kwargs): - """ - Get a mock HTTP response. - - Args: - url (str): The URL of the request. - request_url (str): The URL of the request. - mode (str): The HTTP response mode. - *args: Additional arguments. - **kwargs: Additional keyword arguments. - - Returns: - requests.Response: The mocked HTTP response. - """ - if mode is None: - mode = self.__http_response_mode - - api_path = url.split('/api')[1] if ('/api' in url) else request_url.split('/api')[1] - try: - if api_path == "/login": - response = self.RESPONSE_MODE.Valid[0] - elif api_path == "/logout": - response = self.RESPONSE_MODE.Valid[2] - else: - response = self.MOCK_RESPONSES[mode][api_path] - except KeyError as e: - try: - response = self.DEFAULT_MOCK_RESPONSES[mode][api_path] - except KeyError: - if mode == self.RESPONSE_MODE.BadStatus: - response = self.BAD_STATUS_RESPONSE - else: - raise Exception( - f"Mock API Endpoint is not implemented: [{mode}]" - f"{api_path}" - ) from e - if not isinstance(response, MockResponse): - response = self._get_mock_response(response) - - response.request.url = url - response.request.body = kwargs.get('data') - return response - - def _get_mock_response(self, response): - """ - Returns a MockResponse object based on the given response. - - Args: - response (str): The response to be wrapped. - - Returns: - MockResponse: The mock response object. - """ - if "204" in str(response): - return MockResponse(response, 204) - return MockResponse(response, 200) diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000..7e4b68b --- /dev/null +++ b/tests/common/__init__.py @@ -0,0 +1,283 @@ +# 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. + +"""This module is used for the initialization of the test framework.""" + +# pylint: disable=too-many-instance-attributes,keyword-arg-before-vararg,broad-exception-raised,unused-argument + +import collections +import copy +import contextlib +import json +import logging +from unittest import mock +from unittest import TestCase + +import requests + +import PyPowerFlex +from PyPowerFlex import utils + + +class MockResponse(requests.Response): + """ + Mock HTTP Response. + + Defines http replies from mocked calls to do_request(). + """ + + def __init__(self, content, status_code=200): + """ + Initialize a MockResponse. + + Args: + content (str or dict): The content of the response. + status_code (int): The status code of the response. + """ + super().__init__() + self._content = content + self.request = mock.MagicMock() + self.status_code = status_code + + def json(self, **kwargs): + """ + Return the content of the response as JSON. + + Args: + **kwargs: Additional keyword arguments. + + Returns: + dict: The content of the response. + """ + return self._content + + @property + def text(self): + """ + Return the content of the response as text. + + Returns: + str: The content of the response. + """ + if not isinstance(self._content, bytes): + return json.dumps(self._content) + return super().text + + +class PyPowerFlexTestCase(TestCase): + """ + Base test case for PyPowerFlex. + + Provides a mocked HTTP response for testing. + """ + VERSION_API_PATH = '/version' + + @classmethod + def version(cls, new_version): + """ + Decorator for mocking the version API version. + """ + + def decorator(subclass): + subclass.DEFAULT_MOCK_RESPONSES = copy.deepcopy( + cls.DEFAULT_MOCK_RESPONSES) + subclass.DEFAULT_MOCK_RESPONSES[ + cls.RESPONSE_MODE.Valid + ][cls.VERSION_API_PATH] = new_version + return subclass + + return decorator + + RESPONSE_MODE = ( + collections.namedtuple('RESPONSE_MODE', 'Valid Invalid BadStatus') + (Valid='Valid', Invalid='Invalid', BadStatus='BadStatus') + ) + BAD_STATUS_RESPONSE = MockResponse( + { + 'errorCode': 500, + 'message': 'Test default bad status', + }, 500 + ) + MOCK_RESPONSES = {} + DEFAULT_MOCK_RESPONSES = { + RESPONSE_MODE.Valid: { + '/login': 'token', + VERSION_API_PATH: '4.5', + '/logout': '', + }, + RESPONSE_MODE.Invalid: { + VERSION_API_PATH: '2.5', + }, + RESPONSE_MODE.BadStatus: { + '/login': MockResponse( + { + 'errorCode': 1, + 'message': 'Test login bad status', + }, 400 + ), + '/version': MockResponse( + { + 'errorCode': 2, + 'message': 'Test version bad status', + }, 400 + ), + '/logout': MockResponse( + { + 'errorCode': 3, + 'message': 'Test logout bad status', + }, 400 + ) + } + } + __http_response_mode = RESPONSE_MODE.Valid + + def setUp(self): + """ + Set up the test case. + + Creates a PyPowerFlex client and sets up mocked HTTP responses. + """ + self.gateway_address = '1.2.3.4' + self.gateway_port = 443 + self.username = 'admin' + self.password = 'admin' + self.client = PyPowerFlex.PowerFlexClient(self.gateway_address, + self.gateway_port, + self.username, + self.password, + log_level=logging.DEBUG) + requests.request = self.get_mock_response + self.get_mock = self.mock_object(requests, + 'get', + side_effect=self.get_mock_response) + self.post_mock = self.mock_object(requests, + 'post', + side_effect=self.get_mock_response) + utils.is_version_3 = mock.MagicMock(return_value=True) + + def mock_object(self, obj, attr_name, *args, **kwargs): + """Use python mock to mock an object attribute. + + Mocks the specified objects attribute with the given value. + Automatically performs 'addCleanup' for the mock. + """ + patcher = mock.patch.object(obj, attr_name, *args, **kwargs) + result = patcher.start() + self.addCleanup(patcher.stop) + return result + + @contextlib.contextmanager + def http_response_mode(self, mode): + """ + Context manager for setting the HTTP response mode. + + Args: + mode: The HTTP response mode. + + Yields: + None. + """ + previous_response_mode, self.__http_response_mode = ( + self.__http_response_mode, mode + ) + yield + self.__http_response_mode = previous_response_mode + + def extract_path_segment(self, url, request_url): + """ + Return the REST path from a URL, removing the domain and optional '/api' prefix. + If `url` lacks domain info, fallback to `request_url`. Ensures output starts with '/'. + """ + + def strip_domain(u): + """ + Strip scheme and domain from a full URL, keeping only the path. + """ + if '://' in u: + parts = u.split('://', 1)[-1].split('/', 1) + return '/' + parts[1] if len(parts) > 1 else '/' + if u.startswith('/'): + return u + parts = request_url.split('://', 1)[-1].split('/', 1) + return '/' + parts[1] if len(parts) > 1 else '/' + + path = strip_domain(url) + + if path.startswith('/api/'): + path = path[4:] # remove /api + elif path == '/api': + path = '/' + + if not path.startswith('/'): + path = '/' + path + + return path + + def get_mock_response(self, url, request_url=None, mode=None, *args, **kwargs): + """ + Get a mock HTTP response. + + Args: + url (str): The URL of the request. + request_url (str): The URL of the request. + mode (str): The HTTP response mode. + *args: Additional arguments. + **kwargs: Additional keyword arguments. + + Returns: + requests.Response: The mocked HTTP response. + """ + if mode is None: + mode = self.__http_response_mode + + api_path = self.extract_path_segment(url, request_url) + try: + if api_path == "/login": + response = self.RESPONSE_MODE.Valid[0] + elif api_path == "/logout": + response = self.RESPONSE_MODE.Valid[2] + else: + response = self.MOCK_RESPONSES[mode][api_path] + except KeyError as e: + try: + response = self.DEFAULT_MOCK_RESPONSES[mode][api_path] + except KeyError: + if mode == self.RESPONSE_MODE.BadStatus: + response = self.BAD_STATUS_RESPONSE + else: + raise Exception( + f"Mock API Endpoint is not implemented: [{mode}]" + f"{api_path}" + ) from e + if not isinstance(response, MockResponse): + response = self._get_mock_response(response) + + response.request.url = url + response.request.body = kwargs.get('data') + return response + + def _get_mock_response(self, response): + """ + Returns a MockResponse object based on the given response. + + Args: + response (str): The response to be wrapped. + + Returns: + MockResponse: The mock response object. + """ + if "204" in str(response): + return MockResponse(response, 204) + return MockResponse(response, 200) diff --git a/tests/test_base.py b/tests/common/test_base.py similarity index 97% rename from tests/test_base.py rename to tests/common/test_base.py index 1068f8b..968f6fc 100644 --- a/tests/test_base.py +++ b/tests/common/test_base.py @@ -19,10 +19,10 @@ from PyPowerFlex import exceptions from PyPowerFlex import utils -import tests +from tests.common import PyPowerFlexTestCase - -class TestBaseClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestBaseClient(PyPowerFlexTestCase): """ Test class for the BaseClient. """ diff --git a/tests/test_host.py b/tests/common/test_host.py similarity index 97% rename from tests/test_host.py rename to tests/common/test_host.py index 4012d2d..96857e1 100644 --- a/tests/test_host.py +++ b/tests/common/test_host.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase -class TestHostClient(tests.PyPowerFlexTestCase): +class TestHostClient(PyPowerFlexTestCase): """ Tests for the HostClient class. """ diff --git a/tests/test_sdc.py b/tests/common/test_sdc.py similarity index 98% rename from tests/test_sdc.py rename to tests/common/test_sdc.py index 39f1f08..c246074 100644 --- a/tests/test_sdc.py +++ b/tests/common/test_sdc.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase -class TestSdcClient(tests.PyPowerFlexTestCase): +class TestSdcClient(PyPowerFlexTestCase): """ Tests for the SdcClient class. """ diff --git a/tests/test_sdt.py b/tests/common/test_sdt.py similarity index 98% rename from tests/test_sdt.py rename to tests/common/test_sdt.py index 927d007..98294c1 100644 --- a/tests/test_sdt.py +++ b/tests/common/test_sdt.py @@ -18,11 +18,11 @@ # pylint: disable=invalid-name,too-many-public-methods from PyPowerFlex import exceptions -from PyPowerFlex.objects import sdt -import tests +from PyPowerFlex.objects.common import sdt +from tests.common import PyPowerFlexTestCase -class TestSdtClient(tests.PyPowerFlexTestCase): +class TestSdtClient(PyPowerFlexTestCase): """ Tests for the SdtClient class. """ diff --git a/tests/test_system.py b/tests/common/test_system.py similarity index 98% rename from tests/test_system.py rename to tests/common/test_system.py index b5f6e92..ffe7073 100644 --- a/tests/test_system.py +++ b/tests/common/test_system.py @@ -18,11 +18,11 @@ # pylint: disable=invalid-name,too-many-public-methods,duplicate-code from PyPowerFlex import exceptions -from PyPowerFlex.objects import system -import tests +from PyPowerFlex.objects.common import system +from tests.common import PyPowerFlexTestCase -class TestSystemClient(tests.PyPowerFlexTestCase): +class TestSystemClient(PyPowerFlexTestCase): """ Test class for the SystemClient. """ diff --git a/tests/test_utility.py b/tests/common/test_utility.py similarity index 86% rename from tests/test_utility.py rename to tests/common/test_utility.py index a434a3f..9626f4d 100644 --- a/tests/test_utility.py +++ b/tests/common/test_utility.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase -class TestPowerFlexUtility(tests.PyPowerFlexTestCase): +class TestPowerFlexUtility(PyPowerFlexTestCase): """ Test class for the PowerFlex utility. """ @@ -39,6 +39,8 @@ def setUp(self): {}, '/types/Volume/instances/action/querySelectedStatistics': {}, + '/dtapi/rest/v1/metrics/query': + {}, } } @@ -69,3 +71,9 @@ def test_get_statistics_for_all_volumes_bad_status(self): with self.http_response_mode(self.RESPONSE_MODE.BadStatus): self.assertRaises(exceptions.PowerFlexClientException, self.client.utility.get_statistics_for_all_volumes) + + def test_query_metrics_for_all_storage_nodes(self): + """ + Test the query_metrics_for_all_storage_nodes method. + """ + self.client.utility.query_metrics_for_all_storage_nodes() diff --git a/tests/gen1/__init__.py b/tests/gen1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_acceleration_pool.py b/tests/gen1/test_acceleration_pool.py similarity index 95% rename from tests/test_acceleration_pool.py rename to tests/gen1/test_acceleration_pool.py index 195bfb9..70eeef3 100644 --- a/tests/test_acceleration_pool.py +++ b/tests/gen1/test_acceleration_pool.py @@ -13,16 +13,16 @@ # License for the specific language governing permissions and limitations # under the License. -"""Module for testing accelaration pool client.""" +"""Module for testing acceleration pool client.""" # pylint: disable=invalid-name from PyPowerFlex import exceptions -from PyPowerFlex.objects import acceleration_pool -import tests +from PyPowerFlex.objects.gen1 import acceleration_pool +from tests.common import PyPowerFlexTestCase - -class TestAccelerationPoolClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestAccelerationPoolClient(PyPowerFlexTestCase): """ Test class for the AccelerationPoolClient. """ diff --git a/tests/test_deployment.py b/tests/gen1/test_deployment.py similarity index 97% rename from tests/test_deployment.py rename to tests/gen1/test_deployment.py index 57d26e6..e81c245 100644 --- a/tests/test_deployment.py +++ b/tests/gen1/test_deployment.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase - -class TestDeploymentClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestDeploymentClient(PyPowerFlexTestCase): """ Test class for the DeploymentClient. """ diff --git a/tests/test_device.py b/tests/gen1/test_device.py similarity index 97% rename from tests/test_device.py rename to tests/gen1/test_device.py index 9c604ac..844f03d 100644 --- a/tests/test_device.py +++ b/tests/gen1/test_device.py @@ -18,11 +18,11 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -from PyPowerFlex.objects.device import MediaType -import tests +from PyPowerFlex.objects.gen1.device import MediaType +from tests.common import PyPowerFlexTestCase - -class TestDeviceClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestDeviceClient(PyPowerFlexTestCase): """ Test class for DeviceClient. """ diff --git a/tests/test_fault_set.py b/tests/gen1/test_fault_set.py similarity index 97% rename from tests/test_fault_set.py rename to tests/gen1/test_fault_set.py index 770f064..71686c0 100644 --- a/tests/test_fault_set.py +++ b/tests/gen1/test_fault_set.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase - -class TestFaultSetClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestFaultSetClient(PyPowerFlexTestCase): """ Test class for the PowerFlex FaultSetClient. """ diff --git a/tests/test_firmware_repository.py b/tests/gen1/test_firmware_repository.py similarity index 93% rename from tests/test_firmware_repository.py rename to tests/gen1/test_firmware_repository.py index 7ce7112..dbc1f8f 100644 --- a/tests/test_firmware_repository.py +++ b/tests/gen1/test_firmware_repository.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase - -class TestFirmwareRepositoryClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestFirmwareRepositoryClient(PyPowerFlexTestCase): """ Test class for FirmwareRepositoryClient. """ diff --git a/tests/test_managed_device.py b/tests/gen1/test_managed_device.py similarity index 93% rename from tests/test_managed_device.py rename to tests/gen1/test_managed_device.py index 9e5039c..5146260 100644 --- a/tests/test_managed_device.py +++ b/tests/gen1/test_managed_device.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase - -class TestManagedDeviceClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestManagedDeviceClient(PyPowerFlexTestCase): """ Test class for the ManagedDeviceClient. """ diff --git a/tests/test_protection_domain.py b/tests/gen1/test_protection_domain.py similarity index 98% rename from tests/test_protection_domain.py rename to tests/gen1/test_protection_domain.py index 8d3146a..4d437e9 100644 --- a/tests/test_protection_domain.py +++ b/tests/gen1/test_protection_domain.py @@ -18,11 +18,11 @@ # pylint: disable=invalid-name,too-many-public-methods,duplicate-code from PyPowerFlex import exceptions -from PyPowerFlex.objects import protection_domain -import tests +from PyPowerFlex.objects.gen1 import protection_domain +from tests.common import PyPowerFlexTestCase - -class TestProtectionDomainClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestProtectionDomainClient(PyPowerFlexTestCase): """ Test class for the ProtectionDomainClient. """ diff --git a/tests/test_replication_consistency_group.py b/tests/gen1/test_replication_consistency_group.py similarity index 98% rename from tests/test_replication_consistency_group.py rename to tests/gen1/test_replication_consistency_group.py index fb3befe..6bc6314 100644 --- a/tests/test_replication_consistency_group.py +++ b/tests/gen1/test_replication_consistency_group.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name,too-many-public-methods from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase - -class TestReplicationConsistencyGroupClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestReplicationConsistencyGroupClient(PyPowerFlexTestCase): """ Tests for the ReplicationConsistencyGroupClient. """ diff --git a/tests/test_replication_pair.py b/tests/gen1/test_replication_pair.py similarity index 97% rename from tests/test_replication_pair.py rename to tests/gen1/test_replication_pair.py index d81a95e..b7d8e42 100644 --- a/tests/test_replication_pair.py +++ b/tests/gen1/test_replication_pair.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase - -class TestReplicationPairClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestReplicationPairClient(PyPowerFlexTestCase): """ Test class for the ReplicationPairClient. """ diff --git a/tests/test_sds.py b/tests/gen1/test_sds.py similarity index 98% rename from tests/test_sds.py rename to tests/gen1/test_sds.py index 8b9ac4b..7773554 100644 --- a/tests/test_sds.py +++ b/tests/gen1/test_sds.py @@ -18,11 +18,11 @@ # pylint: disable=invalid-name,too-many-public-methods from PyPowerFlex import exceptions -from PyPowerFlex.objects import sds -import tests +from PyPowerFlex.objects.gen1 import sds +from tests.common import PyPowerFlexTestCase - -class TestSdsClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestSdsClient(PyPowerFlexTestCase): """ Tests for the SdsClient class. """ diff --git a/tests/test_service_template.py b/tests/gen1/test_service_template.py similarity index 95% rename from tests/test_service_template.py rename to tests/gen1/test_service_template.py index b0afa22..94db103 100644 --- a/tests/test_service_template.py +++ b/tests/gen1/test_service_template.py @@ -18,10 +18,10 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -import tests +from tests.common import PyPowerFlexTestCase - -class TestServiceTemplateClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestServiceTemplateClient(PyPowerFlexTestCase): """ Test class for the ServiceTemplateClient. """ diff --git a/tests/test_snapshot_policy.py b/tests/gen1/test_snapshot_policy.py similarity index 95% rename from tests/test_snapshot_policy.py rename to tests/gen1/test_snapshot_policy.py index 917650e..cf00d82 100644 --- a/tests/test_snapshot_policy.py +++ b/tests/gen1/test_snapshot_policy.py @@ -18,11 +18,11 @@ # pylint: disable=invalid-name from PyPowerFlex import exceptions -from PyPowerFlex.objects import snapshot_policy as sp -import tests +from PyPowerFlex.objects.gen1 import snapshot_policy as sp +from tests.common import PyPowerFlexTestCase - -class TestSnapshotPolicyClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestSnapshotPolicyClient(PyPowerFlexTestCase): """ Test class for snapshot policy client. """ @@ -59,7 +59,7 @@ def setUp(self): {}, '/types/SnapshotPolicy' '/instances/action/querySelectedStatistics': { - self.fake_policy_id: {'numOfSrcVols': 1} + self.fake_policy_id: {'numOfpypowerflexVols': 1} }, }, self.RESPONSE_MODE.Invalid: { @@ -231,9 +231,9 @@ def test_snapshot_policy_query_selected_statistics(self): Tests the behavior of the query_selected_statistics method. """ ret = self.client.snapshot_policy.query_selected_statistics( - properties=["numOfSrcVols"] + properties=["numOfpypowerflexVols"] ) - assert ret.get(self.fake_policy_id).get("numOfSrcVols") == 1 + assert ret.get(self.fake_policy_id).get("numOfpypowerflexVols") == 1 def test_snapshot_policy_query_selected_statistics_bad_status(self): """ @@ -244,5 +244,5 @@ def test_snapshot_policy_query_selected_statistics_bad_status(self): self.assertRaises( exceptions.PowerFlexFailQuerying, self.client.snapshot_policy.query_selected_statistics, - properties=["numOfSrcVols"], + properties=["numOfpypowerflexVols"], ) diff --git a/tests/test_storage_pool.py b/tests/gen1/test_storage_pool.py similarity index 98% rename from tests/test_storage_pool.py rename to tests/gen1/test_storage_pool.py index 0b0ba2c..fc0d6f3 100644 --- a/tests/test_storage_pool.py +++ b/tests/gen1/test_storage_pool.py @@ -18,13 +18,13 @@ # pylint: disable=invalid-name,too-many-public-methods from PyPowerFlex import exceptions -from PyPowerFlex.objects.storage_pool import CompressionMethod -from PyPowerFlex.objects.storage_pool import ExternalAccelerationType -from PyPowerFlex.objects.storage_pool import MediaType -import tests +from PyPowerFlex.objects.gen1.storage_pool import CompressionMethod +from PyPowerFlex.objects.gen1.storage_pool import ExternalAccelerationType +from PyPowerFlex.objects.gen1.storage_pool import MediaType +from tests.common import PyPowerFlexTestCase - -class TestStoragePoolClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestStoragePoolClient(PyPowerFlexTestCase): """ Test class for the StoragePoolClient. """ diff --git a/tests/test_volume.py b/tests/gen1/test_volume.py similarity index 98% rename from tests/test_volume.py rename to tests/gen1/test_volume.py index 0944a3d..f2b29b9 100644 --- a/tests/test_volume.py +++ b/tests/gen1/test_volume.py @@ -18,11 +18,11 @@ # pylint: disable=invalid-name,too-many-public-methods from PyPowerFlex import exceptions -from PyPowerFlex.objects import volume -import tests +from PyPowerFlex.objects.gen1 import volume +from tests.common import PyPowerFlexTestCase - -class TestVolumeClient(tests.PyPowerFlexTestCase): +@PyPowerFlexTestCase.version('4.5') +class TestVolumeClient(PyPowerFlexTestCase): """ Test class for the volume client. """ diff --git a/tests/gen2/__init__.py b/tests/gen2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gen2/test_protection_domain.py b/tests/gen2/test_protection_domain.py new file mode 100644 index 0000000..f4859e1 --- /dev/null +++ b/tests/gen2/test_protection_domain.py @@ -0,0 +1,277 @@ +# 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 protection domain client.""" + +# pylint: disable=invalid-name,too-many-public-methods + +from PyPowerFlex import exceptions +from tests.common import PyPowerFlexTestCase + + +@PyPowerFlexTestCase.version('5.0') +class TestProtectionDomainClient(PyPowerFlexTestCase): + """ + Tests for the ProtectionDomainClient class. + """ + + def setUp(self): + """ + Set up the test environment. + """ + super().setUp() + self.client.initialize() + self.fake_pd_id = '1' + self.fake_pd_name = "pd-1" + pd = { + 'id': self.fake_pd_id, + 'name': self.fake_pd_name, + 'protectionDomainState': 'Active', + 'rebuildEnabled': False, + 'rebalanceEnabled': False, + 'overallConcurrentIoLimit': 0, + 'bandwidthLimitOverallIos': 0, + 'bandwidthLimitBgDevScanner': 0, + 'bandwidthLimitGarbageCollector': 0, + 'bandwidthLimitSinglyImpactedRebuild': 0, + 'bandwidthLimitDoublyImpactedRebuild': 0, + 'bandwidthLimitRebalance': 0, + 'bandwidthLimitOther': 0, + 'bandwidthLimitNodeNetwork': 0, + } + + self.MOCK_RESPONSES = { + self.RESPONSE_MODE.Valid: { + '/types/ProtectionDomain/instances': + pd, + f'/instances/ProtectionDomain::{self.fake_pd_id}': + pd, + f'/instances/ProtectionDomain::{self.fake_pd_id}/action/removeProtectionDomain': + {}, + f'/instances/ProtectionDomain::{self.fake_pd_id}/action/activateProtectionDomain': + {'id': self.fake_pd_id}, + f'/instances/ProtectionDomain::{self.fake_pd_id}/action/inactivateProtectionDomain': + {'id': self.fake_pd_id}, + f'/instances/ProtectionDomain::{self.fake_pd_id}/action/setProtectionDomainName': + {}, + f'/instances/ProtectionDomain::{self.fake_pd_id}/action/setRebuildEnabled': + {}, + f'/instances/ProtectionDomain::{self.fake_pd_id}/action/setRebalanceEnabled': + {}, + f'/instances/ProtectionDomain::{self.fake_pd_id}/action/setSecondaryIoPolicy': + {}, + f'/instances/ProtectionDomain::{self.fake_pd_id}/relationships/StoragePool': + [], + }, + self.RESPONSE_MODE.Invalid: { + '/types/ProtectionDomain/instances': + {}, + } + } + + def test_protection_domain_get_by_id(self): + """ + Test the get_by_id of a protection domain. + """ + self.client.protection_domain.get_by_id(self.fake_pd_id) + + def test_protection_domain_get_by_name(self): + """ + Test the get_by_name of a protection domain. + """ + self.client.protection_domain.get_by_name(self.fake_pd_name) + + def test_protection_domain_update(self): + """ + Test the update of a protection domain. + """ + pd = { + 'id': self.fake_pd_id, + 'name': "new_name", + 'protectionDomainState': 'Inactive', + 'rebuildEnabled': True, + 'rebalanceEnabled': True, + 'overallConcurrentIoLimit': 1, + 'bandwidthLimitOverallIos': 1, + 'bandwidthLimitBgDevScanner': 1, + 'bandwidthLimitGarbageCollector': 1, + 'bandwidthLimitSinglyImpactedRebuild': 1, + 'bandwidthLimitDoublyImpactedRebuild': 1, + 'bandwidthLimitRebalance': 1, + 'bandwidthLimitOther': 1, + 'bandwidthLimitNodeNetwork': 1, + } + self.client.protection_domain.update(pd) + + def test_protection_domain_create(self): + """ + Test the creation of a protection domain. + """ + self.client.protection_domain.create({"name": self.fake_pd_name}) + + def test_protection_domain_create_bad_status(self): + """ + Test the creation of a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailCreating, + self.client.protection_domain.create, + {"name": self.fake_pd_name}) + + def test_protection_domain_delete(self): + """ + Test the deletion of a protection domain. + """ + self.client.protection_domain.delete(self.fake_pd_id) + + def test_protection_domain_delete_bad_status(self): + """ + Test the deletion of a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailDeleting, + self.client.protection_domain.delete, + self.fake_pd_id) + + def test_protection_domain_activate(self): + """ + Test the activation of a protection domain. + """ + self.client.protection_domain.activate(self.fake_pd_id) + + def test_protection_domain_activate_bad_status(self): + """ + Test the activation of a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.protection_domain.activate, + self.fake_pd_id) + + def test_protection_domain_inactivate(self): + """ + Test the inactivation of a protection domain. + """ + self.client.protection_domain.inactivate(self.fake_pd_id) + + def test_protection_domain_inactivate_bad_status(self): + """ + Test the inactivation of a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.protection_domain.inactivate, + self.fake_pd_id) + + def test_protection_domain_rebuild(self): + """ + Test the rebuild of a protection domain. + """ + self.client.protection_domain.set_rebuild_enabled( + self.fake_pd_id, False) + + def test_protection_domain_rebuild_bad_status(self): + """ + Test the rebuild of a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.protection_domain.set_rebuild_enabled, + self.fake_pd_id, False) + + def test_protection_domain_rebalance(self): + """ + Test the rebalance of a protection domain. + """ + self.client.protection_domain.set_rebalance_enabled( + self.fake_pd_id, False) + + def test_protection_domain_rebalance_bad_status(self): + """ + Test the rebalance of a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.protection_domain.set_rebalance_enabled, + self.fake_pd_id, False) + + def test_protection_domain_set_secondary_io_policy(self): + """ + Test the set_secondary_io_policy of a protection domain. + """ + self.client.protection_domain.set_secondary_io_policy(self.fake_pd_id, { + 'policy': 'favorApplication', + 'overallConcurrentIoLimit': 0, + 'bandwidthLimitOverallIos': 0, + 'bandwidthLimitBgDevScanner': 0, + 'bandwidthLimitGarbageCollector': 0, + 'bandwidthLimitSinglyImpactedRebuild': 0, + 'bandwidthLimitDoublyImpactedRebuild': 0, + 'bandwidthLimitRebalance': 0, + 'bandwidthLimitOther': 0, + 'bandwidthLimitNodeNetwork': 0, + }) + + def test_protection_domain_set_secondary_io_policy_bad_status(self): + """ + Test the set_secondary_io_policy of a protection domain with a bad status. + """ + policy = { + 'policy': 'favorApplication', + 'overallConcurrentIoLimit': 0, + 'bandwidthLimitOverallIos': 0, + 'bandwidthLimitBgDevScanner': 0, + 'bandwidthLimitGarbageCollector': 0, + 'bandwidthLimitSinglyImpactedRebuild': 0, + 'bandwidthLimitDoublyImpactedRebuild': 0, + 'bandwidthLimitRebalance': 0, + 'bandwidthLimitOther': 0, + 'bandwidthLimitNodeNetwork': 0, + } + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.protection_domain.set_secondary_io_policy, + self.fake_pd_id, policy) + + def test_protection_domain_rename(self): + """ + Test the rename method of a protection domain. + """ + self.client.protection_domain.rename(self.fake_pd_id, name='new_name') + + def test_protection_domain_rename_bad_status(self): + """ + Test the rename method of a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailRenaming, + self.client.protection_domain.rename, + self.fake_pd_id, + name='new_name') + + def test_protection_domain_get_storage_pools(self): + """ + Test the retrieval of storage pools for a protection domain. + """ + self.client.protection_domain.get_storage_pools(self.fake_pd_id) + + def test_protection_domain_get_storage_pools_bad_status(self): + """ + Test the retrieval of storage pools for a protection domain with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.protection_domain.get_storage_pools, + self.fake_pd_id) diff --git a/tests/gen2/test_storage_node.py b/tests/gen2/test_storage_node.py new file mode 100644 index 0000000..9dfd636 --- /dev/null +++ b/tests/gen2/test_storage_node.py @@ -0,0 +1,180 @@ +# 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 Storage Node client.""" + +# pylint: disable=invalid-name,too-many-public-methods + +from PyPowerFlex import exceptions +from PyPowerFlex.objects.gen2.storage_node import StorageNodeIp, StorageNodeIpRoles +from tests.common import PyPowerFlexTestCase + + +@PyPowerFlexTestCase.version('5.0') +class TestStorageNodeClient(PyPowerFlexTestCase): + """ + Tests for the StorageNodeClient class. + """ + + def setUp(self): + """ + Set up the test environment. + """ + super().setUp() + self.client.initialize() + self.fake_node_id = '1' + self.fake_sp_id = '1' + self.fake_pd_id = '1' + self.fake_node_ips = [StorageNodeIp( + '1.2.3.4', StorageNodeIpRoles.storage_and_app)] + + self.MOCK_RESPONSES = { + self.RESPONSE_MODE.Valid: { + '/types/Node/instances': + {'id': self.fake_node_id}, + f'/instances/Node::{self.fake_node_id}': + {'id': self.fake_node_id}, + f'/instances/Node::{self.fake_node_id}/action/addIp': + {}, + f'/instances/Node::{self.fake_node_id}/action/removeNode': + {}, + f'/instances/Node::{self.fake_node_id}/action/removeIp': + {}, + f'/instances/Node::{self.fake_node_id}/action/renameStorageNode': + {}, + f'/instances/Node::{self.fake_node_id}/action/modifyIpRole': + {}, + }, + self.RESPONSE_MODE.Invalid: { + '/types/Node/instances': + {}, + } + } + + def test_storage_node_add_ip(self): + """ + Test the add_ip method of the storage_node client. + """ + self.client.storage_node.add_ip( + self.fake_node_id, self.fake_node_ips[0]) + + def test_storage_node_add_ip_bad_status(self): + """ + Test the add_ip method of the storage_node client with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_node.add_ip, + self.fake_node_id, + self.fake_node_ips[0]) + + def test_storage_node_create(self): + """ + Test the create method of the storage_node client. + """ + self.client.storage_node.create( + name='fake_node_name', + node_ips=self.fake_node_ips, + protection_domain_id=self.fake_pd_id + ) + + def test_storage_node_create_bad_status(self): + """ + Test the create method of the storage_node client with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailCreating, + self.client.storage_node.create, + name='fake_node_name', + node_ips=self.fake_node_ips, + protection_domain_id=self.fake_pd_id + ) + + def test_storage_node_create_no_id_in_response(self): + """ + Test the create method of the storage_node client with no ID in the response. + """ + with self.http_response_mode(self.RESPONSE_MODE.Invalid): + self.assertRaises(KeyError, + self.client.storage_node.create, + name='fake_node_name', + node_ips=[], + protection_domain_id=self.fake_pd_id) + + def test_storage_node_delete(self): + """ + Test the delete method of the storage_node client. + """ + self.client.storage_node.delete(self.fake_node_id) + + def test_storage_node_delete_bad_status(self): + """ + Test the delete method of the storage_node client with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailDeleting, + self.client.storage_node.delete, + self.fake_node_id) + + def test_storage_node_rename(self): + """ + Test the rename method of the storage_node client. + """ + self.client.storage_node.rename(self.fake_node_id, name='new_name') + + def test_storage_node_rename_bad_status(self): + """ + Test the rename method of the storage_node client with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailRenaming, + self.client.storage_node.rename, + self.fake_node_id, + name='new_name') + + def test_storage_node_remove_ip(self): + """ + Test the remove_ip method of the storage_node client. + """ + self.client.storage_node.remove_ip(self.fake_node_id, ip='1.2.3.4') + + def test_storage_node_remove_ip_bad_status(self): + """ + Test the remove_ip method of the storage_node client with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_node.remove_ip, + self.fake_node_id, + ip='1.2.3.4') + + def test_storage_node_set_ip_role(self): + """ + Test the set_ip_role method. + """ + self.client.storage_node.set_ip_role(self.fake_node_id, + ip='1.2.3.4', + role=StorageNodeIpRoles.storage) + + def test_storage_node_set_ip_role_bad_status(self): + """ + Test the set_ip_role method with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_node.set_ip_role, + self.fake_node_id, + ip='1.2.3.4', + role=StorageNodeIpRoles.storage_and_app) diff --git a/tests/gen2/test_storage_pool.py b/tests/gen2/test_storage_pool.py new file mode 100644 index 0000000..920c9d3 --- /dev/null +++ b/tests/gen2/test_storage_pool.py @@ -0,0 +1,346 @@ +# 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 storage pool client.""" + +# pylint: disable=invalid-name,too-many-public-methods + +from PyPowerFlex import exceptions +from tests.common import PyPowerFlexTestCase + + +@PyPowerFlexTestCase.version('5.0') +class TestStoragePoolClient(PyPowerFlexTestCase): + """ + Tests for the StoragePoolClient class. + """ + + # pylint: disable=R0801 + def setUp(self): + """ + Set up the test environment. + """ + super().setUp() + self.client.initialize() + self.fake_pd_id = '1' + self.fake_sp_id = '1' + self.fake_sp_name = "sp-1" + + sp = { + 'id': self.fake_sp_id, + 'name': self.fake_sp_name, + 'protectionDomainId': self.fake_pd_id, + 'deviceGroupId': "1", + 'wrcDeviceGroupId': "1", + 'genType': 'EC', + 'capacityAlertHighThreshold': 70, + 'capacityAlertCriticalThreshold': 90, + 'fragmentationEnabled': False, + 'overProvisioningFactor': 0, + 'physicalSizeGB': 10, + 'protectionScheme': 'TwoPlusTwo', + 'compressionMethod': 'None', + 'zeroPaddingEnabled': True, + } + + self.MOCK_RESPONSES = { + self.RESPONSE_MODE.Valid: { + '/types/StoragePool/instances': + sp, + f"/instances/StoragePool::{self.fake_sp_id}": + sp, + f'/instances/ProtectionDomain::{self.fake_pd_id}/relationships/StoragePool': + [sp], + f'/instances/StoragePool::{self.fake_sp_id}/action/removeStoragePool': + {}, + f'/instances/StoragePool::{self.fake_sp_id}/action/renameStoragePool': + {}, + f'/instances/StoragePool::{self.fake_sp_id}/action/setCapacityAlertThresholds': + {}, + f'/instances/StoragePool::{self.fake_sp_id}/action/setOverProvisioningFactor': + {}, + f'/instances/StoragePool::{self.fake_sp_id}/action/modifyStoragePoolSize': + {}, + f'/instances/StoragePool::{self.fake_sp_id}/action/modifyCompressionMethod': + {}, + f'/instances/StoragePool::{self.fake_sp_id}/action/setZeroPaddingPolicy': + {}, + }, + self.RESPONSE_MODE.Invalid: { + '/types/StoragePool/instances': + {}, + } + } + + def test_storage_pool_get_by_id(self): + """ + Test the get_by_id of a storage pool. + """ + self.client.storage_pool.get_by_id(self.fake_sp_id) + + def test_storage_pool_get_by_name(self): + """ + Test the get_by_name of a storage pool. + """ + self.client.storage_pool.get_by_name( + self.fake_pd_id, self.fake_sp_name) + + def test_storage_pool_update(self): + """ + Test the update of a storage pool. + """ + sp = { + 'id': self.fake_sp_id, + 'name': "new_name", + 'capacityAlertHighThreshold': 80, + 'capacityAlertCriticalThreshold': 95, + 'fragmentationEnabled': True, + 'overProvisioningFactor': 1000, + 'physicalSizeGB': 2, + 'compressionMethod': 'Normal', + 'zeroPaddingEnabled': False, + } + self.client.storage_pool.update(sp) + + def test_storage_pool_update_bad_status(self): + """ + Test the update of a storage pool with a bad status. + """ + sp = { + 'id': self.fake_sp_id, + 'name': self.fake_sp_name, + 'protectionDomainId': "new_pd_id", + 'deviceGroupId': "1", + 'protectionScheme': 'TwoPlusTwo', + 'wrcDeviceGroupId': "1", + 'genType': 'EC', + 'capacityAlertHighThreshold': 70, + 'capacityAlertCriticalThreshold': 90, + 'fragmentationEnabled': False, + 'overProvisioningFactor': 0, + 'physicalSizeGB': 10, + 'compressionMethod': 'None', + 'zeroPaddingEnabled': True, + } + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.update, + sp) + + def test_storage_pool_update_bad_status_1(self): + """ + Test the update of a storage pool with a bad status. + """ + sp = { + 'id': self.fake_sp_id, + 'name': self.fake_sp_name, + 'protectionDomainId': self.fake_pd_id, + 'deviceGroupId': "2", + 'protectionScheme': 'TwoPlusTwo', + 'wrcDeviceGroupId': "1", + 'genType': 'EC', + 'capacityAlertHighThreshold': 70, + 'capacityAlertCriticalThreshold': 90, + 'fragmentationEnabled': False, + 'overProvisioningFactor': 0, + 'physicalSizeGB': 10, + 'compressionMethod': 'None', + 'zeroPaddingEnabled': True, + } + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.update, + sp) + + def test_storage_pool_update_bad_status_2(self): + """ + Test the update of a storage pool with a bad status. + """ + sp = { + 'id': self.fake_sp_id, + 'name': self.fake_sp_name, + 'protectionDomainId': self.fake_pd_id, + 'deviceGroupId': "1", + 'protectionScheme': 'EightPlusTwo', + 'wrcDeviceGroupId': "1", + 'genType': 'EC', + 'capacityAlertHighThreshold': 70, + 'capacityAlertCriticalThreshold': 90, + 'fragmentationEnabled': False, + 'overProvisioningFactor': 0, + 'physicalSizeGB': 10, + 'compressionMethod': 'None', + 'zeroPaddingEnabled': True, + } + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.update, + sp) + + def test_storage_pool_create(self): + """ + Test the creation of a storage pool. + """ + sp = { + 'id': self.fake_sp_id, + 'name': self.fake_sp_name, + 'protectionDomainId': self.fake_pd_id, + 'deviceGroupId': "1", + 'wrcDeviceGroupId': "1", + 'genType': 'EC', + 'capacityAlertHighThreshold': 70, + 'capacityAlertCriticalThreshold': 90, + 'fragmentationEnabled': False, + 'overProvisioningFactor': 0, + 'physicalSizeGB': 10, + 'protectionScheme': 'TwoPlusTwo', + 'compressionMethod': 'None', + 'zeroPaddingEnabled': True, + } + self.client.storage_pool.create(sp) + + def test_storage_pool_create_bad_status(self): + """ + Test the creation of a storage pool with a bad status. + """ + sp = { + 'id': self.fake_sp_id, + 'name': self.fake_sp_name, + 'protectionDomainId': self.fake_pd_id, + 'deviceGroupId': "1", + 'wrcDeviceGroupId': "1", + 'genType': 'EC', + 'capacityAlertHighThreshold': 70, + 'capacityAlertCriticalThreshold': 90, + 'fragmentationEnabled': False, + 'overProvisioningFactor': 0, + 'physicalSizeGB': 10, + 'protectionScheme': 'TwoPlusTwo', + 'compressionMethod': 'None', + 'zeroPaddingEnabled': True, + } + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailCreating, + self.client.storage_pool.create, + sp) + + # pylint: disable=R0801 + def test_storage_pool_delete(self): + """ + Test the deletion of a storage pool. + """ + self.client.storage_pool.delete(self.fake_sp_id) + + def test_storage_pool_delete_bad_status(self): + """ + Test the deletion of a storage pool with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailDeleting, + self.client.storage_pool.delete, + self.fake_sp_id) + + def test_storage_pool_set_capacity_alert_thresholds(self): + """ + Test the set_capacity_alert_thresholds of a storage pool. + """ + self.client.storage_pool.set_capacity_alert_thresholds( + self.fake_sp_id, 1, 2) + + def test_storage_pool_set_capacity_alert_thresholds_bad_status(self): + """ + Test the set_capacity_alert_thresholds of a storage pool with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.set_capacity_alert_thresholds, + self.fake_sp_id, 1, 2) + + def test_storage_pool_set_over_provisioning_factor(self): + """ + Test the set_over_provisioning_factor of a storage pool. + """ + self.client.storage_pool.set_over_provisioning_factor( + self.fake_sp_id, 0) + + def test_storage_pool_set_over_provisioning_factor_bad_status(self): + """ + Test the set_over_provisioning_factor of a storage pool with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.set_over_provisioning_factor, + self.fake_sp_id, 0) + + def test_storage_pool_resize(self): + """ + Test the resize of a storage pool. + """ + self.client.storage_pool.resize(self.fake_sp_id, 1) + + def test_storage_pool_resize_bad_status(self): + """ + Test the resize of a storage pool with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.resize, + self.fake_sp_id, 1) + + def test_storage_pool_set_compression_method(self): + """ + Test the set_compression_method of a storage pool. + """ + self.client.storage_pool.set_compression_method( + self.fake_sp_id, "None") + + def test_storage_pool_set_compression_method_bad_status(self): + """ + Test the set_compression_method of a storage pool with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.set_compression_method, + self.fake_sp_id, "None") + + def test_storage_pool_set_zero_padding_policy(self): + """ + Test the set_zero_padding_policy of a storage pool. + """ + self.client.storage_pool.set_zero_padding_policy( + self.fake_sp_id, False) + + def test_storage_pool_set_zero_padding_policy_bad_status(self): + """ + Test the set_zero_padding_policy of a storage pool with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexClientException, + self.client.storage_pool.set_zero_padding_policy, + self.fake_sp_id, False) + + # pylint: disable=R0801 + def test_storage_pool_rename(self): + """ + Test the rename method of a storage pool. + """ + self.client.storage_pool.rename(self.fake_sp_id, name='new_name') + + def test_storage_pool_rename_bad_status(self): + """ + Test the rename method of a storage pool with a bad status. + """ + with self.http_response_mode(self.RESPONSE_MODE.BadStatus): + self.assertRaises(exceptions.PowerFlexFailRenaming, + self.client.storage_pool.rename, + self.fake_sp_id, + name='new_name') diff --git a/tests/requirements.txt b/tests/requirements.txt index 9b342fd..5423c8d 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,3 @@ testtools pytest pytest-coverage -