diff --git a/plugins/module_utils/common/conversion.py b/plugins/module_utils/common/conversion.py new file mode 100644 index 000000000..684bffe2f --- /dev/null +++ b/plugins/module_utils/common/conversion.py @@ -0,0 +1,173 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import inspect +import re + + +class ConversionUtils: + """ + Utility methods for converting, translating, and validating values. + + - bgp_asn_is_valid: Return True if value is a valid BGP ASN, False otherwise. + - make_boolean: Return value converted to boolean, if possible. + - make_int: Return value converted to int, if possible. + - make_none: Return None if value is a string representation of a None type. + - reject_boolean_string: Reject quoted boolean values e.g. "False", "true" + - translate_mac_address: Convert mac address to dotted-quad format expected by the controller. + - validate_fabric_name: Validate the fabric name meets the requirements of the controller. + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + re_asn_str = "^(((\\+)?[1-9]{1}[0-9]{0,8}|(\\+)?[1-3]{1}[0-9]{1,9}|(\\+)?[4]" + re_asn_str += "{1}([0-1]{1}[0-9]{8}|[2]{1}([0-8]{1}[0-9]{7}|[9]{1}([0-3]{1}" + re_asn_str += "[0-9]{6}|[4]{1}([0-8]{1}[0-9]{5}|[9]{1}([0-5]{1}[0-9]{4}|[6]" + re_asn_str += "{1}([0-6]{1}[0-9]{3}|[7]{1}([0-1]{1}[0-9]{2}|[2]{1}([0-8]{1}" + re_asn_str += "[0-9]{1}|[9]{1}[0-5]{1})))))))))|([1-5]\\d{4}|[1-9]\\d{0,3}|6" + re_asn_str += "[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])" + re_asn_str += "(\\.([1-5]\\d{4}|[1-9]\\d{0,3}|6[0-4]\\d{3}|65[0-4]" + re_asn_str += "\\d{2}|655[0-2]\\d|6553[0-5]|0))?)$" + self.re_asn = re.compile(re_asn_str) + self.re_valid_fabric_name = re.compile(r"[a-zA-Z]+[a-zA-Z0-9_-]*") + + self.bgp_as_invalid_reason = None + + def bgp_as_is_valid(self, value): + """ + - Return True if value is a valid BGP ASN. + - Return False, otherwise. + - Set ConversionUtils().bgp_as_invalid_reason to a string with the + reason why the value is not a valid BGP ASN. + + Usage example: + + ```python + conversion = ConversionUtils() + if not conversion.bgp_as_is_valid(value): + print(conversion.bgp_as_invalid_reason) + ``` + """ + if isinstance(value, float): + msg = f"BGP ASN ({value}) cannot be type float() due to " + msg += "loss of trailing zeros. " + msg += "Use a string or integer instead." + self.bgp_as_invalid_reason = msg + return False + try: + asn = str(value) + except UnicodeEncodeError: + msg = f"BGP ASN ({value}) could not be converted to a string." + self.bgp_as_invalid_reason = msg + return False + if not self.re_asn.match(asn): + msg = f"BGP ASN {value} failed regex validation." + self.bgp_as_invalid_reason = msg + return False + return True + + @staticmethod + def make_boolean(value): + """ + - Return value converted to boolean, if possible. + - Return value, otherwise. + """ + if str(value).lower() in ["true", "yes"]: + return True + if str(value).lower() in ["false", "no"]: + return False + return value + + @staticmethod + def make_int(value): + """ + - Return value converted to int, if possible. + - Return value, otherwise. + """ + # Don't convert boolean values to integers + if isinstance(value, bool): + return value + try: + return int(value) + except (ValueError, TypeError): + return value + + @staticmethod + def make_none(value): + """ + - Return None if value is a string representation of a None type + - Return value, otherwise. + """ + if str(value).lower() in {"", "none", "null"}: + return None + return value + + @staticmethod + def reject_boolean_string(parameter, value) -> None: + """ + - Reject quoted boolean values e.g. "False", "true" + - Raise ``ValueError`` with informative message if the value is + a string representation of a boolean. + """ + if isinstance(value, int): + return + if isinstance(value, bool): + return + if str(value).lower() in ["true", "false"]: + msg = f"Parameter {parameter}, value '{value}', " + msg += "is a quoted string representation of a boolean. " + msg += "Please remove the quotes and try again " + msg += "(e.g. True/False or true/false, instead of " + msg += "'True'/'False' or 'true'/'false')." + raise ValueError(msg) + + @staticmethod + def translate_mac_address(mac_addr): + """ + - Accept mac address with any (or no) punctuation and convert it + into the dotted-quad format that the controller expects. + - On success, return translated mac address. + - On failure, raise ``ValueError``. + """ + mac_addr = re.sub(r"[\W\s_]", "", mac_addr) + if not re.search("^[A-Fa-f0-9]{12}$", mac_addr): + raise ValueError(f"Invalid MAC address: {mac_addr}") + return "".join((mac_addr[:4], ".", mac_addr[4:8], ".", mac_addr[8:])) + + def validate_fabric_name(self, value): + """ + - Validate the fabric name meets the requirements of the controller. + - Raise ``TypeError`` if value is not a string. + - Raise ``ValueError`` if value does not meet the requirements. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid fabric name. Expected string. Got {value}." + raise TypeError(msg) + + if re.fullmatch(self.re_valid_fabric_name, value) is not None: + return + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid fabric name: {value}. " + msg += "Fabric name must start with a letter A-Z or a-z and " + msg += "contain only the characters in: [A-Z,a-z,0-9,-,_]." + raise ValueError(msg) diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py new file mode 100644 index 000000000..d1947d8a9 --- /dev/null +++ b/plugins/module_utils/common/exceptions.py @@ -0,0 +1,22 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + + +class ControllerResponseError(Exception): + pass diff --git a/plugins/module_utils/common/params_validate.py b/plugins/module_utils/common/params_validate.py index 9da3489f6..a38a07836 100644 --- a/plugins/module_utils/common/params_validate.py +++ b/plugins/module_utils/common/params_validate.py @@ -326,7 +326,7 @@ def _ipaddress_guard(self, expected_type, value: Any, param: str) -> None: The ipaddress module accepts int and bool types and converts them to IP addresses or networks. E.g. True becomes 0.0.0.1, - False becomes 0.0.0.0, 1 becomse 0.0.0.1, etc. Because of + False becomes 0.0.0.0, 1 becomes 0.0.0.1, etc. Because of this, we need to fail int and bool values if expected_type is one of ipv4, ipv6, ipv4_subnet, or ipv6_subnet. """ diff --git a/plugins/module_utils/common/response_handler.py b/plugins/module_utils/common/response_handler.py new file mode 100644 index 000000000..1953de773 --- /dev/null +++ b/plugins/module_utils/common/response_handler.py @@ -0,0 +1,232 @@ +# +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging + + +class ResponseHandler: + """ + - Parse response from the controller and set self.result + based on the response. + - Usage: + + ```python + # import and instantiate the class + from ansible_collections.cisco.dcnm.plugins.module_utils.common.response_handler import ResponseHandler + response_handler = ResponseHandler() + + # Set the response from the controller + response_handler.response = controller_response + + # Set the request verb + response_handler.verb = "GET" + + # Call commit to parse the response + response_handler.commit() + + # Access the result + result = response_handler.result + ``` + + - NOTES: + - This class is not currently used. RestSend() will leverage it later. + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self._properties = {} + self._properties["response"] = None + self._properties["result"] = None + + self.return_codes_success = {200, 404} + self.valid_verbs = {"DELETE", "GET", "POST", "PUT"} + + def _handle_response(self) -> None: + """ + - Call the appropriate handler for response based on verb + - Raise ``ValueError`` if verb is unknown + """ + if self.verb == "GET": + self._get_response() + else: + self._post_put_delete_response() + + def _get_response(self) -> None: + """ + - Handle controller responses to GET requests and set self.result + with the following: + - found: + - False, if response: + - MESSAGE == "Not found" and + - RETURN_CODE == 404 + - True otherwise + - success: + - False if response: + - RETURN_CODE != 200 or + - MESSAGE != "OK" + - True otherwise + """ + result = {} + if ( + self.response.get("RETURN_CODE") == 404 + and self.response.get("MESSAGE") == "Not Found" + ): + result["found"] = False + result["success"] = True + elif ( + self.response.get("RETURN_CODE") not in self.return_codes_success + or self.response.get("MESSAGE") != "OK" + ): + result["found"] = False + result["success"] = False + else: + result["found"] = True + result["success"] = True + self.result = copy.copy(result) + + def _post_put_delete_response(self) -> None: + """ + - Handle POST, PUT, DELETE responses from the controller + and set self.result with the following + - changed: + - True if changes were made by the controller + - ERROR key is not present + - MESSAGE == "OK" + - False otherwise + - success: + - False if response: + - MESSAGE != "OK" or + - ERROR key is present + - True otherwise + """ + result = {} + if self.response.get("ERROR") is not None: + result["success"] = False + result["changed"] = False + elif ( + self.response.get("MESSAGE") != "OK" + and self.response.get("MESSAGE") is not None + ): + result["success"] = False + result["changed"] = False + else: + result["success"] = True + result["changed"] = True + self.result = copy.copy(result) + + def commit(self): + """ + - Parse the response from the controller and set self.result + based on the response. + - Raise ``ValueError`` if response is not set + - Raise ``ValueError`` if verb is not set + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"response {self.response}, verb {self.verb}" + self.log.debug(msg) + if self.response is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.response must be set prior to calling " + msg += f"{self.class_name}.{method_name}" + raise ValueError(msg) + if self.verb is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.verb must be set prior to calling " + msg += f"{self.class_name}.{method_name}" + raise ValueError(msg) + self._handle_response() + + @property + def response(self): + """ + - getter: Return response. + - setter: Set response. + - setter: Raise ``ValueError`` if response is not a dict. + - setter: Raise ``ValueError`` if MESSAGE key is missing + in response. + - setter: Raise ``ValueError`` if RETURN_CODE key is missing + in response. + """ + return self._properties.get("response", None) + + @response.setter + def response(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise ValueError(msg) + if value.get("MESSAGE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a MESSAGE key. " + msg += f"Got: {value}." + raise ValueError(msg) + if value.get("RETURN_CODE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a RETURN_CODE key. " + msg += f"Got: {value}." + raise ValueError(msg) + self._properties["response"] = value + + @property + def result(self): + """ + - getter: Return result. + - setter: Set result. + - setter: Raise ``ValueError`` if result is not a dict. + """ + return self._properties.get("result", None) + + @result.setter + def result(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise ValueError(msg) + self._properties["result"] = value + + @property + def verb(self): + """ + - getter: Return request verb. + - setter: Set request verb. + - setter: Raise ``ValueError`` if request verb is invalid. + """ + return self._properties.get("verb", None) + + @verb.setter + def verb(self, value): + method_name = inspect.stack()[0][3] + if value not in self.valid_verbs: + msg = f"{self.class_name}.{method_name}: " + msg += "verb must be one of " + msg += f"{', '.join(sorted(self.valid_verbs))}. " + msg += f"Got {value}." + raise ValueError(msg) + self._properties["verb"] = value diff --git a/plugins/module_utils/common/results.py b/plugins/module_utils/common/results.py index 6d174be44..9ef9e8115 100644 --- a/plugins/module_utils/common/results.py +++ b/plugins/module_utils/common/results.py @@ -39,13 +39,13 @@ class Results: - Typically done by transferring RestSend's responses to the Results instance 3. Register the results of the task with Results, using: - - Results.register_task_results() + - Results.register_task_result() - Typically done after the task is complete Results should be instantiated in the main Ansible Task class and passed to all other task classes. The task classes should populate the Results instance with the results of the task and then register the results with - Results.register_task_results(). This may be done within a separate class + Results.register_task_result(). This may be done within a separate class (as in the example below, where FabricDelete() class is called from the TaskDelete() class. The Results instance can then be used to build the final result, by calling Results.build_final_result(). @@ -95,7 +95,7 @@ def commit(self): ... self.fabric_delete.fabric_names = ["FABRIC_1", "FABRIC_2"] self.fabric_delete.results = self.results - # results.register_task_results() is called within the + # results.register_task_result() is called within the # commit() method of the FabricDelete class. self.fabric_delete.commit() @@ -221,6 +221,7 @@ def did_anything_change(self) -> bool: msg += f"self.result_current: {self.result_current}, " msg += f"self.diff: {self.diff}" self.log.debug(msg) + if self.check_mode is True: return False if self.action == "query": @@ -232,9 +233,13 @@ def did_anything_change(self) -> bool: return True if self.result_current.get("changed", None) is False: return False - if len(self.diff) != 0: - return True - return False + for diff in self.diff: + something_changed = False + test_diff = copy.deepcopy(diff) + test_diff.pop("sequence_number", None) + if len(test_diff) != 0: + something_changed = True + return something_changed def register_task_result(self): """ diff --git a/plugins/module_utils/fabric/__init__.py b/plugins/module_utils/fabric/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py new file mode 100644 index 000000000..104d68d42 --- /dev/null +++ b/plugins/module_utils/fabric/common.py @@ -0,0 +1,456 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import inspect +import logging +from typing import Any, Dict + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.config_deploy import \ + FabricConfigDeploy +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.config_save import \ + FabricConfigSave +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_types import \ + FabricTypes + + +class FabricCommon: + """ + Common methods used by the other classes supporting + the dcnm_fabric module + + Usage (where params is AnsibleModule.params) + + class MyClass(FabricCommon): + def __init__(self, params): + super().__init__(params) + ... + """ + + def __init__(self, params): + self.class_name = self.__class__.__name__ + self.params = params + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.check_mode = self.params.get("check_mode", None) + if self.check_mode is None: + msg = f"{self.class_name}.__init__(): " + msg += "check_mode is required" + raise ValueError(msg) + + self.state = self.params.get("state", None) + if self.state is None: + msg = f"{self.class_name}.__init__(): " + msg += "state is required" + raise ValueError(msg) + + self.conversion = ConversionUtils() + self.config_save = FabricConfigSave(params) + self.config_deploy = FabricConfigDeploy(params) + self.fabric_types = FabricTypes() + + msg = "ENTERED FabricCommon(): " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + + # key: fabric_name, value: boolean + # If True, the operation was successful + # If False, the operation was not successful + self.config_save_result = {} + self.config_deploy_result = {} + self.send_payload_result = {} + + # key: fabric_name, value: dict + # Depending on state, updated in: + # - self._fabric_needs_update_for_merged_state() + # - self._fabric_needs_update_for_replaced_state() + # Used to update the fabric configuration on the controller + # with key/values that bring the controller to the intended + # configuration. This may include values not in the user + # configuration that are needed to set the fabric to its + # intended state. + self._fabric_changes_payload = {} + + # Reset (depending on state) in: + # - self._build_payloads_for_merged_state() + # - self._build_payloads_for_replaced_state() + # Updated (depending on state) in: + # - self._fabric_needs_update_for_merged_state() + # - self._fabric_needs_update_for_replaced_state() + self._fabric_update_required = set() + + self._payloads_to_commit: list = [] + + # path and verb cannot be defined here because endpoints.fabric name + # must be set first. Set these to None here and define them later in + # the commit() method. + self.path = None + self.verb = None + + self._init_properties() + self._init_key_translations() + + def _init_properties(self) -> None: + """ + Initialize the properties dictionary. + """ + self._properties: Dict[str, Any] = {} + self._properties["fabric_details"] = None + self._properties["fabric_summary"] = None + self._properties["fabric_type"] = "VXLAN_EVPN" + self._properties["rest_send"] = None + self._properties["results"] = None + + def _init_key_translations(self): + """ + Build a dictionary of fabric configuration key translations. + + The controller expects certain keys to be misspelled or otherwise + different from the keys used in the payload this module sends. + + The dictionary is keyed on the payload key, and the value is either: + - The key the controller expects. + - None, if the key is not expected to be found in the controller + fabric configuration. This is useful for keys that are only + used in the payload to the controller and later stripped before + sending to the controller. + """ + self._key_translations = {} + self._key_translations["DEFAULT_QUEUING_POLICY_CLOUDSCALE"] = ( + "DEAFULT_QUEUING_POLICY_CLOUDSCALE" + ) + self._key_translations["DEFAULT_QUEUING_POLICY_OTHER"] = ( + "DEAFULT_QUEUING_POLICY_OTHER" + ) + self._key_translations["DEFAULT_QUEUING_POLICY_R_SERIES"] = ( + "DEAFULT_QUEUING_POLICY_R_SERIES" + ) + self._key_translations["DEPLOY"] = None + + def _config_save(self, payload): + """ + - Save the fabric configuration to the controller. + Raise ``ValueError`` if payload is missing FABRIC_NAME. + - Raise ``ValueError`` if the endpoint assignment fails. + """ + method_name = inspect.stack()[0][3] + + fabric_name = payload.get("FABRIC_NAME", None) + if fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payload is missing mandatory parameter: FABRIC_NAME." + raise ValueError(msg) + + if self.send_payload_result[fabric_name] is False: + # Skip config-save if send_payload failed + # Set config_save_result to False so that config_deploy is skipped + self.config_save_result[fabric_name] = False + return + + self.config_save.payload = payload + self.config_save.rest_send = self.rest_send + self.config_save.results = self.results + try: + self.config_save.commit() + except ValueError as error: + raise ValueError(error) from error + result = self.rest_send.result_current["success"] + self.config_save_result[fabric_name] = result + + def _config_deploy(self, payload): + """ + - Deploy the fabric configuration to the controller. + - Skip config-deploy if config-save failed + - Re-raise ``ValueError`` from FabricConfigDeploy(), if any. + - Raise ``ValueError`` if the payload is missing the FABRIC_NAME key. + """ + method_name = inspect.stack()[0][3] + fabric_name = payload.get("FABRIC_NAME") + if fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payload is missing mandatory parameter: FABRIC_NAME." + raise ValueError(msg) + if self.config_save_result.get(fabric_name) is False: + # Skip config-deploy if config-save failed + return + + try: + self.config_deploy.fabric_details = self.fabric_details + self.config_deploy.payload = payload + self.config_deploy.fabric_summary = self.fabric_summary + self.config_deploy.rest_send = self.rest_send + self.config_deploy.results = self.results + except TypeError as error: + raise ValueError(error) from error + try: + self.config_deploy.commit() + except ValueError as error: + raise ValueError(error) from error + result = self.config_deploy.results.result_current["success"] + self.config_deploy_result[fabric_name] = result + + def _prepare_parameter_value_for_comparison(self, value): + """ + convert payload values to controller formats + + Comparison order is important. + bool needs to be checked before int since: + isinstance(True, int) == True + isinstance(False, int) == True + """ + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return str(value) + return value + + def translate_anycast_gw_mac(self, fabric_name, mac_address): + """ + Try to translate the ANYCAST_GW_MAC payload value to the format + expected by the controller. + + - Return the translated mac_address if successful + - Otherwise: + - Set results.failed to True + - Set results.changed to False + - Register the task result + - raise ``ValueError`` + """ + method_name = inspect.stack()[0][3] + try: + mac_address = self.conversion.translate_mac_address(mac_address) + except ValueError as error: + self.results.failed = True + self.results.changed = False + self.results.register_task_result() + + msg = f"{self.class_name}.{method_name}: " + msg += "Error translating ANYCAST_GW_MAC: " + msg += f"for fabric {fabric_name}, " + msg += f"ANYCAST_GW_MAC: {mac_address}, " + msg += f"Error detail: {error}" + self.log.debug(msg) + raise ValueError(msg) from error + return mac_address + + def _fixup_payloads_to_commit(self) -> None: + """ + - Make any modifications to the payloads prior to sending them + to the controller. + - raise ``ValueError`` if any modifications fail. + + NOTES: + 1. Add any modifications to the Modifications list below. + + Modifications: + - Translate ANYCAST_GW_MAC to a format the controller understands + - Validate BGP_AS + """ + try: + self._fixup_anycast_gw_mac() + self._fixup_bgp_as() + except ValueError as error: + self.results.failed = True + self.results.changed = False + self.results.register_task_result() + raise ValueError(error) from error + + def _fixup_anycast_gw_mac(self) -> None: + """ + - Translate the ANYCAST_GW_MAC address to the format the + controller expects. + - Raise ``ValueError`` if the translation fails. + """ + method_name = inspect.stack()[0][3] + for payload in self._payloads_to_commit: + if "ANYCAST_GW_MAC" not in payload: + continue + try: + payload["ANYCAST_GW_MAC"] = self.conversion.translate_mac_address( + payload["ANYCAST_GW_MAC"] + ) + except ValueError as error: + fabric_name = payload.get("FABRIC_NAME", "UNKNOWN") + anycast_gw_mac = payload.get("ANYCAST_GW_MAC", "UNKNOWN") + + msg = f"{self.class_name}.{method_name}: " + msg += "Error translating ANYCAST_GW_MAC " + msg += f"for fabric {fabric_name}, " + msg += f"ANYCAST_GW_MAC: {anycast_gw_mac}, " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + def _fixup_bgp_as(self) -> None: + """ + Raise ``ValueError`` if BGP_AS is not a valid BGP ASN. + """ + method_name = inspect.stack()[0][3] + for payload in self._payloads_to_commit: + if "BGP_AS" not in payload: + continue + bgp_as = payload["BGP_AS"] + if not self.conversion.bgp_as_is_valid(bgp_as): + fabric_name = payload.get("FABRIC_NAME", "UNKNOWN") + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid BGP_AS {bgp_as} " + msg += f"for fabric {fabric_name}, " + msg += f"Error detail: {self.conversion.bgp_as_invalid_reason}" + raise ValueError(msg) + + def _verify_payload(self, payload) -> None: + """ + - Verify that the payload is a dict and contains all mandatory keys + - raise ``ValueError`` if the payload is not a dict + - raise ``ValueError`` if the payload is missing mandatory keys + """ + method_name = inspect.stack()[0][3] + if self.state not in {"merged", "replaced"}: + return + msg = f"{self.class_name}.{method_name}: " + msg += f"payload: {payload}" + self.log.debug(msg) + + if not isinstance(payload, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "Playbook configuration for fabrics must be a dict. " + msg += f"Got type {type(payload).__name__}, " + msg += f"value {payload}." + raise ValueError(msg) + + sorted_payload = dict(sorted(payload.items(), key=lambda item: item[0])) + fabric_type = payload.get("FABRIC_TYPE", None) + fabric_name = payload.get("FABRIC_NAME", "UNKNOWN") + + if fabric_type is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"Playbook configuration for fabric {fabric_name} " + msg += "is missing mandatory parameter FABRIC_TYPE. " + msg += "Valid values for FABRIC_TYPE: " + msg += f"{self.fabric_types.valid_fabric_types}. " + msg += f"Bad configuration: {sorted_payload}." + raise ValueError(msg) + + if fabric_type not in self.fabric_types.valid_fabric_types: + msg = f"{self.class_name}.{method_name}: " + msg += f"Playbook configuration for fabric {fabric_name} " + msg += f"contains an invalid FABRIC_TYPE ({fabric_type}). " + msg += "Valid values for FABRIC_TYPE: " + msg += f"{self.fabric_types.valid_fabric_types}. " + msg += f"Bad configuration: {sorted_payload}." + raise ValueError(msg) + + try: + self.conversion.validate_fabric_name(fabric_name) + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Playbook configuration for fabric {fabric_name} " + msg += "contains an invalid FABRIC_NAME. " + # error below already contains a period "." at the end + msg += f"Error detail: {error} " + msg += f"Bad configuration: {sorted_payload}." + raise ValueError(msg) from error + + missing_parameters = [] + # FABRIC_TYPE is already validated above. + # No need for try/except block here. + self.fabric_types.fabric_type = fabric_type + + for parameter in self.fabric_types.mandatory_parameters: + if parameter not in payload: + missing_parameters.append(parameter) + if len(missing_parameters) == 0: + return + + msg = f"{self.class_name}.{method_name}: " + msg += f"Playbook configuration for fabric {fabric_name} " + msg += "is missing mandatory parameters: " + msg += f"{sorted(missing_parameters)}. " + msg += f"Bad configuration: {sorted_payload}" + raise ValueError(msg) + + @property + def fabric_details(self): + """ + An instance of the FabricDetails class. + """ + return self._properties["fabric_details"] + + @fabric_details.setter + def fabric_details(self, value): + self._properties["fabric_details"] = value + + @property + def fabric_summary(self): + """ + An instance of the FabricSummary class. + """ + return self._properties["fabric_summary"] + + @fabric_summary.setter + def fabric_summary(self, value): + self._properties["fabric_summary"] = value + + @property + def fabric_type(self): + """ + - getter: Return the type of fabric to create/update. + - setter: Set the type of fabric to create/update. + - setter: raise ``ValueError`` if ``value`` is not a valid fabric type + + See ``FabricTypes().valid_fabric_types`` for valid values + """ + return self._properties["fabric_type"] + + @fabric_type.setter + def fabric_type(self, value): + method_name = inspect.stack()[0][3] + if value not in self.fabric_types.valid_fabric_types: + msg = f"{self.class_name}.{method_name}: " + msg += "FABRIC_TYPE must be one of " + msg += f"{self.fabric_types.valid_fabric_types}. " + msg += f"Got {value}" + raise ValueError(msg) + self._properties["fabric_type"] = value + + @property + def rest_send(self): + """ + An instance of the RestSend class. + """ + return self._properties["rest_send"] + + @rest_send.setter + def rest_send(self, value): + self._properties["rest_send"] = value + + @property + def results(self): + """ + An instance of the Results class. + """ + return self._properties["results"] + + @results.setter + def results(self, value): + self._properties["results"] = value diff --git a/plugins/module_utils/fabric/config_deploy.py b/plugins/module_utils/fabric/config_deploy.py new file mode 100644 index 000000000..c89299c92 --- /dev/null +++ b/plugins/module_utils/fabric/config_deploy.py @@ -0,0 +1,419 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging +from typing import Dict + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils +from ansible_collections.cisco.dcnm.plugins.module_utils.common.exceptions import \ + ControllerResponseError +# Used only to verify RestSend instance in rest_send property setter +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ + RestSend +# Used only to verify RestSend instance in rest_send property setter +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints + + +class FabricConfigDeploy: + """ + # Initiate a fabric config-deploy operation on the controller. + + - Raise ``ValueError`` for any caller errors, e.g. required properties + not being set before calling FabricConfigDeploy().commit(). + - Update FabricConfigDeploy().results to reflect success/failure of + the operation on the controller. + + ## Usage (where params is AnsibleModule.params) + + ```python + config_deploy = FabricConfigDeploy(params) + config_deploy.rest_send = RestSend() + config_deploy.payload = payload # a valid payload dictionary + config_deploy.fabric_details = FabricDetailsByName(params) + config_deploy.fabric_summary = FabricSummary(params) + config_deploy.results = Results() + try: + config_deploy.commit() + except ValueError as error: + raise ValueError(error) from error + ``` + """ + + def __init__(self, params): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.params = params + self.action = "config_deploy" + self.cannot_deploy_fabric_reason = "" + self.config_deploy_failed = False + self.fabric_can_be_deployed = False + + self.check_mode = self.params.get("check_mode", None) + if self.check_mode is None: + msg = f"{self.class_name}.__init__(): " + msg += "params is missing mandatory check_mode parameter." + raise ValueError(msg) + + self.state = self.params.get("state", None) + if self.state is None: + msg = f"{self.class_name}.__init__(): " + msg += "params is missing mandatory state parameter." + raise ValueError(msg) + + self.config_deploy_result: Dict[str, bool] = {} + + self.path = None + self.verb = None + self._init_properties() + + self.conversion = ConversionUtils() + self.endpoints = ApiEndpoints() + + msg = "ENTERED FabricConfigDeploy(): " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + + def _init_properties(self): + self._properties = {} + self._properties["fabric_details"] = None + self._properties["fabric_name"] = None + self._properties["fabric_summary"] = None + self._properties["payload"] = None + self._properties["rest_send"] = None + self._properties["results"] = None + + def _can_fabric_be_deployed(self) -> None: + """ + - Set self.fabric_can_be_deployed to True if the fabric configuration + can be deployed. + - Set self.fabric_can_be_deployed to False otherwise. + """ + method_name = inspect.stack()[0][3] + + self.fabric_can_be_deployed = False + + deploy = self.payload.get("DEPLOY", None) + if deploy is False or deploy is None: + msg = f"Fabric {self.fabric_name} DEPLOY is False or None. " + msg += "Skipping config-deploy." + self.log.debug(msg) + self.cannot_deploy_fabric_reason = msg + self.fabric_can_be_deployed = False + self.config_deploy_failed = False + return + + try: + self.fabric_summary.fabric_name = self.fabric_name + except ValueError as error: + msg = f"Fabric {self.fabric_name} is invalid. " + msg += "Cannot deploy fabric. " + msg += f"Error detail: {error}" + self.log.debug(msg) + self.cannot_deploy_fabric_reason = msg + self.fabric_can_be_deployed = False + self.config_deploy_failed = True + return + + try: + self.fabric_summary.refresh() + except (ControllerResponseError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error during FabricSummary().refresh(). " + msg += f"Error detail: {error}" + self.cannot_deploy_fabric_reason = msg + self.fabric_can_be_deployed = False + self.config_deploy_failed = True + return + + if self.fabric_summary.fabric_is_empty is True: + msg = f"Fabric {self.fabric_name} is empty. " + msg += "Cannot deploy an empty fabric." + self.log.debug(msg) + self.cannot_deploy_fabric_reason = msg + self.fabric_can_be_deployed = False + self.config_deploy_failed = False + return + + try: + self.fabric_details.refresh() + except ValueError as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error during FabricDetailsByName().refresh(). " + msg += f"Error detail: {error}" + self.cannot_deploy_fabric_reason = msg + self.fabric_can_be_deployed = False + self.config_deploy_failed = True + return + + self.fabric_details.filter = self.fabric_name + + if self.fabric_details.deployment_freeze is True: + msg = f"Fabric {self.fabric_name} DEPLOYMENT_FREEZE == True. " + msg += "Cannot deploy a fabric with deployment freeze enabled." + self.log.debug(msg) + self.cannot_deploy_fabric_reason = msg + self.fabric_can_be_deployed = False + self.config_deploy_failed = False + return + + if self.fabric_details.is_read_only is True: + msg = f"Fabric {self.fabric_name} IS_READ_ONLY == True. " + msg += "Cannot deploy a read only fabric." + self.log.debug(msg) + self.cannot_deploy_fabric_reason = msg + self.fabric_can_be_deployed = False + self.config_deploy_failed = False + return + + self.fabric_can_be_deployed = True + + def commit(self): + """ + - Initiate a config-deploy operation on the controller. + - Raise ``ValueError`` if FabricConfigDeploy().payload is not set. + - Raise ``ValueError`` if FabricConfigDeploy().rest_send is not set. + - Raise ``ValueError`` if FabricConfigDeploy().results is not set. + - Raise ``ValueError`` if the endpoint assignment fails. + """ + method_name = inspect.stack()[0][3] + + if self.fabric_details is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.fabric_details must be set " + msg += "before calling commit." + raise ValueError(msg) + if self.payload is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.payload must be set " + msg += "before calling commit." + raise ValueError(msg) + if self.fabric_summary is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.fabric_summary must be set " + msg += "before calling commit." + raise ValueError(msg) + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.rest_send must be set " + msg += "before calling commit." + raise ValueError(msg) + if self.results is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.results must be set " + msg += "before calling commit." + raise ValueError(msg) + + self._can_fabric_be_deployed() + + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name: {self.fabric_name}, " + msg += f"fabric_can_be_deployed: {self.fabric_can_be_deployed}, " + msg += f"cannot_deploy_fabric_reason: {self.cannot_deploy_fabric_reason}" + msg += f"config_deploy_failed: {self.config_deploy_failed}" + self.log.debug(msg) + + if self.fabric_can_be_deployed is False: + self.results.diff_current = {} + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.response_current = { + "RETURN_CODE": 200, + "MESSAGE": self.cannot_deploy_fabric_reason, + } + if self.config_deploy_failed is True: + self.results.result_current = {"changed": False, "success": False} + else: + self.results.result_current = {"changed": True, "success": True} + self.results.register_task_result() + return + + try: + self.endpoints.fabric_name = self.fabric_name + self.path = self.endpoints.fabric_config_deploy.get("path") + self.verb = self.endpoints.fabric_config_deploy.get("verb") + except ValueError as error: + raise ValueError(error) from error + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.payload = None + self.rest_send.commit() + + result = self.rest_send.result_current["success"] + self.config_deploy_result[self.fabric_name] = result + if self.config_deploy_result[self.fabric_name] is False: + self.results.diff_current = {} + else: + self.results.diff_current = { + "FABRIC_NAME": self.fabric_name, + f"{self.action}": "OK", + } + + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.response_current = copy.deepcopy(self.rest_send.response_current) + self.results.result_current = copy.deepcopy(self.rest_send.result_current) + self.results.register_task_result() + + @property + def fabric_name(self): + """ + The name of the fabric to config-save. + """ + return self._properties["fabric_name"] + + @fabric_name.setter + def fabric_name(self, value): + try: + self.conversion.validate_fabric_name(value) + except (TypeError, ValueError) as error: + raise ValueError(error) from error + self._properties["fabric_name"] = value + + @property + def fabric_details(self): + """ + - getter: Return an instance of the FabricDetailsByName class. + - setter: Set an instance of the FabricDetailsByName class. + - setter: Raise ``TypeError`` if the value is not an + instance of FabricDetailsByName. + """ + return self._properties["fabric_details"] + + @fabric_details.setter + def fabric_details(self, value): + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_details must be an instance of FabricDetailsByName. " + try: + class_name = value.class_name + except AttributeError as error: + msg += f"Error detail: {error}. " + raise TypeError(msg) from error + if class_name != "FabricDetailsByName": + msg += f"Got {class_name}." + self.log.debug(msg) + raise TypeError(msg) + self._properties["fabric_details"] = value + + @property + def fabric_summary(self): + """ + - getter: Return an instance of the FabricSummary class. + - setter: Set an instance of the FabricSummary class. + - setter: Raise ``TypeError`` if the value is not an + instance of FabricSummary. + """ + return self._properties["fabric_summary"] + + @fabric_summary.setter + def fabric_summary(self, value): + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_summary must be an instance of FabricSummary. " + try: + class_name = value.class_name + except AttributeError as error: + msg += f"Error detail: {error}. " + raise TypeError(msg) from error + if class_name != "FabricSummary": + msg += f"Got {class_name}." + self.log.debug(msg) + raise TypeError(msg) + self._properties["fabric_summary"] = value + + @property + def payload(self): + """ + - The fabric payload used to create/merge/replace the fabric. + - Raise ``ValueError`` if the value is not a dictionary. + - Raise ``ValueError`` the payload is missing FABRIC_NAME key. + """ + return self._properties["payload"] + + @payload.setter + def payload(self, value): + method_name = inspect.stack()[0][3] + + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name} must be a dictionary. " + msg += f"Got type: {type(value).__name__}." + self.log.debug(msg) + raise ValueError(msg) + if value.get("FABRIC_NAME", None) is None: + msg = f"{self.class_name}.{method_name} payload is missing " + msg += "FABRIC_NAME." + self.log.debug(msg) + raise ValueError(msg) + try: + self.fabric_name = value["FABRIC_NAME"] + except ValueError as error: + raise ValueError(error) from error + self._properties["payload"] = value + + @property + def rest_send(self): + """ + - getter: Return an instance of the RestSend class. + - setter: Set an instance of the RestSend class. + - setter: Raise ``TypeError`` if the value is not an + instance of RestSend. + """ + return self._properties["rest_send"] + + @rest_send.setter + def rest_send(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, RestSend): + msg = f"{self.class_name}.{method_name}: " + msg += "rest_send must be an instance of RestSend." + self.log.debug(msg) + raise TypeError(msg) + self._properties["rest_send"] = value + + @property + def results(self): + """ + - getter: Return an instance of the Results class. + - setter: Set an instance of the Results class. + - setter: Raise ``TypeError`` if the value is not an + instance of Results. + """ + return self._properties["results"] + + @results.setter + def results(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, Results): + msg = f"{self.class_name}.{method_name}: " + msg += "results must be an instance of Results." + self.log.debug(msg) + raise TypeError(msg) + self._properties["results"] = value diff --git a/plugins/module_utils/fabric/config_save.py b/plugins/module_utils/fabric/config_save.py new file mode 100644 index 000000000..eb45b563c --- /dev/null +++ b/plugins/module_utils/fabric/config_save.py @@ -0,0 +1,275 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging +from typing import Dict + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils +# Used only to verify RestSend instance in rest_send property setter +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ + RestSend +# Used only to verify RestSend instance in rest_send property setter +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints + + +class FabricConfigSave: + """ + # Initiate a fabric config-save operation on the controller. + + - Raise ``ValueError`` for any caller errors, e.g. required properties + not being set before calling FabricConfigSave().commit(). + - Update FabricConfigSave().results to reflect success/failure of + the operation on the controller. + + ## Usage (where params is AnsibleModule.params) + + ```python + config_save = FabricConfigSave(params) + config_save.rest_send = RestSend() + config_deploy.payload = payload # a valid payload dictionary + config_save.results = Results() + try: + config_save.commit() + except ValueError as error: + raise ValueError(error) from error + ``` + """ + + def __init__(self, params): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.params = params + self.action = "config_save" + self.cannot_save_fabric_reason = "" + self.config_save_failed = False + self.fabric_can_be_saved = False + + self.check_mode = self.params.get("check_mode", None) + if self.check_mode is None: + msg = f"{self.class_name}.__init__(): " + msg += "params is missing mandatory check_mode parameter." + raise ValueError(msg) + + self.state = self.params.get("state", None) + if self.state is None: + msg = f"{self.class_name}.__init__(): " + msg += "params is missing mandatory state parameter." + raise ValueError(msg) + + self.config_save_result: Dict[str, bool] = {} + + self.path = None + self.verb = None + self._init_properties() + + self.conversion = ConversionUtils() + self.endpoints = ApiEndpoints() + + msg = "ENTERED FabricConfigSave(): " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + + def _init_properties(self): + self._properties = {} + self._properties["fabric_name"] = None + self._properties["payload"] = None + self._properties["rest_send"] = None + self._properties["results"] = None + + def _can_fabric_be_saved(self) -> None: + """ + - Set self.fabric_can_be_saved to True if the fabric configuration + can be saved. + - Set self.fabric_can_be_saved to False otherwise. + """ + self.fabric_can_be_saved = False + + deploy = self.payload.get("DEPLOY", None) + if deploy is False or deploy is None: + msg = f"Fabric {self.fabric_name} DEPLOY is False or None. " + msg += "Skipping config-save." + self.log.debug(msg) + self.cannot_save_fabric_reason = msg + self.config_save_failed = False + self.fabric_can_be_saved = False + return + self.fabric_can_be_saved = True + + def commit(self): + """ + - Save the fabric configuration to the controller. + - Raise ``ValueError`` if the endpoint assignment fails. + """ + method_name = inspect.stack()[0][3] + + if self.payload is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.payload must be set " + msg += "before calling commit." + raise ValueError(msg) + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.rest_send must be set " + msg += "before calling commit." + raise ValueError(msg) + if self.results is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.results must be set " + msg += "before calling commit." + raise ValueError(msg) + + self._can_fabric_be_saved() + + if self.fabric_can_be_saved is False: + self.results.diff_current = {} + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.response_current = { + "RETURN_CODE": 200, + "MESSAGE": self.cannot_save_fabric_reason, + } + if self.config_save_failed is True: + self.results.result_current = {"changed": False, "success": False} + else: + self.results.result_current = {"changed": True, "success": True} + self.results.register_task_result() + return + + try: + self.endpoints.fabric_name = self.fabric_name + self.path = self.endpoints.fabric_config_save.get("path") + self.verb = self.endpoints.fabric_config_save.get("verb") + except ValueError as error: + raise ValueError(error) from error + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.payload = None + self.rest_send.commit() + + result = self.rest_send.result_current["success"] + self.config_save_result[self.fabric_name] = result + if self.config_save_result[self.fabric_name] is False: + self.results.diff_current = {} + else: + self.results.diff_current = { + "FABRIC_NAME": self.fabric_name, + f"{self.action}": "OK", + } + + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.response_current = copy.deepcopy(self.rest_send.response_current) + self.results.result_current = copy.deepcopy(self.rest_send.result_current) + self.results.register_task_result() + + @property + def fabric_name(self): + """ + The name of the fabric to config-save. + """ + return self._properties["fabric_name"] + + @fabric_name.setter + def fabric_name(self, value): + try: + self.conversion.validate_fabric_name(value) + except (TypeError, ValueError) as error: + raise ValueError(error) from error + self._properties["fabric_name"] = value + + @property + def payload(self): + """ + - The fabric payload used to create/merge/replace the fabric. + - Raise ``ValueError`` if the value is not a dictionary. + - Raise ``ValueError`` the payload is missing FABRIC_NAME key. + """ + return self._properties["payload"] + + @payload.setter + def payload(self, value): + method_name = inspect.stack()[0][3] + + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name} must be a dictionary. " + msg += f"Got type: {type(value).__name__}." + self.log.debug(msg) + raise ValueError(msg) + if value.get("FABRIC_NAME", None) is None: + msg = f"{self.class_name}.{method_name} payload is missing " + msg += "FABRIC_NAME." + self.log.debug(msg) + raise ValueError(msg) + try: + self.fabric_name = value["FABRIC_NAME"] + except ValueError as error: + raise ValueError(error) from error + self._properties["payload"] = value + + @property + def rest_send(self): + """ + - getter: Return an instance of the RestSend class. + - setter: Set an instance of the RestSend class. + - setter: Raise ``TypeError`` if the value is not an + instance of RestSend. + """ + return self._properties["rest_send"] + + @rest_send.setter + def rest_send(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, RestSend): + msg = f"{self.class_name}.{method_name}: " + msg += "rest_send must be an instance of RestSend." + self.log.debug(msg) + raise TypeError(msg) + self._properties["rest_send"] = value + + @property + def results(self): + """ + - getter: Return an instance of the Results class. + - setter: Set an instance of the Results class. + - setter: Raise ``TypeError`` if the value is not an + instance of Results. + """ + return self._properties["results"] + + @results.setter + def results(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, Results): + msg = f"{self.class_name}.{method_name}: " + msg += "results must be an instance of Results." + self.log.debug(msg) + raise TypeError(msg) + self._properties["results"] = value diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py new file mode 100644 index 000000000..1d1f785a2 --- /dev/null +++ b/plugins/module_utils/fabric/create.py @@ -0,0 +1,401 @@ +# +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import json +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_types import \ + FabricTypes + + +class FabricCreateCommon(FabricCommon): + """ + Common methods and properties for: + - FabricCreate + - FabricCreateBulk + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + self.action: str = "create" + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.endpoints = ApiEndpoints() + self.fabric_types = FabricTypes() + + # path and verb cannot be defined here because endpoints.fabric name + # must be set first. Set these to None here and define them later in + # the commit() method. + self.path: str = None + self.verb: str = None + + self._payloads_to_commit: list = [] + + self._build_properties() + + msg = "ENTERED FabricCreateCommon(): " + msg += f"action: {self.action}, " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + + def _build_properties(self): + """ + - Add properties specific to this class + - self._properties is initialized in FabricCommon + """ + + def _build_payloads_to_commit(self) -> None: + """ + Build a list of payloads to commit. Skip any payloads that + already exist on the controller. + + Expects self.payloads to be a list of dict, with each dict + being a payload for the fabric create API endpoint. + + Populates self._payloads_to_commit with a list of payloads + to commit. + """ + self.fabric_details.refresh() + + self._payloads_to_commit = [] + for payload in self.payloads: + if payload.get("FABRIC_NAME", None) in self.fabric_details.all_data: + continue + self._payloads_to_commit.append(copy.deepcopy(payload)) + + def _set_fabric_create_endpoint(self, payload): + """ + - Set the endpoint for the fabric create API call. + - raise ``ValueError`` if FABRIC_TYPE in the payload is invalid + - raise ``ValueError`` if the fabric_type to template_name mapping fails + - raise ``ValueError`` if the fabric_create endpoint assignment fails + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self.endpoints.fabric_name = payload.get("FABRIC_NAME") + + try: + self.fabric_type = copy.copy(payload.get("FABRIC_TYPE")) + except ValueError as error: + raise ValueError(error) from error + + try: + self.fabric_types.fabric_type = self.fabric_type + template_name = self.fabric_types.template_name + except ValueError as error: + raise ValueError(error) from error + self.endpoints.template_name = template_name + + try: + endpoint = self.endpoints.fabric_create + except ValueError as error: + raise ValueError(error) from error + + payload.pop("FABRIC_TYPE", None) + self.path = endpoint["path"] + self.verb = endpoint["verb"] + + def _send_payloads(self): + """ + - If ``check_mode`` is ``False``, send the payloads + to the controller. + - If ``check_mode`` is ``True``, do not send the payloads + to the controller. + - In both cases, register results. + - raise ``ValueError`` if the fabric_create endpoint assignment fails + + NOTES: + - This overrides the parent class method. + """ + self.rest_send.check_mode = self.check_mode + + for payload in self._payloads_to_commit: + try: + self._set_fabric_create_endpoint(payload) + except ValueError as error: + raise ValueError(error) from error + + # For FabricUpdate, the DEPLOY key is mandatory. + # For FabricCreate, it is not. + # Remove it if it exists. + payload.pop("DEPLOY", None) + + # We don't want RestSend to retry on errors since the likelihood of a + # timeout error when creating a fabric is low, and there are many cases + # of permanent errors for which we don't want to retry. + self.rest_send.timeout = 1 + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.payload = payload + self.rest_send.commit() + + if self.rest_send.result_current["success"] is False: + self.results.diff_current = {} + else: + self.results.diff_current = copy.deepcopy(payload) + self.results.action = self.action + self.results.state = self.state + self.results.check_mode = self.check_mode + self.results.response_current = copy.deepcopy( + self.rest_send.response_current + ) + self.results.result_current = copy.deepcopy(self.rest_send.result_current) + self.results.register_task_result() + + msg = f"self.results.diff: {json.dumps(self.results.diff, indent=4, sort_keys=True)}" + self.log.debug(msg) + + @property + def payloads(self): + """ + Payloads must be a ``list`` of ``dict`` of payloads for the + ``fabric_create`` endpoint. + + - getter: Return the fabric create payloads + - setter: Set the fabric create payloads + - setter: raise ``ValueError`` if ``payloads`` is not a ``list`` of ``dict`` + - setter: raise ``ValueError`` if any payload is missing mandatory keys + """ + return self._properties["payloads"] + + @payloads.setter + def payloads(self, value): + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"value: {value}" + self.log.debug(msg) + + if not isinstance(value, list): + msg = f"{self.class_name}.{method_name}: " + msg += "payloads must be a list of dict. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}" + raise ValueError(msg) + for item in value: + try: + self._verify_payload(item) + except ValueError as error: + raise ValueError(error) from error + self._properties["payloads"] = value + + +class FabricCreateBulk(FabricCreateCommon): + """ + Create fabrics in bulk. Skip any fabrics that already exist. + + Usage: + + ```python + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import \ + FabricCreateBulk + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results + + payloads = [ + { "FABRIC_NAME": "fabric1", "BGP_AS": 65000 }, + { "FABRIC_NAME": "fabric2", "BGP_AS": 65001 } + ] + results = Results() + instance = FabricCreateBulk(ansible_module) + instance.rest_send = RestSend(ansible_module) + instance.payloads = payloads + instance.results = results + instance.commit() + results.build_final_result() + + # diff contains a dictionary of payloads that succeeded and/or failed + diff = results.diff + # result contains the result(s) of the fabric create request + result = results.result + # response contains the response(s) from the controller + response = results.response + + # results.final_result contains all of the above info, and can be passed + # to the exit_json and fail_json methods of AnsibleModule: + + if True in results.failed: + msg = "Fabric create failed." + ansible_module.fail_json(msg, **task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + ``` + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricCreateBulk()") + + self._build_properties() + + def _build_properties(self): + """ + Add properties specific to this class + """ + # properties dict is already initialized in the parent class + self._properties["payloads"] = None + + def commit(self): + """ + # create fabrics. + + - Skip any fabrics that already exist on the controller. + - raise ``ValueError`` if ``payloads`` is not set. + - raise ``ValueError`` if payload fixup fails. + - raise ``ValueError`` if sending the payloads fails. + """ + method_name = inspect.stack()[0][3] + + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += "rest_send must be set prior to calling commit. " + raise ValueError(msg) + + if self.payloads is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payloads must be set prior to calling commit." + raise ValueError(msg) + + self._build_payloads_to_commit() + + msg = "self._payloads_to_commit: " + msg += f"{json.dumps(self._payloads_to_commit, indent=4, sort_keys=True)}" + self.log.debug(msg) + + if len(self._payloads_to_commit) == 0: + return + try: + self._fixup_payloads_to_commit() + except ValueError as error: + raise ValueError(error) from error + + try: + self._send_payloads() + except ValueError as error: + raise ValueError(error) from error + + +class FabricCreate(FabricCreateCommon): + """ + Create a VXLAN fabric on the controller and register the result. + + NOTES: + - FabricCreate is NOT used currently, though may be useful in the future. + - FabricCreateBulk is used instead. + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricCreate()") + + self._build_properties() + + def _build_properties(self): + """ + Add properties specific to this class + """ + # self._properties is already initialized in the parent class + self._properties["payload"] = None + + def commit(self): + """ + - Send the fabric create request to the controller. + - raise ``ValueError`` if ``rest_send`` is not set. + - raise ``ValueError`` if ``payload`` is not set. + - raise ``ValueError`` if ``fabric_create`` endpoint + assignment fails. + - return if the fabric already exists on the controller. + + NOTES: + - FabricCreate().commit() is very similar to + FabricCreateBulk().commit() since we convert the payload + to a list and leverage the processing that already exists + in FabricCreateCommom() + """ + method_name = inspect.stack()[0][3] + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += "rest_send must be set prior to calling commit. " + raise ValueError(msg) + + if self.payload is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payload must be set prior to calling commit. " + raise ValueError(msg) + + self._build_payloads_to_commit() + + if len(self._payloads_to_commit) == 0: + return + try: + self._fixup_payloads_to_commit() + except ValueError as error: + raise ValueError(error) from error + + try: + self._send_payloads() + except ValueError as error: + raise ValueError(error) from error + + @property + def payload(self): + """ + Return a fabric create payload. + """ + return self._properties["payload"] + + @payload.setter + def payload(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "payload must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}" + raise ValueError(msg) + if len(value) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "payload is empty." + raise ValueError(msg) + try: + self._verify_payload(value) + except ValueError as error: + raise ValueError(error) from error + self._properties["payload"] = value + # payloads is also set to a list containing one payload. + # commit() calls FabricCreateCommon()._build_payloads_to_commit(), + # which expects a list of payloads. + # FabricCreateCommon()._build_payloads_to_commit() verifies that + # the fabric does not already exist on the controller. + self._properties["payloads"] = [value] diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py new file mode 100644 index 000000000..e802a2dc9 --- /dev/null +++ b/plugins/module_utils/fabric/delete.py @@ -0,0 +1,340 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.exceptions import \ + ControllerResponseError +# Import Results() only for the case where the user has not set Results() +# prior to calling commit(). In this case, we instantiate Results() +# in _validate_commit_parameters() so that we can register the failure +# in commit(). +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints + + +class FabricDelete(FabricCommon): + """ + Delete fabrics + + A fabric must be empty before it can be deleted. + + Usage: + + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import \ + FabricDelete + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results + + instance = FabricDelete(ansible_module) + instance.fabric_names = ["FABRIC_1", "FABRIC_2"] + instance.results = self.results + instance.commit() + results.build_final_result() + + # diff contains a dictionary of changes made + diff = results.diff + # result contains the result(s) of the delete request + result = results.result + # response contains the response(s) from the controller + response = results.response + + # results.final_result contains all of the above info, and can be passed + # to the exit_json and fail_json methods of AnsibleModule: + + if True in results.failed: + msg = "Query failed." + ansible_module.fail_json(msg, **task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + self.action = "delete" + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self._fabrics_to_delete = [] + self._build_properties() + self._endpoints = ApiEndpoints() + + self._cannot_delete_fabric_reason = None + + # path and verb cannot be defined here because endpoints.fabric name + # must be set first. Set these to None here and define them later in + # the commit() method. + self.path = None + self.verb = None + + msg = "ENTERED FabricDelete(): " + msg += f"action: {self.action}, " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + + def _build_properties(self): + """ + self._properties holds property values for the class + """ + # self._properties is already set in the parent class + self._properties["fabric_names"] = None + + def _get_fabrics_to_delete(self) -> None: + """ + - Retrieve fabric info from the controller and set the list of + controller fabrics that are in our fabric_names list. + - Raise ``ValueError`` if any fabric in ``fabric_names`` + cannot be deleted. + """ + self.fabric_details.refresh() + + self._fabrics_to_delete = [] + for fabric_name in self.fabric_names: + if fabric_name in self.fabric_details.all_data: + try: + self._verify_fabric_can_be_deleted(fabric_name) + except ValueError as error: + raise ValueError(error) from error + self._fabrics_to_delete.append(fabric_name) + + def _verify_fabric_can_be_deleted(self, fabric_name): + """ + raise ``ValueError`` if the fabric cannot be deleted + return otherwise + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self.fabric_summary.fabric_name = fabric_name + + try: + self.fabric_summary.refresh() + except (ControllerResponseError, ValueError) as error: + raise ValueError(error) from error + + if self.fabric_summary.fabric_is_empty is True: + return + msg = f"{self.class_name}.{method_name}: " + msg += f"Fabric {fabric_name} cannot be deleted since it is not " + msg += "empty. Remove all devices from the fabric and try again." + raise ValueError(msg) + + def _set_fabric_delete_endpoint(self, fabric_name) -> None: + """ + - Set the fabric delete endpoint for fabric_name + - Raise ``ValueError`` if the endpoint assignment fails + """ + try: + self._endpoints.fabric_name = fabric_name + except (ValueError, TypeError) as error: + raise ValueError(error) from error + + try: + endpoint = self._endpoints.fabric_delete + except ValueError as error: + raise ValueError(error) from error + + self.path = endpoint.get("path") + self.verb = endpoint.get("verb") + + def _validate_commit_parameters(self): + """ + - validate the parameters for commit + - raise ``ValueError`` if ``fabric_names`` is not set + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + + if self.fabric_names is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be set prior to calling commit." + raise ValueError(msg) + + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += "rest_send must be set prior to calling commit." + raise ValueError(msg) + + if self.results is None: + # Instantiate Results() only to register the failure + self.results = Results() + msg = f"{self.class_name}.{method_name}: " + msg += "results must be set prior to calling commit." + raise ValueError(msg) + + def commit(self): + """ + - delete each of the fabrics in self.fabric_names + - raise ``ValueError`` if any commit parameters are invalid + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + + try: + self._validate_commit_parameters() + except ValueError as error: + self.results.changed = False + self.results.failed = True + self.register_result(None) + raise ValueError(error) from error + + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.diff_current = {} + + try: + self._get_fabrics_to_delete() + except ValueError as error: + self.results.changed = False + self.results.failed = True + self.register_result(None) + raise ValueError(error) from error + + msg = f"self._fabrics_to_delete: {self._fabrics_to_delete}" + self.log.debug(msg) + if len(self._fabrics_to_delete) != 0: + try: + self._send_requests() + except ValueError as error: + self.results.changed = False + self.results.failed = True + self.register_result(None) + raise ValueError(error) from error + return + + self.results.changed = False + self.results.failed = False + self.results.result_current = {"success": True, "changed": False} + msg = "No fabrics to delete" + self.results.response_current = {"RETURN_CODE": 200, "MESSAGE": msg} + self.results.register_task_result() + + def _send_requests(self): + """ + - Update RestSend() parameters: + - check_mode : Enables or disables sending the request + - timeout : Reduce to 1 second from default of 300 seconds + - Call _send_request() for each fabric to be deleted. + - Raise ``ValueError`` if any fabric cannot be deleted. + + NOTES: + - We don't want RestSend to retry on errors since the likelihood of a + timeout error when deleting a fabric is low, and there are cases of + permanent errors for which we don't want to retry. Hence, we set + timeout to 1 second. + """ + self.rest_send.check_mode = self.check_mode + self.rest_send.timeout = 1 + + for fabric_name in self._fabrics_to_delete: + try: + self._send_request(fabric_name) + except ValueError as error: + self.results.changed = False + self.results.failed = True + self.register_result(fabric_name) + raise ValueError(error) from error + + def _send_request(self, fabric_name): + """ + - Send a delete request to the controller and register the result. + - Raise ``ValueError`` if the fabric delete endpoint cannot be set + """ + try: + self._set_fabric_delete_endpoint(fabric_name) + except ValueError as error: + raise ValueError(error) from error + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.commit() + self.register_result(fabric_name) + + def register_result(self, fabric_name): + """ + - Register the result of the fabric delete request + - If ``fabric_name`` is ``None``, set the result to indicate + no changes occurred and the request was not successful. + - If ``fabric_name`` is not ``None``, set the result to indicate + the success or failure of the request. + """ + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + + if fabric_name is None: + self.results.diff_current = {} + self.results.response_current = {} + self.results.result_current = {"success": False, "changed": False} + self.results.register_task_result() + return + + if self.rest_send.result_current.get("success", None) is True: + self.results.diff_current = {"fabric_name": fabric_name} + # need this to match the else clause below since we + # pass response_current (altered or not) to the results object + response_current = copy.deepcopy(self.rest_send.response_current) + else: + self.results.diff_current = {} + # Improve the controller's error message to include the fabric_name + response_current = copy.deepcopy(self.rest_send.response_current) + if "DATA" in response_current: + if "Failed to delete the fabric." in response_current["DATA"]: + msg = f"Failed to delete fabric {fabric_name}." + response_current["DATA"] = msg + + self.results.response_current = response_current + self.results.result_current = self.rest_send.result_current + + self.results.register_task_result() + + @property + def fabric_names(self): + """ + - getter: return list of fabric_names + - setter: set list of fabric_names + - setter: raise ``ValueError`` if ``value`` is not a ``list`` of ``str`` + """ + return self._properties["fabric_names"] + + @fabric_names.setter + def fabric_names(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, list): + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be a list. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}" + raise ValueError(msg) + if len(value) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be a list of at least one string. " + msg += f"got {value}." + raise ValueError(msg) + for item in value: + if not isinstance(item, str): + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be a list of strings. " + msg += f"got {type(item).__name__} for " + msg += f"value {item}" + raise ValueError(msg) + self._properties["fabric_names"] = value diff --git a/plugins/module_utils/fabric/endpoints.py b/plugins/module_utils/fabric/endpoints.py new file mode 100644 index 000000000..f8dd7cead --- /dev/null +++ b/plugins/module_utils/fabric/endpoints.py @@ -0,0 +1,300 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging +import re + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils + + +class ApiEndpoints: + """ + Endpoints for fabric API calls + + Usage + + endpoints = ApiEndpoints() + endpoints.fabric_name = "MyFabric" + endpoints.template_name = "MyTemplate" + try: + endpoint = endpoints.fabric_create + except ValueError as error: + self.ansible_module.fail_json(error) + + rest_send = RestSend(self.ansible_module) + rest_send.path = endpoint.get("path") + rest_send.verb = endpoint.get("verb") + rest_send.commit() + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED ApiEndpoints()") + + self.conversion = ConversionUtils() + + self.endpoint_api_v1 = "/appcenter/cisco/ndfc/api/v1" + + self.endpoint_fabrics = f"{self.endpoint_api_v1}" + self.endpoint_fabrics += "/rest/control/fabrics" + + self.endpoint_fabric_summary = f"{self.endpoint_api_v1}" + self.endpoint_fabric_summary += "/lan-fabric/rest/control/switches" + self.endpoint_fabric_summary += "/_REPLACE_WITH_FABRIC_NAME_/overview" + + self.endpoint_templates = f"{self.endpoint_api_v1}" + self.endpoint_templates += "/configtemplate/rest/config/templates" + + self._init_properties() + + def _init_properties(self): + """ """ + self.properties = {} + self.properties["fabric_name"] = None + self.properties["template_name"] = None + + @property + def fabric_config_deploy(self): + """ + - return fabric_config_deploy endpoint + - verb: POST + - path: /rest/control/fabrics/{FABRIC_NAME}/config-deploy + - Raise ``ValueError`` if fabric_name is not set. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.fabric_name: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + raise ValueError(msg) + path = self.endpoint_fabrics + path += ( + f"/{self.fabric_name}/config-deploy?forceShowRun=false" + ) + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "POST" + return endpoint + + @property + def fabric_config_save(self): + """ + - return fabric_config_save endpoint + - verb: POST + - path: /rest/control/fabrics/{FABRIC_NAME}/config-save + - Raise ``ValueError`` if fabric_name is not set. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.fabric_name: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + raise ValueError(msg) + path = self.endpoint_fabrics + path += f"/{self.fabric_name}/config-save" + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "POST" + return endpoint + + @property + def fabric_create(self): + """ + return fabric_create endpoint + verb: POST + path: /rest/control/fabrics/{FABRIC_NAME}/{TEMPLATE_NAME} + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.fabric_name: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + raise ValueError(msg) + if not self.template_name: + msg = f"{self.class_name}.{method_name}: " + msg += "template_name is required." + raise ValueError(msg) + path = self.endpoint_fabrics + path += f"/{self.fabric_name}/{self.template_name}" + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "POST" + return endpoint + + @property + def fabric_delete(self): + """ + return fabric_delete endpoint + verb: DELETE + path: /rest/control/fabrics/{FABRIC_NAME} + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.fabric_name: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + raise ValueError(msg) + path = self.endpoint_fabrics + path += f"/{self.fabric_name}" + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "DELETE" + return endpoint + + @property + def fabric_summary(self): + """ + return fabric_summary endpoint + verb: GET + path: /rest/control/fabrics/summary/{FABRIC_NAME}/overview + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.fabric_name: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + raise ValueError(msg) + endpoint = {} + path = copy.copy(self.endpoint_fabric_summary) + endpoint["path"] = re.sub("_REPLACE_WITH_FABRIC_NAME_", self.fabric_name, path) + endpoint["verb"] = "GET" + return endpoint + + @property + def fabric_update(self): + """ + return fabric_update endpoint + verb: PUT + path: /rest/control/fabrics/{FABRIC_NAME}/{TEMPLATE_NAME} + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.fabric_name: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + raise ValueError(msg) + if not self.template_name: + msg = f"{self.class_name}.{method_name}: " + msg += "template_name is required." + raise ValueError(msg) + path = self.endpoint_fabrics + path += f"/{self.fabric_name}/{self.template_name}" + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "PUT" + return endpoint + + @property + def fabrics(self): + """ + return fabrics endpoint + verb: GET + path: /rest/control/fabrics + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + endpoint = {} + endpoint["path"] = self.endpoint_fabrics + endpoint["verb"] = "GET" + return endpoint + + @property + def fabric_info(self): + """ + return fabric_info endpoint + verb: GET + path: /rest/control/fabrics/{fabricName} + + Usage: + endpoints = ApiEndpoints() + endpoints.fabric_name = "MyFabric" + try: + endpoint = endpoints.fabric_info + except ValueError as error: + self.ansible_module.fail_json(error) + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.fabric_name: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + raise ValueError(msg) + path = self.endpoint_fabrics + path += f"/{self.fabric_name}" + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "GET" + return endpoint + + @property + def fabric_name(self): + """ + setter: set the fabric_name to include in endpoint paths + getter: get the current value of fabric_name + """ + return self.properties["fabric_name"] + + @fabric_name.setter + def fabric_name(self, value): + self.conversion.validate_fabric_name(value) + self.properties["fabric_name"] = value + + @property + def template_name(self): + """ + setter: set the fabric template_name to include in endpoint paths + getter: get the current value of template_name + """ + return self.properties["template_name"] + + @template_name.setter + def template_name(self, value): + self.properties["template_name"] = value + + @property + def template(self): + """ + return the template content endpoint for template_name + verb: GET + path: /appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates/{template_name} + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + if not self.template_name: + msg = f"{self.class_name}.{method_name}: " + msg += "template_name is required." + raise ValueError(msg) + path = self.endpoint_templates + path += f"/{self.template_name}" + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "GET" + return endpoint + + @property + def templates(self): + """ + return the template contents endpoint + + This endpoint returns the all template names on the controller. + + verb: GET + path: /appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + endpoint = {} + endpoint["path"] = self.endpoint_templates + endpoint["verb"] = "GET" + return endpoint diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py new file mode 100644 index 000000000..3590c2d80 --- /dev/null +++ b/plugins/module_utils/fabric/fabric_details.py @@ -0,0 +1,540 @@ +# +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints + + +class FabricDetails(FabricCommon): + """ + # Parent class for *FabricDetails() subclasses. + + See subclass docstrings for details. + + params is AnsibleModule.params + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED FabricDetails()" + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self.data = {} + self.endpoints = ApiEndpoints() + self.results = Results() + self.conversion = ConversionUtils() + + def _update_results(self): + """ + Update the results object with the current state of the fabric + details. + """ + self.results.response_current = self.rest_send.response_current + self.results.response = self.rest_send.response_current + self.results.result_current = self.rest_send.result_current + self.results.result = self.rest_send.result_current + if self.results.response_current.get("RETURN_CODE") == 200: + self.results.failed = False + else: + self.results.failed = True + # FabricDetails never changes the controller state + self.results.changed = False + + def refresh_super(self): + """ + Refresh the fabric details from the controller and + populate self.data with the results. + + self.data is a dictionary of fabric details, keyed on + fabric name. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + + endpoint = self.endpoints.fabrics + + self.rest_send.path = endpoint.get("path") + self.rest_send.verb = endpoint.get("verb") + + # We always want to get the controller's current fabric state, + # regardless of the current value of check_mode. + # We save the current check_mode value, set rest_send.check_mode + # to False so the request will be sent to the controller, and then + # restore the original check_mode value. + msg = f"{self.class_name}.{method_name}: calling self.rest_send.commit()" + self.log.debug(msg) + save_check_mode = self.rest_send.check_mode + self.rest_send.check_mode = False + self.rest_send.timeout = 1 + self.rest_send.commit() + self.rest_send.check_mode = save_check_mode + + self.data = {} + if self.rest_send.response_current.get("DATA") is None: + # The DATA key should always be present. We should never hit this. + self._update_results() + return + for item in self.rest_send.response_current.get("DATA"): + fabric_name = item.get("nvPairs", {}).get("FABRIC_NAME", None) + if fabric_name is None: + self._update_results() + return + self.data[fabric_name] = item + + msg = f"{self.class_name}.{method_name}: calling self.rest_send.commit() DONE" + self.log.debug(msg) + + self._update_results() + + def _get(self, item): + """ + overridden in subclasses + """ + + def _get_nv_pair(self, item): + """ + overridden in subclasses + """ + + @property + def all_data(self): + """ + Return all fabric details from the controller (i.e. self.data) + """ + return self.data + + @property + def asn(self): + """ + Return the BGP asn of the fabric specified with filter, if it exists. + Return None otherwise + + Type: string + Possible values: + - e.g. 65000 + - None + """ + try: + return self._get("asn") + except ValueError as error: + msg = f"Failed to retrieve asn: Error detail: {error}" + self.log.debug(msg) + return None + + @property + def deployment_freeze(self): + """ + Return the nvPairs.DEPLOYMENT_FREEZE of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - true + - false + """ + try: + return self._get_nv_pair("DEPLOYMENT_FREEZE") + except ValueError as error: + msg = f"Failed to retrieve deployment_freeze: Error detail: {error}" + self.log.debug(msg) + return None + + @property + def enable_pbr(self): + """ + Return the PBR enable state of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: boolean + Possible values: + - True + - False + - None + """ + try: + return self._get_nv_pair("ENABLE_PBR") + except ValueError as error: + msg = f"Failed to retrieve enable_pbr: Error detail: {error}" + self.log.debug(msg) + return None + + @property + def fabric_id(self): + """ + Return the fabricId of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - e.g. FABRIC-5 + - None + """ + try: + return self._get("fabricId") + except ValueError as error: + msg = f"Failed to retrieve fabric_id: Error detail: {error}" + self.log.debug(msg) + return None + + @property + def fabric_type(self): + """ + Return the nvPairs.FABRIC_TYPE of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - Switch_Fabric + - None + """ + try: + return self._get_nv_pair("FABRIC_TYPE") + except ValueError as error: + msg = f"Failed to retrieve fabric_type: Error detail: {error}" + self.log.debug(msg) + return None + + @property + def is_read_only(self): + """ + Return the nvPairs.IS_READ_ONLY of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - true + - false + """ + try: + return self._get_nv_pair("IS_READ_ONLY") + except ValueError as error: + msg = f"Failed to retrieve is_read_only: Error detail: {error}" + self.log.debug(msg) + return None + + @property + def replication_mode(self): + """ + Return the nvPairs.REPLICATION_MODE of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - Ingress + - Multicast + - None + """ + try: + return self._get_nv_pair("REPLICATION_MODE") + except ValueError as error: + msg = f"Failed to retrieve replication_mode: Error detail: {error}" + self.log.debug(msg) + return None + + @property + def template_name(self): + """ + Return the templateName of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - Easy_Fabric + - TODO - add other values + - None + """ + try: + return self._get("templateName") + except ValueError as error: + msg = f"Failed to retrieve template_name: Error detail: {error}" + self.log.debug(msg) + return None + + +class FabricDetailsByName(FabricDetails): + """ + Retrieve fabric details from the controller and provide + property accessors for the fabric attributes. + + Usage (where params is AnsibleModule.params): + + ```python + instance = FabricDetailsByName(params) + instance.rest_send = RestSend(ansible_module) + instance.refresh() + instance.filter = "MyFabric" + # BGP AS for fabric "MyFabric" + bgp_as = instance.asn + + # all fabric details for "MyFabric" + fabric_dict = instance.filtered_data + if fabric_dict is None: + # fabric does not exist on the controller + # etc... + ``` + + Or: + + ```python + instance.FabricDetailsByName(module) + instance.rest_send = RestSend(ansible_module) + instance.refresh() + all_fabrics = instance.all_data + ``` + + - Where ``all_fabrics`` will be a dictionary of all fabrics + on the controller, keyed on fabric name. + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricDetailsByName()") + + self.data_subclass = {} + self._properties["filter"] = None + + def refresh(self): + """ + Refresh fabric_name current details from the controller + """ + self.refresh_super() + self.data_subclass = copy.deepcopy(self.data) + + def _get(self, item): + """ + Retrieve the value of the top-level (non-nvPair) item for fabric_name + (anything not in the nvPairs dictionary). + + - raise ``ValueError`` if ``self.filter`` has not been set. + - raise ``ValueError`` if ``self.filter`` (fabric_name) does not exist + on the controller. + - raise ``ValueError`` if item is not a valid property name for the fabric. + + See also: ``_get_nv_pair()`` + """ + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.filter {self.filter} " + self.log.debug(msg) + + if self.filter is None: + msg = f"{self.class_name}.{method_name}: " + msg += "set instance.filter to a fabric name " + msg += f"before accessing property {item}." + raise ValueError(msg) + + if self.data_subclass.get(self.filter) is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name {self.filter} does not exist on the controller." + raise ValueError(msg) + + if self.data_subclass[self.filter].get(item) is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.filter} unknown property name: {item}." + raise ValueError(msg) + + return self.conversion.make_none( + self.conversion.make_boolean(self.data_subclass[self.filter].get(item)) + ) + + def _get_nv_pair(self, item): + """ + # Retrieve the value of the nvPair item for fabric_name. + + - raise ``ValueError`` if ``self.filter`` has not been set. + - raise ``ValueError`` if ``self.filter`` (fabric_name) does not exist on the controller. + - raise ``ValueError`` if item is not a valid property name for the fabric. + + See also: ``self._get()`` + """ + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.filter {self.filter} " + self.log.debug(msg) + + if self.filter is None: + msg = f"{self.class_name}.{method_name}: " + msg += "set instance.filter to a fabric name " + msg += f"before accessing property {item}." + raise ValueError(msg) + + if self.data_subclass.get(self.filter) is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name {self.filter} " + msg += "does not exist on the controller." + raise ValueError(msg) + + if self.data_subclass[self.filter].get("nvPairs", {}).get(item) is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name {self.filter} " + msg += f"unknown property name: {item}." + raise ValueError(msg) + + return self.conversion.make_none( + self.conversion.make_boolean( + self.data_subclass[self.filter].get("nvPairs").get(item) + ) + ) + + @property + def filtered_data(self): + """ + - Return a dictionary of the fabric matching self.filter. + - Return None if the fabric does not exist on the controller. + - raise ``ValueError`` if self.filter has not been set. + """ + method_name = inspect.stack()[0][3] + if self.filter is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.filter must be set before calling " + msg += f"{self.class_name}.filtered_data" + raise ValueError(msg) + return self.data_subclass.get(self.filter, None) + + @property + def filter(self): + """ + Set the fabric_name of the fabric to query. + + This needs to be set before accessing this class's properties. + """ + return self._properties.get("filter") + + @filter.setter + def filter(self, value): + self._properties["filter"] = value + + +class FabricDetailsByNvPair(FabricDetails): + """ + Retrieve fabric details from the controller filtered + by nvPair key and value. This sets the filtered_data + property to a dictionary of all fabrics on the controller + that match filter_key and filter_value. + + Usage (where params is AnsibleModule.params): + + instance = FabricDetailsNvPair(params) + instance.refresh() + instance.filter_key = "DCI_SUBNET_RANGE" + instance.filter_value = "10.33.0.0/16" + fabrics = instance.filtered_data + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricDetailsByNvPair()") + + self.data_subclass = {} + self._properties["filter_key"] = None + self._properties["filter_value"] = None + + def refresh(self): + """ + Refresh fabric_name current details from the controller. + + - raise ValueError if self.filter_key has not been set. + """ + method_name = inspect.stack()[0][3] + + if self.filter_key is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"set {self.class_name}.filter_key to a nvPair key " + msg += f"before calling {self.class_name}.refresh()." + raise ValueError(msg) + if self.filter_value is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"set {self.class_name}.filter_value to a nvPair value " + msg += f"before calling {self.class_name}.refresh()." + raise ValueError(msg) + + self.refresh_super() + for item, value in self.data.items(): + if value.get("nvPairs", {}).get(self.filter_key) == self.filter_value: + self.data_subclass[item] = value + + @property + def filtered_data(self): + """ + - Return a ``dict`` of the fabric(s) matching ``self.filter_key`` + and ``self.filter_value``. + - Return an empty ``dict`` if the fabric does not exist on + the controller. + """ + return self.data_subclass + + @property + def filter_key(self): + """ + - getter: Return the nvPairs key to filter on. + - setter: Set the nvPairs key to filter on. + + This should be an exact match for the key in the nvPairs + dictionary for the fabric. + """ + return self._properties.get("filter_key") + + @filter_key.setter + def filter_key(self, value): + self._properties["filter_key"] = value + + @property + def filter_value(self): + """ + - getter: Return the nvPairs value to filter on. + - setter: Set the nvPairs value to filter on. + + This should be an exact match for the value in the nvPairs + dictionary for the fabric. + """ + return self._properties.get("filter_value") + + @filter_value.setter + def filter_value(self, value): + self._properties["filter_value"] = value diff --git a/plugins/module_utils/fabric/fabric_summary.py b/plugins/module_utils/fabric/fabric_summary.py new file mode 100644 index 000000000..c54a58808 --- /dev/null +++ b/plugins/module_utils/fabric/fabric_summary.py @@ -0,0 +1,359 @@ +# +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import json +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils +from ansible_collections.cisco.dcnm.plugins.module_utils.common.exceptions import \ + ControllerResponseError +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints + + +class FabricSummary(FabricCommon): + """ + Populate ``dict`` ``self.data`` with fabric summary information. + + Convenience properties are provided to access the data, including: + + - @device_count + - @leaf_count + - @spine_count + - @border_gateway_count + - @in_sync_count + - @out_of_sync_count + + self.data will contain the following structure. + + ```python + { + "switchSWVersions": { + "10.2(5)": 7, + "10.3(1)": 2 + }, + "switchHealth": { + "Healthy": 2, + "Minor": 7 + }, + "switchHWVersions": { + "N9K-C93180YC-EX": 4, + "N9K-C9504": 5 + }, + "switchConfig": { + "Out-of-Sync": 5, + "In-Sync": 4 + }, + "switchRoles": { + "leaf": 4, + "spine": 3, + "border gateway": 2 + } + } + ``` + Usage: + + ```python + params = ansible_module.params + instance = FabricSummary(params) + instance.rest_send = RestSend(ansible_module) + instance.fabric_name = "MyFabric" + instance.refresh() + fabric_summary = instance.data + device_count = instance.device_count + ``` + etc... + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.data = None + self.endpoints = ApiEndpoints() + self.conversion = ConversionUtils() + + # set to True in refresh() after a successful request to the controller + # Used by getter properties to ensure refresh() has been called prior + # to returning data. + self.refreshed = False + + self.results = Results() + + self._build_properties() + + msg = "ENTERED FabricSummary(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode} " + self.log.debug(msg) + + def _build_properties(self): + """ + Initialize properties specific to this class. + """ + # self._properties is already initialized in the parent class + self._properties["border_gateway_count"] = 0 + self._properties["device_count"] = 0 + self._properties["fabric_name"] = None + self._properties["leaf_count"] = 0 + self._properties["spine_count"] = 0 + + def _update_device_counts(self): + """ + - From the controller response, update class properties + pertaining to device counts. + - By the time refresh() calls this method, self.data + has been verified, so no need to verify it here. + """ + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg = f"self.data: {json.dumps(self.data, indent=4, sort_keys=True)}" + self.log.debug(msg) + + self._properties["border_gateway_count"] = self.data.get("switchRoles", {}).get( + "border gateway", 0 + ) + self._properties["leaf_count"] = self.data.get("switchRoles", {}).get("leaf", 0) + self._properties["spine_count"] = self.data.get("switchRoles", {}).get( + "spine", 0 + ) + self._properties["device_count"] = ( + self.leaf_count + self.spine_count + self.border_gateway_count + ) + + def _set_fabric_summary_endpoint(self): + """ + - Set the fabric_summary endpoint. + - Raise ``ValueError`` if unable to retrieve the endpoint. + """ + try: + self.endpoints.fabric_name = self.fabric_name + self.rest_send.path = self.endpoints.fabric_summary.get("path") + self.rest_send.verb = self.endpoints.fabric_summary.get("verb") + except ValueError as error: + msg = "Error retrieving fabric_summary endpoint. " + msg += f"Detail: {error}" + self.log.debug(msg) + raise ValueError(msg) from error + + def _verify_controller_response(self): + """ + - Raise ``ControllerResponseError`` if RETURN_CODE != 200. + - Raise ``ControllerResponseError`` if DATA is missing or empty. + """ + method_name = inspect.stack()[0][3] + + controller_return_code = self.rest_send.response_current.get( + "RETURN_CODE", None + ) + controller_message = self.rest_send.response_current.get("MESSAGE", None) + if controller_return_code != 200: + msg = f"{self.class_name}.{method_name}: " + msg += "Failed to retrieve fabric_summary for fabric_name " + msg += f"{self.fabric_name}. " + msg += f"RETURN_CODE: {controller_return_code}. " + msg += f"MESSAGE: {controller_message}." + self.log.error(msg) + raise ControllerResponseError(msg) + + # DATA is set to an empty dict in refresh() if the controller response + # does not contain a DATA key. + if len(self.data) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "Controller responded with missing or empty DATA." + raise ControllerResponseError(msg) + + def refresh(self): + """ + - Refresh fabric summary info from the controller and + populate ``self.data`` with the result. + - ``self.data`` is a ``dict`` of fabric summary info for one fabric. + - raise ``ValueError`` if ``fabric_name`` is not set. + - raise ``ValueError`` if unable to retrieve fabric_summary endpoint. + - raise ``ValueError`` if ``_update_device_counts()`` fails. + - raise ``ControllerResponseError`` if the controller + ``RETURN_CODE`` != 200 + """ + method_name = inspect.stack()[0][3] + if self.fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"Set {self.class_name}.fabric_name prior to calling " + msg += f"{self.class_name}.refresh()." + raise ValueError(msg) + + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"Set {self.class_name}.rest_send prior to calling " + msg += f"{self.class_name}.refresh()." + raise ValueError(msg) + + try: + self._set_fabric_summary_endpoint() + except ValueError as error: + raise ValueError(error) from error + + # We always want to get the controller's current fabric state, + # regardless of the current value of check_mode. + # We save the current check_mode value, set rest_send.check_mode + # to False so the request will be sent to the controller, and then + # restore the original check_mode value. + save_check_mode = self.rest_send.check_mode + self.rest_send.check_mode = False + self.rest_send.commit() + self.rest_send.check_mode = save_check_mode + self.data = copy.deepcopy(self.rest_send.response_current.get("DATA", {})) + + msg = f"self.data: {json.dumps(self.data, indent=4, sort_keys=True)}" + self.log.debug(msg) + + self.results.response_current = self.rest_send.response_current + self.results.response = self.rest_send.response_current + self.results.result_current = self.rest_send.result_current + self.results.result = self.rest_send.result_current + self.results.register_task_result() + + try: + self._verify_controller_response() + except ControllerResponseError as error: + raise ControllerResponseError(error) from error + + # self.refreshed must be True before calling + # self._update_device_counts() below + self.refreshed = True + self._update_device_counts() + + def verify_refresh_has_been_called(self, attempted_method_name): + """ + - raise ``ValueError`` if ``refresh()`` has not been called. + """ + if self.refreshed is True: + return + msg = f"{self.class_name}.refresh() must be called before accessing " + msg += f"{self.class_name}.{attempted_method_name}." + raise ValueError(msg) + + @property + def all_data(self) -> dict: + """ + - Return raw fabric summary data from the controller. + - Raise ``ValueError`` if ``refresh()`` has not been called. + """ + method_name = inspect.stack()[0][3] + try: + self.verify_refresh_has_been_called(method_name) + except ValueError as error: + raise ValueError(error) from error + return self.data + + @property + def border_gateway_count(self) -> int: + """ + - Return the number of border gateway devices in fabric fabric_name. + - Raise ``ValueError`` if ``refresh()`` has not been called. + """ + method_name = inspect.stack()[0][3] + try: + self.verify_refresh_has_been_called(method_name) + except ValueError as error: + raise ValueError(error) from error + return self._properties["border_gateway_count"] + + @property + def device_count(self) -> int: + """ + - Return the total number of devices in fabric fabric_name. + - Raise ``ValueError`` if ``refresh()`` has not been called. + """ + method_name = inspect.stack()[0][3] + try: + self.verify_refresh_has_been_called(method_name) + except ValueError as error: + raise ValueError(error) from error + return self._properties["device_count"] + + @property + def fabric_is_empty(self) -> bool: + """ + - Return True if the fabric is empty. + - Raise ``ValueError`` if ``refresh()`` has not been called. + """ + method_name = inspect.stack()[0][3] + try: + self.verify_refresh_has_been_called(method_name) + except ValueError as error: + raise ValueError(error) from error + if self.device_count == 0: + return True + return False + + @property + def fabric_name(self) -> str: + """ + - getter: Return the fabric_name to query. + - setter: Set the fabric_name to query. + - setter: Raise ``ValueError`` if fabric_name is not a string. + - setter: Raise ``ValueError`` if fabric_name is invalid (i.e. + the controller would return an error due to invalid characters). + """ + return self._properties.get("fabric_name") + + @fabric_name.setter + def fabric_name(self, value: str): + try: + self.conversion.validate_fabric_name(value) + except ValueError as error: + raise ValueError(error) from error + self._properties["fabric_name"] = value + + @property + def leaf_count(self) -> int: + """ + - Return the number of leaf devices in fabric fabric_name. + - Raise ``ValueError`` if ``refresh()`` has not been called. + """ + method_name = inspect.stack()[0][3] + try: + self.verify_refresh_has_been_called(method_name) + except ValueError as error: + raise ValueError(error) from error + return self._properties["leaf_count"] + + @property + def spine_count(self) -> int: + """ + - Return the number of spine devices in fabric fabric_name. + - Raise ``ValueError`` if ``refresh()`` has not been called. + """ + method_name = inspect.stack()[0][3] + try: + self.verify_refresh_has_been_called(method_name) + except ValueError as error: + raise ValueError(error) from error + return self._properties["spine_count"] diff --git a/plugins/module_utils/fabric/fabric_types.py b/plugins/module_utils/fabric/fabric_types.py new file mode 100644 index 000000000..9cd8f9dfa --- /dev/null +++ b/plugins/module_utils/fabric/fabric_types.py @@ -0,0 +1,163 @@ +# +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import logging + + +class FabricTypes: + """ + Fabric type definitions the dcnm_fabric module. + + Usage + + # import and instantiate the class + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_types import FabricTypes + fabric_types = FabricTypes() + + # Access the set of valid fabric types + valid_fabric_types = fabric_types.valid_fabric_types + + + # Set the fabric type for which further operations will be performed + try: + fabric_types.fabric_type = "VXLAN_EVPN" + except ValueError as error: + raise ValueError(error) from error + + # Access the template name for the VXLAN_EVPN fabric type + template_name = fabric_types.template_name + + # Access mandatory parameters for the VXLAN_EVPN fabric type + mandatory_parameters = fabric_types.mandatory_parameters + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + msg = "ENTERED FabricTypes(): " + self.log.debug(msg) + + self._init_fabric_types() + self._init_properties() + + def _init_fabric_types(self) -> None: + """ + This is the single place to add new fabric types. + + Initialize the following: + - fabric_type_to_template_name_map dict() + - _valid_fabric_types - Sorted list() of fabric types + - _mandatory_payload_keys_all_fabrics list() + """ + self._fabric_type_to_template_name_map = {} + self._fabric_type_to_template_name_map["LAN_CLASSIC"] = "LAN_Classic" + self._fabric_type_to_template_name_map["VXLAN_EVPN"] = "Easy_Fabric" + self._fabric_type_to_template_name_map["VXLAN_EVPN_MSD"] = "MSD_Fabric" + + self._valid_fabric_types = sorted(self._fabric_type_to_template_name_map.keys()) + + self._mandatory_parameters_all_fabrics = [] + self._mandatory_parameters_all_fabrics.append("FABRIC_NAME") + self._mandatory_parameters_all_fabrics.append("FABRIC_TYPE") + + self._mandatory_parameters = {} + self._mandatory_parameters["LAN_CLASSIC"] = copy.copy( + self._mandatory_parameters_all_fabrics + ) + self._mandatory_parameters["VXLAN_EVPN"] = copy.copy( + self._mandatory_parameters_all_fabrics + ) + self._mandatory_parameters["VXLAN_EVPN"].append("BGP_AS") + self._mandatory_parameters["VXLAN_EVPN_MSD"] = copy.copy( + self._mandatory_parameters_all_fabrics + ) + + self._mandatory_parameters["LAN_CLASSIC"].sort() + self._mandatory_parameters["VXLAN_EVPN"].sort() + self._mandatory_parameters["VXLAN_EVPN_MSD"].sort() + + def _init_properties(self) -> None: + """ + Initialize properties specific to this class + """ + self._properties = {} + self._properties["fabric_type"] = None + self._properties["template_name"] = None + self._properties["valid_fabric_types"] = self._valid_fabric_types + + @property + def fabric_type(self): + """ + - getter: Return the currently-set fabric type. + - setter: Set the fabric type. + - setter: raise ``ValueError`` if value is not a valid fabric type + """ + return self._properties["fabric_type"] + + @fabric_type.setter + def fabric_type(self, value): + """ + - Set the fabric type. + - raise ``ValueError`` if value is not a valid fabric type + """ + if value not in self.valid_fabric_types: + msg = f"{self.class_name}.fabric_type.setter: " + msg += f"Invalid fabric type: {value}. " + msg += f"Expected one of: {', '.join(self.valid_fabric_types)}." + raise ValueError(msg) + self._properties["fabric_type"] = value + + @property + def mandatory_parameters(self): + """ + - getter: Return the mandatory playbook parameters for the + currently-set fabric type as a sorted list(). + - getter: raise ``ValueError`` if FabricTypes().fabric_type + is not set. + """ + if self.fabric_type is None: + msg = f"{self.class_name}.mandatory_parameters: " + msg += f"Set {self.class_name}.fabric_type before accessing " + msg += f"{self.class_name}.mandatory_parameters" + raise ValueError(msg) + return self._mandatory_parameters[self.fabric_type] + + @property + def template_name(self): + """ + - getter: Return the template name for the currently-set fabric type. + - getter: raise ``ValueError`` if FabricTypes().fabric_type is not set. + """ + if self.fabric_type is None: + msg = f"{self.class_name}.template_name: " + msg += f"Set {self.class_name}.fabric_type before accessing " + msg += f"{self.class_name}.template_name" + raise ValueError(msg) + return self._fabric_type_to_template_name_map[self.fabric_type] + + @property + def valid_fabric_types(self): + """ + Return a sorted list() of valid fabric types. + """ + return self._properties["valid_fabric_types"] diff --git a/plugins/module_utils/fabric/param_info.py b/plugins/module_utils/fabric/param_info.py new file mode 100644 index 000000000..a70b8b893 --- /dev/null +++ b/plugins/module_utils/fabric/param_info.py @@ -0,0 +1,334 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import inspect +import json +import logging +import re + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils + + +class ParamInfo: + """ + Given a parameter, return a python dict containing parameter info. + Parameter info is culled from the provided template. + + Raise ``ValueError`` during refresh() if: + - template is not set + - template has no parameters + - template[parameters] is not a list + + Raise ``KeyError`` during parameter() call if: + - parameter is not found + + Usage: + + ```python + instance = ParamInfo() + instance.template = template + + try: + instance.refresh() + except ValueError as error: + print(error) + exit(1) + + try: + my_parameter_info = instance.parameter("my_parameter") + except KeyError as error: + print(error) + exit(1) + + parameter_type = my_parameter_info["type"] # python type: bool, str, int, dict, set, list, None + parameter_choices = my_parameter_info["choices"] # python list, or None + parameter_min = my_parameter_info["min"] # int, or None + parameter_max = my_parameter_info["max"] # int, or None + parameter_default = my_parameter_info["default"] # Any, or None + ``` + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.conversion = ConversionUtils() + + self.info = {} + self._init_properties() + + def _init_properties(self): + """ + Initialize the properties dict containing properties used by the class. + """ + self.properties = {} + self.properties["template"] = None + + @property + def template(self): + """ + - getter : return the template used to cull parameter info. + - setter : set the template used to cull parameter info. + - setter : raise ``TypeError`` if template is not a dict + """ + return self.properties["template"] + + @template.setter + def template(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "template must be a dict. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}" + raise TypeError(msg) + self.properties["template"] = value + + def refresh(self): + """ + # Refresh the parameter information based on the template + + - raise ValueError if template is not set + - raise ValueError if template has no parameters key + - raise ValueError if template[parameters] is not a list + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + if self.template is None: + msg += "Call instance.template before calling instance.refresh()." + raise ValueError(msg) + if self.template.get("parameters") is None: + msg += "No parameters in template." + raise ValueError(msg) + if isinstance(self.template["parameters"], list) is False: + msg += "template['parameters'] is not a list." + raise ValueError(msg) + + self._build_info() + + def parameter(self, value): + """ + - Return parameter information based on the template. + - Raise ``KeyError`` if parameter is not found + + Usage: + + ```python + try: + parameter_info = instance.parameter("my_parameter") + except KeyError as error: + print(error) + exit(1) + ``` + + ``parameter_info`` is returned as a python dict: + + ```json + { + "type": str, + "choices": ["Ingress", "Multicast"], + "min": None, + "max": None, + "default": "Multicast" + } + ``` + + - type: (``bool, str, int, dict, set, list, None``), + - choices: (``list``, or ``None``) + - min: (``int``, or ``None``) + - max: (``int``, or ``None``) + - default: (``str``, ``int``, etc, or ``None``) + + """ + method_name = inspect.stack()[0][3] + try: + return self.info[value] + except KeyError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Parameter {value} not found in fabric template. " + msg += f"This likely means that the parameter {value} is not " + msg += "appropriate for the fabric type." + raise KeyError(msg) from error + + def _get_choices(self, parameter): + """ + - Return a python list of valid parameter choices, if specified + in the template. + - Return None otherwise. + + Example conversions: + + ``` + "\\"Multicast,Ingress\\"" -> ["Ingress", "Multicast"] + "\\"1,2\\"" -> [1,2] + "\\"true,false\\"" -> [False, True] + ``` + + """ + parameter_type = self._get_type(parameter) + if parameter_type == "boolean": + return [False, True] + choices = parameter.get("annotations", {}).get("Enum", None) + if choices is None: + return None + choices = re.sub(r"\"", "", choices) + choices = choices.split(",") + choices = [self.conversion.make_int(choice) for choice in choices] + return sorted(choices) + + def _get_default(self, parameter): + """ + - Return the parameter's default value, if specified in the template. + - Return None otherwise. + + NOTES: + - The default value can be in two places. Check both places.: + - metaProperties.defaultValue + - defaultValue + - Conversion to int must preceed conversion to boolean. + """ + value = parameter.get("metaProperties", {}).get("defaultValue", None) + if value is None: + value = parameter.get("defaultValue", None) + if value is None: + return None + value = re.sub('"', "", value) + value_type = self._get_type(parameter) + if value_type == "string": + # This prevents things like MPLS_ISIS_AREA_NUM + # from being converted from "0001" to 1 + return value + if value_type == "integer": + value = self.conversion.make_int(value) + if isinstance(value, int): + return value + return self.conversion.make_boolean(value) + + def _get_internal(self, parameter): + """ + - Return the parameter's annotations.IsInternal value, + if specified in the template. + - Return None otherwise. + """ + value = parameter.get("annotations", {}).get("IsInternal", None) + if value is None: + return None + return self.conversion.make_boolean(value) + + def _get_min(self, parameter): + """ + - Return the parameter's minimum value, if specified in the template. + - Return None otherwise. + """ + value = parameter.get("metaProperties", {}).get("min", None) + if value is None: + return None + return self.conversion.make_int(value) + + def _get_max(self, parameter): + """ + - Return the parameter's maximum value, if specified in the template. + - Return None otherwise. + """ + value = parameter.get("metaProperties", {}).get("max", None) + if value is None: + return None + return self.conversion.make_int(value) + + def _get_param_name(self, parameter): + """ + - Return the ``name`` key from the parameter dict. + - Raise ``KeyError`` if ``name`` key is missing + """ + method_name = inspect.stack()[0][3] + + param_name = parameter.get("name", None) + if param_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "Parameter is missing name key: " + msg += f"parameter={parameter}" + raise KeyError(msg) + return param_name + + def _get_type(self, parameter): + """ + - Return the parameter's type, if specified in the template. + - Return None otherwise. + """ + return parameter.get("parameterType", None) + + def _build_info(self) -> None: + """ + # Build a ``dict`` of parameter information, keyed on parameter name. + + ## Parameter information is culled from the template. + + - choices: (``list``, or ``None``) + - default: (``str``, ``int``, etc, or ``None``) + - internal: (``bool``, or ``None``) + - max: (``int``, or ``None``) + - min: (``int``, or ``None``) + - type: + - boolean + - enum + - integer + - integerRange + - interface + - interfaceRange + - ipAddressList + - ipV4Address + - ipV4AddressWithSubnet + - ipV6AddressWithSubnet + - macAddress + - string + - string[] + - structureArray + - None + + Example: + + ```python + self.info[parameter] = { + "choices": ["Ingress", "Multicast"], + "default": "Multicast", + "internal": False, + "max": None, + "min": None, + "type": "string" + } + ``` + + """ + method_name = inspect.stack()[0][3] + self.info = {} + for parameter in self.template.get("parameters", []): + msg = f"{self.class_name}.{method_name}: " + msg += f"parameter: {json.dumps(parameter, indent=4, sort_keys=True)}" + self.log.debug(msg) + param_name = self._get_param_name(parameter) + if param_name not in self.info: + self.info[param_name] = {} + self.info[param_name]["choices"] = self._get_choices(parameter) + self.info[param_name]["default"] = self._get_default(parameter) + self.info[param_name]["max"] = self._get_max(parameter) + self.info[param_name]["min"] = self._get_min(parameter) + self.info[param_name]["type"] = self._get_type(parameter) + self.info[param_name]["internal"] = self._get_internal(parameter) + self.info[param_name]["type"] = self._get_type(parameter) diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py new file mode 100644 index 000000000..738110e73 --- /dev/null +++ b/plugins/module_utils/fabric/query.py @@ -0,0 +1,165 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon + + +class FabricQuery(FabricCommon): + """ + Query fabrics + + Usage: + + ```python + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import FabricQuery + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import Results + + fabric_details = FabricDetailsByName(params) + fabric_details.rest_send = RestSend(ansible_module) + + results = Results() + params = ansible_module.params + instance = FabricQuery(params) + instance.fabric_details = fabric_details + instance.fabric_names = ["FABRIC_1", "FABRIC_2"] + instance.results = results + instance.commit() + results.build_final_result() + + # diff contains a dictionary of fabric details for each fabric + # in instance.fabric_names + diff = results.diff + # result contains the result(s) of the query request + result = results.result + # response contains the response(s) from the controller + response = results.response + + # results.final_result contains all of the above info, and can be passed + # to the exit_json and fail_json methods of AnsibleModule: + + if True in results.failed: + msg = "Query failed." + ansible_module.fail_json(msg, **task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + ``` + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED FabricQuery(): " + msg += f"state: {self.state}" + self.log.debug(msg) + + self._fabrics_to_query = [] + self._build_properties() + + self.action = "query" + self.changed = False + self.failed = False + + def _build_properties(self): + """ + self._properties holds property values for the class + """ + # self._properties is already set in the parent class + self._properties["fabric_names"] = None + + @property + def fabric_names(self): + """ + - setter: return the fabric names + - getter: set the fable_names + - getter: raise ``ValueError`` if ``value`` is not a ``list`` + - getter: raise ``ValueError`` if ``value`` is an empty list + - getter: raise ``ValueError`` if ``value`` is not a list of strings + + """ + return self._properties["fabric_names"] + + @fabric_names.setter + def fabric_names(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, list): + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be a list. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}" + raise ValueError(msg) + if len(value) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be a list of at least one string. " + msg += f"got {value}." + raise ValueError(msg) + for item in value: + if not isinstance(item, str): + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be a list of strings. " + msg += f"got {type(item).__name__} for " + msg += f"value {item}" + raise ValueError(msg) + self._properties["fabric_names"] = value + + def commit(self): + """ + - query each of the fabrics in self.fabric_names + - raise ``ValueError`` if ``fabric_names`` is not set + - raise ``ValueError`` if ``fabric_details`` is not set + + """ + method_name = inspect.stack()[0][3] + if self.fabric_names is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_names must be set prior to calling commit." + raise ValueError(msg) + + if self.fabric_details is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_details must be set prior to calling commit." + raise ValueError(msg) + + self.fabric_details.refresh() + + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + + msg = f"self.fabric_names: {self.fabric_names}" + self.log.debug(msg) + add_to_diff = {} + for fabric_name in self.fabric_names: + if fabric_name in self.fabric_details.all_data: + add_to_diff[fabric_name] = copy.deepcopy( + self.fabric_details.all_data[fabric_name] + ) + + self.results.diff_current = add_to_diff + self.results.response_current = copy.deepcopy( + self.fabric_details.results.response_current + ) + self.results.result_current = copy.deepcopy( + self.fabric_details.results.result_current + ) + self.results.register_task_result() diff --git a/plugins/module_utils/fabric/replaced.py b/plugins/module_utils/fabric/replaced.py new file mode 100644 index 000000000..a6ddc8053 --- /dev/null +++ b/plugins/module_utils/fabric/replaced.py @@ -0,0 +1,697 @@ +# +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + +import copy +import inspect +import json +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.exceptions import \ + ControllerResponseError +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_types import \ + FabricTypes +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.param_info import \ + ParamInfo +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.ruleset import \ + RuleSet +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.template_get import \ + TemplateGet +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.verify_playbook_params import \ + VerifyPlaybookParams + + +class FabricReplacedCommon(FabricCommon): + """ + Common methods and properties for: + - FabricReplacedBulk + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + self.action = "replace" + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.endpoints = ApiEndpoints() + self.fabric_types = FabricTypes() + self.param_info = ParamInfo() + self.ruleset = RuleSet() + self.template_get = TemplateGet() + self.verify_playbook_params = VerifyPlaybookParams() + + # key: fabric_type, value: dict + # Updated in _build_fabric_templates() + # Stores the fabric template, pulled from the controller, + # for each fabric type in the user's payload. + self.fabric_templates = {} + + # key: fabric_name, value: dict containing the current + # controller fabric configuration for fabric_name. + # Populated in _fabric_needs_update_for_replaced_state() + self._controller_config = {} + + msg = "ENTERED FabricReplacedCommon(): " + msg += f"action: {self.action}, " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + + def _translate_payload_for_comparison(self, payload: dict) -> dict: + """ + Translate user payload keys to controller keys if necessary. + This handles the following: + + - Translate correctly-spelled user keys to incorrectly-spelled + controller keys. + - Translate the format of user values to the format expected by + the controller. + """ + translated_payload = {} + fabric_name = payload.get("FABRIC_NAME", None) + for payload_key, payload_value in payload.items(): + # Translate payload keys to equivilent keys on the controller + # if necessary. This handles cases where the controller key + # is misspelled and we want our users to use the correct + # spelling. + if payload_key in self._key_translations: + user_parameter = self._key_translations[payload_key] + else: + user_parameter = payload_key + + user_value = copy.copy(payload_value) + + # Skip the FABRIC_TYPE key since the payload FABRIC_TYPE value + # will be e.g. "VXLAN_EVPN", whereas the fabric configuration will + # be something along the lines of "Switch_Fabric" + if user_parameter == "FABRIC_TYPE": + continue + + # self._key_translations returns None for any keys that would not + # be found in the controller configuration (e.g. DEPLOY). + # Skip these keys. + if user_parameter is None: + continue + + user_value = self._prepare_parameter_value_for_comparison(user_value) + if user_parameter == "ANYCAST_GW_MAC": + try: + user_value = self.translate_anycast_gw_mac(fabric_name, user_value) + except ValueError as error: + raise ValueError(error) from error + + translated_payload[user_parameter] = user_value + return copy.deepcopy(translated_payload) + + def update_replaced_payload(self, parameter, playbook, controller, default): + """ + Given a parameter, and the parameter's values from: + - playbook config + - controller fabric config + - default value from the template + + Return either: + - None if the parameter does not need to be updated. + - A dict with the parameter and playbook value if the parameter + needs to be updated. + - raise ``ValueError`` for any unhandled case(s). + + Usage: + ```python + payload_to_send_to_controller = {} + for parameter, controller in _controller_config.items(): + playbook = playbook_config.get(parameter, None) + default = default_config.get(parameter, None) + result = self.update_replaced_payload(parameter, playbook, controller, default) + if result is None: + continue + payload_to_send_to_controller.update(result) + ``` + """ + raise_value_error = False + if playbook is None: + if default is None: + return None + if controller != default and controller is not None and controller != "": + return {parameter: default} + if controller != default and (controller is None or controller == ""): + return None + if controller == default: + return None + raise_value_error = True + msg = "UNHANDLED case when playbook value is None. " + if playbook is not None: + if playbook == controller: + return None + if playbook != controller: + return {parameter: playbook} + raise_value_error = True + msg = "UNHANDLED case when playbook value is not None. " + if raise_value_error is False: + msg = "UNHANDLED case " + msg += f"parameter {parameter}, " + msg += f"playbook: {playbook}, " + msg += f"controller: {controller}, " + msg += f"default: {default}" + raise ValueError(msg) + + def _verify_value_types_for_comparison( + self, fabric_name, parameter, user_value, controller_value, default_value + ) -> None: + """ + - Raise ``ValueError`` if the value types differ between: + playbook, controller, and default values. + """ + method_name = inspect.stack()[0][3] + type_set = set() + value_source_set = set() + if user_value is not None: + type_set.add(type(user_value)) + value_source_set.add("playbook") + if controller_value is not None: + type_set.add(type(controller_value)) + value_source_set.add("controller") + if default_value is not None: + type_set.add(type(default_value)) + value_source_set.add("default") + if len(type_set) > 1: + msg = f"{self.class_name}.{method_name}: " + msg += f"parameter: {parameter}, " + msg += f"fabric: {fabric_name}, " + msg += f"conflicting value types {type_set} between " + msg += f"the following sources: {sorted(value_source_set)}." + raise ValueError(msg) + + def _fabric_needs_update_for_replaced_state(self, payload): + """ + - Add True to self._fabric_update_required set() if the fabric needs + to be updated for replaced state. + - Populate self._fabric_changes_payload[fabric_name], + with key/values that are required to: + - Bring the fabric configuration to a default state + - Apply the user's non-default parameters onto this + default configuration + - This payload will be used to update the fabric. + - Raise ``ValueError`` if the fabric template is not found. + - Raise ``ValueError`` if ParamInfo().refresh() fails. + - Raise ``ValueError`` if the value types differ between the + playbook, controller, and default values. + + The fabric needs to be updated if all of the following are true: + - A fabric configuration parameter (on the controller) differs + from the default value for that parameter. This needs to be + set to either 1) the default value, or 2) the value in the + caller's playbook configuration. + - A parameter in the payload has a different value than the + corresponding default parameter in fabric configuration on + the controller (case 2 above). + + NOTES: + - The fabric has already been verified to exist on the + controller in ``_build_payloads_for_replaced_state()``. + - self.fabric_templates has already been populated in + ``_build_payloads_for_replaced_state()``. + """ + method_name = inspect.stack()[0][3] + + fabric_name = payload.get("FABRIC_NAME", None) + fabric_type = payload.get("FABRIC_TYPE", None) + + self._fabric_changes_payload[fabric_name] = {} + self._controller_config = self.fabric_details.all_data[fabric_name].get( + "nvPairs", {} + ) + + # Refresh ParamInfo() with the fabric template + try: + self.param_info.template = self.fabric_templates.get(fabric_type) + except TypeError as error: + raise ValueError(error) from error + try: + self.param_info.refresh() + except ValueError as error: + raise ValueError(error) from error + + # Translate user payload for comparison against the controller + # fabric configuration and default values in the fabric template. + translated_payload = self._translate_payload_for_comparison(payload) + + # For each of the parameters in the controller fabric configuration, + # compare against the user's payload and the default value in the + # template. Update _fabric_changes_payload with the result of + # the comparison. + for parameter, controller_value in self._controller_config.items(): + + msg = f"parameter: {parameter}, " + msg += f"controller_value: {controller_value}, " + msg += f"type: {type(controller_value)}" + self.log.debug(msg) + + try: + parameter_info = self.param_info.parameter(parameter) + except KeyError as error: + msg = f"SKIP parameter: {parameter} in fabric {fabric_name}. " + msg += "parameter not found in template." + self.log.debug(msg) + continue + + if parameter_info.get("internal", True) is True: + msg = f"SKIP parameter: {parameter} in fabric {fabric_name}. " + msg += "parameter is internal." + self.log.debug(msg) + continue + + user_value = translated_payload.get(parameter, None) + default_value = parameter_info.get("default", None) + default_value = self._prepare_parameter_value_for_comparison(default_value) + + msg = f"parameter: {parameter}, " + msg += f"user_value: {user_value}, " + msg += f"type: {type(user_value)}" + self.log.debug(msg) + + msg = f"parameter: {parameter}, " + msg += f"default_value: {default_value}, " + msg += f"type: {type(default_value)}" + self.log.debug(msg) + + self._verify_value_types_for_comparison( + fabric_name, parameter, user_value, controller_value, default_value + ) + + result = self.update_replaced_payload( + parameter, user_value, controller_value, default_value + ) + if result is None: + continue + msg = f"UPDATE _fabric_changes_payload with result: {result}" + self.log.debug(msg) + self._fabric_changes_payload[fabric_name].update(result) + self._fabric_update_required.add(True) + + # Copy mandatory key/values DEPLOY, FABRIC_NAME, and FABRIC_TYPE + # from the old payload to the new payload. + deploy = payload.get("DEPLOY", None) + fabric_type = payload.get("FABRIC_TYPE", None) + self._fabric_changes_payload[fabric_name]["DEPLOY"] = deploy + self._fabric_changes_payload[fabric_name]["FABRIC_NAME"] = fabric_name + self._fabric_changes_payload[fabric_name]["FABRIC_TYPE"] = fabric_type + + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name: {fabric_name}, " + msg += f"fabric_update_required: {self._fabric_update_required}, " + msg += "fabric_changes_payload: " + msg += f"{json.dumps(self._fabric_changes_payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + + def _build_fabric_templates(self): + """ + - Build a dictionary, keyed on fabric_type, whose value is the + template for that fabric_type. + - Re-raise ``ValueError`` if ``fabric_types`` raises ``ValueError`` + - To minimize requests to the controller, only the templates + associated with fabric_types present in the user's payload are + retrieved. + """ + for payload in self.payloads: + fabric_type = payload.get("FABRIC_TYPE", None) + if fabric_type in self.fabric_templates: + continue + try: + self.fabric_types.fabric_type = fabric_type + except ValueError as error: + raise ValueError(error) from error + + self.template_get.template_name = self.fabric_types.template_name + self.template_get.refresh() + self.fabric_templates[fabric_type] = self.template_get.template + + def _build_payloads_for_replaced_state(self): + """ + - Build a list of dict of payloads to commit for replaced state. + Skip payloads for fabrics that do not exist on the controller. + - raise ``ValueError`` if ``_fabric_needs_update_for_replaced_state`` + fails. + - Expects self.payloads to be a list of dict, with each dict + being a payload for the fabric create API endpoint. + - Populates self._payloads_to_commit with a list of payloads to + commit. + + NOTES: + - self._fabric_needs_update_for_replaced_state() may remove + payload key/values that would not change the controller + configuration. + """ + self.fabric_details.refresh() + self._payloads_to_commit = [] + # Builds self.fabric_templates dictionary, keyed on fabric type. + # Value is the fabric template associated with each fabric_type. + self._build_fabric_templates() + + for payload in self.payloads: + fabric_name = payload.get("FABRIC_NAME", None) + if fabric_name not in self.fabric_details.all_data: + continue + + # Validate explicitly-set user parameters and inter-parameter + # dependencies. The user must provide a complete valid + # non-default config since replaced-state defaults everything else. + try: + self._initial_payload_validation(payload) + except ValueError as error: + raise ValueError(error) from error + + self._fabric_update_required = set() + try: + self._fabric_needs_update_for_replaced_state(payload) + except ValueError as error: + raise ValueError(error) from error + + if True not in self._fabric_update_required: + continue + self._payloads_to_commit.append( + copy.deepcopy(self._fabric_changes_payload[fabric_name]) + ) + + def _initial_payload_validation(self, payload) -> None: + """ + - Perform parameter validation and inter-parameter dependency + checks on parameters the user is explicitely setting. + - Raise ``ValueError`` if a payload validation fails. + """ + fabric_type = payload.get("FABRIC_TYPE", None) + fabric_name = payload.get("FABRIC_NAME", None) + try: + self.verify_playbook_params.config_playbook = payload + except TypeError as error: + raise ValueError(error) from error + + try: + self.fabric_types.fabric_type = fabric_type + except ValueError as error: + raise ValueError(error) from error + + try: + self.verify_playbook_params.template = self.fabric_templates[fabric_type] + except TypeError as error: + raise ValueError(error) from error + config_controller = self.fabric_details.all_data.get(fabric_name, {}).get( + "nvPairs", {} + ) + + try: + self.verify_playbook_params.config_controller = config_controller + except TypeError as error: + raise ValueError(error) from error + + try: + self.verify_playbook_params.commit() + except ValueError as error: + raise ValueError(error) from error + + def _send_payloads(self): + """ + - If ``check_mode`` is ``False``, send the payloads + to the controller. + - If ``check_mode`` is ``True``, do not send the payloads + to the controller. + - In both cases, register results. + - Re-raise ``ValueError`` if any of the following fail: + - ``FabricCommon()._fixup_payloads_to_commit()`` + - ``FabricReplacedCommon()._send_payload()`` + - ``FabricReplacedCommon()._config_save()`` + - ``FabricReplacedCommon()._config_deploy()`` + """ + self.rest_send.check_mode = self.check_mode + + try: + self._fixup_payloads_to_commit() + except ValueError as error: + raise ValueError(error) from error + + for payload in self._payloads_to_commit: + commit_payload = copy.deepcopy(payload) + if commit_payload.get("DEPLOY", None) is not None: + commit_payload.pop("DEPLOY") + try: + self._send_payload(commit_payload) + except ValueError as error: + raise ValueError(error) from error + + # Skip config-save if prior actions encountered errors. + if True in self.results.failed: + return + + for payload in self._payloads_to_commit: + try: + self._config_save(payload) + except ValueError as error: + raise ValueError(error) from error + + # Skip config-deploy if prior actions encountered errors. + if True in self.results.failed: + return + + for payload in self._payloads_to_commit: + try: + self._config_deploy(payload) + except (ControllerResponseError, ValueError) as error: + raise ValueError(error) from error + + def _set_fabric_update_endpoint(self, payload): + """ + - Set the endpoint for the fabric update API call. + - raise ``ValueError`` if the enpoint assignment fails + """ + self.endpoints.fabric_name = payload.get("FABRIC_NAME") + self.fabric_type = copy.copy(payload.get("FABRIC_TYPE")) + try: + self.fabric_types.fabric_type = self.fabric_type + except ValueError as error: + raise ValueError(error) from error + + try: + self.endpoints.template_name = self.fabric_types.template_name + except ValueError as error: + raise ValueError(error) from error + + try: + endpoint = self.endpoints.fabric_update + except ValueError as error: + raise ValueError(error) from error + + payload.pop("FABRIC_TYPE", None) + self.path = endpoint["path"] + self.verb = endpoint["verb"] + + def _send_payload(self, payload): + """ + - Send one fabric update payload + - raise ``ValueError`` if the endpoint assignment fails + """ + method_name = inspect.stack()[0][3] + + try: + self._set_fabric_update_endpoint(payload) + except ValueError as error: + raise ValueError(error) from error + + msg = f"{self.class_name}.{method_name}: " + msg += f"verb: {self.verb}, path: {self.path}, " + msg += f"payload: {json.dumps(payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + + # We don't want RestSend to retry on errors since the likelihood of a + # timeout error when updating a fabric is low, and there are many cases + # of permanent errors for which we don't want to retry. + self.rest_send.timeout = 1 + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.payload = payload + self.rest_send.commit() + + if self.rest_send.result_current["success"] is False: + self.results.diff_current = {} + else: + self.results.diff_current = copy.deepcopy(payload) + + self.send_payload_result[payload["FABRIC_NAME"]] = ( + self.rest_send.result_current["success"] + ) + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.response_current = copy.deepcopy(self.rest_send.response_current) + self.results.result_current = copy.deepcopy(self.rest_send.result_current) + self.results.register_task_result() + + @property + def payloads(self): + """ + Payloads must be a ``list`` of ``dict`` of payloads for the + ``fabric_update`` endpoint. + + - getter: Return the fabric update payloads + - setter: Set the fabric update payloads + - setter: raise ``ValueError`` if ``payloads`` is not a ``list`` of ``dict`` + - setter: raise ``ValueError`` if any payload is missing mandatory keys + """ + return self._properties["payloads"] + + @payloads.setter + def payloads(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, list): + msg = f"{self.class_name}.{method_name}: " + msg += "payloads must be a list of dict. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}" + raise ValueError(msg) + for item in value: + try: + self._verify_payload(item) + except ValueError as error: + raise ValueError(error) from error + self._properties["payloads"] = value + + +class FabricReplacedBulk(FabricReplacedCommon): + """ + Update fabrics in bulk for replaced state. + + Usage (where params is an AnsibleModule.params dictionary): + ```python + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import \ + FabricReplacedBulk + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results + + payloads = [ + { "FABRIC_NAME": "fabric1", "FABRIC_TYPE": "VXLAN_EVPN", "BGP_AS": 65000, "DEPLOY": True }, + { "FABRIC_NAME": "fabric2", "FABRIC_TYPE": "LAN_CLASSIC", "DEPLOY: False } + ] + results = Results() + instance = FabricReplacedBulk(params) + instance.payloads = payloads + instance.results = results + instance.commit() + results.build_final_result() + + # diff contains a dictionary of payloads that succeeded and/or failed + diff = results.diff + # result contains the result(s) of the fabric create request + result = results.result + # response contains the response(s) from the controller + response = results.response + + # results.final_result contains all of the above info, and can be passed + # to the exit_json and fail_json methods of AnsibleModule: + + if True in results.failed: + msg = "Fabric update(s) failed." + ansible_module.fail_json(msg, **task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + ``` + """ + + def __init__(self, params): + super().__init__(params) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricReplacedBulk()") + + self._build_properties() + + def _build_properties(self): + """ + Add properties specific to this class + """ + # properties dict is already initialized in FabricCommon + self._properties["payloads"] = None + + def commit(self): + """ + - Update fabrics and register results. + - Return if there are no fabrics to update for replaced state. + - raise ``ValueError`` if ``fabric_details`` is not set + - raise ``ValueError`` if ``fabric_summary`` is not set + - raise ``ValueError`` if ``payloads`` is not set + - raise ``ValueError`` if ``rest_send`` is not set + - raise ``ValueError`` if ``_build_payloads_for_replaced_state`` fails + - raise ``ValueError`` if ``_send_payloads`` fails + """ + method_name = inspect.stack()[0][3] + if self.fabric_details is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_details must be set prior to calling commit." + raise ValueError(msg) + + if self.fabric_summary is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_summary must be set prior to calling commit." + raise ValueError(msg) + + if self.payloads is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payloads must be set prior to calling commit." + raise ValueError(msg) + + if self.rest_send is None: + msg = f"{self.class_name}.{method_name}: " + msg += "rest_send must be set prior to calling commit." + raise ValueError(msg) + + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + + self.template_get.rest_send = self.rest_send + try: + self._build_payloads_for_replaced_state() + except ValueError as error: + raise ValueError(error) from error + + if len(self._payloads_to_commit) == 0: + self.results.diff_current = {} + self.results.result_current = {"success": True, "changed": False} + msg = "No fabrics to update for replaced state." + self.results.response_current = {"RETURN_CODE": 200, "MESSAGE": msg} + self.results.register_task_result() + return + + try: + self._send_payloads() + except ValueError as error: + self.results.diff_current = {} + self.results.result_current = {"success": False, "changed": False} + return_code = self.rest_send.response_current.get("RETURN_CODE", None) + msg = f"ValueError self.results.response: {self.results.response}" + self.log.debug(msg) + self.results.response_current = { + "RETURN_CODE": f"{return_code}", + "MESSAGE": f"{error}", + } + self.results.register_task_result() + raise ValueError(error) from error diff --git a/plugins/module_utils/fabric/ruleset.py b/plugins/module_utils/fabric/ruleset.py new file mode 100644 index 000000000..019aad852 --- /dev/null +++ b/plugins/module_utils/fabric/ruleset.py @@ -0,0 +1,436 @@ +# Copyright (c) 2024 Cisco and/or its affiliates. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Allen Robel" + + +import inspect +import json +import logging +import re + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.conversion import \ + ConversionUtils + + +class RuleSetCommon: + """ + Common methods for the RuleSet class. + + This may be merged back into RuleSet at some point. + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.conversion = ConversionUtils() + + self.re_multi_rule = re.compile(r"^\s*(\(.*\))(.*)(\(.*\))\s*$") + + self.param_name = None + self.rule = None + self.properties = {} + self.properties["template"] = None + self.properties["ruleset"] = {} + + def clean_rule(self): + """ + Clean the rule string. + """ + self.rule = self.rule.strip('"') + self.rule = self.rule.strip("'") + self.rule = self.rule.replace("$$", "") + self.rule = self.rule.replace("&&", " and ") + self.rule = self.rule.replace("||", " or ") + self.rule = self.rule.replace("==", " == ") + self.rule = self.rule.replace("!=", " != ") + self.rule = self.rule.replace("(", " ( ") + self.rule = self.rule.replace(")", " ) ") + self.rule = self.rule.replace("true", "True") + self.rule = self.rule.replace("false", "False") + self.rule = re.sub(r"\s+", " ", self.rule) + + @property + def ruleset(self): + """ + - getter : return the ruleset. + - setter : set the ruleset. + """ + return self.properties["ruleset"] + + @ruleset.setter + def ruleset(self, value): + self.properties["ruleset"] = value + + @property + def template(self): + """ + - getter : return a controller template. + - setter : set a controller template. + - The template is a dictionary retrieved from the controller. + """ + return self.properties["template"] + + @template.setter + def template(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name} must be a dictionary." + raise ValueError(msg) + self.properties["template"] = value + + @staticmethod + def annotations(parameter): + """ + Return the annotations for the parameter, if any. + + Otherwise, return None. + """ + if parameter.get("annotations") is None: + return None + if isinstance(parameter["annotations"], dict) is False: + return None + return parameter["annotations"] + + def is_mandatory(self, parameter): + """ + - Return False if annotations is not present + - Return True if annotations["IsMandatory"] is True + - Return False if annotations["IsMandatory"] is not present + - Return False if annotations["IsMandatory"] is False + - Return False if annotations["IsMandatory"] is not set + - Return annotations["IsMandatory"] if all else fails + """ + annotations = self.annotations(parameter) + if annotations is None: + return False + if annotations.get("IsMandatory") is None: + return False + if annotations["IsMandatory"] in ("true", "True", True): + return True + if annotations["IsMandatory"] in ("false", "False", False): + return False + return annotations["IsMandatory"] + + def is_show(self, parameter): + """ + - Return False if annotations is not present + - Return False if annotations["IsShow"] is not present + - Return True if annotations["IsShow"] is True + - Return False if annotations["IsShow"] is False + - Return annotations["IsShow"] if all else fails + """ + annotations = self.annotations(parameter) + if annotations is None: + return False + if annotations.get("IsShow") is None: + return False + if annotations["IsShow"] in ("true", "True", True): + return True + if annotations["IsShow"] in ("false", "False", False): + return False + return annotations["IsShow"] + + def is_internal(self, parameter): + """ + - Return False if annotations is not present + - Return False if annotations["IsInternal"] is not present + - Return True if annotations["IsInternal"] is True + - Return False if annotations["IsInternal"] is False + - Return False if all else fails + """ + annotations = self.annotations(parameter) + if annotations is None: + return False + if annotations.get("IsInternal") is None: + return False + if annotations["IsInternal"] in ("true", "True", True): + return True + if annotations["IsInternal"] in ("false", "False", False): + return False + return False + + def section(self, parameter): + """ + - Return "" if annotations is not present + - Return "" if annotations["Section"] is not present + - Return annotations["Section"] if present + """ + annotations = self.annotations(parameter) + if annotations is None: + return "" + if annotations.get("Section") is None: + return "" + return annotations["Section"] + + @staticmethod + def name(parameter): + """ + - Return the parameter's name, if present. + - Return None otherwise. + """ + if parameter.get("name") is None: + return None + return parameter["name"] + + +class RuleSet(RuleSetCommon): + """ + # Generate a ruleset from a controller template + + ## Usage + + ```python + ruleset = RuleSet() + ruleset.template =