From 32976fd433b4547439573946a20438c67f84338d Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Sat, 2 Mar 2024 14:43:47 -1000 Subject: [PATCH 001/228] initial commit (needs lots of work) --- plugins/module_utils/fabric/__init__.py | 0 plugins/module_utils/fabric/fabric.py | 950 ++++++++++++++++++++++++ plugins/modules/dcnm_fabric_vxlan.py | 689 +++++++++++++++++ 3 files changed, 1639 insertions(+) create mode 100644 plugins/module_utils/fabric/__init__.py create mode 100644 plugins/module_utils/fabric/fabric.py create mode 100644 plugins/modules/dcnm_fabric_vxlan.py 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/fabric.py b/plugins/module_utils/fabric/fabric.py new file mode 100644 index 000000000..69e652470 --- /dev/null +++ b/plugins/module_utils/fabric/fabric.py @@ -0,0 +1,950 @@ +#!/usr/bin/python +# +# Copyright (c) 2023-2023 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. +""" +Classes and methods to verify NDFC Data Center VXLAN EVPN Fabric parameters. +This should go in: +ansible_collections/cisco/dcnm/plugins/module_utils/fabric/fabric.py + +Example Usage: +import sys +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric import ( + VerifyFabricParams, +) + +config = {} +config["fabric_name"] = "foo" +config["bgp_as"] = "65000.869" +# If auto_symmetric_vrf_lite == True, several other parameters +# become mandatory. The user has not explicitely set these other +# parameters. Hence, verify.result would be False (i.e. an error) +# If auto_symmetric_vrf_lite == False, no other parameters are required +# and so verify.result would be True and verify.payload would contain +# a valid payload to send to NDFC +config["auto_symmetric_vrf_lite"] = False +verify = VerifyFabricParams() +verify.config = config +verify.state = "merged" +verify.validate_config() +if verify.result == False: + print(f"result {verify.result}, {verify.msg}") + sys.exit(1) +print(f"result {verify.result}, {verify.msg}, payload {verify.payload}") +""" +import re + + +def translate_mac_address(mac_addr): + """ + Accept mac address with any (or no) punctuation and convert it + into the dotted-quad format that NDFC expects. + + Return mac address formatted for NDFC on success + Return False on failure. + """ + mac_addr = re.sub(r"[\W\s_]", "", mac_addr) + if not re.search("^[A-Fa-f0-9]{12}$", mac_addr): + return False + return "".join((mac_addr[:4], ".", mac_addr[4:8], ".", mac_addr[8:])) + + +def translate_vrf_lite_autoconfig(value): + """ + Translate playbook values to those expected by NDFC + """ + try: + value = int(value) + except ValueError: + return False + if value == 0: + return "Manual" + if value == 1: + return "Back2Back&ToExternal" + return False + + +class VerifyFabricParams: + """ + Parameter validation for NDFC Easy_Fabric (Data Center VXLAN EVPN) + """ + + def __init__(self): + self._initialize_properties() + + self.msg = None + self.payload = {} + self._default_fabric_params = {} + self._default_nv_pairs = {} + # See self._build_parameter_aliases + self._parameter_aliases = {} + # See self._build_mandatory_params() + self._mandatory_params = {} + # See self._validate_dependencies() + self._requires_validation = set() + # See self._build_failed_dependencies() + self._failed_dependencies = {} + # nvPairs that are safe to translate from lowercase dunder + # (as used in the playbook) to uppercase dunder (as used + # in the NDFC payload). + self._translatable_nv_pairs = set() + # A dictionary that holds the set of nvPairs that have been + # translated for use in the NDFC payload. These include only + # parameters that the user has changed. Keyed on the NDFC-expected + # parameter name, value is the user's setting for the parameter. + # Populated in: + # self._translate_to_ndfc_nv_pairs() + # self._build_translatable_nv_pairs() + self._translated_nv_pairs = {} + self._valid_states = {"merged"} + self._mandatory_keys = {"fabric_name", "bgp_as"} + self._build_default_fabric_params() + self._build_default_nv_pairs() + + def _initialize_properties(self): + self.properties = {} + self.properties["msg"] = None + self.properties["result"] = True + self.properties["state"] = None + self.properties["config"] = {} + + def _append_msg(self, msg): + if self.msg is None: + self.msg = msg + else: + self.msg += f" {msg}" + + def _validate_config(self, config): + """ + verify that self.config is a dict and that it contains + the minimal set of mandatory keys. + + Caller: self.config (@property setter) + + On success: + return True + On failure: + set self.result to False + set self.msg to an approprate error message + return False + """ + if not isinstance(config, dict): + msg = "error: config must be a dictionary" + self.result = False + self._append_msg(msg) + return False + if not self._mandatory_keys.issubset(config): + missing_keys = self._mandatory_keys.difference(config.keys()) + msg = f"error: missing mandatory keys {','.join(sorted(missing_keys))}." + self.result = False + self._append_msg(msg) + return False + return True + + def validate_config(self): + """ + Caller: public method, called by the user + Validate the items in self.config are appropriate for self.state + """ + if self.state is None: + msg = "call instance.state before calling instance.validate_config" + self._append_msg(msg) + self.result = False + return + if self.state == "merged": + self._validate_merged_state_config() + + def _validate_merged_state_config(self): + """ + Caller: self._validate_config_for_merged_state() + + Update self.config with a verified version of the users playbook + parameters. + + + Verify the user's playbook parameters for an individual fabric + configuration. Whenever possible, throw the user a bone by + converting values to NDFC's expectations. For example, NDFC's + REST API accepts mac addresses in any format (does not return + an error), since the NDFC GUI validates that it is in the expected + format, but the fabric will be in an errored state if the mac address + sent via REST is any format other than dotted-quad format + (xxxx.xxxx.xxxx). So, we convert all mac address formats to + dotted-quad before passing them to NDFC. + + Set self.result to False and update self.msg if anything is not valid + that we couldn't fix + """ + if not self.config: + msg = "config: element is mandatory for state merged" + self._append_msg(msg) + self.result = False + return + if "fabric_name" not in self.config: + msg = "fabric_name is mandatory" + self._append_msg(msg) + self.result = False + return + if "bgp_as" not in self.config: + msg = "bgp_as is mandatory" + self._append_msg(msg) + self.result = False + return + if "anycast_gw_mac" in self.config: + result = translate_mac_address(self.config["anycast_gw_mac"]) + if result is False: + msg = f"invalid anycast_gw_mac {self.config['anycast_gw_mac']}" + self._append_msg(msg) + self.result = False + return + self.config["anycast_gw_mac"] = result + if "vrf_lite_autoconfig" in self.config: + result = translate_vrf_lite_autoconfig(self.config["vrf_lite_autoconfig"]) + if result is False: + msg = "invalid vrf_lite_autoconfig " + msg += f"{self.config['vrf_lite_autoconfig']}. Expected one of 0,1" + self._append_msg(msg) + self.result = False + return + self.config["vrf_lite_autoconfig"] = result + + # validate self.config for cross-parameter dependencies + self._validate_dependencies() + if self.result is False: + return + self._build_payload() + + def _build_default_nv_pairs(self): + """ + Caller: __init__() + + Build a dict() of default fabric nvPairs that will be sent to NDFC. + The values for these items are what NDFC currently (as of 12.1.2e) + uses for defaults. Items that are supported by this module may be + modified by the user's playbook. + """ + self._default_nv_pairs = {} + self._default_nv_pairs["AAA_REMOTE_IP_ENABLED"] = False + self._default_nv_pairs["AAA_SERVER_CONF"] = "" + self._default_nv_pairs["ACTIVE_MIGRATION"] = False + self._default_nv_pairs["ADVERTISE_PIP_BGP"] = False + self._default_nv_pairs["AGENT_INTF"] = "eth0" + self._default_nv_pairs["ANYCAST_BGW_ADVERTISE_PIP"] = False + self._default_nv_pairs["ANYCAST_GW_MAC"] = "2020.0000.00aa" + self._default_nv_pairs["ANYCAST_LB_ID"] = "" + self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" + self._default_nv_pairs["ANYCAST_RP_IP_RANGE_INTERNAL"] = "" + self._default_nv_pairs["AUTO_SYMMETRIC_DEFAULT_VRF"] = False + self._default_nv_pairs["AUTO_SYMMETRIC_VRF_LITE"] = False + self._default_nv_pairs["AUTO_VRFLITE_IFC_DEFAULT_VRF"] = False + self._default_nv_pairs["BFD_AUTH_ENABLE"] = False + self._default_nv_pairs["BFD_AUTH_KEY"] = "" + self._default_nv_pairs["BFD_AUTH_KEY_ID"] = "" + self._default_nv_pairs["BFD_ENABLE"] = False + self._default_nv_pairs["BFD_IBGP_ENABLE"] = False + self._default_nv_pairs["BFD_ISIS_ENABLE"] = False + self._default_nv_pairs["BFD_OSPF_ENABLE"] = False + self._default_nv_pairs["BFD_PIM_ENABLE"] = False + self._default_nv_pairs["BGP_AS"] = "1" + self._default_nv_pairs["BGP_AS_PREV"] = "" + self._default_nv_pairs["BGP_AUTH_ENABLE"] = False + self._default_nv_pairs["BGP_AUTH_KEY"] = "" + self._default_nv_pairs["BGP_AUTH_KEY_TYPE"] = "" + self._default_nv_pairs["BGP_LB_ID"] = "0" + self._default_nv_pairs["BOOTSTRAP_CONF"] = "" + self._default_nv_pairs["BOOTSTRAP_ENABLE"] = False + self._default_nv_pairs["BOOTSTRAP_ENABLE_PREV"] = False + self._default_nv_pairs["BOOTSTRAP_MULTISUBNET"] = "" + self._default_nv_pairs["BOOTSTRAP_MULTISUBNET_INTERNAL"] = "" + self._default_nv_pairs["BRFIELD_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs[ + "BROWNFIELD_NETWORK_NAME_FORMAT" + ] = "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$" + key = "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS" + self._default_nv_pairs[key] = False + self._default_nv_pairs["CDP_ENABLE"] = False + self._default_nv_pairs["COPP_POLICY"] = "strict" + self._default_nv_pairs["DCI_SUBNET_RANGE"] = "10.33.0.0/16" + self._default_nv_pairs["DCI_SUBNET_TARGET_MASK"] = "30" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_CLOUDSCALE"] = "" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_OTHER"] = "" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_R_SERIES"] = "" + self._default_nv_pairs["DEFAULT_VRF_REDIS_BGP_RMAP"] = "" + self._default_nv_pairs["DEPLOYMENT_FREEZE"] = False + self._default_nv_pairs["DHCP_ENABLE"] = False + self._default_nv_pairs["DHCP_END"] = "" + self._default_nv_pairs["DHCP_END_INTERNAL"] = "" + self._default_nv_pairs["DHCP_IPV6_ENABLE"] = "" + self._default_nv_pairs["DHCP_IPV6_ENABLE_INTERNAL"] = "" + self._default_nv_pairs["DHCP_START"] = "" + self._default_nv_pairs["DHCP_START_INTERNAL"] = "" + self._default_nv_pairs["DNS_SERVER_IP_LIST"] = "" + self._default_nv_pairs["DNS_SERVER_VRF"] = "" + self._default_nv_pairs["ENABLE_AAA"] = False + self._default_nv_pairs["ENABLE_AGENT"] = False + self._default_nv_pairs["ENABLE_DEFAULT_QUEUING_POLICY"] = False + self._default_nv_pairs["ENABLE_EVPN"] = True + self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID"] = False + self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID_PREV"] = "" + self._default_nv_pairs["ENABLE_MACSEC"] = False + self._default_nv_pairs["ENABLE_NETFLOW"] = False + self._default_nv_pairs["ENABLE_NETFLOW_PREV"] = "" + self._default_nv_pairs["ENABLE_NGOAM"] = True + self._default_nv_pairs["ENABLE_NXAPI"] = True + self._default_nv_pairs["ENABLE_NXAPI_HTTP"] = True + self._default_nv_pairs["ENABLE_PBR"] = False + self._default_nv_pairs["ENABLE_PVLAN"] = False + self._default_nv_pairs["ENABLE_PVLAN_PREV"] = False + self._default_nv_pairs["ENABLE_TENANT_DHCP"] = True + self._default_nv_pairs["ENABLE_TRM"] = False + self._default_nv_pairs["ENABLE_VPC_PEER_LINK_NATIVE_VLAN"] = False + self._default_nv_pairs["EXTRA_CONF_INTRA_LINKS"] = "" + self._default_nv_pairs["EXTRA_CONF_LEAF"] = "" + self._default_nv_pairs["EXTRA_CONF_SPINE"] = "" + self._default_nv_pairs["EXTRA_CONF_TOR"] = "" + self._default_nv_pairs["FABRIC_INTERFACE_TYPE"] = "p2p" + self._default_nv_pairs["FABRIC_MTU"] = "9216" + self._default_nv_pairs["FABRIC_MTU_PREV"] = "9216" + self._default_nv_pairs["FABRIC_NAME"] = "easy-fabric" + self._default_nv_pairs["FABRIC_TYPE"] = "Switch_Fabric" + self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID"] = "" + self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID_PREV"] = "" + self._default_nv_pairs["FABRIC_VPC_QOS"] = False + self._default_nv_pairs["FABRIC_VPC_QOS_POLICY_NAME"] = "" + self._default_nv_pairs["FEATURE_PTP"] = False + self._default_nv_pairs["FEATURE_PTP_INTERNAL"] = False + self._default_nv_pairs["FF"] = "Easy_Fabric" + self._default_nv_pairs["GRFIELD_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs["HD_TIME"] = "180" + self._default_nv_pairs["HOST_INTF_ADMIN_STATE"] = True + self._default_nv_pairs["IBGP_PEER_TEMPLATE"] = "" + self._default_nv_pairs["IBGP_PEER_TEMPLATE_LEAF"] = "" + self._default_nv_pairs["INBAND_DHCP_SERVERS"] = "" + self._default_nv_pairs["INBAND_MGMT"] = False + self._default_nv_pairs["INBAND_MGMT_PREV"] = False + self._default_nv_pairs["ISIS_AUTH_ENABLE"] = False + self._default_nv_pairs["ISIS_AUTH_KEY"] = "" + self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_KEY_ID"] = "" + self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_NAME"] = "" + self._default_nv_pairs["ISIS_LEVEL"] = "" + self._default_nv_pairs["ISIS_OVERLOAD_ELAPSE_TIME"] = "" + self._default_nv_pairs["ISIS_OVERLOAD_ENABLE"] = False + self._default_nv_pairs["ISIS_P2P_ENABLE"] = False + self._default_nv_pairs["L2_HOST_INTF_MTU"] = "9216" + self._default_nv_pairs["L2_HOST_INTF_MTU_PREV"] = "9216" + self._default_nv_pairs["L2_SEGMENT_ID_RANGE"] = "30000-49000" + self._default_nv_pairs["L3VNI_MCAST_GROUP"] = "" + self._default_nv_pairs["L3_PARTITION_ID_RANGE"] = "50000-59000" + self._default_nv_pairs["LINK_STATE_ROUTING"] = "ospf" + self._default_nv_pairs["LINK_STATE_ROUTING_TAG"] = "UNDERLAY" + self._default_nv_pairs["LINK_STATE_ROUTING_TAG_PREV"] = "" + self._default_nv_pairs["LOOPBACK0_IPV6_RANGE"] = "" + self._default_nv_pairs["LOOPBACK0_IP_RANGE"] = "10.2.0.0/22" + self._default_nv_pairs["LOOPBACK1_IPV6_RANGE"] = "" + self._default_nv_pairs["LOOPBACK1_IP_RANGE"] = "10.3.0.0/22" + self._default_nv_pairs["MACSEC_ALGORITHM"] = "" + self._default_nv_pairs["MACSEC_CIPHER_SUITE"] = "" + self._default_nv_pairs["MACSEC_FALLBACK_ALGORITHM"] = "" + self._default_nv_pairs["MACSEC_FALLBACK_KEY_STRING"] = "" + self._default_nv_pairs["MACSEC_KEY_STRING"] = "" + self._default_nv_pairs["MACSEC_REPORT_TIMER"] = "" + self._default_nv_pairs["MGMT_GW"] = "" + self._default_nv_pairs["MGMT_GW_INTERNAL"] = "" + self._default_nv_pairs["MGMT_PREFIX"] = "" + self._default_nv_pairs["MGMT_PREFIX_INTERNAL"] = "" + self._default_nv_pairs["MGMT_V6PREFIX"] = "64" + self._default_nv_pairs["MGMT_V6PREFIX_INTERNAL"] = "" + self._default_nv_pairs["MPLS_HANDOFF"] = False + self._default_nv_pairs["MPLS_LB_ID"] = "" + self._default_nv_pairs["MPLS_LOOPBACK_IP_RANGE"] = "" + self._default_nv_pairs["MSO_CONNECTIVITY_DEPLOYED"] = "" + self._default_nv_pairs["MSO_CONTROLER_ID"] = "" + self._default_nv_pairs["MSO_SITE_GROUP_NAME"] = "" + self._default_nv_pairs["MSO_SITE_ID"] = "" + self._default_nv_pairs["MST_INSTANCE_RANGE"] = "" + self._default_nv_pairs["MULTICAST_GROUP_SUBNET"] = "239.1.1.0/25" + self._default_nv_pairs["NETFLOW_EXPORTER_LIST"] = "" + self._default_nv_pairs["NETFLOW_MONITOR_LIST"] = "" + self._default_nv_pairs["NETFLOW_RECORD_LIST"] = "" + self._default_nv_pairs["NETWORK_VLAN_RANGE"] = "2300-2999" + self._default_nv_pairs["NTP_SERVER_IP_LIST"] = "" + self._default_nv_pairs["NTP_SERVER_VRF"] = "" + self._default_nv_pairs["NVE_LB_ID"] = "1" + self._default_nv_pairs["OSPF_AREA_ID"] = "0.0.0.0" + self._default_nv_pairs["OSPF_AUTH_ENABLE"] = False + self._default_nv_pairs["OSPF_AUTH_KEY"] = "" + self._default_nv_pairs["OSPF_AUTH_KEY_ID"] = "" + self._default_nv_pairs["OVERLAY_MODE"] = "config-profile" + self._default_nv_pairs["OVERLAY_MODE_PREV"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID1"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID2"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID3"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID4"] = "" + self._default_nv_pairs["PIM_HELLO_AUTH_ENABLE"] = False + self._default_nv_pairs["PIM_HELLO_AUTH_KEY"] = "" + self._default_nv_pairs["PM_ENABLE"] = False + self._default_nv_pairs["PM_ENABLE_PREV"] = False + self._default_nv_pairs["POWER_REDUNDANCY_MODE"] = "ps-redundant" + self._default_nv_pairs["PREMSO_PARENT_FABRIC"] = "" + self._default_nv_pairs["PTP_DOMAIN_ID"] = "" + self._default_nv_pairs["PTP_LB_ID"] = "" + self._default_nv_pairs["REPLICATION_MODE"] = "Multicast" + self._default_nv_pairs["ROUTER_ID_RANGE"] = "" + self._default_nv_pairs["ROUTE_MAP_SEQUENCE_NUMBER_RANGE"] = "1-65534" + self._default_nv_pairs["RP_COUNT"] = "2" + self._default_nv_pairs["RP_LB_ID"] = "254" + self._default_nv_pairs["RP_MODE"] = "asm" + self._default_nv_pairs["RR_COUNT"] = "2" + self._default_nv_pairs["SEED_SWITCH_CORE_INTERFACES"] = "" + self._default_nv_pairs["SERVICE_NETWORK_VLAN_RANGE"] = "3000-3199" + self._default_nv_pairs["SITE_ID"] = "" + self._default_nv_pairs["SNMP_SERVER_HOST_TRAP"] = True + self._default_nv_pairs["SPINE_COUNT"] = "0" + self._default_nv_pairs["SPINE_SWITCH_CORE_INTERFACES"] = "" + self._default_nv_pairs["SSPINE_ADD_DEL_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs["SSPINE_COUNT"] = "0" + self._default_nv_pairs["STATIC_UNDERLAY_IP_ALLOC"] = False + self._default_nv_pairs["STP_BRIDGE_PRIORITY"] = "" + self._default_nv_pairs["STP_ROOT_OPTION"] = "unmanaged" + self._default_nv_pairs["STP_VLAN_RANGE"] = "" + self._default_nv_pairs["STRICT_CC_MODE"] = False + self._default_nv_pairs["SUBINTERFACE_RANGE"] = "2-511" + self._default_nv_pairs["SUBNET_RANGE"] = "10.4.0.0/16" + self._default_nv_pairs["SUBNET_TARGET_MASK"] = "30" + self._default_nv_pairs["SYSLOG_SERVER_IP_LIST"] = "" + self._default_nv_pairs["SYSLOG_SERVER_VRF"] = "" + self._default_nv_pairs["SYSLOG_SEV"] = "" + self._default_nv_pairs["TCAM_ALLOCATION"] = True + self._default_nv_pairs["UNDERLAY_IS_V6"] = False + self._default_nv_pairs["UNNUM_BOOTSTRAP_LB_ID"] = "" + self._default_nv_pairs["UNNUM_DHCP_END"] = "" + self._default_nv_pairs["UNNUM_DHCP_END_INTERNAL"] = "" + self._default_nv_pairs["UNNUM_DHCP_START"] = "" + self._default_nv_pairs["UNNUM_DHCP_START_INTERNAL"] = "" + self._default_nv_pairs["USE_LINK_LOCAL"] = False + self._default_nv_pairs["V6_SUBNET_RANGE"] = "" + self._default_nv_pairs["V6_SUBNET_TARGET_MASK"] = "" + self._default_nv_pairs["VPC_AUTO_RECOVERY_TIME"] = "360" + self._default_nv_pairs["VPC_DELAY_RESTORE"] = "150" + self._default_nv_pairs["VPC_DELAY_RESTORE_TIME"] = "60" + self._default_nv_pairs["VPC_DOMAIN_ID_RANGE"] = "1-1000" + self._default_nv_pairs["VPC_ENABLE_IPv6_ND_SYNC"] = True + self._default_nv_pairs["VPC_PEER_KEEP_ALIVE_OPTION"] = "management" + self._default_nv_pairs["VPC_PEER_LINK_PO"] = "500" + self._default_nv_pairs["VPC_PEER_LINK_VLAN"] = "3600" + self._default_nv_pairs["VRF_LITE_AUTOCONFIG"] = "Manual" + self._default_nv_pairs["VRF_VLAN_RANGE"] = "2000-2299" + self._default_nv_pairs["abstract_anycast_rp"] = "anycast_rp" + self._default_nv_pairs["abstract_bgp"] = "base_bgp" + value = "evpn_bgp_rr_neighbor" + self._default_nv_pairs["abstract_bgp_neighbor"] = value + self._default_nv_pairs["abstract_bgp_rr"] = "evpn_bgp_rr" + self._default_nv_pairs["abstract_dhcp"] = "base_dhcp" + self._default_nv_pairs[ + "abstract_extra_config_bootstrap" + ] = "extra_config_bootstrap_11_1" + value = "extra_config_leaf" + self._default_nv_pairs["abstract_extra_config_leaf"] = value + value = "extra_config_spine" + self._default_nv_pairs["abstract_extra_config_spine"] = value + value = "extra_config_tor" + self._default_nv_pairs["abstract_extra_config_tor"] = value + value = "base_feature_leaf_upg" + self._default_nv_pairs["abstract_feature_leaf"] = value + value = "base_feature_spine_upg" + self._default_nv_pairs["abstract_feature_spine"] = value + self._default_nv_pairs["abstract_isis"] = "base_isis_level2" + self._default_nv_pairs["abstract_isis_interface"] = "isis_interface" + self._default_nv_pairs[ + "abstract_loopback_interface" + ] = "int_fabric_loopback_11_1" + self._default_nv_pairs["abstract_multicast"] = "base_multicast_11_1" + self._default_nv_pairs["abstract_ospf"] = "base_ospf" + value = "ospf_interface_11_1" + self._default_nv_pairs["abstract_ospf_interface"] = value + self._default_nv_pairs["abstract_pim_interface"] = "pim_interface" + self._default_nv_pairs["abstract_route_map"] = "route_map" + self._default_nv_pairs["abstract_routed_host"] = "int_routed_host" + self._default_nv_pairs["abstract_trunk_host"] = "int_trunk_host" + value = "int_fabric_vlan_11_1" + self._default_nv_pairs["abstract_vlan_interface"] = value + self._default_nv_pairs["abstract_vpc_domain"] = "base_vpc_domain_11_1" + value = "Default_Network_Universal" + self._default_nv_pairs["default_network"] = value + self._default_nv_pairs["default_pvlan_sec_network"] = "" + self._default_nv_pairs["default_vrf"] = "Default_VRF_Universal" + self._default_nv_pairs["enableRealTimeBackup"] = "" + self._default_nv_pairs["enableScheduledBackup"] = "" + self._default_nv_pairs[ + "network_extension_template" + ] = "Default_Network_Extension_Universal" + self._default_nv_pairs["scheduledTime"] = "" + self._default_nv_pairs["temp_anycast_gateway"] = "anycast_gateway" + self._default_nv_pairs["temp_vpc_domain_mgmt"] = "vpc_domain_mgmt" + self._default_nv_pairs["temp_vpc_peer_link"] = "int_vpc_peer_link_po" + self._default_nv_pairs[ + "vrf_extension_template" + ] = "Default_VRF_Extension_Universal" + + def _build_default_fabric_params(self): + """ + Caller: __init__() + + Initialize default NDFC top-level parameters + See also: self._build_default_nv_pairs() + """ + # TODO:3 We may need translation methods for these as well. See the + # method for nvPair transation: _translate_to_ndfc_nv_pairs + self._default_fabric_params = {} + self._default_fabric_params["deviceType"] = "n9k" + self._default_fabric_params["fabricTechnology"] = "VXLANFabric" + self._default_fabric_params["fabricTechnologyFriendly"] = "VXLAN Fabric" + self._default_fabric_params["fabricType"] = "Switch_Fabric" + self._default_fabric_params["fabricTypeFriendly"] = "Switch Fabric" + self._default_fabric_params[ + "networkExtensionTemplate" + ] = "Default_Network_Extension_Universal" + value = "Default_Network_Universal" + self._default_fabric_params["networkTemplate"] = value + self._default_fabric_params["provisionMode"] = "DCNMTopDown" + self._default_fabric_params["replicationMode"] = "Multicast" + self._default_fabric_params["siteId"] = "" + self._default_fabric_params["templateName"] = "Easy_Fabric" + self._default_fabric_params[ + "vrfExtensionTemplate" + ] = "Default_VRF_Extension_Universal" + self._default_fabric_params["vrfTemplate"] = "Default_VRF_Universal" + + def _build_translatable_nv_pairs(self): + """ + Caller: _translate_to_ndfc_nv_pairs() + + All parameters in the playbook are lowercase dunder, while + NDFC nvPairs contains a mish-mash of styles, for example: + - enableScheduledBackup + - default_vrf + - REPLICATION_MODE + + This method builds a set of playbook parameters that conform to the + most common case (uppercase dunder e.g. REPLICATION_MODE) and so + can safely be translated to uppercase dunder style that NDFC expects + in the payload. + + See also: self._translate_to_ndfc_nv_pairs, where the actual + translation happens. + """ + # self._default_nv_pairs is already built via create_fabric() + # Given we have a specific controlled input, we can use a more + # relaxed regex here. We just want to exclude camelCase e.g. + # "thisThing", lowercase dunder e.g. "this_thing", and lowercase + # e.g. "thisthing". + re_uppercase_dunder = "^[A-Z0-9_]+$" + self._translatable_nv_pairs = set() + for param in self._default_nv_pairs: + if re.search(re_uppercase_dunder, param): + self._translatable_nv_pairs.add(param.lower()) + + def _translate_to_ndfc_nv_pairs(self, params): + """ + Caller: self._build_payload() + + translate keys in params dict into what NDFC + expects in nvPairs and populate dict + self._translated_nv_pairs + + """ + self._build_translatable_nv_pairs() + # TODO:4 We currently don't handle non-dunder uppercase and lowercase, + # e.g. THIS or that. But (knock on wood), so far there are no + # cases like this (or THAT). + self._translated_nv_pairs = {} + # upper-case dunder keys + for param in self._translatable_nv_pairs: + if param not in params: + continue + self._translated_nv_pairs[param.upper()] = params[param] + # special cases + # dunder keys, these need no modification + dunder_keys = { + "default_network", + "default_vrf", + "network_extension_template", + "vrf_extension_template", + } + for key in dunder_keys: + if key not in params: + continue + self._translated_nv_pairs[key] = params[key] + # camelCase keys + # These are currently manually mapped with a dictionary. + camel_keys = { + "enableRealTimeBackup": "enable_real_time_backup", + "enableScheduledBackup": "enable_scheduled_backup", + "scheduledTime": "scheduled_time", + } + for ndfc_key, user_key in camel_keys.items(): + if user_key not in params: + continue + self._translated_nv_pairs[ndfc_key] = params[user_key] + + def _build_mandatory_params(self): + """ + Caller: self._validate_dependencies() + + build a map of mandatory parameters. + + Certain parameters become mandatory only if another parameter is + set, or only if it's set to a specific value. For example, if + underlay_is_v6 is set to True, the following parameters become + mandatory: + - anycast_lb_id + - loopback0_ipv6_range + - loopback1_ipv6_range + - router_id_range + - v6_subnet_range + - v6_subnet_target_mask + + self._mandatory_params is a dictionary, keyed on parameter. + The value is a dictionary with the following keys: + + value: The parameter value that makes the dependent parameters + mandatory. Using underlay_is_v6 as an example, it must + have a value of True, for the six dependent parameters to + be considered mandatory. + mandatory: a python dict() containing mandatory parameters and what + value (if any) they must have. Indicate that the value + should not be considered by setting it to None. + + NOTE: Generalized parameter value validation is handled elsewhere + + Hence, we have the following structure for the + self._mandatory_params dictionary, to handle the case where + underlay_is_v6 is set to True. Below, we don't case what the + value for any of the mandatory parameters is. We only care that + they are set. + + self._mandatory_params = { + "underlay_is_v6": { + "value": True, + "mandatory": { + "anycast_lb_id": None + "loopback0_ipv6_range": None + "loopback1_ipv6_range": None + "router_id_range": None + "v6_subnet_range": None + "v6_subnet_target_mask": None + } + } + } + + Above, we validate that all mandatory parameters are set, only + if the value of underlay_is_v6 is True. + + Set "value:" above to "__any__" if the dependent parameters are + mandatory regardless of the parameter's value. For example, if + we wanted to verify that underlay_is_v6 is set to True in the case + that anycast_lb_id is set (which can be a value between 1-1023) we + don't care what the value of anycast_lb_id is. We only care that + underlay_is_v6 is set to True. In this case, we could add the following: + + self._mandatory_params.update = { + "anycast_lb_id": { + "value": "__any__", + "mandatory": { + "underlay_is_v6": True + } + } + } + + """ + self._mandatory_params = {} + self._mandatory_params.update( + { + "anycast_lb_id": { + "value": "__any__", + "mandatory": {"underlay_is_v6": True}, + } + } + ) + self._mandatory_params.update( + { + "underlay_is_v6": { + "value": True, + "mandatory": { + "anycast_lb_id": None, + "loopback0_ipv6_range": None, + "loopback1_ipv6_range": None, + "router_id_range": None, + "v6_subnet_range": None, + "v6_subnet_target_mask": None, + }, + } + } + ) + self._mandatory_params.update( + { + "auto_symmetric_default_vrf": { + "value": True, + "mandatory": { + "vrf_lite_autoconfig": "Back2Back&ToExternal", + "auto_vrflite_ifc_default_vrf": True, + }, + } + } + ) + self._mandatory_params.update( + { + "auto_symmetric_vrf_lite": { + "value": True, + "mandatory": {"vrf_lite_autoconfig": "Back2Back&ToExternal"}, + } + } + ) + self._mandatory_params.update( + { + "auto_vrflite_ifc_default_vrf": { + "value": True, + "mandatory": { + "vrf_lite_autoconfig": "Back2Back&ToExternal", + "default_vrf_redis_bgp_rmap": None, + }, + } + } + ) + + def _build_parameter_aliases(self): + """ + Caller self._validate_dependencies() + + For some parameters, like vrf_lite_autoconfig, we don't + want the user to have to remember the spelling for + their values e.g. Back2Back&ToExternal. So, we alias + the value NDFC expects (Back2Back&ToExternal) to something + easier. In this case, 1. + + See also: _get_parameter_alias() + """ + self._parameter_aliases = {} + self._parameter_aliases["vrf_lite_autoconfig"] = { + "Back2Back&ToExternal": 1, + "Manual": 0, + } + + def _get_parameter_alias(self, param, value): + """ + Caller: self._validate_dependencies() + + Accessor method for self._parameter_aliases + + param: the parameter + value: the parameter's value that NDFC expects + + Return the value alias for param (i.e. param's value + prior to translation, i.e. the value that's used in the + playbook) if it exists. + + Return None otherwise + + See also: self._build_parameter_aliases() + """ + if param not in self._parameter_aliases: + return None + if value not in self._parameter_aliases[param]: + return None + return self._parameter_aliases[param][value] + + def _build_failed_dependencies(self): + """ + If the user has set one or more parameters that, in turn, cause + other parameters to become mandatory, build a dictionary of these + dependencies and what value is expected for each. + + Example self._failed_dependencies. In this case, the user set + auto_symmetric_vrf_lite to True, which makes vrf_lite_autoconfig + mandatory. Too, vrf_lite_autoconfig MUST have a value of + Back2Back&ToExternal. Though, in the playbook, the sets + vrf_lite_autoconfig to 1, since 1 is an alias for + Back2Back&ToExternal. See self._handle_failed_dependencies() + for how we handle aliased parameters. + + { + 'vrf_lite_autoconfig': 'Back2Back&ToExternal' + } + """ + if not self._requires_validation: + return + self._failed_dependencies = {} + for user_param in self._requires_validation: + # mandatory_params associated with user_param + mandatory_params = self._mandatory_params[user_param]["mandatory"] + for check_param in mandatory_params: + check_value = mandatory_params[check_param] + if check_param not in self.config and check_value is not None: + # The playbook doesn't contain this mandatory parameter. + # We care what the value is (since it's not None). + # If the mandatory parameter's default value is not equal + # to the required value, add it to the failed dependencies. + param_up = check_param.upper() + if param_up in self._default_nv_pairs: + if self._default_nv_pairs[param_up] != check_value: + self._failed_dependencies[check_param] = check_value + continue + if self.config[check_param] != check_value and check_value is not None: + # The playbook does contain this mandatory parameter, but + # the value in the playbook does not match the required value + # and we care about what the required value is. + self._failed_dependencies[check_param] = check_value + continue + print(f"self._failed_dependencies {self._failed_dependencies}") + + def _validate_dependencies(self): + """ + Validate cross-parameter dependencies. + + Caller: self._validate_config_for_merged_state() + + On failure to validate cross-parameter dependencies: + set self.result to False + set self.msg to an appropriate error message + + See also: docstring for self._build_mandatory_params() + """ + self._build_mandatory_params() + self._build_parameter_aliases() + self._requires_validation = set() + for user_param in self.config: + # param doesn't have any dependent parameters + if user_param not in self._mandatory_params: + continue + # need to run validation for user_param with value "__any__" + if self._mandatory_params[user_param]["value"] == "__any__": + self._requires_validation.add(user_param) + # need to run validation because user_param is a specific value + if self.config[user_param] == self._mandatory_params[user_param]["value"]: + self._requires_validation.add(user_param) + if not self._requires_validation: + return + self._build_failed_dependencies() + self._handle_failed_dependencies() + + def _handle_failed_dependencies(self): + """ + If there are failed dependencies: + 1. Set self.result to False + 2. Build a useful message for the user that lists + the additional parameters that NDFC expects + """ + if not self._failed_dependencies: + return + for user_param in self._requires_validation: + if self._mandatory_params[user_param]["value"] == "any": + msg = f"When {user_param} is set, " + else: + msg = f"When {user_param} is set to " + msg += f"{self._mandatory_params[user_param]['value']}, " + msg += "the following parameters are mandatory: " + + for key, value in self._failed_dependencies.items(): + msg += f"parameter {key} " + if value is None: + msg += "value " + else: + # If the value expected in the playbook is different + # from the value sent to NDFC, use the value expected in + # the playbook so as not to confuse the user. + alias = self._get_parameter_alias(key, value) + if alias is None: + msg_value = value + else: + msg_value = alias + msg += f"value {msg_value}" + self._append_msg(msg) + self.result = False + + def _build_payload(self): + """ + Build the payload to create the fabric specified self.config + Caller: _validate_dependencies + """ + self.payload = self._default_fabric_params + self.payload["fabricName"] = self.config["fabric_name"] + self.payload["asn"] = self.config["bgp_as"] + self.payload["nvPairs"] = self._default_nv_pairs + self._translate_to_ndfc_nv_pairs(self.config) + for key, value in self._translated_nv_pairs.items(): + self.payload["nvPairs"][key] = value + + @property + def config(self): + """ + Basic initial validatation for individual fabric configuration + Verifies that config is a dict() and that mandatory keys are + present. + """ + return self.properties["config"] + + @config.setter + def config(self, param): + if not self._validate_config(param): + return + self.properties["config"] = param + + @property + def msg(self): + """ + messages to return to the caller + """ + return self.properties["msg"] + + @msg.setter + def msg(self, param): + self.properties["msg"] = param + + @property + def payload(self): + """ + The payload to send to NDFC + """ + return self.properties["payload"] + + @payload.setter + def payload(self, param): + self.properties["payload"] = param + + @property + def result(self): + """ + get/set intermediate results and final result + """ + return self.properties["result"] + + @result.setter + def result(self, param): + self.properties["result"] = param + + @property + def state(self): + """ + The Ansible state provided by the caller + """ + return self.properties["state"] + + @state.setter + def state(self, param): + if param not in self._valid_states: + msg = f"invalid state {param}. " + msg += f"expected one of: {','.join(sorted(self._valid_states))}" + self.result = False + self._append_msg(msg) + self.properties["state"] = param diff --git a/plugins/modules/dcnm_fabric_vxlan.py b/plugins/modules/dcnm_fabric_vxlan.py new file mode 100644 index 000000000..ed0323029 --- /dev/null +++ b/plugins/modules/dcnm_fabric_vxlan.py @@ -0,0 +1,689 @@ +#!/usr/bin/python +# +# Copyright (c) 2020-2022 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. +""" +Classes and methods for Ansible support of NDFC Data Center VXLAN EVPN Fabric. + +Ansible states "merged", "deleted", and "query" are implemented. +""" +from __future__ import absolute_import, division, print_function + +import copy +import inspect +import json +import logging +from typing import Any, Dict, List + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log +from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import ( + dcnm_send, + dcnm_version_supported, + get_fabric_details, + #get_fabric_inventory_details, + validate_list_of_dicts, +) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric import ( + VerifyFabricParams, +) + +__metaclass__ = type +__author__ = "Allen Robel" + +DOCUMENTATION = """ +--- +module: dcnm_fabric_vxlan +short_description: Create VXLAN/EVPN Fabrics. +version_added: "0.9.0" +description: + - "Create VXLAN/EVPN Fabrics." +author: Allen Robel +options: + state: + description: + - The state of DCNM after module completion. + - I(merged) and I(query) are the only states supported. + type: str + choices: + - merged + - query + default: merged + config: + description: + - A dictionary of fabric configurations + type: list + elements: dict + suboptions: + aaa_remote_ip_enabled: + description: + - Enable (True) or disable (False) AAA remote IP + - NDFC GUI label, Enable AAA IP Authorization + - NDFC GUI tab, Advanced + type: bool + required: false + default: False + advertise_pip_bgp: + description: + - Enable (True) or disable (False) usage of Primary VTEP IP Advertisement As Next-Hop Of Prefix Routes + - NDFC GUI label, vPC advertise-pip + - NDFC GUI tab, VPC + type: bool + required: false + default: False + anycast_bgw_advertise_pip: + description: + - Enable (True) or disable (False) advertising Anycast Border Gateway PIP as VTEP. + - Effective after Recalculate Config on parent MSD fabric. + - NDFC GUI label, Anycast Border Gateway advertise-pip + - NDFC GUI tab, Advanced + type: bool + required: false + default: False + anycast_gw_mac: + description: + - Shared MAC address for all leafs (xx:xx:xx:xx:xx:xx, xxxx.xxxx.xxxx, etc) + - NDFC GUI label, Anycast Gateway MAC + - NDFC GUI tab, General Parameters + type: str + required: false + default: "2020.0000.00aa" + anycast_lb_id: + description: + - Underlay Anycast Loopback Id + - NDFC GUI label, Underlay Anycast Loopback Id + - NDFC GUI tab, Protocols + type: int + required: false + default: "" + anycast_rp_ip_range: + description: + - Anycast or Phantom RP IP Address Range + - NDFC GUI label, Underlay RP Loopback IP Range + - NDFC GUI tab, Resources + type: str + required: false + default: 10.254.254.0/24 + auto_symmetric_default_vrf: + description: + - Enable (True) or disable (False) auto generation of Default VRF interface and BGP peering configuration on managed neighbor devices. + - If True, auto created VRF Lite IFC links will have 'Auto Deploy Default VRF for Peer' enabled. + - vrf_lite_autoconfig must be set to 1 + - auto_symmetric_vrf_lite must be set to True + - auto_vrflite_ifc_default_vrf must be set to True + - NDFC GUI label: Auto Deploy Default VRF for Peer + - NDFC GUI tab: Resources + type: bool + required: false + default: False + auto_symmetric_vrf_lite: + description: + - Enable (True) or disable (False) auto generation of Whether to auto generate VRF LITE sub-interface and BGP peering configuration on managed neighbor devices. + - If True, auto created VRF Lite IFC links will have 'Auto Deploy for Peer' enabled. + - NDFC GUI label, Auto Deploy for Peer + - NDFC GUI tab, Resources + - vrf_lite_autoconfig must be set to 1 + type: bool + required: false + default: False + auto_vrflite_ifc_default_vrf: + description: + - Enable (True) or disable (False) auto generation of Default VRF interface and BGP peering configuration on VRF LITE IFC auto deployment. + - If True, auto created VRF Lite IFC links will have 'Auto Deploy Default VRF' enabled. + - NDFC GUI label, Auto Deploy Default VRF + - NDFC GUI tab, Resources + - vrf_lite_autoconfig must be set to 1 + type: bool + required: false + default: False + bgp_as: + description: + - The fabric BGP Autonomous System number + - NDFC GUI label, BGP ASN + - NDFC GUI tab, General Parameters + type: str + required: true + default_vrf_redis_bgp_rmap: + description: + - Route Map used to redistribute BGP routes to IGP in default vrf in auto created VRF Lite IFC links + - NDFC GUI label, Redistribute BGP Route-map Name + - NDFC GUI tab, Resources + type: str + required: false, unless auto_vrflite_ifc_default_vrf is set to True + fabric_name: + description: + - The name of the fabric + type: str + required: true + pm_enable: + description: + - Enable (True) or disable (False) fabric performance monitoring + - NDFC GUI label, Enable Performance Monitoring + - NDFC GUI tab, General Parameters + type: bool + required: false + default: False + replication_mode: + description: + - Replication Mode for BUM Traffic + - NDFC GUI label, Replication Mode + - NDFC GUI tab, Replication + type: str + required: False + choices: + - Ingress + - Multicast + default: Multicast + vrf_lite_autoconfig: + description: + - VRF Lite Inter-Fabric Connection Deployment Options. + - If (0), VRF Lite configuration is Manual. + - If (1), VRF Lite IFCs are auto created between border devices of two Easy Fabrics + - If (1), VRF Lite IFCs are auto created between border devices in Easy Fabric and edge routers in External Fabric. + - The IP address is taken from the 'VRF Lite Subnet IP Range' pool. + - NDFC GUI label, VRF Lite Deployment + - NDFC GUI tab, Resources + type: int + required: false + default: 0 + choices: + - 0 + - 1 + +""" + +EXAMPLES = """ +# This module supports the following states: +# +# Merged: +# Fabric defined in the playbook will be created. +# +# Query: +# Returns the current DCNM state for the fabric. + + +# The following will create fabric my-fabric +- name: Create fabric + cisco.dcnm.dcnm_fabric_vxlan: + state: merged + config: + - fabric_name: my-fabric + bgp_as: 100 + +""" + + +class FabricVxlanTask: + """ + Ansible support for Data Center VXLAN EVPN + """ + + def __init__(self, module): + self.class_name = self.__class__.__name__ + method_name = inspect.stack()[0][3] + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricVxlanTask()") + + self.module = module + self.params = module.params + self.verify = VerifyFabricParams() + # populated in self.validate_input() + self.payloads = {} + # TODO:1 set self.debug to False to disable self.log_msg() + self.debug = True + # File descriptor set by self.log_msg() + self.fd = None + # File self.log_msg() logs to + self.logfile = "/tmp/dcnm_fabric_vxlan.log" + + self.config = module.params.get("config") + if not isinstance(self.config, list): + msg = "expected list type for self.config. " + msg = f"got {type(self.config).__name__}" + self.module.fail_json(msg=msg) + + self.check_mode = False + self.validated = [] + self.have_create = [] + self.want_create = [] + self.diff_create = [] + self.diff_save = {} + self.query = [] + self.result = dict(changed=False, diff=[], response=[]) + + self.nd_prefix = "/appcenter/cisco/ndfc/api/v1/lan-fabric" + self.controller_version = dcnm_version_supported(self.module) + self.nd = self.controller_version >= 12 + + # TODO:4 Will need to revisit bgp_as at some point since the + # following fabric types don't require it. + # - Fabric Group + # - Classis LAN + # - LAN Monitor + # - VXLAN EVPN Multi-Site + # We'll cross that bridge when we get to it + self.mandatory_keys = {"fabric_name", "bgp_as"} + # Populated in self.get_have() + self.fabric_details = {} + # Not currently using. Commented out in self.get_have() + self.inventory_data = {} + for item in self.config: + if not self.mandatory_keys.issubset(item): + msg = f"missing mandatory keys in {item}. " + msg += f"expected {self.mandatory_keys}" + self.module.fail_json(msg=msg) + + def get_have(self): + """ + Caller: main() + + Determine current fabric state on NDFC for all existing fabrics + """ + for item in self.config: + # mandatory keys have already been checked in __init__() + fabric = item["fabric_name"] + self.fabric_details[fabric] = get_fabric_details(self.module, fabric) + # self.inventory_data[fabric] = get_fabric_inventory_details( + # self.module, fabric + # ) + + fabrics_exist = set() + for fabric in self.fabric_details: + path = f"/rest/control/fabrics/{fabric}" + if self.nd: + path = self.nd_prefix + path + fabric_info = dcnm_send(self.module, "GET", path) + result = self._handle_get_response(fabric_info) + if result["found"]: + fabrics_exist.add(fabric) + if not result["success"]: + msg = "Unable to retrieve fabric information from NDFC" + self.module.fail_json(msg=msg) + if fabrics_exist: + msg = "Fabric(s) already present on NDFC: " + msg += f"{','.join(sorted(fabrics_exist))}" + self.module.fail_json(msg=msg) + + def get_want(self): + """ + Caller: main() + + Update self.want_create for all fabrics defined in the playbook + """ + want_create = [] + + # we don't want to use self.validated here since + # validate_list_of_dicts() adds items the user did not set to + # self.validated. self.validate_input() has already been called, + # so if we got this far the items in self.config have been validated + # to conform to their param spec. + for fabric_config in self.config: + want_create.append(fabric_config) + if not want_create: + return + self.want_create = want_create + + def get_diff_merge(self): + """ + Caller: main() + + Populates self.diff_create list() with items from our want list + that are not in our have list. These items will be sent to NDFC. + """ + diff_create = [] + + for want_c in self.want_create: + found = False + for have_c in self.have_create: + if want_c["fabric_name"] == have_c["fabric_name"]: + found = True + if not found: + diff_create.append(want_c) + self.diff_create = diff_create + + @staticmethod + def _build_params_spec_for_merged_state(): + """ + Build the specs for the parameters expected when state == merged. + + Caller: _validate_input_for_merged_state() + Return: params_spec, a dictionary containing the set of + parameter specifications. + """ + params_spec = {} + params_spec.update( + aaa_remote_ip_enabled=dict(required=False, type="bool", default=False) + ) + # TODO:6 active_migration + # active_migration doesn't seem to be represented in + # the NDFC EasyFabric GUI. Add this param if we figure out + # what it's used for and where in the GUI it's represented + params_spec.update( + advertise_pip_bgp=dict(required=False, type="bool", default=False) + ) + # TODO:6 agent_intf (add if needed) + params_spec.update( + anycast_bgw_advertise_pip=dict(required=False, type="bool", default=False) + ) + params_spec.update( + anycast_gw_mac=dict(required=False, type="str", default="2020.0000.00aa") + ) + params_spec.update( + anycast_lb_id=dict( + required=False, type="int", range_min=0, range_max=1023, default="" + ) + ) + params_spec.update( + anycast_rp_ip_range=dict( + required=False, type="ipv4_subnet", default="10.254.254.0/24" + ) + ) + params_spec.update( + auto_symmetric_default_vrf=dict(required=False, type="bool", default=False) + ) + params_spec.update( + auto_symmetric_vrf_lite=dict(required=False, type="bool", default=False) + ) + params_spec.update( + auto_vrflite_ifc_default_vrf=dict( + required=False, type="bool", default=False + ) + ) + params_spec.update(bgp_as=dict(required=True, type="str")) + params_spec.update( + default_vrf_redis_bgp_rmap=dict(required=False, type="str", default="") + ) + params_spec.update(fabric_name=dict(required=True, type="str")) + params_spec.update(pm_enable=dict(required=False, type="bool", default=False)) + params_spec.update( + replication_mode=dict( + required=False, + type="str", + default="Multicast", + choices=["Ingress", "Multicast"], + ) + ) + params_spec.update( + vrf_lite_autoconfig=dict( + required=False, type="int", default=0, choices=[0, 1] + ) + ) + return params_spec + + def validate_input(self): + """ + Caller: main() + + Validate the playbook parameters + Build the payloads for each fabric + """ + + state = self.params["state"] + + # TODO:2 remove this when we implement query state + if state != "merged": + msg = f"Only merged state is supported. Got state {state}" + self.module.fail_json(msg=msg) + + if state == "merged": + self._validate_input_for_merged_state() + + self.payloads = {} + for fabric_config in self.config: + verify = VerifyFabricParams() + verify.state = state + verify.config = fabric_config + if verify.result is False: + self.module.fail_json(msg=verify.msg) + verify.validate_config() + if verify.result is False: + self.module.fail_json(msg=verify.msg) + self.payloads[fabric_config["fabric_name"]] = verify.payload + + def _validate_input_for_merged_state(self): + """ + Caller: self._validate_input() + + Valid self.config contains appropriate values for merged state + """ + params_spec = self._build_params_spec_for_merged_state() + msg = None + if not self.config: + msg = "config: element is mandatory for state merged" + self.module.fail_json(msg=msg) + + valid_params, invalid_params = validate_list_of_dicts( + self.config, params_spec, self.module + ) + # We're not using self.validated. Keeping this to avoid + # linter error due to non-use of valid_params + self.validated = copy.deepcopy(valid_params) + + if invalid_params: + msg = "Invalid parameters in playbook: " + msg += f"{','.join(invalid_params)}" + self.module.fail_json(msg=msg) + + def create_fabrics(self): + """ + Caller: main() + + Build and send the payload to create the + fabrics specified in the playbook. + """ + path = "/rest/control/fabrics" + if self.nd: + path = self.nd_prefix + path + + for item in self.want_create: + fabric = item["fabric_name"] + + payload = self.payloads[fabric] + response = dcnm_send(self.module, "POST", path, data=json.dumps(payload)) + result = self._handle_post_put_response(response, "POST") + + if not result["success"]: + self.log_msg( + f"create_fabrics: calling self._failure with response {response}" + ) + self._failure(response) + + def _handle_get_response(self, response): + """ + Caller: + - self.get_have() + Handle NDFC responses to GET requests + Returns: dict() with the following keys: + - found: + - False, if request error was "Not found" and RETURN_CODE == 404 + - True otherwise + - success: + - False if RETURN_CODE != 200 or MESSAGE != "OK" + - True otherwise + """ + # Example response + # { + # 'RETURN_CODE': 404, + # 'METHOD': 'GET', + # 'REQUEST_PATH': '...user path goes here...', + # 'MESSAGE': 'Not Found', + # 'DATA': { + # 'timestamp': 1691970528998, + # 'status': 404, + # 'error': 'Not Found', + # 'path': '/rest/control/fabrics/IR-Fabric' + # } + # } + result = {} + success_return_codes = {200, 404} + self.log_msg(f"_handle_get_request: response {response}") + if ( + response.get("RETURN_CODE") == 404 + and response.get("MESSAGE") == "Not Found" + ): + result["found"] = False + result["success"] = True + return result + if ( + response.get("RETURN_CODE") not in success_return_codes + or response.get("MESSAGE") != "OK" + ): + result["found"] = False + result["success"] = False + return result + result["found"] = True + result["success"] = True + return result + + def _handle_post_put_response(self, response, verb): + """ + Caller: + - self.create_fabrics() + + Handle POST, PUT responses from NDFC. + + Returns: dict() with the following keys: + - changed: + - True if changes were made to NDFC + - False otherwise + - success: + - False if RETURN_CODE != 200 or MESSAGE != "OK" + - True otherwise + + """ + # Example response + # { + # 'RETURN_CODE': 200, + # 'METHOD': 'POST', + # 'REQUEST_PATH': '...user path goes here...', + # 'MESSAGE': 'OK', + # 'DATA': {...} + valid_verbs = {"POST", "PUT"} + if verb not in valid_verbs: + msg = f"invalid verb {verb}. " + msg += f"expected one of: {','.join(sorted(valid_verbs))}" + self.module.fail_json(msg=msg) + + result = {} + if response.get("MESSAGE") != "OK": + result["success"] = False + result["changed"] = False + return result + if response.get("ERROR"): + result["success"] = False + result["changed"] = False + return result + result["success"] = True + result["changed"] = True + return result + + def _failure(self, resp): + """ + Caller: self.create_fabrics() + + This came from dcnm_inventory.py, but doesn't seem to be correct + for the case where resp["DATA"] does not exist? + + If resp["DATA"] does not exist, the contents of the + if block don't seem to actually do anything: + - data will be None + - Hence, data.get("stackTrace") will also be None + - Hence, data.update() and res.update() are never executed + + So, the only two lines that will actually ever be executed are + the happy path: + + res = copy.deepcopy(resp) + self.module.fail_json(msg=res) + """ + res = copy.deepcopy(resp) + + if not resp.get("DATA"): + data = copy.deepcopy(resp.get("DATA")) + if data.get("stackTrace"): + data.update( + {"stackTrace": "Stack trace is hidden, use '-vvvvv' to print it"} + ) + res.update({"DATA": data}) + + self.module.fail_json(msg=res) + + def log_msg(self, msg): + """ + used for debugging. disable this when committing to main + """ + if self.debug is False: + return + if self.fd is None: + try: + self.fd = open(f"{self.logfile}", "a+", encoding="UTF-8") + except IOError as err: + msg = f"error opening logfile {self.logfile}. " + msg += f"detail: {err}" + self.module.fail_json(msg=msg) + + self.fd.write(msg) + self.fd.write("\n") + self.fd.flush() + + +def main(): + """main entry point for module execution""" + + element_spec = dict( + config=dict(required=False, type="list", elements="dict"), + state=dict(default="merged", choices=["merged"]), + ) + + ansible_module = AnsibleModule(argument_spec=element_spec, supports_check_mode=True) + + # Create the base/parent logger for the dcnm collection. + # To disable logging, comment out log.config = below + # log.config can be either a dictionary, or a path to a JSON file + # Both dictionary and JSON file formats must be conformant with + # logging.config.dictConfig and must not log to the console. + # For an example configuration, see: + # $ANSIBLE_COLLECTIONS_PATH/cisco/dcnm/plugins/module_utils/common/logging_config.json + log = Log(ansible_module) + collection_path = "/Users/arobel/repos/collections/ansible_collections/cisco/dcnm" + config_file = f"{collection_path}/plugins/module_utils/common/logging_config.json" + log.config = config_file + log.commit() + + task = FabricVxlanTask(ansible_module) + task.validate_input() + task.get_have() + task.get_want() + + if ansible_module.params["state"] == "merged": + task.get_diff_merge() + + if task.diff_create: + task.result["changed"] = True + else: + ansible_module.exit_json(**task.result) + + if ansible_module.check_mode: + task.result["changed"] = False + ansible_module.exit_json(**task.result) + + if task.diff_create: + task.create_fabrics() + + ansible_module.exit_json(**task.result) + + +if __name__ == "__main__": + main() From 059f4048f48d780a71caa8a4b5dfc84f4a3587e7 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Sat, 2 Mar 2024 15:20:05 -1000 Subject: [PATCH 002/228] Use have/want/need nomenclature and initial support class renaming --- plugins/module_utils/fabric/vxlan/__init__.py | 0 .../fabric/vxlan/verify_fabric_params.py | 950 ++++++++++++++++++ plugins/modules/dcnm_fabric_vxlan.py | 39 +- 3 files changed, 970 insertions(+), 19 deletions(-) create mode 100644 plugins/module_utils/fabric/vxlan/__init__.py create mode 100644 plugins/module_utils/fabric/vxlan/verify_fabric_params.py diff --git a/plugins/module_utils/fabric/vxlan/__init__.py b/plugins/module_utils/fabric/vxlan/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/fabric/vxlan/verify_fabric_params.py b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py new file mode 100644 index 000000000..f60182885 --- /dev/null +++ b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py @@ -0,0 +1,950 @@ +#!/usr/bin/python +# +# Copyright (c) 2023-2023 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. +""" +Classes and methods to verify NDFC Data Center VXLAN EVPN Fabric parameters. +This should go in: +ansible_collections/cisco/dcnm/plugins/module_utils/fabric/fabric_vxlan/verify_fabric_params.py + +Example Usage: +import sys +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric import ( + VerifyFabricParams, +) + +config = {} +config["fabric_name"] = "foo" +config["bgp_as"] = "65000.869" +# If auto_symmetric_vrf_lite == True, several other parameters +# become mandatory. The user has not explicitely set these other +# parameters. Hence, verify.result would be False (i.e. an error) +# If auto_symmetric_vrf_lite == False, no other parameters are required +# and so verify.result would be True and verify.payload would contain +# a valid payload to send to NDFC +config["auto_symmetric_vrf_lite"] = False +verify = VerifyFabricParams() +verify.config = config +verify.state = "merged" +verify.validate_config() +if verify.result == False: + print(f"result {verify.result}, {verify.msg}") + sys.exit(1) +print(f"result {verify.result}, {verify.msg}, payload {verify.payload}") +""" +import re + + +def translate_mac_address(mac_addr): + """ + Accept mac address with any (or no) punctuation and convert it + into the dotted-quad format that NDFC expects. + + Return mac address formatted for NDFC on success + Return False on failure. + """ + mac_addr = re.sub(r"[\W\s_]", "", mac_addr) + if not re.search("^[A-Fa-f0-9]{12}$", mac_addr): + return False + return "".join((mac_addr[:4], ".", mac_addr[4:8], ".", mac_addr[8:])) + + +def translate_vrf_lite_autoconfig(value): + """ + Translate playbook values to those expected by NDFC + """ + try: + value = int(value) + except ValueError: + return False + if value == 0: + return "Manual" + if value == 1: + return "Back2Back&ToExternal" + return False + + +class VerifyFabricParams: + """ + Parameter validation for NDFC Data Center VXLAN EVPN + """ + + def __init__(self): + self._initialize_properties() + + self.msg = None + self.payload = {} + self._default_fabric_params = {} + self._default_nv_pairs = {} + # See self._build_parameter_aliases + self._parameter_aliases = {} + # See self._build_mandatory_params() + self._mandatory_params = {} + # See self._validate_dependencies() + self._requires_validation = set() + # See self._build_failed_dependencies() + self._failed_dependencies = {} + # nvPairs that are safe to translate from lowercase dunder + # (as used in the playbook) to uppercase dunder (as used + # in the NDFC payload). + self._translatable_nv_pairs = set() + # A dictionary that holds the set of nvPairs that have been + # translated for use in the NDFC payload. These include only + # parameters that the user has changed. Keyed on the NDFC-expected + # parameter name, value is the user's setting for the parameter. + # Populated in: + # self._translate_to_ndfc_nv_pairs() + # self._build_translatable_nv_pairs() + self._translated_nv_pairs = {} + self._valid_states = {"merged"} + self._mandatory_keys = {"fabric_name", "bgp_as"} + self._build_default_fabric_params() + self._build_default_nv_pairs() + + def _initialize_properties(self): + self.properties = {} + self.properties["msg"] = None + self.properties["result"] = True + self.properties["state"] = None + self.properties["config"] = {} + + def _append_msg(self, msg): + if self.msg is None: + self.msg = msg + else: + self.msg += f" {msg}" + + def _validate_config(self, config): + """ + verify that self.config is a dict and that it contains + the minimal set of mandatory keys. + + Caller: self.config (@property setter) + + On success: + return True + On failure: + set self.result to False + set self.msg to an approprate error message + return False + """ + if not isinstance(config, dict): + msg = "error: config must be a dictionary" + self.result = False + self._append_msg(msg) + return False + if not self._mandatory_keys.issubset(config): + missing_keys = self._mandatory_keys.difference(config.keys()) + msg = f"error: missing mandatory keys {','.join(sorted(missing_keys))}." + self.result = False + self._append_msg(msg) + return False + return True + + def validate_config(self): + """ + Caller: public method, called by the user + Validate the items in self.config are appropriate for self.state + """ + if self.state is None: + msg = "call instance.state before calling instance.validate_config" + self._append_msg(msg) + self.result = False + return + if self.state == "merged": + self._validate_merged_state_config() + + def _validate_merged_state_config(self): + """ + Caller: self._validate_config_for_merged_state() + + Update self.config with a verified version of the users playbook + parameters. + + + Verify the user's playbook parameters for an individual fabric + configuration. Whenever possible, throw the user a bone by + converting values to NDFC's expectations. For example, NDFC's + REST API accepts mac addresses in any format (does not return + an error), since the NDFC GUI validates that it is in the expected + format, but the fabric will be in an errored state if the mac address + sent via REST is any format other than dotted-quad format + (xxxx.xxxx.xxxx). So, we convert all mac address formats to + dotted-quad before passing them to NDFC. + + Set self.result to False and update self.msg if anything is not valid + that we couldn't fix + """ + if not self.config: + msg = "config: element is mandatory for state merged" + self._append_msg(msg) + self.result = False + return + if "fabric_name" not in self.config: + msg = "fabric_name is mandatory" + self._append_msg(msg) + self.result = False + return + if "bgp_as" not in self.config: + msg = "bgp_as is mandatory" + self._append_msg(msg) + self.result = False + return + if "anycast_gw_mac" in self.config: + result = translate_mac_address(self.config["anycast_gw_mac"]) + if result is False: + msg = f"invalid anycast_gw_mac {self.config['anycast_gw_mac']}" + self._append_msg(msg) + self.result = False + return + self.config["anycast_gw_mac"] = result + if "vrf_lite_autoconfig" in self.config: + result = translate_vrf_lite_autoconfig(self.config["vrf_lite_autoconfig"]) + if result is False: + msg = "invalid vrf_lite_autoconfig " + msg += f"{self.config['vrf_lite_autoconfig']}. Expected one of 0,1" + self._append_msg(msg) + self.result = False + return + self.config["vrf_lite_autoconfig"] = result + + # validate self.config for cross-parameter dependencies + self._validate_dependencies() + if self.result is False: + return + self._build_payload() + + def _build_default_nv_pairs(self): + """ + Caller: __init__() + + Build a dict() of default fabric nvPairs that will be sent to NDFC. + The values for these items are what NDFC currently (as of 12.1.2e) + uses for defaults. Items that are supported by this module may be + modified by the user's playbook. + """ + self._default_nv_pairs = {} + self._default_nv_pairs["AAA_REMOTE_IP_ENABLED"] = False + self._default_nv_pairs["AAA_SERVER_CONF"] = "" + self._default_nv_pairs["ACTIVE_MIGRATION"] = False + self._default_nv_pairs["ADVERTISE_PIP_BGP"] = False + self._default_nv_pairs["AGENT_INTF"] = "eth0" + self._default_nv_pairs["ANYCAST_BGW_ADVERTISE_PIP"] = False + self._default_nv_pairs["ANYCAST_GW_MAC"] = "2020.0000.00aa" + self._default_nv_pairs["ANYCAST_LB_ID"] = "" + self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" + self._default_nv_pairs["ANYCAST_RP_IP_RANGE_INTERNAL"] = "" + self._default_nv_pairs["AUTO_SYMMETRIC_DEFAULT_VRF"] = False + self._default_nv_pairs["AUTO_SYMMETRIC_VRF_LITE"] = False + self._default_nv_pairs["AUTO_VRFLITE_IFC_DEFAULT_VRF"] = False + self._default_nv_pairs["BFD_AUTH_ENABLE"] = False + self._default_nv_pairs["BFD_AUTH_KEY"] = "" + self._default_nv_pairs["BFD_AUTH_KEY_ID"] = "" + self._default_nv_pairs["BFD_ENABLE"] = False + self._default_nv_pairs["BFD_IBGP_ENABLE"] = False + self._default_nv_pairs["BFD_ISIS_ENABLE"] = False + self._default_nv_pairs["BFD_OSPF_ENABLE"] = False + self._default_nv_pairs["BFD_PIM_ENABLE"] = False + self._default_nv_pairs["BGP_AS"] = "1" + self._default_nv_pairs["BGP_AS_PREV"] = "" + self._default_nv_pairs["BGP_AUTH_ENABLE"] = False + self._default_nv_pairs["BGP_AUTH_KEY"] = "" + self._default_nv_pairs["BGP_AUTH_KEY_TYPE"] = "" + self._default_nv_pairs["BGP_LB_ID"] = "0" + self._default_nv_pairs["BOOTSTRAP_CONF"] = "" + self._default_nv_pairs["BOOTSTRAP_ENABLE"] = False + self._default_nv_pairs["BOOTSTRAP_ENABLE_PREV"] = False + self._default_nv_pairs["BOOTSTRAP_MULTISUBNET"] = "" + self._default_nv_pairs["BOOTSTRAP_MULTISUBNET_INTERNAL"] = "" + self._default_nv_pairs["BRFIELD_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs[ + "BROWNFIELD_NETWORK_NAME_FORMAT" + ] = "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$" + key = "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS" + self._default_nv_pairs[key] = False + self._default_nv_pairs["CDP_ENABLE"] = False + self._default_nv_pairs["COPP_POLICY"] = "strict" + self._default_nv_pairs["DCI_SUBNET_RANGE"] = "10.33.0.0/16" + self._default_nv_pairs["DCI_SUBNET_TARGET_MASK"] = "30" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_CLOUDSCALE"] = "" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_OTHER"] = "" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_R_SERIES"] = "" + self._default_nv_pairs["DEFAULT_VRF_REDIS_BGP_RMAP"] = "" + self._default_nv_pairs["DEPLOYMENT_FREEZE"] = False + self._default_nv_pairs["DHCP_ENABLE"] = False + self._default_nv_pairs["DHCP_END"] = "" + self._default_nv_pairs["DHCP_END_INTERNAL"] = "" + self._default_nv_pairs["DHCP_IPV6_ENABLE"] = "" + self._default_nv_pairs["DHCP_IPV6_ENABLE_INTERNAL"] = "" + self._default_nv_pairs["DHCP_START"] = "" + self._default_nv_pairs["DHCP_START_INTERNAL"] = "" + self._default_nv_pairs["DNS_SERVER_IP_LIST"] = "" + self._default_nv_pairs["DNS_SERVER_VRF"] = "" + self._default_nv_pairs["ENABLE_AAA"] = False + self._default_nv_pairs["ENABLE_AGENT"] = False + self._default_nv_pairs["ENABLE_DEFAULT_QUEUING_POLICY"] = False + self._default_nv_pairs["ENABLE_EVPN"] = True + self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID"] = False + self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID_PREV"] = "" + self._default_nv_pairs["ENABLE_MACSEC"] = False + self._default_nv_pairs["ENABLE_NETFLOW"] = False + self._default_nv_pairs["ENABLE_NETFLOW_PREV"] = "" + self._default_nv_pairs["ENABLE_NGOAM"] = True + self._default_nv_pairs["ENABLE_NXAPI"] = True + self._default_nv_pairs["ENABLE_NXAPI_HTTP"] = True + self._default_nv_pairs["ENABLE_PBR"] = False + self._default_nv_pairs["ENABLE_PVLAN"] = False + self._default_nv_pairs["ENABLE_PVLAN_PREV"] = False + self._default_nv_pairs["ENABLE_TENANT_DHCP"] = True + self._default_nv_pairs["ENABLE_TRM"] = False + self._default_nv_pairs["ENABLE_VPC_PEER_LINK_NATIVE_VLAN"] = False + self._default_nv_pairs["EXTRA_CONF_INTRA_LINKS"] = "" + self._default_nv_pairs["EXTRA_CONF_LEAF"] = "" + self._default_nv_pairs["EXTRA_CONF_SPINE"] = "" + self._default_nv_pairs["EXTRA_CONF_TOR"] = "" + self._default_nv_pairs["FABRIC_INTERFACE_TYPE"] = "p2p" + self._default_nv_pairs["FABRIC_MTU"] = "9216" + self._default_nv_pairs["FABRIC_MTU_PREV"] = "9216" + self._default_nv_pairs["FABRIC_NAME"] = "easy-fabric" + self._default_nv_pairs["FABRIC_TYPE"] = "Switch_Fabric" + self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID"] = "" + self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID_PREV"] = "" + self._default_nv_pairs["FABRIC_VPC_QOS"] = False + self._default_nv_pairs["FABRIC_VPC_QOS_POLICY_NAME"] = "" + self._default_nv_pairs["FEATURE_PTP"] = False + self._default_nv_pairs["FEATURE_PTP_INTERNAL"] = False + self._default_nv_pairs["FF"] = "Easy_Fabric" + self._default_nv_pairs["GRFIELD_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs["HD_TIME"] = "180" + self._default_nv_pairs["HOST_INTF_ADMIN_STATE"] = True + self._default_nv_pairs["IBGP_PEER_TEMPLATE"] = "" + self._default_nv_pairs["IBGP_PEER_TEMPLATE_LEAF"] = "" + self._default_nv_pairs["INBAND_DHCP_SERVERS"] = "" + self._default_nv_pairs["INBAND_MGMT"] = False + self._default_nv_pairs["INBAND_MGMT_PREV"] = False + self._default_nv_pairs["ISIS_AUTH_ENABLE"] = False + self._default_nv_pairs["ISIS_AUTH_KEY"] = "" + self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_KEY_ID"] = "" + self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_NAME"] = "" + self._default_nv_pairs["ISIS_LEVEL"] = "" + self._default_nv_pairs["ISIS_OVERLOAD_ELAPSE_TIME"] = "" + self._default_nv_pairs["ISIS_OVERLOAD_ENABLE"] = False + self._default_nv_pairs["ISIS_P2P_ENABLE"] = False + self._default_nv_pairs["L2_HOST_INTF_MTU"] = "9216" + self._default_nv_pairs["L2_HOST_INTF_MTU_PREV"] = "9216" + self._default_nv_pairs["L2_SEGMENT_ID_RANGE"] = "30000-49000" + self._default_nv_pairs["L3VNI_MCAST_GROUP"] = "" + self._default_nv_pairs["L3_PARTITION_ID_RANGE"] = "50000-59000" + self._default_nv_pairs["LINK_STATE_ROUTING"] = "ospf" + self._default_nv_pairs["LINK_STATE_ROUTING_TAG"] = "UNDERLAY" + self._default_nv_pairs["LINK_STATE_ROUTING_TAG_PREV"] = "" + self._default_nv_pairs["LOOPBACK0_IPV6_RANGE"] = "" + self._default_nv_pairs["LOOPBACK0_IP_RANGE"] = "10.2.0.0/22" + self._default_nv_pairs["LOOPBACK1_IPV6_RANGE"] = "" + self._default_nv_pairs["LOOPBACK1_IP_RANGE"] = "10.3.0.0/22" + self._default_nv_pairs["MACSEC_ALGORITHM"] = "" + self._default_nv_pairs["MACSEC_CIPHER_SUITE"] = "" + self._default_nv_pairs["MACSEC_FALLBACK_ALGORITHM"] = "" + self._default_nv_pairs["MACSEC_FALLBACK_KEY_STRING"] = "" + self._default_nv_pairs["MACSEC_KEY_STRING"] = "" + self._default_nv_pairs["MACSEC_REPORT_TIMER"] = "" + self._default_nv_pairs["MGMT_GW"] = "" + self._default_nv_pairs["MGMT_GW_INTERNAL"] = "" + self._default_nv_pairs["MGMT_PREFIX"] = "" + self._default_nv_pairs["MGMT_PREFIX_INTERNAL"] = "" + self._default_nv_pairs["MGMT_V6PREFIX"] = "64" + self._default_nv_pairs["MGMT_V6PREFIX_INTERNAL"] = "" + self._default_nv_pairs["MPLS_HANDOFF"] = False + self._default_nv_pairs["MPLS_LB_ID"] = "" + self._default_nv_pairs["MPLS_LOOPBACK_IP_RANGE"] = "" + self._default_nv_pairs["MSO_CONNECTIVITY_DEPLOYED"] = "" + self._default_nv_pairs["MSO_CONTROLER_ID"] = "" + self._default_nv_pairs["MSO_SITE_GROUP_NAME"] = "" + self._default_nv_pairs["MSO_SITE_ID"] = "" + self._default_nv_pairs["MST_INSTANCE_RANGE"] = "" + self._default_nv_pairs["MULTICAST_GROUP_SUBNET"] = "239.1.1.0/25" + self._default_nv_pairs["NETFLOW_EXPORTER_LIST"] = "" + self._default_nv_pairs["NETFLOW_MONITOR_LIST"] = "" + self._default_nv_pairs["NETFLOW_RECORD_LIST"] = "" + self._default_nv_pairs["NETWORK_VLAN_RANGE"] = "2300-2999" + self._default_nv_pairs["NTP_SERVER_IP_LIST"] = "" + self._default_nv_pairs["NTP_SERVER_VRF"] = "" + self._default_nv_pairs["NVE_LB_ID"] = "1" + self._default_nv_pairs["OSPF_AREA_ID"] = "0.0.0.0" + self._default_nv_pairs["OSPF_AUTH_ENABLE"] = False + self._default_nv_pairs["OSPF_AUTH_KEY"] = "" + self._default_nv_pairs["OSPF_AUTH_KEY_ID"] = "" + self._default_nv_pairs["OVERLAY_MODE"] = "config-profile" + self._default_nv_pairs["OVERLAY_MODE_PREV"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID1"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID2"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID3"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID4"] = "" + self._default_nv_pairs["PIM_HELLO_AUTH_ENABLE"] = False + self._default_nv_pairs["PIM_HELLO_AUTH_KEY"] = "" + self._default_nv_pairs["PM_ENABLE"] = False + self._default_nv_pairs["PM_ENABLE_PREV"] = False + self._default_nv_pairs["POWER_REDUNDANCY_MODE"] = "ps-redundant" + self._default_nv_pairs["PREMSO_PARENT_FABRIC"] = "" + self._default_nv_pairs["PTP_DOMAIN_ID"] = "" + self._default_nv_pairs["PTP_LB_ID"] = "" + self._default_nv_pairs["REPLICATION_MODE"] = "Multicast" + self._default_nv_pairs["ROUTER_ID_RANGE"] = "" + self._default_nv_pairs["ROUTE_MAP_SEQUENCE_NUMBER_RANGE"] = "1-65534" + self._default_nv_pairs["RP_COUNT"] = "2" + self._default_nv_pairs["RP_LB_ID"] = "254" + self._default_nv_pairs["RP_MODE"] = "asm" + self._default_nv_pairs["RR_COUNT"] = "2" + self._default_nv_pairs["SEED_SWITCH_CORE_INTERFACES"] = "" + self._default_nv_pairs["SERVICE_NETWORK_VLAN_RANGE"] = "3000-3199" + self._default_nv_pairs["SITE_ID"] = "" + self._default_nv_pairs["SNMP_SERVER_HOST_TRAP"] = True + self._default_nv_pairs["SPINE_COUNT"] = "0" + self._default_nv_pairs["SPINE_SWITCH_CORE_INTERFACES"] = "" + self._default_nv_pairs["SSPINE_ADD_DEL_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs["SSPINE_COUNT"] = "0" + self._default_nv_pairs["STATIC_UNDERLAY_IP_ALLOC"] = False + self._default_nv_pairs["STP_BRIDGE_PRIORITY"] = "" + self._default_nv_pairs["STP_ROOT_OPTION"] = "unmanaged" + self._default_nv_pairs["STP_VLAN_RANGE"] = "" + self._default_nv_pairs["STRICT_CC_MODE"] = False + self._default_nv_pairs["SUBINTERFACE_RANGE"] = "2-511" + self._default_nv_pairs["SUBNET_RANGE"] = "10.4.0.0/16" + self._default_nv_pairs["SUBNET_TARGET_MASK"] = "30" + self._default_nv_pairs["SYSLOG_SERVER_IP_LIST"] = "" + self._default_nv_pairs["SYSLOG_SERVER_VRF"] = "" + self._default_nv_pairs["SYSLOG_SEV"] = "" + self._default_nv_pairs["TCAM_ALLOCATION"] = True + self._default_nv_pairs["UNDERLAY_IS_V6"] = False + self._default_nv_pairs["UNNUM_BOOTSTRAP_LB_ID"] = "" + self._default_nv_pairs["UNNUM_DHCP_END"] = "" + self._default_nv_pairs["UNNUM_DHCP_END_INTERNAL"] = "" + self._default_nv_pairs["UNNUM_DHCP_START"] = "" + self._default_nv_pairs["UNNUM_DHCP_START_INTERNAL"] = "" + self._default_nv_pairs["USE_LINK_LOCAL"] = False + self._default_nv_pairs["V6_SUBNET_RANGE"] = "" + self._default_nv_pairs["V6_SUBNET_TARGET_MASK"] = "" + self._default_nv_pairs["VPC_AUTO_RECOVERY_TIME"] = "360" + self._default_nv_pairs["VPC_DELAY_RESTORE"] = "150" + self._default_nv_pairs["VPC_DELAY_RESTORE_TIME"] = "60" + self._default_nv_pairs["VPC_DOMAIN_ID_RANGE"] = "1-1000" + self._default_nv_pairs["VPC_ENABLE_IPv6_ND_SYNC"] = True + self._default_nv_pairs["VPC_PEER_KEEP_ALIVE_OPTION"] = "management" + self._default_nv_pairs["VPC_PEER_LINK_PO"] = "500" + self._default_nv_pairs["VPC_PEER_LINK_VLAN"] = "3600" + self._default_nv_pairs["VRF_LITE_AUTOCONFIG"] = "Manual" + self._default_nv_pairs["VRF_VLAN_RANGE"] = "2000-2299" + self._default_nv_pairs["abstract_anycast_rp"] = "anycast_rp" + self._default_nv_pairs["abstract_bgp"] = "base_bgp" + value = "evpn_bgp_rr_neighbor" + self._default_nv_pairs["abstract_bgp_neighbor"] = value + self._default_nv_pairs["abstract_bgp_rr"] = "evpn_bgp_rr" + self._default_nv_pairs["abstract_dhcp"] = "base_dhcp" + self._default_nv_pairs[ + "abstract_extra_config_bootstrap" + ] = "extra_config_bootstrap_11_1" + value = "extra_config_leaf" + self._default_nv_pairs["abstract_extra_config_leaf"] = value + value = "extra_config_spine" + self._default_nv_pairs["abstract_extra_config_spine"] = value + value = "extra_config_tor" + self._default_nv_pairs["abstract_extra_config_tor"] = value + value = "base_feature_leaf_upg" + self._default_nv_pairs["abstract_feature_leaf"] = value + value = "base_feature_spine_upg" + self._default_nv_pairs["abstract_feature_spine"] = value + self._default_nv_pairs["abstract_isis"] = "base_isis_level2" + self._default_nv_pairs["abstract_isis_interface"] = "isis_interface" + self._default_nv_pairs[ + "abstract_loopback_interface" + ] = "int_fabric_loopback_11_1" + self._default_nv_pairs["abstract_multicast"] = "base_multicast_11_1" + self._default_nv_pairs["abstract_ospf"] = "base_ospf" + value = "ospf_interface_11_1" + self._default_nv_pairs["abstract_ospf_interface"] = value + self._default_nv_pairs["abstract_pim_interface"] = "pim_interface" + self._default_nv_pairs["abstract_route_map"] = "route_map" + self._default_nv_pairs["abstract_routed_host"] = "int_routed_host" + self._default_nv_pairs["abstract_trunk_host"] = "int_trunk_host" + value = "int_fabric_vlan_11_1" + self._default_nv_pairs["abstract_vlan_interface"] = value + self._default_nv_pairs["abstract_vpc_domain"] = "base_vpc_domain_11_1" + value = "Default_Network_Universal" + self._default_nv_pairs["default_network"] = value + self._default_nv_pairs["default_pvlan_sec_network"] = "" + self._default_nv_pairs["default_vrf"] = "Default_VRF_Universal" + self._default_nv_pairs["enableRealTimeBackup"] = "" + self._default_nv_pairs["enableScheduledBackup"] = "" + self._default_nv_pairs[ + "network_extension_template" + ] = "Default_Network_Extension_Universal" + self._default_nv_pairs["scheduledTime"] = "" + self._default_nv_pairs["temp_anycast_gateway"] = "anycast_gateway" + self._default_nv_pairs["temp_vpc_domain_mgmt"] = "vpc_domain_mgmt" + self._default_nv_pairs["temp_vpc_peer_link"] = "int_vpc_peer_link_po" + self._default_nv_pairs[ + "vrf_extension_template" + ] = "Default_VRF_Extension_Universal" + + def _build_default_fabric_params(self): + """ + Caller: __init__() + + Initialize default NDFC top-level parameters + See also: self._build_default_nv_pairs() + """ + # TODO:3 We may need translation methods for these as well. See the + # method for nvPair transation: _translate_to_ndfc_nv_pairs + self._default_fabric_params = {} + self._default_fabric_params["deviceType"] = "n9k" + self._default_fabric_params["fabricTechnology"] = "VXLANFabric" + self._default_fabric_params["fabricTechnologyFriendly"] = "VXLAN Fabric" + self._default_fabric_params["fabricType"] = "Switch_Fabric" + self._default_fabric_params["fabricTypeFriendly"] = "Switch Fabric" + self._default_fabric_params[ + "networkExtensionTemplate" + ] = "Default_Network_Extension_Universal" + value = "Default_Network_Universal" + self._default_fabric_params["networkTemplate"] = value + self._default_fabric_params["provisionMode"] = "DCNMTopDown" + self._default_fabric_params["replicationMode"] = "Multicast" + self._default_fabric_params["siteId"] = "" + self._default_fabric_params["templateName"] = "Easy_Fabric" + self._default_fabric_params[ + "vrfExtensionTemplate" + ] = "Default_VRF_Extension_Universal" + self._default_fabric_params["vrfTemplate"] = "Default_VRF_Universal" + + def _build_translatable_nv_pairs(self): + """ + Caller: _translate_to_ndfc_nv_pairs() + + All parameters in the playbook are lowercase dunder, while + NDFC nvPairs contains a mish-mash of styles, for example: + - enableScheduledBackup + - default_vrf + - REPLICATION_MODE + + This method builds a set of playbook parameters that conform to the + most common case (uppercase dunder e.g. REPLICATION_MODE) and so + can safely be translated to uppercase dunder style that NDFC expects + in the payload. + + See also: self._translate_to_ndfc_nv_pairs, where the actual + translation happens. + """ + # self._default_nv_pairs is already built via create_fabric() + # Given we have a specific controlled input, we can use a more + # relaxed regex here. We just want to exclude camelCase e.g. + # "thisThing", lowercase dunder e.g. "this_thing", and lowercase + # e.g. "thisthing". + re_uppercase_dunder = "^[A-Z0-9_]+$" + self._translatable_nv_pairs = set() + for param in self._default_nv_pairs: + if re.search(re_uppercase_dunder, param): + self._translatable_nv_pairs.add(param.lower()) + + def _translate_to_ndfc_nv_pairs(self, params): + """ + Caller: self._build_payload() + + translate keys in params dict into what NDFC + expects in nvPairs and populate dict + self._translated_nv_pairs + + """ + self._build_translatable_nv_pairs() + # TODO:4 We currently don't handle non-dunder uppercase and lowercase, + # e.g. THIS or that. But (knock on wood), so far there are no + # cases like this (or THAT). + self._translated_nv_pairs = {} + # upper-case dunder keys + for param in self._translatable_nv_pairs: + if param not in params: + continue + self._translated_nv_pairs[param.upper()] = params[param] + # special cases + # dunder keys, these need no modification + dunder_keys = { + "default_network", + "default_vrf", + "network_extension_template", + "vrf_extension_template", + } + for key in dunder_keys: + if key not in params: + continue + self._translated_nv_pairs[key] = params[key] + # camelCase keys + # These are currently manually mapped with a dictionary. + camel_keys = { + "enableRealTimeBackup": "enable_real_time_backup", + "enableScheduledBackup": "enable_scheduled_backup", + "scheduledTime": "scheduled_time", + } + for ndfc_key, user_key in camel_keys.items(): + if user_key not in params: + continue + self._translated_nv_pairs[ndfc_key] = params[user_key] + + def _build_mandatory_params(self): + """ + Caller: self._validate_dependencies() + + build a map of mandatory parameters. + + Certain parameters become mandatory only if another parameter is + set, or only if it's set to a specific value. For example, if + underlay_is_v6 is set to True, the following parameters become + mandatory: + - anycast_lb_id + - loopback0_ipv6_range + - loopback1_ipv6_range + - router_id_range + - v6_subnet_range + - v6_subnet_target_mask + + self._mandatory_params is a dictionary, keyed on parameter. + The value is a dictionary with the following keys: + + value: The parameter value that makes the dependent parameters + mandatory. Using underlay_is_v6 as an example, it must + have a value of True, for the six dependent parameters to + be considered mandatory. + mandatory: a python dict() containing mandatory parameters and what + value (if any) they must have. Indicate that the value + should not be considered by setting it to None. + + NOTE: Generalized parameter value validation is handled elsewhere + + Hence, we have the following structure for the + self._mandatory_params dictionary, to handle the case where + underlay_is_v6 is set to True. Below, we don't case what the + value for any of the mandatory parameters is. We only care that + they are set. + + self._mandatory_params = { + "underlay_is_v6": { + "value": True, + "mandatory": { + "anycast_lb_id": None + "loopback0_ipv6_range": None + "loopback1_ipv6_range": None + "router_id_range": None + "v6_subnet_range": None + "v6_subnet_target_mask": None + } + } + } + + Above, we validate that all mandatory parameters are set, only + if the value of underlay_is_v6 is True. + + Set "value:" above to "__any__" if the dependent parameters are + mandatory regardless of the parameter's value. For example, if + we wanted to verify that underlay_is_v6 is set to True in the case + that anycast_lb_id is set (which can be a value between 1-1023) we + don't care what the value of anycast_lb_id is. We only care that + underlay_is_v6 is set to True. In this case, we could add the following: + + self._mandatory_params.update = { + "anycast_lb_id": { + "value": "__any__", + "mandatory": { + "underlay_is_v6": True + } + } + } + + """ + self._mandatory_params = {} + self._mandatory_params.update( + { + "anycast_lb_id": { + "value": "__any__", + "mandatory": {"underlay_is_v6": True}, + } + } + ) + self._mandatory_params.update( + { + "underlay_is_v6": { + "value": True, + "mandatory": { + "anycast_lb_id": None, + "loopback0_ipv6_range": None, + "loopback1_ipv6_range": None, + "router_id_range": None, + "v6_subnet_range": None, + "v6_subnet_target_mask": None, + }, + } + } + ) + self._mandatory_params.update( + { + "auto_symmetric_default_vrf": { + "value": True, + "mandatory": { + "vrf_lite_autoconfig": "Back2Back&ToExternal", + "auto_vrflite_ifc_default_vrf": True, + }, + } + } + ) + self._mandatory_params.update( + { + "auto_symmetric_vrf_lite": { + "value": True, + "mandatory": {"vrf_lite_autoconfig": "Back2Back&ToExternal"}, + } + } + ) + self._mandatory_params.update( + { + "auto_vrflite_ifc_default_vrf": { + "value": True, + "mandatory": { + "vrf_lite_autoconfig": "Back2Back&ToExternal", + "default_vrf_redis_bgp_rmap": None, + }, + } + } + ) + + def _build_parameter_aliases(self): + """ + Caller self._validate_dependencies() + + For some parameters, like vrf_lite_autoconfig, we don't + want the user to have to remember the spelling for + their values e.g. Back2Back&ToExternal. So, we alias + the value NDFC expects (Back2Back&ToExternal) to something + easier. In this case, 1. + + See also: _get_parameter_alias() + """ + self._parameter_aliases = {} + self._parameter_aliases["vrf_lite_autoconfig"] = { + "Back2Back&ToExternal": 1, + "Manual": 0, + } + + def _get_parameter_alias(self, param, value): + """ + Caller: self._validate_dependencies() + + Accessor method for self._parameter_aliases + + param: the parameter + value: the parameter's value that NDFC expects + + Return the value alias for param (i.e. param's value + prior to translation, i.e. the value that's used in the + playbook) if it exists. + + Return None otherwise + + See also: self._build_parameter_aliases() + """ + if param not in self._parameter_aliases: + return None + if value not in self._parameter_aliases[param]: + return None + return self._parameter_aliases[param][value] + + def _build_failed_dependencies(self): + """ + If the user has set one or more parameters that, in turn, cause + other parameters to become mandatory, build a dictionary of these + dependencies and what value is expected for each. + + Example self._failed_dependencies. In this case, the user set + auto_symmetric_vrf_lite to True, which makes vrf_lite_autoconfig + mandatory. Too, vrf_lite_autoconfig MUST have a value of + Back2Back&ToExternal. Though, in the playbook, the sets + vrf_lite_autoconfig to 1, since 1 is an alias for + Back2Back&ToExternal. See self._handle_failed_dependencies() + for how we handle aliased parameters. + + { + 'vrf_lite_autoconfig': 'Back2Back&ToExternal' + } + """ + if not self._requires_validation: + return + self._failed_dependencies = {} + for user_param in self._requires_validation: + # mandatory_params associated with user_param + mandatory_params = self._mandatory_params[user_param]["mandatory"] + for check_param in mandatory_params: + check_value = mandatory_params[check_param] + if check_param not in self.config and check_value is not None: + # The playbook doesn't contain this mandatory parameter. + # We care what the value is (since it's not None). + # If the mandatory parameter's default value is not equal + # to the required value, add it to the failed dependencies. + param_up = check_param.upper() + if param_up in self._default_nv_pairs: + if self._default_nv_pairs[param_up] != check_value: + self._failed_dependencies[check_param] = check_value + continue + if self.config[check_param] != check_value and check_value is not None: + # The playbook does contain this mandatory parameter, but + # the value in the playbook does not match the required value + # and we care about what the required value is. + self._failed_dependencies[check_param] = check_value + continue + print(f"self._failed_dependencies {self._failed_dependencies}") + + def _validate_dependencies(self): + """ + Validate cross-parameter dependencies. + + Caller: self._validate_config_for_merged_state() + + On failure to validate cross-parameter dependencies: + set self.result to False + set self.msg to an appropriate error message + + See also: docstring for self._build_mandatory_params() + """ + self._build_mandatory_params() + self._build_parameter_aliases() + self._requires_validation = set() + for user_param in self.config: + # param doesn't have any dependent parameters + if user_param not in self._mandatory_params: + continue + # need to run validation for user_param with value "__any__" + if self._mandatory_params[user_param]["value"] == "__any__": + self._requires_validation.add(user_param) + # need to run validation because user_param is a specific value + if self.config[user_param] == self._mandatory_params[user_param]["value"]: + self._requires_validation.add(user_param) + if not self._requires_validation: + return + self._build_failed_dependencies() + self._handle_failed_dependencies() + + def _handle_failed_dependencies(self): + """ + If there are failed dependencies: + 1. Set self.result to False + 2. Build a useful message for the user that lists + the additional parameters that NDFC expects + """ + if not self._failed_dependencies: + return + for user_param in self._requires_validation: + if self._mandatory_params[user_param]["value"] == "any": + msg = f"When {user_param} is set, " + else: + msg = f"When {user_param} is set to " + msg += f"{self._mandatory_params[user_param]['value']}, " + msg += "the following parameters are mandatory: " + + for key, value in self._failed_dependencies.items(): + msg += f"parameter {key} " + if value is None: + msg += "value " + else: + # If the value expected in the playbook is different + # from the value sent to NDFC, use the value expected in + # the playbook so as not to confuse the user. + alias = self._get_parameter_alias(key, value) + if alias is None: + msg_value = value + else: + msg_value = alias + msg += f"value {msg_value}" + self._append_msg(msg) + self.result = False + + def _build_payload(self): + """ + Build the payload to create the fabric specified self.config + Caller: _validate_dependencies + """ + self.payload = self._default_fabric_params + self.payload["fabricName"] = self.config["fabric_name"] + self.payload["asn"] = self.config["bgp_as"] + self.payload["nvPairs"] = self._default_nv_pairs + self._translate_to_ndfc_nv_pairs(self.config) + for key, value in self._translated_nv_pairs.items(): + self.payload["nvPairs"][key] = value + + @property + def config(self): + """ + Basic initial validatation for individual fabric configuration + Verifies that config is a dict() and that mandatory keys are + present. + """ + return self.properties["config"] + + @config.setter + def config(self, param): + if not self._validate_config(param): + return + self.properties["config"] = param + + @property + def msg(self): + """ + messages to return to the caller + """ + return self.properties["msg"] + + @msg.setter + def msg(self, param): + self.properties["msg"] = param + + @property + def payload(self): + """ + The payload to send to NDFC + """ + return self.properties["payload"] + + @payload.setter + def payload(self, param): + self.properties["payload"] = param + + @property + def result(self): + """ + get/set intermediate results and final result + """ + return self.properties["result"] + + @result.setter + def result(self, param): + self.properties["result"] = param + + @property + def state(self): + """ + The Ansible state provided by the caller + """ + return self.properties["state"] + + @state.setter + def state(self, param): + if param not in self._valid_states: + msg = f"invalid state {param}. " + msg += f"expected one of: {','.join(sorted(self._valid_states))}" + self.result = False + self._append_msg(msg) + self.properties["state"] = param diff --git a/plugins/modules/dcnm_fabric_vxlan.py b/plugins/modules/dcnm_fabric_vxlan.py index ed0323029..6f518b3bb 100644 --- a/plugins/modules/dcnm_fabric_vxlan.py +++ b/plugins/modules/dcnm_fabric_vxlan.py @@ -35,7 +35,7 @@ #get_fabric_inventory_details, validate_list_of_dicts, ) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric import ( +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import ( VerifyFabricParams, ) @@ -256,9 +256,9 @@ def __init__(self, module): self.check_mode = False self.validated = [] - self.have_create = [] - self.want_create = [] - self.diff_create = [] + self.have = [] + self.want = [] + self.need = [] self.diff_save = {} self.query = [] self.result = dict(changed=False, diff=[], response=[]) @@ -267,7 +267,7 @@ def __init__(self, module): self.controller_version = dcnm_version_supported(self.module) self.nd = self.controller_version >= 12 - # TODO:4 Will need to revisit bgp_as at some point since the + # TODO:4 Revisit bgp_as at some point since the # following fabric types don't require it. # - Fabric Group # - Classis LAN @@ -320,7 +320,7 @@ def get_want(self): """ Caller: main() - Update self.want_create for all fabrics defined in the playbook + Update self.want for all fabrics defined in the playbook """ want_create = [] @@ -333,25 +333,26 @@ def get_want(self): want_create.append(fabric_config) if not want_create: return - self.want_create = want_create + self.want = want_create def get_diff_merge(self): """ Caller: main() - Populates self.diff_create list() with items from our want list - that are not in our have list. These items will be sent to NDFC. + Populates self.need list() with items from our want list + that are not in our have list. These items will be sent to + the controller. """ - diff_create = [] + need = [] - for want_c in self.want_create: + for want in self.want: found = False - for have_c in self.have_create: - if want_c["fabric_name"] == have_c["fabric_name"]: + for have in self.have: + if want["fabric_name"] == have["fabric_name"]: found = True if not found: - diff_create.append(want_c) - self.diff_create = diff_create + need.append(want) + self.need = need @staticmethod def _build_params_spec_for_merged_state(): @@ -373,7 +374,7 @@ def _build_params_spec_for_merged_state(): params_spec.update( advertise_pip_bgp=dict(required=False, type="bool", default=False) ) - # TODO:6 agent_intf (add if needed) + # TODO:6 agent_intf (add if required) params_spec.update( anycast_bgw_advertise_pip=dict(required=False, type="bool", default=False) ) @@ -487,7 +488,7 @@ def create_fabrics(self): if self.nd: path = self.nd_prefix + path - for item in self.want_create: + for item in self.want: fabric = item["fabric_name"] payload = self.payloads[fabric] @@ -670,7 +671,7 @@ def main(): if ansible_module.params["state"] == "merged": task.get_diff_merge() - if task.diff_create: + if task.need: task.result["changed"] = True else: ansible_module.exit_json(**task.result) @@ -679,7 +680,7 @@ def main(): task.result["changed"] = False ansible_module.exit_json(**task.result) - if task.diff_create: + if task.need: task.create_fabrics() ansible_module.exit_json(**task.result) From 0fc269881425094bd4806181d0324a0602af3b5a Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Sun, 3 Mar 2024 16:20:52 -1000 Subject: [PATCH 003/228] Initial cleanup (much more work to do) --- plugins/module_utils/fabric/endpoints.py | 151 +++++++++ plugins/module_utils/fabric/fabric.py | 5 +- .../module_utils/fabric/fabric_task_result.py | 221 +++++++++++++ .../fabric/vxlan/verify_fabric_params.py | 30 +- plugins/modules/dcnm_fabric_vxlan.py | 307 +++++++++--------- 5 files changed, 542 insertions(+), 172 deletions(-) create mode 100644 plugins/module_utils/fabric/endpoints.py create mode 100644 plugins/module_utils/fabric/fabric_task_result.py diff --git a/plugins/module_utils/fabric/endpoints.py b/plugins/module_utils/fabric/endpoints.py new file mode 100644 index 000000000..9871d7017 --- /dev/null +++ b/plugins/module_utils/fabric/endpoints.py @@ -0,0 +1,151 @@ +# 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 + + +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) + + """ + + def __init__(self): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED ApiEndpoints()") + + self.endpoint_api_v1 = "/appcenter/cisco/ndfc/api/v1" + + self.endpoint_fabrics = f"{self.endpoint_api_v1}" + self.endpoint_fabrics += "/rest/control/fabrics" + + self._build_properties() + + def _build_properties(self): + self.properties = {} + self.properties["fabric_name"] = None + self.properties["template_name"] = None + + @property + def fabric_create(self): + """ + return fabric_create endpoint + verb: POST + path: /rest/control/fabrics + """ + method_name = inspect.stack()[0][3] + 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" + self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + return endpoint + + @property + def fabrics(self): + """ + return fabrics endpoint + verb: GET + path: /rest/control/fabrics + """ + method_name = inspect.stack()[0][3] + endpoint = {} + endpoint["path"] = self.endpoint_fabrics + endpoint["verb"] = "GET" + self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + return endpoint + + @property + def fabric_info(self): + """ + return fabric_infoendpoint + verb: GET + path: /rest/control/fabrics/{fabricName} + """ + method_name = inspect.stack()[0][3] + 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" + self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + return endpoint + + @property + def fabric_name(self): + return self.properties["fabric_name"] + + @fabric_name.setter + def fabric_name(self, value): + self.properties["fabric_name"] = value + + @property + def template_name(self): + return self.properties["template_name"] + + @template_name.setter + def template_name(self, value): + self.properties["template_name"] = value + + @property + def template_get(self): + """ + return get template contents endpoint + verb: GET + path: /appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates/{templateName} + """ + method_name = inspect.stack()[0][3] + if not self.template_name: + msg = f"{self.class_name}.{method_name}: " + msg += "template_name is required." + raise ValueError(msg) + path = self.endpoint_api_v1 + path += f"/configtemplate/rest/config/templates/{self.template_name}" + endpoint = {} + endpoint["path"] = path + endpoint["verb"] = "GET" + self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + return endpoint diff --git a/plugins/module_utils/fabric/fabric.py b/plugins/module_utils/fabric/fabric.py index 69e652470..278f9a7a5 100644 --- a/plugins/module_utils/fabric/fabric.py +++ b/plugins/module_utils/fabric/fabric.py @@ -77,7 +77,7 @@ def translate_vrf_lite_autoconfig(value): class VerifyFabricParams: """ - Parameter validation for NDFC Easy_Fabric (Data Center VXLAN EVPN) + Parameter validation for NDFC Data Center VXLAN EVPN """ def __init__(self): @@ -243,7 +243,8 @@ def _build_default_nv_pairs(self): self._default_nv_pairs["ANYCAST_BGW_ADVERTISE_PIP"] = False self._default_nv_pairs["ANYCAST_GW_MAC"] = "2020.0000.00aa" self._default_nv_pairs["ANYCAST_LB_ID"] = "" - self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" + # self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" + self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "" self._default_nv_pairs["ANYCAST_RP_IP_RANGE_INTERNAL"] = "" self._default_nv_pairs["AUTO_SYMMETRIC_DEFAULT_VRF"] = False self._default_nv_pairs["AUTO_SYMMETRIC_VRF_LITE"] = False diff --git a/plugins/module_utils/fabric/fabric_task_result.py b/plugins/module_utils/fabric/fabric_task_result.py new file mode 100644 index 000000000..3584a6cbf --- /dev/null +++ b/plugins/module_utils/fabric/fabric_task_result.py @@ -0,0 +1,221 @@ +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import inspect +import logging + + +class FabricTaskResult: + """ + Storage for FabricTask result + + Usage: + + NOTES: + 1. Assumes deleted and merged are class instances with diff properties + that return the diff for the deleted and merged states. + 2. diff must be a dict() + 3. result.deleted, etc do not overwrite the existing value. They append + to it. So, for example: + result.deleted = {"foo": "bar"} + result.deleted = {"baz": "qux"} + print(result.deleted) + Output: [{"foo": "bar"}, {"baz": "qux"}] + 4. result.response is a list of dicts. Each dict represents a response + from the controller. + + result = Result(ansible_module) + result.deleted = deleted.diff # Appends to deleted-state changes + result.merged = merged.diff # Appends to merged-state changes + # If a class doesn't have a diff property, then just append the dict + # that represents the changes for a given state. + result.overridden = {"foo": "bar"} + etc for other states + # If you want to append a response from the controller, then do this: + result.response = response_from_controller + + print(result.result) + + # output of the above print() will be a dict with the following structure: + { + "changed": True, # or False + "diff": { + "deleted": [], + "merged": [], + "overridden": [], + "query": [], + "replaced": [] + } + "response": { + "deleted": [], + "merged": [], + "overridden": [], + "query": [], + "replaced": [] + } + } + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricTaskResult()") + + self.states = ["merged", "query"] + + self.diff_properties = {} + self.diff_properties["diff_merged"] = "merged" + self.diff_properties["diff_query"] = "query" + self.response_properties = {} + self.response_properties["response_merged"] = "merged" + self.response_properties["response_query"] = "query" + + self._build_properties() + + def _build_properties(self): + """ + Build the properties dict() with default values + """ + self.properties = {} + self.properties["diff_merged"] = [] + self.properties["diff_query"] = [] + + self.properties["response_merged"] = [] + self.properties["response_query"] = [] + + def did_anything_change(self): + """ + return True if diffs have been appended to any of the diff lists. + """ + for key in self.diff_properties: + # skip query state diffs + if key == "diff_query": + continue + if len(self.properties[key]) != 0: + return True + return False + + def _verify_is_dict(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "value must be a dict. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}" + self.ansible_module.fail_json(msg, **self.failed_result) + + @property + def failed_result(self): + """ + return a result for a failed task with no changes + """ + result = {} + result["changed"] = False + result["failed"] = True + result["diff"] = {} + result["response"] = {} + for key in self.diff_properties: + result["diff"][key] = [] + for key in self.response_properties: + result["response"][key] = [] + return result + + @property + def module_result(self): + """ + return a result that AnsibleModule can use + """ + result = {} + result["changed"] = self.did_anything_change() + result["diff"] = {} + result["response"] = {} + for key, diff_key in self.diff_properties.items(): + result["diff"][diff_key] = self.properties[key] + for key, response_key in self.response_properties.items(): + result["response"][response_key] = self.properties[key] + return result + + # diff properties + @property + def diff_merged(self): + """ + Getter for diff_merged property + + This is used for merged state i.e. create image policies + """ + return self.properties["diff_merged"] + + @diff_merged.setter + def diff_merged(self, value): + """ + Setter for diff_merged property + """ + self._verify_is_dict(value) + self.properties["diff_merged"].append(value) + + @property + def diff_query(self): + """ + Getter for diff_query property + + There should never be a diff for query state. + """ + return self.properties["diff_query"] + + @diff_query.setter + def diff_query(self, value): + """ + Setter for diff_query property + """ + self._verify_is_dict(value) + self.properties["diff_query"].append(value) + + # response properties + @property + def response_merged(self): + """ + Getter for response_merged property + """ + return self.properties["response_merged"] + + @response_merged.setter + def response_merged(self, value): + """ + Setter for response_merged property + """ + self._verify_is_dict(value) + self.properties["response_merged"].append(value) + + @property + def response_query(self): + """ + Getter for response_query property + """ + return self.properties["response_query"] + + @response_query.setter + def response_query(self, value): + """ + Setter for response_query property + """ + self._verify_is_dict(value) + self.properties["response_query"].append(value) diff --git a/plugins/module_utils/fabric/vxlan/verify_fabric_params.py b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py index f60182885..3c83ba326 100644 --- a/plugins/module_utils/fabric/vxlan/verify_fabric_params.py +++ b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py @@ -43,6 +43,7 @@ sys.exit(1) print(f"result {verify.result}, {verify.msg}, payload {verify.payload}") """ +import inspect import re @@ -140,13 +141,17 @@ def _validate_config(self, config): return False """ if not isinstance(config, dict): - msg = "error: config must be a dictionary" + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "expected a dict for config. " + msg += f"Got {type(config)}." self.result = False self._append_msg(msg) return False if not self._mandatory_keys.issubset(config): missing_keys = self._mandatory_keys.difference(config.keys()) - msg = f"error: missing mandatory keys {','.join(sorted(missing_keys))}." + msg = f"{self.class_name}.{method_name}: " + msg = f"missing mandatory keys {','.join(sorted(missing_keys))}." self.result = False self._append_msg(msg) return False @@ -243,8 +248,9 @@ def _build_default_nv_pairs(self): self._default_nv_pairs["ANYCAST_BGW_ADVERTISE_PIP"] = False self._default_nv_pairs["ANYCAST_GW_MAC"] = "2020.0000.00aa" self._default_nv_pairs["ANYCAST_LB_ID"] = "" - self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" - self._default_nv_pairs["ANYCAST_RP_IP_RANGE_INTERNAL"] = "" + # self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" + # self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "" + # self._default_nv_pairs["ANYCAST_RP_IP_RANGE_INTERNAL"] = "" self._default_nv_pairs["AUTO_SYMMETRIC_DEFAULT_VRF"] = False self._default_nv_pairs["AUTO_SYMMETRIC_VRF_LITE"] = False self._default_nv_pairs["AUTO_VRFLITE_IFC_DEFAULT_VRF"] = False @@ -339,8 +345,9 @@ def _build_default_nv_pairs(self): self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_NAME"] = "" self._default_nv_pairs["ISIS_LEVEL"] = "" self._default_nv_pairs["ISIS_OVERLOAD_ELAPSE_TIME"] = "" - self._default_nv_pairs["ISIS_OVERLOAD_ENABLE"] = False - self._default_nv_pairs["ISIS_P2P_ENABLE"] = False + self._default_nv_pairs["ISIS_OVERLOAD_ENABLE"] = "" + # self._default_nv_pairs["ISIS_P2P_ENABLE"] = False + self._default_nv_pairs["ISIS_P2P_ENABLE"] = "" self._default_nv_pairs["L2_HOST_INTF_MTU"] = "9216" self._default_nv_pairs["L2_HOST_INTF_MTU_PREV"] = "9216" self._default_nv_pairs["L2_SEGMENT_ID_RANGE"] = "30000-49000" @@ -432,7 +439,7 @@ def _build_default_nv_pairs(self): self._default_nv_pairs["UNNUM_DHCP_END_INTERNAL"] = "" self._default_nv_pairs["UNNUM_DHCP_START"] = "" self._default_nv_pairs["UNNUM_DHCP_START_INTERNAL"] = "" - self._default_nv_pairs["USE_LINK_LOCAL"] = False + self._default_nv_pairs["USE_LINK_LOCAL"] = "" self._default_nv_pairs["V6_SUBNET_RANGE"] = "" self._default_nv_pairs["V6_SUBNET_TARGET_MASK"] = "" self._default_nv_pairs["VPC_AUTO_RECOVERY_TIME"] = "360" @@ -630,7 +637,7 @@ def _build_mandatory_params(self): Hence, we have the following structure for the self._mandatory_params dictionary, to handle the case where - underlay_is_v6 is set to True. Below, we don't case what the + underlay_is_v6 is set to True. Below, we don't care what the value for any of the mandatory parameters is. We only care that they are set. @@ -877,13 +884,10 @@ def _build_payload(self): Build the payload to create the fabric specified self.config Caller: _validate_dependencies """ - self.payload = self._default_fabric_params - self.payload["fabricName"] = self.config["fabric_name"] - self.payload["asn"] = self.config["bgp_as"] - self.payload["nvPairs"] = self._default_nv_pairs + self.payload = {} self._translate_to_ndfc_nv_pairs(self.config) for key, value in self._translated_nv_pairs.items(): - self.payload["nvPairs"][key] = value + self.payload[key] = value @property def config(self): diff --git a/plugins/modules/dcnm_fabric_vxlan.py b/plugins/modules/dcnm_fabric_vxlan.py index 6f518b3bb..e05045bff 100644 --- a/plugins/modules/dcnm_fabric_vxlan.py +++ b/plugins/modules/dcnm_fabric_vxlan.py @@ -30,14 +30,17 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import ( dcnm_send, - dcnm_version_supported, - get_fabric_details, - #get_fabric_inventory_details, validate_list_of_dicts, ) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import ( + ApiEndpoints +) from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import ( VerifyFabricParams, ) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import ( + FabricTaskResult +) __metaclass__ = type __author__ = "Allen Robel" @@ -229,51 +232,39 @@ class FabricVxlanTask: Ansible support for Data Center VXLAN EVPN """ - def __init__(self, module): + def __init__(self, ansible_module): self.class_name = self.__class__.__name__ method_name = inspect.stack()[0][3] self.log = logging.getLogger(f"dcnm.{self.class_name}") self.log.debug("ENTERED FabricVxlanTask()") - self.module = module - self.params = module.params + self.endpoints = ApiEndpoints() + + self._implemented_states = set() + self._implemented_states.add("merged") + + self.ansible_module = ansible_module + self.params = ansible_module.params self.verify = VerifyFabricParams() # populated in self.validate_input() self.payloads = {} - # TODO:1 set self.debug to False to disable self.log_msg() - self.debug = True - # File descriptor set by self.log_msg() - self.fd = None - # File self.log_msg() logs to - self.logfile = "/tmp/dcnm_fabric_vxlan.log" - - self.config = module.params.get("config") + + self.config = ansible_module.params.get("config") if not isinstance(self.config, list): msg = "expected list type for self.config. " msg = f"got {type(self.config).__name__}" - self.module.fail_json(msg=msg) + self.ansible_module.fail_json(msg=msg) self.check_mode = False self.validated = [] - self.have = [] + self.have = {} self.want = [] self.need = [] - self.diff_save = {} self.query = [] - self.result = dict(changed=False, diff=[], response=[]) - - self.nd_prefix = "/appcenter/cisco/ndfc/api/v1/lan-fabric" - self.controller_version = dcnm_version_supported(self.module) - self.nd = self.controller_version >= 12 - - # TODO:4 Revisit bgp_as at some point since the - # following fabric types don't require it. - # - Fabric Group - # - Classis LAN - # - LAN Monitor - # - VXLAN EVPN Multi-Site - # We'll cross that bridge when we get to it + + self.task_result = dict(changed=False, diff=[], response=[]) + self.mandatory_keys = {"fabric_name", "bgp_as"} # Populated in self.get_have() self.fabric_details = {} @@ -281,40 +272,46 @@ def __init__(self, module): self.inventory_data = {} for item in self.config: if not self.mandatory_keys.issubset(item): - msg = f"missing mandatory keys in {item}. " + msg = f"{self.class_name}.{method_name}: " + msg += f"missing mandatory keys in {item}. " msg += f"expected {self.mandatory_keys}" - self.module.fail_json(msg=msg) + self.ansible_module.fail_json(msg=msg) def get_have(self): """ Caller: main() - Determine current fabric state on NDFC for all existing fabrics + Build self.have, which is a dict containing the current controller + fabrics and their details. + + Have is a dict, keyed on fabric_name, where each element is a dict + with the following structure: + + { + "fabric_name": "fabric_name", + "fabric_config": { + "fabricName": "fabric_name", + "fabricType": "VXLAN EVPN", + etc... + } + } """ - for item in self.config: - # mandatory keys have already been checked in __init__() - fabric = item["fabric_name"] - self.fabric_details[fabric] = get_fabric_details(self.module, fabric) - # self.inventory_data[fabric] = get_fabric_inventory_details( - # self.module, fabric - # ) - - fabrics_exist = set() - for fabric in self.fabric_details: - path = f"/rest/control/fabrics/{fabric}" - if self.nd: - path = self.nd_prefix + path - fabric_info = dcnm_send(self.module, "GET", path) - result = self._handle_get_response(fabric_info) - if result["found"]: - fabrics_exist.add(fabric) - if not result["success"]: - msg = "Unable to retrieve fabric information from NDFC" - self.module.fail_json(msg=msg) - if fabrics_exist: - msg = "Fabric(s) already present on NDFC: " - msg += f"{','.join(sorted(fabrics_exist))}" - self.module.fail_json(msg=msg) + method_name = inspect.stack()[0][3] + endpoint = self.endpoints.fabrics + path = endpoint["path"] + verb = endpoint["verb"] + response = dcnm_send(self.ansible_module, verb, path) + result = self._handle_get_response(response) + if not result["success"]: + msg = f"{self.class_name}.{method_name}: " + msg += "Unable to retrieve fabric information from " + msg += "the controller." + self.ansible_module.fail_json(msg=msg) + self.have = {} + for item in response["DATA"]: + self.have[item["fabricName"]] = {} + self.have[item["fabricName"]]["fabric_name"] = item["fabricName"] + self.have[item["fabricName"]]["fabric_config"] = item def get_want(self): """ @@ -322,37 +319,91 @@ def get_want(self): Update self.want for all fabrics defined in the playbook """ - want_create = [] - - # we don't want to use self.validated here since - # validate_list_of_dicts() adds items the user did not set to - # self.validated. self.validate_input() has already been called, - # so if we got this far the items in self.config have been validated - # to conform to their param spec. + self.want = [] for fabric_config in self.config: - want_create.append(fabric_config) - if not want_create: - return - self.want = want_create + self.want.append(copy.deepcopy(fabric_config)) + + def get_need_for_merged_state(self): + """ + Caller: handle_merged_state() - def get_diff_merge(self): + Build self.need for state merged + """ + method_name = inspect.stack()[0][3] + need: List[Dict[str, Any]] = [] + for want in self.want: + msg = f"{self.class_name}.{method_name}: " + msg += f"want: {json.dumps(want, indent=4, sort_keys=True)}" + self.log.debug(msg) + if want["fabric_name"] in self.have: + continue + need.append(want) + self.need = copy.deepcopy(need) + + def handle_merged_state(self): """ Caller: main() - Populates self.need list() with items from our want list - that are not in our have list. These items will be sent to - the controller. + Handle the merged state """ - need = [] + method_name = inspect.stack()[0][3] + self.log.debug(f"{self.class_name}.{method_name}: entered") - for want in self.want: - found = False - for have in self.have: - if want["fabric_name"] == have["fabric_name"]: - found = True - if not found: - need.append(want) - self.need = need + self.get_need_for_merged_state() + if self.ansible_module.check_mode: + self.task_result["changed"] = False + self.task_result["success"] = True + self.task_result["diff"] = [] + self.ansible_module.exit_json(**self.task_result) + self.send_need() + + def send_need(self): + """ + Caller: handle_merged_state() + + Build and send the payload to create the + fabrics specified in the playbook. + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"need: {json.dumps(self.need, indent=4, sort_keys=True)}" + self.log.debug(msg) + if len(self.need) == 0: + self.task_result["changed"] = False + self.task_result["success"] = True + self.task_result["diff"] = [] + self.ansible_module.exit_json(**self.task_result) + for item in self.need: + fabric_name = item["fabric_name"] + self.endpoints.fabric_name = fabric_name + self.endpoints.template_name = "Easy_Fabric" + + try: + endpoint = self.endpoints.fabric_create + except ValueError as error: + self.ansible_module.fail_json(error) + + path = endpoint["path"] + verb = endpoint["verb"] + + payload = self.payloads[fabric_name] + msg = f"{self.class_name}.{method_name}: " + msg += f"verb: {verb}, path: {path}" + self.log.debug(msg) + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name: {fabric_name}, payload: {json.dumps(payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + response = dcnm_send(self.ansible_module, verb, path, data=json.dumps(payload)) + result = self._handle_post_put_response(response, verb) + self.log.debug(f"response]: {json.dumps(response, indent=4, sort_keys=True)}") + self.log.debug(f"result: {json.dumps(result, indent=4, sort_keys=True)}") + self.task_result["changed"] = result["changed"] + + if not result["success"]: + msg = f"{self.class_name}.{method_name}: " + msg += f"failed to create fabric {fabric_name}. " + msg += f"response: {response}" + self._failure(response) @staticmethod def _build_params_spec_for_merged_state(): @@ -386,11 +437,6 @@ def _build_params_spec_for_merged_state(): required=False, type="int", range_min=0, range_max=1023, default="" ) ) - params_spec.update( - anycast_rp_ip_range=dict( - required=False, type="ipv4_subnet", default="10.254.254.0/24" - ) - ) params_spec.update( auto_symmetric_default_vrf=dict(required=False, type="bool", default=False) ) @@ -434,9 +480,10 @@ def validate_input(self): state = self.params["state"] # TODO:2 remove this when we implement query state - if state != "merged": - msg = f"Only merged state is supported. Got state {state}" - self.module.fail_json(msg=msg) + if state not in self._implemented_states: + msg = f"Got state {state}. " + msg += f"Expected one of: {','.join(sorted(self._implemented_states))}" + self.ansible_module.fail_json(msg=msg) if state == "merged": self._validate_input_for_merged_state() @@ -446,11 +493,9 @@ def validate_input(self): verify = VerifyFabricParams() verify.state = state verify.config = fabric_config - if verify.result is False: - self.module.fail_json(msg=verify.msg) verify.validate_config() if verify.result is False: - self.module.fail_json(msg=verify.msg) + self.ansible_module.fail_json(msg=verify.msg) self.payloads[fabric_config["fabric_name"]] = verify.payload def _validate_input_for_merged_state(self): @@ -463,10 +508,10 @@ def _validate_input_for_merged_state(self): msg = None if not self.config: msg = "config: element is mandatory for state merged" - self.module.fail_json(msg=msg) + self.ansible_module.fail_json(msg=msg) valid_params, invalid_params = validate_list_of_dicts( - self.config, params_spec, self.module + self.config, params_spec, self.ansible_module ) # We're not using self.validated. Keeping this to avoid # linter error due to non-use of valid_params @@ -475,31 +520,7 @@ def _validate_input_for_merged_state(self): if invalid_params: msg = "Invalid parameters in playbook: " msg += f"{','.join(invalid_params)}" - self.module.fail_json(msg=msg) - - def create_fabrics(self): - """ - Caller: main() - - Build and send the payload to create the - fabrics specified in the playbook. - """ - path = "/rest/control/fabrics" - if self.nd: - path = self.nd_prefix + path - - for item in self.want: - fabric = item["fabric_name"] - - payload = self.payloads[fabric] - response = dcnm_send(self.module, "POST", path, data=json.dumps(payload)) - result = self._handle_post_put_response(response, "POST") - - if not result["success"]: - self.log_msg( - f"create_fabrics: calling self._failure with response {response}" - ) - self._failure(response) + self.ansible_module.fail_json(msg=msg) def _handle_get_response(self, response): """ @@ -529,7 +550,7 @@ def _handle_get_response(self, response): # } result = {} success_return_codes = {200, 404} - self.log_msg(f"_handle_get_request: response {response}") + #self.log.debug(f"_handle_get_request: response {json.dumps(response, indent=4, sort_keys=True)}") if ( response.get("RETURN_CODE") == 404 and response.get("MESSAGE") == "Not Found" @@ -575,7 +596,7 @@ def _handle_post_put_response(self, response, verb): if verb not in valid_verbs: msg = f"invalid verb {verb}. " msg += f"expected one of: {','.join(sorted(valid_verbs))}" - self.module.fail_json(msg=msg) + self.ansible_module.fail_json(msg=msg) result = {} if response.get("MESSAGE") != "OK": @@ -607,7 +628,7 @@ def _failure(self, resp): the happy path: res = copy.deepcopy(resp) - self.module.fail_json(msg=res) + self.ansible_module.fail_json(msg=res) """ res = copy.deepcopy(resp) @@ -619,33 +640,15 @@ def _failure(self, resp): ) res.update({"DATA": data}) - self.module.fail_json(msg=res) - - def log_msg(self, msg): - """ - used for debugging. disable this when committing to main - """ - if self.debug is False: - return - if self.fd is None: - try: - self.fd = open(f"{self.logfile}", "a+", encoding="UTF-8") - except IOError as err: - msg = f"error opening logfile {self.logfile}. " - msg += f"detail: {err}" - self.module.fail_json(msg=msg) - - self.fd.write(msg) - self.fd.write("\n") - self.fd.flush() - + self.log.debug("HERE") + self.ansible_module.fail_json(msg=res) def main(): """main entry point for module execution""" element_spec = dict( config=dict(required=False, type="list", elements="dict"), - state=dict(default="merged", choices=["merged"]), + state=dict(default="merged", choices=["merged", "query"]), ) ansible_module = AnsibleModule(argument_spec=element_spec, supports_check_mode=True) @@ -668,22 +671,12 @@ def main(): task.get_have() task.get_want() - if ansible_module.params["state"] == "merged": - task.get_diff_merge() + task.log.debug(f"state: {ansible_module.params['state']}") - if task.need: - task.result["changed"] = True - else: - ansible_module.exit_json(**task.result) - - if ansible_module.check_mode: - task.result["changed"] = False - ansible_module.exit_json(**task.result) - - if task.need: - task.create_fabrics() + if ansible_module.params["state"] == "merged": + task.handle_merged_state() - ansible_module.exit_json(**task.result) + ansible_module.exit_json(**task.task_result) if __name__ == "__main__": From 9b988e477303198b43150da62153251d2d6655ee Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 6 Mar 2024 15:14:41 -1000 Subject: [PATCH 004/228] Refactor to leverage RestSend and improve modularity. This is a work in progress. merged state works, but it still needs a lot of cleaning up and support for additional fabric parameters. TODO: general cleanup query state unit tests integration tests --- plugins/module_utils/common | 1 + plugins/module_utils/fabric/common.py | 317 +++++++++ plugins/module_utils/fabric/endpoints.py | 4 + plugins/module_utils/fabric/fabric_create.py | 435 +++++++++++++ plugins/module_utils/fabric/fabric_details.py | 279 ++++++++ .../module_utils/fabric/fabric_task_result.py | 9 +- .../module_utils/fabric/vxlan/params_spec.py | 188 ++++++ plugins/modules/dcnm_fabric_vxlan.py | 601 ++++++++++-------- 8 files changed, 1560 insertions(+), 274 deletions(-) create mode 120000 plugins/module_utils/common create mode 100644 plugins/module_utils/fabric/common.py create mode 100644 plugins/module_utils/fabric/fabric_create.py create mode 100644 plugins/module_utils/fabric/fabric_details.py create mode 100644 plugins/module_utils/fabric/vxlan/params_spec.py diff --git a/plugins/module_utils/common b/plugins/module_utils/common new file mode 120000 index 000000000..79f5a1911 --- /dev/null +++ b/plugins/module_utils/common @@ -0,0 +1 @@ +/Users/arobel/repos/ansible_dev/dcnm_image_upgrade/ansible_collections/cisco/dcnm/plugins/module_utils/common \ No newline at end of file diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py new file mode 100644 index 000000000..a6a36adb3 --- /dev/null +++ b/plugins/module_utils/fabric/common.py @@ -0,0 +1,317 @@ +# 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 + +# Using only for its failed_result property +# pylint: disable=line-too-long +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import \ + FabricTaskResult + +# pylint: enable=line-too-long + + +class FabricCommon: + """ + Common methods used by the other classes supporting + dcnm_fabric_* modules + + Usage (where ansible_module is an instance of + AnsibleModule or MockAnsibleModule): + + class MyClass(FabricCommon): + def __init__(self, module): + super().__init__(module) + ... + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricCommon()") + + self.ansible_module = ansible_module + self.check_mode = self.ansible_module.check_mode + + self.params = ansible_module.params + + self.properties: Dict[str, Any] = {} + self.properties["changed"] = False + self.properties["diff"] = [] + self.properties["failed"] = False + self.properties["response"] = [] + self.properties["response_current"] = {} + self.properties["response_data"] = [] + self.properties["result"] = [] + self.properties["result_current"] = {} + + def _handle_response(self, response, verb): + """ + Call the appropriate handler for response based on verb + """ + if verb == "GET": + return self._handle_get_response(response) + if verb in {"POST", "PUT", "DELETE"}: + return self._handle_post_put_delete_response(response) + return self._handle_unknown_request_verbs(response, verb) + + def _handle_unknown_request_verbs(self, response, verb): + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"Unknown request verb ({verb}) for response {response}." + self.ansible_module.fail_json(msg) + + def _handle_get_response(self, response): + """ + Caller: + - self._handle_response() + Handle controller responses to GET requests + Returns: dict() with the following keys: + - found: + - False, if request error was "Not found" and RETURN_CODE == 404 + - True otherwise + - success: + - False if RETURN_CODE != 200 or MESSAGE != "OK" + - True otherwise + """ + result = {} + success_return_codes = {200, 404} + if ( + response.get("RETURN_CODE") == 404 + and response.get("MESSAGE") == "Not Found" + ): + result["found"] = False + result["success"] = True + return result + if ( + response.get("RETURN_CODE") not in success_return_codes + or response.get("MESSAGE") != "OK" + ): + result["found"] = False + result["success"] = False + return result + result["found"] = True + result["success"] = True + return result + + def _handle_post_put_delete_response(self, response): + """ + Caller: + - self.self._handle_response() + + Handle POST, PUT, DELETE responses from the controller. + + Returns: dict() with the following keys: + - changed: + - True if changes were made to by the controller + - ERROR key is not present + - MESSAGE == "OK" + - False otherwise + - success: + - False if MESSAGE != "OK" or ERROR key is present + - True otherwise + """ + result = {} + if response.get("ERROR") is not None: + result["success"] = False + result["changed"] = False + return result + if response.get("MESSAGE") != "OK" and response.get("MESSAGE") is not None: + result["success"] = False + result["changed"] = False + return result + result["success"] = True + result["changed"] = True + return result + + def make_boolean(self, value): + """ + Return value converted to boolean, if possible. + Return value, if value cannot be converted. + """ + if isinstance(value, bool): + return value + if isinstance(value, str): + if value.lower() in ["true", "yes"]: + return True + if value.lower() in ["false", "no"]: + return False + return value + + def make_none(self, value): + """ + Return None if value is an empty string, or a string + representation of a None type + Return value otherwise + """ + if value in ["", "none", "None", "NONE", "null", "Null", "NULL"]: + return None + return value + + @property + def changed(self): + """ + bool = whether we changed anything + """ + return self.properties["changed"] + + @changed.setter + def changed(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.changed must be a bool. Got {value}" + self.ansible_module.fail_json(msg) + self.properties["changed"] = value + + @property + def diff(self): + """ + List of dicts representing the changes made + """ + return self.properties["diff"] + + @diff.setter + def diff(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.diff must be a dict. Got {value}" + self.ansible_module.fail_json(msg) + self.properties["diff"].append(value) + + @property + def failed(self): + """ + bool = whether we failed or not + If True, this means we failed to make a change + If False, this means we succeeded in making a change + """ + return self.properties["failed"] + + @failed.setter + def failed(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.failed must be a bool. Got {value}" + self.ansible_module.fail_json(msg) + self.properties["failed"] = value + + @property + def failed_result(self): + """ + return a result for a failed task with no changes + """ + return FabricTaskResult(self.ansible_module).failed_result + + @property + def response_current(self): + """ + Return the current POST response from the controller + instance.commit() must be called first. + + This is a dict of the current response from the controller. + """ + return self.properties.get("response_current") + + @response_current.setter + def response_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.response_current must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["response_current"] = value + + @property + def response(self): + """ + Return the aggregated POST response from the controller + instance.commit() must be called first. + + This is a list of responses from the controller. + """ + return self.properties.get("response") + + @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 += "instance.response must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["response"].append(value) + + @property + def response_data(self): + """ + Return the contents of the DATA key within current_response. + """ + return self.properties.get("response_data") + + @response_data.setter + def response_data(self, value): + self.properties["response_data"].append(value) + + @property + def result(self): + """ + Return the aggregated result from the controller + instance.commit() must be called first. + + This is a list of results from the controller. + """ + return self.properties.get("result") + + @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 += "instance.result must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["result"].append(value) + + @property + def result_current(self): + """ + Return the current result from the controller + instance.commit() must be called first. + + This is a dict containing the current result. + """ + return self.properties.get("result_current") + + @result_current.setter + def result_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.result_current must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["result_current"] = value diff --git a/plugins/module_utils/fabric/endpoints.py b/plugins/module_utils/fabric/endpoints.py index 9871d7017..57fc6c31b 100644 --- a/plugins/module_utils/fabric/endpoints.py +++ b/plugins/module_utils/fabric/endpoints.py @@ -36,6 +36,10 @@ class ApiEndpoints: 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): diff --git a/plugins/module_utils/fabric/fabric_create.py b/plugins/module_utils/fabric/fabric_create.py new file mode 100644 index 000000000..3f34e5eb2 --- /dev/null +++ b/plugins/module_utils/fabric/fabric_create.py @@ -0,0 +1,435 @@ +# +# 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.endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ + FabricDetailsByName +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend + +class FabricCreateCommon(FabricCommon): + """ + Common methods and properties for: + - FabricCreate + - FabricCreateBulk + """ + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED ImagePolicyCreateCommon()" + self.log.debug(msg) + + self.fabric_details = FabricDetailsByName(self.ansible_module) + self.endpoints = ApiEndpoints() + + self.rest_send = RestSend(self.ansible_module) + + self.action = "create" + self._payloads_to_commit = [] + self.response_ok = [] + self.result_ok = [] + self.diff_ok = [] + self.response_nok = [] + self.result_nok = [] + self.diff_nok = [] + + self._mandatory_payload_keys = set() + self._mandatory_payload_keys.add("FABRIC_NAME") + self._mandatory_payload_keys.add("BGP_AS") + + def _verify_payload(self, payload): + """ + Verify that the payload is a dict and contains all mandatory keys + """ + method_name = inspect.stack()[0][3] + if not isinstance(payload, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "payload must be a dict. " + msg += f"Got type {type(payload).__name__}, " + msg += f"value {payload}" + self.ansible_module.fail_json(msg, **self.failed_result) + + missing_keys = [] + for key in self._mandatory_payload_keys: + if key not in payload: + missing_keys.append(key) + if len(missing_keys) == 0: + return + + msg = f"{self.class_name}.{method_name}: " + msg += "payload is missing mandatory keys: " + msg += f"{sorted(missing_keys)}" + self.ansible_module.fail_json(msg, **self.failed_result) + + def _build_payloads_to_commit(self): + """ + 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 image policy create API endpoint. + + Populates self._payloads_to_commit with a list of payloads + to commit. + """ + self.fabric_details.refresh() + + msg = "fabric_details.all_data: " + msg += f"{json.dumps(self.fabric_details.all_data, indent=4, sort_keys=True)}" + self.log.debug(msg) + + self._payloads_to_commit = [] + for payload in self.payloads: + msg = f"payload: {json.dumps(payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + if payload.get("FABRIC_NAME", None) in self.fabric_details.all_data: + continue + self._payloads_to_commit.append(copy.deepcopy(payload)) + + msg = "self._payloads_to_commit: " + msg += f"{json.dumps(self._payloads_to_commit, indent=4, sort_keys=True)}" + self.log.debug(msg) + + def _send_payloads(self): + if self.check_mode is True: + self._send_payloads_check_mode() + else: + self._send_payloads_normal_mode() + + def _send_payloads_check_mode(self): + """ + Simulate sending the payloads to the controller and populate the following lists: + + - self.response_ok : list of controller responses associated with success result + - self.result_ok : list of results where success is True + - self.diff_ok : list of payloads for which the request succeeded + - self.response_nok : list of controller responses associated with failed result + - self.result_nok : list of results where success is False + - self.diff_nok : list of payloads for which the request failed + """ + self.response_ok = [] + self.result_ok = [] + self.diff_ok = [] + self.response_nok = [] + self.result_nok = [] + self.diff_nok = [] + for payload in self._payloads_to_commit: + self.result_current = {"success": True} + self.response_current = {"msg": "skipped: check_mode"} + + if self.result_current["success"]: + self.response_ok.append(copy.deepcopy(self.response_current)) + self.result_ok.append(copy.deepcopy(self.result_current)) + self.diff_ok.append(copy.deepcopy(payload)) + else: + self.response_nok.append(copy.deepcopy(self.response_current)) + self.result_nok.append(copy.deepcopy(self.result_current)) + self.diff_nok.append(copy.deepcopy(payload)) + + msg = f"self.response_ok: {json.dumps(self.response_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.result_ok: {json.dumps(self.result_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.diff_ok: {json.dumps(self.diff_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.response_nok: {json.dumps(self.response_nok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = ( + f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" + ) + self.log.debug(msg) + + def _send_payloads_normal_mode(self): + """ + Send the payloads to the controller and populate the following lists: + + - self.response_ok : list of controller responses associated with success result + - self.result_ok : list of results where success is True + - self.diff_ok : list of payloads for which the request succeeded + - self.response_nok : list of controller responses associated with failed result + - self.result_nok : list of results where success is False + - self.diff_nok : list of payloads for which the request failed + """ + self.response_ok = [] + self.result_ok = [] + self.diff_ok = [] + self.response_nok = [] + self.result_nok = [] + self.diff_nok = [] + for payload in self._payloads_to_commit: + self.endpoints.fabric_name = payload.get("FABRIC_NAME") + self.endpoints.template_name = "Easy_Fabric" + self.path = self.endpoints.fabric_create.get("path") + self.verb = self.endpoints.fabric_create.get("verb") + 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"]: + self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_ok.append(copy.deepcopy(payload)) + else: + self.response_nok.append(copy.deepcopy(self.response_current)) + self.result_nok.append(copy.deepcopy(self.result_current)) + self.diff_nok.append(copy.deepcopy(payload)) + + msg = f"self.response_ok: {json.dumps(self.response_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.result_ok: {json.dumps(self.result_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.diff_ok: {json.dumps(self.diff_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.response_nok: {json.dumps(self.response_nok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = ( + f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" + ) + self.log.debug(msg) + + def _process_responses(self): + method_name = inspect.stack()[0][3] + + msg = f"len(self.result_ok): {len(self.result_ok)}, " + msg += f"len(self._payloads_to_commit): {len(self._payloads_to_commit)}" + self.log.debug(msg) + if len(self.result_ok) == len(self._payloads_to_commit): + self.changed = True + for diff in self.diff_ok: + diff["action"] = self.action + self.diff = copy.deepcopy(diff) + for result in self.result_ok: + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_ok: + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + return + + self.failed = True + self.changed = False + # at least one request succeeded, so set changed to True + if len(self.result_nok) != len(self._payloads_to_commit): + self.changed = True + + # When failing, provide the info for the request(s) that succeeded + # Since these represent the change(s) that were made. + for diff in self.diff_ok: + diff["action"] = self.action + self.diff = copy.deepcopy(diff) + for result in self.result_ok: + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_ok: + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + result = {} + result["failed"] = self.failed + result["changed"] = self.changed + result["diff"] = self.diff_ok + result["response"] = self.response_ok + result["result"] = self.result_ok + + msg = f"{self.class_name}.{method_name}: " + msg += "Bad response(s) during fabric create. " + msg += f"response(s): {self.response_nok}" + self.ansible_module.fail_json(msg, **result) + + @property + def payloads(self): + """ + Return the fabric create payloads + + Payloads must be a list of dict. Each dict is a + payload for the fabric create API endpoint. + """ + 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}" + self.ansible_module.fail_json(msg, **self.failed_result) + for item in value: + self._verify_payload(item) + self.properties["payloads"] = value + +class FabricCreateBulk(FabricCreateCommon): + def __init__(self, ansible_module): + super().__init__(ansible_module) + 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, + """ + method_name = inspect.stack()[0][3] + if self.payloads is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payloads must be set prior to calling commit." + self.ansible_module.fail_json(msg, **self.failed_result) + + self._build_payloads_to_commit() + if len(self._payloads_to_commit) == 0: + return + self._send_payloads() + self._process_responses() + +class FabricCreate(FabricCommon): + """ + Create a VXLAN fabric on the controller. + """ + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricCreate()") + + self.data = {} + self.endpoints = ApiEndpoints() + self.rest_send = RestSend(self.ansible_module) + + self._init_properties() + + def _init_properties(self): + # self.properties is already initialized in the parent class + self.properties["payload"] = None + + def commit(self): + """ + Send the fabric create request to the controller. + """ + method_name = inspect.stack()[0][3] + if self.payload is None: + msg = f"{self.class_name}.{method_name}: " + msg += "Exiting. Missing mandatory property: payload" + self.ansible_module.fail_json(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += "payload: " + msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + + if len(self.payload) == 0: + self.ansible_module.exit_json(**self.task_result.module_result) + + fabric_name = self.payload.get("FABRIC_NAME") + if fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payload is missing mandatory FABRIC_NAME key." + self.ansible_module.fail_json(msg) + + self.endpoints.fabric_name = fabric_name + self.endpoints.template_name = "Easy_Fabric" + try: + endpoint = self.endpoints.fabric_create + except ValueError as error: + self.ansible_module.fail_json(error) + + path = endpoint["path"] + verb = endpoint["verb"] + + msg = f"{self.class_name}.{method_name}: " + msg += f"verb: {verb}, path: {path}" + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name: {fabric_name}, " + msg += f"payload: {json.dumps(self.payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + + self.rest_send.path = path + self.rest_send.verb = verb + self.rest_send.payload = self.payload + self.rest_send.commit() + + self.result_current = self.rest_send.result_current + self.result = self.rest_send.result_current + self.response_current = self.rest_send.response_current + self.response = self.rest_send.response_current + + if self.response_current["RETURN_CODE"] == 200: + self.diff_merged = self.payload + + msg = "self.diff_merged: " + msg += f"{json.dumps(self.diff_merged, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.response_current: " + msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.response: " + msg += f"{json.dumps(self.response, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.result_current: " + msg += f"{json.dumps(self.result_current, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.result: " + msg += f"{json.dumps(self.result, indent=4, sort_keys=True)}" + self.log.debug(msg) + + + @property + def payload(self): + """ + Return a fabric create payload. + """ + return self.properties["payload"] + + @payload.setter + def payload(self, value): + self.properties["payload"] = value diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py new file mode 100644 index 000000000..c352f60ad --- /dev/null +++ b/plugins/module_utils/fabric/fabric_details.py @@ -0,0 +1,279 @@ +# +# 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 + +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend + +class FabricDetails(FabricCommon): + """ + Retrieve fabric details from the controller and provide + property accessors for the fabric attributes. + """ + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricDetails()") + + self.data = {} + self.endpoints = ApiEndpoints() + self.rest_send = RestSend(self.ansible_module) + + self._init_properties() + + def _init_properties(self): + # self.properties is already initialized in the parent class + self.properties["foo"] = "bar" + + def refresh_super(self): + """ + Refresh the fabric details from the controller. + """ + method_name = inspect.stack()[0][3] + endpoint = self.endpoints.fabrics + self.rest_send.path = endpoint.get("path") + self.rest_send.verb = endpoint.get("verb") + self.rest_send.commit() + self.data = {} + for item in self.rest_send.response_current["DATA"]: + self.data[item["fabricName"]] = item + msg = f"item: {json.dumps(item, indent=4, sort_keys=True)}" + self.log.debug(msg) + + 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. + """ + 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 + """ + return self._get("asn") + + @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 + """ + return self._get_nv_pair("ENABLE_PBR") + + @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 + """ + return self._get("fabricId") + + @property + def fabric_type(self): + """ + Return the fabricType of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - Switch_Fabric + - None + """ + return self._get("fabricType") + + @property + def replication_mode(self): + """ + Return the replicationMode of the fabric specified with filter, + if it exists. + Return None otherwise + + Type: string + Possible values: + - Ingress + - Multicast + - None + """ + return self._get("replicationMode") + + @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 + """ + return self._get("templateName") + +class FabricDetailsByName(FabricDetails): + """ + Retrieve fabric details from the controller and provide + property accessors for the fabric attributes. + + Usage (where module is an instance of AnsibleModule): + + instance = FabricDetailsByName(module) + instance.refresh() + instance.filter = "MyFabric" + bgp_as = instance.bgp_as + fabric_dict = instance.filtered_data + etc... + + See FabricDetails for more details. + """ + def __init__(self, ansible_module): + super().__init__(ansible_module) + 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 = {} + for item in self.response_current: + self.data_subclass[item["fabricName"]] = item + + msg = f"{self.class_name}.refresh(): self.data_subclass: " + msg += f"{json.dumps(self.data_subclass, indent=4, sort_keys=True)}" + self.log.debug(msg) + + def _get(self, item): + method_name = inspect.stack()[0][3] + + 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}." + self.ansible_module.fail_json(msg, **self.failed_result) + + if self.data_subclass.get(self.filter) is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.filter} does not exist on the controller." + self.ansible_module.fail_json(msg, **self.failed_result) + + 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}." + self.ansible_module.fail_json(msg, **self.failed_result) + + return self.make_none( + self.make_boolean(self.data_subclass[self.filter].get(item)) + ) + + def _get_nv_pair(self, item): + method_name = inspect.stack()[0][3] + + 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}." + self.ansible_module.fail_json(msg, **self.failed_result) + + 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." + self.ansible_module.fail_json(msg, **self.failed_result) + + 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}." + self.ansible_module.fail_json(msg, **self.failed_result) + + return self.make_none( + self.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. + """ + return self.data_subclass.get(self.filter) + + @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 diff --git a/plugins/module_utils/fabric/fabric_task_result.py b/plugins/module_utils/fabric/fabric_task_result.py index 3584a6cbf..05717c5d0 100644 --- a/plugins/module_utils/fabric/fabric_task_result.py +++ b/plugins/module_utils/fabric/fabric_task_result.py @@ -76,9 +76,13 @@ class FabricTaskResult: def __init__(self, ansible_module): self.class_name = self.__class__.__name__ self.ansible_module = ansible_module + self.check_mode = self.ansible_module.check_mode self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.log.debug("ENTERED FabricTaskResult()") + + msg = "ENTERED FabricTaskResult(): " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) self.states = ["merged", "query"] @@ -106,6 +110,9 @@ def did_anything_change(self): """ return True if diffs have been appended to any of the diff lists. """ + if self.check_mode is True: + self.log.debug("check_mode is True. No changes made.") + return False for key in self.diff_properties: # skip query state diffs if key == "diff_query": diff --git a/plugins/module_utils/fabric/vxlan/params_spec.py b/plugins/module_utils/fabric/vxlan/params_spec.py new file mode 100644 index 000000000..f7d1581a3 --- /dev/null +++ b/plugins/module_utils/fabric/vxlan/params_spec.py @@ -0,0 +1,188 @@ +# 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 + + +class ParamsSpec: + """ + Parameter specifications for the dcnm_fabric_vxlan module. + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED ParamsSpec()") + + self._params_spec: Dict[str, Any] = {} + + def commit(self): + """ + build the parameter specification based on the state + """ + method_name = inspect.stack()[0][3] + + if self.ansible_module.params["state"] is None: + self.ansible_module.fail_json(msg="state is None") + + if self.ansible_module.params["state"] == "merged": + self._build_params_spec_for_merged_state() + elif self.ansible_module.params["state"] == "replaced": + self._build_params_spec_for_replaced_state() + elif self.ansible_module.params["state"] == "overridden": + self._build_params_spec_for_overridden_state() + elif self.ansible_module.params["state"] == "deleted": + self._build_params_spec_for_deleted_state() + elif self.ansible_module.params["state"] == "query": + self._build_params_spec_for_query_state() + else: + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid state {self.ansible_module.params['state']}" + self.ansible_module.fail_json(msg) + + def _build_params_spec_for_merged_state(self) -> None: + """ + Build the specs for the playbook parameters expected + when state == merged. These are then accessible via + the @params_spec property. + + Caller: commit() + """ + msg = "Building params spec for merged state" + self.log.debug(msg) + + self._params_spec: Dict[str, Any] = {} + + self._params_spec["aaa_remote_ip_enabled"] = {} + self._params_spec["aaa_remote_ip_enabled"]["default"] = False + self._params_spec["aaa_remote_ip_enabled"]["required"] = False + self._params_spec["aaa_remote_ip_enabled"]["type"] = "bool" + + # TODO:6 active_migration + # active_migration doesn't seem to be represented in + # the NDFC EasyFabric GUI. Add this param if we figure out + # what it's used for and where in the GUI it's represented + + self._params_spec["advertise_pip_bgp"] = {} + self._params_spec["advertise_pip_bgp"]["default"] = False + self._params_spec["advertise_pip_bgp"]["required"] = False + self._params_spec["advertise_pip_bgp"]["type"] = "bool" + + # TODO:6 agent_intf (add if required) + + self._params_spec["anycast_bgw_advertise_pip"] = {} + self._params_spec["anycast_bgw_advertise_pip"]["default"] = False + self._params_spec["anycast_bgw_advertise_pip"]["required"] = False + self._params_spec["anycast_bgw_advertise_pip"]["type"] = "bool" + + self._params_spec["anycast_gw_mac"] = {} + self._params_spec["anycast_gw_mac"]["default"] = "2020.0000.00aa" + self._params_spec["anycast_gw_mac"]["required"] = False + self._params_spec["anycast_gw_mac"]["type"] = "str" + + # self._params_spec["anycast_lb_id"] = {} + # # self._params_spec["anycast_lb_id"]["default"] = "" + # # self._params_spec["anycast_lb_id"]["range_max"] = 1023 + # # self._params_spec["anycast_lb_id"]["range_min"] = 0 + # self._params_spec["anycast_lb_id"]["required"] = False + # self._params_spec["anycast_lb_id"]["type"] = "str" + + self._params_spec["auto_symmetric_default_vrf"] = {} + self._params_spec["auto_symmetric_default_vrf"]["default"] = False + self._params_spec["auto_symmetric_default_vrf"]["required"] = False + self._params_spec["auto_symmetric_default_vrf"]["type"] = "bool" + + self._params_spec["auto_symmetric_vrf_lite"] = {} + self._params_spec["auto_symmetric_vrf_lite"]["default"] = False + self._params_spec["auto_symmetric_vrf_lite"]["required"] = False + self._params_spec["auto_symmetric_vrf_lite"]["type"] = "bool" + + self._params_spec["auto_vrflite_ifc_default_vrf"] = {} + self._params_spec["auto_vrflite_ifc_default_vrf"]["default"] = False + self._params_spec["auto_vrflite_ifc_default_vrf"]["required"] = False + self._params_spec["auto_vrflite_ifc_default_vrf"]["type"] = "bool" + + self._params_spec["bgp_as"] = {} + self._params_spec["bgp_as"]["required"] = True + self._params_spec["bgp_as"]["type"] = "str" + + self._params_spec["default_vrf_redis_bgp_rmap"] = {} + self._params_spec["default_vrf_redis_bgp_rmap"]["default"] = "" + self._params_spec["default_vrf_redis_bgp_rmap"]["required"] = False + self._params_spec["default_vrf_redis_bgp_rmap"]["type"] = "str" + + self._params_spec["fabric_name"] = {} + self._params_spec["fabric_name"]["required"] = True + self._params_spec["fabric_name"]["type"] = "str" + + self._params_spec["pm_enable"] = {} + self._params_spec["pm_enable"]["default"] = False + self._params_spec["pm_enable"]["required"] = False + self._params_spec["pm_enable"]["type"] = "bool" + + self._params_spec["replication_mode"] = {} + self._params_spec["replication_mode"]["choices"] = ["Ingress", "Multicast"] + self._params_spec["replication_mode"]["default"] = "Multicast" + self._params_spec["replication_mode"]["required"] = False + self._params_spec["replication_mode"]["type"] = "str" + + self._params_spec["vrf_lite_autoconfig"] = {} + self._params_spec["vrf_lite_autoconfig"]["choices"] = [0, 1] + self._params_spec["vrf_lite_autoconfig"]["default"] = 0 + self._params_spec["vrf_lite_autoconfig"]["required"] = False + self._params_spec["vrf_lite_autoconfig"]["type"] = "int" + + def _build_params_spec_for_deleted_state(self) -> None: + """ + Build the specs for the playbook parameters expected + when state == deleted. These are then accessible via + the @params_spec property. + + Caller: commit() + """ + self._params_spec: Dict[str, Any] = {} + + self._params_spec["fabric_name"] = {} + self._params_spec["fabric_name"]["required"] = True + self._params_spec["fabric_name"]["type"] = "str" + + def _build_params_spec_for_query_state(self) -> None: + """ + Build the specs for the playbook parameters expected + when state == query. These are then accessible via + the @params_spec property. + + Caller: commit() + """ + self._params_spec: Dict[str, Any] = {} + + self._params_spec["fabric_name"] = {} + self._params_spec["fabric_name"]["required"] = True + self._params_spec["fabric_name"]["type"] = "str" + + @property + def params_spec(self) -> Dict[str, Any]: + """ + return the parameter specification + """ + return self._params_spec diff --git a/plugins/modules/dcnm_fabric_vxlan.py b/plugins/modules/dcnm_fabric_vxlan.py index e05045bff..1cf553029 100644 --- a/plugins/modules/dcnm_fabric_vxlan.py +++ b/plugins/modules/dcnm_fabric_vxlan.py @@ -13,35 +13,9 @@ # 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. -""" -Classes and methods for Ansible support of NDFC Data Center VXLAN EVPN Fabric. -Ansible states "merged", "deleted", and "query" are implemented. -""" from __future__ import absolute_import, division, print_function -import copy -import inspect -import json -import logging -from typing import Any, Dict, List - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log -from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import ( - dcnm_send, - validate_list_of_dicts, -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import ( - ApiEndpoints -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import ( - VerifyFabricParams, -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import ( - FabricTaskResult -) - __metaclass__ = type __author__ = "Allen Robel" @@ -226,14 +200,55 @@ """ +import copy +import inspect +import json +import logging +from typing import Any, Dict, List -class FabricVxlanTask: +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log +from ansible_collections.cisco.dcnm.plugins.module_utils.common.merge_dicts import \ + MergeDicts +from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_merge_defaults import \ + ParamsMergeDefaults +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.params_spec import \ + ParamsSpec +from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_validate import \ + ParamsValidate +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ + FabricDetailsByName +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import ( + ApiEndpoints +) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import ( + VerifyFabricParams, +) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_create import ( + FabricCreateBulk +) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import ( + FabricTaskResult +) + +def json_pretty(msg): + """ + Return a pretty-printed JSON string for logging messages + """ + return json.dumps(msg, indent=4, sort_keys=True) + +class FabricVxlanTask(FabricCommon): """ Ansible support for Data Center VXLAN EVPN """ def __init__(self, ansible_module): self.class_name = self.__class__.__name__ + super().__init__(ansible_module) method_name = inspect.stack()[0][3] self.log = logging.getLogger(f"dcnm.{self.class_name}") @@ -244,11 +259,12 @@ def __init__(self, ansible_module): self._implemented_states = set() self._implemented_states.add("merged") - self.ansible_module = ansible_module self.params = ansible_module.params self.verify = VerifyFabricParams() + self.rest_send = RestSend(self.ansible_module) # populated in self.validate_input() self.payloads = {} + self._valid_states = ["merged", "query"] self.config = ansible_module.params.get("config") if not isinstance(self.config, list): @@ -263,19 +279,8 @@ def __init__(self, ansible_module): self.need = [] self.query = [] - self.task_result = dict(changed=False, diff=[], response=[]) + self.task_result = FabricTaskResult(self.ansible_module) - self.mandatory_keys = {"fabric_name", "bgp_as"} - # Populated in self.get_have() - self.fabric_details = {} - # Not currently using. Commented out in self.get_have() - self.inventory_data = {} - for item in self.config: - if not self.mandatory_keys.issubset(item): - msg = f"{self.class_name}.{method_name}: " - msg += f"missing mandatory keys in {item}. " - msg += f"expected {self.mandatory_keys}" - self.ansible_module.fail_json(msg=msg) def get_have(self): """ @@ -297,31 +302,63 @@ def get_have(self): } """ method_name = inspect.stack()[0][3] - endpoint = self.endpoints.fabrics - path = endpoint["path"] - verb = endpoint["verb"] - response = dcnm_send(self.ansible_module, verb, path) - result = self._handle_get_response(response) - if not result["success"]: - msg = f"{self.class_name}.{method_name}: " - msg += "Unable to retrieve fabric information from " - msg += "the controller." - self.ansible_module.fail_json(msg=msg) - self.have = {} - for item in response["DATA"]: - self.have[item["fabricName"]] = {} - self.have[item["fabricName"]]["fabric_name"] = item["fabricName"] - self.have[item["fabricName"]]["fabric_config"] = item + self.have = FabricDetailsByName(self.ansible_module) + self.have.refresh() + msg = f"{self.class_name}.{method_name}: " + msg += f"self.have: {self.have.all_data}" + self.log.debug(msg) + # for item in response["DATA"]: + # self.have[item["fabricName"]] = {} + # self.have[item["fabricName"]]["fabric_name"] = item["fabricName"] + # self.have[item["fabricName"]]["fabric_config"] = item - def get_want(self): + def get_want(self) -> None: """ Caller: main() - Update self.want for all fabrics defined in the playbook + 1. Validate the playbook configs + 2. Update self.want with the playbook configs """ + msg = "ENTERED" + self.log.debug(msg) + + # Generate the params_spec used to validate the configs + params_spec = ParamsSpec(self.ansible_module) + params_spec.commit() + + # If a parameter is missing from the config, and it has a default + # value, add it to the config. + merged_configs = [] + merge_defaults = ParamsMergeDefaults(self.ansible_module) + merge_defaults.params_spec = params_spec.params_spec + for config in self.config: + merge_defaults.parameters = config + merge_defaults.commit() + merged_configs.append(merge_defaults.merged_parameters) + + # validate the merged configs self.want = [] - for fabric_config in self.config: - self.want.append(copy.deepcopy(fabric_config)) + validator = ParamsValidate(self.ansible_module) + validator.params_spec = params_spec.params_spec + for config in merged_configs: + msg = f"{self.class_name}.get_want(): " + msg += f"config: {json.dumps(config, indent=4, sort_keys=True)}" + self.log.debug(msg) + validator.parameters = config + validator.commit() + self.want.append(copy.deepcopy(validator.parameters)) + + # # convert the validated configs to payloads to more easily compare them + # # to self.have (the current image policies on the controller). + # for config in self.validated_configs: + # payload = Config2Payload(self.ansible_module) + # payload.config = config + # payload.commit() + # self.want.append(payload.payload) + + # Exit if there's nothing to do + if len(self.want) == 0: + self.ansible_module.exit_json(**self.task_result.module_result) def get_need_for_merged_state(self): """ @@ -330,16 +367,26 @@ def get_need_for_merged_state(self): Build self.need for state merged """ method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"self.have.all_data: {json.dumps(self.have.all_data, indent=4, sort_keys=True)}" + self.log.debug(msg) need: List[Dict[str, Any]] = [] + state = self.params["state"] + self.payloads = {} for want in self.want: - msg = f"{self.class_name}.{method_name}: " - msg += f"want: {json.dumps(want, indent=4, sort_keys=True)}" - self.log.debug(msg) - if want["fabric_name"] in self.have: - continue - need.append(want) + verify = VerifyFabricParams() + verify.state = state + verify.config = want + verify.validate_config() + if verify.result is False: + self.ansible_module.fail_json(msg=verify.msg) + need.append(verify.payload) self.need = copy.deepcopy(need) + msg = f"{self.class_name}.validate_input(): " + msg += f"self.need: {json.dumps(self.need, indent=4, sort_keys=True)}" + self.log.debug(msg) + def handle_merged_state(self): """ Caller: main() @@ -354,27 +401,123 @@ def handle_merged_state(self): self.task_result["changed"] = False self.task_result["success"] = True self.task_result["diff"] = [] - self.ansible_module.exit_json(**self.task_result) + self.ansible_module.exit_json(**self.task_result.module_result) self.send_need() def send_need(self): """ Caller: handle_merged_state() + """ + if self.check_mode is True: + self.send_need_check_mode() + else: + self.send_need_normal_mode() + + def send_need_check_mode(self): + """ + Caller: send_need() + + Simulate sending the payload to the controller + to create the fabrics specified in the playbook. + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"need: {json.dumps(self.need, indent=4, sort_keys=True)}" + self.log.debug(msg) + if len(self.need) == 0: + self.ansible_module.exit_json(**self.task_result.module_result) + for item in self.need: + fabric_name = item.get("FABRIC_NAME") + if fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + self.ansible_module.fail_json(msg) + self.endpoints.fabric_name = fabric_name + self.endpoints.template_name = "Easy_Fabric" + + try: + endpoint = self.endpoints.fabric_create + except ValueError as error: + self.ansible_module.fail_json(error) + + path = endpoint["path"] + verb = endpoint["verb"] + payload = item + msg = f"{self.class_name}.{method_name}: " + msg += f"verb: {verb}, path: {path}" + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"fabric_name: {fabric_name}, payload: {json.dumps(payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + + self.rest_send.path = path + self.rest_send.verb = verb + self.rest_send.payload = payload + self.rest_send.commit() + + self.result_current = self.rest_send.result_current + self.result = self.rest_send.result_current + self.response_current = self.rest_send.response_current + self.response = self.rest_send.response_current + + self.task_result.response_merged = self.response_current + if self.response_current["RETURN_CODE"] == 200: + self.task_result.diff_merged = payload + + msg = "self.response_current: " + msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.response: " + msg += f"{json.dumps(self.response, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.result_current: " + msg += f"{json.dumps(self.result_current, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.result: " + msg += f"{json.dumps(self.result, indent=4, sort_keys=True)}" + self.log.debug(msg) + + def send_need_normal_mode(self): + """ + Caller: send_need() + + Build and send the payload to create the + fabrics specified in the playbook. + """ + method_name = inspect.stack()[0][3] + self.fabric_create = FabricCreateBulk(self.ansible_module) + self.fabric_create.payloads = self.need + self.fabric_create.commit() + self.update_diff_and_response(self.fabric_create) + # for payload in self.need: + # self.fabric_create.payload = payload + # self.fabric_create.commit() + # self.update_diff_and_response(self.fabric_create) + + def send_need_normal_mode_orig(self): + """ + Caller: send_need() Build and send the payload to create the fabrics specified in the playbook. """ method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " msg += f"need: {json.dumps(self.need, indent=4, sort_keys=True)}" self.log.debug(msg) if len(self.need) == 0: - self.task_result["changed"] = False - self.task_result["success"] = True - self.task_result["diff"] = [] - self.ansible_module.exit_json(**self.task_result) + self.ansible_module.exit_json(**self.task_result.module_result) for item in self.need: - fabric_name = item["fabric_name"] + fabric_name = item.get("FABRIC_NAME") + if fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + self.ansible_module.fail_json(msg) self.endpoints.fabric_name = fabric_name self.endpoints.template_name = "Easy_Fabric" @@ -385,231 +528,128 @@ def send_need(self): path = endpoint["path"] verb = endpoint["verb"] - - payload = self.payloads[fabric_name] + payload = item msg = f"{self.class_name}.{method_name}: " msg += f"verb: {verb}, path: {path}" self.log.debug(msg) + msg = f"{self.class_name}.{method_name}: " msg += f"fabric_name: {fabric_name}, payload: {json.dumps(payload, indent=4, sort_keys=True)}" self.log.debug(msg) - response = dcnm_send(self.ansible_module, verb, path, data=json.dumps(payload)) - result = self._handle_post_put_response(response, verb) - self.log.debug(f"response]: {json.dumps(response, indent=4, sort_keys=True)}") - self.log.debug(f"result: {json.dumps(result, indent=4, sort_keys=True)}") - self.task_result["changed"] = result["changed"] - if not result["success"]: - msg = f"{self.class_name}.{method_name}: " - msg += f"failed to create fabric {fabric_name}. " - msg += f"response: {response}" - self._failure(response) + self.rest_send.path = path + self.rest_send.verb = verb + self.rest_send.payload = payload + self.rest_send.commit() - @staticmethod - def _build_params_spec_for_merged_state(): - """ - Build the specs for the parameters expected when state == merged. + self.result_current = self.rest_send.result_current + self.result = self.rest_send.result_current + self.response_current = self.rest_send.response_current + self.response = self.rest_send.response_current + + self.task_result.response_merged = self.response_current + if self.response_current["RETURN_CODE"] == 200: + self.task_result.diff_merged = payload + + msg = "self.response_current: " + msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.response: " + msg += f"{json.dumps(self.response, indent=4, sort_keys=True)}" + self.log.debug(msg) - Caller: _validate_input_for_merged_state() - Return: params_spec, a dictionary containing the set of - parameter specifications. + msg = "self.result_current: " + msg += f"{json.dumps(self.result_current, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = "self.result: " + msg += f"{json.dumps(self.result, indent=4, sort_keys=True)}" + self.log.debug(msg) + + + def update_diff_and_response(self, obj) -> None: """ - params_spec = {} - params_spec.update( - aaa_remote_ip_enabled=dict(required=False, type="bool", default=False) - ) - # TODO:6 active_migration - # active_migration doesn't seem to be represented in - # the NDFC EasyFabric GUI. Add this param if we figure out - # what it's used for and where in the GUI it's represented - params_spec.update( - advertise_pip_bgp=dict(required=False, type="bool", default=False) - ) - # TODO:6 agent_intf (add if required) - params_spec.update( - anycast_bgw_advertise_pip=dict(required=False, type="bool", default=False) - ) - params_spec.update( - anycast_gw_mac=dict(required=False, type="str", default="2020.0000.00aa") - ) - params_spec.update( - anycast_lb_id=dict( - required=False, type="int", range_min=0, range_max=1023, default="" - ) - ) - params_spec.update( - auto_symmetric_default_vrf=dict(required=False, type="bool", default=False) - ) - params_spec.update( - auto_symmetric_vrf_lite=dict(required=False, type="bool", default=False) - ) - params_spec.update( - auto_vrflite_ifc_default_vrf=dict( - required=False, type="bool", default=False - ) - ) - params_spec.update(bgp_as=dict(required=True, type="str")) - params_spec.update( - default_vrf_redis_bgp_rmap=dict(required=False, type="str", default="") - ) - params_spec.update(fabric_name=dict(required=True, type="str")) - params_spec.update(pm_enable=dict(required=False, type="bool", default=False)) - params_spec.update( - replication_mode=dict( - required=False, - type="str", - default="Multicast", - choices=["Ingress", "Multicast"], - ) - ) - params_spec.update( - vrf_lite_autoconfig=dict( - required=False, type="int", default=0, choices=[0, 1] - ) - ) - return params_spec + Update the appropriate self.task_result diff and response, + based on the current ansible state, with the diff and + response from obj. - def validate_input(self): + fail_json if the state is not in self._valid_states """ - Caller: main() + state = self.ansible_module.params["state"] + if state not in self._valid_states: + msg = f"Inappropriate state {state}" + self.log.error(msg) + self.ansible_module.fail_json(msg, **obj.result) + for diff in obj.diff: + msg = f"state {state} diff: {json_pretty(diff)}" + self.log.debug(msg) + if state == "deleted": + self.task_result.diff_deleted = diff + if state == "merged": + self.task_result.diff_merged = diff + if state == "query": + self.task_result.diff_query = diff + + msg = f"PRE_FOR: state {state} response: {json_pretty(obj.response)}" + self.log.debug(msg) + for response in obj.response: + if "DATA" in response: + response.pop("DATA") + msg = f"state {state} response: {json_pretty(response)}" + self.log.debug(msg) + if state == "deleted": + self.task_result.response_deleted = copy.deepcopy(response) + if state == "merged": + self.task_result.response_merged = copy.deepcopy(response) + if state == "query": + self.task_result.response_query = copy.deepcopy(response) + + def build_payloads(self): + """ + Caller: send_need() Validate the playbook parameters Build the payloads for each fabric """ state = self.params["state"] - - # TODO:2 remove this when we implement query state - if state not in self._implemented_states: - msg = f"Got state {state}. " - msg += f"Expected one of: {','.join(sorted(self._implemented_states))}" - self.ansible_module.fail_json(msg=msg) - - if state == "merged": - self._validate_input_for_merged_state() - self.payloads = {} - for fabric_config in self.config: + for want in self.want: verify = VerifyFabricParams() verify.state = state - verify.config = fabric_config + verify.config = want verify.validate_config() if verify.result is False: self.ansible_module.fail_json(msg=verify.msg) - self.payloads[fabric_config["fabric_name"]] = verify.payload + self.payloads[want["fabric_name"]] = verify.payload - def _validate_input_for_merged_state(self): - """ - Caller: self._validate_input() + msg = f"{self.class_name}.validate_input(): " + msg += f"payloads: {json.dumps(self.payloads, indent=4, sort_keys=True)}" + self.log.debug(msg) - Valid self.config contains appropriate values for merged state + def build_payloads_orig(self): """ - params_spec = self._build_params_spec_for_merged_state() - msg = None - if not self.config: - msg = "config: element is mandatory for state merged" - self.ansible_module.fail_json(msg=msg) + Caller: send_need() - valid_params, invalid_params = validate_list_of_dicts( - self.config, params_spec, self.ansible_module - ) - # We're not using self.validated. Keeping this to avoid - # linter error due to non-use of valid_params - self.validated = copy.deepcopy(valid_params) - - if invalid_params: - msg = "Invalid parameters in playbook: " - msg += f"{','.join(invalid_params)}" - self.ansible_module.fail_json(msg=msg) - - def _handle_get_response(self, response): - """ - Caller: - - self.get_have() - Handle NDFC responses to GET requests - Returns: dict() with the following keys: - - found: - - False, if request error was "Not found" and RETURN_CODE == 404 - - True otherwise - - success: - - False if RETURN_CODE != 200 or MESSAGE != "OK" - - True otherwise - """ - # Example response - # { - # 'RETURN_CODE': 404, - # 'METHOD': 'GET', - # 'REQUEST_PATH': '...user path goes here...', - # 'MESSAGE': 'Not Found', - # 'DATA': { - # 'timestamp': 1691970528998, - # 'status': 404, - # 'error': 'Not Found', - # 'path': '/rest/control/fabrics/IR-Fabric' - # } - # } - result = {} - success_return_codes = {200, 404} - #self.log.debug(f"_handle_get_request: response {json.dumps(response, indent=4, sort_keys=True)}") - if ( - response.get("RETURN_CODE") == 404 - and response.get("MESSAGE") == "Not Found" - ): - result["found"] = False - result["success"] = True - return result - if ( - response.get("RETURN_CODE") not in success_return_codes - or response.get("MESSAGE") != "OK" - ): - result["found"] = False - result["success"] = False - return result - result["found"] = True - result["success"] = True - return result - - def _handle_post_put_response(self, response, verb): + Validate the playbook parameters + Build the payloads for each fabric """ - Caller: - - self.create_fabrics() - - Handle POST, PUT responses from NDFC. - Returns: dict() with the following keys: - - changed: - - True if changes were made to NDFC - - False otherwise - - success: - - False if RETURN_CODE != 200 or MESSAGE != "OK" - - True otherwise - - """ - # Example response - # { - # 'RETURN_CODE': 200, - # 'METHOD': 'POST', - # 'REQUEST_PATH': '...user path goes here...', - # 'MESSAGE': 'OK', - # 'DATA': {...} - valid_verbs = {"POST", "PUT"} - if verb not in valid_verbs: - msg = f"invalid verb {verb}. " - msg += f"expected one of: {','.join(sorted(valid_verbs))}" - self.ansible_module.fail_json(msg=msg) + state = self.params["state"] + self.payloads = {} + for fabric_config in self.config: + verify = VerifyFabricParams() + verify.state = state + verify.config = fabric_config + verify.validate_config() + if verify.result is False: + self.ansible_module.fail_json(msg=verify.msg) + self.payloads[fabric_config["fabric_name"]] = verify.payload - result = {} - if response.get("MESSAGE") != "OK": - result["success"] = False - result["changed"] = False - return result - if response.get("ERROR"): - result["success"] = False - result["changed"] = False - return result - result["success"] = True - result["changed"] = True - return result + msg = f"{self.class_name}.validate_input(): " + msg += f"payloads: {json.dumps(self.payloads, indent=4, sort_keys=True)}" + self.log.debug(msg) def _failure(self, resp): """ @@ -654,29 +694,44 @@ def main(): ansible_module = AnsibleModule(argument_spec=element_spec, supports_check_mode=True) # Create the base/parent logger for the dcnm collection. - # To disable logging, comment out log.config = below + # To enable logging, set enable_logging to True. # log.config can be either a dictionary, or a path to a JSON file # Both dictionary and JSON file formats must be conformant with # logging.config.dictConfig and must not log to the console. # For an example configuration, see: # $ANSIBLE_COLLECTIONS_PATH/cisco/dcnm/plugins/module_utils/common/logging_config.json + enable_logging = True log = Log(ansible_module) - collection_path = "/Users/arobel/repos/collections/ansible_collections/cisco/dcnm" - config_file = f"{collection_path}/plugins/module_utils/common/logging_config.json" - log.config = config_file + if enable_logging is True: + collection_path = ( + "/Users/arobel/repos/collections/ansible_collections/cisco/dcnm" + ) + config_file = ( + f"{collection_path}/plugins/module_utils/common/logging_config.json" + ) + log.config = config_file log.commit() task = FabricVxlanTask(ansible_module) - task.validate_input() - task.get_have() + task.log.debug(f"state: {ansible_module.params['state']}") + # task.log.debug(f"calling task.validate_input()") + # task.validate_input() + # task.log.debug(f"calling task.validate_input() DONE") task.get_want() + task.get_have() task.log.debug(f"state: {ansible_module.params['state']}") if ansible_module.params["state"] == "merged": task.handle_merged_state() - - ansible_module.exit_json(**task.task_result) + else: + msg = f"Unknown state {task.ansible_module.params['state']}" + task.ansible_module.fail_json(msg) + + msg = "task_result.module_result: " + msg += f"{json.dumps(task.task_result.module_result, indent=4, sort_keys=True)}" + task.log.debug(msg) + ansible_module.exit_json(**task.task_result.module_result) if __name__ == "__main__": From 0cd8a13af7660eaf87c2889354e299e4e2cb3599 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Sat, 9 Mar 2024 16:41:14 -1000 Subject: [PATCH 005/228] Changes for modularity --- plugins/module_utils/fabric/common.py | 10 +- .../fabric/{fabric_create.py => create.py} | 106 +- plugins/module_utils/fabric/delete.py | 205 ++++ plugins/module_utils/fabric/endpoints.py | 117 ++- plugins/module_utils/fabric/fabric.py | 951 ------------------ plugins/module_utils/fabric/fabric_details.py | 153 ++- plugins/module_utils/fabric/fabric_summary.py | 232 +++++ .../module_utils/fabric/fabric_task_result.py | 42 +- plugins/module_utils/fabric/query.py | 151 +++ plugins/module_utils/fabric/results.py | 127 +++ plugins/module_utils/fabric/template_get.py | 129 +++ .../module_utils/fabric/template_get_all.py | 104 ++ .../fabric/template_parse_common.py | 457 +++++++++ .../fabric/template_parse_easy_fabric.py | 285 ++++++ .../fabric/vxlan/verify_fabric_params.py | 2 +- .../fabric/vxlan/verify_playbook_params.py | 112 +++ plugins/modules/dcnm_fabric_vxlan.py | 261 ++--- 17 files changed, 2248 insertions(+), 1196 deletions(-) rename plugins/module_utils/fabric/{fabric_create.py => create.py} (85%) create mode 100644 plugins/module_utils/fabric/delete.py delete mode 100644 plugins/module_utils/fabric/fabric.py create mode 100644 plugins/module_utils/fabric/fabric_summary.py create mode 100644 plugins/module_utils/fabric/query.py create mode 100644 plugins/module_utils/fabric/results.py create mode 100755 plugins/module_utils/fabric/template_get.py create mode 100755 plugins/module_utils/fabric/template_get_all.py create mode 100755 plugins/module_utils/fabric/template_parse_common.py create mode 100644 plugins/module_utils/fabric/template_parse_easy_fabric.py create mode 100644 plugins/module_utils/fabric/vxlan/verify_playbook_params.py diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index a6a36adb3..45e4c7bf1 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -45,12 +45,16 @@ def __init__(self, module): def __init__(self, ansible_module): self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + self.check_mode = self.ansible_module.check_mode + self.state = ansible_module.params["state"] self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.log.debug("ENTERED FabricCommon()") - self.ansible_module = ansible_module - self.check_mode = self.ansible_module.check_mode + msg = "ENTERED FabricCommon(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) self.params = ansible_module.params diff --git a/plugins/module_utils/fabric/fabric_create.py b/plugins/module_utils/fabric/create.py similarity index 85% rename from plugins/module_utils/fabric/fabric_create.py rename to plugins/module_utils/fabric/create.py index 3f34e5eb2..7b28eeb6d 100644 --- a/plugins/module_utils/fabric/fabric_create.py +++ b/plugins/module_utils/fabric/create.py @@ -23,14 +23,17 @@ import json import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ - ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend 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_details import \ FabricDetailsByName -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ - RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_playbook_params import \ + VerifyPlaybookParams + class FabricCreateCommon(FabricCommon): """ @@ -38,18 +41,25 @@ class FabricCreateCommon(FabricCommon): - FabricCreate - FabricCreateBulk """ + def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED ImagePolicyCreateCommon()" + msg = "ENTERED FabricCreateCommon()" self.log.debug(msg) self.fabric_details = FabricDetailsByName(self.ansible_module) self.endpoints = ApiEndpoints() - self.rest_send = RestSend(self.ansible_module) + self._verify_params = VerifyPlaybookParams(self.ansible_module) + + # 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.action = "create" self._payloads_to_commit = [] @@ -76,6 +86,17 @@ def _verify_payload(self, payload): msg += f"value {payload}" self.ansible_module.fail_json(msg, **self.failed_result) + # START = TODO: REMOVE THIS LATER + self._verify_params.config = payload + self._verify_params.refresh_template() + self._verify_params.commit() + + msg = f"HHH instance.config: {json.dumps(self._verify_params.config, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"HHH instance.template: {json.dumps(self._verify_params.template, indent=4, sort_keys=True)}" + self.log.debug(msg) + # END - TODO: REMOVE THIS LATER + missing_keys = [] for key in self._mandatory_payload_keys: if key not in payload: @@ -94,7 +115,7 @@ def _build_payloads_to_commit(self): already exist on the controller. Expects self.payloads to be a list of dict, with each dict - being a payload for the image policy create API endpoint. + being a payload for the fabric create API endpoint. Populates self._payloads_to_commit with a list of payloads to commit. @@ -140,10 +161,10 @@ def _send_payloads_check_mode(self): self.response_nok = [] self.result_nok = [] self.diff_nok = [] - for payload in self._payloads_to_commit: - self.result_current = {"success": True} - self.response_current = {"msg": "skipped: check_mode"} + self.result_current = {"success": True} + self.response_current = {"msg": "skipped: check_mode"} + for payload in self._payloads_to_commit: if self.result_current["success"]: self.response_ok.append(copy.deepcopy(self.response_current)) self.result_ok.append(copy.deepcopy(self.result_current)) @@ -153,20 +174,20 @@ def _send_payloads_check_mode(self): self.result_nok.append(copy.deepcopy(self.result_current)) self.diff_nok.append(copy.deepcopy(payload)) - msg = f"self.response_ok: {json.dumps(self.response_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.result_ok: {json.dumps(self.result_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.diff_ok: {json.dumps(self.diff_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.response_nok: {json.dumps(self.response_nok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = ( - f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" - ) - self.log.debug(msg) + msg = f"self.response_ok: {json.dumps(self.response_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.result_ok: {json.dumps(self.result_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.diff_ok: {json.dumps(self.diff_ok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.response_nok: {json.dumps(self.response_nok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" + self.log.debug(msg) + msg = ( + f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" + ) + self.log.debug(msg) def _send_payloads_normal_mode(self): """ @@ -195,14 +216,14 @@ def _send_payloads_normal_mode(self): self.rest_send.payload = payload self.rest_send.commit() - if self.rest_send.result_current["success"]: - self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_ok.append(copy.deepcopy(payload)) - else: - self.response_nok.append(copy.deepcopy(self.response_current)) - self.result_nok.append(copy.deepcopy(self.result_current)) - self.diff_nok.append(copy.deepcopy(payload)) + if self.rest_send.result_current["success"]: + self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_ok.append(copy.deepcopy(payload)) + else: + self.response_nok.append(copy.deepcopy(self.response_current)) + self.result_nok.append(copy.deepcopy(self.result_current)) + self.diff_nok.append(copy.deepcopy(payload)) msg = f"self.response_ok: {json.dumps(self.response_ok, indent=4, sort_keys=True)}" self.log.debug(msg) @@ -212,12 +233,12 @@ def _send_payloads_normal_mode(self): self.log.debug(msg) msg = f"self.response_nok: {json.dumps(self.response_nok, indent=4, sort_keys=True)}" self.log.debug(msg) - msg = f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" - self.log.debug(msg) msg = ( - f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" + f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" ) self.log.debug(msg) + msg = f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" + self.log.debug(msg) def _process_responses(self): method_name = inspect.stack()[0][3] @@ -291,7 +312,11 @@ def payloads(self, value): self._verify_payload(item) self.properties["payloads"] = value + class FabricCreateBulk(FabricCreateCommon): + """ + Create fabrics in bulk. Skip any fabrics that already exist. + """ def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ @@ -325,10 +350,12 @@ def commit(self): self._send_payloads() self._process_responses() + class FabricCreate(FabricCommon): """ Create a VXLAN fabric on the controller. """ + def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ @@ -362,7 +389,7 @@ def commit(self): self.log.debug(msg) if len(self.payload) == 0: - self.ansible_module.exit_json(**self.task_result.module_result) + self.ansible_module.exit_json(**self.failed_result) fabric_name = self.payload.get("FABRIC_NAME") if fabric_name is None: @@ -400,10 +427,10 @@ def commit(self): self.response = self.rest_send.response_current if self.response_current["RETURN_CODE"] == 200: - self.diff_merged = self.payload + self.diff = self.payload - msg = "self.diff_merged: " - msg += f"{json.dumps(self.diff_merged, indent=4, sort_keys=True)}" + msg = "self.diff: " + msg += f"{json.dumps(self.diff, indent=4, sort_keys=True)}" self.log.debug(msg) msg = "self.response_current: " @@ -422,7 +449,6 @@ def commit(self): msg += f"{json.dumps(self.result, indent=4, sort_keys=True)}" self.log.debug(msg) - @property def payload(self): """ diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py new file mode 100644 index 000000000..fb824938e --- /dev/null +++ b/plugins/module_utils/fabric/delete.py @@ -0,0 +1,205 @@ +# 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.endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ + FabricDetailsByName +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_summary import \ + FabricSummary +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend + + +class FabricDelete(FabricCommon): + """ + Delete fabrics + + A fabric must be empty before it can be deleted. + + Usage: + + instance = FabricDelete(ansible_module) + instance.fabric_names = ["FABRIC_1", "FABRIC_2"] + instance.commit() + diff = instance.diff # contains list of deleted fabrics + result = instance.result # contains the result(s) of the delete request + response = instance.response # contains the response(s) from the controller + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED FabricDelete(): " + msg += f"state: {self.state}" + self.log.debug(msg) + + self._fabrics_to_delete = [] + self._build_properties() + self._endpoints = ApiEndpoints() + self._fabric_details = FabricDetailsByName(self.ansible_module) + self._fabric_summary = FabricSummary(self.ansible_module) + self._rest_send = RestSend(self.ansible_module) + + # 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.action = "delete" + 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): + """ + return the fabric names + """ + 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}" + self.ansible_module.fail_json(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}." + self.ansible_module.fail_json(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}" + self.ansible_module.fail_json(msg) + self.properties["fabric_names"] = value + + 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. + """ + self._fabric_details.refresh() + + self._fabrics_to_delete = [] + for fabric_name in self.fabric_names: + if fabric_name in self._fabric_details.all_data: + self._fabrics_to_delete.append(fabric_name) + + def _can_fabric_be_deleted(self, fabric_name): + """ + return True if the fabric can be deleted + return False otherwise + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "ENTERED" + self.log.debug(msg) + self._fabric_summary.fabric_name = fabric_name + self._fabric_summary.refresh() + msg = f"self._fabric_summary.fabric_is_empty: " + msg += f"fabric_is_empty: {self._fabric_summary.fabric_is_empty}" + self.log.debug(msg) + if self._fabric_summary.fabric_is_empty is False: + self.cannot_delete_fabric_reason = "Fabric is not empty" + return False + return True + + def _set_endpoint(self, fabric_name): + """ + return the endpoint for the fabric_name + """ + self._endpoints.fabric_name = fabric_name + try: + self._endpoint = self._endpoints.fabric_delete + except ValueError as error: + self.ansible_module.fail_json(error, **self.failed_result) + self.path = self._endpoint.get("path") + self.verb = self._endpoint.get("verb") + + def commit(self): + """ + delete each of the fabrics in self.fabric_names + """ + 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." + self.ansible_module.fail_json(msg, **self.failed_result) + + self._get_fabrics_to_delete() + + msg = f"self._fabrics_to_delete: {self._fabrics_to_delete}" + self.log.debug(msg) + if len(self._fabrics_to_delete) == 0: + self.changed = False + self.failed = False + return + + msg = f"Populating diff {self._fabrics_to_delete}" + self.log.debug(msg) + + for fabric_name in self._fabrics_to_delete: + if self._can_fabric_be_deleted(fabric_name) is False: + msg = f"Cannot delete fabric {fabric_name}. " + msg += f"Reason: {self.cannot_delete_fabric_reason}" + self.ansible_module.fail_json(msg, **self.failed_result) + + self._set_endpoint(fabric_name) + self._rest_send.path = self.path + self._rest_send.verb = self.verb + self._rest_send.commit() + + self.diff = {"fabric_name": fabric_name} + self.response = copy.deepcopy(self._rest_send.response_current) + self.response_current = copy.deepcopy(self._rest_send.response_current) + self.result = copy.deepcopy(self._rest_send.result_current) + self.result_current = copy.deepcopy(self._rest_send.result_current) + + # msg = f"self.diff: {self.diff}" + # self.log.debug(msg) + msg = f"self.response: {self.response}" + self.log.debug(msg) + msg = f"self.result: {self.result}" + self.log.debug(msg) + msg = f"self.response_current: {self.response_current}" + self.log.debug(msg) + msg = f"self.result_current: {self.result_current}" + self.log.debug(msg) diff --git a/plugins/module_utils/fabric/endpoints.py b/plugins/module_utils/fabric/endpoints.py index 57fc6c31b..40f300355 100644 --- a/plugins/module_utils/fabric/endpoints.py +++ b/plugins/module_utils/fabric/endpoints.py @@ -17,9 +17,11 @@ __metaclass__ = type __author__ = "Allen Robel" +import copy import inspect import json import logging +import re class ApiEndpoints: @@ -53,8 +55,15 @@ def __init__(self): 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._build_properties() - + def _build_properties(self): self.properties = {} self.properties["fabric_name"] = None @@ -81,7 +90,52 @@ def fabric_create(self): endpoint = {} endpoint["path"] = path endpoint["verb"] = "POST" - self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + self.log.debug( + f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" + ) + return endpoint + + @property + def fabric_delete(self): + """ + return fabric_delete endpoint + verb: DELETE + path: /rest/control/fabrics + """ + method_name = inspect.stack()[0][3] + 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" + self.log.debug( + f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" + ) + return endpoint + + @property + def fabric_summary(self): + """ + return fabric_summary endpoint + verb: GET + path: /rest/control/fabrics/summary + """ + method_name = inspect.stack()[0][3] + 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" + self.log.debug( + f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" + ) return endpoint @property @@ -95,15 +149,25 @@ def fabrics(self): endpoint = {} endpoint["path"] = self.endpoint_fabrics endpoint["verb"] = "GET" - self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + self.log.debug( + f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" + ) return endpoint @property def fabric_info(self): """ - return fabric_infoendpoint + 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] if not self.fabric_name: @@ -115,11 +179,17 @@ def fabric_info(self): endpoint = {} endpoint["path"] = path endpoint["verb"] = "GET" - self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + self.log.debug( + f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" + ) 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 @@ -128,6 +198,10 @@ def fabric_name(self, 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 @@ -135,21 +209,42 @@ def template_name(self, value): self.properties["template_name"] = value @property - def template_get(self): + def template(self): """ - return get template contents endpoint + return the template content endpoint for template_name verb: GET - path: /appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates/{templateName} + path: /appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates/{template_name} """ method_name = inspect.stack()[0][3] if not self.template_name: msg = f"{self.class_name}.{method_name}: " msg += "template_name is required." raise ValueError(msg) - path = self.endpoint_api_v1 - path += f"/configtemplate/rest/config/templates/{self.template_name}" + path = self.endpoint_templates + path += f"/{self.template_name}" endpoint = {} endpoint["path"] = path endpoint["verb"] = "GET" - self.log.debug(f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}") + self.log.debug( + f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" + ) + 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] + endpoint = {} + endpoint["path"] = self.endpoint_templates + endpoint["verb"] = "GET" + self.log.debug( + f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" + ) return endpoint diff --git a/plugins/module_utils/fabric/fabric.py b/plugins/module_utils/fabric/fabric.py deleted file mode 100644 index 278f9a7a5..000000000 --- a/plugins/module_utils/fabric/fabric.py +++ /dev/null @@ -1,951 +0,0 @@ -#!/usr/bin/python -# -# Copyright (c) 2023-2023 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. -""" -Classes and methods to verify NDFC Data Center VXLAN EVPN Fabric parameters. -This should go in: -ansible_collections/cisco/dcnm/plugins/module_utils/fabric/fabric.py - -Example Usage: -import sys -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric import ( - VerifyFabricParams, -) - -config = {} -config["fabric_name"] = "foo" -config["bgp_as"] = "65000.869" -# If auto_symmetric_vrf_lite == True, several other parameters -# become mandatory. The user has not explicitely set these other -# parameters. Hence, verify.result would be False (i.e. an error) -# If auto_symmetric_vrf_lite == False, no other parameters are required -# and so verify.result would be True and verify.payload would contain -# a valid payload to send to NDFC -config["auto_symmetric_vrf_lite"] = False -verify = VerifyFabricParams() -verify.config = config -verify.state = "merged" -verify.validate_config() -if verify.result == False: - print(f"result {verify.result}, {verify.msg}") - sys.exit(1) -print(f"result {verify.result}, {verify.msg}, payload {verify.payload}") -""" -import re - - -def translate_mac_address(mac_addr): - """ - Accept mac address with any (or no) punctuation and convert it - into the dotted-quad format that NDFC expects. - - Return mac address formatted for NDFC on success - Return False on failure. - """ - mac_addr = re.sub(r"[\W\s_]", "", mac_addr) - if not re.search("^[A-Fa-f0-9]{12}$", mac_addr): - return False - return "".join((mac_addr[:4], ".", mac_addr[4:8], ".", mac_addr[8:])) - - -def translate_vrf_lite_autoconfig(value): - """ - Translate playbook values to those expected by NDFC - """ - try: - value = int(value) - except ValueError: - return False - if value == 0: - return "Manual" - if value == 1: - return "Back2Back&ToExternal" - return False - - -class VerifyFabricParams: - """ - Parameter validation for NDFC Data Center VXLAN EVPN - """ - - def __init__(self): - self._initialize_properties() - - self.msg = None - self.payload = {} - self._default_fabric_params = {} - self._default_nv_pairs = {} - # See self._build_parameter_aliases - self._parameter_aliases = {} - # See self._build_mandatory_params() - self._mandatory_params = {} - # See self._validate_dependencies() - self._requires_validation = set() - # See self._build_failed_dependencies() - self._failed_dependencies = {} - # nvPairs that are safe to translate from lowercase dunder - # (as used in the playbook) to uppercase dunder (as used - # in the NDFC payload). - self._translatable_nv_pairs = set() - # A dictionary that holds the set of nvPairs that have been - # translated for use in the NDFC payload. These include only - # parameters that the user has changed. Keyed on the NDFC-expected - # parameter name, value is the user's setting for the parameter. - # Populated in: - # self._translate_to_ndfc_nv_pairs() - # self._build_translatable_nv_pairs() - self._translated_nv_pairs = {} - self._valid_states = {"merged"} - self._mandatory_keys = {"fabric_name", "bgp_as"} - self._build_default_fabric_params() - self._build_default_nv_pairs() - - def _initialize_properties(self): - self.properties = {} - self.properties["msg"] = None - self.properties["result"] = True - self.properties["state"] = None - self.properties["config"] = {} - - def _append_msg(self, msg): - if self.msg is None: - self.msg = msg - else: - self.msg += f" {msg}" - - def _validate_config(self, config): - """ - verify that self.config is a dict and that it contains - the minimal set of mandatory keys. - - Caller: self.config (@property setter) - - On success: - return True - On failure: - set self.result to False - set self.msg to an approprate error message - return False - """ - if not isinstance(config, dict): - msg = "error: config must be a dictionary" - self.result = False - self._append_msg(msg) - return False - if not self._mandatory_keys.issubset(config): - missing_keys = self._mandatory_keys.difference(config.keys()) - msg = f"error: missing mandatory keys {','.join(sorted(missing_keys))}." - self.result = False - self._append_msg(msg) - return False - return True - - def validate_config(self): - """ - Caller: public method, called by the user - Validate the items in self.config are appropriate for self.state - """ - if self.state is None: - msg = "call instance.state before calling instance.validate_config" - self._append_msg(msg) - self.result = False - return - if self.state == "merged": - self._validate_merged_state_config() - - def _validate_merged_state_config(self): - """ - Caller: self._validate_config_for_merged_state() - - Update self.config with a verified version of the users playbook - parameters. - - - Verify the user's playbook parameters for an individual fabric - configuration. Whenever possible, throw the user a bone by - converting values to NDFC's expectations. For example, NDFC's - REST API accepts mac addresses in any format (does not return - an error), since the NDFC GUI validates that it is in the expected - format, but the fabric will be in an errored state if the mac address - sent via REST is any format other than dotted-quad format - (xxxx.xxxx.xxxx). So, we convert all mac address formats to - dotted-quad before passing them to NDFC. - - Set self.result to False and update self.msg if anything is not valid - that we couldn't fix - """ - if not self.config: - msg = "config: element is mandatory for state merged" - self._append_msg(msg) - self.result = False - return - if "fabric_name" not in self.config: - msg = "fabric_name is mandatory" - self._append_msg(msg) - self.result = False - return - if "bgp_as" not in self.config: - msg = "bgp_as is mandatory" - self._append_msg(msg) - self.result = False - return - if "anycast_gw_mac" in self.config: - result = translate_mac_address(self.config["anycast_gw_mac"]) - if result is False: - msg = f"invalid anycast_gw_mac {self.config['anycast_gw_mac']}" - self._append_msg(msg) - self.result = False - return - self.config["anycast_gw_mac"] = result - if "vrf_lite_autoconfig" in self.config: - result = translate_vrf_lite_autoconfig(self.config["vrf_lite_autoconfig"]) - if result is False: - msg = "invalid vrf_lite_autoconfig " - msg += f"{self.config['vrf_lite_autoconfig']}. Expected one of 0,1" - self._append_msg(msg) - self.result = False - return - self.config["vrf_lite_autoconfig"] = result - - # validate self.config for cross-parameter dependencies - self._validate_dependencies() - if self.result is False: - return - self._build_payload() - - def _build_default_nv_pairs(self): - """ - Caller: __init__() - - Build a dict() of default fabric nvPairs that will be sent to NDFC. - The values for these items are what NDFC currently (as of 12.1.2e) - uses for defaults. Items that are supported by this module may be - modified by the user's playbook. - """ - self._default_nv_pairs = {} - self._default_nv_pairs["AAA_REMOTE_IP_ENABLED"] = False - self._default_nv_pairs["AAA_SERVER_CONF"] = "" - self._default_nv_pairs["ACTIVE_MIGRATION"] = False - self._default_nv_pairs["ADVERTISE_PIP_BGP"] = False - self._default_nv_pairs["AGENT_INTF"] = "eth0" - self._default_nv_pairs["ANYCAST_BGW_ADVERTISE_PIP"] = False - self._default_nv_pairs["ANYCAST_GW_MAC"] = "2020.0000.00aa" - self._default_nv_pairs["ANYCAST_LB_ID"] = "" - # self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" - self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "" - self._default_nv_pairs["ANYCAST_RP_IP_RANGE_INTERNAL"] = "" - self._default_nv_pairs["AUTO_SYMMETRIC_DEFAULT_VRF"] = False - self._default_nv_pairs["AUTO_SYMMETRIC_VRF_LITE"] = False - self._default_nv_pairs["AUTO_VRFLITE_IFC_DEFAULT_VRF"] = False - self._default_nv_pairs["BFD_AUTH_ENABLE"] = False - self._default_nv_pairs["BFD_AUTH_KEY"] = "" - self._default_nv_pairs["BFD_AUTH_KEY_ID"] = "" - self._default_nv_pairs["BFD_ENABLE"] = False - self._default_nv_pairs["BFD_IBGP_ENABLE"] = False - self._default_nv_pairs["BFD_ISIS_ENABLE"] = False - self._default_nv_pairs["BFD_OSPF_ENABLE"] = False - self._default_nv_pairs["BFD_PIM_ENABLE"] = False - self._default_nv_pairs["BGP_AS"] = "1" - self._default_nv_pairs["BGP_AS_PREV"] = "" - self._default_nv_pairs["BGP_AUTH_ENABLE"] = False - self._default_nv_pairs["BGP_AUTH_KEY"] = "" - self._default_nv_pairs["BGP_AUTH_KEY_TYPE"] = "" - self._default_nv_pairs["BGP_LB_ID"] = "0" - self._default_nv_pairs["BOOTSTRAP_CONF"] = "" - self._default_nv_pairs["BOOTSTRAP_ENABLE"] = False - self._default_nv_pairs["BOOTSTRAP_ENABLE_PREV"] = False - self._default_nv_pairs["BOOTSTRAP_MULTISUBNET"] = "" - self._default_nv_pairs["BOOTSTRAP_MULTISUBNET_INTERNAL"] = "" - self._default_nv_pairs["BRFIELD_DEBUG_FLAG"] = "Disable" - self._default_nv_pairs[ - "BROWNFIELD_NETWORK_NAME_FORMAT" - ] = "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$" - key = "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS" - self._default_nv_pairs[key] = False - self._default_nv_pairs["CDP_ENABLE"] = False - self._default_nv_pairs["COPP_POLICY"] = "strict" - self._default_nv_pairs["DCI_SUBNET_RANGE"] = "10.33.0.0/16" - self._default_nv_pairs["DCI_SUBNET_TARGET_MASK"] = "30" - self._default_nv_pairs["DEAFULT_QUEUING_POLICY_CLOUDSCALE"] = "" - self._default_nv_pairs["DEAFULT_QUEUING_POLICY_OTHER"] = "" - self._default_nv_pairs["DEAFULT_QUEUING_POLICY_R_SERIES"] = "" - self._default_nv_pairs["DEFAULT_VRF_REDIS_BGP_RMAP"] = "" - self._default_nv_pairs["DEPLOYMENT_FREEZE"] = False - self._default_nv_pairs["DHCP_ENABLE"] = False - self._default_nv_pairs["DHCP_END"] = "" - self._default_nv_pairs["DHCP_END_INTERNAL"] = "" - self._default_nv_pairs["DHCP_IPV6_ENABLE"] = "" - self._default_nv_pairs["DHCP_IPV6_ENABLE_INTERNAL"] = "" - self._default_nv_pairs["DHCP_START"] = "" - self._default_nv_pairs["DHCP_START_INTERNAL"] = "" - self._default_nv_pairs["DNS_SERVER_IP_LIST"] = "" - self._default_nv_pairs["DNS_SERVER_VRF"] = "" - self._default_nv_pairs["ENABLE_AAA"] = False - self._default_nv_pairs["ENABLE_AGENT"] = False - self._default_nv_pairs["ENABLE_DEFAULT_QUEUING_POLICY"] = False - self._default_nv_pairs["ENABLE_EVPN"] = True - self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID"] = False - self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID_PREV"] = "" - self._default_nv_pairs["ENABLE_MACSEC"] = False - self._default_nv_pairs["ENABLE_NETFLOW"] = False - self._default_nv_pairs["ENABLE_NETFLOW_PREV"] = "" - self._default_nv_pairs["ENABLE_NGOAM"] = True - self._default_nv_pairs["ENABLE_NXAPI"] = True - self._default_nv_pairs["ENABLE_NXAPI_HTTP"] = True - self._default_nv_pairs["ENABLE_PBR"] = False - self._default_nv_pairs["ENABLE_PVLAN"] = False - self._default_nv_pairs["ENABLE_PVLAN_PREV"] = False - self._default_nv_pairs["ENABLE_TENANT_DHCP"] = True - self._default_nv_pairs["ENABLE_TRM"] = False - self._default_nv_pairs["ENABLE_VPC_PEER_LINK_NATIVE_VLAN"] = False - self._default_nv_pairs["EXTRA_CONF_INTRA_LINKS"] = "" - self._default_nv_pairs["EXTRA_CONF_LEAF"] = "" - self._default_nv_pairs["EXTRA_CONF_SPINE"] = "" - self._default_nv_pairs["EXTRA_CONF_TOR"] = "" - self._default_nv_pairs["FABRIC_INTERFACE_TYPE"] = "p2p" - self._default_nv_pairs["FABRIC_MTU"] = "9216" - self._default_nv_pairs["FABRIC_MTU_PREV"] = "9216" - self._default_nv_pairs["FABRIC_NAME"] = "easy-fabric" - self._default_nv_pairs["FABRIC_TYPE"] = "Switch_Fabric" - self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID"] = "" - self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID_PREV"] = "" - self._default_nv_pairs["FABRIC_VPC_QOS"] = False - self._default_nv_pairs["FABRIC_VPC_QOS_POLICY_NAME"] = "" - self._default_nv_pairs["FEATURE_PTP"] = False - self._default_nv_pairs["FEATURE_PTP_INTERNAL"] = False - self._default_nv_pairs["FF"] = "Easy_Fabric" - self._default_nv_pairs["GRFIELD_DEBUG_FLAG"] = "Disable" - self._default_nv_pairs["HD_TIME"] = "180" - self._default_nv_pairs["HOST_INTF_ADMIN_STATE"] = True - self._default_nv_pairs["IBGP_PEER_TEMPLATE"] = "" - self._default_nv_pairs["IBGP_PEER_TEMPLATE_LEAF"] = "" - self._default_nv_pairs["INBAND_DHCP_SERVERS"] = "" - self._default_nv_pairs["INBAND_MGMT"] = False - self._default_nv_pairs["INBAND_MGMT_PREV"] = False - self._default_nv_pairs["ISIS_AUTH_ENABLE"] = False - self._default_nv_pairs["ISIS_AUTH_KEY"] = "" - self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_KEY_ID"] = "" - self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_NAME"] = "" - self._default_nv_pairs["ISIS_LEVEL"] = "" - self._default_nv_pairs["ISIS_OVERLOAD_ELAPSE_TIME"] = "" - self._default_nv_pairs["ISIS_OVERLOAD_ENABLE"] = False - self._default_nv_pairs["ISIS_P2P_ENABLE"] = False - self._default_nv_pairs["L2_HOST_INTF_MTU"] = "9216" - self._default_nv_pairs["L2_HOST_INTF_MTU_PREV"] = "9216" - self._default_nv_pairs["L2_SEGMENT_ID_RANGE"] = "30000-49000" - self._default_nv_pairs["L3VNI_MCAST_GROUP"] = "" - self._default_nv_pairs["L3_PARTITION_ID_RANGE"] = "50000-59000" - self._default_nv_pairs["LINK_STATE_ROUTING"] = "ospf" - self._default_nv_pairs["LINK_STATE_ROUTING_TAG"] = "UNDERLAY" - self._default_nv_pairs["LINK_STATE_ROUTING_TAG_PREV"] = "" - self._default_nv_pairs["LOOPBACK0_IPV6_RANGE"] = "" - self._default_nv_pairs["LOOPBACK0_IP_RANGE"] = "10.2.0.0/22" - self._default_nv_pairs["LOOPBACK1_IPV6_RANGE"] = "" - self._default_nv_pairs["LOOPBACK1_IP_RANGE"] = "10.3.0.0/22" - self._default_nv_pairs["MACSEC_ALGORITHM"] = "" - self._default_nv_pairs["MACSEC_CIPHER_SUITE"] = "" - self._default_nv_pairs["MACSEC_FALLBACK_ALGORITHM"] = "" - self._default_nv_pairs["MACSEC_FALLBACK_KEY_STRING"] = "" - self._default_nv_pairs["MACSEC_KEY_STRING"] = "" - self._default_nv_pairs["MACSEC_REPORT_TIMER"] = "" - self._default_nv_pairs["MGMT_GW"] = "" - self._default_nv_pairs["MGMT_GW_INTERNAL"] = "" - self._default_nv_pairs["MGMT_PREFIX"] = "" - self._default_nv_pairs["MGMT_PREFIX_INTERNAL"] = "" - self._default_nv_pairs["MGMT_V6PREFIX"] = "64" - self._default_nv_pairs["MGMT_V6PREFIX_INTERNAL"] = "" - self._default_nv_pairs["MPLS_HANDOFF"] = False - self._default_nv_pairs["MPLS_LB_ID"] = "" - self._default_nv_pairs["MPLS_LOOPBACK_IP_RANGE"] = "" - self._default_nv_pairs["MSO_CONNECTIVITY_DEPLOYED"] = "" - self._default_nv_pairs["MSO_CONTROLER_ID"] = "" - self._default_nv_pairs["MSO_SITE_GROUP_NAME"] = "" - self._default_nv_pairs["MSO_SITE_ID"] = "" - self._default_nv_pairs["MST_INSTANCE_RANGE"] = "" - self._default_nv_pairs["MULTICAST_GROUP_SUBNET"] = "239.1.1.0/25" - self._default_nv_pairs["NETFLOW_EXPORTER_LIST"] = "" - self._default_nv_pairs["NETFLOW_MONITOR_LIST"] = "" - self._default_nv_pairs["NETFLOW_RECORD_LIST"] = "" - self._default_nv_pairs["NETWORK_VLAN_RANGE"] = "2300-2999" - self._default_nv_pairs["NTP_SERVER_IP_LIST"] = "" - self._default_nv_pairs["NTP_SERVER_VRF"] = "" - self._default_nv_pairs["NVE_LB_ID"] = "1" - self._default_nv_pairs["OSPF_AREA_ID"] = "0.0.0.0" - self._default_nv_pairs["OSPF_AUTH_ENABLE"] = False - self._default_nv_pairs["OSPF_AUTH_KEY"] = "" - self._default_nv_pairs["OSPF_AUTH_KEY_ID"] = "" - self._default_nv_pairs["OVERLAY_MODE"] = "config-profile" - self._default_nv_pairs["OVERLAY_MODE_PREV"] = "" - self._default_nv_pairs["PHANTOM_RP_LB_ID1"] = "" - self._default_nv_pairs["PHANTOM_RP_LB_ID2"] = "" - self._default_nv_pairs["PHANTOM_RP_LB_ID3"] = "" - self._default_nv_pairs["PHANTOM_RP_LB_ID4"] = "" - self._default_nv_pairs["PIM_HELLO_AUTH_ENABLE"] = False - self._default_nv_pairs["PIM_HELLO_AUTH_KEY"] = "" - self._default_nv_pairs["PM_ENABLE"] = False - self._default_nv_pairs["PM_ENABLE_PREV"] = False - self._default_nv_pairs["POWER_REDUNDANCY_MODE"] = "ps-redundant" - self._default_nv_pairs["PREMSO_PARENT_FABRIC"] = "" - self._default_nv_pairs["PTP_DOMAIN_ID"] = "" - self._default_nv_pairs["PTP_LB_ID"] = "" - self._default_nv_pairs["REPLICATION_MODE"] = "Multicast" - self._default_nv_pairs["ROUTER_ID_RANGE"] = "" - self._default_nv_pairs["ROUTE_MAP_SEQUENCE_NUMBER_RANGE"] = "1-65534" - self._default_nv_pairs["RP_COUNT"] = "2" - self._default_nv_pairs["RP_LB_ID"] = "254" - self._default_nv_pairs["RP_MODE"] = "asm" - self._default_nv_pairs["RR_COUNT"] = "2" - self._default_nv_pairs["SEED_SWITCH_CORE_INTERFACES"] = "" - self._default_nv_pairs["SERVICE_NETWORK_VLAN_RANGE"] = "3000-3199" - self._default_nv_pairs["SITE_ID"] = "" - self._default_nv_pairs["SNMP_SERVER_HOST_TRAP"] = True - self._default_nv_pairs["SPINE_COUNT"] = "0" - self._default_nv_pairs["SPINE_SWITCH_CORE_INTERFACES"] = "" - self._default_nv_pairs["SSPINE_ADD_DEL_DEBUG_FLAG"] = "Disable" - self._default_nv_pairs["SSPINE_COUNT"] = "0" - self._default_nv_pairs["STATIC_UNDERLAY_IP_ALLOC"] = False - self._default_nv_pairs["STP_BRIDGE_PRIORITY"] = "" - self._default_nv_pairs["STP_ROOT_OPTION"] = "unmanaged" - self._default_nv_pairs["STP_VLAN_RANGE"] = "" - self._default_nv_pairs["STRICT_CC_MODE"] = False - self._default_nv_pairs["SUBINTERFACE_RANGE"] = "2-511" - self._default_nv_pairs["SUBNET_RANGE"] = "10.4.0.0/16" - self._default_nv_pairs["SUBNET_TARGET_MASK"] = "30" - self._default_nv_pairs["SYSLOG_SERVER_IP_LIST"] = "" - self._default_nv_pairs["SYSLOG_SERVER_VRF"] = "" - self._default_nv_pairs["SYSLOG_SEV"] = "" - self._default_nv_pairs["TCAM_ALLOCATION"] = True - self._default_nv_pairs["UNDERLAY_IS_V6"] = False - self._default_nv_pairs["UNNUM_BOOTSTRAP_LB_ID"] = "" - self._default_nv_pairs["UNNUM_DHCP_END"] = "" - self._default_nv_pairs["UNNUM_DHCP_END_INTERNAL"] = "" - self._default_nv_pairs["UNNUM_DHCP_START"] = "" - self._default_nv_pairs["UNNUM_DHCP_START_INTERNAL"] = "" - self._default_nv_pairs["USE_LINK_LOCAL"] = False - self._default_nv_pairs["V6_SUBNET_RANGE"] = "" - self._default_nv_pairs["V6_SUBNET_TARGET_MASK"] = "" - self._default_nv_pairs["VPC_AUTO_RECOVERY_TIME"] = "360" - self._default_nv_pairs["VPC_DELAY_RESTORE"] = "150" - self._default_nv_pairs["VPC_DELAY_RESTORE_TIME"] = "60" - self._default_nv_pairs["VPC_DOMAIN_ID_RANGE"] = "1-1000" - self._default_nv_pairs["VPC_ENABLE_IPv6_ND_SYNC"] = True - self._default_nv_pairs["VPC_PEER_KEEP_ALIVE_OPTION"] = "management" - self._default_nv_pairs["VPC_PEER_LINK_PO"] = "500" - self._default_nv_pairs["VPC_PEER_LINK_VLAN"] = "3600" - self._default_nv_pairs["VRF_LITE_AUTOCONFIG"] = "Manual" - self._default_nv_pairs["VRF_VLAN_RANGE"] = "2000-2299" - self._default_nv_pairs["abstract_anycast_rp"] = "anycast_rp" - self._default_nv_pairs["abstract_bgp"] = "base_bgp" - value = "evpn_bgp_rr_neighbor" - self._default_nv_pairs["abstract_bgp_neighbor"] = value - self._default_nv_pairs["abstract_bgp_rr"] = "evpn_bgp_rr" - self._default_nv_pairs["abstract_dhcp"] = "base_dhcp" - self._default_nv_pairs[ - "abstract_extra_config_bootstrap" - ] = "extra_config_bootstrap_11_1" - value = "extra_config_leaf" - self._default_nv_pairs["abstract_extra_config_leaf"] = value - value = "extra_config_spine" - self._default_nv_pairs["abstract_extra_config_spine"] = value - value = "extra_config_tor" - self._default_nv_pairs["abstract_extra_config_tor"] = value - value = "base_feature_leaf_upg" - self._default_nv_pairs["abstract_feature_leaf"] = value - value = "base_feature_spine_upg" - self._default_nv_pairs["abstract_feature_spine"] = value - self._default_nv_pairs["abstract_isis"] = "base_isis_level2" - self._default_nv_pairs["abstract_isis_interface"] = "isis_interface" - self._default_nv_pairs[ - "abstract_loopback_interface" - ] = "int_fabric_loopback_11_1" - self._default_nv_pairs["abstract_multicast"] = "base_multicast_11_1" - self._default_nv_pairs["abstract_ospf"] = "base_ospf" - value = "ospf_interface_11_1" - self._default_nv_pairs["abstract_ospf_interface"] = value - self._default_nv_pairs["abstract_pim_interface"] = "pim_interface" - self._default_nv_pairs["abstract_route_map"] = "route_map" - self._default_nv_pairs["abstract_routed_host"] = "int_routed_host" - self._default_nv_pairs["abstract_trunk_host"] = "int_trunk_host" - value = "int_fabric_vlan_11_1" - self._default_nv_pairs["abstract_vlan_interface"] = value - self._default_nv_pairs["abstract_vpc_domain"] = "base_vpc_domain_11_1" - value = "Default_Network_Universal" - self._default_nv_pairs["default_network"] = value - self._default_nv_pairs["default_pvlan_sec_network"] = "" - self._default_nv_pairs["default_vrf"] = "Default_VRF_Universal" - self._default_nv_pairs["enableRealTimeBackup"] = "" - self._default_nv_pairs["enableScheduledBackup"] = "" - self._default_nv_pairs[ - "network_extension_template" - ] = "Default_Network_Extension_Universal" - self._default_nv_pairs["scheduledTime"] = "" - self._default_nv_pairs["temp_anycast_gateway"] = "anycast_gateway" - self._default_nv_pairs["temp_vpc_domain_mgmt"] = "vpc_domain_mgmt" - self._default_nv_pairs["temp_vpc_peer_link"] = "int_vpc_peer_link_po" - self._default_nv_pairs[ - "vrf_extension_template" - ] = "Default_VRF_Extension_Universal" - - def _build_default_fabric_params(self): - """ - Caller: __init__() - - Initialize default NDFC top-level parameters - See also: self._build_default_nv_pairs() - """ - # TODO:3 We may need translation methods for these as well. See the - # method for nvPair transation: _translate_to_ndfc_nv_pairs - self._default_fabric_params = {} - self._default_fabric_params["deviceType"] = "n9k" - self._default_fabric_params["fabricTechnology"] = "VXLANFabric" - self._default_fabric_params["fabricTechnologyFriendly"] = "VXLAN Fabric" - self._default_fabric_params["fabricType"] = "Switch_Fabric" - self._default_fabric_params["fabricTypeFriendly"] = "Switch Fabric" - self._default_fabric_params[ - "networkExtensionTemplate" - ] = "Default_Network_Extension_Universal" - value = "Default_Network_Universal" - self._default_fabric_params["networkTemplate"] = value - self._default_fabric_params["provisionMode"] = "DCNMTopDown" - self._default_fabric_params["replicationMode"] = "Multicast" - self._default_fabric_params["siteId"] = "" - self._default_fabric_params["templateName"] = "Easy_Fabric" - self._default_fabric_params[ - "vrfExtensionTemplate" - ] = "Default_VRF_Extension_Universal" - self._default_fabric_params["vrfTemplate"] = "Default_VRF_Universal" - - def _build_translatable_nv_pairs(self): - """ - Caller: _translate_to_ndfc_nv_pairs() - - All parameters in the playbook are lowercase dunder, while - NDFC nvPairs contains a mish-mash of styles, for example: - - enableScheduledBackup - - default_vrf - - REPLICATION_MODE - - This method builds a set of playbook parameters that conform to the - most common case (uppercase dunder e.g. REPLICATION_MODE) and so - can safely be translated to uppercase dunder style that NDFC expects - in the payload. - - See also: self._translate_to_ndfc_nv_pairs, where the actual - translation happens. - """ - # self._default_nv_pairs is already built via create_fabric() - # Given we have a specific controlled input, we can use a more - # relaxed regex here. We just want to exclude camelCase e.g. - # "thisThing", lowercase dunder e.g. "this_thing", and lowercase - # e.g. "thisthing". - re_uppercase_dunder = "^[A-Z0-9_]+$" - self._translatable_nv_pairs = set() - for param in self._default_nv_pairs: - if re.search(re_uppercase_dunder, param): - self._translatable_nv_pairs.add(param.lower()) - - def _translate_to_ndfc_nv_pairs(self, params): - """ - Caller: self._build_payload() - - translate keys in params dict into what NDFC - expects in nvPairs and populate dict - self._translated_nv_pairs - - """ - self._build_translatable_nv_pairs() - # TODO:4 We currently don't handle non-dunder uppercase and lowercase, - # e.g. THIS or that. But (knock on wood), so far there are no - # cases like this (or THAT). - self._translated_nv_pairs = {} - # upper-case dunder keys - for param in self._translatable_nv_pairs: - if param not in params: - continue - self._translated_nv_pairs[param.upper()] = params[param] - # special cases - # dunder keys, these need no modification - dunder_keys = { - "default_network", - "default_vrf", - "network_extension_template", - "vrf_extension_template", - } - for key in dunder_keys: - if key not in params: - continue - self._translated_nv_pairs[key] = params[key] - # camelCase keys - # These are currently manually mapped with a dictionary. - camel_keys = { - "enableRealTimeBackup": "enable_real_time_backup", - "enableScheduledBackup": "enable_scheduled_backup", - "scheduledTime": "scheduled_time", - } - for ndfc_key, user_key in camel_keys.items(): - if user_key not in params: - continue - self._translated_nv_pairs[ndfc_key] = params[user_key] - - def _build_mandatory_params(self): - """ - Caller: self._validate_dependencies() - - build a map of mandatory parameters. - - Certain parameters become mandatory only if another parameter is - set, or only if it's set to a specific value. For example, if - underlay_is_v6 is set to True, the following parameters become - mandatory: - - anycast_lb_id - - loopback0_ipv6_range - - loopback1_ipv6_range - - router_id_range - - v6_subnet_range - - v6_subnet_target_mask - - self._mandatory_params is a dictionary, keyed on parameter. - The value is a dictionary with the following keys: - - value: The parameter value that makes the dependent parameters - mandatory. Using underlay_is_v6 as an example, it must - have a value of True, for the six dependent parameters to - be considered mandatory. - mandatory: a python dict() containing mandatory parameters and what - value (if any) they must have. Indicate that the value - should not be considered by setting it to None. - - NOTE: Generalized parameter value validation is handled elsewhere - - Hence, we have the following structure for the - self._mandatory_params dictionary, to handle the case where - underlay_is_v6 is set to True. Below, we don't case what the - value for any of the mandatory parameters is. We only care that - they are set. - - self._mandatory_params = { - "underlay_is_v6": { - "value": True, - "mandatory": { - "anycast_lb_id": None - "loopback0_ipv6_range": None - "loopback1_ipv6_range": None - "router_id_range": None - "v6_subnet_range": None - "v6_subnet_target_mask": None - } - } - } - - Above, we validate that all mandatory parameters are set, only - if the value of underlay_is_v6 is True. - - Set "value:" above to "__any__" if the dependent parameters are - mandatory regardless of the parameter's value. For example, if - we wanted to verify that underlay_is_v6 is set to True in the case - that anycast_lb_id is set (which can be a value between 1-1023) we - don't care what the value of anycast_lb_id is. We only care that - underlay_is_v6 is set to True. In this case, we could add the following: - - self._mandatory_params.update = { - "anycast_lb_id": { - "value": "__any__", - "mandatory": { - "underlay_is_v6": True - } - } - } - - """ - self._mandatory_params = {} - self._mandatory_params.update( - { - "anycast_lb_id": { - "value": "__any__", - "mandatory": {"underlay_is_v6": True}, - } - } - ) - self._mandatory_params.update( - { - "underlay_is_v6": { - "value": True, - "mandatory": { - "anycast_lb_id": None, - "loopback0_ipv6_range": None, - "loopback1_ipv6_range": None, - "router_id_range": None, - "v6_subnet_range": None, - "v6_subnet_target_mask": None, - }, - } - } - ) - self._mandatory_params.update( - { - "auto_symmetric_default_vrf": { - "value": True, - "mandatory": { - "vrf_lite_autoconfig": "Back2Back&ToExternal", - "auto_vrflite_ifc_default_vrf": True, - }, - } - } - ) - self._mandatory_params.update( - { - "auto_symmetric_vrf_lite": { - "value": True, - "mandatory": {"vrf_lite_autoconfig": "Back2Back&ToExternal"}, - } - } - ) - self._mandatory_params.update( - { - "auto_vrflite_ifc_default_vrf": { - "value": True, - "mandatory": { - "vrf_lite_autoconfig": "Back2Back&ToExternal", - "default_vrf_redis_bgp_rmap": None, - }, - } - } - ) - - def _build_parameter_aliases(self): - """ - Caller self._validate_dependencies() - - For some parameters, like vrf_lite_autoconfig, we don't - want the user to have to remember the spelling for - their values e.g. Back2Back&ToExternal. So, we alias - the value NDFC expects (Back2Back&ToExternal) to something - easier. In this case, 1. - - See also: _get_parameter_alias() - """ - self._parameter_aliases = {} - self._parameter_aliases["vrf_lite_autoconfig"] = { - "Back2Back&ToExternal": 1, - "Manual": 0, - } - - def _get_parameter_alias(self, param, value): - """ - Caller: self._validate_dependencies() - - Accessor method for self._parameter_aliases - - param: the parameter - value: the parameter's value that NDFC expects - - Return the value alias for param (i.e. param's value - prior to translation, i.e. the value that's used in the - playbook) if it exists. - - Return None otherwise - - See also: self._build_parameter_aliases() - """ - if param not in self._parameter_aliases: - return None - if value not in self._parameter_aliases[param]: - return None - return self._parameter_aliases[param][value] - - def _build_failed_dependencies(self): - """ - If the user has set one or more parameters that, in turn, cause - other parameters to become mandatory, build a dictionary of these - dependencies and what value is expected for each. - - Example self._failed_dependencies. In this case, the user set - auto_symmetric_vrf_lite to True, which makes vrf_lite_autoconfig - mandatory. Too, vrf_lite_autoconfig MUST have a value of - Back2Back&ToExternal. Though, in the playbook, the sets - vrf_lite_autoconfig to 1, since 1 is an alias for - Back2Back&ToExternal. See self._handle_failed_dependencies() - for how we handle aliased parameters. - - { - 'vrf_lite_autoconfig': 'Back2Back&ToExternal' - } - """ - if not self._requires_validation: - return - self._failed_dependencies = {} - for user_param in self._requires_validation: - # mandatory_params associated with user_param - mandatory_params = self._mandatory_params[user_param]["mandatory"] - for check_param in mandatory_params: - check_value = mandatory_params[check_param] - if check_param not in self.config and check_value is not None: - # The playbook doesn't contain this mandatory parameter. - # We care what the value is (since it's not None). - # If the mandatory parameter's default value is not equal - # to the required value, add it to the failed dependencies. - param_up = check_param.upper() - if param_up in self._default_nv_pairs: - if self._default_nv_pairs[param_up] != check_value: - self._failed_dependencies[check_param] = check_value - continue - if self.config[check_param] != check_value and check_value is not None: - # The playbook does contain this mandatory parameter, but - # the value in the playbook does not match the required value - # and we care about what the required value is. - self._failed_dependencies[check_param] = check_value - continue - print(f"self._failed_dependencies {self._failed_dependencies}") - - def _validate_dependencies(self): - """ - Validate cross-parameter dependencies. - - Caller: self._validate_config_for_merged_state() - - On failure to validate cross-parameter dependencies: - set self.result to False - set self.msg to an appropriate error message - - See also: docstring for self._build_mandatory_params() - """ - self._build_mandatory_params() - self._build_parameter_aliases() - self._requires_validation = set() - for user_param in self.config: - # param doesn't have any dependent parameters - if user_param not in self._mandatory_params: - continue - # need to run validation for user_param with value "__any__" - if self._mandatory_params[user_param]["value"] == "__any__": - self._requires_validation.add(user_param) - # need to run validation because user_param is a specific value - if self.config[user_param] == self._mandatory_params[user_param]["value"]: - self._requires_validation.add(user_param) - if not self._requires_validation: - return - self._build_failed_dependencies() - self._handle_failed_dependencies() - - def _handle_failed_dependencies(self): - """ - If there are failed dependencies: - 1. Set self.result to False - 2. Build a useful message for the user that lists - the additional parameters that NDFC expects - """ - if not self._failed_dependencies: - return - for user_param in self._requires_validation: - if self._mandatory_params[user_param]["value"] == "any": - msg = f"When {user_param} is set, " - else: - msg = f"When {user_param} is set to " - msg += f"{self._mandatory_params[user_param]['value']}, " - msg += "the following parameters are mandatory: " - - for key, value in self._failed_dependencies.items(): - msg += f"parameter {key} " - if value is None: - msg += "value " - else: - # If the value expected in the playbook is different - # from the value sent to NDFC, use the value expected in - # the playbook so as not to confuse the user. - alias = self._get_parameter_alias(key, value) - if alias is None: - msg_value = value - else: - msg_value = alias - msg += f"value {msg_value}" - self._append_msg(msg) - self.result = False - - def _build_payload(self): - """ - Build the payload to create the fabric specified self.config - Caller: _validate_dependencies - """ - self.payload = self._default_fabric_params - self.payload["fabricName"] = self.config["fabric_name"] - self.payload["asn"] = self.config["bgp_as"] - self.payload["nvPairs"] = self._default_nv_pairs - self._translate_to_ndfc_nv_pairs(self.config) - for key, value in self._translated_nv_pairs.items(): - self.payload["nvPairs"][key] = value - - @property - def config(self): - """ - Basic initial validatation for individual fabric configuration - Verifies that config is a dict() and that mandatory keys are - present. - """ - return self.properties["config"] - - @config.setter - def config(self, param): - if not self._validate_config(param): - return - self.properties["config"] = param - - @property - def msg(self): - """ - messages to return to the caller - """ - return self.properties["msg"] - - @msg.setter - def msg(self, param): - self.properties["msg"] = param - - @property - def payload(self): - """ - The payload to send to NDFC - """ - return self.properties["payload"] - - @payload.setter - def payload(self, param): - self.properties["payload"] = param - - @property - def result(self): - """ - get/set intermediate results and final result - """ - return self.properties["result"] - - @result.setter - def result(self, param): - self.properties["result"] = param - - @property - def state(self): - """ - The Ansible state provided by the caller - """ - return self.properties["state"] - - @state.setter - def state(self, param): - if param not in self._valid_states: - msg = f"invalid state {param}. " - msg += f"expected one of: {','.join(sorted(self._valid_states))}" - self.result = False - self._append_msg(msg) - self.properties["state"] = param diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py index c352f60ad..c1e177d02 100644 --- a/plugins/module_utils/fabric/fabric_details.py +++ b/plugins/module_utils/fabric/fabric_details.py @@ -18,28 +18,34 @@ __metaclass__ = type __author__ = "Allen Robel" +import copy import inspect import json import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ - ApiEndpoints -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ - FabricCommon from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ RestSend +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): """ - Retrieve fabric details from the controller and provide - property accessors for the fabric attributes. + Parent class for *FabricDetails() subclasses. + See subclass docstrings for details. """ + def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.log.debug("ENTERED FabricDetails()") + msg = "ENTERED FabricDetails()" + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) self.data = {} self.endpoints = ApiEndpoints() @@ -49,11 +55,15 @@ def __init__(self, ansible_module): def _init_properties(self): # self.properties is already initialized in the parent class - self.properties["foo"] = "bar" + pass def refresh_super(self): """ - Refresh the fabric details from the controller. + 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] endpoint = self.endpoints.fabrics @@ -65,6 +75,10 @@ def refresh_super(self): self.data[item["fabricName"]] = item msg = f"item: {json.dumps(item, indent=4, sort_keys=True)}" self.log.debug(msg) + self.response_current = self.rest_send.response_current + self.response = self.rest_send.response_current + self.result_current = self.rest_send.result_current + self.result = self.rest_send.result_current def _get(self, item): """ @@ -170,6 +184,7 @@ def template_name(self): """ return self._get("templateName") + class FabricDetailsByName(FabricDetails): """ Retrieve fabric details from the controller and provide @@ -180,12 +195,23 @@ class FabricDetailsByName(FabricDetails): instance = FabricDetailsByName(module) instance.refresh() instance.filter = "MyFabric" - bgp_as = instance.bgp_as + # BGP AS for fabric "MyFabric" + bgp_as = instance.asn + + # all fabric details for "MyFabric" fabric_dict = instance.filtered_data etc... - See FabricDetails for more details. + Or: + + instance.FabricDetailsByName(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, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ @@ -201,15 +227,20 @@ def refresh(self): Refresh fabric_name current details from the controller """ self.refresh_super() - self.data_subclass = {} - for item in self.response_current: - self.data_subclass[item["fabricName"]] = item + self.data_subclass = copy.deepcopy(self.data) - msg = f"{self.class_name}.refresh(): self.data_subclass: " + msg = "self.data_subclass: " msg += f"{json.dumps(self.data_subclass, indent=4, sort_keys=True)}" self.log.debug(msg) def _get(self, item): + """ + Retrieve the value of the top-level (non-nvPair) + item for the fabric specified (anything not in + the nvPairs dictionary). + + See also: _get_nv_pair() + """ method_name = inspect.stack()[0][3] if self.filter is None: @@ -233,6 +264,12 @@ def _get(self, item): ) def _get_nv_pair(self, item): + """ + Retrieve the value of the nvPair item + for the fabric specified. + + See also: _get() + """ method_name = inspect.stack()[0][3] if self.filter is None: @@ -277,3 +314,89 @@ def filter(self): @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 ansible_module is an instance of AnsibleModule): + + instance = FabricDetailsNvPair(ansible_module) + instance.refresh() + instance.filter_key = "DCI_SUBNET_RANGE" + instance.filter_value = "10.33.0.0/16" + fabrics = instance.filtered_data + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + 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 + """ + if self.filter_key is None: + msg = "set instance.filter_key to a nvPair key " + msg += "before calling refresh()." + self.ansible_module.fail_json(msg, **self.failed_result) + if self.filter_value is None: + msg = "set instance.filter_value to a nvPair value " + msg += "before calling refresh()." + self.ansible_module.fail_json(msg, **self.failed_result) + + self.refresh_super() + for item in self.data: + if self.data[item].get("nvPairs", {}).get(self.filter_key) == self.filter_value: + self.data_subclass[item["fabricName"]] = self.data[item] + + msg = "self.data_subclass: " + msg += f"{json.dumps(self.data_subclass, indent=4, sort_keys=True)}" + self.log.debug(msg) + + @property + def filtered_data(self): + """ + Return a dictionary of the fabric(s) matching self.filter_key + and self.filter_value. + Return None if the fabric does not exist on the controller. + """ + return self.data_subclass + + @property + def filter_key(self): + """ + Return 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): + """ + Return 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..6c3f8b110 --- /dev/null +++ b/plugins/module_utils/fabric/fabric_summary.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 json +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend +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): + """ + Return populate self.data with fabric summary information, + formatted as a dictionary. + + 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. + + { + "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: + + instance = FabricSummary(ansible_module) + instance.fabric_name = "MyFabric" + instance.refresh() + fabric_summary = instance.data + device_count = instance.device_count + etc... + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + msg = "ENTERED FabricSummary(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self.data = None + self.endpoints = ApiEndpoints() + self.rest_send = RestSend(self.ansible_module) + + self._init_properties() + + def _init_properties(self): + # 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): + """ + Get the device counts from the controller. + """ + method_name = inspect.stack()[0][3] + if self.data is None: + self.fail(f"refresh() must be called before accessing {method_name}.") + 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 refresh(self): + """ + Refresh the fabric summary info from the controller and + populate self.data with the result. + + self.data is a dictionary of fabric summary info for one fabric. + """ + method_name = inspect.stack()[0][3] + if self.fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "fabric_name is required." + self.ansible_module.fail_json(msg, **self.failed_result) + + 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) + self.ansible_module.fail_json(msg, **self.failed_result) + + self.rest_send.commit() + 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.response_current = self.rest_send.response_current + self.response = self.rest_send.response_current + self.result_current = self.rest_send.result_current + self.result = self.rest_send.result_current + + self._update_device_counts() + + def verify_refresh(self, method_name): + """ + If refresh() has not been called, fail with a message. + """ + if self.data is None: + msg = f"refresh() must be called before accessing {method_name}." + self.ansible_module.fail_json(msg, **self.failed_result) + + @property + def all_data(self) -> dict: + """ + Return all fabric details from the controller. + """ + return self.data + + @property + def border_gateway_count(self) -> int: + """ + Return the number of border gateway devices in fabric fabric_name. + """ + method_name = inspect.stack()[0][3] + self.verify_refresh(method_name) + return self.properties["border_gateway_count"] + + @property + def device_count(self) -> int: + """ + Return the total number of devices in fabric fabric_name. + """ + method_name = inspect.stack()[0][3] + self.verify_refresh(method_name) + return self.properties["device_count"] + + @property + def fabric_is_empty(self) -> bool: + """ + Return True if the fabric is empty. + """ + method_name = inspect.stack()[0][3] + self.verify_refresh(method_name) + if self.device_count == 0: + return True + return False + + @property + def fabric_name(self) -> str: + """ + Set the fabric_name to query. + """ + return self.properties.get("fabric_name") + + @fabric_name.setter + def fabric_name(self, value: str): + self.properties["fabric_name"] = value + + @property + def leaf_count(self) -> int: + """ + Return the number of leaf devices in fabric fabric_name. + """ + method_name = inspect.stack()[0][3] + self.verify_refresh(method_name) + return self.properties["leaf_count"] + + @property + def spine_count(self) -> int: + """ + Return the number of spine devices in fabric fabric_name. + """ + method_name = inspect.stack()[0][3] + self.verify_refresh(method_name) + return self.properties["spine_count"] + + + diff --git a/plugins/module_utils/fabric/fabric_task_result.py b/plugins/module_utils/fabric/fabric_task_result.py index 05717c5d0..949094fc2 100644 --- a/plugins/module_utils/fabric/fabric_task_result.py +++ b/plugins/module_utils/fabric/fabric_task_result.py @@ -76,20 +76,24 @@ class FabricTaskResult: def __init__(self, ansible_module): self.class_name = self.__class__.__name__ self.ansible_module = ansible_module + self.state = self.ansible_module.params["state"] self.check_mode = self.ansible_module.check_mode self.log = logging.getLogger(f"dcnm.{self.class_name}") msg = "ENTERED FabricTaskResult(): " + msg += f"state: {self.state}, " msg += f"check_mode: {self.check_mode}" self.log.debug(msg) - self.states = ["merged", "query"] + self.states = ["deleted", "merged", "query"] self.diff_properties = {} + self.diff_properties["diff_deleted"] = "deleted" self.diff_properties["diff_merged"] = "merged" self.diff_properties["diff_query"] = "query" self.response_properties = {} + self.response_properties["response_deleted"] = "deleted" self.response_properties["response_merged"] = "merged" self.response_properties["response_query"] = "query" @@ -100,9 +104,11 @@ def _build_properties(self): Build the properties dict() with default values """ self.properties = {} + self.properties["diff_deleted"] = [] self.properties["diff_merged"] = [] self.properties["diff_query"] = [] + self.properties["response_deleted"] = [] self.properties["response_merged"] = [] self.properties["response_query"] = [] @@ -162,12 +168,29 @@ def module_result(self): return result # diff properties + @property + def diff_deleted(self): + """ + Getter for diff_deleted property + + This is used for deleted state i.e. delete fabrics + """ + return self.properties["diff_deleted"] + + @diff_deleted.setter + def diff_deleted(self, value): + """ + Setter for diff_deleted property + """ + self._verify_is_dict(value) + self.properties["diff_deleted"].append(value) + @property def diff_merged(self): """ Getter for diff_merged property - This is used for merged state i.e. create image policies + This is used for merged state i.e. create fabrics """ return self.properties["diff_merged"] @@ -197,6 +220,21 @@ def diff_query(self, value): self.properties["diff_query"].append(value) # response properties + @property + def response_deleted(self): + """ + Getter for response_deleted property + """ + return self.properties["response_deleted"] + + @response_deleted.setter + def response_deleted(self, value): + """ + Setter for response_deleted property + """ + self._verify_is_dict(value) + self.properties["response_deleted"].append(value) + @property def response_merged(self): """ diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py new file mode 100644 index 000000000..009795c9a --- /dev/null +++ b/plugins/module_utils/fabric/query.py @@ -0,0 +1,151 @@ +# 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.fabric_details import \ + FabricDetailsByName + + +class FabricQuery(FabricCommon): + """ + Query fabrics + + Usage: + + instance = FabricQuery(ansible_module) + instance.fabric_names = ["FABRIC_1", "FABRIC_2"] + instance.commit() + diff = instance.diff # contains the fabric information + result = instance.result # contains the result(s) of the query + response = instance.response # contains the response(s) from the controller + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + 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._fabric_details = FabricDetailsByName(self.ansible_module) + + 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): + """ + return the fabric names + """ + 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}" + self.ansible_module.fail_json(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}." + self.ansible_module.fail_json(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}" + self.ansible_module.fail_json(msg) + self.properties["fabric_names"] = value + + def _get_fabrics_to_query(self) -> None: + """ + Retrieve fabric info from the controller and set the list of + controller fabrics that are in our fabric_names list. + """ + self._fabric_details.refresh() + + self._fabrics_to_query = [] + for fabric_name in self.fabric_names: + if fabric_name in self._fabric_details.all_data: + self._fabrics_to_query.append(fabric_name) + + def commit(self): + """ + query each of the fabrics in self.fabric_names + """ + 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." + self.ansible_module.fail_json(msg, **self.failed_result) + + self._get_fabrics_to_query() + + msg = f"self._fabrics_to_query: {self._fabrics_to_query}" + self.log.debug(msg) + if len(self._fabrics_to_query) == 0: + self.changed = False + self.failed = False + return + + msg = f"Populating diff {self._fabrics_to_query}" + self.log.debug(msg) + + for fabric_name in self._fabrics_to_query: + if fabric_name in self._fabric_details.all_data: + fabric = copy.deepcopy(self._fabric_details.all_data[fabric_name]) + fabric["action"] = self.action + self.diff = fabric + self.response = copy.deepcopy(self._fabric_details.response_current) + self.response_current = copy.deepcopy(self._fabric_details.response_current) + self.result = copy.deepcopy(self._fabric_details.result_current) + self.result_current = copy.deepcopy(self._fabric_details.result_current) + + msg = f"self.diff: {self.diff}" + self.log.debug(msg) + msg = f"self.response: {self.response}" + self.log.debug(msg) + msg = f"self.result: {self.result}" + self.log.debug(msg) + msg = f"self.response_current: {self.response_current}" + self.log.debug(msg) + msg = f"self.result_current: {self.result_current}" + self.log.debug(msg) diff --git a/plugins/module_utils/fabric/results.py b/plugins/module_utils/fabric/results.py new file mode 100644 index 000000000..32838b510 --- /dev/null +++ b/plugins/module_utils/fabric/results.py @@ -0,0 +1,127 @@ +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import logging +from typing import Any, Dict + +class Results: + """ + Return various result templates that AnsibleModule can use. + + results = Results() + # A generic result that indicates a task failed with no changes + failed_result = results.failed_result + # A generic result that indicates a task succeeded + # obj is an instance of a class that has diff, result, and response properties + module_result = results.module_result(obj) + + # output of the above print() will be a dict with the following structure + # specific keys within the diff and response dictionaries will vary depending + # on the obj properties + { + "changed": True, # or False + "diff": { + "deleted": [], + "merged": [], + "overridden": [], + "query": [], + "replaced": [] + } + "response": { + "deleted": [], + "merged": [], + "overridden": [], + "query": [], + "replaced": [] + } + } + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.state = ansible_module.params.get("state") + self.check_mode = ansible_module.check_mode + + msg = "ENTERED Results(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self.diff_keys = ["deleted", "merged", "query"] + self.response_keys = ["deleted", "merged", "query"] + + def did_anything_change(self, obj): + """ + return True if obj has any changes + Caller: module_result + """ + if self.check_mode is True: + self.log.debug("check_mode is True. No changes made.") + return False + if len(obj.diff) != 0: + return True + return False + + @property + def failed_result(self): + """ + return a result for a failed task with no changes + """ + result = {} + result["changed"] = False + result["failed"] = True + result["diff"] = {} + result["response"] = {} + for key in self.diff_keys: + result["diff"][key] = [] + for key in self.response_keys: + result["response"][key] = [] + return result + + @property + def module_result(self, obj) -> Dict[str, Any]: + """ + Return a result that AnsibleModule can use + Result is based on the obj properties: diff, response + """ + if not isinstance(list, obj.result): + raise ValueError("obj.result must be a list of dict") + if not isinstance(list, obj.diff): + raise ValueError("obj.diff must be a list of dict") + if not isinstance(list, obj.response): + raise ValueError("obj.response must be a list of dict") + result = {} + result["changed"] = self.did_anything_change(obj) + result["diff"] = {} + result["response"] = {} + for key in self.diff_keys: + if self.state == key: + result["diff"][key] = obj.diff + else: + result["diff"][key] = [] + for key in self.response_keys: + if self.state == key: + result["response"][key] = obj.response + else: + result["response"][key] = [] + return result diff --git a/plugins/module_utils/fabric/template_get.py b/plugins/module_utils/fabric/template_get.py new file mode 100755 index 000000000..d35860909 --- /dev/null +++ b/plugins/module_utils/fabric/template_get.py @@ -0,0 +1,129 @@ +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect +import json +import logging +from typing import Any, Dict +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + Results + +class TemplateGet: + """ + Retrieve a template from the controller. + + Usage: + + instance = TemplateGet() + instance.template_name = "Easy_Fabric" + instance.refresh() + template = instance.template + + """ + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + self.state = self.ansible_module.params["state"] + self.check_mode = self.ansible_module.check_mode + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + msg = "ENTERED Template(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self._endpoints = ApiEndpoints() + self._rest_send = RestSend(self.ansible_module) + self._results = Results(self.ansible_module) + self._init_properties() + + def _init_properties(self) -> None: + self._properties = {} + self._properties["template_name"] = None + self._properties["template"] = None + + @property + def template(self): + """ + Return the template retrieved from the controller. + """ + return self._properties["template"] + + @template.setter + def template(self, value: Dict[str, Any]) -> None: + self._properties["template"] = value + + @property + def template_name(self) -> str: + """ + getter: Return the template name to be retrieved from the controller. + setter: Set the template name to be retrieved from the controller. + """ + return self._properties["template_name"] + + @template_name.setter + def template_name(self, value) -> None: + self._properties["template_name"] = value + + def refresh(self): + """ + Retrieve the template from the controller. + """ + method_name = inspect.stack()[0][3] + if self.template_name == None: + msg = f"{self.class_name}.{method_name}: " + msg += "Set instance.template_name property before " + msg += "calling instance.refresh()" + self.log.error(msg) + self.ansible_module.fail_json(msg, **self._results.failed_result) + + self._endpoints.template_name = self.template_name + try: + self.endpoint = self._endpoints.template + except ValueError as error: + raise ValueError(error) + + self._rest_send.path = self.endpoint.get("path") + self._rest_send.verb = self.endpoint.get("verb") + self._rest_send.commit() + + self.response_current = copy.deepcopy(self._rest_send.response_current) + self.response = copy.deepcopy(self._rest_send.response_current) + self.result_current = copy.deepcopy(self._rest_send.result_current) + self.result = copy.deepcopy(self._rest_send.result_current) + + if self.response_current.get("RETURN_CODE", None) != 200: + msg = f"{self.class_name}.{method_name}: " + msg = "Exiting. Failed to retrieve template." + self.log.error(msg) + self.ansible_module.fail_json(msg, **self._results.failed_result) + + self.template = {} + self.template["parameters"] = self.response_current.get("DATA", {}).get("parameters", []) + + # self.template = self.response_current.get("DATA", {}).get("parameters", []) + # msg = f"{self.class_name}.{method_name}: " + # msg += f"template: {json.dumps(self.template, indent=4, sort_keys=True)}" diff --git a/plugins/module_utils/fabric/template_get_all.py b/plugins/module_utils/fabric/template_get_all.py new file mode 100755 index 000000000..828e83fe2 --- /dev/null +++ b/plugins/module_utils/fabric/template_get_all.py @@ -0,0 +1,104 @@ +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import inspect +import json +import logging +from typing import Any, Dict +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + Results + +class TemplateGetAll: + """ + Retrieve a list of all templates from the controller. + + Usage: + + instance = TemplateGetAll() + instance.refresh() + templates = instance.templates + """ + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + self.state = self.ansible_module.params["state"] + self.check_mode = self.ansible_module.check_mode + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + msg = "ENTERED TemplateGetAll(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self._endpoints = ApiEndpoints() + self._rest_send = RestSend() + self._results = Results(self.ansible_module) + self._init_properties() + + def _init_properties(self) -> None: + self._properties = {} + self._properties["templates"] = None + + @property + def templates(self): + """ + Return the templates retrieved from the controller. + """ + return self._properties["templates"] + + @templates.setter + def templates(self, value: Dict[str, Any]) -> None: + self._properties["templates"] = value + + def refresh(self): + """ + Retrieve the templates from the controller. + """ + method_name = inspect.stack()[0][3] + + try: + self.endpoint = self._endpoints.templates + except ValueError as error: + raise ValueError(error) + + self._rest_send.path = self.endpoint.get("path") + self._rest_send.verb = self.endpoint.get("verb") + self._rest_send.commit() + + self.response_current = self._rest_send.response_current + self.response = self._rest_send.response_current + self.result_current = self._rest_send.result_current + self.result = self._rest_send.result_current + + if self.response_current.get("RETURN_CODE", None) != 200: + msg = f"{self.class_name}.{method_name}: " + msg = "Exiting. Failed to retrieve templates." + self.log.error(msg) + self.ansible_module.fail_json(msg, **self._results.failed_result) + + self.templates = self.result_current + + msg = f"{self.class_name}.{method_name}: " + msg += f"templates: {json.dumps(self.templates, indent=4, sort_keys=True)}" diff --git a/plugins/module_utils/fabric/template_parse_common.py b/plugins/module_utils/fabric/template_parse_common.py new file mode 100755 index 000000000..bc113f96a --- /dev/null +++ b/plugins/module_utils/fabric/template_parse_common.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python +""" +Name: ndfc_template.py +Description: + +Superclass for NdfcTemplateEasyFabric() and NdfcTemplateAll() +""" +import re +import sys +import json + +class TemplateParseCommon: + """ + Superclass for TemplateParse*() classes + """ + def __init__(self): + self._properties = {} + self._properties["template"] = None + self._properties["template_json"] = None + + @property + def template(self): + return self._properties["template"] + + @template.setter + def template(self, value): + """ + The template contents supported by the subclass + """ + self._properties["template"] = value + + @property + def template_json(self): + return self._properties["template_json"] + + @template_json.setter + def template_json(self, value): + """ + Full path to a file containing the template content + in JSON format + """ + self._properties["template_json"] = value + + @staticmethod + def delete_key(key, dictionary): + """ + Delete key from dictionary + """ + if key in dictionary: + del dictionary[key] + return dictionary + + def get_default_value_meta_properties(self, item): + try: + result = item["metaProperties"]["defaultValue"] + except KeyError: + return None + return self.clean_string(result) + + def get_default_value_root(self, item): + try: + result = item["defaultValue"] + except KeyError: + return None + return self.clean_string(result) + + def get_default_value(self, item): + """ + Return the default value for item, if it exists. + Return None otherwise. + item["metaProperties"]["defaultValue"] + """ + result = self.get_default_value_meta_properties(item) + if result is not None: + return result + result = self.get_default_value_root(item) + return result + + + def get_description(self, item): + """ + Return the description of an item, i.e.: + item['annotations']['Description'] + """ + try: + description = item['annotations']['Description'] + except KeyError: + description = "unknown" + return self.clean_string(description) + + def get_dict_value(self, dictionary, key): + """ + Return value of the first instance of key found via + recursive search of dictionary + + Return None if key is not found + """ + if not isinstance(dictionary, dict): + return None + if key in dictionary: return dictionary[key] + for k, v in dictionary.items(): + if isinstance(v,dict): + item = self.get_dict_value(v, key) + if item is not None: + return item + return None + + def get_display_name(self, item): + """ + Return the NDFC GUI label for an item, i.e.: + item['annotations']['DisplayName'] + """ + result = self.get_dict_value(item, "DisplayName") + return self.clean_string(result) + + def get_enum(self, item): + """ + Return the enum for an item as a list() + Return an empty list if the key does not exist + item["annotations"]["Enum"] + """ + result = self.get_dict_value(item, "Enum") + if result is None: + return [] + # if "annotations" not in item: + # return [] + # if "Enum" not in item["annotations"]: + # return [] + result = self.clean_string(result) + result = result.split(",") + try: + result = [int(x) for x in result] + except ValueError: + pass + return result + + def get_min_max(self, item): + """ + Return the min and max values of an item from the item's description + Otherwise return None, None + + If item['annotations']['Description'] contains + "(Min: X, Max: Y)" return int(X), and int(Y) + """ + result = self.get_dict_value(item, "Description") + if result is None: + return None, None + # (Min:240, Max:3600) + m = re.search(r"\(Min:\s*(\d+),\s*Max:\s*(\d+)\)", result) + if m: + return int(m.group(1)), int(m.group(2)) + return None, None + + def get_min(self, item): + """ + Return the minimum value of an item + Otherwise return None + + Typically, item['metaProperties']['min'] + """ + result = self.get_dict_value(item, "min") + return self.clean_string(result) + + def get_max(self, item): + """ + Return the maximum value of an item + Otherwise return None + + Typically, item['metaProperties']['max'] + """ + result = self.get_dict_value(item, "max") + return self.clean_string(result) + + def get_min_length(self, item): + """ + Return the minimum length of an item + Otherwise return None + + Typically, item['metaProperties']['minLength'] + """ + result = self.get_dict_value(item, "minLength") + return self.clean_string(result) + + def get_max_length(self, item): + """ + Return the maximum length of an item + Otherwise return None + + Typically, item['metaProperties']['maxLength'] + """ + result = self.get_dict_value(item, "minLength") + return self.clean_string(result) + + def get_name(self, item): + """ + Return the name of an item + Otherwise return None + + Typically, item['name'] + """ + try: + result = item['name'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def is_internal(self, item): + """ + Return True if item["annotations"]["IsInternal"] is True + Return False otherwise + """ + result = self.get_dict_value(item, "IsInternal") + return self.make_bool(result) + + def is_optional(self,item): + """ + Return the optional status of an item (True or False) if it exists. + + Otherwise return None + """ + result = self.make_bool(item.get('optional', None)) + return result + + def get_parameter_type(self, item): + """ + Return the parameter type of an item if it exists. + Otherwise return None + + Typically, item['parameterType'] + + TODO:2 review whether we should do this translation + This is translated to the Ansible type, e.g. + string -> str + boolean -> bool + ipV4Address -> ipv4 + etc. + """ + result = self.get_dict_value(item, 'parameterType') + if result is None: + return None + if result in ["STRING", "string", "str"]: + return "str" + if result in ["INTEGER", "INT", "integer", "int"]: + return "int" + if result in ["BOOLEAN", "boolean", "bool"]: + return "bool" + if result in ["ipAddress", "ipV4Address"]: + return "ipv4" + if result in ["ipV4AddressWithSubnet"]: + return "ipv4_subnet" + if result in ["ipV6Address"]: + return "ipv6" + if result in ["ipV6AddressWithSubnet"]: + return "ipv6_subnet" + return result + + def get_section(self, item): + """ + Return the Section annotation of an item + Otherwise return None + + Typically, item['annotations']['Section'] + """ + result = self.get_dict_value(item, "Section") + return self.clean_string(result) + + def get_template_content_type(self, template): + """ + Return the type of the template if it exists. + Return None otherwise. + template['type'] + """ + try: + result = template['contentType'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def get_template_description(self, template): + """ + Return the description of the template if it exists. + Return None otherwise. + template['description'] + """ + try: + result = template['description'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def get_template_name(self, template): + """ + Return the name of the template if it exists. + Return None otherwise. + template['name'] + """ + try: + result = template['name'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def get_template_subtype(self, template): + """ + Return the subtype of the template if it exists. + Return None otherwise. + template['templateSubType'] + """ + try: + result = template['templateSubType'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def get_template_supported_platforms(self, template): + """ + Return the supportedPlatforms of the template if it exists. + Return None otherwise. + template['supportedPlatforms'] + """ + try: + result = template['supportedPlatforms'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def get_template_tags(self, template): + """ + Return the tags of the template if it exists. + Return None otherwise. + template['tags'] + """ + try: + result = template['tags'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def get_template_type(self, template): + """ + Return the type of the template if it exists. + Return None otherwise. + template['templateType'] + """ + try: + result = template['templateType'] + except KeyError: + result = "unknown" + return self.clean_string(result) + + def get_valid_values(self, item): + """ + Return the validValues annotation of an item + Otherwise return None + + Typically, item['metaProperties']['validValues'] + """ + result = self.get_dict_value(item, "validValues") + if result is None: + return [] + result = result.split(",") + try: + result = [int(x) for x in result] + except ValueError: + pass + return result + + def is_mandatory(self, item): + """ + Return the mandatory status of a parameter + True if the parameter is mandatory. + False if the parameter is optional. + False if the IsMandatory key does not exist + + item["annotations"]["IsMandatory"] + """ + result = self.get_dict_value(item, "IsMandatory") + return self.make_bool(result) + + def is_hidden(self, item): + """ + Return True if item["annotations"]["Section"] is "Hidden" + Return False otherwise + """ + result = self.get_section(item) + if "Hidden" in result: + return True + return False + + def is_required(self,item): + """ + Return the required status of an item (True or False) + The inverse of item['optional'] + + Otherwise return None + """ + result = self.make_bool(item.get('optional', None)) + if result is True: + return False + if result is False: + return True + return None + + + @staticmethod + def make_bool(value): + """ + Translate various string values to a boolean value + """ + if value in ["true", "yes", "True", "Yes", "TRUE", "YES"]: + return True + if value in ["false", "no", "False", "No", "FALSE", "NO"]: + return False + return value + + def clean_string(self, string): + """ + Remove unwanted characters found in various locations + within the returned NDFC JSON. + """ + if string is None: + return "" + string = string.strip() + string = re.sub('
', ' ', string) + string = re.sub(''', '', string) + string = re.sub('+', '+', string) + string = re.sub('=', '=', string) + string = re.sub('amp;', '', string) + string = re.sub(r'\[', '', string) + string = re.sub(r'\]', '', string) + string = re.sub('\"', '', string) + string = re.sub("\'", '', string) + string = re.sub(r"\s+", " ", string) + string = self.make_bool(string) + try: + string = int(string) + except ValueError: + pass + if not isinstance(string, int): + try: + string = float(string) + except ValueError: + pass + return string + + def load(self): + """ + Load the template from a JSON file + """ + if self.template_json is None: + msg = "exiting. set instance.template_json to the file " + msg += "path of the JSON content before calling " + msg += "load_template()" + print(f"{msg}") + sys.exit(1) + with open(self.template_json, 'r', encoding="utf-8") as handle: + self.template = json.load(handle) diff --git a/plugins/module_utils/fabric/template_parse_easy_fabric.py b/plugins/module_utils/fabric/template_parse_easy_fabric.py new file mode 100644 index 000000000..147f4b2eb --- /dev/null +++ b/plugins/module_utils/fabric/template_parse_easy_fabric.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python +""" +Name: ndfc_template_easy_fabric.py +Description: + +Generate ruleset and documentation for the NDFC EasyFabric template. + +The template is retieved via the following controller endpoint: + +/appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates/Easy_Fabric +""" +import json +import re +import sys +import yaml +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.template_parse_common import \ + TemplateParseCommon + +class TemplateParseEasyFabric(TemplateParseCommon): + def __init__(self): + super().__init__() + self.translation = None + self.suboptions = None + self.documentation = None + + @property + def template_all(self): + return self._properties["template_all"] + @template_all.setter + def template_all(self, value): + """ + An instance of NdfcTemplateAll() + """ + self._properties["template_all"] = value + + def init_translation(self): + """ + All parameters in the playbook are lowercase dunder, while + NDFC nvPairs contains a mish-mash of styles (and typos), + for example: + - enableScheduledBackup + - default_vrf + - REPLICATION_MODE + - DEAFULT_QUEUING_POLICY_CLOUDSCALE + + This method builds a dictionary which maps between NDFC's expected + parameter names and the corresponding playbook names. + e.g.: + DEAFULT_QUEUING_POLICY_CLOUDSCALE -> default_queuing_policy_cloudscale + + The dictionary excludes hidden and internal parameters. + """ + if self.template is None: + msg = "exiting. call instance.load_template() first." + print(f"{msg}") + sys.exit(1) + re_uppercase_dunder = "^[A-Z0-9_]+$" + self.translation = {} + typo_keys = { + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "default_queuing_policy_cloudscale", + "DEAFULT_QUEUING_POLICY_OTHER": "default_queuing_policy_other", + "DEAFULT_QUEUING_POLICY_R_SERIES": "default_queuing_policy_r_series", + } + camel_keys = { + "enableRealTimeBackup": "enable_real_time_backup", + "enableScheduledBackup": "enable_scheduled_backup", + "scheduledTime": "scheduled_time", + } + other_keys = { + "VPC_ENABLE_IPv6_ND_SYNC": "vpc_enable_ipv6_nd_sync", + "default_vrf": "default_vrf", + "default_network": "default_network", + "vrf_extension_template": "vrf_extension_template", + "network_extension_template": "network_extension_template", + "default_pvlan_sec_network": "default_pvlan_sec_network", + } + for item in self.template.get("parameters"): + if self.is_internal(item): + continue + if self.is_hidden(item): + continue + name = self.get_name(item) + if not name: + continue + if name in typo_keys: + self.translation[name] = typo_keys[name] + continue + if name in camel_keys: + self.translation[name] = camel_keys[name] + continue + if name in other_keys: + self.translation[name] = other_keys[name] + continue + if re.search(re_uppercase_dunder, name): + self.translation[name] = name.lower() + + def validate_base_prerequisites(self): + """ + 1. Validate that the prerequisites are met before proceeding. + Specifically: + - User has set self.template + - User has set self.template_all + 2. Call self.init_translation() if self.translation is None + """ + if self.template is None: + msg = "exiting. call instance.load_template() first." + print(f"{msg}") + sys.exit(1) + if self.translation is None: + self.init_translation() + + def build_documentation(self): + """ + Build the documentation for the EasyFabric template. + """ + self.validate_base_prerequisites() + if self.template_all is None: + msg = "exiting. call instance.template_all first." + print(f"{msg}") + sys.exit(1) + self.documentation = {} + self.documentation["module"] = "dcnm_easy_fabric" + self.documentation["author"] = "Cisco Systems, Inc." + self.documentation["description"] = [] + self.documentation["description"].append( + self.get_template_description(self.template) + ) + self.documentation["options"] = {} + self.documentation["options"]["state"] = {} + self.documentation["options"]["state"]["description"] = [] + value = "The state of DCNM after module completion" + self.documentation["options"]["state"]["description"].append(value) + value = "I(deleted), I(merged), and I(query) states are supported." + self.documentation["options"]["state"]["description"].append(value) + self.documentation["options"]["state"]["type"] = "str" + self.documentation["options"]["state"]["choices"] = ["deleted", "merged", "query"] + self.documentation["options"]["state"]["default"] = "merged" + self.documentation["options"]["config"] = {} + self.documentation["options"]["config"]["description"] = [] + value = "A list of fabric configuration dictionaries" + self.documentation["options"]["config"]["description"].append(value) + self.documentation["options"]["config"]["type"] = "list" + self.documentation["options"]["config"]["elements"] = "dict" + self.documentation["options"]["config"]["suboptions"] = {} + + suboptions = {} + for item in self.template.get("parameters"): + if self.is_internal(item): + continue + if self.is_hidden(item): + continue + if not item.get('name', None): + continue + name = self.translation.get(item['name'], None) + if name is None: + print(f"WARNING: skipping {item['name']}") + continue + suboptions[name] = {} + suboptions[name]["description"] = [] + suboptions[name]["description"].append(self.get_description(item)) + suboptions[name]["type"] = self.get_parameter_type(item) + suboptions[name]["required"] = self.is_required(item) + default = self.get_default_value(item) + if default is not None: + suboptions[name]["default"] = default + choices = self.get_enum(item) + if len(choices) > 0: + if "TEMPLATES" in str(choices[0]): + tag = str(choices[0]).split(".")[1] + choices = self.template_all.get_templates_by_tag(tag) + suboptions[name]["choices"] = choices + min_value, max_value = self.get_min_max(item) + if min_value is not None: + suboptions[name]["min"] = min_value + if max_value is not None: + suboptions[name]["max"] = max_value + ndfc_label = self.get_display_name(item) + if ndfc_label is not None: + suboptions[name]["ndfc_gui_label"] = ndfc_label + ndfc_section = self.get_section(item) + if ndfc_section is not None: + suboptions[name]["ndfc_gui_section"] = ndfc_section + + self.documentation["options"]["config"]["suboptions"] = [] + for key in sorted(suboptions.keys()): + self.documentation["options"]["config"]["suboptions"].append({key: suboptions[key]}) + + def build_ruleset(self): + """ + Build the ruleset for the EasyFabric template, based on + annotations.IsShow in each parameter dictionary. + + The ruleset is keyed on parameter name, with values being set of + rules that determine whether a given parameter is mandatory, based + on the state of other parameters. + + Usage: + + template.build_ruleset() + parameter = "unnum_dhcp_end" + try: + result = eval(template.ruleset[parameter]) + except: + result = False + if result is True: + print(f"{parameter} is mandatory") + """ + self.validate_base_prerequisites() + self.ruleset = {} + for item in self.template.get("parameters"): + if self.is_internal(item): + continue + if self.is_hidden(item): + continue + if not item.get('name', None): + continue + name = self.translation.get(item['name'], None) + if name is None: + print(f"WARNING: skipping {item['name']}") + continue + self.ruleset[name] = item.get("annotations", {}).get("IsShow", None) + self.ruleset = self.pythonize_ruleset(self.ruleset) + + def pythonize_ruleset(self, ruleset): + mixed_rules = {} + for key in ruleset: + rule = ruleset[key] + if rule is None: + continue + # print(f"PRE1 : key {key}, RULE: {rule}") + rule = rule.replace("$$", "") + rule = rule.replace("&&", " and ") + rule = rule.replace(r"\"", "") + rule = rule.replace(r"\'", "") + rule = rule.replace("||", " or ") + rule = rule.replace("==", " == ") + rule = rule.replace("!=", " != ") + rule = rule.replace("(", " ( ") + rule = rule.replace(")", " ) ") + rule = rule.replace("true", " True") + rule = rule.replace("false", " False") + rule = re.sub(r"\s+", " ", rule) + if "and" in rule and "or" in rule: + mixed_rules[key] = rule + continue + if "and" in rule and "or" not in rule: + rule = rule.split("and") + rule = [x.strip() for x in rule] + rule = [re.sub(r"\s{2}+", " ", x) for x in rule] + #print(f"POST1: key {key}, len {len(rule)} rule: {rule}") + rule = [re.sub(r"\"", "", x) for x in rule] + rule = [re.sub(r"\'", "", x) for x in rule] + #rule = [re.sub(r"\s{2}+", " ", x) for x in rule] + #print(f"POST2: key {key}, len {len(rule)} rule: {rule}") + new_rule = [] + for item in rule: + lhs,op,rhs = item.split(" ") + rhs = rhs.replace("\"", "") + rhs = rhs.replace("\'", "") + if rhs not in ["True", "False", True, False]: + rhs = f"\"{rhs}\"" + lhs = self.translation.get(lhs, lhs) + # print(f"POST3: key {key}: lhs: {lhs}, op: {op}, rhs: {rhs}") + new_rule.append(f"{lhs} {op} {rhs}") + new_rule = " and ".join(new_rule) + # print(f"POST4: key {key}: {new_rule}") + ruleset[key] = new_rule + return ruleset + + + def documentation_yaml(self): + """ + Dump the documentation in YAML format + """ + if self.documentation is None: + self.build_documentation() + print(yaml.dump(self.documentation, indent=4)) + + def documentation_json(self): + """ + Dump the documentation in JSON format + """ + if self.documentation is None: + self.build_documentation() + print(json.dumps(self.documentation, indent=4)) diff --git a/plugins/module_utils/fabric/vxlan/verify_fabric_params.py b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py index 3c83ba326..1d7e60c69 100644 --- a/plugins/module_utils/fabric/vxlan/verify_fabric_params.py +++ b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py @@ -151,7 +151,7 @@ def _validate_config(self, config): if not self._mandatory_keys.issubset(config): missing_keys = self._mandatory_keys.difference(config.keys()) msg = f"{self.class_name}.{method_name}: " - msg = f"missing mandatory keys {','.join(sorted(missing_keys))}." + msg += f"missing mandatory keys {','.join(sorted(missing_keys))}." self.result = False self._append_msg(msg) return False diff --git a/plugins/module_utils/fabric/vxlan/verify_playbook_params.py b/plugins/module_utils/fabric/vxlan/verify_playbook_params.py new file mode 100644 index 000000000..ee57099c8 --- /dev/null +++ b/plugins/module_utils/fabric/vxlan/verify_playbook_params.py @@ -0,0 +1,112 @@ +# 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.template_get import \ + TemplateGet +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.template_parse_easy_fabric import \ + TemplateParseEasyFabric + + +class VerifyPlaybookParams: + """ + Verify playbook parameters NDFC VxLAN fabric + + Usage: + + instance = VerifyPlaybookParams(ansible_module) + instance.playbook_config = playbook_config + instance.commit() + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self._template_get = TemplateGet(self.ansible_module) + self._template_parse_easy_fabric = TemplateParseEasyFabric() + + self.state = self.ansible_module.params["state"] + msg = "ENTERED VerifyPlaybookParams(): " + msg += f"state: {self.state}" + self.log.debug(msg) + + self._build_properties() + + def _build_properties(self): + """ + self.properties holds property values for the class + """ + self.properties = {} + self.properties["config"] = None + + @property + def config(self): + """ + getter: return the config to verify + setter: set the config to verify + """ + return self.properties["config"] + + @config.setter + def config(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "config must be a dict. " + msg += f"got {type(value).__name__} for " + msg += f"value {value}" + self.ansible_module.fail_json(msg) + self.properties["config"] = value + + def refresh_template(self) -> None: + """ + Retrieve the template used to verify config + """ + self._template_get.template_name = "Easy_Fabric" + self._template_get.refresh() + self.template = self._template_get.template + + def commit(self): + """ + verify the config against the retrieved template + """ + method_name = inspect.stack()[0][3] + if self.config is None: + msg = f"{self.class_name}.{method_name}: " + msg += "instance.config must be set prior to calling commit." + self.ansible_module.fail_json(msg, **self.failed_result) + + self._template_parse_easy_fabric.template = self.template + self._template_parse_easy_fabric.build_ruleset() + + msg = f"self.config: {json.dumps(self.config, indent=4, sort_keys=True)}" + self.log.debug(msg) + + for parameter in self.config: + result = eval(f"{self._template_parse_easy_fabric.ruleset[parameter.lower()]}") + if result is True: + self.log.debug(f"ZZZ Parameter {parameter} is mandatory.") + else: + self.log.debug(f"ZZZ Parameter {parameter} is optional.") + diff --git a/plugins/modules/dcnm_fabric_vxlan.py b/plugins/modules/dcnm_fabric_vxlan.py index 1cf553029..8aa97bf46 100644 --- a/plugins/modules/dcnm_fabric_vxlan.py +++ b/plugins/modules/dcnm_fabric_vxlan.py @@ -220,6 +220,8 @@ FabricCommon from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import \ + FabricQuery from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import ( @@ -228,9 +230,12 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import ( VerifyFabricParams, ) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_create import ( +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import ( FabricCreateBulk ) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import ( + FabricDelete +) from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import ( FabricTaskResult ) @@ -252,7 +257,11 @@ def __init__(self, ansible_module): method_name = inspect.stack()[0][3] self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.log.debug("ENTERED FabricVxlanTask()") + + msg = "ENTERED FabricVxlanTask(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) self.endpoints = ApiEndpoints() @@ -264,7 +273,6 @@ def __init__(self, ansible_module): self.rest_send = RestSend(self.ansible_module) # populated in self.validate_input() self.payloads = {} - self._valid_states = ["merged", "query"] self.config = ansible_module.params.get("config") if not isinstance(self.config, list): @@ -302,15 +310,16 @@ def get_have(self): } """ method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "entered" + self.log.debug(msg) self.have = FabricDetailsByName(self.ansible_module) + msg = "Calling self.have.refresh (FabricDetailsByName.refresh)" + self.log.debug(msg) self.have.refresh() - msg = f"{self.class_name}.{method_name}: " - msg += f"self.have: {self.have.all_data}" + msg = "Calling self.have.refresh (FabricDetailsByName.refresh) " + msg = f"DONE. self.have: {json.dumps(self.have.all_data, indent=4, sort_keys=True)}" self.log.debug(msg) - # for item in response["DATA"]: - # self.have[item["fabricName"]] = {} - # self.have[item["fabricName"]]["fabric_name"] = item["fabricName"] - # self.have[item["fabricName"]]["fabric_config"] = item def get_want(self) -> None: """ @@ -319,7 +328,8 @@ def get_want(self) -> None: 1. Validate the playbook configs 2. Update self.want with the playbook configs """ - msg = "ENTERED" + method_name = inspect.stack()[0][3] + msg = f"ENTERED" self.log.debug(msg) # Generate the params_spec used to validate the configs @@ -341,21 +351,13 @@ def get_want(self) -> None: validator = ParamsValidate(self.ansible_module) validator.params_spec = params_spec.params_spec for config in merged_configs: - msg = f"{self.class_name}.get_want(): " + msg = f"{self.class_name}.{method_name}: " msg += f"config: {json.dumps(config, indent=4, sort_keys=True)}" self.log.debug(msg) validator.parameters = config validator.commit() self.want.append(copy.deepcopy(validator.parameters)) - # # convert the validated configs to payloads to more easily compare them - # # to self.have (the current image policies on the controller). - # for config in self.validated_configs: - # payload = Config2Payload(self.ansible_module) - # payload.config = config - # payload.commit() - # self.want.append(payload.payload) - # Exit if there's nothing to do if len(self.want) == 0: self.ansible_module.exit_json(**self.task_result.module_result) @@ -368,19 +370,19 @@ def get_need_for_merged_state(self): """ method_name = inspect.stack()[0][3] msg = f"{self.class_name}.{method_name}: " - msg += f"self.have.all_data: {json.dumps(self.have.all_data, indent=4, sort_keys=True)}" + msg += "self.have.all_data: " + msg += f"{json.dumps(self.have.all_data, indent=4, sort_keys=True)}" self.log.debug(msg) need: List[Dict[str, Any]] = [] state = self.params["state"] self.payloads = {} for want in self.want: - verify = VerifyFabricParams() - verify.state = state - verify.config = want - verify.validate_config() - if verify.result is False: - self.ansible_module.fail_json(msg=verify.msg) - need.append(verify.payload) + self.verify.state = state + self.verify.config = want + self.verify.validate_config() + if self.verify.result is False: + self.ansible_module.fail_json(msg=self.verify.msg) + need.append(self.verify.payload) self.need = copy.deepcopy(need) msg = f"{self.class_name}.validate_input(): " @@ -404,6 +406,44 @@ def handle_merged_state(self): self.ansible_module.exit_json(**self.task_result.module_result) self.send_need() + def handle_query_state(self) -> None: + """ + 1. query the fabrics in self.want that exist on the controller + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "entered" + self.log.debug(msg) + instance = FabricQuery(self.ansible_module) + fabric_names_to_query = [] + for want in self.want: + fabric_names_to_query.append(want["fabric_name"]) + instance.fabric_names = fabric_names_to_query + msg = f"{self.class_name}.{method_name}: " + msg += "Calling FabricQuery.commit" + self.log.debug(msg) + instance.commit() + self.update_diff_and_response(instance) + + def handle_deleted_state(self) -> None: + """ + delete the fabrics in self.want that exist on the controller + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "entered" + self.log.debug(msg) + instance = FabricDelete(self.ansible_module) + fabric_names_to_delete = [] + for want in self.want: + fabric_names_to_delete.append(want["fabric_name"]) + instance.fabric_names = fabric_names_to_delete + msg = f"{self.class_name}.{method_name}: " + msg += "Calling FabricDelete.commit" + self.log.debug(msg) + instance.commit() + self.update_diff_and_response(instance) + def send_need(self): """ Caller: handle_merged_state() @@ -448,7 +488,8 @@ def send_need_check_mode(self): self.log.debug(msg) msg = f"{self.class_name}.{method_name}: " - msg += f"fabric_name: {fabric_name}, payload: {json.dumps(payload, indent=4, sort_keys=True)}" + msg += f"fabric_name: {fabric_name}, " + msg += f"payload: {json.dumps(payload, indent=4, sort_keys=True)}" self.log.debug(msg) self.rest_send.path = path @@ -493,164 +534,37 @@ def send_need_normal_mode(self): self.fabric_create.payloads = self.need self.fabric_create.commit() self.update_diff_and_response(self.fabric_create) - # for payload in self.need: - # self.fabric_create.payload = payload - # self.fabric_create.commit() - # self.update_diff_and_response(self.fabric_create) - - def send_need_normal_mode_orig(self): - """ - Caller: send_need() - - Build and send the payload to create the - fabrics specified in the playbook. - """ - method_name = inspect.stack()[0][3] - - msg = f"{self.class_name}.{method_name}: " - msg += f"need: {json.dumps(self.need, indent=4, sort_keys=True)}" - self.log.debug(msg) - if len(self.need) == 0: - self.ansible_module.exit_json(**self.task_result.module_result) - for item in self.need: - fabric_name = item.get("FABRIC_NAME") - if fabric_name is None: - msg = f"{self.class_name}.{method_name}: " - msg += "fabric_name is required." - self.ansible_module.fail_json(msg) - self.endpoints.fabric_name = fabric_name - self.endpoints.template_name = "Easy_Fabric" - - try: - endpoint = self.endpoints.fabric_create - except ValueError as error: - self.ansible_module.fail_json(error) - - path = endpoint["path"] - verb = endpoint["verb"] - payload = item - msg = f"{self.class_name}.{method_name}: " - msg += f"verb: {verb}, path: {path}" - self.log.debug(msg) - - msg = f"{self.class_name}.{method_name}: " - msg += f"fabric_name: {fabric_name}, payload: {json.dumps(payload, indent=4, sort_keys=True)}" - self.log.debug(msg) - - self.rest_send.path = path - self.rest_send.verb = verb - self.rest_send.payload = payload - self.rest_send.commit() - - self.result_current = self.rest_send.result_current - self.result = self.rest_send.result_current - self.response_current = self.rest_send.response_current - self.response = self.rest_send.response_current - - self.task_result.response_merged = self.response_current - if self.response_current["RETURN_CODE"] == 200: - self.task_result.diff_merged = payload - - msg = "self.response_current: " - msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = "self.response: " - msg += f"{json.dumps(self.response, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = "self.result_current: " - msg += f"{json.dumps(self.result_current, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = "self.result: " - msg += f"{json.dumps(self.result, indent=4, sort_keys=True)}" - self.log.debug(msg) - def update_diff_and_response(self, obj) -> None: """ Update the appropriate self.task_result diff and response, based on the current ansible state, with the diff and response from obj. - - fail_json if the state is not in self._valid_states """ - state = self.ansible_module.params["state"] - if state not in self._valid_states: - msg = f"Inappropriate state {state}" - self.log.error(msg) - self.ansible_module.fail_json(msg, **obj.result) for diff in obj.diff: - msg = f"state {state} diff: {json_pretty(diff)}" + msg = f"diff: {json_pretty(diff)}" self.log.debug(msg) - if state == "deleted": + if self.state == "deleted": self.task_result.diff_deleted = diff - if state == "merged": + if self.state == "merged": self.task_result.diff_merged = diff - if state == "query": + if self.state == "query": self.task_result.diff_query = diff - msg = f"PRE_FOR: state {state} response: {json_pretty(obj.response)}" + msg = f"PRE_FOR: state {self.state} response: {json_pretty(obj.response)}" self.log.debug(msg) for response in obj.response: if "DATA" in response: response.pop("DATA") - msg = f"state {state} response: {json_pretty(response)}" + msg = f"state {self.state} response: {json_pretty(response)}" self.log.debug(msg) - if state == "deleted": + if self.state == "deleted": self.task_result.response_deleted = copy.deepcopy(response) - if state == "merged": + if self.state == "merged": self.task_result.response_merged = copy.deepcopy(response) - if state == "query": + if self.state == "query": self.task_result.response_query = copy.deepcopy(response) - def build_payloads(self): - """ - Caller: send_need() - - Validate the playbook parameters - Build the payloads for each fabric - """ - - state = self.params["state"] - self.payloads = {} - for want in self.want: - verify = VerifyFabricParams() - verify.state = state - verify.config = want - verify.validate_config() - if verify.result is False: - self.ansible_module.fail_json(msg=verify.msg) - self.payloads[want["fabric_name"]] = verify.payload - - msg = f"{self.class_name}.validate_input(): " - msg += f"payloads: {json.dumps(self.payloads, indent=4, sort_keys=True)}" - self.log.debug(msg) - - def build_payloads_orig(self): - """ - Caller: send_need() - - Validate the playbook parameters - Build the payloads for each fabric - """ - - state = self.params["state"] - self.payloads = {} - for fabric_config in self.config: - verify = VerifyFabricParams() - verify.state = state - verify.config = fabric_config - verify.validate_config() - if verify.result is False: - self.ansible_module.fail_json(msg=verify.msg) - self.payloads[fabric_config["fabric_name"]] = verify.payload - - msg = f"{self.class_name}.validate_input(): " - msg += f"payloads: {json.dumps(self.payloads, indent=4, sort_keys=True)}" - self.log.debug(msg) - def _failure(self, resp): """ Caller: self.create_fabrics() @@ -688,7 +602,7 @@ def main(): element_spec = dict( config=dict(required=False, type="list", elements="dict"), - state=dict(default="merged", choices=["merged", "query"]), + state=dict(default="merged", choices=["deleted", "merged", "query"]), ) ansible_module = AnsibleModule(argument_spec=element_spec, supports_check_mode=True) @@ -700,7 +614,7 @@ def main(): # logging.config.dictConfig and must not log to the console. # For an example configuration, see: # $ANSIBLE_COLLECTIONS_PATH/cisco/dcnm/plugins/module_utils/common/logging_config.json - enable_logging = True + enable_logging = False log = Log(ansible_module) if enable_logging is True: collection_path = ( @@ -713,17 +627,18 @@ def main(): log.commit() task = FabricVxlanTask(ansible_module) - task.log.debug(f"state: {ansible_module.params['state']}") - # task.log.debug(f"calling task.validate_input()") - # task.validate_input() - # task.log.debug(f"calling task.validate_input() DONE") - task.get_want() - task.get_have() - task.log.debug(f"state: {ansible_module.params['state']}") if ansible_module.params["state"] == "merged": + task.get_want() + task.get_have() task.handle_merged_state() + elif ansible_module.params["state"] == "deleted": + task.get_want() + task.handle_deleted_state() + elif ansible_module.params["state"] == "query": + task.get_want() + task.handle_query_state() else: msg = f"Unknown state {task.ansible_module.params['state']}" task.ansible_module.fail_json(msg) From 69db9b61dfda701eacac1449e1047c5607064f8b Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 13 Mar 2024 13:02:41 -1000 Subject: [PATCH 006/228] Work in progress (basic create, update, delete, query is working) No dynamic template capability in this commit. --- plugins/module_utils/fabric/common.py | 59 +- plugins/module_utils/fabric/create.py | 239 ++++---- plugins/module_utils/fabric/delete.py | 203 +++++-- plugins/module_utils/fabric/endpoints.py | 101 +++- plugins/module_utils/fabric/fabric_details.py | 31 +- plugins/module_utils/fabric/fabric_summary.py | 40 +- plugins/module_utils/fabric/query.py | 1 - plugins/module_utils/fabric/results.py | 65 +- plugins/module_utils/fabric/template_get.py | 64 +- .../module_utils/fabric/template_get_all.py | 58 +- .../fabric/template_parse_common.py | 104 ++-- .../fabric/template_parse_easy_fabric.py | 44 +- plugins/module_utils/fabric/update.py | 570 ++++++++++++++++++ .../fabric/vxlan/verify_fabric_params.py | 4 +- .../{dcnm_fabric_vxlan.py => dcnm_fabric.py} | 390 +++++------- 15 files changed, 1341 insertions(+), 632 deletions(-) create mode 100644 plugins/module_utils/fabric/update.py rename plugins/modules/{dcnm_fabric_vxlan.py => dcnm_fabric.py} (64%) diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index 45e4c7bf1..024ed8e19 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -19,13 +19,16 @@ import inspect import logging +import re from typing import Any, Dict -# Using only for its failed_result property -# pylint: disable=line-too-long from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import \ FabricTaskResult +# Using only for its failed_result property +# pylint: disable=line-too-long + + # pylint: enable=line-too-long @@ -61,6 +64,8 @@ def __init__(self, ansible_module): self.properties: Dict[str, Any] = {} self.properties["changed"] = False self.properties["diff"] = [] + # Default to VXLAN_EVPN + self.properties["fabric_type"] = "VXLAN_EVPN" self.properties["failed"] = False self.properties["response"] = [] self.properties["response_current"] = {} @@ -68,6 +73,25 @@ def __init__(self, ansible_module): self.properties["result"] = [] self.properties["result_current"] = {} + self._valid_fabric_types = {"VXLAN_EVPN"} + + self.fabric_type_to_template_name_map = {} + self.fabric_type_to_template_name_map["VXLAN_EVPN"] = "Easy_Fabric" + + @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. + + Return mac address formatted for the controller on success + Return False on failure. + """ + mac_addr = re.sub(r"[\W\s_]", "", mac_addr) + if not re.search("^[A-Fa-f0-9]{12}$", mac_addr): + return False + return "".join((mac_addr[:4], ".", mac_addr[4:8], ".", mac_addr[8:])) + def _handle_response(self, response, verb): """ Call the appropriate handler for response based on verb @@ -148,6 +172,17 @@ def _handle_post_put_delete_response(self, response): result["changed"] = True return result + def fabric_type_to_template_name(self, value): + """ + Return the template name for a given fabric type + """ + method_name = inspect.stack()[0][3] + if value not in self.fabric_type_to_template_name_map: + msg = f"{self.class_name}.{method_name}: " + msg += f"Unknown fabric type: {value}" + self.ansible_module.fail_json(msg, **self.failed_result) + return self.fabric_type_to_template_name_map[value] + def make_boolean(self, value): """ Return value converted to boolean, if possible. @@ -204,6 +239,26 @@ def diff(self, value): self.ansible_module.fail_json(msg) self.properties["diff"].append(value) + @property + def fabric_type(self): + """ + The type of fabric to create/update. + + See self._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._valid_fabric_types: + msg = f"{self.class_name}.{method_name}: " + msg += f"FABRIC_TYPE must be one of " + msg += f"{sorted(self._valid_fabric_types)}. " + msg += f"Got {value}" + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["fabric_type"] = value + @property def failed(self): """ diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index 7b28eeb6d..04b49b62c 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -20,7 +20,6 @@ import copy import inspect -import json import logging from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ @@ -47,7 +46,10 @@ def __init__(self, ansible_module): self.class_name = self.__class__.__name__ self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED FabricCreateCommon()" + + self.check_mode = self.ansible_module.check_mode + msg = "ENTERED FabricCreateCommon(): " + msg += f"check_mode: {self.check_mode}" self.log.debug(msg) self.fabric_details = FabricDetailsByName(self.ansible_module) @@ -86,17 +88,6 @@ def _verify_payload(self, payload): msg += f"value {payload}" self.ansible_module.fail_json(msg, **self.failed_result) - # START = TODO: REMOVE THIS LATER - self._verify_params.config = payload - self._verify_params.refresh_template() - self._verify_params.commit() - - msg = f"HHH instance.config: {json.dumps(self._verify_params.config, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"HHH instance.template: {json.dumps(self._verify_params.template, indent=4, sort_keys=True)}" - self.log.debug(msg) - # END - TODO: REMOVE THIS LATER - missing_keys = [] for key in self._mandatory_payload_keys: if key not in payload: @@ -109,6 +100,21 @@ def _verify_payload(self, payload): msg += f"{sorted(missing_keys)}" self.ansible_module.fail_json(msg, **self.failed_result) + def _fixup_payloads_to_commit(self): + """ + Make any modifications to the payloads prior to sending them + to the controller. + + Add any modifications to the list below. + + - Translate ANYCAST_GW_MAC to a format the controller understands + """ + for payload in self._payloads_to_commit: + if "ANYCAST_GW_MAC" in payload: + payload["ANYCAST_GW_MAC"] = self.translate_mac_address( + payload["ANYCAST_GW_MAC"] + ) + def _build_payloads_to_commit(self): """ Build a list of payloads to commit. Skip any payloads that @@ -122,76 +128,52 @@ def _build_payloads_to_commit(self): """ self.fabric_details.refresh() - msg = "fabric_details.all_data: " - msg += f"{json.dumps(self.fabric_details.all_data, indent=4, sort_keys=True)}" - self.log.debug(msg) - self._payloads_to_commit = [] for payload in self.payloads: - msg = f"payload: {json.dumps(payload, indent=4, sort_keys=True)}" - self.log.debug(msg) if payload.get("FABRIC_NAME", None) in self.fabric_details.all_data: continue self._payloads_to_commit.append(copy.deepcopy(payload)) - msg = "self._payloads_to_commit: " - msg += f"{json.dumps(self._payloads_to_commit, indent=4, sort_keys=True)}" - self.log.debug(msg) - - def _send_payloads(self): - if self.check_mode is True: - self._send_payloads_check_mode() - else: - self._send_payloads_normal_mode() - - def _send_payloads_check_mode(self): + def _get_endpoint(self): """ - Simulate sending the payloads to the controller and populate the following lists: - - - self.response_ok : list of controller responses associated with success result - - self.result_ok : list of results where success is True - - self.diff_ok : list of payloads for which the request succeeded - - self.response_nok : list of controller responses associated with failed result - - self.result_nok : list of results where success is False - - self.diff_nok : list of payloads for which the request failed + Get the endpoint for the fabric create API call. """ - self.response_ok = [] - self.result_ok = [] - self.diff_ok = [] - self.response_nok = [] - self.result_nok = [] - self.diff_nok = [] - self.result_current = {"success": True} - self.response_current = {"msg": "skipped: check_mode"} + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self.endpoints.fabric_name = self._payloads_to_commit[0].get("FABRIC_NAME") + self.endpoints.template_name = "Easy_Fabric" + try: + endpoint = self.endpoints.fabric_create + except ValueError as error: + self.ansible_module.fail_json(error) - for payload in self._payloads_to_commit: - if self.result_current["success"]: - self.response_ok.append(copy.deepcopy(self.response_current)) - self.result_ok.append(copy.deepcopy(self.result_current)) - self.diff_ok.append(copy.deepcopy(payload)) - else: - self.response_nok.append(copy.deepcopy(self.response_current)) - self.result_nok.append(copy.deepcopy(self.result_current)) - self.diff_nok.append(copy.deepcopy(payload)) + self.path = endpoint["path"] + self.verb = endpoint["verb"] - msg = f"self.response_ok: {json.dumps(self.response_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.result_ok: {json.dumps(self.result_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.diff_ok: {json.dumps(self.diff_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.response_nok: {json.dumps(self.response_nok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = ( - f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" + def _set_fabric_create_endpoint(self, payload): + """ + Set the endpoint for the fabric create API call. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self.endpoints.fabric_name = payload.get("FABRIC_NAME") + self.fabric_type = copy.copy(payload.get("FABRIC_TYPE")) + self.endpoints.template_name = self.fabric_type_to_template_name( + self.fabric_type ) - self.log.debug(msg) + try: + endpoint = self.endpoints.fabric_create + except ValueError as error: + self.ansible_module.fail_json(error) - def _send_payloads_normal_mode(self): + payload.pop("FABRIC_TYPE", None) + self.path = endpoint["path"] + self.verb = endpoint["verb"] + + def _send_payloads(self): """ - Send the payloads to the controller and populate the following lists: + 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, populate the following lists: - self.response_ok : list of controller responses associated with success result - self.result_ok : list of results where success is True @@ -200,6 +182,8 @@ def _send_payloads_normal_mode(self): - self.result_nok : list of results where success is False - self.diff_nok : list of payloads for which the request failed """ + self.rest_send.check_mode = self.check_mode + self.response_ok = [] self.result_ok = [] self.diff_ok = [] @@ -207,10 +191,18 @@ def _send_payloads_normal_mode(self): self.result_nok = [] self.diff_nok = [] for payload in self._payloads_to_commit: - self.endpoints.fabric_name = payload.get("FABRIC_NAME") - self.endpoints.template_name = "Easy_Fabric" - self.path = self.endpoints.fabric_create.get("path") - self.verb = self.endpoints.fabric_create.get("verb") + self._set_fabric_create_endpoint(payload) + + # 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 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 @@ -221,32 +213,15 @@ def _send_payloads_normal_mode(self): self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) self.diff_ok.append(copy.deepcopy(payload)) else: - self.response_nok.append(copy.deepcopy(self.response_current)) - self.result_nok.append(copy.deepcopy(self.result_current)) + self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) self.diff_nok.append(copy.deepcopy(payload)) - msg = f"self.response_ok: {json.dumps(self.response_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.result_ok: {json.dumps(self.result_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.diff_ok: {json.dumps(self.diff_ok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = f"self.response_nok: {json.dumps(self.response_nok, indent=4, sort_keys=True)}" - self.log.debug(msg) - msg = ( - f"self.result_nok: {json.dumps(self.result_nok, indent=4, sort_keys=True)}" - ) - self.log.debug(msg) - msg = f"self.diff_nok: {json.dumps(self.diff_nok, indent=4, sort_keys=True)}" - self.log.debug(msg) - def _process_responses(self): method_name = inspect.stack()[0][3] - msg = f"len(self.result_ok): {len(self.result_ok)}, " - msg += f"len(self._payloads_to_commit): {len(self._payloads_to_commit)}" - self.log.debug(msg) - if len(self.result_ok) == len(self._payloads_to_commit): + # All requests succeeded, set changed to True and return + if len(self.result_nok) == 0: self.changed = True for diff in self.diff_ok: diff["action"] = self.action @@ -265,28 +240,52 @@ def _process_responses(self): if len(self.result_nok) != len(self._payloads_to_commit): self.changed = True - # When failing, provide the info for the request(s) that succeeded - # Since these represent the change(s) that were made. + # Provide the info for the request(s) that succeeded + # and the request(s) that failed + + # Add an "OK" result to the response(s) that succeeded for diff in self.diff_ok: diff["action"] = self.action + diff["result"] = "OK" self.diff = copy.deepcopy(diff) for result in self.result_ok: + result["result"] = "OK" self.result = copy.deepcopy(result) self.result_current = copy.deepcopy(result) for response in self.response_ok: + response["result"] = "OK" + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + # Add a "FAILED" result to the response(s) that failed + for diff in self.diff_nok: + diff["action"] = self.action + diff["result"] = "FAILED" + self.diff = copy.deepcopy(diff) + for result in self.result_nok: + result["result"] = "FAILED" + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_nok: + response["result"] = "FAILED" self.response = copy.deepcopy(response) self.response_current = copy.deepcopy(response) result = {} + result["diff"] = {} + result["response"] = {} + result["result"] = {} result["failed"] = self.failed result["changed"] = self.changed - result["diff"] = self.diff_ok - result["response"] = self.response_ok - result["result"] = self.result_ok + result["diff"]["OK"] = self.diff_ok + result["response"]["OK"] = self.response_ok + result["result"]["OK"] = self.result_ok + result["diff"]["FAILED"] = self.diff_nok + result["response"]["FAILED"] = self.response_nok + result["result"]["FAILED"] = self.result_nok msg = f"{self.class_name}.{method_name}: " - msg += "Bad response(s) during fabric create. " - msg += f"response(s): {self.response_nok}" + msg += f"Bad response(s) during fabric {self.action}. " self.ansible_module.fail_json(msg, **result) @property @@ -317,6 +316,7 @@ class FabricCreateBulk(FabricCreateCommon): """ Create fabrics in bulk. Skip any fabrics that already exist. """ + def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ @@ -347,6 +347,7 @@ def commit(self): self._build_payloads_to_commit() if len(self._payloads_to_commit) == 0: return + self._fixup_payloads_to_commit() self._send_payloads() self._process_responses() @@ -383,11 +384,6 @@ def commit(self): msg += "Exiting. Missing mandatory property: payload" self.ansible_module.fail_json(msg) - msg = f"{self.class_name}.{method_name}: " - msg += "payload: " - msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" - self.log.debug(msg) - if len(self.payload) == 0: self.ansible_module.exit_json(**self.failed_result) @@ -407,15 +403,6 @@ def commit(self): path = endpoint["path"] verb = endpoint["verb"] - msg = f"{self.class_name}.{method_name}: " - msg += f"verb: {verb}, path: {path}" - self.log.debug(msg) - - msg = f"{self.class_name}.{method_name}: " - msg += f"fabric_name: {fabric_name}, " - msg += f"payload: {json.dumps(self.payload, indent=4, sort_keys=True)}" - self.log.debug(msg) - self.rest_send.path = path self.rest_send.verb = verb self.rest_send.payload = self.payload @@ -429,26 +416,6 @@ def commit(self): if self.response_current["RETURN_CODE"] == 200: self.diff = self.payload - msg = "self.diff: " - msg += f"{json.dumps(self.diff, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = "self.response_current: " - msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = "self.response: " - msg += f"{json.dumps(self.response, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = "self.result_current: " - msg += f"{json.dumps(self.result_current, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = "self.result: " - msg += f"{json.dumps(self.result, indent=4, sort_keys=True)}" - self.log.debug(msg) - @property def payload(self): """ diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index fb824938e..97a1740e5 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -18,18 +18,19 @@ import copy import inspect +import json import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ - ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend 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_details import \ FabricDetailsByName from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_summary import \ FabricSummary -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ - RestSend class FabricDelete(FabricCommon): @@ -62,7 +63,9 @@ def __init__(self, ansible_module): self._endpoints = ApiEndpoints() self._fabric_details = FabricDetailsByName(self.ansible_module) self._fabric_summary = FabricSummary(self.ansible_module) - self._rest_send = RestSend(self.ansible_module) + self.rest_send = RestSend(self.ansible_module) + + 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 @@ -73,6 +76,12 @@ def __init__(self, ansible_module): self.action = "delete" self.changed = False self.failed = False + self.response_ok = [] + self.result_ok = [] + self.diff_ok = [] + self.response_nok = [] + self.result_nok = [] + self.diff_nok = [] def _build_properties(self): """ @@ -134,29 +143,26 @@ def _can_fabric_be_deleted(self, fabric_name): self.log.debug(msg) self._fabric_summary.fabric_name = fabric_name self._fabric_summary.refresh() - msg = f"self._fabric_summary.fabric_is_empty: " - msg += f"fabric_is_empty: {self._fabric_summary.fabric_is_empty}" - self.log.debug(msg) if self._fabric_summary.fabric_is_empty is False: - self.cannot_delete_fabric_reason = "Fabric is not empty" + self._cannot_delete_fabric_reason = "Fabric is not empty" return False return True - def _set_endpoint(self, fabric_name): + def _set_fabric_delete_endpoint(self, fabric_name): """ return the endpoint for the fabric_name """ self._endpoints.fabric_name = fabric_name try: - self._endpoint = self._endpoints.fabric_delete + endpoint = self._endpoints.fabric_delete except ValueError as error: self.ansible_module.fail_json(error, **self.failed_result) - self.path = self._endpoint.get("path") - self.verb = self._endpoint.get("verb") + self.path = endpoint.get("path") + self.verb = endpoint.get("verb") - def commit(self): + def _validate_commit_parameters(self): """ - delete each of the fabrics in self.fabric_names + validate the parameters for commit """ method_name = inspect.stack()[0][3] if self.fabric_names is None: @@ -164,6 +170,13 @@ def commit(self): msg += "fabric_names must be set prior to calling commit." self.ansible_module.fail_json(msg, **self.failed_result) + def commit(self): + """ + delete each of the fabrics in self.fabric_names + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self._validate_commit_parameters() + self._get_fabrics_to_delete() msg = f"self._fabrics_to_delete: {self._fabrics_to_delete}" @@ -173,33 +186,141 @@ def commit(self): self.failed = False return - msg = f"Populating diff {self._fabrics_to_delete}" - self.log.debug(msg) + self._send_requests() + self._process_responses() + + def _send_requests(self): + """ + If check_mode is False, send the requests to the controller + If check_mode is True, do not send the requests to the controller + + In both cases, populate the following lists: + + - self.response_ok : list of controller responses associated with success result + - self.result_ok : list of results where success is True + - self.diff_ok : list of payloads for which the request succeeded + - self.response_nok : list of controller responses associated with failed result + - self.result_nok : list of results where success is False + - self.diff_nok : list of payloads for which the request failed + """ + self.rest_send.check_mode = self.check_mode + + self.response_ok = [] + self.result_ok = [] + self.diff_ok = [] + self.response_nok = [] + self.result_nok = [] + self.diff_nok = [] + + # 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. + self.rest_send.timeout = 1 for fabric_name in self._fabrics_to_delete: - if self._can_fabric_be_deleted(fabric_name) is False: - msg = f"Cannot delete fabric {fabric_name}. " - msg += f"Reason: {self.cannot_delete_fabric_reason}" - self.ansible_module.fail_json(msg, **self.failed_result) - - self._set_endpoint(fabric_name) - self._rest_send.path = self.path - self._rest_send.verb = self.verb - self._rest_send.commit() - - self.diff = {"fabric_name": fabric_name} - self.response = copy.deepcopy(self._rest_send.response_current) - self.response_current = copy.deepcopy(self._rest_send.response_current) - self.result = copy.deepcopy(self._rest_send.result_current) - self.result_current = copy.deepcopy(self._rest_send.result_current) - - # msg = f"self.diff: {self.diff}" - # self.log.debug(msg) - msg = f"self.response: {self.response}" - self.log.debug(msg) - msg = f"self.result: {self.result}" - self.log.debug(msg) - msg = f"self.response_current: {self.response_current}" + self._send_request(fabric_name) + + def _send_request(self, fabric_name): + method_name = inspect.stack()[0][3] + self._set_fabric_delete_endpoint(fabric_name) + + msg = f"{self.class_name}.{method_name}: " + msg += f"verb: {self.verb}, path: {self.path}" self.log.debug(msg) - msg = f"self.result_current: {self.result_current}" + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.commit() + + if self.rest_send.result_current["success"]: + self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_ok.append({"fabric_name": fabric_name}) + else: + # 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.response_nok.append(copy.deepcopy(response_current)) + self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_nok.append({"fabric_name": fabric_name}) + + def _process_responses(self): + method_name = inspect.stack()[0][3] + + # All requests succeeded, set changed to True and return + if len(self.result_nok) == 0: + self.changed = True + for diff in self.diff_ok: + diff["action"] = self.action + self.diff = copy.deepcopy(diff) + for result in self.result_ok: + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_ok: + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + return + + # At least one request failed. + # Set failed to true, set changed appropriately, + # build response/result/diff, and call fail_json + self.failed = True + self.changed = False + # At least one request succeeded, so set changed to True + if self.result_ok != 0: + self.changed = True + + # Provide the results for all (failed and successful) requests + + # Add an "OK" result to the response(s) that succeeded + for diff in self.diff_ok: + diff["action"] = self.action + diff["result"] = "OK" + self.diff = copy.deepcopy(diff) + for result in self.result_ok: + result["result"] = "OK" + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_ok: + response["result"] = "OK" + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + # Add a "FAILED" result to the response(s) that failed + for diff in self.diff_nok: + diff["action"] = self.action + diff["result"] = "FAILED" + self.diff = copy.deepcopy(diff) + for result in self.result_nok: + result["result"] = "FAILED" + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_nok: + response["result"] = "FAILED" + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + result = {} + result["diff"] = {} + result["response"] = {} + result["result"] = {} + result["failed"] = self.failed + result["changed"] = self.changed + result["diff"]["OK"] = self.diff_ok + result["response"]["OK"] = self.response_ok + result["result"]["OK"] = self.result_ok + result["diff"]["FAILED"] = self.diff_nok + result["response"]["FAILED"] = self.response_nok + result["result"]["FAILED"] = self.result_nok + + msg = f"{self.class_name}.{method_name}: " + msg += f"Bad response(s) during fabric {self.action}. " + msg += f"result: {json.dumps(result, indent=4, sort_keys=True)}" self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"Bad response(s) during fabric {self.action}. " + self.ansible_module.fail_json(msg, **result) diff --git a/plugins/module_utils/fabric/endpoints.py b/plugins/module_utils/fabric/endpoints.py index 40f300355..6e97301d9 100644 --- a/plugins/module_utils/fabric/endpoints.py +++ b/plugins/module_utils/fabric/endpoints.py @@ -19,7 +19,6 @@ import copy import inspect -import json import logging import re @@ -69,6 +68,46 @@ def _build_properties(self): 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//config-deploy + """ + 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&inclAllMSDSwitches" + ) + 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//config-save + """ + 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): """ @@ -76,7 +115,7 @@ def fabric_create(self): verb: POST path: /rest/control/fabrics """ - method_name = inspect.stack()[0][3] + 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." @@ -90,9 +129,6 @@ def fabric_create(self): endpoint = {} endpoint["path"] = path endpoint["verb"] = "POST" - self.log.debug( - f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" - ) return endpoint @property @@ -102,7 +138,7 @@ def fabric_delete(self): verb: DELETE path: /rest/control/fabrics """ - method_name = inspect.stack()[0][3] + 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." @@ -112,9 +148,6 @@ def fabric_delete(self): endpoint = {} endpoint["path"] = path endpoint["verb"] = "DELETE" - self.log.debug( - f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" - ) return endpoint @property @@ -124,7 +157,7 @@ def fabric_summary(self): verb: GET path: /rest/control/fabrics/summary """ - method_name = inspect.stack()[0][3] + 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." @@ -133,9 +166,29 @@ def fabric_summary(self): path = copy.copy(self.endpoint_fabric_summary) endpoint["path"] = re.sub("_REPLACE_WITH_FABRIC_NAME_", self.fabric_name, path) endpoint["verb"] = "GET" - self.log.debug( - f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" - ) + return endpoint + + @property + def fabric_update(self): + """ + return fabric_update endpoint + verb: PUT + 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) + 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 @@ -145,13 +198,10 @@ def fabrics(self): verb: GET path: /rest/control/fabrics """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable endpoint = {} endpoint["path"] = self.endpoint_fabrics endpoint["verb"] = "GET" - self.log.debug( - f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" - ) return endpoint @property @@ -169,7 +219,7 @@ def fabric_info(self): except ValueError as error: self.ansible_module.fail_json(error) """ - method_name = inspect.stack()[0][3] + 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." @@ -179,9 +229,6 @@ def fabric_info(self): endpoint = {} endpoint["path"] = path endpoint["verb"] = "GET" - self.log.debug( - f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" - ) return endpoint @property @@ -215,7 +262,7 @@ def template(self): verb: GET path: /appcenter/cisco/ndfc/api/v1/configtemplate/rest/config/templates/{template_name} """ - method_name = inspect.stack()[0][3] + 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." @@ -225,26 +272,20 @@ def template(self): endpoint = {} endpoint["path"] = path endpoint["verb"] = "GET" - self.log.debug( - f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" - ) 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] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable endpoint = {} endpoint["path"] = self.endpoint_templates endpoint["verb"] = "GET" - self.log.debug( - f"Returning endpoint: {json.dumps(endpoint, indent=4, sort_keys=True)}" - ) return endpoint diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py index c1e177d02..fb43c887d 100644 --- a/plugins/module_utils/fabric/fabric_details.py +++ b/plugins/module_utils/fabric/fabric_details.py @@ -20,7 +20,6 @@ import copy import inspect -import json import logging from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ @@ -65,7 +64,7 @@ def refresh_super(self): self.data is a dictionary of fabric details, keyed on fabric name. """ - method_name = inspect.stack()[0][3] + 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") @@ -73,8 +72,6 @@ def refresh_super(self): self.data = {} for item in self.rest_send.response_current["DATA"]: self.data[item["fabricName"]] = item - msg = f"item: {json.dumps(item, indent=4, sort_keys=True)}" - self.log.debug(msg) self.response_current = self.rest_send.response_current self.response = self.rest_send.response_current self.result_current = self.rest_send.result_current @@ -229,15 +226,10 @@ def refresh(self): self.refresh_super() self.data_subclass = copy.deepcopy(self.data) - msg = "self.data_subclass: " - msg += f"{json.dumps(self.data_subclass, indent=4, sort_keys=True)}" - self.log.debug(msg) - def _get(self, item): """ - Retrieve the value of the top-level (non-nvPair) - item for the fabric specified (anything not in - the nvPairs dictionary). + Retrieve the value of the top-level (non-nvPair) item for fabric_name + (anything not in the nvPairs dictionary). See also: _get_nv_pair() """ @@ -265,8 +257,7 @@ def _get(self, item): def _get_nv_pair(self, item): """ - Retrieve the value of the nvPair item - for the fabric specified. + Retrieve the value of the nvPair item for fabric_name. See also: _get() """ @@ -315,6 +306,7 @@ def filter(self): def filter(self, value): self.properties["filter"] = value + class FabricDetailsByNvPair(FabricDetails): """ Retrieve fabric details from the controller filtered @@ -356,13 +348,12 @@ def refresh(self): self.ansible_module.fail_json(msg, **self.failed_result) self.refresh_super() - for item in self.data: - if self.data[item].get("nvPairs", {}).get(self.filter_key) == self.filter_value: - self.data_subclass[item["fabricName"]] = self.data[item] - - msg = "self.data_subclass: " - msg += f"{json.dumps(self.data_subclass, indent=4, sort_keys=True)}" - self.log.debug(msg) + 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): diff --git a/plugins/module_utils/fabric/fabric_summary.py b/plugins/module_utils/fabric/fabric_summary.py index 6c3f8b110..2efc35edc 100644 --- a/plugins/module_utils/fabric/fabric_summary.py +++ b/plugins/module_utils/fabric/fabric_summary.py @@ -97,6 +97,9 @@ def __init__(self, ansible_module): self._init_properties() def _init_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 @@ -110,15 +113,25 @@ def _update_device_counts(self): """ method_name = inspect.stack()[0][3] if self.data is None: - self.fail(f"refresh() must be called before accessing {method_name}.") + msg = f"{self.class_name}.{method_name}: " + msg = f"refresh() must be called before accessing {method_name}." + self.ansible_module.fail_json(msg, **self.failed_result) + 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["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 - + 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 refresh(self): """ Refresh the fabric summary info from the controller and @@ -155,9 +168,9 @@ def refresh(self): self._update_device_counts() - def verify_refresh(self, method_name): + def verify_refresh_has_been_called(self, method_name): """ - If refresh() has not been called, fail with a message. + Fail if refresh() has not been called. """ if self.data is None: msg = f"refresh() must be called before accessing {method_name}." @@ -176,7 +189,7 @@ def border_gateway_count(self) -> int: Return the number of border gateway devices in fabric fabric_name. """ method_name = inspect.stack()[0][3] - self.verify_refresh(method_name) + self.verify_refresh_has_been_called(method_name) return self.properties["border_gateway_count"] @property @@ -185,7 +198,7 @@ def device_count(self) -> int: Return the total number of devices in fabric fabric_name. """ method_name = inspect.stack()[0][3] - self.verify_refresh(method_name) + self.verify_refresh_has_been_called(method_name) return self.properties["device_count"] @property @@ -194,7 +207,7 @@ def fabric_is_empty(self) -> bool: Return True if the fabric is empty. """ method_name = inspect.stack()[0][3] - self.verify_refresh(method_name) + self.verify_refresh_has_been_called(method_name) if self.device_count == 0: return True return False @@ -216,7 +229,7 @@ def leaf_count(self) -> int: Return the number of leaf devices in fabric fabric_name. """ method_name = inspect.stack()[0][3] - self.verify_refresh(method_name) + self.verify_refresh_has_been_called(method_name) return self.properties["leaf_count"] @property @@ -225,8 +238,5 @@ def spine_count(self) -> int: Return the number of spine devices in fabric fabric_name. """ method_name = inspect.stack()[0][3] - self.verify_refresh(method_name) + self.verify_refresh_has_been_called(method_name) return self.properties["spine_count"] - - - diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py index 009795c9a..0a19a8e4d 100644 --- a/plugins/module_utils/fabric/query.py +++ b/plugins/module_utils/fabric/query.py @@ -18,7 +18,6 @@ import copy import inspect -import json import logging from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ diff --git a/plugins/module_utils/fabric/results.py b/plugins/module_utils/fabric/results.py index 32838b510..a3eae378d 100644 --- a/plugins/module_utils/fabric/results.py +++ b/plugins/module_utils/fabric/results.py @@ -21,6 +21,7 @@ import logging from typing import Any, Dict + class Results: """ Return various result templates that AnsibleModule can use. @@ -28,8 +29,10 @@ class Results: results = Results() # A generic result that indicates a task failed with no changes failed_result = results.failed_result + + TODO: module_result is not yet implemented # A generic result that indicates a task succeeded - # obj is an instance of a class that has diff, result, and response properties + # obj is an instance of a class that has diff, result, and response properties module_result = results.module_result(obj) # output of the above print() will be a dict with the following structure @@ -70,10 +73,12 @@ def __init__(self, ansible_module): self.diff_keys = ["deleted", "merged", "query"] self.response_keys = ["deleted", "merged", "query"] - def did_anything_change(self, obj): + def did_anything_change(self, obj) -> bool: """ return True if obj has any changes Caller: module_result + + TODO: Need to implement module_result """ if self.check_mode is True: self.log.debug("check_mode is True. No changes made.") @@ -83,7 +88,7 @@ def did_anything_change(self, obj): return False @property - def failed_result(self): + def failed_result(self) -> Dict[str, Any]: """ return a result for a failed task with no changes """ @@ -98,30 +103,30 @@ def failed_result(self): result["response"][key] = [] return result - @property - def module_result(self, obj) -> Dict[str, Any]: - """ - Return a result that AnsibleModule can use - Result is based on the obj properties: diff, response - """ - if not isinstance(list, obj.result): - raise ValueError("obj.result must be a list of dict") - if not isinstance(list, obj.diff): - raise ValueError("obj.diff must be a list of dict") - if not isinstance(list, obj.response): - raise ValueError("obj.response must be a list of dict") - result = {} - result["changed"] = self.did_anything_change(obj) - result["diff"] = {} - result["response"] = {} - for key in self.diff_keys: - if self.state == key: - result["diff"][key] = obj.diff - else: - result["diff"][key] = [] - for key in self.response_keys: - if self.state == key: - result["response"][key] = obj.response - else: - result["response"][key] = [] - return result + # @property + # def module_result(self, obj) -> Dict[str, Any]: + # """ + # Return a result that AnsibleModule can use + # Result is based on the obj properties: diff, response + # """ + # if not isinstance(list, obj.result): + # raise ValueError("obj.result must be a list of dict") + # if not isinstance(list, obj.diff): + # raise ValueError("obj.diff must be a list of dict") + # if not isinstance(list, obj.response): + # raise ValueError("obj.response must be a list of dict") + # result = {} + # result["changed"] = self.did_anything_change(obj) + # result["diff"] = {} + # result["response"] = {} + # for key in self.diff_keys: + # if self.state == key: + # result["diff"][key] = obj.diff + # else: + # result["diff"][key] = [] + # for key in self.response_keys: + # if self.state == key: + # result["response"][key] = obj.response + # else: + # result["response"][key] = [] + # return result diff --git a/plugins/module_utils/fabric/template_get.py b/plugins/module_utils/fabric/template_get.py index d35860909..9ca32d532 100755 --- a/plugins/module_utils/fabric/template_get.py +++ b/plugins/module_utils/fabric/template_get.py @@ -20,16 +20,17 @@ import copy import inspect -import json import logging from typing import Any, Dict -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ - ApiEndpoints + from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ Results + class TemplateGet: """ Retrieve a template from the controller. @@ -42,6 +43,7 @@ class TemplateGet: template = instance.template """ + def __init__(self, ansible_module): self.class_name = self.__class__.__name__ self.ansible_module = ansible_module @@ -55,9 +57,17 @@ def __init__(self, ansible_module): msg += f"check_mode: {self.check_mode}" self.log.debug(msg) - self._endpoints = ApiEndpoints() - self._rest_send = RestSend(self.ansible_module) + self.endpoints = ApiEndpoints() + self.rest_send = RestSend(self.ansible_module) self._results = Results(self.ansible_module) + self.path = None + self.verb = None + + self.response = [] + self.response_current = {} + self.result = [] + self.result_current = {} + self._init_properties() def _init_properties(self) -> None: @@ -88,32 +98,42 @@ def template_name(self) -> str: def template_name(self, value) -> None: self._properties["template_name"] = value - def refresh(self): + def _set_template_endpoint(self) -> None: """ - Retrieve the template from the controller. + Set the endpoint for the template to be retrieved from the controller. """ method_name = inspect.stack()[0][3] - if self.template_name == None: + if self.template_name is None: msg = f"{self.class_name}.{method_name}: " msg += "Set instance.template_name property before " msg += "calling instance.refresh()" self.log.error(msg) self.ansible_module.fail_json(msg, **self._results.failed_result) - self._endpoints.template_name = self.template_name + self.endpoints.template_name = self.template_name try: - self.endpoint = self._endpoints.template + endpoint = self.endpoints.template except ValueError as error: - raise ValueError(error) + raise ValueError(error) from error - self._rest_send.path = self.endpoint.get("path") - self._rest_send.verb = self.endpoint.get("verb") - self._rest_send.commit() + self.path = endpoint.get("path") + self.verb = endpoint.get("verb") - self.response_current = copy.deepcopy(self._rest_send.response_current) - self.response = copy.deepcopy(self._rest_send.response_current) - self.result_current = copy.deepcopy(self._rest_send.result_current) - self.result = copy.deepcopy(self._rest_send.result_current) + def refresh(self): + """ + Retrieve the template from the controller. + """ + method_name = inspect.stack()[0][3] + self._set_template_endpoint() + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.commit() + + self.response_current = copy.deepcopy(self.rest_send.response_current) + self.response.append(copy.deepcopy(self.rest_send.response_current)) + self.result_current = copy.deepcopy(self.rest_send.result_current) + self.result.append(copy.deepcopy(self.rest_send.result_current)) if self.response_current.get("RETURN_CODE", None) != 200: msg = f"{self.class_name}.{method_name}: " @@ -122,8 +142,6 @@ def refresh(self): self.ansible_module.fail_json(msg, **self._results.failed_result) self.template = {} - self.template["parameters"] = self.response_current.get("DATA", {}).get("parameters", []) - - # self.template = self.response_current.get("DATA", {}).get("parameters", []) - # msg = f"{self.class_name}.{method_name}: " - # msg += f"template: {json.dumps(self.template, indent=4, sort_keys=True)}" + self.template["parameters"] = self.response_current.get("DATA", {}).get( + "parameters", [] + ) diff --git a/plugins/module_utils/fabric/template_get_all.py b/plugins/module_utils/fabric/template_get_all.py index 828e83fe2..e985cab0c 100755 --- a/plugins/module_utils/fabric/template_get_all.py +++ b/plugins/module_utils/fabric/template_get_all.py @@ -18,17 +18,19 @@ __copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." __author__ = "Allen Robel" +import copy import inspect -import json import logging from typing import Any, Dict -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ - ApiEndpoints + from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ Results + class TemplateGetAll: """ Retrieve a list of all templates from the controller. @@ -39,6 +41,7 @@ class TemplateGetAll: instance.refresh() templates = instance.templates """ + def __init__(self, ansible_module): self.class_name = self.__class__.__name__ self.ansible_module = ansible_module @@ -52,9 +55,17 @@ def __init__(self, ansible_module): msg += f"check_mode: {self.check_mode}" self.log.debug(msg) - self._endpoints = ApiEndpoints() - self._rest_send = RestSend() + self.endpoints = ApiEndpoints() + self.rest_send = RestSend(self.ansible_module) self._results = Results(self.ansible_module) + self.path = None + self.verb = None + + self.response = [] + self.response_current = {} + self.result = [] + self.result_current = {} + self._init_properties() def _init_properties(self) -> None: @@ -72,25 +83,35 @@ def templates(self): def templates(self, value: Dict[str, Any]) -> None: self._properties["templates"] = value - def refresh(self): + def _set_templates_endpoint(self) -> None: """ - Retrieve the templates from the controller. + Set the endpoint for the template to be retrieved from the controller. """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable try: - self.endpoint = self._endpoints.templates + endpoint = self.endpoints.templates except ValueError as error: - raise ValueError(error) + raise ValueError(error) from error - self._rest_send.path = self.endpoint.get("path") - self._rest_send.verb = self.endpoint.get("verb") - self._rest_send.commit() + self.path = endpoint.get("path") + self.verb = endpoint.get("verb") - self.response_current = self._rest_send.response_current - self.response = self._rest_send.response_current - self.result_current = self._rest_send.result_current - self.result = self._rest_send.result_current + def refresh(self): + """ + Retrieve the templates from the controller. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self._set_templates_endpoint() + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.commit() + + self.response_current = copy.deepcopy(self.rest_send.response_current) + self.response.append(copy.deepcopy(self.rest_send.response_current)) + self.result_current = copy.deepcopy(self.rest_send.result_current) + self.result.append(copy.deepcopy(self.rest_send.result_current)) if self.response_current.get("RETURN_CODE", None) != 200: msg = f"{self.class_name}.{method_name}: " @@ -99,6 +120,3 @@ def refresh(self): self.ansible_module.fail_json(msg, **self._results.failed_result) self.templates = self.result_current - - msg = f"{self.class_name}.{method_name}: " - msg += f"templates: {json.dumps(self.templates, indent=4, sort_keys=True)}" diff --git a/plugins/module_utils/fabric/template_parse_common.py b/plugins/module_utils/fabric/template_parse_common.py index bc113f96a..7b9e9a4b2 100755 --- a/plugins/module_utils/fabric/template_parse_common.py +++ b/plugins/module_utils/fabric/template_parse_common.py @@ -6,41 +6,28 @@ Superclass for NdfcTemplateEasyFabric() and NdfcTemplateAll() """ import re -import sys -import json + class TemplateParseCommon: """ Superclass for TemplateParse*() classes """ + def __init__(self): self._properties = {} self._properties["template"] = None - self._properties["template_json"] = None @property def template(self): + """ + The template contents supported by the subclass + """ return self._properties["template"] @template.setter def template(self, value): - """ - The template contents supported by the subclass - """ self._properties["template"] = value - @property - def template_json(self): - return self._properties["template_json"] - - @template_json.setter - def template_json(self, value): - """ - Full path to a file containing the template content - in JSON format - """ - self._properties["template_json"] = value - @staticmethod def delete_key(key, dictionary): """ @@ -51,6 +38,10 @@ def delete_key(key, dictionary): return dictionary def get_default_value_meta_properties(self, item): + """ + Return defaultValue of metaProperties item, if any + Return None otherwise + """ try: result = item["metaProperties"]["defaultValue"] except KeyError: @@ -58,6 +49,10 @@ def get_default_value_meta_properties(self, item): return self.clean_string(result) def get_default_value_root(self, item): + """ + Return defaultValue of a root item, if any + Return None otherwise + """ try: result = item["defaultValue"] except KeyError: @@ -68,7 +63,6 @@ def get_default_value(self, item): """ Return the default value for item, if it exists. Return None otherwise. - item["metaProperties"]["defaultValue"] """ result = self.get_default_value_meta_properties(item) if result is not None: @@ -76,14 +70,13 @@ def get_default_value(self, item): result = self.get_default_value_root(item) return result - def get_description(self, item): """ Return the description of an item, i.e.: item['annotations']['Description'] """ try: - description = item['annotations']['Description'] + description = item["annotations"]["Description"] except KeyError: description = "unknown" return self.clean_string(description) @@ -97,9 +90,10 @@ def get_dict_value(self, dictionary, key): """ if not isinstance(dictionary, dict): return None - if key in dictionary: return dictionary[key] - for k, v in dictionary.items(): - if isinstance(v,dict): + if key in dictionary: + return dictionary[key] + for k, v in dictionary.items(): # pylint: disable=unused-variable + if isinstance(v, dict): item = self.get_dict_value(v, key) if item is not None: return item @@ -199,7 +193,7 @@ def get_name(self, item): Typically, item['name'] """ try: - result = item['name'] + result = item["name"] except KeyError: result = "unknown" return self.clean_string(result) @@ -212,16 +206,16 @@ def is_internal(self, item): result = self.get_dict_value(item, "IsInternal") return self.make_bool(result) - def is_optional(self,item): + def is_optional(self, item): """ Return the optional status of an item (True or False) if it exists. Otherwise return None """ - result = self.make_bool(item.get('optional', None)) + result = self.make_bool(item.get("optional", None)) return result - def get_parameter_type(self, item): + def get_parameter_type(self, item): # pylint: disable=too-many-return-statements """ Return the parameter type of an item if it exists. Otherwise return None @@ -235,7 +229,7 @@ def get_parameter_type(self, item): ipV4Address -> ipv4 etc. """ - result = self.get_dict_value(item, 'parameterType') + result = self.get_dict_value(item, "parameterType") if result is None: return None if result in ["STRING", "string", "str"]: @@ -271,7 +265,7 @@ def get_template_content_type(self, template): template['type'] """ try: - result = template['contentType'] + result = template["contentType"] except KeyError: result = "unknown" return self.clean_string(result) @@ -283,7 +277,7 @@ def get_template_description(self, template): template['description'] """ try: - result = template['description'] + result = template["description"] except KeyError: result = "unknown" return self.clean_string(result) @@ -295,7 +289,7 @@ def get_template_name(self, template): template['name'] """ try: - result = template['name'] + result = template["name"] except KeyError: result = "unknown" return self.clean_string(result) @@ -307,7 +301,7 @@ def get_template_subtype(self, template): template['templateSubType'] """ try: - result = template['templateSubType'] + result = template["templateSubType"] except KeyError: result = "unknown" return self.clean_string(result) @@ -319,7 +313,7 @@ def get_template_supported_platforms(self, template): template['supportedPlatforms'] """ try: - result = template['supportedPlatforms'] + result = template["supportedPlatforms"] except KeyError: result = "unknown" return self.clean_string(result) @@ -331,7 +325,7 @@ def get_template_tags(self, template): template['tags'] """ try: - result = template['tags'] + result = template["tags"] except KeyError: result = "unknown" return self.clean_string(result) @@ -343,7 +337,7 @@ def get_template_type(self, template): template['templateType'] """ try: - result = template['templateType'] + result = template["templateType"] except KeyError: result = "unknown" return self.clean_string(result) @@ -387,21 +381,20 @@ def is_hidden(self, item): return True return False - def is_required(self,item): + def is_required(self, item): """ Return the required status of an item (True or False) The inverse of item['optional'] - + Otherwise return None """ - result = self.make_bool(item.get('optional', None)) + result = self.make_bool(item.get("optional", None)) if result is True: return False if result is False: return True return None - @staticmethod def make_bool(value): """ @@ -421,15 +414,15 @@ def clean_string(self, string): if string is None: return "" string = string.strip() - string = re.sub('
', ' ', string) - string = re.sub(''', '', string) - string = re.sub('+', '+', string) - string = re.sub('=', '=', string) - string = re.sub('amp;', '', string) - string = re.sub(r'\[', '', string) - string = re.sub(r'\]', '', string) - string = re.sub('\"', '', string) - string = re.sub("\'", '', string) + string = re.sub("
", " ", string) + string = re.sub("'", "", string) + string = re.sub("+", "+", string) + string = re.sub("=", "=", string) + string = re.sub("amp;", "", string) + string = re.sub(r"\[", "", string) + string = re.sub(r"\]", "", string) + string = re.sub('"', "", string) + string = re.sub("'", "", string) string = re.sub(r"\s+", " ", string) string = self.make_bool(string) try: @@ -442,16 +435,3 @@ def clean_string(self, string): except ValueError: pass return string - - def load(self): - """ - Load the template from a JSON file - """ - if self.template_json is None: - msg = "exiting. set instance.template_json to the file " - msg += "path of the JSON content before calling " - msg += "load_template()" - print(f"{msg}") - sys.exit(1) - with open(self.template_json, 'r', encoding="utf-8") as handle: - self.template = json.load(handle) diff --git a/plugins/module_utils/fabric/template_parse_easy_fabric.py b/plugins/module_utils/fabric/template_parse_easy_fabric.py index 147f4b2eb..613484964 100644 --- a/plugins/module_utils/fabric/template_parse_easy_fabric.py +++ b/plugins/module_utils/fabric/template_parse_easy_fabric.py @@ -12,20 +12,24 @@ import json import re import sys + import yaml from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.template_parse_common import \ TemplateParseCommon + class TemplateParseEasyFabric(TemplateParseCommon): def __init__(self): super().__init__() self.translation = None self.suboptions = None self.documentation = None + self.ruleset = {} @property def template_all(self): return self._properties["template_all"] + @template_all.setter def template_all(self, value): """ @@ -133,7 +137,11 @@ def build_documentation(self): value = "I(deleted), I(merged), and I(query) states are supported." self.documentation["options"]["state"]["description"].append(value) self.documentation["options"]["state"]["type"] = "str" - self.documentation["options"]["state"]["choices"] = ["deleted", "merged", "query"] + self.documentation["options"]["state"]["choices"] = [ + "deleted", + "merged", + "query", + ] self.documentation["options"]["state"]["default"] = "merged" self.documentation["options"]["config"] = {} self.documentation["options"]["config"]["description"] = [] @@ -149,9 +157,9 @@ def build_documentation(self): continue if self.is_hidden(item): continue - if not item.get('name', None): + if not item.get("name", None): continue - name = self.translation.get(item['name'], None) + name = self.translation.get(item["name"], None) if name is None: print(f"WARNING: skipping {item['name']}") continue @@ -163,7 +171,7 @@ def build_documentation(self): default = self.get_default_value(item) if default is not None: suboptions[name]["default"] = default - choices = self.get_enum(item) + choices = self.get_enum(item) if len(choices) > 0: if "TEMPLATES" in str(choices[0]): tag = str(choices[0]).split(".")[1] @@ -183,11 +191,13 @@ def build_documentation(self): self.documentation["options"]["config"]["suboptions"] = [] for key in sorted(suboptions.keys()): - self.documentation["options"]["config"]["suboptions"].append({key: suboptions[key]}) + self.documentation["options"]["config"]["suboptions"].append( + {key: suboptions[key]} + ) def build_ruleset(self): """ - Build the ruleset for the EasyFabric template, based on + Build the ruleset for the EasyFabric template, based on annotations.IsShow in each parameter dictionary. The ruleset is keyed on parameter name, with values being set of @@ -212,9 +222,9 @@ def build_ruleset(self): continue if self.is_hidden(item): continue - if not item.get('name', None): + if not item.get("name", None): continue - name = self.translation.get(item['name'], None) + name = self.translation.get(item["name"], None) if name is None: print(f"WARNING: skipping {item['name']}") continue @@ -222,6 +232,9 @@ def build_ruleset(self): self.ruleset = self.pythonize_ruleset(self.ruleset) def pythonize_ruleset(self, ruleset): + """ + Convert a template ruleset to python code that can be eval()'d + """ mixed_rules = {} for key in ruleset: rule = ruleset[key] @@ -247,18 +260,18 @@ def pythonize_ruleset(self, ruleset): rule = rule.split("and") rule = [x.strip() for x in rule] rule = [re.sub(r"\s{2}+", " ", x) for x in rule] - #print(f"POST1: key {key}, len {len(rule)} rule: {rule}") + # print(f"POST1: key {key}, len {len(rule)} rule: {rule}") rule = [re.sub(r"\"", "", x) for x in rule] rule = [re.sub(r"\'", "", x) for x in rule] - #rule = [re.sub(r"\s{2}+", " ", x) for x in rule] - #print(f"POST2: key {key}, len {len(rule)} rule: {rule}") + # rule = [re.sub(r"\s{2}+", " ", x) for x in rule] + # print(f"POST2: key {key}, len {len(rule)} rule: {rule}") new_rule = [] for item in rule: - lhs,op,rhs = item.split(" ") - rhs = rhs.replace("\"", "") - rhs = rhs.replace("\'", "") + lhs, op, rhs = item.split(" ") + rhs = rhs.replace('"', "") + rhs = rhs.replace("'", "") if rhs not in ["True", "False", True, False]: - rhs = f"\"{rhs}\"" + rhs = f'"{rhs}"' lhs = self.translation.get(lhs, lhs) # print(f"POST3: key {key}: lhs: {lhs}, op: {op}, rhs: {rhs}") new_rule.append(f"{lhs} {op} {rhs}") @@ -266,7 +279,6 @@ def pythonize_ruleset(self, ruleset): # print(f"POST4: key {key}: {new_rule}") ruleset[key] = new_rule return ruleset - def documentation_yaml(self): """ diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py new file mode 100644 index 000000000..b40cae10c --- /dev/null +++ b/plugins/module_utils/fabric/update.py @@ -0,0 +1,570 @@ +# +# 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.rest_send_fabric import \ + RestSend +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_details import \ + FabricDetailsByName +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_summary import \ + FabricSummary +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_playbook_params import \ + VerifyPlaybookParams + + +class FabricUpdateCommon(FabricCommon): + """ + Common methods and properties for: + - FabricUpdate + - FabricUpdateBulk + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.check_mode = self.ansible_module.check_mode + msg = "ENTERED FabricUpdateCommon(): " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self.fabric_details = FabricDetailsByName(self.ansible_module) + self._fabric_summary = FabricSummary(self.ansible_module) + self.endpoints = ApiEndpoints() + self.rest_send = RestSend(self.ansible_module) + self._verify_params = VerifyPlaybookParams(self.ansible_module) + + # 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 + # List of fabrics that have the deploy flag set to True + # and that are not empty. + # Updated in _build_fabrics_to_config_deploy() + self._fabrics_to_config_deploy = [] + # List of fabrics that have the deploy flag set to True + # Updated in _build_fabrics_to_config_save() + self._fabrics_to_config_save = [] + + self.action = "update" + self._payloads_to_commit = [] + self.response_ok = [] + self.result_ok = [] + self.diff_ok = [] + self.response_nok = [] + self.result_nok = [] + self.diff_nok = [] + + # Number of successful fabric update payloads + # Used to determine if all fabric updates were successful + self.successful_fabric_payloads = 0 + + self.cannot_deploy_fabric_reason = "" + + self._mandatory_payload_keys = set() + self._mandatory_payload_keys.add("FABRIC_NAME") + self._mandatory_payload_keys.add("DEPLOY") + + def _can_fabric_be_deployed(self, fabric_name): + """ + return True if the fabric configuration can be saved and deployed + return False otherwise + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "ENTERED" + self.log.debug(msg) + self._fabric_summary.fabric_name = fabric_name + self._fabric_summary.refresh() + msg = "self._fabric_summary.fabric_is_empty: " + msg += f"{self._fabric_summary.fabric_is_empty}" + self.log.debug(msg) + if self._fabric_summary.fabric_is_empty is True: + self.cannot_deploy_fabric_reason = "Fabric is not empty" + return False + return True + + def _verify_payload(self, payload): + """ + Verify that the payload is a dict and contains all mandatory keys + """ + method_name = inspect.stack()[0][3] + if not isinstance(payload, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "payload must be a dict. " + msg += f"Got type {type(payload).__name__}, " + msg += f"value {payload}" + self.ansible_module.fail_json(msg, **self.failed_result) + + missing_keys = [] + for key in self._mandatory_payload_keys: + if key not in payload: + missing_keys.append(key) + if len(missing_keys) == 0: + return + + msg = f"{self.class_name}.{method_name}: " + msg += "payload is missing mandatory keys: " + msg += f"{sorted(missing_keys)}" + self.ansible_module.fail_json(msg, **self.failed_result) + + def _build_payloads_to_commit(self): + """ + 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: + self._payloads_to_commit.append(copy.deepcopy(payload)) + + def _fixup_payloads_to_commit(self): + """ + Make any modifications to the payloads prior to sending them + to the controller. + + Add any modifications to the list below. + + - Translate ANYCAST_GW_MAC to a format the controller understands + """ + for payload in self._payloads_to_commit: + if "ANYCAST_GW_MAC" in payload: + payload["ANYCAST_GW_MAC"] = self.translate_mac_address( + payload["ANYCAST_GW_MAC"] + ) + + 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, populate the following lists: + + - self.response_ok : list of controller responses associated with success result + - self.result_ok : list of results where success is True + - self.diff_ok : list of payloads for which the request succeeded + - self.response_nok : list of controller responses associated with failed result + - self.result_nok : list of results where success is False + - self.diff_nok : list of payloads for which the request failed + """ + self.rest_send.check_mode = self.check_mode + + self.response_ok = [] + self.result_ok = [] + self.diff_ok = [] + self.response_nok = [] + self.result_nok = [] + self.diff_nok = [] + + self._build_fabrics_to_config_deploy() + self._fixup_payloads_to_commit() + for payload in self._payloads_to_commit: + self._send_payload(payload) + + # Skip config-save if any errors were encountered with fabric updates. + if len(self.result_nok) != 0: + return + self._config_save() + # Skip config-deploy if any errors were encountered with config-save. + if len(self.result_nok) != 0: + return + self._config_deploy() + + def _build_fabrics_to_config_deploy(self): + """ + Build a list of fabrics to config-deploy and config-save + + This also removes the DEPLOY key from the payload + + Skip: + - payloads without FABRIC_NAME key (shouldn't happen, but just in case) + - fabrics with DEPLOY key set to False + - Empty fabrics (these cannot be config-deploy'ed or config-save'd) + """ + method_name = inspect.stack()[0][3] + for payload in self._payloads_to_commit: + fabric_name = payload.get("FABRIC_NAME", None) + if fabric_name is None: + continue + deploy = payload.pop("DEPLOY", None) + if deploy is not True: + continue + if self._can_fabric_be_deployed(fabric_name) is False: + continue + + msg = f"{self.class_name}.{method_name}: " + msg += f"_can_fabric_be_deployed: {self._can_fabric_be_deployed(fabric_name)}, " + msg += ( + f"self.cannot_deploy_fabric_reason: {self.cannot_deploy_fabric_reason}" + ) + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"Adding fabric_name: {fabric_name}" + self.log.debug(msg) + + self._fabrics_to_config_deploy.append(fabric_name) + self._fabrics_to_config_save.append(fabric_name) + + def _set_fabric_update_endpoint(self, payload): + """ + Set the endpoint for the fabric create API call. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self.endpoints.fabric_name = payload.get("FABRIC_NAME") + self.fabric_type = copy.copy(payload.get("FABRIC_TYPE")) + self.endpoints.template_name = self.fabric_type_to_template_name( + self.fabric_type + ) + try: + endpoint = self.endpoints.fabric_update + except ValueError as error: + self.ansible_module.fail_json(error) + + payload.pop("FABRIC_TYPE", None) + self.path = endpoint["path"] + self.verb = endpoint["verb"] + + def _send_payload(self, payload): + """ + Send one fabric update payload + """ + method_name = inspect.stack()[0][3] + self._set_fabric_update_endpoint(payload) + + 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"]: + self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_ok.append(copy.deepcopy(payload)) + else: + self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_nok.append(copy.deepcopy(payload)) + + def _config_save(self): + """ + Save the fabric configuration to the controller + """ + method_name = inspect.stack()[0][3] + for fabric_name in self._fabrics_to_config_save: + msg = f"{self.class_name}.{method_name}: fabric_name: {fabric_name}" + self.log.debug(msg) + + try: + self.endpoints.fabric_name = fabric_name + self.path = self.endpoints.fabric_config_save.get("path") + self.verb = self.endpoints.fabric_config_save.get("verb") + except ValueError as error: + self.ansible_module.fail_json(error, **self.failed_result) + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.payload = None + self.rest_send.commit() + + if self.rest_send.result_current["success"]: + self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_ok.append({"FABRIC_NAME": fabric_name, "config_save": "OK"}) + else: + self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_nok.append( + copy.deepcopy({"FABRIC_NAME": fabric_name, "config_save": "FAILED"}) + ) + + def _config_deploy(self): + """ + Deploy the fabric configuration to the controller + """ + method_name = inspect.stack()[0][3] + for fabric_name in self._fabrics_to_config_deploy: + msg = f"{self.class_name}.{method_name}: fabric_name: {fabric_name}" + self.log.debug(msg) + + try: + self.endpoints.fabric_name = fabric_name + self.path = self.endpoints.fabric_config_deploy.get("path") + self.verb = self.endpoints.fabric_config_deploy.get("verb") + except ValueError as error: + self.ansible_module.fail_json(error, **self.failed_result) + + self.rest_send.path = self.path + self.rest_send.verb = self.verb + self.rest_send.payload = None + self.rest_send.commit() + + if self.rest_send.result_current["success"]: + self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_ok.append({"FABRIC_NAME": fabric_name, "config_deploy": "OK"}) + else: + self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.diff_nok.append( + copy.deepcopy( + {"FABRIC_NAME": fabric_name, "config_deploy": "FAILED"} + ) + ) + + def _process_responses(self): + method_name = inspect.stack()[0][3] + + # All requests succeeded, set changed to True and return + if len(self.result_nok) == 0: + self.changed = True + for diff in self.diff_ok: + diff["action"] = self.action + self.diff = copy.deepcopy(diff) + for result in self.result_ok: + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_ok: + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + return + + # At least one request failed. + # Set failed to true, set changed appropriately, + # build response/result/diff, and call fail_json + self.failed = True + self.changed = False + # At least one request succeeded, so set changed to True + if self.result_ok != 0: + self.changed = True + + # Provide the results for all (failed and successful) requests + + # Add an "OK" result to the response(s) that succeeded + for diff in self.diff_ok: + diff["action"] = self.action + diff["result"] = "OK" + self.diff = copy.deepcopy(diff) + for result in self.result_ok: + result["result"] = "OK" + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_ok: + response["result"] = "OK" + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + # Add a "FAILED" result to the response(s) that failed + for diff in self.diff_nok: + diff["action"] = self.action + diff["result"] = "FAILED" + self.diff = copy.deepcopy(diff) + for result in self.result_nok: + result["result"] = "FAILED" + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_nok: + response["result"] = "FAILED" + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + result = {} + result["diff"] = {} + result["response"] = {} + result["result"] = {} + result["failed"] = self.failed + result["changed"] = self.changed + result["diff"]["OK"] = self.diff_ok + result["response"]["OK"] = self.response_ok + result["result"]["OK"] = self.result_ok + result["diff"]["FAILED"] = self.diff_nok + result["response"]["FAILED"] = self.response_nok + result["result"]["FAILED"] = self.result_nok + + msg = f"{self.class_name}.{method_name}: " + msg += f"Bad response(s) during fabric {self.action}. " + self.ansible_module.fail_json(msg, **result) + + @property + def payloads(self): + """ + Return the fabric create payloads + + Payloads must be a list of dict. Each dict is a + payload for the fabric create API endpoint. + """ + 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}" + self.ansible_module.fail_json(msg, **self.failed_result) + for item in value: + self._verify_payload(item) + self.properties["payloads"] = value + + +class FabricUpdateBulk(FabricUpdateCommon): + """ + Create fabrics in bulk. Skip any fabrics that already exist. + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricUpdateBulk()") + + 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, + """ + method_name = inspect.stack()[0][3] + if self.payloads is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payloads must be set prior to calling commit." + self.ansible_module.fail_json(msg, **self.failed_result) + + self._build_payloads_to_commit() + if len(self._payloads_to_commit) == 0: + return + self._send_payloads() + self._process_responses() + + +class FabricUpdate(FabricCommon): + """ + Update a VXLAN fabric on the controller. + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED FabricUpdate()") + + self.data = {} + self.endpoints = ApiEndpoints() + self.rest_send = RestSend(self.ansible_module) + + self._init_properties() + + def _init_properties(self): + # self.properties is already initialized in the parent class + self.properties["payload"] = None + + def commit(self): + """ + Send the fabric create request to the controller. + """ + method_name = inspect.stack()[0][3] + if self.payload is None: + msg = f"{self.class_name}.{method_name}: " + msg += "Exiting. Missing mandatory property: payload" + self.ansible_module.fail_json(msg) + + if len(self.payload) == 0: + self.ansible_module.exit_json(**self.failed_result) + + fabric_name = self.payload.get("FABRIC_NAME") + if fabric_name is None: + msg = f"{self.class_name}.{method_name}: " + msg += "payload is missing mandatory FABRIC_NAME key." + self.ansible_module.fail_json(msg) + + self.endpoints.fabric_name = fabric_name + self.endpoints.template_name = "Easy_Fabric" + try: + endpoint = self.endpoints.fabric_create + except ValueError as error: + self.ansible_module.fail_json(error) + + path = endpoint["path"] + verb = endpoint["verb"] + + self.rest_send.path = path + self.rest_send.verb = verb + self.rest_send.payload = self.payload + self.rest_send.commit() + + self.result_current = self.rest_send.result_current + self.result = self.rest_send.result_current + self.response_current = self.rest_send.response_current + self.response = self.rest_send.response_current + + if self.response_current["RETURN_CODE"] == 200: + self.diff = self.payload + + @property + def payload(self): + """ + Return a fabric create payload. + """ + return self.properties["payload"] + + @payload.setter + def payload(self, value): + self.properties["payload"] = value diff --git a/plugins/module_utils/fabric/vxlan/verify_fabric_params.py b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py index 1d7e60c69..24c68da5b 100644 --- a/plugins/module_utils/fabric/vxlan/verify_fabric_params.py +++ b/plugins/module_utils/fabric/vxlan/verify_fabric_params.py @@ -172,7 +172,7 @@ def validate_config(self): def _validate_merged_state_config(self): """ - Caller: self._validate_config_for_merged_state() + Caller: self.validate_config() Update self.config with a verified version of the users playbook parameters. @@ -214,6 +214,7 @@ def _validate_merged_state_config(self): self.result = False return self.config["anycast_gw_mac"] = result + if "vrf_lite_autoconfig" in self.config: result = translate_vrf_lite_autoconfig(self.config["vrf_lite_autoconfig"]) if result is False: @@ -224,6 +225,7 @@ def _validate_merged_state_config(self): return self.config["vrf_lite_autoconfig"] = result + # TODO: Discuss with Shangxin/Mike whether we should even do this # validate self.config for cross-parameter dependencies self._validate_dependencies() if self.result is False: diff --git a/plugins/modules/dcnm_fabric_vxlan.py b/plugins/modules/dcnm_fabric.py similarity index 64% rename from plugins/modules/dcnm_fabric_vxlan.py rename to plugins/modules/dcnm_fabric.py index 8aa97bf46..3d4544a08 100644 --- a/plugins/modules/dcnm_fabric_vxlan.py +++ b/plugins/modules/dcnm_fabric.py @@ -21,11 +21,11 @@ DOCUMENTATION = """ --- -module: dcnm_fabric_vxlan -short_description: Create VXLAN/EVPN Fabrics. +module: dcnm_fabric +short_description: Create Fabrics. version_added: "0.9.0" description: - - "Create VXLAN/EVPN Fabrics." + - "Create Fabrics." author: Allen Robel options: state: @@ -143,6 +143,14 @@ - The name of the fabric type: str required: true + fabric_type: + description: + - The type of fabric + type: str + required: true + default: "VXLAN_EVPN" + choices: + - "VXLAN_EVPN" pm_enable: description: - Enable (True) or disable (False) fabric performance monitoring @@ -239,6 +247,9 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import ( FabricTaskResult ) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import ( + FabricUpdateBulk +) def json_pretty(msg): """ @@ -246,11 +257,7 @@ def json_pretty(msg): """ return json.dumps(msg, indent=4, sort_keys=True) -class FabricVxlanTask(FabricCommon): - """ - Ansible support for Data Center VXLAN EVPN - """ - +class TaskCommon(FabricCommon): def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) @@ -258,7 +265,7 @@ def __init__(self, ansible_module): self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED FabricVxlanTask(): " + msg = "ENTERED TaskCommon(): " msg += f"state: {self.state}, " msg += f"check_mode: {self.check_mode}" self.log.debug(msg) @@ -266,10 +273,9 @@ def __init__(self, ansible_module): self.endpoints = ApiEndpoints() self._implemented_states = set() - self._implemented_states.add("merged") self.params = ansible_module.params - self.verify = VerifyFabricParams() + self._verify_fabric_params = VerifyFabricParams() self.rest_send = RestSend(self.ansible_module) # populated in self.validate_input() self.payloads = {} @@ -278,18 +284,15 @@ def __init__(self, ansible_module): if not isinstance(self.config, list): msg = "expected list type for self.config. " msg = f"got {type(self.config).__name__}" - self.ansible_module.fail_json(msg=msg) + self.ansible_module.fail_json(msg, **self.failed_result) - self.check_mode = False self.validated = [] self.have = {} self.want = [] - self.need = [] self.query = [] self.task_result = FabricTaskResult(self.ansible_module) - def get_have(self): """ Caller: main() @@ -310,16 +313,8 @@ def get_have(self): } """ method_name = inspect.stack()[0][3] - msg = f"{self.class_name}.{method_name}: " - msg += "entered" - self.log.debug(msg) self.have = FabricDetailsByName(self.ansible_module) - msg = "Calling self.have.refresh (FabricDetailsByName.refresh)" - self.log.debug(msg) self.have.refresh() - msg = "Calling self.have.refresh (FabricDetailsByName.refresh) " - msg = f"DONE. self.have: {json.dumps(self.have.all_data, indent=4, sort_keys=True)}" - self.log.debug(msg) def get_want(self) -> None: """ @@ -329,106 +324,96 @@ def get_want(self) -> None: 2. Update self.want with the playbook configs """ method_name = inspect.stack()[0][3] - msg = f"ENTERED" - self.log.debug(msg) - - # Generate the params_spec used to validate the configs - params_spec = ParamsSpec(self.ansible_module) - params_spec.commit() - - # If a parameter is missing from the config, and it has a default - # value, add it to the config. merged_configs = [] - merge_defaults = ParamsMergeDefaults(self.ansible_module) - merge_defaults.params_spec = params_spec.params_spec for config in self.config: - merge_defaults.parameters = config - merge_defaults.commit() - merged_configs.append(merge_defaults.merged_parameters) + merged_configs.append(copy.deepcopy(config)) - # validate the merged configs self.want = [] - validator = ParamsValidate(self.ansible_module) - validator.params_spec = params_spec.params_spec for config in merged_configs: - msg = f"{self.class_name}.{method_name}: " - msg += f"config: {json.dumps(config, indent=4, sort_keys=True)}" - self.log.debug(msg) - validator.parameters = config - validator.commit() - self.want.append(copy.deepcopy(validator.parameters)) + self.want.append(copy.deepcopy(config)) # Exit if there's nothing to do if len(self.want) == 0: self.ansible_module.exit_json(**self.task_result.module_result) - def get_need_for_merged_state(self): + def update_diff_and_response(self, obj) -> None: """ - Caller: handle_merged_state() - - Build self.need for state merged + Update the appropriate self.task_result diff and response, + based on the current ansible state, with the diff and + response from obj. """ - method_name = inspect.stack()[0][3] - msg = f"{self.class_name}.{method_name}: " - msg += "self.have.all_data: " - msg += f"{json.dumps(self.have.all_data, indent=4, sort_keys=True)}" - self.log.debug(msg) - need: List[Dict[str, Any]] = [] - state = self.params["state"] - self.payloads = {} - for want in self.want: - self.verify.state = state - self.verify.config = want - self.verify.validate_config() - if self.verify.result is False: - self.ansible_module.fail_json(msg=self.verify.msg) - need.append(self.verify.payload) - self.need = copy.deepcopy(need) - - msg = f"{self.class_name}.validate_input(): " - msg += f"self.need: {json.dumps(self.need, indent=4, sort_keys=True)}" - self.log.debug(msg) + for diff in obj.diff: + if self.state == "deleted": + self.task_result.diff_deleted = copy.deepcopy(diff) + if self.state == "merged": + self.task_result.diff_merged = copy.deepcopy(diff) + if self.state == "query": + self.task_result.diff_query = copy.deepcopy(diff) - def handle_merged_state(self): - """ - Caller: main() + for response in obj.response: + if self.state == "deleted": + self.task_result.response_deleted = copy.deepcopy(response) + if self.state == "merged": + self.task_result.response_merged = copy.deepcopy(response) + if self.state == "query": + self.task_result.response_query = copy.deepcopy(response) - Handle the merged state - """ + +class QueryTask(TaskCommon): + """ + Query state for FabricVxlanTask + """ + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + super().__init__(ansible_module) method_name = inspect.stack()[0][3] - self.log.debug(f"{self.class_name}.{method_name}: entered") - self.get_need_for_merged_state() - if self.ansible_module.check_mode: - self.task_result["changed"] = False - self.task_result["success"] = True - self.task_result["diff"] = [] - self.ansible_module.exit_json(**self.task_result.module_result) - self.send_need() + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + msg = "ENTERED QueryTask(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self._implemented_states.add("query") - def handle_query_state(self) -> None: + def commit(self) -> None: """ 1. query the fabrics in self.want that exist on the controller """ + self.get_want() method_name = inspect.stack()[0][3] - msg = f"{self.class_name}.{method_name}: " - msg += "entered" - self.log.debug(msg) instance = FabricQuery(self.ansible_module) fabric_names_to_query = [] for want in self.want: fabric_names_to_query.append(want["fabric_name"]) - instance.fabric_names = fabric_names_to_query - msg = f"{self.class_name}.{method_name}: " - msg += "Calling FabricQuery.commit" - self.log.debug(msg) + instance.fabric_names = copy.copy(fabric_names_to_query) instance.commit() self.update_diff_and_response(instance) - def handle_deleted_state(self) -> None: +class DeletedTask(TaskCommon): + """ + deleted state for FabricVxlanTask + """ + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + super().__init__(ansible_module) + method_name = inspect.stack()[0][3] + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + msg = "ENTERED DeletedTask(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self._implemented_states.add("deleted") + + def commit(self) -> None: """ delete the fabrics in self.want that exist on the controller """ + self.get_want() method_name = inspect.stack()[0][3] msg = f"{self.class_name}.{method_name}: " msg += "entered" @@ -438,164 +423,104 @@ def handle_deleted_state(self) -> None: for want in self.want: fabric_names_to_delete.append(want["fabric_name"]) instance.fabric_names = fabric_names_to_delete - msg = f"{self.class_name}.{method_name}: " - msg += "Calling FabricDelete.commit" - self.log.debug(msg) instance.commit() self.update_diff_and_response(instance) - def send_need(self): - """ - Caller: handle_merged_state() - """ - if self.check_mode is True: - self.send_need_check_mode() - else: - self.send_need_normal_mode() - def send_need_check_mode(self): - """ - Caller: send_need() +class MergedTask(TaskCommon): + """ + Ansible support for Data Center VXLAN EVPN + """ - Simulate sending the payload to the controller - to create the fabrics specified in the playbook. - """ + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + super().__init__(ansible_module) method_name = inspect.stack()[0][3] - msg = f"{self.class_name}.{method_name}: " - msg += f"need: {json.dumps(self.need, indent=4, sort_keys=True)}" - self.log.debug(msg) - if len(self.need) == 0: - self.ansible_module.exit_json(**self.task_result.module_result) - for item in self.need: - fabric_name = item.get("FABRIC_NAME") - if fabric_name is None: - msg = f"{self.class_name}.{method_name}: " - msg += "fabric_name is required." - self.ansible_module.fail_json(msg) - self.endpoints.fabric_name = fabric_name - self.endpoints.template_name = "Easy_Fabric" - - try: - endpoint = self.endpoints.fabric_create - except ValueError as error: - self.ansible_module.fail_json(error) - - path = endpoint["path"] - verb = endpoint["verb"] - payload = item - msg = f"{self.class_name}.{method_name}: " - msg += f"verb: {verb}, path: {path}" - self.log.debug(msg) - - msg = f"{self.class_name}.{method_name}: " - msg += f"fabric_name: {fabric_name}, " - msg += f"payload: {json.dumps(payload, indent=4, sort_keys=True)}" - self.log.debug(msg) - - self.rest_send.path = path - self.rest_send.verb = verb - self.rest_send.payload = payload - self.rest_send.commit() - - self.result_current = self.rest_send.result_current - self.result = self.rest_send.result_current - self.response_current = self.rest_send.response_current - self.response = self.rest_send.response_current - - self.task_result.response_merged = self.response_current - if self.response_current["RETURN_CODE"] == 200: - self.task_result.diff_merged = payload - msg = "self.response_current: " - msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}" - self.log.debug(msg) + self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "self.response: " - msg += f"{json.dumps(self.response, indent=4, sort_keys=True)}" - self.log.debug(msg) + msg = "ENTERED MergedTask(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) - msg = "self.result_current: " - msg += f"{json.dumps(self.result_current, indent=4, sort_keys=True)}" - self.log.debug(msg) + self.need_create = [] + self.need_update = [] - msg = "self.result: " - msg += f"{json.dumps(self.result, indent=4, sort_keys=True)}" - self.log.debug(msg) + self._implemented_states.add("merged") - def send_need_normal_mode(self): + def get_need(self): """ - Caller: send_need() + Caller: commit() - Build and send the payload to create the - fabrics specified in the playbook. + Build self.need for merged state """ method_name = inspect.stack()[0][3] - self.fabric_create = FabricCreateBulk(self.ansible_module) - self.fabric_create.payloads = self.need - self.fabric_create.commit() - self.update_diff_and_response(self.fabric_create) + self.payloads = {} + for want in self.want: + if want["FABRIC_NAME"] not in self.have.all_data: + self.need_create.append(want) + else: + self.need_update.append(want) - def update_diff_and_response(self, obj) -> None: + def commit(self): """ - Update the appropriate self.task_result diff and response, - based on the current ansible state, with the diff and - response from obj. + Caller: main() + + Commit the merged state request """ - for diff in obj.diff: - msg = f"diff: {json_pretty(diff)}" - self.log.debug(msg) - if self.state == "deleted": - self.task_result.diff_deleted = diff - if self.state == "merged": - self.task_result.diff_merged = diff - if self.state == "query": - self.task_result.diff_query = diff + method_name = inspect.stack()[0][3] + self.log.debug(f"{self.class_name}.{method_name}: entered") - msg = f"PRE_FOR: state {self.state} response: {json_pretty(obj.response)}" - self.log.debug(msg) - for response in obj.response: - if "DATA" in response: - response.pop("DATA") - msg = f"state {self.state} response: {json_pretty(response)}" - self.log.debug(msg) - if self.state == "deleted": - self.task_result.response_deleted = copy.deepcopy(response) - if self.state == "merged": - self.task_result.response_merged = copy.deepcopy(response) - if self.state == "query": - self.task_result.response_query = copy.deepcopy(response) + self.get_want() + self.get_have() + self.get_need() + self.send_need_create() + self.send_need_update() - def _failure(self, resp): + def send_need_create(self) -> None: """ - Caller: self.create_fabrics() + Caller: commit() - This came from dcnm_inventory.py, but doesn't seem to be correct - for the case where resp["DATA"] does not exist? + Build and send the payload to create fabrics specified in the playbook. + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: entered. " + msg += f"self.need_create: {json_pretty(self.need_create)}" + self.log.debug(msg) + + if len(self.need_create) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "No fabrics to create." + self.log.debug(msg) + return - If resp["DATA"] does not exist, the contents of the - if block don't seem to actually do anything: - - data will be None - - Hence, data.get("stackTrace") will also be None - - Hence, data.update() and res.update() are never executed + self.fabric_create = FabricCreateBulk(self.ansible_module) + self.fabric_create.payloads = self.need_create + self.fabric_create.commit() + self.update_diff_and_response(self.fabric_create) - So, the only two lines that will actually ever be executed are - the happy path: + def send_need_update(self) -> None: + """ + Caller: commit() - res = copy.deepcopy(resp) - self.ansible_module.fail_json(msg=res) + Build and send the payload to create fabrics specified in the playbook. """ - res = copy.deepcopy(resp) + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: entered. " + msg += f"self.need_update: {json_pretty(self.need_update)}" + self.log.debug(msg) - if not resp.get("DATA"): - data = copy.deepcopy(resp.get("DATA")) - if data.get("stackTrace"): - data.update( - {"stackTrace": "Stack trace is hidden, use '-vvvvv' to print it"} - ) - res.update({"DATA": data}) + if len(self.need_update) == 0: + msg = f"{self.class_name}.{method_name}: " + msg += "No fabrics to update." + self.log.debug(msg) + return - self.log.debug("HERE") - self.ansible_module.fail_json(msg=res) + self.fabric_update = FabricUpdateBulk(self.ansible_module) + self.fabric_update.payloads = self.need_update + self.fabric_update.commit() + self.update_diff_and_response(self.fabric_update) def main(): """main entry point for module execution""" @@ -626,26 +551,21 @@ def main(): log.config = config_file log.commit() - task = FabricVxlanTask(ansible_module) - task.log.debug(f"state: {ansible_module.params['state']}") - if ansible_module.params["state"] == "merged": - task.get_want() - task.get_have() - task.handle_merged_state() + task = MergedTask(ansible_module) + task.commit() elif ansible_module.params["state"] == "deleted": - task.get_want() - task.handle_deleted_state() + task = DeletedTask(ansible_module) + task.commit() elif ansible_module.params["state"] == "query": - task.get_want() - task.handle_query_state() + task = QueryTask(ansible_module) + task.commit() else: + # We should never get here since the state parameter has + # already been validated. msg = f"Unknown state {task.ansible_module.params['state']}" task.ansible_module.fail_json(msg) - msg = "task_result.module_result: " - msg += f"{json.dumps(task.task_result.module_result, indent=4, sort_keys=True)}" - task.log.debug(msg) ansible_module.exit_json(**task.task_result.module_result) From 24b2367181e0f35c9df217faa64434993fdd639d Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 13 Mar 2024 16:36:02 -1000 Subject: [PATCH 007/228] Comment out unused imports for now --- plugins/modules/dcnm_fabric.py | 59 +++++++++++++++++----------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/plugins/modules/dcnm_fabric.py b/plugins/modules/dcnm_fabric.py index 3d4544a08..8647deeac 100644 --- a/plugins/modules/dcnm_fabric.py +++ b/plugins/modules/dcnm_fabric.py @@ -216,40 +216,36 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log -from ansible_collections.cisco.dcnm.plugins.module_utils.common.merge_dicts import \ - MergeDicts -from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_merge_defaults import \ - ParamsMergeDefaults -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.params_spec import \ - ParamsSpec -from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_validate import \ - ParamsValidate +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ + RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import \ + FabricCreateBulk +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import \ + FabricDelete +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ + ApiEndpoints from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import \ + FabricTaskResult from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import \ FabricQuery -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ - RestSend -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import ( - ApiEndpoints -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import ( - VerifyFabricParams, -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import ( - FabricCreateBulk -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import ( - FabricDelete -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import ( - FabricTaskResult -) -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import ( +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import \ FabricUpdateBulk -) +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import \ + VerifyFabricParams + +# from ansible_collections.cisco.dcnm.plugins.module_utils.common.merge_dicts import \ +# MergeDicts +# from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_merge_defaults import \ +# ParamsMergeDefaults +# from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.params_spec import \ +# ParamsSpec +# from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_validate import \ +# ParamsValidate + def json_pretty(msg): """ @@ -257,6 +253,7 @@ def json_pretty(msg): """ return json.dumps(msg, indent=4, sort_keys=True) + class TaskCommon(FabricCommon): def __init__(self, ansible_module): self.class_name = self.__class__.__name__ @@ -362,7 +359,8 @@ def update_diff_and_response(self, obj) -> None: class QueryTask(TaskCommon): """ Query state for FabricVxlanTask - """ + """ + def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) @@ -391,10 +389,12 @@ def commit(self) -> None: instance.commit() self.update_diff_and_response(instance) + class DeletedTask(TaskCommon): """ deleted state for FabricVxlanTask - """ + """ + def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) @@ -522,6 +522,7 @@ def send_need_update(self) -> None: self.fabric_update.commit() self.update_diff_and_response(self.fabric_update) + def main(): """main entry point for module execution""" From d2178581257bb6ca44916cc4fc372653925afb60 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 14 Mar 2024 11:21:33 -1000 Subject: [PATCH 008/228] Add files from module_utils/common --- plugins/module_utils/common | 1 - plugins/module_utils/common/__init__.py | 0 .../module_utils/common/controller_version.py | 283 +++++++++ plugins/module_utils/common/log.py | 118 ++++ .../module_utils/common/logging_config.json | 36 ++ plugins/module_utils/common/merge_dicts.py | 145 +++++ .../common/params_merge_defaults.py | 173 +++++ .../module_utils/common/params_validate.py | 599 ++++++++++++++++++ plugins/module_utils/common/rest_send.py | 500 +++++++++++++++ .../module_utils/common/rest_send_fabric.py | 500 +++++++++++++++ 10 files changed, 2354 insertions(+), 1 deletion(-) delete mode 120000 plugins/module_utils/common create mode 100644 plugins/module_utils/common/__init__.py create mode 100644 plugins/module_utils/common/controller_version.py create mode 100644 plugins/module_utils/common/log.py create mode 100644 plugins/module_utils/common/logging_config.json create mode 100644 plugins/module_utils/common/merge_dicts.py create mode 100644 plugins/module_utils/common/params_merge_defaults.py create mode 100644 plugins/module_utils/common/params_validate.py create mode 100644 plugins/module_utils/common/rest_send.py create mode 100644 plugins/module_utils/common/rest_send_fabric.py diff --git a/plugins/module_utils/common b/plugins/module_utils/common deleted file mode 120000 index 79f5a1911..000000000 --- a/plugins/module_utils/common +++ /dev/null @@ -1 +0,0 @@ -/Users/arobel/repos/ansible_dev/dcnm_image_upgrade/ansible_collections/cisco/dcnm/plugins/module_utils/common \ No newline at end of file diff --git a/plugins/module_utils/common/__init__.py b/plugins/module_utils/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/common/controller_version.py b/plugins/module_utils/common/controller_version.py new file mode 100644 index 000000000..3a79bc985 --- /dev/null +++ b/plugins/module_utils/common/controller_version.py @@ -0,0 +1,283 @@ +""" +Class to retrieve and return information about an NDFC controller +""" +# +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import logging + +from ansible_collections.cisco.dcnm.plugins.module_utils.image_upgrade.api_endpoints import \ + ApiEndpoints +from ansible_collections.cisco.dcnm.plugins.module_utils.image_upgrade.image_upgrade_common import \ + ImageUpgradeCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import \ + dcnm_send + + +class ControllerVersion(ImageUpgradeCommon): + """ + Return image version information from the Controller + + NOTES: + 1. considered using dcnm_version_supported() but it does not return + minor release info, which is needed due to key changes between + 12.1.2e and 12.1.3b. For example, see ImageStage().commit() + + Endpoint: + /appcenter/cisco/ndfc/api/v1/fm/about/version + + Usage (where module is an instance of AnsibleModule): + + instance = ControllerVersion(module) + instance.refresh() + if instance.version == "12.1.2e": + do 12.1.2e stuff + else: + do other stuff + + Response: + { + "version": "12.1.2e", + "mode": "LAN", + "isMediaController": false, + "dev": false, + "isHaEnabled": false, + "install": "EASYFABRIC", + "uuid": "f49e6088-ad4f-4406-bef6-2419de914ff1", + "is_upgrade_inprogress": false + } + """ + + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED ControllerVersion()") + + self.endpoints = ApiEndpoints() + self._init_properties() + + def _init_properties(self): + self.properties = {} + self.properties["data"] = None + self.properties["result"] = None + self.properties["response"] = None + + def refresh(self): + """ + Refresh self.response_data with current version info from the Controller + """ + path = self.endpoints.controller_version.get("path") + verb = self.endpoints.controller_version.get("verb") + self.properties["response"] = dcnm_send(self.ansible_module, verb, path) + self.properties["result"] = self._handle_response(self.response, verb) + + if self.result["success"] is False or self.result["found"] is False: + msg = f"{self.class_name}.refresh() failed: {self.result}" + self.ansible_module.fail_json(msg) + + self.properties["response_data"] = self.response.get("DATA") + if self.response_data is None: + msg = f"{self.class_name}.refresh() failed: response " + msg += "does not contain DATA key. Controller response: " + msg += f"{self.response}" + self.ansible_module.fail_json(msg) + + def _get(self, item): + return self.make_boolean(self.make_none(self.response_data.get(item))) + + @property + def dev(self): + """ + Return True if the Controller is running a development release. + Return False if the Controller is not running a development release. + Return None otherwise + + Possible values: + True + False + None + """ + return self._get("dev") + + @property + def install(self): + """ + Return the value of install, if it exists. + Return None otherwise + + Possible values: + EASYFABRIC + (probably other values) + None + """ + return self._get("install") + + @property + def is_ha_enabled(self): + """ + Return True if Controller is high-availability enabled. + Return False if Controller is not high-availability enabled. + Return None otherwise + + Possible values: + True + False + None + """ + return self.make_boolean(self._get("isHaEnabled")) + + @property + def is_media_controller(self): + """ + Return True if Controller is a media controller. + Return False if Controller is not a media controller. + Return None otherwise + + Possible values: + True + False + None + """ + return self.make_boolean(self._get("isMediaController")) + + @property + def is_upgrade_inprogress(self): + """ + Return True if a Controller upgrade is in progress. + Return False if a Controller upgrade is not in progress. + Return None otherwise + + Possible values: + True + False + None + """ + return self.make_boolean(self._get("is_upgrade_inprogress")) + + @property + def response_data(self): + """ + Return the data retrieved from the request + """ + return self.properties.get("response_data") + + @property + def result(self): + """ + Return the GET result from the Controller + """ + return self.properties.get("result") + + @property + def response(self): + """ + Return the GET response from the Controller + """ + return self.properties.get("response") + + @property + def mode(self): + """ + Return the controller mode, if it exists. + Return None otherwise + + Possible values: + LAN + None + """ + return self._get("mode") + + @property + def uuid(self): + """ + Return the value of uuid, if it exists. + Return None otherwise + + Possible values: + uuid e.g. "f49e6088-ad4f-4406-bef6-2419de914df1" + None + """ + return self._get("uuid") + + @property + def version(self): + """ + Return the controller version, if it exists. + Return None otherwise + + Possible values: + version, e.g. "12.1.2e" + None + """ + return self._get("version") + + @property + def version_major(self): + """ + Return the controller major version, if it exists. + Return None otherwise + + We are assuming semantic versioning based on: + https://semver.org + + Possible values: + if version is 12.1.2e, return 12 + None + """ + if self.version is None: + return None + return (self._get("version").split("."))[0] + + @property + def version_minor(self): + """ + Return the controller minor version, if it exists. + Return None otherwise + + We are assuming semantic versioning based on: + https://semver.org + + Possible values: + if version is 12.1.2e, return 1 + None + """ + if self.version is None: + return None + return (self._get("version").split("."))[1] + + @property + def version_patch(self): + """ + Return the controller minor version, if it exists. + Return None otherwise + + We are assuming semantic versioning based on: + https://semver.org + + Possible values: + if version is 12.1.2e, return 2e + None + """ + if self.version is None: + return None + return (self._get("version").split("."))[2] diff --git a/plugins/module_utils/common/log.py b/plugins/module_utils/common/log.py new file mode 100644 index 000000000..33eff8d2f --- /dev/null +++ b/plugins/module_utils/common/log.py @@ -0,0 +1,118 @@ +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import json +import logging +from logging.config import dictConfig + + +class Log: + """ + Create the base dcnm logging object. + + Usage (where ansible_module is an instance of AnsibleModule): + + Below, config.json is a logging config file in JSON format conformant + with Python's logging.config.dictConfig. The file can be located + anywhere on the filesystem. See the following for an example: + + cisco/dcnm/plugins/module_utils/common/logging_config.json + + from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log + log = Log(ansible_module) + log.config = "/path/to/logging/config.json" + log.commit() + + At this point, a base/parent logger is created for which all other + loggers throughout the dcnm collection will be children. + This allows for a single logging config to be used for all dcnm + modules, and allows for the logging config to be specified in a + single place external to the code. + + If log.config is set to None (which is the default if it's not explictely set), + then logging is disabled. + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self._build_properties() + + def _build_properties(self) -> None: + self.properties = {} + self.properties["config"] = None + + def commit(self): + """ + Create the base logger instance from a source conformant with + logging.config.dictConfig. + + 1. If self.config is None, then logging is disabled. + 2. If self.config is a JSON file, then it is read and logging + is configured from the JSON file. + 3. If self.config is a dictionary, then logging is configured + from the dictionary. + """ + if self.config is None: + logger = logging.getLogger() + for handler in logger.handlers.copy(): + try: + logger.removeHandler(handler) + except ValueError: # if handler already removed + pass + logger.addHandler(logging.NullHandler()) + logger.propagate = False + return + + if isinstance(self.config, dict): + try: + dictConfig(self.config) + return + except ValueError as err: + msg = "error configuring logging from dict. " + msg += f"detail: {err}" + self.ansible_module.fail_json(msg=msg) + + try: + with open(self.config, "r", encoding="utf-8") as file: + logging_config = json.load(file) + except IOError as err: + msg = f"error reading logging config from {self.config}. " + msg += f"detail: {err}" + self.ansible_module.fail_json(msg=msg) + dictConfig(logging_config) + + @property + def config(self): + """ + Can be either: + + 1. None, in which case logging is disabled + 2. A JSON file from which logging config is read. + Must conform to logging.config.dictConfig + 3. A dictionary containing logging config + Must conform to logging.config.dictConfig + """ + return self.properties["config"] + + @config.setter + def config(self, value): + self.properties["config"] = value diff --git a/plugins/module_utils/common/logging_config.json b/plugins/module_utils/common/logging_config.json new file mode 100644 index 000000000..5bb98868b --- /dev/null +++ b/plugins/module_utils/common/logging_config.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "formatters": { + "standard": { + "class": "logging.Formatter", + "format": "%(asctime)s - %(levelname)s - [%(name)s.%(funcName)s.%(lineno)d] %(message)s" + } + }, + "handlers": { + "file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "standard", + "level": "DEBUG", + "filename": "/tmp/dcnm.log", + "mode": "a", + "encoding": "utf-8", + "maxBytes": 50000000, + "backupCount": 4 + } + }, + "loggers": { + "dcnm": { + "handlers": [ + "file" + ], + "level": "DEBUG", + "propagate": false + } + }, + "root": { + "level": "INFO", + "handlers": [ + "file" + ] + } +} \ No newline at end of file diff --git a/plugins/module_utils/common/merge_dicts.py b/plugins/module_utils/common/merge_dicts.py new file mode 100644 index 000000000..561a71afd --- /dev/null +++ b/plugins/module_utils/common/merge_dicts.py @@ -0,0 +1,145 @@ +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect +import logging +from collections.abc import MutableMapping as Map +from typing import Any, Dict + + +class MergeDicts: + """ + Merge two dictionaries. + + Given two dictionaries, dict1 and dict2, merge them into a + single dictionary, dict_merged, where keys in dict2 have + precedence over (will overwrite) keys in dict1. + + Example: + + module = AnsibleModule(...) + instance = MergeDicts(module) + instance.dict1 = { "foo": 1, "bar": 2 } + instance.dict2 = { "foo": 3, "baz": 4 } + instance.commit() + dict_merged = instance.dict_merged + print(dict_merged) + + Output: + { foo: 3, bar: 2, baz: 4 } + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED MergeDicts()") + + self._build_properties() + + def _build_properties(self) -> None: + self.properties = {} + self.properties["dict1"] = None + self.properties["dict2"] = None + self.properties["dict_merged"] = None + + def commit(self) -> None: + """ + Commit the merged dict. + """ + method_name = inspect.stack()[0][3] + if self.dict1 is None or self.dict2 is None: + msg = f"{self.class_name}.{method_name}: " + msg += "dict1 and dict2 must be set before calling commit()" + self.ansible_module.fail_json(msg) + + self.properties["dict_merged"] = self.merge_dicts(self.dict1, self.dict2) + + def merge_dicts( + self, dict1: Dict[Any, Any], dict2: Dict[Any, Any] + ) -> Dict[Any, Any]: + """ + Merge dict2 into dict1 and return dict1. + Keys in dict2 have precedence over keys in dict1. + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + for key in dict2: + if ( + key in dict1 + and isinstance(dict1[key], Map) + and isinstance(dict2[key], Map) + ): + self.merge_dicts(dict1[key], dict2[key]) + else: + dict1[key] = dict2[key] + return copy.deepcopy(dict1) + + @property + def dict_merged(self): + """ + Getter for the merged dictionary. + """ + method_name = inspect.stack()[0][3] + if self.properties["dict_merged"] is None: + msg = f"{self.class_name}.{method_name}: " + msg += "Call instance.commit() before calling " + msg += f"instance.{method_name}." + self.ansible_module.fail_json(msg) + return self.properties["dict_merged"] + + @property + def dict1(self): + """ + The dictionary into which dict2 will be merged. + + dict1's keys will be overwritten by dict2's keys. + """ + return self.properties["dict1"] + + @dict1.setter + def dict1(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "Invalid value. Expected type dict. " + msg += f"Got type {type(value)}." + self.ansible_module.fail_json(msg) + self.properties["dict1"] = copy.deepcopy(value) + + @property + def dict2(self): + """ + The dictionary which will be merged into dict1. + + dict2's keys will overwrite by dict1's keys. + """ + return self.properties["dict2"] + + @dict2.setter + def dict2(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "Invalid value. Expected type dict. " + msg += f"Got type {type(value)}." + self.ansible_module.fail_json(msg) + self.properties["dict2"] = copy.deepcopy(value) diff --git a/plugins/module_utils/common/params_merge_defaults.py b/plugins/module_utils/common/params_merge_defaults.py new file mode 100644 index 000000000..cd28bc6f1 --- /dev/null +++ b/plugins/module_utils/common/params_merge_defaults.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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import copy +import inspect +import logging +from collections.abc import MutableMapping as Map +from typing import Any, Dict + + +class ParamsMergeDefaults: + """ + Merge default parameters into parameters. + + Given a parameter specification (params_spec) and a playbook config + (parameters) merge key/values from params_spec which have a default + associated with them into parameters (if parameters is missing the + corresponding key/value). + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED ParamsMergeDefaults()") + + self._build_properties() + self._build_reserved_params() + self.committed = False + + def _build_properties(self): + """ + Container for the properties of this class. + """ + self.properties = {} + self.properties["params_spec"] = None + self.properties["parameters"] = None + self.properties["merged_parameters"] = None + + def _build_reserved_params(self): + """ + These are reserved parameter names that are skipped + during merge. + """ + self.reserved_params = set() + self.reserved_params.add("choices") + self.reserved_params.add("default") + self.reserved_params.add("length_max") + self.reserved_params.add("no_log") + self.reserved_params.add("range_max") + self.reserved_params.add("range_min") + self.reserved_params.add("required") + self.reserved_params.add("type") + self.reserved_params.add("preferred_type") + + def _merge_default_params( + self, spec: Dict[str, Any], params: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Merge default parameters into parameters. + + Caller: + - commit() + Return: + - A modified copy of params where missing parameters are added if: + 1. they are present in spec + 2. they have a default value defined in spec + """ + for spec_key, spec_value in spec.items(): + if spec_key in self.reserved_params: + continue + + if params.get(spec_key, None) is None and "default" not in spec_value: + continue + + if params.get(spec_key, None) is None and "default" in spec_value: + params[spec_key] = spec_value["default"] + + if isinstance(spec_value, Map): + params[spec_key] = self._merge_default_params( + spec_value, params[spec_key] + ) + + return copy.deepcopy(params) + + def commit(self) -> None: + """ + Merge default parameters into parameters. + + The merged parameters are stored in self.merged_parameters + """ + method_name = inspect.stack()[0][3] + + if self.params_spec is None: + msg = f"{self.class_name}.{method_name}: " + msg += "Cannot commit. params_spec is None." + self.ansible_module.fail_json(msg) + + if self.parameters is None: + msg = f"{self.class_name}.{method_name}: " + msg += "Cannot commit. parameters is None." + self.ansible_module.fail_json(msg) + + self.properties["merged_parameters"] = self._merge_default_params( + self.params_spec, self.parameters + ) + + @property + def merged_parameters(self): + """ + Getter for the merged parameters. + """ + if self.properties["merged_parameters"] is None: + msg = f"{self.class_name}.merged_parameters: " + msg += "Call instance.commit() before calling merged_parameters." + self.ansible_module.fail_json(msg) + return self.properties["merged_parameters"] + + @property + def parameters(self): + """ + The parameters into which defaults are merged. + + The merge consists of adding any missing parameters + (per a comparison with params_spec) and setting their + value to the default value defined in params_spec. + """ + return self.properties["parameters"] + + @parameters.setter + def parameters(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "Invalid parameters. Expected type dict. " + msg += f"Got type {type(value)}." + self.ansible_module.fail_json(msg) + self.properties["parameters"] = value + + @property + def params_spec(self): + """ + The param specification used to validate the parameters + """ + return self.properties["params_spec"] + + @params_spec.setter + def params_spec(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "Invalid params_spec. Expected type dict. " + msg += f"Got type {type(value)}." + self.ansible_module.fail_json(msg) + self.properties["params_spec"] = value diff --git a/plugins/module_utils/common/params_validate.py b/plugins/module_utils/common/params_validate.py new file mode 100644 index 000000000..ec2548629 --- /dev/null +++ b/plugins/module_utils/common/params_validate.py @@ -0,0 +1,599 @@ +# 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 +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import inspect +import ipaddress +import logging +from collections.abc import MutableMapping as Map +from typing import Any, List + +from ansible.module_utils.common import validation + + +class ParamsValidate: + """ + Validate playbook parameters. + + This expects the following: + 1. parameters: fully-merged dictionary of parameters + 2. params_spec: Dictionary that describes each parameter + in parameters + + Usage (where ansible_module is an instance of AnsibleModule): + + Assume the following params_spec describing parameters + ip_address and foo. + ip_address is a required parameter of type ipv4. + foo is an optional parameter of type dict. + foo contains a parameter named bar that is an optional + parameter of type str with a default value of bingo. + bar can be assigned one of three values: bingo, bango, or bongo. + + params_spec: Dict[str, Any] = {} + params_spec["ip_address"] = {} + params_spec["ip_address"]["required"] = False + params_spec["ip_address"]["type"] = "ipv4" + params_spec["foo"] = {} + params_spec["foo"]["required"] = False + params_spec["foo"]["type"] = "dict" + params_spec["foo"]["bar"] = {} + params_spec["foo"]["bar"]["required"] = False + params_spec["foo"]["bar"]["type"] = "str" + params_spec["foo"]["bar"]["choices"] = ["bingo", "bango", "bongo"] + params_spec["foo"]["baz"] = {} + params_spec["foo"]["baz"]["required"] = False + params_spec["foo"]["baz"]["type"] = int + params_spec["foo"]["baz"]["range_min"] = 0 + params_spec["foo"]["baz"]["range_max"] = 10 + + Which describes the following YAML: + + ip_address: 1.2.3.4 + foo: + bar: bingo + baz: 10 + + validator = ParamsValidate(ansible_module) + validator.parameters = ansible_module.params + validator.params_spec = params_spec + validator.commit() + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + self.validation = validation + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.log.debug("ENTERED ParamsValidate()") + + self._build_properties() + self._build_reserved_params() + self._build_mandatory_param_spec_keys() + self._build_standard_types() + self._build_ipaddress_types() + self._build_valid_expected_types() + self._build_validations() + + def _build_properties(self): + """ + Set default values for the properties in this class + """ + self.properties = {} + self.properties["parameters"] = None + self.properties["params_spec"] = None + + def _build_reserved_params(self): + """ + These are reserved parameter names that are skipped + during validation. + """ + self.reserved_params = set() + self.reserved_params.add("choices") + self.reserved_params.add("default") + self.reserved_params.add("length_max") + self.reserved_params.add("no_log") + self.reserved_params.add("range_max") + self.reserved_params.add("range_min") + self.reserved_params.add("required") + self.reserved_params.add("type") + self.reserved_params.add("preferred_type") + + def _build_standard_types(self): + """ + Standard python types. These are used with + isinstance() since isinstance() requires the + actual type and not the string representation. + """ + self._standard_types = {} + self._standard_types["bool"] = bool + self._standard_types["dict"] = dict + self._standard_types["float"] = float + self._standard_types["int"] = int + self._standard_types["list"] = list + self._standard_types["set"] = set + self._standard_types["str"] = str + self._standard_types["tuple"] = tuple + + def _build_ipaddress_types(self): + """ + IP address types require special handling since + they cannot be verified using isinstance(). + """ + self._ipaddress_types = set() + self._ipaddress_types.add("ipv4") + self._ipaddress_types.add("ipv6") + self._ipaddress_types.add("ipv4_subnet") + self._ipaddress_types.add("ipv6_subnet") + + def _build_mandatory_param_spec_keys(self): + """ + Mandatory keys for every parameter in params_spec. + """ + self.mandatory_param_spec_keys = set() + self.mandatory_param_spec_keys.add("required") + self.mandatory_param_spec_keys.add("type") + + def _build_valid_expected_types(self): + """ + Valid values for the 'type' key in params_spec. + """ + self.valid_expected_types = set(self._standard_types.keys()).union( + self._ipaddress_types + ) + + def _build_validations(self): + """ + Map of validation functions keyed by the parameter + type they validate. + """ + self.validations = {} + self.validations["bool"] = validation.check_type_bool + self.validations["dict"] = validation.check_type_dict + self.validations["float"] = validation.check_type_float + self.validations["int"] = validation.check_type_int + self.validations["list"] = validation.check_type_list + self.validations["set"] = self._validate_set + self.validations["str"] = validation.check_type_str + self.validations["tuple"] = self._validate_tuple + self.validations["ipv4"] = self._validate_ipv4_address + self.validations["ipv6"] = self._validate_ipv6_address + self.validations["ipv4_subnet"] = self._validate_ipv4_subnet + self.validations["ipv6_subnet"] = self._validate_ipv6_subnet + + def commit(self) -> None: + """ + Verify that parameters in self.parameters conform to self.params_spec + """ + method_name = inspect.stack()[0][3] + if self.parameters is None: + msg = f"{self.class_name}.{method_name}: " + msg += "instance.parameters needs to be set " + msg += "prior to calling instance.commit()." + self.ansible_module.fail_json(msg) + + if self.params_spec is None: + msg = f"{self.class_name}.{method_name}: " + msg += "instance.params_spec needs to be set " + msg += "prior to calling instance.commit()." + self.ansible_module.fail_json(msg) + + self._validate_parameters(self.params_spec, self.parameters) + + def _validate_parameters(self, spec, parameters): + """ + Recursively traverse parameters and verify conformity with spec + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + + for param in spec: + if param in self.reserved_params: + continue + + if isinstance(spec[param], Map): + self._validate_parameters(spec[param], parameters.get(param, {})) + + # We shouldn't hit this since defaults are merged for all + # missing parameters, but just in case... + if ( + parameters.get(param, None) is None + and spec[param].get("required", False) is True + ): + msg = f"{self.class_name}.{method_name}: " + msg += f"Playbook is missing mandatory parameter: {param}." + self.ansible_module.fail_json(msg) + + if isinstance(spec[param]["type"], list): + parameters[param] = self._verify_multitype( + spec[param], parameters, param + ) + else: + parameters[param] = self._verify_type( + spec[param]["type"], parameters, param + ) + + self._verify_choices( + spec[param].get("choices", None), parameters[param], param + ) + + if spec[param].get("type", None) != "int" and ( + spec[param].get("range_min", None) is not None + or spec[param].get("range_max", None) is not None + ): + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid param_spec for parameter '{param}'. " + msg += "range_min and range_max are only valid for " + msg += "parameters of type int. " + msg += f"Got type {spec[param]['type']} for param {param}." + self.ansible_module.fail_json(msg) + + if ( + spec[param].get("type", None) == "int" + and spec[param].get("range_min", None) is not None + and spec[param].get("range_max", None) is not None + ): + self._verify_integer_range( + spec[param].get("range_min", None), + spec[param].get("range_max", None), + parameters[param], + param, + ) + + def _verify_choices(self, choices: List[Any], value: Any, param: str) -> None: + """ + Verify that value is one of the choices + """ + method_name = inspect.stack()[0][3] + if choices is None: + return + + if value not in choices: + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid value for parameter '{param}'. " + msg += f"Expected one of {choices}. " + msg += f"Got {value}" + self.ansible_module.fail_json(msg) + + def _verify_integer_range( + self, range_min: int, range_max: int, value: int, param: str + ) -> None: + """ + Verify that value is within the range range_min to range_max + """ + method_name = inspect.stack()[0][3] + + for range_value in [range_min, range_max]: + if not isinstance(range_value, int): + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid specification for parameter '{param}'. " + msg += "range_min and range_max must be integers. Got " + msg += f"range_min '{range_min}' type {type(range_min)}, " + msg += f"range_max '{range_max}' type {type(range_max)}." + self.ansible_module.fail_json(msg) + + if value < range_min or value > range_max: + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid value for parameter '{param}'. " + msg += f"Expected value between {range_min} and {range_max}. " + msg += f"Got {value}" + self.ansible_module.fail_json(msg) + + def _verify_type(self, expected_type: str, params: Any, param: str) -> Any: + """ + Verify that value's type matches the expected type + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self._verify_expected_type(expected_type, param) + + self.log.debug(f"Validating param {param}, params: {params}") + value = params[param] + if expected_type in self._ipaddress_types: + try: + self._ipaddress_guard(expected_type, value, param) + except TypeError as err: + self._invalid_type(expected_type, value, param, err) + return value + + try: + return self.validations[expected_type](value) + except (ValueError, TypeError) as err: + self._invalid_type(expected_type, value, param, err) + return value + + def _ipaddress_guard(self, expected_type, value: Any, param: str) -> None: + """ + Guard against int and bool types for ipv4, ipv6, ipv4_subnet, + and ipv6_subnet type. + + Raise TypeError if value's type is int or bool and + expected_type is one of self._ipaddress_types. + + 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 + this, we need to fail int and bool values if expected_type is + one of ipv4, ipv6, ipv4_subnet, or ipv6_subnet. + """ + method_name = inspect.stack()[0][3] + if type(value) not in [int, bool]: + return + if expected_type not in self._ipaddress_types: + return + + msg = f"{self.class_name}.{method_name}: " + msg += f"Expected type {expected_type}. " + msg += f"Got type {type(value)} for " + msg += f"param {param} with value {value}." + raise TypeError(f"{msg}") + + def _invalid_type( + self, expected_type: str, value: Any, param: str, error: str = "" + ) -> None: + """ + Calls fail_json when value's type does not match expected_type + """ + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid type for parameter '{param}'. " + msg += f"Expected {expected_type}. " + msg += f"Got '{value}'. " + msg += f"More info: {error}" + self.ansible_module.fail_json(msg) + + def _verify_multitype( # pylint: disable=inconsistent-return-statements + self, spec: Any, params: Any, param: str + ) -> Any: + """ + Verify that value's type matches one of the types in expected_types + + NOTES: + 1. We've disabled inconsistent-return-statements. We're pretty + sure this method is correct. + """ + method_name = inspect.stack()[0][3] + + # preferred_type is mandatory for multitype + self._verify_preferred_type_param_spec_is_present(spec, param) + + # try to convert value to the preferred_type + preferred_type = spec["preferred_type"] + + (result, value) = self._verify_preferred_type_for_standard_types( + preferred_type, params[param] + ) + if result is True: + return value + + (result, value) = self._verify_preferred_type_for_ipaddress_types( + preferred_type, params[param] + ) + if result is True: + return value + + # Couldn't convert value to the preferred_type. Try the other types. + value = params[param] + + expected_types = spec.get("type", []) + + if preferred_type in expected_types: + # We've already tried preferred_type, so remove it + expected_types.remove(preferred_type) + + for expected_type in expected_types: + if expected_type in self._ipaddress_types and type(value) in [int, bool]: + # These are invalid, so skip them + continue + + try: + value = self.validations[expected_type](value) + return value + except (ValueError, TypeError): + pass + + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid type for parameter '{param}'. " + msg += f"Expected one of {expected_types}. " + msg += f"Got '{value}'." + self.ansible_module.fail_json(msg) + + def _verify_preferred_type_param_spec_is_present( + self, spec: Any, param: str + ) -> None: + """ + verify that spec contains the key 'preferred_type' + """ + method_name = inspect.stack()[0][3] + if spec.get("preferred_type", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid param_spec for parameter '{param}'. " + msg += "If type is a list, preferred_type must be specified." + self.ansible_module.fail_json(msg) + + def _verify_preferred_type_for_standard_types( + self, preferred_type: str, value: Any + ) -> tuple: + """ + If preferred_type is one of the standard python types + we use isinstance() to check if we are able to convert + the value to preferred_type + """ + standard_type_success = True + if preferred_type not in self._standard_types: + return (False, value) + try: + value = self.validations[preferred_type](value) + except (ValueError, TypeError): + standard_type_success = False + + if standard_type_success is True: + if isinstance(value, self._standard_types[preferred_type]): + return (True, value) + return (False, value) + + def _verify_preferred_type_for_ipaddress_types( + self, preferred_type: str, value: Any + ) -> tuple: + """ + We can't use isinstance() to verify ipaddress types. + Hence, we check these types separately. + """ + ip_type_success = True + if preferred_type not in self._ipaddress_types: + return (False, value) + try: + value = self.validations[preferred_type](value) + except (ValueError, TypeError): + ip_type_success = False + if ip_type_success is True: + return (True, value) + return (False, value) + + @staticmethod + def _validate_ipv4_address(value: Any) -> Any: + """ + verify that value is an IPv4 address + """ + try: + ipaddress.IPv4Address(value) + return value + except ipaddress.AddressValueError as err: + raise ValueError(f"invalid IPv4 address: {err}") from err + + @staticmethod + def _validate_ipv4_subnet(value: Any) -> Any: + """ + verify that value is an IPv4 network + """ + try: + ipaddress.IPv4Network(value) + return value + except ipaddress.AddressValueError as err: + raise ValueError(f"invalid IPv4 network: {err}") from err + + @staticmethod + def _validate_ipv6_address(value: Any) -> Any: + """ + verify that value is an IPv6 address + """ + try: + ipaddress.IPv6Address(value) + return value + except ipaddress.AddressValueError as err: + raise ValueError(f"invalid IPv6 address: {err}") from err + + @staticmethod + def _validate_ipv6_subnet(value: Any) -> Any: + """ + verify that value is an IPv6 network + """ + try: + ipaddress.IPv6Network(value) + return value + except ipaddress.AddressValueError as err: + raise ValueError(f"invalid IPv6 network: {err}") from err + + @staticmethod + def _validate_set(value: Any) -> Any: + """ + verify that value is a set + """ + if not isinstance(value, set): + raise TypeError(f"expected set, got {type(value)}") + return value + + @staticmethod + def _validate_tuple(value: Any) -> Any: + """ + verify that value is a tuple + """ + if not isinstance(value, tuple): + raise TypeError(f"expected tuple, got {type(value)}") + return value + + def _verify_mandatory_param_spec_keys(self, params_spec: dict) -> None: + """ + Recurse over params_spec dictionary and verify that the + specification for each param contains the mandatory keys + defined in self.mandatory_param_spec_keys + """ + method_name = inspect.stack()[0][3] + for param in params_spec: + if not isinstance(params_spec[param], Map): + continue + if param in self.reserved_params: + continue + self._verify_mandatory_param_spec_keys(params_spec[param]) + for key in self.mandatory_param_spec_keys: + if key in params_spec[param]: + continue + msg = f"{self.class_name}.{method_name}: " + msg += "Invalid params_spec. Missing mandatory key " + msg += f"'{key}' for param '{param}'." + self.ansible_module.fail_json(msg) + + def _verify_expected_type(self, expected_type: str, param: str) -> None: + """ + Verify that expected_type is valid + """ + method_name = inspect.stack()[0][3] + if expected_type in self.valid_expected_types: + return + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid 'type' in params_spec for parameter '{param}'. " + msg += "Expected one of " + msg += f"'{','.join(sorted(self.valid_expected_types))}'. " + msg += f"Got '{expected_type}'." + self.ansible_module.fail_json(msg) + + @property + def parameters(self): + """ + The parameters to validate. + parameters have the same structure as params_spec. + """ + return self.properties["parameters"] + + @parameters.setter + def parameters(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "Invalid parameters. Expected type dict. " + msg += f"Got type {type(value)}." + self.ansible_module.fail_json(msg) + self.properties["parameters"] = value + + @property + def params_spec(self): + """ + The param specification used to validate the parameters + """ + return self.properties["params_spec"] + + @params_spec.setter + def params_spec(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "Invalid params_spec. Expected type dict. " + msg += f"Got type {type(value)}." + self.ansible_module.fail_json(msg) + self._verify_mandatory_param_spec_keys(value) + self.properties["params_spec"] = value diff --git a/plugins/module_utils/common/rest_send.py b/plugins/module_utils/common/rest_send.py new file mode 100644 index 000000000..3087b7c10 --- /dev/null +++ b/plugins/module_utils/common/rest_send.py @@ -0,0 +1,500 @@ +# +# 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 +import re +from time import sleep + +# Using only for its failed_result property +from ansible_collections.cisco.dcnm.plugins.module_utils.image_upgrade.image_upgrade_task_result import \ + ImageUpgradeTaskResult +from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import \ + dcnm_send + + +class RestSend: + """ + Send REST requests to the controller with retries, and handle responses. + + Usage (where ansible_module is an instance of AnsibleModule): + + rest_send = RestSend(ansible_module) + rest_send.path = "/rest/top-down/fabrics" + rest_send.verb = "GET" + rest_send.payload = my_payload # Optional + rest_send.commit() + + # list of responses from the controller for this session + response = rest_send.response + # dict with current controller response + response_current = rest_send.response_current + # list of results from the controller for this session + result = rest_send.result + # dict with current controller result + result_current = rest_send.result_current + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.params = ansible_module.params + + self._valid_verbs = {"GET", "POST", "PUT", "DELETE"} + self.properties = {} + self.properties["check_mode"] = False + self.properties["response"] = [] + self.properties["response_current"] = {} + self.properties["result"] = [] + self.properties["result_current"] = {} + self.properties["send_interval"] = 5 + self.properties["timeout"] = 300 + self.properties["unit_test"] = False + self.properties["verb"] = None + self.properties["path"] = None + self.properties["payload"] = None + + self.check_mode = self.ansible_module.check_mode + self.state = self.params.get("state") + + msg = "ENTERED RestSend(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + def _verify_commit_parameters(self): + if self.verb is None: + msg = f"{self.class_name}._verify_commit_parameters: " + msg += "verb must be set before calling commit()." + self.ansible_module.fail_json(msg, **self.failed_result) + if self.path is None: + msg = f"{self.class_name}._verify_commit_parameters: " + msg += "path must be set before calling commit()." + self.ansible_module.fail_json(msg, **self.failed_result) + + def commit(self): + if self.check_mode is True: + self.commit_check_mode() + else: + self.commit_normal_mode() + + def commit_check_mode(self): + """ + Simulate a dcnm_send() call for check_mode + + Properties read: + self.verb: HTTP verb e.g. GET, POST, PUT, DELETE + self.path: HTTP path e.g. http://controller_ip/path/to/endpoint + self.payload: Optional HTTP payload + + Properties written: + self.properties["response_current"]: raw simulated response + self.properties["result_current"]: result from self._handle_response() method + """ + method_name = inspect.stack()[0][3] + caller = inspect.stack()[1][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += f"verb {self.verb}, path {self.path}." + self.log.debug(msg) + + self._verify_commit_parameters() + + self.response_current = {} + self.response_current["RETURN_CODE"] = 200 + self.response_current["METHOD"] = self.verb + self.response_current["REQUEST_PATH"] = self.path + self.response_current["MESSAGE"] = "OK" + self.response_current["CHECK_MODE"] = True + self.response_current["DATA"] = "[simulated-check-mode-response:Success]" + self.result_current = self._handle_response(copy.deepcopy(self.response_current)) + + self.response = copy.deepcopy(self.response_current) + self.result = copy.deepcopy(self.result_current) + + def commit_normal_mode(self): + """ + Call dcnm_send() with retries until successful response or timeout is exceeded. + + Properties read: + self.send_interval: interval between retries (set in ImageUpgradeCommon) + self.timeout: timeout in seconds (set in ImageUpgradeCommon) + self.verb: HTTP verb e.g. GET, POST, PUT, DELETE + self.path: HTTP path e.g. http://controller_ip/path/to/endpoint + self.payload: Optional HTTP payload + + Properties written: + self.properties["response"]: raw response from the controller + self.properties["result"]: result from self._handle_response() method + """ + method_name = inspect.stack()[0][3] + caller = inspect.stack()[1][3] + + self._verify_commit_parameters() + try: + timeout = self.timeout + except AttributeError: + timeout = 300 + + success = False + msg = f"{caller}: Entering commit loop. " + self.log.debug(msg) + + while timeout > 0 and success is False: + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += f"Calling dcnm_send: verb {self.verb}, path {self.path}" + if self.payload is None: + self.log.debug(msg) + self.response_current = dcnm_send(self.ansible_module, self.verb, self.path) + else: + msg += f", payload: " + msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + self.response_current = dcnm_send( + self.ansible_module, + self.verb, + self.path, + data=json.dumps(self.payload), + ) + self.result_current = self._handle_response(self.response_current) + + success = self.result_current["success"] + if success is False and self.unit_test is False: + sleep(self.send_interval) + timeout -= self.send_interval + + self.response_current = self._strip_invalid_json_from_response_data( + self.response_current + ) + + self.response = copy.deepcopy(self.response_current) + self.result = copy.deepcopy(self.result_current) + + def _strip_invalid_json_from_response_data(self, response): + """ + Strip "Invalid JSON response:" from response["DATA"] if present + + This just clutters up the output and is not useful to the user. + """ + if "DATA" not in response: + return response + if not isinstance(response["DATA"], str): + return response + response["DATA"] = re.sub(r"Invalid JSON response:\s*", "", response["DATA"]) + return response + + def _handle_response(self, response): + """ + Call the appropriate handler for response based on verb + """ + if self.verb == "GET": + return self._handle_get_response(response) + if self.verb in {"POST", "PUT", "DELETE"}: + return self._handle_post_put_delete_response(response) + return self._handle_unknown_request_verbs(response) + + def _handle_unknown_request_verbs(self, response): + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"Unknown request verb ({self.verb}) for response {response}." + self.ansible_module.fail_json(msg, **self.failed_result) + + def _handle_get_response(self, response): + """ + Caller: + - self._handle_response() + Handle controller responses to GET requests + Returns: dict() with the following keys: + - found: + - False, if request error was "Not found" and RETURN_CODE == 404 + - True otherwise + - success: + - False if RETURN_CODE != 200 or MESSAGE != "OK" + - True otherwise + """ + result = {} + success_return_codes = {200, 404} + if ( + response.get("RETURN_CODE") == 404 + and response.get("MESSAGE") == "Not Found" + ): + result["found"] = False + result["success"] = True + return result + if ( + response.get("RETURN_CODE") not in success_return_codes + or response.get("MESSAGE") != "OK" + ): + result["found"] = False + result["success"] = False + return result + result["found"] = True + result["success"] = True + return result + + def _handle_post_put_delete_response(self, response): + """ + Caller: + - self.self._handle_response() + + Handle POST, PUT responses from the controller. + + Returns: dict() with the following keys: + - changed: + - True if changes were made to by the controller + - False otherwise + - success: + - False if RETURN_CODE != 200 or MESSAGE != "OK" + - True otherwise + """ + result = {} + if response.get("ERROR") is not None: + result["success"] = False + result["changed"] = False + return result + if response.get("MESSAGE") != "OK" and response.get("MESSAGE") is not None: + result["success"] = False + result["changed"] = False + return result + result["success"] = True + result["changed"] = True + return result + + @property + def check_mode(self): + """ + Determines if dcnm_send should be called. + + Default: False + + If False, dcnm_send is called. Real controller responses + are returned by RestSend() + + If True, dcnm_send is not called. Simulated controller responses + are returned by RestSend() + + Discussion: + We don't set check_mode from the value of self.ansible_module.check_mode + because we want to be able to read data from the controller even when + self.ansible_module.check_mode is True. For example, SwitchIssuDetails + is a read-only operation, and we want to be able to read this data + to provide a realistic simulation of stage, validate, and upgrade + tasks. + """ + return self.properties.get("check_mode") + + @check_mode.setter + def check_mode(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a bool(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["check_mode"] = value + + @property + def failed_result(self): + """ + Return a result for a failed task with no changes + """ + return ImageUpgradeTaskResult(self.ansible_module).failed_result + + @property + def path(self): + """ + Endpoint path for the REST request. + e.g. "/appcenter/cisco/ndfc/api/v1/...etc..." + """ + return self.properties.get("path") + + @path.setter + def path(self, value): + self.properties["path"] = value + + @property + def payload(self): + """ + Return the payload to send to the controller + """ + return self.properties["payload"] + + @payload.setter + def payload(self, value): + self.properties["payload"] = value + + @property + def response_current(self): + """ + Return the current POST response from the controller + instance.commit() must be called first. + + This is a dict of the current response from the controller. + """ + return copy.deepcopy(self.properties.get("response_current")) + + @response_current.setter + def response_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.response_current must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["response_current"] = value + + @property + def response(self): + """ + Return the aggregated POST response from the controller + instance.commit() must be called first. + + This is a list of responses from the controller. + """ + return copy.deepcopy(self.properties.get("response")) + + @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 += "instance.response must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["response"].append(value) + + @property + def result(self): + """ + Return the aggregated result from the controller + instance.commit() must be called first. + + This is a list of results from the controller. + """ + return copy.deepcopy(self.properties.get("result")) + + @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 += "instance.result must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["result"].append(value) + + @property + def result_current(self): + """ + Return the current result from the controller + instance.commit() must be called first. + + This is a dict containing the current result. + """ + return copy.deepcopy(self.properties.get("result_current")) + + @result_current.setter + def result_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.result_current must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["result_current"] = value + + @property + def send_interval(self): + """ + Send interval, in seconds, for retrying responses from the controller. + Valid values: int() + Default: 5 + """ + return self.properties.get("send_interval") + + @send_interval.setter + def send_interval(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, int): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an int(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["send_interval"] = value + + @property + def timeout(self): + """ + Timeout, in seconds, for retrieving responses from the controller. + Valid values: int() + Default: 300 + """ + return self.properties.get("timeout") + + @timeout.setter + def timeout(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, int): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an int(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["timeout"] = value + + @property + def unit_test(self): + """ + Is the class running under a unit test. + Set this to True in unit tests to speed the test up. + Default: False + """ + return self.properties.get("unit_test") + + @unit_test.setter + def unit_test(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a bool(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["unit_test"] = value + + @property + def verb(self): + """ + Verb for the REST request. + One of "GET", "POST", "PUT", "DELETE" + """ + return self.properties.get("verb") + + @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 += f"{method_name} must be one of {sorted(self._valid_verbs)}. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["verb"] = value diff --git a/plugins/module_utils/common/rest_send_fabric.py b/plugins/module_utils/common/rest_send_fabric.py new file mode 100644 index 000000000..638450bbf --- /dev/null +++ b/plugins/module_utils/common/rest_send_fabric.py @@ -0,0 +1,500 @@ +# +# 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 +import re +from time import sleep + +# Using only for its failed_result property +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import \ + FabricTaskResult +from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import \ + dcnm_send + + +class RestSend: + """ + Send REST requests to the controller with retries, and handle responses. + + Usage (where ansible_module is an instance of AnsibleModule): + + rest_send = RestSend(ansible_module) + rest_send.path = "/rest/top-down/fabrics" + rest_send.verb = "GET" + rest_send.payload = my_payload # Optional + rest_send.commit() + + # list of responses from the controller for this session + response = rest_send.response + # dict with current controller response + response_current = rest_send.response_current + # list of results from the controller for this session + result = rest_send.result + # dict with current controller result + result_current = rest_send.result_current + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + self.ansible_module = ansible_module + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.params = ansible_module.params + + self._valid_verbs = {"GET", "POST", "PUT", "DELETE"} + self.properties = {} + self.properties["check_mode"] = False + self.properties["response"] = [] + self.properties["response_current"] = {} + self.properties["result"] = [] + self.properties["result_current"] = {} + self.properties["send_interval"] = 5 + self.properties["timeout"] = 300 + self.properties["unit_test"] = False + self.properties["verb"] = None + self.properties["path"] = None + self.properties["payload"] = None + + self.check_mode = self.ansible_module.check_mode + self.state = self.params.get("state") + + msg = "ENTERED RestSendFabric(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + def _verify_commit_parameters(self): + if self.verb is None: + msg = f"{self.class_name}._verify_commit_parameters: " + msg += "verb must be set before calling commit()." + self.ansible_module.fail_json(msg, **self.failed_result) + if self.path is None: + msg = f"{self.class_name}._verify_commit_parameters: " + msg += "path must be set before calling commit()." + self.ansible_module.fail_json(msg, **self.failed_result) + + def commit(self): + if self.check_mode is True: + self.commit_check_mode() + else: + self.commit_normal_mode() + + def commit_check_mode(self): + """ + Simulate a dcnm_send() call for check_mode + + Properties read: + self.verb: HTTP verb e.g. GET, POST, PUT, DELETE + self.path: HTTP path e.g. http://controller_ip/path/to/endpoint + self.payload: Optional HTTP payload + + Properties written: + self.properties["response_current"]: raw simulated response + self.properties["result_current"]: result from self._handle_response() method + """ + method_name = inspect.stack()[0][3] + caller = inspect.stack()[1][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += f"verb {self.verb}, path {self.path}." + self.log.debug(msg) + + self._verify_commit_parameters() + + self.response_current = {} + self.response_current["RETURN_CODE"] = 200 + self.response_current["METHOD"] = self.verb + self.response_current["REQUEST_PATH"] = self.path + self.response_current["MESSAGE"] = "OK" + self.response_current["CHECK_MODE"] = True + self.response_current["DATA"] = "[simulated-check-mode-response:Success]" + self.result_current = self._handle_response(copy.deepcopy(self.response_current)) + + self.response = copy.deepcopy(self.response_current) + self.result = copy.deepcopy(self.result_current) + + def commit_normal_mode(self): + """ + Call dcnm_send() with retries until successful response or timeout is exceeded. + + Properties read: + self.send_interval: interval between retries (set in ImageUpgradeCommon) + self.timeout: timeout in seconds (set in ImageUpgradeCommon) + self.verb: HTTP verb e.g. GET, POST, PUT, DELETE + self.path: HTTP path e.g. http://controller_ip/path/to/endpoint + self.payload: Optional HTTP payload + + Properties written: + self.properties["response"]: raw response from the controller + self.properties["result"]: result from self._handle_response() method + """ + method_name = inspect.stack()[0][3] + caller = inspect.stack()[1][3] + + self._verify_commit_parameters() + try: + timeout = self.timeout + except AttributeError: + timeout = 300 + + success = False + msg = f"{caller}: Entering commit loop. " + self.log.debug(msg) + + while timeout > 0 and success is False: + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += f"Calling dcnm_send: verb {self.verb}, path {self.path}" + if self.payload is None: + self.log.debug(msg) + self.response_current = dcnm_send(self.ansible_module, self.verb, self.path) + else: + msg += f", payload: " + msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + self.response_current = dcnm_send( + self.ansible_module, + self.verb, + self.path, + data=json.dumps(self.payload), + ) + self.result_current = self._handle_response(self.response_current) + + success = self.result_current["success"] + if success is False and self.unit_test is False: + sleep(self.send_interval) + timeout -= self.send_interval + + self.response_current = self._strip_invalid_json_from_response_data( + self.response_current + ) + + self.response = copy.deepcopy(self.response_current) + self.result = copy.deepcopy(self.result_current) + + def _strip_invalid_json_from_response_data(self, response): + """ + Strip "Invalid JSON response:" from response["DATA"] if present + + This just clutters up the output and is not useful to the user. + """ + if "DATA" not in response: + return response + if not isinstance(response["DATA"], str): + return response + response["DATA"] = re.sub(r"Invalid JSON response:\s*", "", response["DATA"]) + return response + + def _handle_response(self, response): + """ + Call the appropriate handler for response based on verb + """ + if self.verb == "GET": + return self._handle_get_response(response) + if self.verb in {"POST", "PUT", "DELETE"}: + return self._handle_post_put_delete_response(response) + return self._handle_unknown_request_verbs(response) + + def _handle_unknown_request_verbs(self, response): + method_name = inspect.stack()[0][3] + + msg = f"{self.class_name}.{method_name}: " + msg += f"Unknown request verb ({self.verb}) for response {response}." + self.ansible_module.fail_json(msg, **self.failed_result) + + def _handle_get_response(self, response): + """ + Caller: + - self._handle_response() + Handle controller responses to GET requests + Returns: dict() with the following keys: + - found: + - False, if request error was "Not found" and RETURN_CODE == 404 + - True otherwise + - success: + - False if RETURN_CODE != 200 or MESSAGE != "OK" + - True otherwise + """ + result = {} + success_return_codes = {200, 404} + if ( + response.get("RETURN_CODE") == 404 + and response.get("MESSAGE") == "Not Found" + ): + result["found"] = False + result["success"] = True + return result + if ( + response.get("RETURN_CODE") not in success_return_codes + or response.get("MESSAGE") != "OK" + ): + result["found"] = False + result["success"] = False + return result + result["found"] = True + result["success"] = True + return result + + def _handle_post_put_delete_response(self, response): + """ + Caller: + - self.self._handle_response() + + Handle POST, PUT responses from the controller. + + Returns: dict() with the following keys: + - changed: + - True if changes were made to by the controller + - False otherwise + - success: + - False if RETURN_CODE != 200 or MESSAGE != "OK" + - True otherwise + """ + result = {} + if response.get("ERROR") is not None: + result["success"] = False + result["changed"] = False + return result + if response.get("MESSAGE") != "OK" and response.get("MESSAGE") is not None: + result["success"] = False + result["changed"] = False + return result + result["success"] = True + result["changed"] = True + return result + + @property + def check_mode(self): + """ + Determines if dcnm_send should be called. + + Default: False + + If False, dcnm_send is called. Real controller responses + are returned by RestSend() + + If True, dcnm_send is not called. Simulated controller responses + are returned by RestSend() + + Discussion: + We don't set check_mode from the value of self.ansible_module.check_mode + because we want to be able to read data from the controller even when + self.ansible_module.check_mode is True. For example, SwitchIssuDetails + is a read-only operation, and we want to be able to read this data + to provide a realistic simulation of stage, validate, and upgrade + tasks. + """ + return self.properties.get("check_mode") + + @check_mode.setter + def check_mode(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a bool(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["check_mode"] = value + + @property + def failed_result(self): + """ + Return a result for a failed task with no changes + """ + return FabricTaskResult(self.ansible_module).failed_result + + @property + def path(self): + """ + Endpoint path for the REST request. + e.g. "/appcenter/cisco/ndfc/api/v1/...etc..." + """ + return self.properties.get("path") + + @path.setter + def path(self, value): + self.properties["path"] = value + + @property + def payload(self): + """ + Return the payload to send to the controller + """ + return self.properties["payload"] + + @payload.setter + def payload(self, value): + self.properties["payload"] = value + + @property + def response_current(self): + """ + Return the current POST response from the controller + instance.commit() must be called first. + + This is a dict of the current response from the controller. + """ + return copy.deepcopy(self.properties.get("response_current")) + + @response_current.setter + def response_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.response_current must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["response_current"] = value + + @property + def response(self): + """ + Return the aggregated POST response from the controller + instance.commit() must be called first. + + This is a list of responses from the controller. + """ + return copy.deepcopy(self.properties.get("response")) + + @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 += "instance.response must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["response"].append(value) + + @property + def result(self): + """ + Return the aggregated result from the controller + instance.commit() must be called first. + + This is a list of results from the controller. + """ + return copy.deepcopy(self.properties.get("result")) + + @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 += "instance.result must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["result"].append(value) + + @property + def result_current(self): + """ + Return the current result from the controller + instance.commit() must be called first. + + This is a dict containing the current result. + """ + return copy.deepcopy(self.properties.get("result_current")) + + @result_current.setter + def result_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.result_current must be a dict. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["result_current"] = value + + @property + def send_interval(self): + """ + Send interval, in seconds, for retrying responses from the controller. + Valid values: int() + Default: 5 + """ + return self.properties.get("send_interval") + + @send_interval.setter + def send_interval(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, int): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an int(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["send_interval"] = value + + @property + def timeout(self): + """ + Timeout, in seconds, for retrieving responses from the controller. + Valid values: int() + Default: 300 + """ + return self.properties.get("timeout") + + @timeout.setter + def timeout(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, int): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an int(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["timeout"] = value + + @property + def unit_test(self): + """ + Is the class running under a unit test. + Set this to True in unit tests to speed the test up. + Default: False + """ + return self.properties.get("unit_test") + + @unit_test.setter + def unit_test(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a bool(). Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["unit_test"] = value + + @property + def verb(self): + """ + Verb for the REST request. + One of "GET", "POST", "PUT", "DELETE" + """ + return self.properties.get("verb") + + @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 += f"{method_name} must be one of {sorted(self._valid_verbs)}. " + msg += f"Got {value}." + self.ansible_module.fail_json(msg, **self.failed_result) + self.properties["verb"] = value From a5c5d59048008219f1d85228443c21bc1b61455f Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Fri, 15 Mar 2024 14:04:20 -1000 Subject: [PATCH 009/228] Use Results() class to standardize results A Results() class is introduced. The intent is to: 1. Standardize results processing and output 2. Rremove the distraction of results processing from the main logic of a given module. 3. Enable result format changes to be made in a single place, should we ever want to do that. --- plugins/module_utils/fabric/common.py | 182 +------ plugins/module_utils/fabric/create.py | 147 ++---- plugins/module_utils/fabric/delete.py | 141 +---- plugins/module_utils/fabric/fabric_details.py | 8 +- plugins/module_utils/fabric/query.py | 35 +- plugins/module_utils/fabric/results.py | 481 ++++++++++++++++-- plugins/module_utils/fabric/template_get.py | 26 +- plugins/module_utils/fabric/update.py | 184 ++----- plugins/modules/dcnm_fabric.py | 125 +++-- 9 files changed, 653 insertions(+), 676 deletions(-) diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index 024ed8e19..0e89a4a83 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -22,20 +22,11 @@ import re from typing import Any, Dict -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import \ - FabricTaskResult - -# Using only for its failed_result property -# pylint: disable=line-too-long - - -# pylint: enable=line-too-long - class FabricCommon: """ Common methods used by the other classes supporting - dcnm_fabric_* modules + the dcnm_fabric module Usage (where ansible_module is an instance of AnsibleModule or MockAnsibleModule): @@ -55,23 +46,16 @@ def __init__(self, ansible_module): self.log = logging.getLogger(f"dcnm.{self.class_name}") msg = "ENTERED FabricCommon(): " - msg += f"state: {self.state}, " - msg += f"check_mode: {self.check_mode}" + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" self.log.debug(msg) self.params = ansible_module.params self.properties: Dict[str, Any] = {} - self.properties["changed"] = False - self.properties["diff"] = [] # Default to VXLAN_EVPN self.properties["fabric_type"] = "VXLAN_EVPN" - self.properties["failed"] = False - self.properties["response"] = [] - self.properties["response_current"] = {} - self.properties["response_data"] = [] - self.properties["result"] = [] - self.properties["result_current"] = {} + self.properties["results"] = None self._valid_fabric_types = {"VXLAN_EVPN"} @@ -92,7 +76,7 @@ def translate_mac_address(mac_addr): return False return "".join((mac_addr[:4], ".", mac_addr[4:8], ".", mac_addr[8:])) - def _handle_response(self, response, verb): + def _handle_response(self, response, verb) -> Dict[str, Any]: """ Call the appropriate handler for response based on verb """ @@ -109,7 +93,7 @@ def _handle_unknown_request_verbs(self, response, verb): msg += f"Unknown request verb ({verb}) for response {response}." self.ansible_module.fail_json(msg) - def _handle_get_response(self, response): + def _handle_get_response(self, response) -> Dict[str, Any]: """ Caller: - self._handle_response() @@ -142,7 +126,7 @@ def _handle_get_response(self, response): result["success"] = True return result - def _handle_post_put_delete_response(self, response): + def _handle_post_put_delete_response(self, response) -> Dict[str, Any]: """ Caller: - self.self._handle_response() @@ -180,7 +164,7 @@ def fabric_type_to_template_name(self, value): if value not in self.fabric_type_to_template_name_map: msg = f"{self.class_name}.{method_name}: " msg += f"Unknown fabric type: {value}" - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) return self.fabric_type_to_template_name_map[value] def make_boolean(self, value): @@ -207,38 +191,6 @@ def make_none(self, value): return None return value - @property - def changed(self): - """ - bool = whether we changed anything - """ - return self.properties["changed"] - - @changed.setter - def changed(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, bool): - msg = f"{self.class_name}.{method_name}: " - msg += f"instance.changed must be a bool. Got {value}" - self.ansible_module.fail_json(msg) - self.properties["changed"] = value - - @property - def diff(self): - """ - List of dicts representing the changes made - """ - return self.properties["diff"] - - @diff.setter - def diff(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += f"instance.diff must be a dict. Got {value}" - self.ansible_module.fail_json(msg) - self.properties["diff"].append(value) - @property def fabric_type(self): """ @@ -256,121 +208,17 @@ def fabric_type(self, value): msg += f"FABRIC_TYPE must be one of " msg += f"{sorted(self._valid_fabric_types)}. " msg += f"Got {value}" - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) self.properties["fabric_type"] = value @property - def failed(self): - """ - bool = whether we failed or not - If True, this means we failed to make a change - If False, this means we succeeded in making a change - """ - return self.properties["failed"] - - @failed.setter - def failed(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, bool): - msg = f"{self.class_name}.{method_name}: " - msg += f"instance.failed must be a bool. Got {value}" - self.ansible_module.fail_json(msg) - self.properties["failed"] = value - - @property - def failed_result(self): - """ - return a result for a failed task with no changes - """ - return FabricTaskResult(self.ansible_module).failed_result - - @property - def response_current(self): - """ - Return the current POST response from the controller - instance.commit() must be called first. - - This is a dict of the current response from the controller. - """ - return self.properties.get("response_current") - - @response_current.setter - def response_current(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += "instance.response_current must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["response_current"] = value - - @property - def response(self): - """ - Return the aggregated POST response from the controller - instance.commit() must be called first. - - This is a list of responses from the controller. - """ - return self.properties.get("response") - - @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 += "instance.response must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["response"].append(value) - - @property - def response_data(self): + def results(self): """ - Return the contents of the DATA key within current_response. + An instance of the Results class. """ - return self.properties.get("response_data") + return self.properties["results"] - @response_data.setter - def response_data(self, value): - self.properties["response_data"].append(value) - - @property - def result(self): - """ - Return the aggregated result from the controller - instance.commit() must be called first. - - This is a list of results from the controller. - """ - return self.properties.get("result") - - @result.setter - def result(self, value): + @results.setter + def results(self, value): method_name = inspect.stack()[0][3] - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += "instance.result must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["result"].append(value) - - @property - def result_current(self): - """ - Return the current result from the controller - instance.commit() must be called first. - - This is a dict containing the current result. - """ - return self.properties.get("result_current") - - @result_current.setter - def result_current(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += "instance.result_current must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["result_current"] = value + self.properties["results"] = value diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index 04b49b62c..c816a4c72 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -44,18 +44,20 @@ class FabricCreateCommon(FabricCommon): def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ + self.action = "create" self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.check_mode = self.ansible_module.check_mode msg = "ENTERED FabricCreateCommon(): " - msg += f"check_mode: {self.check_mode}" + msg += f"action: {self.action}, " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" self.log.debug(msg) self.fabric_details = FabricDetailsByName(self.ansible_module) self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) - self._verify_params = VerifyPlaybookParams(self.ansible_module) + #self._verify_params = VerifyPlaybookParams(self.ansible_module) # 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 @@ -63,19 +65,17 @@ def __init__(self, ansible_module): self.path = None self.verb = None - self.action = "create" self._payloads_to_commit = [] - self.response_ok = [] - self.result_ok = [] - self.diff_ok = [] - self.response_nok = [] - self.result_nok = [] - self.diff_nok = [] self._mandatory_payload_keys = set() self._mandatory_payload_keys.add("FABRIC_NAME") self._mandatory_payload_keys.add("BGP_AS") + msg = "ENTERED FabricCreateCommon(): " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + def _verify_payload(self, payload): """ Verify that the payload is a dict and contains all mandatory keys @@ -86,7 +86,7 @@ def _verify_payload(self, payload): msg += "payload must be a dict. " msg += f"Got type {type(payload).__name__}, " msg += f"value {payload}" - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) missing_keys = [] for key in self._mandatory_payload_keys: @@ -98,7 +98,7 @@ def _verify_payload(self, payload): msg = f"{self.class_name}.{method_name}: " msg += "payload is missing mandatory keys: " msg += f"{sorted(missing_keys)}" - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) def _fixup_payloads_to_commit(self): """ @@ -173,23 +173,10 @@ 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, populate the following lists: - - - self.response_ok : list of controller responses associated with success result - - self.result_ok : list of results where success is True - - self.diff_ok : list of payloads for which the request succeeded - - self.response_nok : list of controller responses associated with failed result - - self.result_nok : list of results where success is False - - self.diff_nok : list of payloads for which the request failed + In both cases, update results """ self.rest_send.check_mode = self.check_mode - self.response_ok = [] - self.result_ok = [] - self.diff_ok = [] - self.response_nok = [] - self.result_nok = [] - self.diff_nok = [] for payload in self._payloads_to_commit: self._set_fabric_create_endpoint(payload) @@ -209,84 +196,14 @@ def _send_payloads(self): self.rest_send.commit() if self.rest_send.result_current["success"]: - self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_ok.append(copy.deepcopy(payload)) + self.results.changed = True + self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_ok.append(copy.deepcopy(payload)) else: - self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_nok.append(copy.deepcopy(payload)) - - def _process_responses(self): - method_name = inspect.stack()[0][3] - - # All requests succeeded, set changed to True and return - if len(self.result_nok) == 0: - self.changed = True - for diff in self.diff_ok: - diff["action"] = self.action - self.diff = copy.deepcopy(diff) - for result in self.result_ok: - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_ok: - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - return - - self.failed = True - self.changed = False - # at least one request succeeded, so set changed to True - if len(self.result_nok) != len(self._payloads_to_commit): - self.changed = True - - # Provide the info for the request(s) that succeeded - # and the request(s) that failed - - # Add an "OK" result to the response(s) that succeeded - for diff in self.diff_ok: - diff["action"] = self.action - diff["result"] = "OK" - self.diff = copy.deepcopy(diff) - for result in self.result_ok: - result["result"] = "OK" - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_ok: - response["result"] = "OK" - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - - # Add a "FAILED" result to the response(s) that failed - for diff in self.diff_nok: - diff["action"] = self.action - diff["result"] = "FAILED" - self.diff = copy.deepcopy(diff) - for result in self.result_nok: - result["result"] = "FAILED" - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_nok: - response["result"] = "FAILED" - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - - result = {} - result["diff"] = {} - result["response"] = {} - result["result"] = {} - result["failed"] = self.failed - result["changed"] = self.changed - result["diff"]["OK"] = self.diff_ok - result["response"]["OK"] = self.response_ok - result["result"]["OK"] = self.result_ok - result["diff"]["FAILED"] = self.diff_nok - result["response"]["FAILED"] = self.response_nok - result["result"]["FAILED"] = self.result_nok - - msg = f"{self.class_name}.{method_name}: " - msg += f"Bad response(s) during fabric {self.action}. " - self.ansible_module.fail_json(msg, **result) + self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_nok.append(copy.deepcopy(payload)) @property def payloads(self): @@ -306,7 +223,7 @@ def payloads(self, value): msg += "payloads must be a list of dict. " msg += f"got {type(value).__name__} for " msg += f"value {value}" - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) for item in value: self._verify_payload(item) self.properties["payloads"] = value @@ -342,14 +259,15 @@ def commit(self): if self.payloads is None: msg = f"{self.class_name}.{method_name}: " msg += "payloads must be set prior to calling commit." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) self._build_payloads_to_commit() if len(self._payloads_to_commit) == 0: return self._fixup_payloads_to_commit() self._send_payloads() - self._process_responses() + self.results.action = self.action + self.results.register_task_results() class FabricCreate(FabricCommon): @@ -385,7 +303,7 @@ def commit(self): self.ansible_module.fail_json(msg) if len(self.payload) == 0: - self.ansible_module.exit_json(**self.failed_result) + self.ansible_module.exit_json(**self.results.failed_result) fabric_name = self.payload.get("FABRIC_NAME") if fabric_name is None: @@ -408,13 +326,16 @@ def commit(self): self.rest_send.payload = self.payload self.rest_send.commit() - self.result_current = self.rest_send.result_current - self.result = self.rest_send.result_current - self.response_current = self.rest_send.response_current - self.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.response_current = self.rest_send.response_current + self.results.response = self.rest_send.response_current + + if self.results.response_current["RETURN_CODE"] == 200: + self.results.diff = self.payload - if self.response_current["RETURN_CODE"] == 200: - self.diff = self.payload + self.results.action = self.action + self.results.register_task_results() @property def payload(self): diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index 97a1740e5..6c79e4a12 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -18,7 +18,6 @@ import copy import inspect -import json import logging from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ @@ -52,11 +51,9 @@ class FabricDelete(FabricCommon): def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ + self.action = "delete" self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED FabricDelete(): " - msg += f"state: {self.state}" - self.log.debug(msg) self._fabrics_to_delete = [] self._build_properties() @@ -73,15 +70,11 @@ def __init__(self, ansible_module): self.path = None self.verb = None - self.action = "delete" - self.changed = False - self.failed = False - self.response_ok = [] - self.result_ok = [] - self.diff_ok = [] - self.response_nok = [] - self.result_nok = [] - self.diff_nok = [] + 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): """ @@ -137,10 +130,7 @@ def _can_fabric_be_deleted(self, fabric_name): return True if the fabric can be deleted return False otherwise """ - method_name = inspect.stack()[0][3] - msg = f"{self.class_name}.{method_name}: " - msg += "ENTERED" - self.log.debug(msg) + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable self._fabric_summary.fabric_name = fabric_name self._fabric_summary.refresh() if self._fabric_summary.fabric_is_empty is False: @@ -156,7 +146,7 @@ def _set_fabric_delete_endpoint(self, fabric_name): try: endpoint = self._endpoints.fabric_delete except ValueError as error: - self.ansible_module.fail_json(error, **self.failed_result) + self.ansible_module.fail_json(error, **self.results.failed_result) self.path = endpoint.get("path") self.verb = endpoint.get("verb") @@ -164,11 +154,11 @@ def _validate_commit_parameters(self): """ validate the parameters for commit """ - method_name = inspect.stack()[0][3] + 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." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) def commit(self): """ @@ -181,14 +171,11 @@ def commit(self): msg = f"self._fabrics_to_delete: {self._fabrics_to_delete}" self.log.debug(msg) - if len(self._fabrics_to_delete) == 0: - self.changed = False - self.failed = False - return - - self._send_requests() - self._process_responses() - + if len(self._fabrics_to_delete) != 0: + self._send_requests() + self.results.action = "delete" + self.results.register_task_results() + def _send_requests(self): """ If check_mode is False, send the requests to the controller @@ -205,13 +192,6 @@ def _send_requests(self): """ self.rest_send.check_mode = self.check_mode - self.response_ok = [] - self.result_ok = [] - self.diff_ok = [] - self.response_nok = [] - self.result_nok = [] - self.diff_nok = [] - # 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. @@ -233,9 +213,10 @@ def _send_request(self, fabric_name): self.rest_send.commit() if self.rest_send.result_current["success"]: - self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_ok.append({"fabric_name": fabric_name}) + self.results.changed = True + self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_ok.append({"fabric_name": fabric_name}) else: # Improve the controller's error message to include the fabric_name response_current = copy.deepcopy(self.rest_send.response_current) @@ -243,84 +224,6 @@ def _send_request(self, fabric_name): if "Failed to delete the fabric." in response_current["DATA"]: msg = f"Failed to delete fabric {fabric_name}." response_current["DATA"] = msg - self.response_nok.append(copy.deepcopy(response_current)) - self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_nok.append({"fabric_name": fabric_name}) - - def _process_responses(self): - method_name = inspect.stack()[0][3] - - # All requests succeeded, set changed to True and return - if len(self.result_nok) == 0: - self.changed = True - for diff in self.diff_ok: - diff["action"] = self.action - self.diff = copy.deepcopy(diff) - for result in self.result_ok: - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_ok: - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - return - - # At least one request failed. - # Set failed to true, set changed appropriately, - # build response/result/diff, and call fail_json - self.failed = True - self.changed = False - # At least one request succeeded, so set changed to True - if self.result_ok != 0: - self.changed = True - - # Provide the results for all (failed and successful) requests - - # Add an "OK" result to the response(s) that succeeded - for diff in self.diff_ok: - diff["action"] = self.action - diff["result"] = "OK" - self.diff = copy.deepcopy(diff) - for result in self.result_ok: - result["result"] = "OK" - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_ok: - response["result"] = "OK" - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - - # Add a "FAILED" result to the response(s) that failed - for diff in self.diff_nok: - diff["action"] = self.action - diff["result"] = "FAILED" - self.diff = copy.deepcopy(diff) - for result in self.result_nok: - result["result"] = "FAILED" - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_nok: - response["result"] = "FAILED" - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - - result = {} - result["diff"] = {} - result["response"] = {} - result["result"] = {} - result["failed"] = self.failed - result["changed"] = self.changed - result["diff"]["OK"] = self.diff_ok - result["response"]["OK"] = self.response_ok - result["result"]["OK"] = self.result_ok - result["diff"]["FAILED"] = self.diff_nok - result["response"]["FAILED"] = self.response_nok - result["result"]["FAILED"] = self.result_nok - - msg = f"{self.class_name}.{method_name}: " - msg += f"Bad response(s) during fabric {self.action}. " - msg += f"result: {json.dumps(result, indent=4, sort_keys=True)}" - self.log.debug(msg) - - msg = f"{self.class_name}.{method_name}: " - msg += f"Bad response(s) during fabric {self.action}. " - self.ansible_module.fail_json(msg, **result) + self.results.response_nok.append(copy.deepcopy(response_current)) + self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_nok.append({"fabric_name": fabric_name}) diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py index fb43c887d..bc4f76833 100644 --- a/plugins/module_utils/fabric/fabric_details.py +++ b/plugins/module_utils/fabric/fabric_details.py @@ -49,6 +49,10 @@ def __init__(self, ansible_module): self.data = {} self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) + # We always want to get the controller's current fabric state + # so we set check_mode to False here so the request will be + # sent to the controller + self.rest_send.check_mode = False self._init_properties() @@ -70,7 +74,9 @@ def refresh_super(self): self.rest_send.verb = endpoint.get("verb") self.rest_send.commit() self.data = {} - for item in self.rest_send.response_current["DATA"]: + if self.rest_send.response_current.get("DATA") is None: + return + for item in self.rest_send.response_current.get("DATA"): self.data[item["fabricName"]] = item self.response_current = self.rest_send.response_current self.response = self.rest_send.response_current diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py index 0a19a8e4d..b9d1686dc 100644 --- a/plugins/module_utils/fabric/query.py +++ b/plugins/module_utils/fabric/query.py @@ -114,37 +114,28 @@ def commit(self): if self.fabric_names is None: msg = f"{self.class_name}.{method_name}: " msg += "fabric_names must be set prior to calling commit." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) self._get_fabrics_to_query() msg = f"self._fabrics_to_query: {self._fabrics_to_query}" self.log.debug(msg) if len(self._fabrics_to_query) == 0: - self.changed = False - self.failed = False + self.results.changed = False + self.results.failed = False return msg = f"Populating diff {self._fabrics_to_query}" self.log.debug(msg) for fabric_name in self._fabrics_to_query: - if fabric_name in self._fabric_details.all_data: - fabric = copy.deepcopy(self._fabric_details.all_data[fabric_name]) - fabric["action"] = self.action - self.diff = fabric - self.response = copy.deepcopy(self._fabric_details.response_current) - self.response_current = copy.deepcopy(self._fabric_details.response_current) - self.result = copy.deepcopy(self._fabric_details.result_current) - self.result_current = copy.deepcopy(self._fabric_details.result_current) - - msg = f"self.diff: {self.diff}" - self.log.debug(msg) - msg = f"self.response: {self.response}" - self.log.debug(msg) - msg = f"self.result: {self.result}" - self.log.debug(msg) - msg = f"self.response_current: {self.response_current}" - self.log.debug(msg) - msg = f"self.result_current: {self.result_current}" - self.log.debug(msg) + if fabric_name not in self._fabric_details.all_data: + continue + fabric = copy.deepcopy(self._fabric_details.all_data[fabric_name]) + fabric["action"] = self.action + self.results.diff_ok.append(fabric) + self.results.response_ok.append(copy.deepcopy(self._fabric_details.response_current)) + self.results.result_ok.append(copy.deepcopy(self._fabric_details.result_current)) + + self.results.action = self.action + self.results.register_task_results() diff --git a/plugins/module_utils/fabric/results.py b/plugins/module_utils/fabric/results.py index a3eae378d..4a7072fbf 100644 --- a/plugins/module_utils/fabric/results.py +++ b/plugins/module_utils/fabric/results.py @@ -18,22 +18,105 @@ __copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." __author__ = "Allen Robel" +import copy +import inspect import logging from typing import Any, Dict class Results: """ - Return various result templates that AnsibleModule can use. + Collect results across tasks. - results = Results() - # A generic result that indicates a task failed with no changes - failed_result = results.failed_result + Provides a mechanism to collect results across tasks. The task classes + must do the following: + + 1. Accept an instantiation of Results + - Typically a class property is used for this + 2. Populate the Results instance with the results of the task + - Typically done by transferring RestSend's responses to the + Results instance + 3. Register the results of the task with Results.register_task_results() + - 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 + (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(). + + + Example Usage: + + We assume an Ansible module structure as follows: + + TaskCommon() : Common methods used by the various ansible state classes. + TaskDelete(TaskCommon) : Implements the delete state + TaskMerge(TaskCommon) : Implements the merge state + TaskQuery(TaskCommon) : Implements the query state + etc... + + In TaskCommon, Results is instantiated and, hence, is inherited by all + state classes.: + + class TaskCommon: + def __init__(self): + self.results = Results() + + @property + def results(self): + ''' + An instance of the Results class. + ''' + return self.properties["results"] + + @results.setter + def results(self, value): + self.properties["results"] = value + + + In each of the state classes (TaskDelete, TaskMerge, TaskQuery, etc...) + a class is instantiated (in the example below, FabricDelete) that + supports collecting results for the Results instance: + + class TaskDelete(TaskCommon): + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.fabric_delete = FabricDelete(self.ansible_module) + + def commit(self): + ''' + delete the fabric + ''' + ... + self.fabric_delete.fabric_names = ["FABRIC_1", "FABRIC_2"] + self.fabric_delete.results = self.results + # results.register_task_results() is called within the + # commit() method of the FabricDelete class. + self.fabric_delete.commit() + + + Finally, within the main() method of the Ansible module, the final result + is built by calling Results.build_final_result(): + + if ansible_module.params["state"] == "deleted": + task = TaskDelete(ansible_module) + task.commit() + elif ansible_module.params["state"] == "merged": + task = TaskDelete(ansible_module) + task.commit() + etc... + + # Build the final result + task.results.build_final_result() + + # Call fail_json() or exit_json() based on the final result + if True in task.results.failed: + ansible_module.fail_json(**task.results.final_result) + ansible_module.exit_json(**task.results.final_result) - TODO: module_result is not yet implemented - # A generic result that indicates a task succeeded - # obj is an instance of a class that has diff, result, and response properties - module_result = results.module_result(obj) # output of the above print() will be a dict with the following structure # specific keys within the diff and response dictionaries will vary depending @@ -57,36 +140,165 @@ class Results: } """ - def __init__(self, ansible_module): + def __init__(self): self.class_name = self.__class__.__name__ self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.state = ansible_module.params.get("state") - self.check_mode = ansible_module.check_mode - - msg = "ENTERED Results(): " - msg += f"state: {self.state}, " - msg += f"check_mode: {self.check_mode}" + msg = "ENTERED Results():" self.log.debug(msg) self.diff_keys = ["deleted", "merged", "query"] self.response_keys = ["deleted", "merged", "query"] - def did_anything_change(self, obj) -> bool: + self.diff_ok = [] + self.response_ok = [] + self.result_ok = [] + self.diff_nok = [] + self.response_nok = [] + self.result_nok = [] + + # Assign a unique sequence number to each diff to enable tracking + # of the order in which it was executed + self.diff_sequence_number = 0 + + self.final_result = {} + self._build_properties() + + def _build_properties(self): + self.properties: Dict[str, Any] = {} + self.properties["action"] = None + self.properties["changed"] = set() + self.properties["check_mode"] = False + self.properties["diff"] = [] + self.properties["failed"] = set() + self.properties["response"] = [] + self.properties["response_current"] = {} + self.properties["response_data"] = [] + self.properties["result"] = [] + self.properties["result_current"] = {} + self.properties["state"] = None + + def get_diff_sequence_number(self) -> int: """ - return True if obj has any changes - Caller: module_result + Return a unique sequence number for the current result + """ + self.diff_sequence_number += 1 + return self.diff_sequence_number - TODO: Need to implement module_result + def did_anything_change(self) -> bool: + """ + return True if there were any changes """ if self.check_mode is True: self.log.debug("check_mode is True. No changes made.") return False - if len(obj.diff) != 0: + if len(self.diff) != 0: return True return False + def register_task_results(self): + """ + Register a task's results + + - self.response_ok : list of controller responses associated with success result + - self.result_ok : list of results where success is True + - self.diff_ok : list of payloads for which the request succeeded + - self.response_nok : list of controller responses associated with failed result + - self.result_nok : list of results where success is False + - self.diff_nok : list of payloads for which the request failed + """ + method_name = inspect.stack()[0][3] + + self.changed = self.did_anything_change() + # All requests succeeded, set changed to True and return + if len(self.result_nok) == 0: + self.failed = False + else: + self.failed = True + + # Provide the results for all (failed and successful) requests + + # Add a sequence number, action, and "OK" result to the + # response(s) that succeeded + result_string = "OK" + for diff in self.diff_ok: + if diff.get("metadata") is None: + diff["metadata"] = {} + diff["metadata"]["action"] = self.action + diff["metadata"]["check_mode"] = self.check_mode + diff["metadata"]["sequence_number"] = self.get_diff_sequence_number() + diff["metadata"]["result"] = result_string + self.diff = copy.deepcopy(diff) + for result in self.result_ok: + if result.get("metadata") is None: + result["metadata"] = {} + result["metadata"]["action"] = self.action + result["metadata"]["check_mode"] = self.check_mode + result["metadata"]["result"] = result_string + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_ok: + if response.get("metadata") is None: + response["metadata"] = {} + response["metadata"]["action"] = self.action + response["metadata"]["check_mode"] = self.check_mode + response["metadata"]["result"] = result_string + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + # Add a "FAILED" result to the response(s) that failed + result_string = "FAILED" + for diff in self.diff_nok: + if diff.get("metadata") is None: + diff["metadata"] = {} + diff["metadata"]["action"] = self.action + diff["metadata"]["check_mode"] = self.check_mode + diff["metadata"]["sequence_number"] = self.get_diff_sequence_number() + diff["metadata"]["result"] = result_string + self.diff = copy.deepcopy(diff) + for result in self.result_nok: + if result.get("metadata") is None: + result["metadata"] = {} + result["metadata"]["action"] = self.action + result["metadata"]["check_mode"] = self.check_mode + result["metadata"]["result"] = result_string + self.result = copy.deepcopy(result) + self.result_current = copy.deepcopy(result) + for response in self.response_nok: + if response.get("metadata") is None: + response["metadata"] = {} + response["metadata"]["action"] = self.action + response["metadata"]["check_mode"] = self.check_mode + response["metadata"]["result"] = result_string + self.response = copy.deepcopy(response) + self.response_current = copy.deepcopy(response) + + def build_final_result(self): + """ + Build the final result + """ + self.final_result = {} + self.final_result["diff"] = {} + self.final_result["response"] = {} + self.final_result["result"] = {} + if True in self.failed: + self.final_result["failed"] = True + else: + self.final_result["failed"] = False + msg = f"self.changed: {self.changed}" + self.log.debug(msg) + if True in self.changed: + self.final_result["changed"] = True + else: + self.final_result["changed"] = False + self.final_result["diff"]["OK"] = self.diff_ok + self.final_result["response"]["OK"] = self.response_ok + self.final_result["result"]["OK"] = self.result_ok + self.final_result["diff"]["FAILED"] = self.diff_nok + self.final_result["response"]["FAILED"] = self.response_nok + self.final_result["result"]["FAILED"] = self.result_nok + @property def failed_result(self) -> Dict[str, Any]: """ @@ -103,30 +315,205 @@ def failed_result(self) -> Dict[str, Any]: result["response"][key] = [] return result - # @property - # def module_result(self, obj) -> Dict[str, Any]: - # """ - # Return a result that AnsibleModule can use - # Result is based on the obj properties: diff, response - # """ - # if not isinstance(list, obj.result): - # raise ValueError("obj.result must be a list of dict") - # if not isinstance(list, obj.diff): - # raise ValueError("obj.diff must be a list of dict") - # if not isinstance(list, obj.response): - # raise ValueError("obj.response must be a list of dict") - # result = {} - # result["changed"] = self.did_anything_change(obj) - # result["diff"] = {} - # result["response"] = {} - # for key in self.diff_keys: - # if self.state == key: - # result["diff"][key] = obj.diff - # else: - # result["diff"][key] = [] - # for key in self.response_keys: - # if self.state == key: - # result["response"][key] = obj.response - # else: - # result["response"][key] = [] - # return result + + @property + def action(self): + """ + Added to results to indicate the action that was taken + """ + return self.properties.get("action") + + @action.setter + def action(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.{method_name} must be a string. " + msg += f"Got {value}." + raise ValueError(msg) + msg = f"{self.class_name}.{method_name}: " + msg += f"value: {value}" + self.log.debug(msg) + self.properties["action"] = value + + @property + def changed(self): + """ + bool = whether we changed anything + + raise ValueError if value is not a bool + """ + return self.properties["changed"] + + @changed.setter + def changed(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.changed must be a bool. Got {value}" + raise ValueError(msg) + self.properties["changed"].add(value) + + @property + def check_mode(self): + """ + check_mode + """ + return self.properties.get("check_mode") + + @check_mode.setter + def check_mode(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.{method_name} must be a bool. " + msg += f"Got {value}." + raise ValueError(msg) + self.properties["check_mode"] = value + + @property + def diff(self): + """ + List of dicts representing the changes made + + raise ValueError if value is not a dict + """ + return self.properties["diff"] + + @diff.setter + def diff(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.diff must be a dict. Got {value}" + raise ValueError(msg) + self.properties["diff"].append(value) + + @property + def failed(self): + """ + A set() of Boolean values indicating whether any tasks failed + + If the set contains True, at least one task failed + If the set contains only False all tasks succeeded + + raise ValueError if value is not a bool + """ + return self.properties["failed"] + + @failed.setter + def failed(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.failed must be a bool. Got {value}" + raise ValueError(msg) + self.properties["failed"].add(value) + + @property + def response_current(self): + """ + Return the current POST response from the controller + instance.commit() must be called first. + + This is a dict of the current response from the controller. + """ + return self.properties.get("response_current") + + @response_current.setter + def response_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.response_current must be a dict. " + msg += f"Got {value}." + raise ValueError(msg) + self.properties["response_current"] = value + + @property + def response(self): + """ + Return the aggregated POST response from the controller + instance.commit() must be called first. + + This is a list of responses from the controller. + """ + return self.properties.get("response") + + @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 += "instance.response must be a dict. " + msg += f"Got {value}." + raise ValueError(msg) + self.properties["response"].append(value) + + @property + def response_data(self): + """ + Return the contents of the DATA key within current_response. + """ + return self.properties.get("response_data") + + @response_data.setter + def response_data(self, value): + self.properties["response_data"].append(value) + + @property + def result(self): + """ + Return the aggregated result from the controller + instance.commit() must be called first. + + This is a list of results from the controller. + """ + return self.properties.get("result") + + @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 += "instance.result must be a dict. " + msg += f"Got {value}." + raise ValueError(msg) + self.properties["result"].append(value) + + @property + def result_current(self): + """ + Return the current result from the controller + instance.commit() must be called first. + + This is a dict containing the current result. + """ + return self.properties.get("result_current") + + @result_current.setter + def result_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.result_current must be a dict. " + msg += f"Got {value}." + raise ValueError(msg) + self.properties["result_current"] = value + + @property + def state(self): + """ + Ansible state + """ + return self.properties.get("state") + + @state.setter + def state(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.{method_name} must be a string. " + msg += f"Got {value}." + raise ValueError(msg) + self.properties["state"] = value diff --git a/plugins/module_utils/fabric/template_get.py b/plugins/module_utils/fabric/template_get.py index 9ca32d532..60c91f2a0 100755 --- a/plugins/module_utils/fabric/template_get.py +++ b/plugins/module_utils/fabric/template_get.py @@ -27,8 +27,6 @@ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ ApiEndpoints -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ - Results class TemplateGet: @@ -47,19 +45,18 @@ class TemplateGet: def __init__(self, ansible_module): self.class_name = self.__class__.__name__ self.ansible_module = ansible_module - self.state = self.ansible_module.params["state"] + self.state = self.ansible_module.params.get("state") self.check_mode = self.ansible_module.check_mode self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED Template(): " - msg += f"state: {self.state}, " - msg += f"check_mode: {self.check_mode}" + msg = "ENTERED TemplateGet(): " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" self.log.debug(msg) self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) - self._results = Results(self.ansible_module) self.path = None self.verb = None @@ -74,6 +71,7 @@ def _init_properties(self) -> None: self._properties = {} self._properties["template_name"] = None self._properties["template"] = None + self._properties["results"] = None @property def template(self): @@ -139,9 +137,21 @@ def refresh(self): msg = f"{self.class_name}.{method_name}: " msg = "Exiting. Failed to retrieve template." self.log.error(msg) - self.ansible_module.fail_json(msg, **self._results.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) self.template = {} self.template["parameters"] = self.response_current.get("DATA", {}).get( "parameters", [] ) + + @property + def results(self): + """ + An instance of the Results class. + """ + return self.properties["results"] + + @results.setter + def results(self, value): + method_name = inspect.stack()[0][3] + self.properties["results"] = value diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index b40cae10c..079b61272 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -47,19 +47,22 @@ class FabricUpdateCommon(FabricCommon): def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ + self.action = "update" self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.check_mode = self.ansible_module.check_mode msg = "ENTERED FabricUpdateCommon(): " - msg += f"check_mode: {self.check_mode}" + msg += f"action: {self.action}, " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" self.log.debug(msg) self.fabric_details = FabricDetailsByName(self.ansible_module) self._fabric_summary = FabricSummary(self.ansible_module) self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) - self._verify_params = VerifyPlaybookParams(self.ansible_module) + # self._verify_params = VerifyPlaybookParams(self.ansible_module) + # 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 @@ -74,14 +77,7 @@ def __init__(self, ansible_module): # Updated in _build_fabrics_to_config_save() self._fabrics_to_config_save = [] - self.action = "update" self._payloads_to_commit = [] - self.response_ok = [] - self.result_ok = [] - self.diff_ok = [] - self.response_nok = [] - self.result_nok = [] - self.diff_nok = [] # Number of successful fabric update payloads # Used to determine if all fabric updates were successful @@ -122,7 +118,7 @@ def _verify_payload(self, payload): msg += "payload must be a dict. " msg += f"Got type {type(payload).__name__}, " msg += f"value {payload}" - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) missing_keys = [] for key in self._mandatory_payload_keys: @@ -133,8 +129,9 @@ def _verify_payload(self, payload): msg = f"{self.class_name}.{method_name}: " msg += "payload is missing mandatory keys: " - msg += f"{sorted(missing_keys)}" - self.ansible_module.fail_json(msg, **self.failed_result) + msg += f"{sorted(missing_keys)}. " + msg += f"payload: {sorted(payload)}" + self.ansible_module.fail_json(msg, **self.results.failed_result) def _build_payloads_to_commit(self): """ @@ -174,35 +171,21 @@ 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, populate the following lists: - - - self.response_ok : list of controller responses associated with success result - - self.result_ok : list of results where success is True - - self.diff_ok : list of payloads for which the request succeeded - - self.response_nok : list of controller responses associated with failed result - - self.result_nok : list of results where success is False - - self.diff_nok : list of payloads for which the request failed + In both cases, update results """ self.rest_send.check_mode = self.check_mode - self.response_ok = [] - self.result_ok = [] - self.diff_ok = [] - self.response_nok = [] - self.result_nok = [] - self.diff_nok = [] - self._build_fabrics_to_config_deploy() self._fixup_payloads_to_commit() for payload in self._payloads_to_commit: self._send_payload(payload) # Skip config-save if any errors were encountered with fabric updates. - if len(self.result_nok) != 0: + if len(self.results.result_nok) != 0: return self._config_save() # Skip config-deploy if any errors were encountered with config-save. - if len(self.result_nok) != 0: + if len(self.results.result_nok) != 0: return self._config_deploy() @@ -284,13 +267,14 @@ def _send_payload(self, payload): self.rest_send.commit() if self.rest_send.result_current["success"]: - self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_ok.append(copy.deepcopy(payload)) + self.results.changed = True + self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_ok.append(copy.deepcopy(payload)) else: - self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_nok.append(copy.deepcopy(payload)) + self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_nok.append(copy.deepcopy(payload)) def _config_save(self): """ @@ -306,7 +290,7 @@ def _config_save(self): self.path = self.endpoints.fabric_config_save.get("path") self.verb = self.endpoints.fabric_config_save.get("verb") except ValueError as error: - self.ansible_module.fail_json(error, **self.failed_result) + self.ansible_module.fail_json(error, **self.results.failed_result) self.rest_send.path = self.path self.rest_send.verb = self.verb @@ -314,13 +298,14 @@ def _config_save(self): self.rest_send.commit() if self.rest_send.result_current["success"]: - self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_ok.append({"FABRIC_NAME": fabric_name, "config_save": "OK"}) + self.results.changed = True + self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_ok.append({"FABRIC_NAME": fabric_name, "config_save": "OK"}) else: - self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_nok.append( + self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_nok.append( copy.deepcopy({"FABRIC_NAME": fabric_name, "config_save": "FAILED"}) ) @@ -338,7 +323,7 @@ def _config_deploy(self): self.path = self.endpoints.fabric_config_deploy.get("path") self.verb = self.endpoints.fabric_config_deploy.get("verb") except ValueError as error: - self.ansible_module.fail_json(error, **self.failed_result) + self.ansible_module.fail_json(error, **self.results.failed_result) self.rest_send.path = self.path self.rest_send.verb = self.verb @@ -346,91 +331,19 @@ def _config_deploy(self): self.rest_send.commit() if self.rest_send.result_current["success"]: - self.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_ok.append({"FABRIC_NAME": fabric_name, "config_deploy": "OK"}) + self.results.changed = True + self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_ok.append({"FABRIC_NAME": fabric_name, "config_deploy": "OK"}) else: - self.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.diff_nok.append( + self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) + self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) + self.results.diff_nok.append( copy.deepcopy( {"FABRIC_NAME": fabric_name, "config_deploy": "FAILED"} ) ) - def _process_responses(self): - method_name = inspect.stack()[0][3] - - # All requests succeeded, set changed to True and return - if len(self.result_nok) == 0: - self.changed = True - for diff in self.diff_ok: - diff["action"] = self.action - self.diff = copy.deepcopy(diff) - for result in self.result_ok: - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_ok: - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - return - - # At least one request failed. - # Set failed to true, set changed appropriately, - # build response/result/diff, and call fail_json - self.failed = True - self.changed = False - # At least one request succeeded, so set changed to True - if self.result_ok != 0: - self.changed = True - - # Provide the results for all (failed and successful) requests - - # Add an "OK" result to the response(s) that succeeded - for diff in self.diff_ok: - diff["action"] = self.action - diff["result"] = "OK" - self.diff = copy.deepcopy(diff) - for result in self.result_ok: - result["result"] = "OK" - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_ok: - response["result"] = "OK" - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - - # Add a "FAILED" result to the response(s) that failed - for diff in self.diff_nok: - diff["action"] = self.action - diff["result"] = "FAILED" - self.diff = copy.deepcopy(diff) - for result in self.result_nok: - result["result"] = "FAILED" - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_nok: - response["result"] = "FAILED" - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - - result = {} - result["diff"] = {} - result["response"] = {} - result["result"] = {} - result["failed"] = self.failed - result["changed"] = self.changed - result["diff"]["OK"] = self.diff_ok - result["response"]["OK"] = self.response_ok - result["result"]["OK"] = self.result_ok - result["diff"]["FAILED"] = self.diff_nok - result["response"]["FAILED"] = self.response_nok - result["result"]["FAILED"] = self.result_nok - - msg = f"{self.class_name}.{method_name}: " - msg += f"Bad response(s) during fabric {self.action}. " - self.ansible_module.fail_json(msg, **result) - @property def payloads(self): """ @@ -449,7 +362,7 @@ def payloads(self, value): msg += "payloads must be a list of dict. " msg += f"got {type(value).__name__} for " msg += f"value {value}" - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) for item in value: self._verify_payload(item) self.properties["payloads"] = value @@ -485,16 +398,17 @@ def commit(self): if self.payloads is None: msg = f"{self.class_name}.{method_name}: " msg += "payloads must be set prior to calling commit." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) self._build_payloads_to_commit() if len(self._payloads_to_commit) == 0: return self._send_payloads() - self._process_responses() + self.results.action = self.action + self.results.register_task_results() -class FabricUpdate(FabricCommon): +class FabricUpdate(FabricUpdateCommon): """ Update a VXLAN fabric on the controller. """ @@ -527,7 +441,7 @@ def commit(self): self.ansible_module.fail_json(msg) if len(self.payload) == 0: - self.ansible_module.exit_json(**self.failed_result) + self.ansible_module.exit_json(**self.results.failed_result) fabric_name = self.payload.get("FABRIC_NAME") if fabric_name is None: @@ -550,13 +464,15 @@ def commit(self): self.rest_send.payload = self.payload self.rest_send.commit() - self.result_current = self.rest_send.result_current - self.result = self.rest_send.result_current - self.response_current = self.rest_send.response_current - self.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.response_current = self.rest_send.response_current + self.results.response = self.rest_send.response_current - if self.response_current["RETURN_CODE"] == 200: - self.diff = self.payload + if self.results.response_current["RETURN_CODE"] == 200: + self.results.diff = self.payload + self.results.action = self.action + self.results.register_task_results() @property def payload(self): diff --git a/plugins/modules/dcnm_fabric.py b/plugins/modules/dcnm_fabric.py index 8647deeac..de829fd3e 100644 --- a/plugins/modules/dcnm_fabric.py +++ b/plugins/modules/dcnm_fabric.py @@ -218,6 +218,8 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ RestSend +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + Results from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import \ @@ -237,8 +239,6 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import \ VerifyFabricParams -# from ansible_collections.cisco.dcnm.plugins.module_utils.common.merge_dicts import \ -# MergeDicts # from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_merge_defaults import \ # ParamsMergeDefaults # from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.params_spec import \ @@ -254,15 +254,21 @@ def json_pretty(msg): return json.dumps(msg, indent=4, sort_keys=True) -class TaskCommon(FabricCommon): +class Common(FabricCommon): def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self.state = self.ansible_module.params.get("state") + if self.ansible_module.params.get("check_mode") is True: + self.check_mode = True self.log = logging.getLogger(f"dcnm.{self.class_name}") + self.results = Results() + self.results.state = self.state + self.results.check_mode = self.check_mode - msg = "ENTERED TaskCommon(): " + msg = "ENTERED Common(): " msg += f"state: {self.state}, " msg += f"check_mode: {self.check_mode}" self.log.debug(msg) @@ -309,7 +315,7 @@ def get_have(self): } } """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable self.have = FabricDetailsByName(self.ansible_module) self.have.refresh() @@ -333,42 +339,19 @@ def get_want(self) -> None: if len(self.want) == 0: self.ansible_module.exit_json(**self.task_result.module_result) - def update_diff_and_response(self, obj) -> None: - """ - Update the appropriate self.task_result diff and response, - based on the current ansible state, with the diff and - response from obj. - """ - for diff in obj.diff: - if self.state == "deleted": - self.task_result.diff_deleted = copy.deepcopy(diff) - if self.state == "merged": - self.task_result.diff_merged = copy.deepcopy(diff) - if self.state == "query": - self.task_result.diff_query = copy.deepcopy(diff) - - for response in obj.response: - if self.state == "deleted": - self.task_result.response_deleted = copy.deepcopy(response) - if self.state == "merged": - self.task_result.response_merged = copy.deepcopy(response) - if self.state == "query": - self.task_result.response_query = copy.deepcopy(response) - - -class QueryTask(TaskCommon): +class Query(Common): """ - Query state for FabricVxlanTask + Handle query state """ def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED QueryTask(): " + msg = "ENTERED Query(): " msg += f"state: {self.state}, " msg += f"check_mode: {self.check_mode}" self.log.debug(msg) @@ -379,30 +362,32 @@ def commit(self) -> None: """ 1. query the fabrics in self.want that exist on the controller """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + self.get_want() - method_name = inspect.stack()[0][3] - instance = FabricQuery(self.ansible_module) + + fabric_query = FabricQuery(self.ansible_module) + fabric_query.results = self.results fabric_names_to_query = [] for want in self.want: fabric_names_to_query.append(want["fabric_name"]) - instance.fabric_names = copy.copy(fabric_names_to_query) - instance.commit() - self.update_diff_and_response(instance) - + fabric_query.fabric_names = copy.copy(fabric_names_to_query) + fabric_query.commit() -class DeletedTask(TaskCommon): +class Deleted(Common): """ - deleted state for FabricVxlanTask + Handle deleted state """ - def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED DeletedTask(): " + self.fabric_delete = FabricDelete(self.ansible_module) + + msg = "ENTERED Deleted(): " msg += f"state: {self.state}, " msg += f"check_mode: {self.check_mode}" self.log.debug(msg) @@ -414,32 +399,32 @@ def commit(self) -> None: delete the fabrics in self.want that exist on the controller """ self.get_want() - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + msg = f"{self.class_name}.{method_name}: " msg += "entered" self.log.debug(msg) - instance = FabricDelete(self.ansible_module) + fabric_names_to_delete = [] for want in self.want: fabric_names_to_delete.append(want["fabric_name"]) - instance.fabric_names = fabric_names_to_delete - instance.commit() - self.update_diff_and_response(instance) - + self.fabric_delete.fabric_names = fabric_names_to_delete + self.fabric_delete.results = self.results + self.fabric_delete.commit() -class MergedTask(TaskCommon): +class Merged(Common): """ - Ansible support for Data Center VXLAN EVPN + Handle merged state """ def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED MergedTask(): " + msg = f"ENTERED {self.class_name}.{method_name}: " msg += f"state: {self.state}, " msg += f"check_mode: {self.check_mode}" self.log.debug(msg) @@ -455,9 +440,15 @@ def get_need(self): Build self.need for merged state """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable self.payloads = {} for want in self.want: + if want.get("FABRIC_NAME") is None: + msg = f"{self.class_name}.{method_name}: " + msg += "Skipping config with missing FABRIC_NAME: " + msg += f"{json_pretty(want)}" + self.log.debug(msg) + continue if want["FABRIC_NAME"] not in self.have.all_data: self.need_create.append(want) else: @@ -469,7 +460,7 @@ def commit(self): Commit the merged state request """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable self.log.debug(f"{self.class_name}.{method_name}: entered") self.get_want() @@ -484,7 +475,7 @@ def send_need_create(self) -> None: Build and send the payload to create fabrics specified in the playbook. """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable msg = f"{self.class_name}.{method_name}: entered. " msg += f"self.need_create: {json_pretty(self.need_create)}" self.log.debug(msg) @@ -496,9 +487,9 @@ def send_need_create(self) -> None: return self.fabric_create = FabricCreateBulk(self.ansible_module) + self.fabric_create.results = self.results self.fabric_create.payloads = self.need_create self.fabric_create.commit() - self.update_diff_and_response(self.fabric_create) def send_need_update(self) -> None: """ @@ -506,7 +497,7 @@ def send_need_update(self) -> None: Build and send the payload to create fabrics specified in the playbook. """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable msg = f"{self.class_name}.{method_name}: entered. " msg += f"self.need_update: {json_pretty(self.need_update)}" self.log.debug(msg) @@ -518,9 +509,9 @@ def send_need_update(self) -> None: return self.fabric_update = FabricUpdateBulk(self.ansible_module) + self.fabric_update.results = self.results self.fabric_update.payloads = self.need_update self.fabric_update.commit() - self.update_diff_and_response(self.fabric_update) def main(): @@ -553,22 +544,26 @@ def main(): log.commit() if ansible_module.params["state"] == "merged": - task = MergedTask(ansible_module) + task = Merged(ansible_module) task.commit() elif ansible_module.params["state"] == "deleted": - task = DeletedTask(ansible_module) + task = Deleted(ansible_module) task.commit() elif ansible_module.params["state"] == "query": - task = QueryTask(ansible_module) + task = Query(ansible_module) task.commit() else: # We should never get here since the state parameter has # already been validated. msg = f"Unknown state {task.ansible_module.params['state']}" - task.ansible_module.fail_json(msg) + ansible_module.fail_json(msg) - ansible_module.exit_json(**task.task_result.module_result) + task.results.build_final_result() + if True in task.results.failed: + msg = "Module failed." + ansible_module.fail_json(msg, **task.results.final_result) + ansible_module.exit_json(**task.results.final_result) if __name__ == "__main__": main() From 54a09074b395bd50f9f4a4688f4168a67e7fdcbb Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Sat, 16 Mar 2024 08:50:55 -1000 Subject: [PATCH 010/228] FabricQuery: handle failed result. All: add doc strings Add class-level doc strings with Usage examples to the following classes: - FabricCreateBulk - FabricDelete - FabricQuery - FabricUpdateBulk - Results --- plugins/module_utils/fabric/create.py | 32 ++++++++++++++++++++ plugins/module_utils/fabric/delete.py | 25 +++++++++++++-- plugins/module_utils/fabric/query.py | 42 +++++++++++++++++++++----- plugins/module_utils/fabric/results.py | 30 +++++++++--------- plugins/module_utils/fabric/update.py | 39 +++++++++++++++++++++--- 5 files changed, 139 insertions(+), 29 deletions(-) diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index c816a4c72..34a17de85 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -232,6 +232,38 @@ def payloads(self, value): class FabricCreateBulk(FabricCreateCommon): """ Create fabrics in bulk. Skip any fabrics that already exist. + + Usage: + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import \ + FabricCreateBulk + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + Results + + payloads = [ + { "FABRIC_NAME": "fabric1", "BGP_AS": 65000 }, + { "FABRIC_NAME": "fabric2", "BGP_AS": 65001 } + ] + results = Results() + instance = FabricCreateBulk(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, ansible_module): diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index 6c79e4a12..2fd63b544 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -40,12 +40,31 @@ class FabricDelete(FabricCommon): Usage: + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import \ + FabricDelete + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + Results + instance = FabricDelete(ansible_module) instance.fabric_names = ["FABRIC_1", "FABRIC_2"] + instance.results = self.results instance.commit() - diff = instance.diff # contains list of deleted fabrics - result = instance.result # contains the result(s) of the delete request - response = instance.response # contains the response(s) from the controller + 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, ansible_module): diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py index b9d1686dc..eabcf4975 100644 --- a/plugins/module_utils/fabric/query.py +++ b/plugins/module_utils/fabric/query.py @@ -31,13 +31,31 @@ class FabricQuery(FabricCommon): Query fabrics Usage: + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import FabricQuery + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import Results + results = Results() instance = FabricQuery(ansible_module) instance.fabric_names = ["FABRIC_1", "FABRIC_2"] + instance.results = results instance.commit() - diff = instance.diff # contains the fabric information - result = instance.result # contains the result(s) of the query - response = instance.response # contains the response(s) from the controller + 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, ansible_module): @@ -102,10 +120,22 @@ def _get_fabrics_to_query(self) -> None: self._fabric_details.refresh() self._fabrics_to_query = [] + + if self._fabric_details.result_current.get("success") is False: + self.results.failed = True + self.results.changed = False + self.results.response_nok.append(copy.deepcopy(self._fabric_details.response_current)) + self.results.result_nok.append(copy.deepcopy(self._fabric_details.result_current)) + return + for fabric_name in self.fabric_names: if fabric_name in self._fabric_details.all_data: self._fabrics_to_query.append(fabric_name) + if len(self._fabrics_to_query) == 0: + self.results.changed = False + self.results.failed = False + def commit(self): """ query each of the fabrics in self.fabric_names @@ -121,16 +151,14 @@ def commit(self): msg = f"self._fabrics_to_query: {self._fabrics_to_query}" self.log.debug(msg) if len(self._fabrics_to_query) == 0: - self.results.changed = False - self.results.failed = False + # Don't modify results.changed or results.failed here. + # These are set in _get_fabrics_to_query() return msg = f"Populating diff {self._fabrics_to_query}" self.log.debug(msg) for fabric_name in self._fabrics_to_query: - if fabric_name not in self._fabric_details.all_data: - continue fabric = copy.deepcopy(self._fabric_details.all_data[fabric_name]) fabric["action"] = self.action self.results.diff_ok.append(fabric) diff --git a/plugins/module_utils/fabric/results.py b/plugins/module_utils/fabric/results.py index 4a7072fbf..e58b87a6e 100644 --- a/plugins/module_utils/fabric/results.py +++ b/plugins/module_utils/fabric/results.py @@ -29,14 +29,16 @@ class Results: Collect results across tasks. Provides a mechanism to collect results across tasks. The task classes - must do the following: + must support this Results class. Specifically, they must implement the + following: 1. Accept an instantiation of Results - Typically a class property is used for this 2. Populate the Results instance with the results of the task - Typically done by transferring RestSend's responses to the Results instance - 3. Register the results of the task with Results.register_task_results() + 3. Register the results of the task with Results, using: + - Results.register_task_results() - Typically done after the task is complete Results should be instantiated in the main Ansible Task class and passed @@ -118,24 +120,22 @@ def commit(self): ansible_module.exit_json(**task.results.final_result) - # output of the above print() will be a dict with the following structure - # specific keys within the diff and response dictionaries will vary depending - # on the obj properties + # results.final_result will be a dict with the following structure + { "changed": True, # or False + "failed": True, # or False "diff": { - "deleted": [], - "merged": [], - "overridden": [], - "query": [], - "replaced": [] + "OK": [], + "FAILED": [] } "response": { - "deleted": [], - "merged": [], - "overridden": [], - "query": [], - "replaced": [] + "OK": [], + "FAILED": [] + } + "result": { + "OK": [], + "FAILED": [] } } """ diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index 079b61272..529f345c5 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -370,7 +370,39 @@ def payloads(self, value): class FabricUpdateBulk(FabricUpdateCommon): """ - Create fabrics in bulk. Skip any fabrics that already exist. + Update fabrics in bulk. + + Usage: + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import \ + FabricUpdateBulk + from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + Results + + payloads = [ + { "FABRIC_NAME": "fabric1", "BGP_AS": 65000, "DEPLOY": True }, + { "FABRIC_NAME": "fabric2", "BGP_AS": 65001, "DEPLOY: False } + ] + results = Results() + instance = FabricUpdateBulk(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 update(s) failed." + ansible_module.fail_json(msg, **task.results.final_result) + ansible_module.exit_json(**task.results.final_result) """ def __init__(self, ansible_module): @@ -391,8 +423,7 @@ def _build_properties(self): def commit(self): """ - create fabrics. Skip any fabrics that already exist - on the controller, + Update fabrics. """ method_name = inspect.stack()[0][3] if self.payloads is None: @@ -410,7 +441,7 @@ def commit(self): class FabricUpdate(FabricUpdateCommon): """ - Update a VXLAN fabric on the controller. + Update a fabric on the controller. """ def __init__(self, ansible_module): From 36c180a892c512314e81df20196e053ad1227ec5 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Sun, 17 Mar 2024 15:11:33 -1000 Subject: [PATCH 011/228] Make results more like existing module results Previously, results contained an "OK" and "FAILED" key for each of results, response, diff. Results for existing modules do not have this. So, we've changed the results for dcnm_fabric to more closely match the results of existing modules. We've still retained the unique sequence_number for each of result, response, and diff entries so that they can be correlated. Also: Move template_parse_each_fabric.py from module_utils/fabric to module_utils/fabric/vxlan --- plugins/module_utils/fabric/create.py | 47 ++-- plugins/module_utils/fabric/delete.py | 36 ++- plugins/module_utils/fabric/query.py | 59 ++--- plugins/module_utils/fabric/results.py | 221 ++++++++++-------- plugins/module_utils/fabric/update.py | 116 +++++---- .../{ => vxlan}/template_parse_easy_fabric.py | 35 ++- 6 files changed, 285 insertions(+), 229 deletions(-) rename plugins/module_utils/fabric/{ => vxlan}/template_parse_easy_fabric.py (90%) diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index 34a17de85..34d476c3a 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -195,15 +195,16 @@ def _send_payloads(self): self.rest_send.payload = payload self.rest_send.commit() - if self.rest_send.result_current["success"]: - self.results.changed = True - self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_ok.append(copy.deepcopy(payload)) + if self.rest_send.result_current["success"] is False: + self.results.diff_current = {} else: - self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_nok.append(copy.deepcopy(payload)) + 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() @property def payloads(self): @@ -298,9 +299,6 @@ def commit(self): return self._fixup_payloads_to_commit() self._send_payloads() - self.results.action = self.action - self.results.register_task_results() - class FabricCreate(FabricCommon): """ @@ -332,7 +330,7 @@ def commit(self): if self.payload is None: msg = f"{self.class_name}.{method_name}: " msg += "Exiting. Missing mandatory property: payload" - self.ansible_module.fail_json(msg) + self.ansible_module.fail_json(msg, **self.results.failed_result) if len(self.payload) == 0: self.ansible_module.exit_json(**self.results.failed_result) @@ -341,14 +339,14 @@ def commit(self): if fabric_name is None: msg = f"{self.class_name}.{method_name}: " msg += "payload is missing mandatory FABRIC_NAME key." - self.ansible_module.fail_json(msg) + self.ansible_module.fail_json(msg, **self.results.failed_result) self.endpoints.fabric_name = fabric_name self.endpoints.template_name = "Easy_Fabric" try: endpoint = self.endpoints.fabric_create except ValueError as error: - self.ansible_module.fail_json(error) + self.ansible_module.fail_json(error, **self.results.failed_result) path = endpoint["path"] verb = endpoint["verb"] @@ -358,16 +356,23 @@ def commit(self): self.rest_send.payload = self.payload self.rest_send.commit() - self.results.result_current = self.rest_send.result_current - self.results.result = self.rest_send.result_current - self.results.response_current = self.rest_send.response_current - self.results.response = self.rest_send.response_current + self.register_result() - if self.results.response_current["RETURN_CODE"] == 200: - self.results.diff = self.payload + def register_result(self): + """ + Register the result of the fabric create request + """ + if self.rest_send.result_current["success"]: + self.results.diff_current = self.payload + else: + self.results.diff_current = {} self.results.action = self.action - self.results.register_task_results() + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.result_current = self.rest_send.result_current + self.results.response_current = self.rest_send.response_current + self.results.register_task_result() @property def payload(self): diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index 2fd63b544..fbbce6a84 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -192,8 +192,15 @@ def commit(self): self.log.debug(msg) if len(self._fabrics_to_delete) != 0: self._send_requests() - self.results.action = "delete" - self.results.register_task_results() + else: + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.diff_current = {} + self.results.result_current = {"success": True, "changed": False} + msg = "No fabrics to delete" + self.results.response_current = {"RETURN_CODE": 200, "MESSAGE": msg} + self.log.debug(msg) def _send_requests(self): """ @@ -230,19 +237,30 @@ def _send_request(self, fabric_name): 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 create request + """ if self.rest_send.result_current["success"]: - self.results.changed = True - self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_ok.append({"fabric_name": fabric_name}) + 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_nok.append(copy.deepcopy(response_current)) - self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_nok.append({"fabric_name": fabric_name}) + + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.response_current = response_current + self.results.result_current = self.rest_send.result_current + + self.results.register_task_result() diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py index eabcf4975..cdbe1ceca 100644 --- a/plugins/module_utils/fabric/query.py +++ b/plugins/module_utils/fabric/query.py @@ -112,30 +112,6 @@ def fabric_names(self, value): self.ansible_module.fail_json(msg) self.properties["fabric_names"] = value - def _get_fabrics_to_query(self) -> None: - """ - Retrieve fabric info from the controller and set the list of - controller fabrics that are in our fabric_names list. - """ - self._fabric_details.refresh() - - self._fabrics_to_query = [] - - if self._fabric_details.result_current.get("success") is False: - self.results.failed = True - self.results.changed = False - self.results.response_nok.append(copy.deepcopy(self._fabric_details.response_current)) - self.results.result_nok.append(copy.deepcopy(self._fabric_details.result_current)) - return - - for fabric_name in self.fabric_names: - if fabric_name in self._fabric_details.all_data: - self._fabrics_to_query.append(fabric_name) - - if len(self._fabrics_to_query) == 0: - self.results.changed = False - self.results.failed = False - def commit(self): """ query each of the fabrics in self.fabric_names @@ -146,24 +122,23 @@ def commit(self): msg += "fabric_names must be set prior to calling commit." self.ansible_module.fail_json(msg, **self.results.failed_result) - self._get_fabrics_to_query() - - msg = f"self._fabrics_to_query: {self._fabrics_to_query}" - self.log.debug(msg) - if len(self._fabrics_to_query) == 0: - # Don't modify results.changed or results.failed here. - # These are set in _get_fabrics_to_query() - return + self._fabric_details.refresh() - msg = f"Populating diff {self._fabrics_to_query}" - self.log.debug(msg) + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state - for fabric_name in self._fabrics_to_query: - fabric = copy.deepcopy(self._fabric_details.all_data[fabric_name]) - fabric["action"] = self.action - self.results.diff_ok.append(fabric) - self.results.response_ok.append(copy.deepcopy(self._fabric_details.response_current)) - self.results.result_ok.append(copy.deepcopy(self._fabric_details.result_current)) + if self._fabric_details.result_current.get("success") is False: + self.results.diff_current = {} + self.results.response_current = copy.deepcopy(self._fabric_details.response_current) + self.results.result_current = copy.deepcopy(self._fabric_details.result_current) + self.results.register_task_result() + return - self.results.action = self.action - self.results.register_task_results() + for fabric_name in self.fabric_names: + if fabric_name not in self._fabric_details.all_data: + continue + self.results.diff_current = copy.deepcopy(self._fabric_details.all_data[fabric_name]) + self.results.response_current = copy.deepcopy(self._fabric_details.response_current) + self.results.result_current = copy.deepcopy(self._fabric_details.result_current) + self.results.register_task_result() diff --git a/plugins/module_utils/fabric/results.py b/plugins/module_utils/fabric/results.py index e58b87a6e..e9d8e077d 100644 --- a/plugins/module_utils/fabric/results.py +++ b/plugins/module_utils/fabric/results.py @@ -19,6 +19,7 @@ __author__ = "Allen Robel" import copy +import json import inspect import logging from typing import Any, Dict @@ -158,9 +159,8 @@ def __init__(self): self.response_nok = [] self.result_nok = [] - # Assign a unique sequence number to each diff to enable tracking - # of the order in which it was executed - self.diff_sequence_number = 0 + # Assign a unique sequence number to each registered task + self.task_sequence_number = 0 self.final_result = {} self._build_properties() @@ -171,7 +171,9 @@ def _build_properties(self): self.properties["changed"] = set() self.properties["check_mode"] = False self.properties["diff"] = [] + self.properties["diff_current"] = {} self.properties["failed"] = set() + self.properties["metadata"] = [] self.properties["response"] = [] self.properties["response_current"] = {} self.properties["response_data"] = [] @@ -179,12 +181,13 @@ def _build_properties(self): self.properties["result_current"] = {} self.properties["state"] = None - def get_diff_sequence_number(self) -> int: + def increment_task_sequence_number(self) -> None: """ - Return a unique sequence number for the current result + Increment a unique task sequence number. """ - self.diff_sequence_number += 1 - return self.diff_sequence_number + self.task_sequence_number += 1 + msg = f"self.task_sequence_number: {self.task_sequence_number}" + self.log.debug(msg) def did_anything_change(self) -> bool: """ @@ -197,107 +200,67 @@ def did_anything_change(self) -> bool: return True return False - def register_task_results(self): + def register_task_result(self): """ - Register a task's results + Register a task's result. + + 1. Append result_current, response_current, diff_current and + metadata_current their respective lists (result, response, diff, + and metadata) + 2. Set self.changed based on current_diff. + If current_diff is empty, it is assumed that no changes were made + and self.changed is set to False. Else, self.changed is set to True. + 3. Set self.failed based on current_result. If current_result["success"] + is True, self.failed is set to False. Else, self.failed is set to True. + 4. Set self.metadata based on current_metadata. - - self.response_ok : list of controller responses associated with success result - - self.result_ok : list of results where success is True - - self.diff_ok : list of payloads for which the request succeeded - - self.response_nok : list of controller responses associated with failed result - - self.result_nok : list of results where success is False - - self.diff_nok : list of payloads for which the request failed + - self.response : list of controller responses + - self.result : list of results returned by the handler + - self.diff : list of diffs + - self.metadata : list of metadata """ method_name = inspect.stack()[0][3] - self.changed = self.did_anything_change() - # All requests succeeded, set changed to True and return - if len(self.result_nok) == 0: + if self.did_anything_change() is False: + self.changed = False + else: + self.changed = True + + if self.result_current.get("success") is True: self.failed = False else: self.failed = True + self.increment_task_sequence_number() + self.metadata = self.metadata_current + self.response = self.response_current + self.result = self.result_current + self.diff = self.diff_current - # Provide the results for all (failed and successful) requests - - # Add a sequence number, action, and "OK" result to the - # response(s) that succeeded - result_string = "OK" - for diff in self.diff_ok: - if diff.get("metadata") is None: - diff["metadata"] = {} - diff["metadata"]["action"] = self.action - diff["metadata"]["check_mode"] = self.check_mode - diff["metadata"]["sequence_number"] = self.get_diff_sequence_number() - diff["metadata"]["result"] = result_string - self.diff = copy.deepcopy(diff) - for result in self.result_ok: - if result.get("metadata") is None: - result["metadata"] = {} - result["metadata"]["action"] = self.action - result["metadata"]["check_mode"] = self.check_mode - result["metadata"]["result"] = result_string - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_ok: - if response.get("metadata") is None: - response["metadata"] = {} - response["metadata"]["action"] = self.action - response["metadata"]["check_mode"] = self.check_mode - response["metadata"]["result"] = result_string - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) - - # Add a "FAILED" result to the response(s) that failed - result_string = "FAILED" - for diff in self.diff_nok: - if diff.get("metadata") is None: - diff["metadata"] = {} - diff["metadata"]["action"] = self.action - diff["metadata"]["check_mode"] = self.check_mode - diff["metadata"]["sequence_number"] = self.get_diff_sequence_number() - diff["metadata"]["result"] = result_string - self.diff = copy.deepcopy(diff) - for result in self.result_nok: - if result.get("metadata") is None: - result["metadata"] = {} - result["metadata"]["action"] = self.action - result["metadata"]["check_mode"] = self.check_mode - result["metadata"]["result"] = result_string - self.result = copy.deepcopy(result) - self.result_current = copy.deepcopy(result) - for response in self.response_nok: - if response.get("metadata") is None: - response["metadata"] = {} - response["metadata"]["action"] = self.action - response["metadata"]["check_mode"] = self.check_mode - response["metadata"]["result"] = result_string - self.response = copy.deepcopy(response) - self.response_current = copy.deepcopy(response) + msg = f"{self.class_name}.{method_name}: " + msg += f"self.metadata: {json.dumps(self.metadata, indent=4, sort_keys=True)}, " + self.log.debug(msg) def build_final_result(self): """ Build the final result """ - self.final_result = {} - self.final_result["diff"] = {} - self.final_result["response"] = {} - self.final_result["result"] = {} + msg = f"self.changed: {self.changed}, " + msg = f"self.failed: {self.failed}, " + self.log.debug(msg) + if True in self.failed: self.final_result["failed"] = True else: self.final_result["failed"] = False - msg = f"self.changed: {self.changed}" - self.log.debug(msg) + if True in self.changed: self.final_result["changed"] = True else: self.final_result["changed"] = False - self.final_result["diff"]["OK"] = self.diff_ok - self.final_result["response"]["OK"] = self.response_ok - self.final_result["result"]["OK"] = self.result_ok - self.final_result["diff"]["FAILED"] = self.diff_nok - self.final_result["response"]["FAILED"] = self.response_nok - self.final_result["result"]["FAILED"] = self.result_nok + self.final_result["diff"] = self.diff + self.final_result["response"] = self.response + self.final_result["result"] = self.result + self.final_result["metadata"] = self.metadata @property def failed_result(self) -> Dict[str, Any]: @@ -307,12 +270,9 @@ def failed_result(self) -> Dict[str, Any]: result = {} result["changed"] = False result["failed"] = True - result["diff"] = {} - result["response"] = {} - for key in self.diff_keys: - result["diff"][key] = [] - for key in self.response_keys: - result["response"][key] = [] + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] return result @@ -321,7 +281,7 @@ def action(self): """ Added to results to indicate the action that was taken """ - return self.properties.get("action") + return self.properties["action"] @action.setter def action(self, value): @@ -359,7 +319,7 @@ def check_mode(self): """ check_mode """ - return self.properties.get("check_mode") + return self.properties["check_mode"] @check_mode.setter def check_mode(self, value): @@ -387,7 +347,29 @@ def diff(self, value): msg = f"{self.class_name}.{method_name}: " msg += f"instance.diff must be a dict. Got {value}" raise ValueError(msg) - self.properties["diff"].append(value) + value["sequence_number"] = self.task_sequence_number + self.properties["diff"].append(copy.deepcopy(value)) + + @property + def diff_current(self): + """ + Return the current diff + + This is a dict of the current diff set by the handler. + """ + value = self.properties.get("diff_current") + value["sequence_number"] = self.task_sequence_number + return value + + @diff_current.setter + def diff_current(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "instance.diff_current must be a dict. " + msg += f"Got {value}." + raise ValueError(msg) + self.properties["diff_current"] = value @property def failed(self): @@ -410,6 +392,39 @@ def failed(self, value): raise ValueError(msg) self.properties["failed"].add(value) + @property + def metadata(self): + """ + List of dicts representing the metadata (if any) + for each diff. + + raise ValueError if value is not a dict + """ + return self.properties["metadata"] + + @metadata.setter + def metadata(self, value): + method_name = inspect.stack()[0][3] + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.metadata must be a dict. Got {value}" + raise ValueError(msg) + value["sequence_number"] = self.task_sequence_number + self.properties["metadata"].append(copy.deepcopy(value)) + + @property + def metadata_current(self): + """ + Return the current metadata which is comprised of the + properties action, check_mode, and state. + """ + value = {} + value["action"] = self.action + value["check_mode"] = self.check_mode + value["state"] = self.state + value["sequence_number"] = self.task_sequence_number + return value + @property def response_current(self): """ @@ -418,7 +433,9 @@ def response_current(self): This is a dict of the current response from the controller. """ - return self.properties.get("response_current") + value = self.properties.get("response_current") + value["sequence_number"] = self.task_sequence_number + return value @response_current.setter def response_current(self, value): @@ -448,7 +465,8 @@ def response(self, value): msg += "instance.response must be a dict. " msg += f"Got {value}." raise ValueError(msg) - self.properties["response"].append(value) + value["sequence_number"] = self.task_sequence_number + self.properties["response"].append(copy.deepcopy(value)) @property def response_data(self): @@ -479,7 +497,8 @@ def result(self, value): msg += "instance.result must be a dict. " msg += f"Got {value}." raise ValueError(msg) - self.properties["result"].append(value) + value["sequence_number"] = self.task_sequence_number + self.properties["result"].append(copy.deepcopy(value)) @property def result_current(self): @@ -489,7 +508,9 @@ def result_current(self): This is a dict containing the current result. """ - return self.properties.get("result_current") + value = self.properties.get("result_current") + value["sequence_number"] = self.task_sequence_number + return value @result_current.setter def result_current(self, value): @@ -506,7 +527,7 @@ def state(self): """ Ansible state """ - return self.properties.get("state") + return self.properties["state"] @state.setter def state(self, value): diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index 529f345c5..f6772fb1b 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -89,6 +89,13 @@ def __init__(self, ansible_module): self._mandatory_payload_keys.add("FABRIC_NAME") self._mandatory_payload_keys.add("DEPLOY") + # 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 = {} + def _can_fabric_be_deployed(self, fabric_name): """ return True if the fabric configuration can be saved and deployed @@ -266,15 +273,18 @@ def _send_payload(self, payload): self.rest_send.payload = payload self.rest_send.commit() - if self.rest_send.result_current["success"]: - self.results.changed = True - self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_ok.append(copy.deepcopy(payload)) + if self.rest_send.result_current["success"] is False: + self.results.diff_current = {} else: - self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_nok.append(copy.deepcopy(payload)) + 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() def _config_save(self): """ @@ -284,6 +294,17 @@ def _config_save(self): for fabric_name in self._fabrics_to_config_save: msg = f"{self.class_name}.{method_name}: fabric_name: {fabric_name}" self.log.debug(msg) + if fabric_name not in self.send_payload_result: + # Skip config-save if send_payload failed + msg = f"{self.class_name}.{method_name}: " + msg += f"WARNING: fabric_name: {fabric_name} not in send_payload_result" + self.log.debug(msg) + continue + 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 + continue try: self.endpoints.fabric_name = fabric_name @@ -297,17 +318,20 @@ def _config_save(self): self.rest_send.payload = None self.rest_send.commit() - if self.rest_send.result_current["success"]: - self.results.changed = True - self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_ok.append({"FABRIC_NAME": fabric_name, "config_save": "OK"}) + self.config_save_result[fabric_name] = self.rest_send.result_current["success"] + if self.rest_send.result_current["success"] is False: + self.results.diff_current = {} else: - self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_nok.append( - copy.deepcopy({"FABRIC_NAME": fabric_name, "config_save": "FAILED"}) - ) + self.results.diff_current = {"FABRIC_NAME": fabric_name, "config_save": "OK"} + + self.results.action = "config_save" + 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() + + self.config_save_result = self.rest_send.result_current def _config_deploy(self): """ @@ -318,6 +342,10 @@ def _config_deploy(self): msg = f"{self.class_name}.{method_name}: fabric_name: {fabric_name}" self.log.debug(msg) + if self.config_save_result.get(fabric_name) is False: + # Skip config-deploy if config-save failed + continue + try: self.endpoints.fabric_name = fabric_name self.path = self.endpoints.fabric_config_deploy.get("path") @@ -330,19 +358,19 @@ def _config_deploy(self): self.rest_send.payload = None self.rest_send.commit() - if self.rest_send.result_current["success"]: - self.results.changed = True - self.results.response_ok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_ok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_ok.append({"FABRIC_NAME": fabric_name, "config_deploy": "OK"}) + self.config_deploy_result = self.rest_send.result_current["success"] + if self.rest_send.result_current["success"] is False: + self.results.diff_current = {} + self.results.diff = {"FABRIC_NAME": fabric_name, "config_deploy": "OK"} else: - self.results.response_nok.append(copy.deepcopy(self.rest_send.response_current)) - self.results.result_nok.append(copy.deepcopy(self.rest_send.result_current)) - self.results.diff_nok.append( - copy.deepcopy( - {"FABRIC_NAME": fabric_name, "config_deploy": "FAILED"} - ) - ) + self.results.diff_current = {"FABRIC_NAME": fabric_name, "config_deploy": "OK"} + + self.results.action = "config_deploy" + 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): @@ -431,12 +459,20 @@ def commit(self): msg += "payloads must be set prior to calling commit." self.ansible_module.fail_json(msg, **self.results.failed_result) + self.results.action = self.action + self.results.check_mode = self.check_mode + self.results.state = self.state + self._build_payloads_to_commit() if len(self._payloads_to_commit) == 0: + self.results.diff_current = {} + self.results.result_current = {"success": True} + msg = "No fabrics to create." + self.results.response_current = {"RETURN_CODE": 200, "MESSAGE": msg} + self.results.register_task_result() return self._send_payloads() - self.results.action = self.action - self.results.register_task_results() + class FabricUpdate(FabricUpdateCommon): @@ -495,15 +531,17 @@ def commit(self): self.rest_send.payload = self.payload self.rest_send.commit() - self.results.result_current = self.rest_send.result_current - self.results.result = self.rest_send.result_current - self.results.response_current = self.rest_send.response_current - self.results.response = self.rest_send.response_current + if self.rest_send.result_current["success"] is False: + self.results.diff_current = {} + else: + self.results.diff_current = self.payload - if self.results.response_current["RETURN_CODE"] == 200: - self.results.diff = self.payload self.results.action = self.action - self.results.register_task_results() + self.results.check_mode = self.check_mode + self.results.state = self.state + self.results.result_current = self.rest_send.result_current + self.results.response_current = self.rest_send.response_current + self.results.register_task_result() @property def payload(self): diff --git a/plugins/module_utils/fabric/template_parse_easy_fabric.py b/plugins/module_utils/fabric/vxlan/template_parse_easy_fabric.py similarity index 90% rename from plugins/module_utils/fabric/template_parse_easy_fabric.py rename to plugins/module_utils/fabric/vxlan/template_parse_easy_fabric.py index 613484964..dab137723 100644 --- a/plugins/module_utils/fabric/template_parse_easy_fabric.py +++ b/plugins/module_utils/fabric/vxlan/template_parse_easy_fabric.py @@ -50,7 +50,7 @@ def init_translation(self): This method builds a dictionary which maps between NDFC's expected parameter names and the corresponding playbook names. e.g.: - DEAFULT_QUEUING_POLICY_CLOUDSCALE -> default_queuing_policy_cloudscale + DEAFULT_QUEUING_POLICY_CLOUDSCALE -> DEFAULT_QUEUING_POLICY_CLOUDSCALE The dictionary excludes hidden and internal parameters. """ @@ -61,22 +61,22 @@ def init_translation(self): re_uppercase_dunder = "^[A-Z0-9_]+$" self.translation = {} typo_keys = { - "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "default_queuing_policy_cloudscale", - "DEAFULT_QUEUING_POLICY_OTHER": "default_queuing_policy_other", - "DEAFULT_QUEUING_POLICY_R_SERIES": "default_queuing_policy_r_series", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "DEFAULT_QUEUING_POLICY_CLOUDSCALE", + "DEAFULT_QUEUING_POLICY_OTHER": "DEFAULT_QUEUING_POLICY_OTHER", + "DEAFULT_QUEUING_POLICY_R_SERIES": "DEFAULT_QUEUING_POLICY_R_SERIES", } camel_keys = { - "enableRealTimeBackup": "enable_real_time_backup", - "enableScheduledBackup": "enable_scheduled_backup", - "scheduledTime": "scheduled_time", + "enableRealTimeBackup": "ENABLE_REAL_TIME_BACKUP", + "enableScheduledBackup": "ENABLE_SCHEDULED_BACKUP", + "scheduledTime": "SCHEDULED_TIME", } other_keys = { - "VPC_ENABLE_IPv6_ND_SYNC": "vpc_enable_ipv6_nd_sync", - "default_vrf": "default_vrf", - "default_network": "default_network", - "vrf_extension_template": "vrf_extension_template", - "network_extension_template": "network_extension_template", - "default_pvlan_sec_network": "default_pvlan_sec_network", + "VPC_ENABLE_IPv6_ND_SYNC": "VPC_ENABLE_IPV6_ND_SYNC", + "default_vrf": "DEFAULT_VRF", + "default_network": "DEFAULT_NETWORK", + "vrf_extension_template": "VRF_EXTENSION_TEMPLATE", + "network_extension_template": "NETWORK_EXTENSION_TEMPLATE", + "default_pvlan_sec_network": "DEFAULT_PVLAN_SEC_NETWORK", } for item in self.template.get("parameters"): if self.is_internal(item): @@ -95,8 +95,7 @@ def init_translation(self): if name in other_keys: self.translation[name] = other_keys[name] continue - if re.search(re_uppercase_dunder, name): - self.translation[name] = name.lower() + self.translation[name] = name.upper() def validate_base_prerequisites(self): """ @@ -200,14 +199,14 @@ def build_ruleset(self): Build the ruleset for the EasyFabric template, based on annotations.IsShow in each parameter dictionary. - The ruleset is keyed on parameter name, with values being set of + The ruleset is keyed on parameter name, with values being a set of rules that determine whether a given parameter is mandatory, based on the state of other parameters. Usage: template.build_ruleset() - parameter = "unnum_dhcp_end" + parameter = "UNNUM_DHCP_END" try: result = eval(template.ruleset[parameter]) except: @@ -222,7 +221,7 @@ def build_ruleset(self): continue if self.is_hidden(item): continue - if not item.get("name", None): + if item.get("name", None) is None: continue name = self.translation.get(item["name"], None) if name is None: From fd68445a7e0886ed411074726669c8291e4aa34d Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Sun, 17 Mar 2024 15:12:22 -1000 Subject: [PATCH 012/228] Forgot to commit with last commit Change the import for TemplateParseEasyFabric --- plugins/module_utils/fabric/vxlan/verify_playbook_params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/fabric/vxlan/verify_playbook_params.py b/plugins/module_utils/fabric/vxlan/verify_playbook_params.py index ee57099c8..0db7fee7e 100644 --- a/plugins/module_utils/fabric/vxlan/verify_playbook_params.py +++ b/plugins/module_utils/fabric/vxlan/verify_playbook_params.py @@ -23,7 +23,7 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.template_get import \ TemplateGet -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.template_parse_easy_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.template_parse_easy_fabric import \ TemplateParseEasyFabric From 49aa5eb390d1d83cd9ce250ac2e49bf4a216aff3 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 18 Mar 2024 07:10:06 -1000 Subject: [PATCH 013/228] Move results.py to module_utils/common --- plugins/module_utils/{fabric => common}/results.py | 0 plugins/module_utils/fabric/create.py | 2 +- plugins/module_utils/fabric/delete.py | 2 +- plugins/module_utils/fabric/query.py | 2 +- plugins/module_utils/fabric/template_get_all.py | 2 +- plugins/module_utils/fabric/update.py | 2 +- plugins/modules/dcnm_fabric.py | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename plugins/module_utils/{fabric => common}/results.py (100%) diff --git a/plugins/module_utils/fabric/results.py b/plugins/module_utils/common/results.py similarity index 100% rename from plugins/module_utils/fabric/results.py rename to plugins/module_utils/common/results.py diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index 34d476c3a..f7a9cd4fc 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -237,7 +237,7 @@ class FabricCreateBulk(FabricCreateCommon): Usage: from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import \ FabricCreateBulk - from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ Results payloads = [ diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index fbbce6a84..e2c9774b7 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -42,7 +42,7 @@ class FabricDelete(FabricCommon): from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import \ FabricDelete - from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ Results instance = FabricDelete(ansible_module) diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py index cdbe1ceca..39708be9d 100644 --- a/plugins/module_utils/fabric/query.py +++ b/plugins/module_utils/fabric/query.py @@ -32,7 +32,7 @@ class FabricQuery(FabricCommon): Usage: from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import FabricQuery - from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import Results + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import Results results = Results() instance = FabricQuery(ansible_module) diff --git a/plugins/module_utils/fabric/template_get_all.py b/plugins/module_utils/fabric/template_get_all.py index e985cab0c..4138b6256 100755 --- a/plugins/module_utils/fabric/template_get_all.py +++ b/plugins/module_utils/fabric/template_get_all.py @@ -27,7 +27,7 @@ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ ApiEndpoints -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ Results diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index f6772fb1b..738351ef9 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -403,7 +403,7 @@ class FabricUpdateBulk(FabricUpdateCommon): Usage: from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import \ FabricUpdateBulk - from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ + from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ Results payloads = [ diff --git a/plugins/modules/dcnm_fabric.py b/plugins/modules/dcnm_fabric.py index de829fd3e..f8cd118e4 100644 --- a/plugins/modules/dcnm_fabric.py +++ b/plugins/modules/dcnm_fabric.py @@ -218,7 +218,7 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ RestSend -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.results import \ +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 e213e4bd6b451f905aaee011efad3b9cd9125481 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 18 Mar 2024 08:22:01 -1000 Subject: [PATCH 014/228] Use RestSend from rest_send.py RestSend and Results are now generic enough to use across all of dcnm_image_upgrade, dcnm_image_policy, and dcnm_fabric modules. Hence, we can use module_utils/common/rest_send.py for dcnm_fabric and delete rest_send_fabric.py/ --- plugins/module_utils/common/rest_send.py | 6 +- .../module_utils/common/rest_send_fabric.py | 500 ------------------ plugins/module_utils/fabric/create.py | 2 +- plugins/module_utils/fabric/delete.py | 2 +- plugins/module_utils/fabric/fabric_details.py | 2 +- plugins/module_utils/fabric/fabric_summary.py | 2 +- plugins/module_utils/fabric/template_get.py | 2 +- .../module_utils/fabric/template_get_all.py | 2 +- plugins/module_utils/fabric/update.py | 2 +- plugins/modules/dcnm_fabric.py | 24 +- 10 files changed, 20 insertions(+), 524 deletions(-) delete mode 100644 plugins/module_utils/common/rest_send_fabric.py diff --git a/plugins/module_utils/common/rest_send.py b/plugins/module_utils/common/rest_send.py index 3087b7c10..58ebf6fd4 100644 --- a/plugins/module_utils/common/rest_send.py +++ b/plugins/module_utils/common/rest_send.py @@ -26,8 +26,8 @@ from time import sleep # Using only for its failed_result property -from ansible_collections.cisco.dcnm.plugins.module_utils.image_upgrade.image_upgrade_task_result import \ - ImageUpgradeTaskResult +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import \ dcnm_send @@ -322,7 +322,7 @@ def failed_result(self): """ Return a result for a failed task with no changes """ - return ImageUpgradeTaskResult(self.ansible_module).failed_result + return Results().failed_result @property def path(self): diff --git a/plugins/module_utils/common/rest_send_fabric.py b/plugins/module_utils/common/rest_send_fabric.py deleted file mode 100644 index 638450bbf..000000000 --- a/plugins/module_utils/common/rest_send_fabric.py +++ /dev/null @@ -1,500 +0,0 @@ -# -# 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 -import re -from time import sleep - -# Using only for its failed_result property -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import \ - FabricTaskResult -from ansible_collections.cisco.dcnm.plugins.module_utils.network.dcnm.dcnm import \ - dcnm_send - - -class RestSend: - """ - Send REST requests to the controller with retries, and handle responses. - - Usage (where ansible_module is an instance of AnsibleModule): - - rest_send = RestSend(ansible_module) - rest_send.path = "/rest/top-down/fabrics" - rest_send.verb = "GET" - rest_send.payload = my_payload # Optional - rest_send.commit() - - # list of responses from the controller for this session - response = rest_send.response - # dict with current controller response - response_current = rest_send.response_current - # list of results from the controller for this session - result = rest_send.result - # dict with current controller result - result_current = rest_send.result_current - """ - - def __init__(self, ansible_module): - self.class_name = self.__class__.__name__ - self.ansible_module = ansible_module - - self.log = logging.getLogger(f"dcnm.{self.class_name}") - - self.params = ansible_module.params - - self._valid_verbs = {"GET", "POST", "PUT", "DELETE"} - self.properties = {} - self.properties["check_mode"] = False - self.properties["response"] = [] - self.properties["response_current"] = {} - self.properties["result"] = [] - self.properties["result_current"] = {} - self.properties["send_interval"] = 5 - self.properties["timeout"] = 300 - self.properties["unit_test"] = False - self.properties["verb"] = None - self.properties["path"] = None - self.properties["payload"] = None - - self.check_mode = self.ansible_module.check_mode - self.state = self.params.get("state") - - msg = "ENTERED RestSendFabric(): " - msg += f"state: {self.state}, " - msg += f"check_mode: {self.check_mode}" - self.log.debug(msg) - - def _verify_commit_parameters(self): - if self.verb is None: - msg = f"{self.class_name}._verify_commit_parameters: " - msg += "verb must be set before calling commit()." - self.ansible_module.fail_json(msg, **self.failed_result) - if self.path is None: - msg = f"{self.class_name}._verify_commit_parameters: " - msg += "path must be set before calling commit()." - self.ansible_module.fail_json(msg, **self.failed_result) - - def commit(self): - if self.check_mode is True: - self.commit_check_mode() - else: - self.commit_normal_mode() - - def commit_check_mode(self): - """ - Simulate a dcnm_send() call for check_mode - - Properties read: - self.verb: HTTP verb e.g. GET, POST, PUT, DELETE - self.path: HTTP path e.g. http://controller_ip/path/to/endpoint - self.payload: Optional HTTP payload - - Properties written: - self.properties["response_current"]: raw simulated response - self.properties["result_current"]: result from self._handle_response() method - """ - method_name = inspect.stack()[0][3] - caller = inspect.stack()[1][3] - - msg = f"{self.class_name}.{method_name}: " - msg += f"caller: {caller}. " - msg += f"verb {self.verb}, path {self.path}." - self.log.debug(msg) - - self._verify_commit_parameters() - - self.response_current = {} - self.response_current["RETURN_CODE"] = 200 - self.response_current["METHOD"] = self.verb - self.response_current["REQUEST_PATH"] = self.path - self.response_current["MESSAGE"] = "OK" - self.response_current["CHECK_MODE"] = True - self.response_current["DATA"] = "[simulated-check-mode-response:Success]" - self.result_current = self._handle_response(copy.deepcopy(self.response_current)) - - self.response = copy.deepcopy(self.response_current) - self.result = copy.deepcopy(self.result_current) - - def commit_normal_mode(self): - """ - Call dcnm_send() with retries until successful response or timeout is exceeded. - - Properties read: - self.send_interval: interval between retries (set in ImageUpgradeCommon) - self.timeout: timeout in seconds (set in ImageUpgradeCommon) - self.verb: HTTP verb e.g. GET, POST, PUT, DELETE - self.path: HTTP path e.g. http://controller_ip/path/to/endpoint - self.payload: Optional HTTP payload - - Properties written: - self.properties["response"]: raw response from the controller - self.properties["result"]: result from self._handle_response() method - """ - method_name = inspect.stack()[0][3] - caller = inspect.stack()[1][3] - - self._verify_commit_parameters() - try: - timeout = self.timeout - except AttributeError: - timeout = 300 - - success = False - msg = f"{caller}: Entering commit loop. " - self.log.debug(msg) - - while timeout > 0 and success is False: - msg = f"{self.class_name}.{method_name}: " - msg += f"caller: {caller}. " - msg += f"Calling dcnm_send: verb {self.verb}, path {self.path}" - if self.payload is None: - self.log.debug(msg) - self.response_current = dcnm_send(self.ansible_module, self.verb, self.path) - else: - msg += f", payload: " - msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" - self.log.debug(msg) - self.response_current = dcnm_send( - self.ansible_module, - self.verb, - self.path, - data=json.dumps(self.payload), - ) - self.result_current = self._handle_response(self.response_current) - - success = self.result_current["success"] - if success is False and self.unit_test is False: - sleep(self.send_interval) - timeout -= self.send_interval - - self.response_current = self._strip_invalid_json_from_response_data( - self.response_current - ) - - self.response = copy.deepcopy(self.response_current) - self.result = copy.deepcopy(self.result_current) - - def _strip_invalid_json_from_response_data(self, response): - """ - Strip "Invalid JSON response:" from response["DATA"] if present - - This just clutters up the output and is not useful to the user. - """ - if "DATA" not in response: - return response - if not isinstance(response["DATA"], str): - return response - response["DATA"] = re.sub(r"Invalid JSON response:\s*", "", response["DATA"]) - return response - - def _handle_response(self, response): - """ - Call the appropriate handler for response based on verb - """ - if self.verb == "GET": - return self._handle_get_response(response) - if self.verb in {"POST", "PUT", "DELETE"}: - return self._handle_post_put_delete_response(response) - return self._handle_unknown_request_verbs(response) - - def _handle_unknown_request_verbs(self, response): - method_name = inspect.stack()[0][3] - - msg = f"{self.class_name}.{method_name}: " - msg += f"Unknown request verb ({self.verb}) for response {response}." - self.ansible_module.fail_json(msg, **self.failed_result) - - def _handle_get_response(self, response): - """ - Caller: - - self._handle_response() - Handle controller responses to GET requests - Returns: dict() with the following keys: - - found: - - False, if request error was "Not found" and RETURN_CODE == 404 - - True otherwise - - success: - - False if RETURN_CODE != 200 or MESSAGE != "OK" - - True otherwise - """ - result = {} - success_return_codes = {200, 404} - if ( - response.get("RETURN_CODE") == 404 - and response.get("MESSAGE") == "Not Found" - ): - result["found"] = False - result["success"] = True - return result - if ( - response.get("RETURN_CODE") not in success_return_codes - or response.get("MESSAGE") != "OK" - ): - result["found"] = False - result["success"] = False - return result - result["found"] = True - result["success"] = True - return result - - def _handle_post_put_delete_response(self, response): - """ - Caller: - - self.self._handle_response() - - Handle POST, PUT responses from the controller. - - Returns: dict() with the following keys: - - changed: - - True if changes were made to by the controller - - False otherwise - - success: - - False if RETURN_CODE != 200 or MESSAGE != "OK" - - True otherwise - """ - result = {} - if response.get("ERROR") is not None: - result["success"] = False - result["changed"] = False - return result - if response.get("MESSAGE") != "OK" and response.get("MESSAGE") is not None: - result["success"] = False - result["changed"] = False - return result - result["success"] = True - result["changed"] = True - return result - - @property - def check_mode(self): - """ - Determines if dcnm_send should be called. - - Default: False - - If False, dcnm_send is called. Real controller responses - are returned by RestSend() - - If True, dcnm_send is not called. Simulated controller responses - are returned by RestSend() - - Discussion: - We don't set check_mode from the value of self.ansible_module.check_mode - because we want to be able to read data from the controller even when - self.ansible_module.check_mode is True. For example, SwitchIssuDetails - is a read-only operation, and we want to be able to read this data - to provide a realistic simulation of stage, validate, and upgrade - tasks. - """ - return self.properties.get("check_mode") - - @check_mode.setter - def check_mode(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, bool): - msg = f"{self.class_name}.{method_name}: " - msg += f"{method_name} must be a bool(). Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["check_mode"] = value - - @property - def failed_result(self): - """ - Return a result for a failed task with no changes - """ - return FabricTaskResult(self.ansible_module).failed_result - - @property - def path(self): - """ - Endpoint path for the REST request. - e.g. "/appcenter/cisco/ndfc/api/v1/...etc..." - """ - return self.properties.get("path") - - @path.setter - def path(self, value): - self.properties["path"] = value - - @property - def payload(self): - """ - Return the payload to send to the controller - """ - return self.properties["payload"] - - @payload.setter - def payload(self, value): - self.properties["payload"] = value - - @property - def response_current(self): - """ - Return the current POST response from the controller - instance.commit() must be called first. - - This is a dict of the current response from the controller. - """ - return copy.deepcopy(self.properties.get("response_current")) - - @response_current.setter - def response_current(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += "instance.response_current must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["response_current"] = value - - @property - def response(self): - """ - Return the aggregated POST response from the controller - instance.commit() must be called first. - - This is a list of responses from the controller. - """ - return copy.deepcopy(self.properties.get("response")) - - @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 += "instance.response must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["response"].append(value) - - @property - def result(self): - """ - Return the aggregated result from the controller - instance.commit() must be called first. - - This is a list of results from the controller. - """ - return copy.deepcopy(self.properties.get("result")) - - @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 += "instance.result must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["result"].append(value) - - @property - def result_current(self): - """ - Return the current result from the controller - instance.commit() must be called first. - - This is a dict containing the current result. - """ - return copy.deepcopy(self.properties.get("result_current")) - - @result_current.setter - def result_current(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += "instance.result_current must be a dict. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["result_current"] = value - - @property - def send_interval(self): - """ - Send interval, in seconds, for retrying responses from the controller. - Valid values: int() - Default: 5 - """ - return self.properties.get("send_interval") - - @send_interval.setter - def send_interval(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, int): - msg = f"{self.class_name}.{method_name}: " - msg += f"{method_name} must be an int(). Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["send_interval"] = value - - @property - def timeout(self): - """ - Timeout, in seconds, for retrieving responses from the controller. - Valid values: int() - Default: 300 - """ - return self.properties.get("timeout") - - @timeout.setter - def timeout(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, int): - msg = f"{self.class_name}.{method_name}: " - msg += f"{method_name} must be an int(). Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["timeout"] = value - - @property - def unit_test(self): - """ - Is the class running under a unit test. - Set this to True in unit tests to speed the test up. - Default: False - """ - return self.properties.get("unit_test") - - @unit_test.setter - def unit_test(self, value): - method_name = inspect.stack()[0][3] - if not isinstance(value, bool): - msg = f"{self.class_name}.{method_name}: " - msg += f"{method_name} must be a bool(). Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["unit_test"] = value - - @property - def verb(self): - """ - Verb for the REST request. - One of "GET", "POST", "PUT", "DELETE" - """ - return self.properties.get("verb") - - @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 += f"{method_name} must be one of {sorted(self._valid_verbs)}. " - msg += f"Got {value}." - self.ansible_module.fail_json(msg, **self.failed_result) - self.properties["verb"] = value diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index f7a9cd4fc..71950290a 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -22,7 +22,7 @@ import inspect import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index e2c9774b7..7816fcdb9 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -20,7 +20,7 @@ import inspect import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py index bc4f76833..e24b459c5 100644 --- a/plugins/module_utils/fabric/fabric_details.py +++ b/plugins/module_utils/fabric/fabric_details.py @@ -22,7 +22,7 @@ import inspect import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon diff --git a/plugins/module_utils/fabric/fabric_summary.py b/plugins/module_utils/fabric/fabric_summary.py index 2efc35edc..dae007090 100644 --- a/plugins/module_utils/fabric/fabric_summary.py +++ b/plugins/module_utils/fabric/fabric_summary.py @@ -23,7 +23,7 @@ import json import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon diff --git a/plugins/module_utils/fabric/template_get.py b/plugins/module_utils/fabric/template_get.py index 60c91f2a0..e64923d87 100755 --- a/plugins/module_utils/fabric/template_get.py +++ b/plugins/module_utils/fabric/template_get.py @@ -23,7 +23,7 @@ import logging from typing import Any, Dict -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ ApiEndpoints diff --git a/plugins/module_utils/fabric/template_get_all.py b/plugins/module_utils/fabric/template_get_all.py index 4138b6256..c5b86dd7a 100755 --- a/plugins/module_utils/fabric/template_get_all.py +++ b/plugins/module_utils/fabric/template_get_all.py @@ -23,7 +23,7 @@ import logging from typing import Any, Dict -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.endpoints import \ ApiEndpoints diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index 738351ef9..abbd0dba4 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -23,7 +23,7 @@ import json import logging -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon diff --git a/plugins/modules/dcnm_fabric.py b/plugins/modules/dcnm_fabric.py index f8cd118e4..b56bbda90 100644 --- a/plugins/modules/dcnm_fabric.py +++ b/plugins/modules/dcnm_fabric.py @@ -13,7 +13,6 @@ # 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 @@ -216,7 +215,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.dcnm.plugins.module_utils.common.log import Log -from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send_fabric import \ +from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ Results @@ -230,15 +229,12 @@ ApiEndpoints from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_task_result import \ - FabricTaskResult from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import \ FabricQuery from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import \ FabricUpdateBulk from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_fabric_params import \ VerifyFabricParams - # from ansible_collections.cisco.dcnm.plugins.module_utils.common.params_merge_defaults import \ # ParamsMergeDefaults # from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.params_spec import \ @@ -286,16 +282,14 @@ def __init__(self, ansible_module): self.config = ansible_module.params.get("config") if not isinstance(self.config, list): msg = "expected list type for self.config. " - msg = f"got {type(self.config).__name__}" - self.ansible_module.fail_json(msg, **self.failed_result) + msg += f"got {type(self.config).__name__}" + self.ansible_module.fail_json(msg, **self.rest_send.failed_result) self.validated = [] self.have = {} self.want = [] self.query = [] - self.task_result = FabricTaskResult(self.ansible_module) - def get_have(self): """ Caller: main() @@ -326,7 +320,7 @@ def get_want(self) -> None: 1. Validate the playbook configs 2. Update self.want with the playbook configs """ - method_name = inspect.stack()[0][3] + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable merged_configs = [] for config in self.config: merged_configs.append(copy.deepcopy(config)) @@ -335,9 +329,6 @@ def get_want(self) -> None: for config in merged_configs: self.want.append(copy.deepcopy(config)) - # Exit if there's nothing to do - if len(self.want) == 0: - self.ansible_module.exit_json(**self.task_result.module_result) class Query(Common): """ @@ -374,10 +365,12 @@ def commit(self) -> None: fabric_query.fabric_names = copy.copy(fabric_names_to_query) fabric_query.commit() + class Deleted(Common): """ Handle deleted state """ + def __init__(self, ansible_module): self.class_name = self.__class__.__name__ super().__init__(ansible_module) @@ -412,6 +405,7 @@ def commit(self) -> None: self.fabric_delete.results = self.results self.fabric_delete.commit() + class Merged(Common): """ Handle merged state @@ -461,7 +455,8 @@ def commit(self): Commit the merged state request """ method_name = inspect.stack()[0][3] # pylint: disable=unused-variable - self.log.debug(f"{self.class_name}.{method_name}: entered") + msg = f"{self.class_name}.{method_name}: entered" + self.log.debug(msg) self.get_want() self.get_have() @@ -565,5 +560,6 @@ def main(): ansible_module.fail_json(msg, **task.results.final_result) ansible_module.exit_json(**task.results.final_result) + if __name__ == "__main__": main() From 36138e5685ff9d16034f18b61df9c120926c85f5 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 25 Mar 2024 15:50:48 -1000 Subject: [PATCH 015/228] FabricQuery().commit() simplify logic, more... - Add unit tests for: -- FabricQuery() -- FabricCreate() - Copy the following from dcnm_image_upgrade/plugins/module_utils/common -- rest_send.py -- results.py --- plugins/module_utils/common/rest_send.py | 27 +- plugins/module_utils/common/results.py | 113 ++- plugins/module_utils/fabric/common.py | 1 - plugins/module_utils/fabric/create.py | 35 +- plugins/module_utils/fabric/delete.py | 60 +- plugins/module_utils/fabric/fabric_details.py | 19 +- plugins/module_utils/fabric/query.py | 24 +- plugins/module_utils/fabric/update.py | 11 +- plugins/modules/dcnm_fabric.py | 72 +- .../unit/modules/dcnm/dcnm_fabric/fixture.py | 50 ++ .../fixtures/payloads_FabricCreateBulk.json | 38 ++ .../fixtures/response_current_RestSend.json | 642 ++++++++++++++++++ .../fixtures/responses_FabricCreateBulk.json | 317 +++++++++ .../fixtures/responses_FabricDetails.json | 332 +++++++++ .../dcnm_fabric/test_fabric_create_bulk.py | 446 ++++++++++++ .../dcnm/dcnm_fabric/test_fabric_query.py | 534 +++++++++++++++ tests/unit/modules/dcnm/dcnm_fabric/utils.py | 311 +++++++++ 17 files changed, 2915 insertions(+), 117 deletions(-) create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixture.py create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixtures/response_current_RestSend.json create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricCreateBulk.json create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/utils.py diff --git a/plugins/module_utils/common/rest_send.py b/plugins/module_utils/common/rest_send.py index 58ebf6fd4..9fd433e9e 100644 --- a/plugins/module_utils/common/rest_send.py +++ b/plugins/module_utils/common/rest_send.py @@ -95,6 +95,12 @@ def _verify_commit_parameters(self): self.ansible_module.fail_json(msg, **self.failed_result) def commit(self): + """ + Send the REST request to the controller + """ + msg = f"{self.class_name}.commit: " + msg += f"check_mode: {self.check_mode}." + self.log.debug(msg) if self.check_mode is True: self.commit_check_mode() else: @@ -130,7 +136,9 @@ def commit_check_mode(self): self.response_current["MESSAGE"] = "OK" self.response_current["CHECK_MODE"] = True self.response_current["DATA"] = "[simulated-check-mode-response:Success]" - self.result_current = self._handle_response(copy.deepcopy(self.response_current)) + self.result_current = self._handle_response( + copy.deepcopy(self.response_current) + ) self.response = copy.deepcopy(self.response_current) self.result = copy.deepcopy(self.result_current) @@ -161,6 +169,7 @@ def commit_normal_mode(self): success = False msg = f"{caller}: Entering commit loop. " + msg += f"timeout: {timeout}, unit_test: {self.unit_test}." self.log.debug(msg) while timeout > 0 and success is False: @@ -169,9 +178,11 @@ def commit_normal_mode(self): msg += f"Calling dcnm_send: verb {self.verb}, path {self.path}" if self.payload is None: self.log.debug(msg) - self.response_current = dcnm_send(self.ansible_module, self.verb, self.path) + self.response_current = dcnm_send( + self.ansible_module, self.verb, self.path + ) else: - msg += f", payload: " + msg += ", payload: " msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" self.log.debug(msg) self.response_current = dcnm_send( @@ -182,6 +193,11 @@ def commit_normal_mode(self): ) self.result_current = self._handle_response(self.response_current) + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += f"result_current: {json.dumps(self.result_current, indent=4, sort_keys=True)}." + self.log.debug(msg) + success = self.result_current["success"] if success is False and self.unit_test is False: sleep(self.send_interval) @@ -190,6 +206,11 @@ def commit_normal_mode(self): self.response_current = self._strip_invalid_json_from_response_data( self.response_current ) + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += "response_current: " + msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}." + self.log.debug(msg) self.response = copy.deepcopy(self.response_current) self.result = copy.deepcopy(self.result_current) diff --git a/plugins/module_utils/common/results.py b/plugins/module_utils/common/results.py index e9d8e077d..6d174be44 100644 --- a/plugins/module_utils/common/results.py +++ b/plugins/module_utils/common/results.py @@ -19,8 +19,8 @@ __author__ = "Allen Robel" import copy -import json import inspect +import json import logging from typing import Any, Dict @@ -50,7 +50,6 @@ class Results: TaskDelete() class. The Results instance can then be used to build the final result, by calling Results.build_final_result(). - Example Usage: We assume an Ansible module structure as follows: @@ -127,18 +126,41 @@ def commit(self): "changed": True, # or False "failed": True, # or False "diff": { - "OK": [], - "FAILED": [] + [], } "response": { - "OK": [], - "FAILED": [] + [], } "result": { - "OK": [], - "FAILED": [] + [], + } + "metadata": { + [], } } + + diff, response, and result dicts are per the Ansible DCNM Collection standard output. + + An example of a result dict would be (sequence_number is added by Results): + + { + "found": true, + "sequence_number": 0, + "success": true + } + + An examplke of a metadata dict would be (sequence_number is added by Results): + + { + "action": "merge", + "check_mode": false, + "state": "merged", + "sequence_number": 0 + } + + sequence_number indicates the order in which the task was registered with Results. + It provides a way to correlate the diff, response, result, and metadata across all + tasks. """ def __init__(self): @@ -164,7 +186,7 @@ def __init__(self): self.final_result = {} self._build_properties() - + def _build_properties(self): self.properties: Dict[str, Any] = {} self.properties["action"] = None @@ -191,10 +213,24 @@ def increment_task_sequence_number(self) -> None: def did_anything_change(self) -> bool: """ - return True if there were any changes + Return True if there were any changes + Otherwise, return False """ + msg = f"{self.class_name}.did_anything_change(): ENTERED: " + msg += f"self.action: {self.action}, " + msg += f"self.result_current: {self.result_current}, " + msg += f"self.diff: {self.diff}" + self.log.debug(msg) if self.check_mode is True: - self.log.debug("check_mode is True. No changes made.") + return False + if self.action == "query": + msg = f"{self.class_name}.did_anything_change(): " + msg += f"self.action: {self.action}" + self.log.debug(msg) + return False + if self.result_current.get("changed", None) is True: + return True + if self.result_current.get("changed", None) is False: return False if len(self.diff) != 0: return True @@ -205,7 +241,7 @@ def register_task_result(self): Register a task's result. 1. Append result_current, response_current, diff_current and - metadata_current their respective lists (result, response, diff, + metadata_current their respective lists (result, response, diff, and metadata) 2. Set self.changed based on current_diff. If current_diff is empty, it is assumed that no changes were made @@ -221,6 +257,17 @@ def register_task_result(self): """ method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"ENTERED: self.action: {self.action}, " + msg += f"self.result_current: {self.result_current}" + self.log.debug(msg) + + self.increment_task_sequence_number() + self.metadata = self.metadata_current + self.response = self.response_current + self.result = self.result_current + self.diff = self.diff_current + if self.did_anything_change() is False: self.changed = False else: @@ -230,14 +277,21 @@ def register_task_result(self): self.failed = False else: self.failed = True - self.increment_task_sequence_number() - self.metadata = self.metadata_current - self.response = self.response_current - self.result = self.result_current - self.diff = self.diff_current msg = f"{self.class_name}.{method_name}: " - msg += f"self.metadata: {json.dumps(self.metadata, indent=4, sort_keys=True)}, " + msg += f"self.diff: {json.dumps(self.diff, indent=4, sort_keys=True)}, " + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.metadata: {json.dumps(self.metadata, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.response: {json.dumps(self.response, indent=4, sort_keys=True)}, " + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"self.result: {json.dumps(self.result, indent=4, sort_keys=True)}, " self.log.debug(msg) def build_final_result(self): @@ -248,12 +302,12 @@ def build_final_result(self): msg = f"self.failed: {self.failed}, " self.log.debug(msg) - if True in self.failed: + if True in self.failed: # pylint: disable=unsupported-membership-test self.final_result["failed"] = True else: self.final_result["failed"] = False - if True in self.changed: + if True in self.changed: # pylint: disable=unsupported-membership-test self.final_result["changed"] = True else: self.final_result["changed"] = False @@ -275,6 +329,18 @@ def failed_result(self) -> Dict[str, Any]: result["result"] = [{}] return result + @property + def ok_result(self) -> Dict[str, Any]: + """ + return a result for a successful task with no changes + """ + result = {} + result["changed"] = False + result["failed"] = False + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] + return result @property def action(self): @@ -297,7 +363,7 @@ def action(self, value): self.properties["action"] = value @property - def changed(self): + def changed(self) -> set: """ bool = whether we changed anything @@ -372,7 +438,7 @@ def diff_current(self, value): self.properties["diff_current"] = value @property - def failed(self): + def failed(self) -> set: """ A set() of Boolean values indicating whether any tasks failed @@ -387,6 +453,9 @@ def failed(self): def failed(self, value): method_name = inspect.stack()[0][3] if not isinstance(value, bool): + # Setting failed, itself failed(!) + # Add True to failed to indicate this. + self.properties["failed"].add(True) msg = f"{self.class_name}.{method_name}: " msg += f"instance.failed must be a bool. Got {value}" raise ValueError(msg) diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index 0e89a4a83..7c9c43766 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -220,5 +220,4 @@ def results(self): @results.setter def results(self, value): - method_name = inspect.stack()[0][3] self.properties["results"] = value diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index 71950290a..65cb36fb0 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -20,6 +20,7 @@ import copy import inspect +import json import logging from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ @@ -48,12 +49,6 @@ def __init__(self, ansible_module): self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED FabricCreateCommon(): " - msg += f"action: {self.action}, " - msg += f"check_mode: {self.check_mode}, " - msg += f"state: {self.state}" - self.log.debug(msg) - self.fabric_details = FabricDetailsByName(self.ansible_module) self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) @@ -72,6 +67,7 @@ def __init__(self, ansible_module): self._mandatory_payload_keys.add("BGP_AS") msg = "ENTERED FabricCreateCommon(): " + msg += f"action: {self.action}, " msg += f"check_mode: {self.check_mode}, " msg += f"state: {self.state}" self.log.debug(msg) @@ -81,6 +77,10 @@ def _verify_payload(self, payload): Verify that the payload is a dict and contains all mandatory keys """ method_name = inspect.stack()[0][3] + 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 += "payload must be a dict. " @@ -128,12 +128,22 @@ def _build_payloads_to_commit(self): """ self.fabric_details.refresh() + msg = f"self.fabric_details.all_data: {json.dumps(self.fabric_details.all_data, indent=4, sort_keys=True)}" + self.log.debug(msg) + self._payloads_to_commit = [] for payload in self.payloads: if payload.get("FABRIC_NAME", None) in self.fabric_details.all_data: continue + + msg = f"payload: {json.dumps(payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + self._payloads_to_commit.append(copy.deepcopy(payload)) + msg = f"self._payloads_to_commit: {json.dumps(self._payloads_to_commit, indent=4, sort_keys=True)}" + self.log.debug(msg) + def _get_endpoint(self): """ Get the endpoint for the fabric create API call. @@ -206,6 +216,9 @@ def _send_payloads(self): 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): """ @@ -219,6 +232,11 @@ def payloads(self): @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. " @@ -295,6 +313,8 @@ def commit(self): self.ansible_module.fail_json(msg, **self.results.failed_result) self._build_payloads_to_commit() + msg = f"self._payloads_to_commit: {json.dumps(self._payloads_to_commit, indent=4, sort_keys=True)}" + self.log.debug(msg) if len(self._payloads_to_commit) == 0: return self._fixup_payloads_to_commit() @@ -319,6 +339,9 @@ def __init__(self, ansible_module): self._init_properties() def _init_properties(self): + """ + Add properties specific to this class + """ # self.properties is already initialized in the parent class self.properties["payload"] = None diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index 7816fcdb9..53f1193e6 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -102,36 +102,6 @@ def _build_properties(self): # self.properties is already set in the parent class self.properties["fabric_names"] = None - @property - def fabric_names(self): - """ - return the fabric names - """ - 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}" - self.ansible_module.fail_json(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}." - self.ansible_module.fail_json(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}" - self.ansible_module.fail_json(msg) - self.properties["fabric_names"] = value - def _get_fabrics_to_delete(self) -> None: """ Retrieve fabric info from the controller and set the list of @@ -264,3 +234,33 @@ def register_result(self, fabric_name): self.results.result_current = self.rest_send.result_current self.results.register_task_result() + + @property + def fabric_names(self): + """ + return the fabric names + """ + 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}" + self.ansible_module.fail_json(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}." + self.ansible_module.fail_json(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}" + self.ansible_module.fail_json(msg) + self.properties["fabric_names"] = value diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py index e24b459c5..23d857e71 100644 --- a/plugins/module_utils/fabric/fabric_details.py +++ b/plugins/module_utils/fabric/fabric_details.py @@ -19,6 +19,7 @@ __author__ = "Allen Robel" import copy +import json import inspect import logging @@ -78,6 +79,14 @@ def refresh_super(self): return for item in self.rest_send.response_current.get("DATA"): self.data[item["fabricName"]] = item + + msg = f"self.data: {json.dumps(self.data, indent=4, sort_keys=True)}" + self.log.debug(msg) + + msg = f"self.rest_send.response_current: " + msg += f"{json.dumps(self.rest_send.response_current, indent=4, sort_keys=True)}" + self.log.debug(msg) + self.response_current = self.rest_send.response_current self.response = self.rest_send.response_current self.result_current = self.rest_send.result_current @@ -96,7 +105,7 @@ def _get_nv_pair(self, item): @property def all_data(self): """ - Return all fabric details from the controller. + Return all fabric details from the controller (i.e. self.data) """ return self.data @@ -241,6 +250,10 @@ def _get(self, item): """ 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 " @@ -269,6 +282,10 @@ def _get_nv_pair(self, item): """ 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 " diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py index 39708be9d..d47e855d0 100644 --- a/plugins/module_utils/fabric/query.py +++ b/plugins/module_utils/fabric/query.py @@ -18,6 +18,7 @@ import copy import inspect +import json import logging from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ @@ -128,17 +129,14 @@ def commit(self): self.results.check_mode = self.check_mode self.results.state = self.state - if self._fabric_details.result_current.get("success") is False: - self.results.diff_current = {} - self.results.response_current = copy.deepcopy(self._fabric_details.response_current) - self.results.result_current = copy.deepcopy(self._fabric_details.result_current) - self.results.register_task_result() - return - + 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 not in self._fabric_details.all_data: - continue - self.results.diff_current = copy.deepcopy(self._fabric_details.all_data[fabric_name]) - self.results.response_current = copy.deepcopy(self._fabric_details.response_current) - self.results.result_current = copy.deepcopy(self._fabric_details.result_current) - self.results.register_task_result() + 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.response_current) + self.results.result_current = copy.deepcopy(self._fabric_details.result_current) + self.results.register_task_result() diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index abbd0dba4..3af10b268 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -51,11 +51,6 @@ def __init__(self, ansible_module): self.log = logging.getLogger(f"dcnm.{self.class_name}") - msg = "ENTERED FabricUpdateCommon(): " - msg += f"action: {self.action}, " - msg += f"check_mode: {self.check_mode}, " - msg += f"state: {self.state}" - self.log.debug(msg) self.fabric_details = FabricDetailsByName(self.ansible_module) self._fabric_summary = FabricSummary(self.ansible_module) @@ -96,6 +91,12 @@ def __init__(self, ansible_module): self.config_deploy_result = {} self.send_payload_result = {} + msg = "ENTERED FabricUpdateCommon(): " + msg += f"action: {self.action}, " + msg += f"check_mode: {self.check_mode}, " + msg += f"state: {self.state}" + self.log.debug(msg) + def _can_fabric_be_deployed(self, fabric_name): """ return True if the fabric configuration can be saved and deployed diff --git a/plugins/modules/dcnm_fabric.py b/plugins/modules/dcnm_fabric.py index b56bbda90..e89fecfd3 100644 --- a/plugins/modules/dcnm_fabric.py +++ b/plugins/modules/dcnm_fabric.py @@ -330,42 +330,6 @@ def get_want(self) -> None: self.want.append(copy.deepcopy(config)) -class Query(Common): - """ - Handle query state - """ - - def __init__(self, ansible_module): - self.class_name = self.__class__.__name__ - super().__init__(ansible_module) - method_name = inspect.stack()[0][3] # pylint: disable=unused-variable - - self.log = logging.getLogger(f"dcnm.{self.class_name}") - - msg = "ENTERED Query(): " - msg += f"state: {self.state}, " - msg += f"check_mode: {self.check_mode}" - self.log.debug(msg) - - self._implemented_states.add("query") - - def commit(self) -> None: - """ - 1. query the fabrics in self.want that exist on the controller - """ - method_name = inspect.stack()[0][3] # pylint: disable=unused-variable - - self.get_want() - - fabric_query = FabricQuery(self.ansible_module) - fabric_query.results = self.results - fabric_names_to_query = [] - for want in self.want: - fabric_names_to_query.append(want["fabric_name"]) - fabric_query.fabric_names = copy.copy(fabric_names_to_query) - fabric_query.commit() - - class Deleted(Common): """ Handle deleted state @@ -509,6 +473,42 @@ def send_need_update(self) -> None: self.fabric_update.commit() +class Query(Common): + """ + Handle query state + """ + + def __init__(self, ansible_module): + self.class_name = self.__class__.__name__ + super().__init__(ansible_module) + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + msg = "ENTERED Query(): " + msg += f"state: {self.state}, " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + self._implemented_states.add("query") + + def commit(self) -> None: + """ + 1. query the fabrics in self.want that exist on the controller + """ + method_name = inspect.stack()[0][3] # pylint: disable=unused-variable + + self.get_want() + + fabric_query = FabricQuery(self.ansible_module) + fabric_query.results = self.results + fabric_names_to_query = [] + for want in self.want: + fabric_names_to_query.append(want["fabric_name"]) + fabric_query.fabric_names = copy.copy(fabric_names_to_query) + fabric_query.commit() + + def main(): """main entry point for module execution""" diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixture.py b/tests/unit/modules/dcnm/dcnm_fabric/fixture.py new file mode 100644 index 000000000..bb3730787 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixture.py @@ -0,0 +1,50 @@ +# 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 + +import json +import os +import sys + +fixture_path = os.path.join(os.path.dirname(__file__), "fixtures") + + +def load_fixture(filename): + """ + load test inputs from json files + """ + path = os.path.join(fixture_path, f"{filename}.json") + + try: + with open(path, encoding="utf-8") as file_handle: + data = file_handle.read() + except IOError as exception: + msg = f"Exception opening test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + try: + fixture = json.loads(data) + except json.JSONDecodeError as exception: + msg = "Exception reading JSON contents in " + msg += f"test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + return fixture diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json new file mode 100644 index 000000000..840fd61b9 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json @@ -0,0 +1,38 @@ +{ + "TEST_NOTES": [ + "Mocked payloads for FabricCreateBulk unit tests.", + "tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py" + ], + "test_fabric_create_bulk_00020a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ], + "test_fabric_create_bulk_00030a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ], + "test_fabric_create_bulk_00031a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ], + "test_fabric_create_bulk_00032a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ] +} \ No newline at end of file diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/response_current_RestSend.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/response_current_RestSend.json new file mode 100644 index 000000000..e83c33e03 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/response_current_RestSend.json @@ -0,0 +1,642 @@ +{ + "test_fabric_query_00030a": { + "DATA": [], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + }, + "test_fabric_query_00031a": { + "DATA": [ + { + "asn": "65002", + "createdOn": 1711389483345, + "deviceType": "n9k", + "fabricId": "FABRIC-2", + "fabricName": "f2", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "modifiedOn": 1711389487198, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "AI_Fabric_QOS_400G", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "true", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "false", + "AUTO_SYMMETRIC_VRF_LITE": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "false", + "BANNER": "", + "BFD_AUTH_ENABLE": "false", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "false", + "BFD_ISIS_ENABLE": "false", + "BFD_OSPF_ENABLE": "false", + "BFD_PIM_ENABLE": "false", + "BGP_AS": "65002", + "BGP_AS_PREV": "65002", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "3", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "#Scope_Start_IP, Scope_End_IP, Scope_Default_Gateway, Scope_Subnet_Prefix", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "queuing_policy_default_8q_cloudscale", + "DEAFULT_QUEUING_POLICY_OTHER": "queuing_policy_default_other", + "DEAFULT_QUEUING_POLICY_R_SERIES": "queuing_policy_default_r_series", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "false", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "DOMAIN_NAME_INTERNAL": "", + "ENABLE_AAA": "false", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "false", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "false", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "false", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f2", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "spine_qos_for_fabric_vpc_peering", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "0001", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "false", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "level-2", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "false", + "ISIS_P2P_ENABLE": "false", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "UNDERLAY", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "0001", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "management", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "cli", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "fd00::a05:0/112", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "false", + "SITE_ID": "65002", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "notstarted", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "false", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "126", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "operStatus": "HEALTHY", + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "65002", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + }, + "test_fabric_query_00032a": { + "DATA": [], + "MESSAGE": "NOK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 500 + }, + "test_fabric_query_00033a": { + "DATA": [ + { + "asn": "65001", + "createdOn": 1711389483525, + "deviceType": "n9k", + "fabricId": "FABRIC-1", + "fabricName": "f1", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "modifiedOn": 1711389487198, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "AI_Fabric_QOS_400G", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "true", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "false", + "AUTO_SYMMETRIC_VRF_LITE": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "false", + "BANNER": "", + "BFD_AUTH_ENABLE": "false", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "false", + "BFD_ISIS_ENABLE": "false", + "BFD_OSPF_ENABLE": "false", + "BFD_PIM_ENABLE": "false", + "BGP_AS": "65001", + "BGP_AS_PREV": "65001", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "3", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "#Scope_Start_IP, Scope_End_IP, Scope_Default_Gateway, Scope_Subnet_Prefix", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "queuing_policy_default_8q_cloudscale", + "DEAFULT_QUEUING_POLICY_OTHER": "queuing_policy_default_other", + "DEAFULT_QUEUING_POLICY_R_SERIES": "queuing_policy_default_r_series", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "false", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "DOMAIN_NAME_INTERNAL": "", + "ENABLE_AAA": "false", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "false", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "false", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "false", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "spine_qos_for_fabric_vpc_peering", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "0001", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "false", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "level-2", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "false", + "ISIS_P2P_ENABLE": "false", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "UNDERLAY", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "0001", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "management", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "cli", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "fd00::a05:0/112", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "false", + "SITE_ID": "65002", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "notstarted", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "false", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "126", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "operStatus": "HEALTHY", + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "65001", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricCreateBulk.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricCreateBulk.json new file mode 100644 index 000000000..a2d19ab2d --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricCreateBulk.json @@ -0,0 +1,317 @@ +{ + "test_fabric_create_bulk_00030a": { + "DATA": { + "asn": "65001", + "deviceType": "n9k", + "fabricId": "FABRIC-2", + "fabricName": "f1", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "", + "AUTO_SYMMETRIC_VRF_LITE": "", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "", + "BANNER": "", + "BFD_AUTH_ENABLE": "", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "", + "BFD_ISIS_ENABLE": "", + "BFD_OSPF_ENABLE": "", + "BFD_PIM_ENABLE": "", + "BGP_AS": "65001", + "BGP_AS_PREV": "", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "", + "DEAFULT_QUEUING_POLICY_OTHER": "", + "DEAFULT_QUEUING_POLICY_R_SERIES": "", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "ENABLE_AAA": "", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "", + "ISIS_P2P_ENABLE": "", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "", + "SITE_ID": "", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + }, + "MESSAGE": "OK", + "METHOD": "POST", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics/f1/Easy_Fabric", + "RETURN_CODE": 200 + }, + "test_fabric_create_bulk_00032a": { + "DATA": "Error in validating provided name value pair: [BGP_AS]", + "MESSAGE": "Internal Server Error", + "METHOD": "POST", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics/f1/Easy_Fabric", + "RETURN_CODE": 500, + "sequence_number": 1 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json new file mode 100644 index 000000000..b3bf03200 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json @@ -0,0 +1,332 @@ +{ + "test_notes": [ + "Mocked responses for FabricDetails() class" + ], + "test_fabric_create_bulk_00030a": { + "DATA": [], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + }, + "test_fabric_create_bulk_00031a": { + "DATA": [ + { + "asn": "65001", + "createdOn": 1711411093680, + "deviceType": "n9k", + "fabricId": "FABRIC-2", + "fabricName": "f1", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "modifiedOn": 1711411096857, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "AI_Fabric_QOS_400G", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "true", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "false", + "AUTO_SYMMETRIC_VRF_LITE": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "false", + "BANNER": "", + "BFD_AUTH_ENABLE": "false", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "false", + "BFD_ISIS_ENABLE": "false", + "BFD_OSPF_ENABLE": "false", + "BFD_PIM_ENABLE": "false", + "BGP_AS": "65001", + "BGP_AS_PREV": "65001", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "3", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "#Scope_Start_IP, Scope_End_IP, Scope_Default_Gateway, Scope_Subnet_Prefix", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "queuing_policy_default_8q_cloudscale", + "DEAFULT_QUEUING_POLICY_OTHER": "queuing_policy_default_other", + "DEAFULT_QUEUING_POLICY_R_SERIES": "queuing_policy_default_r_series", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "false", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "DOMAIN_NAME_INTERNAL": "", + "ENABLE_AAA": "false", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "false", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "false", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "false", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "spine_qos_for_fabric_vpc_peering", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "0001", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "false", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "level-2", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "", + "ISIS_P2P_ENABLE": "", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "UNDERLAY", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "0001", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "cli", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "", + "SITE_ID": "65001", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "notstarted", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "true", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "126", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "operStatus": "HEALTHY", + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "65001", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + }, + "test_fabric_create_bulk_00032a": { + "DATA": [], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py new file mode 100644 index 000000000..f6965de93 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py @@ -0,0 +1,446 @@ +# 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. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=invalid-name + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import pytest +from ansible_collections.ansible.netcommon.tests.unit.modules.utils import \ + AnsibleFailJson +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ + FabricDetailsByName +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( + GenerateResponses, does_not_raise, fabric_create_bulk_fixture, payloads_fabric_create_bulk, + responses_fabric_create_bulk, responses_fabric_details, rest_send_response_current) + + +def test_fabric_create_bulk_00010(fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - FabricCreateBulk + - __init__() + + Test + - Class attributes are initialized to expected values + - fail_json is not called + """ + with does_not_raise(): + instance = fabric_create_bulk + assert instance.class_name == "FabricCreateBulk" + assert instance.action == "create" + assert instance.state == "merged" + assert isinstance(instance.fabric_details, FabricDetailsByName) + + +def test_fabric_create_bulk_00020(fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricCreateBulk + - __init__() + + Test + - payloads is set to expected value + - fail_json is not called + """ + key = "test_fabric_create_bulk_00020a" + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + instance.payloads = payloads_fabric_create_bulk(key) + assert instance.payloads == payloads_fabric_create_bulk(key) + + +def test_fabric_create_bulk_00021(fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricCreateBulk + - __init__() + + Test + - fail_json is called because payloads is not a list + - instance.payloads is not modified, hence it retains its initial value of None + """ + match = r"FabricCreateBulk\.payloads: " + match += r"payloads must be a list of dict\." + + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + with pytest.raises(AnsibleFailJson, match=match): + instance.payloads = "NOT_A_LIST" + assert instance.payloads is None + + +def test_fabric_create_bulk_00022(fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricCreateBulk + - __init__() + + Test + - fail_json is called because payloads is a list with a non-dict element + - instance.payloads is not modified, hence it retains its initial value of None + """ + match = r"FabricCreateBulk._verify_payload: " + match += r"payload must be a dict\." + + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + with pytest.raises(AnsibleFailJson, match=match): + instance.payloads = [1, 2, 3] + assert instance.payloads is None + + +def test_fabric_create_bulk_00023(fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricCreateBulk + - __init__() + + Summary + Verify behavior when payloads is not set prior to calling commit + + Test + - fail_json is called because payloads is not set prior to calling commit + - instance.payloads is not modified, hence it retains its initial value of None + """ + match = r"FabricCreateBulk\.commit: " + match += r"payloads must be set prior to calling commit\." + + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + with pytest.raises(AnsibleFailJson, match=match): + instance.commit() + assert instance.payloads is None + + +def test_fabric_create_bulk_00024(fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricCreateBulk + - __init__() + + Summary + Verify behavior when payloads is set to an empty list + + Setup + - FabricCreatebulk().payloads is set to an empty list + + Test + - fail_json not called + - payloads is set to an empty list + + NOTES: + - element_spec is configured such that AnsibleModule will raise an exception when + config is not a list of dict. Hence, we do not test instance.commit() here since + it would never be reached. + """ + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + instance.payloads = [] + assert instance.payloads == [] + + +def test_fabric_create_bulk_00030(monkeypatch, fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - payloads setter + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricCreateBulk + - __init__() + - commit() + + Summary + Verify behavior when user attempts to create a fabric and no + fabrics exist on the controller and the RestSend() RETURN_CODE is 200. + + Code Flow + - main.Merge() is instantiated and instantiates FabricQuery() + - FabricQuery() instantiates FabricDetailsByName() + - FabricQuery.fabric_names is set to contain one fabric_name (f1) + that does not exist on the controller. + - FabricQuery().commit() calls FabricDetailsByName().refresh() which + calls FabricDetails.refresh_super() + - FabricDetails.refresh_super() calls RestSend().commit() which sets + RestSend().response_current to a dict with keys DATA == [], + RETURN_CODE == 200, MESSAGE="OK" + - Hence, FabricDetails().data is set to an empty dict: {} + - Hence, FabricDetailsByName().data_subclass is set to an empty dict: {} + - Since FabricDetails().all_data is an empty dict, FabricQuery().commit() sets: + - instance.results.diff_current to an empty dict + - instance.results.response_current to the RestSend().response_current + - instance.results.result_current to the RestSend().result_current + - FabricQuery.commit() calls Results().register_task_result() + - Results().register_task_result() adds sequence_number (with value 1) to + each of the results dicts + """ + key = "test_fabric_create_bulk_00030a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + yield responses_fabric_create_bulk(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + instance.payloads = payloads_fabric_create_bulk(key) + instance.fabric_details.rest_send.unit_test = True + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + instance.fabric_details.results = Results() + with does_not_raise(): + instance.commit() + + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.metadata) == 1 + assert len(instance.results.response) == 1 + assert len(instance.results.result) == 1 + + assert instance.results.diff[0].get("sequence_number", None) == 1 + assert instance.results.diff[0].get("BGP_AS", None) == 65001 + assert instance.results.diff[0].get("FABRIC_NAME", None) == "f1" + + assert instance.results.metadata[0].get("action", None) == "create" + assert instance.results.metadata[0].get("check_mode", None) is False + assert instance.results.metadata[0].get("sequence_number", None) == 1 + assert instance.results.metadata[0].get("state", None) == "merged" + + assert instance.results.response[0].get("RETURN_CODE", None) == 200 + assert instance.results.response[0].get("DATA", {}).get("nvPairs", {}).get("BGP_AS", None) == "65001" + assert instance.results.response[0].get("METHOD", None) == "POST" + + assert instance.results.result[0].get("changed", None) == True + assert instance.results.result[0].get("success", None) == True + + assert False in instance.results.failed + assert True not in instance.results.failed + assert True in instance.results.changed + assert False not in instance.results.changed + + +def test_fabric_create_bulk_00031(monkeypatch, fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricCreateCommon() + - __init__() + - payloads setter + - FabricCreateBulk() + - __init__() + - commit() + + Summary + Verify behavior when FabricCreateBulk() is used to create a fabric and + the fabric exists on the controller. + + Setup + - FabricDetails().refresh() is set to indicate that fabric f1 exists on the controller + + Code Flow + - FabricCreateBulk.payloads is set to contain one payload for a fabric (f1) + that already exists on the controller. + - FabricCreateBulk.commit() calls FabricCreate()._build_payloads_to_commit() + - FabricCreate()._build_payloads_to_commit() calls FabricDetails().refresh() + which returns a dict with keys DATA == [{f1 fabric data dict}], RETURN_CODE == 200 + - FabricCreate()._build_payloads_to_commit() sets FabricCreate()._payloads_to_commit + to an empty list since fabric f1 already exists on the controller + - FabricCreateBulk.commit() returns without doing anything. + + Test + """ + key = "test_fabric_create_bulk_00031a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + instance.payloads = payloads_fabric_create_bulk(key) + instance.fabric_details.rest_send.unit_test = True + instance.fabric_details.results = Results() + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + with does_not_raise(): + instance.commit() + + assert instance._payloads_to_commit == [] + assert instance.results.diff == [] + assert instance.results.metadata == [] + assert instance.results.response == [] + assert instance.results.result == [] + + +def test_fabric_create_bulk_00032(monkeypatch, fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - payloads setter + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricCreateBulk + - __init__() + - commit() + + Summary + Verify behavior when user attempts to create a fabric but the + controller RETURN_CODE is 500. + + Setup + - FabricDetails().refresh() is set to indicate that no fabrics exist on the controller + + Code Flow + - FabricCreateBulk.payloads is set to contain one payload for a fabric (f1) + that does not exist on the controller. + - FabricCreateBulk.commit() calls FabricCreate()._build_payloads_to_commit() + - FabricCreate()._build_payloads_to_commit() calls FabricDetails().refresh() + which returns a dict with keys DATA == [], RETURN_CODE == 200 + - FabricCreate()._build_payloads_to_commit() sets FabricCreate()._payloads_to_commit + to a list containing fabric f1 payload + - FabricCreateBulk.commit() calls RestSend().commit() which sets RestSend().response_current + to a dict with keys: + - DATA == "Error in validating provided name value pair: [BGP_AS]" + RETURN_CODE == 500, + MESSAGE = "Internal Server Error" + """ + key = "test_fabric_create_bulk_00032a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + yield responses_fabric_create_bulk(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + instance.payloads = payloads_fabric_create_bulk(key) + instance.fabric_details.rest_send.unit_test = True + instance.rest_send.unit_test = True + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + instance.fabric_details.results = Results() + with does_not_raise(): + instance.commit() + + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.metadata) == 1 + assert len(instance.results.response) == 1 + assert len(instance.results.result) == 1 + + assert instance.results.diff[0].get("sequence_number", None) == 1 + + assert instance.results.metadata[0].get("action", None) == "create" + assert instance.results.metadata[0].get("check_mode", None) is False + assert instance.results.metadata[0].get("sequence_number", None) == 1 + assert instance.results.metadata[0].get("state", None) == "merged" + + assert instance.results.response[0].get("RETURN_CODE", None) == 500 + assert instance.results.response[0].get("DATA", {}) == "Error in validating provided name value pair: [BGP_AS]" + assert instance.results.response[0].get("METHOD", None) == "POST" + + assert instance.results.result[0].get("changed", None) == False + assert instance.results.result[0].get("success", None) == False + + assert True in instance.results.failed + assert False not in instance.results.failed + assert False in instance.results.changed + assert True not in instance.results.changed + diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py new file mode 100644 index 000000000..621c59b5b --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py @@ -0,0 +1,534 @@ +# 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. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=invalid-name + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import pytest +from ansible_collections.ansible.netcommon.tests.unit.modules.utils import \ + AnsibleFailJson +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ + FabricDetailsByName +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( + GenerateResponses, does_not_raise, fabric_query_fixture, + rest_send_response_current) + + +def test_fabric_query_00010(fabric_query) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - FabricQuery + - __init__() + + Test + - Class attributes are initialized to expected values + - fail_json is not called + """ + with does_not_raise(): + instance = fabric_query + assert instance.class_name == "FabricQuery" + assert instance.action == "query" + assert instance.state == "query" + assert isinstance(instance._fabric_details, FabricDetailsByName) + + +def test_fabric_query_00020(fabric_query) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - FabricQuery + - __init__() + - fabric_names setter + + Test + - fabric_names is set to expected value + - fail_json is not called + """ + fabric_names = ["FOO", "BAR"] + with does_not_raise(): + instance = fabric_query + instance.fabric_names = fabric_names + assert instance.fabric_names == fabric_names + + +def test_fabric_query_00021(fabric_query) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - FabricQuery + - __init__() + - fabric_names setter + + Test + - fail_json is called because fabric_names is not a list + - instance.fabric_names is not modified, hence it retains its initial value of None + """ + match = "FabricQuery.fabric_names: " + match += "fabric_names must be a list." + + with does_not_raise(): + instance = fabric_query + with pytest.raises(AnsibleFailJson, match=match): + instance.fabric_names = "NOT_A_LIST" + assert instance.fabric_names is None + + +def test_fabric_query_00022(fabric_query) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - FabricQuery + - __init__() + - fabric_names setter + + Test + - fail_json is called because fabric_names is a list with a non-string element + - instance.fabric_names is not modified, hence it retains its initial value of None + """ + match = "FabricQuery.fabric_names: " + match += "fabric_names must be a list of strings." + + with does_not_raise(): + instance = fabric_query + with pytest.raises(AnsibleFailJson, match=match): + instance.fabric_names = [1, 2, 3] + assert instance.fabric_names is None + + +def test_fabric_query_00023(fabric_query) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - FabricQuery + - __init__() + - fabric_names setter + + Summary + Verify behavior when fabric_names is not set prior to calling commit + + Test + - fail_json is called because fabric_names is not set prior to calling commit + - instance.fabric_names is not modified, hence it retains its initial value of None + """ + match = r"FabricQuery\.commit: " + match += r"fabric_names must be set prior to calling commit\." + + with does_not_raise(): + instance = fabric_query + instance.results = Results() + with pytest.raises(AnsibleFailJson, match=match): + instance.commit() + assert instance.fabric_names is None + + +def test_fabric_query_00024(fabric_query) -> None: + """ + Classes and Methods + - FabricQuery + - fabric_names setter + + Summary + Verify behavior when fabric_names is set to an empty list + + Setup + - FabricQuery().fabric_names is set to an empty list + + Test + - fail_json is called from fabric_names setter + """ + match = r"FabricQuery\.fabric_names: fabric_names must be a list of " + match += r"at least one string\." + with pytest.raises(AnsibleFailJson, match=match): + instance = fabric_query + instance.fabric_names = [] + + +def test_fabric_query_00030(monkeypatch, fabric_query) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricQuery + - __init__() + - fabric_names setter + - commit() + - Query() + - __init__() + - commit() + + Summary + Verify behavior when user queries a fabric and no fabrics exist + on the controller and the RestSend() RETURN_CODE is 200. + + Code Flow + - main.Query() is instantiated and instantiates FabricQuery() + - FabricQuery() instantiates FabricDetailsByName() + - FabricQuery.fabric_names is set to contain one fabric_name (f1) + that does not exist on the controller. + - FabricQuery().commit() calls FabricDetailsByName().refresh() which + calls FabricDetails.refresh_super() + - FabricDetails.refresh_super() calls RestSend().commit() which sets + RestSend().response_current to a dict with keys DATA == [], + RETURN_CODE == 200, MESSAGE="OK" + - Hence, FabricDetails().data is set to an empty dict: {} + - Hence, FabricDetailsByName().data_subclass is set to an empty dict: {} + - Since FabricDetails().all_data is an empty dict, FabricQuery().commit() sets: + - instance.results.diff_current to an empty dict + - instance.results.response_current to the RestSend().response_current + - instance.results.result_current to the RestSend().result_current + - FabricQuery.commit() calls Results().register_task_result() + - Results().register_task_result() adds sequence_number (with value 1) to + each of the results dicts + """ + key = "test_fabric_query_00030a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield rest_send_response_current(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_query + instance.results = Results() + instance.fabric_names = ["f1"] + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + instance._fabric_details.results = Results() + with does_not_raise(): + instance.commit() + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.result) == 1 + assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 + assert instance.results.response[0].get("RETURN_CODE", None) == 200 + assert instance.results.result[0].get("found", None) == True + assert instance.results.result[0].get("success", None) == True + assert False in instance.results.failed + assert True not in instance.results.failed + assert False in instance.results.changed + assert True not in instance.results.changed + + +def test_fabric_query_00031(monkeypatch, fabric_query) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricQuery + - __init__() + - fabric_names setter + - commit() + - Query() + - __init__() + - commit() + + Summary + Verify behavior when user queries a fabric that does not exist + on the controller. One fabric (f2) exists on the controller, + and the RestSend() RETURN_CODE is 200. + + Code Flow + - main.Query() is instantiated and instantiates FabricQuery() + - FabricQuery() instantiates FabricDetailsByName() + - FabricQuery.fabric_names is set to contain one fabric_name (f1) + that does not exist on the controller. + - FabricQuery().commit() calls FabricDetailsByName().refresh() which + calls FabricDetails.refresh_super() + - FabricDetails.refresh_super() calls RestSend().commit() which sets + RestSend().response_current to a dict with keys DATA == [{f2 fabric data dict}], + RETURN_CODE == 200, MESSAGE="OK" + - Hence, FabricDetails().data is set to: { "f2": {f2 fabric data dict} } + - Hence, FabricDetailsByName().data_subclass is set to: { "f2": {f2 fabric data dict} } + - Since FabricDetails.all_data is not an empty dict, FabricQuery().commit() iterates + over the fabrics in FabricDetails.all_data but does not find any fabrics matching + the user query. Hence, it sets: + - instance.results.diff_current to an empty dict + - instance.results.response_current to the RestSend().response_current + - instance.results.result_current to the RestSend().result_current + - FabricQuery.commit() calls Results().register_task_result() + - Results().register_task_result() adds sequence_number (with value 1) to + each of the results dicts + + Test + - FabricQuery.commit() calls instance._fabric_details() which sets + instance._fabric_details.all_data to a list of dict containing + all fabrics on the controller. + - since instance._fabric_details.all_data is empty, none of the + following are set: + - instance.results.diff_current + - instance.results.response_current + - instance.results.result_current + - instance.results.changed set() contains False + - instance.results.failed set() contains False + - commit() returns without doing anything else + - fail_json is not called + """ + key = "test_fabric_query_00031a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield rest_send_response_current(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_query + instance.results = Results() + instance.fabric_names = ["f1"] + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + instance._fabric_details.results = Results() + with does_not_raise(): + instance.commit() + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.result) == 1 + assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 + assert instance.results.response[0].get("RETURN_CODE", None) == 200 + assert instance.results.result[0].get("found", None) == True + assert instance.results.result[0].get("success", None) == True + assert False in instance.results.failed + assert True not in instance.results.failed + assert False in instance.results.changed + assert True not in instance.results.changed + + +def test_fabric_query_00032(monkeypatch, fabric_query) -> None: + """ + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricQuery + - __init__() + - fabric_names setter + - commit() + - Query() + - __init__() + - commit() + + Summary + Verify behavior when user queries a fabric that does not exist + on the controller. One fabric (f2) exists on the controller, + but the RestSend() RETURN_CODE is 500. + + Code Flow + - main.Query() is instantiated and instantiates FabricQuery() + - FabricQuery() instantiates FabricDetailsByName() + - FabricQuery.fabric_names is set to contain one fabric_name (f1) + that does not exist on the controller. + - FabricQuery().commit() calls FabricDetailsByName().refresh() which + calls FabricDetails.refresh_super() + - FabricDetails.refresh_super() calls RestSend().commit() which sets + RestSend().response_current to a dict with keys DATA == [{f2 fabric data dict}], + RETURN_CODE == 200, MESSAGE="OK" + - Hence, FabricDetails().data is set to: { "f2": {f2 fabric data dict} } + - Hence, FabricDetailsByName().data_subclass is set to: { "f2": {f2 fabric data dict} } + - Since FabricDetails.all_data is not an empty dict, FabricQuery().commit() iterates + over the fabrics in FabricDetails.all_data but does not find any fabrics matching + the user query. Hence, it sets: + - instance.results.diff_current to an empty dict + - instance.results.response_current to the RestSend().response_current + - instance.results.result_current to the RestSend().result_current + - FabricQuery.commit() calls Results().register_task_result() + - Results().register_task_result() adds sequence_number (with value 1) to + each of the results dicts + + Setup + - RestSend().commit() is mocked to return a dict with key RETURN_CODE == 500 + - RestSend().timeout is set to 1 + - RestSend().send_interval is set to 1 + - RestSend().unit_test is set to True + + """ + key = "test_fabric_query_00032a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield rest_send_response_current(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_query + instance.results = Results() + instance._fabric_details.rest_send.unit_test = True + instance._fabric_details.rest_send.timeout = 1 + instance._fabric_details.rest_send.send_interval = 1 + instance.fabric_names = ["f1"] + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + instance._fabric_details.results = Results() + with does_not_raise(): + instance.commit() + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.result) == 1 + assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 + assert instance.results.response[0].get("RETURN_CODE", None) == 500 + assert instance.results.result[0].get("found", None) == False + assert instance.results.result[0].get("success", None) == False + assert True in instance.results.failed + assert False not in instance.results.failed + assert False in instance.results.changed + assert True not in instance.results.changed + + +def test_fabric_query_00033(monkeypatch, fabric_query) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricQuery + - __init__() + - fabric_names setter + - commit() + - Query() + - __init__() + - commit() + + Summary + Verify behavior when user queries a fabric that exists + on the controller. One fabric (f1) exists on the controller, + and the RestSend() RETURN_CODE is 200. + + Code Flow + - main.Query() is instantiated and instantiates FabricQuery() + - FabricQuery() instantiates FabricDetailsByName() + - FabricQuery.fabric_names is set to contain one fabric_name (f1) + that does not exist on the controller. + - FabricQuery().commit() calls FabricDetailsByName().refresh() which + calls FabricDetails.refresh_super() + - FabricDetails.refresh_super() calls RestSend().commit() which sets + RestSend().response_current to a dict with keys DATA == [{f1 fabric data dict}], + RETURN_CODE == 200, MESSAGE="OK" + - Hence, FabricDetails().data is set to: { "f1": {f1 fabric data dict} } + - Hence, FabricDetailsByName().data_subclass is set to: { "f1": {f1 fabric data dict} } + - Since FabricDetails.all_data is not an empty dict, FabricQuery().commit() iterates + over the fabrics in FabricDetails.all_data and finds fabric f1. + Hence, it sets: + - instance.results.diff_current to be the fabric info dict for fabric f1 + - instance.results.response_current to the RestSend().response_current + - instance.results.result_current to the RestSend().result_current + - FabricQuery.commit() calls Results().register_task_result() + - Results().register_task_result() adds sequence_number (with value 1) to + each of the results dicts + """ + key = "test_fabric_query_00033a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield rest_send_response_current(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_query + instance.results = Results() + instance.fabric_names = ["f1"] + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + instance._fabric_details.results = Results() + with does_not_raise(): + instance.commit() + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.result) == 1 + assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 + assert instance.results.diff[0].get("f1", {}).get("asn", None) == "65001" + assert instance.results.diff[0].get("f1", {}).get("nvPairs", {}).get("BGP_AS") == "65001" + assert instance.results.response[0].get("RETURN_CODE", None) == 200 + assert instance.results.result[0].get("found", None) == True + assert instance.results.result[0].get("success", None) == True + assert False in instance.results.failed + assert True not in instance.results.failed + assert False in instance.results.changed + assert True not in instance.results.changed diff --git a/tests/unit/modules/dcnm/dcnm_fabric/utils.py b/tests/unit/modules/dcnm/dcnm_fabric/utils.py new file mode 100644 index 000000000..aef952fd2 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/utils.py @@ -0,0 +1,311 @@ +# 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 + + +from contextlib import contextmanager +from typing import Any, Dict + +import pytest +from ansible_collections.ansible.netcommon.tests.unit.modules.utils import \ + AnsibleFailJson +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ + FabricCommon +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import \ + FabricCreateCommon, FabricCreate, FabricCreateBulk +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import \ + FabricDelete +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import \ + FabricQuery +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.fixture import \ + load_fixture + + +class GenerateResponses: + """ + Given a generator, return the items in the generator with + each call to the next property + + For usage in the context of dcnm_image_policy unit tests, see: + test: test_image_policy_create_bulk_00037 + file: tests/unit/modules/dcnm/dcnm_image_policy/test_image_policy_create_bulk.py + + Simplified usage example below. + + def responses(): + yield {"key1": "value1"} + yield {"key2": "value2"} + + gen = GenerateResponses(responses()) + + print(gen.next) # {"key1": "value1"} + print(gen.next) # {"key2": "value2"} + """ + + def __init__(self, gen): + self.gen = gen + + @property + def next(self): + """ + Return the next item in the generator + """ + return next(self.gen) + + def public_method_for_pylint(self) -> Any: + """ + Add one public method to appease pylint + """ + + +class MockAnsibleModule: + """ + Mock the AnsibleModule class + """ + + check_mode = False + + params = { + "state": "merged", + "config": {"switches": [{"ip_address": "172.22.150.105"}]}, + "check_mode": False, + } + argument_spec = { + "config": {"required": True, "type": "dict"}, + "state": { + "default": "merged", + "choices": ["deleted", "overridden", "merged", "query", "replaced"], + }, + } + supports_check_mode = True + + @property + def state(self): + """ + return the state + """ + return self.params["state"] + + @state.setter + def state(self, value): + """ + set the state + """ + self.params["state"] = value + + @staticmethod + def fail_json(msg, **kwargs) -> AnsibleFailJson: + """ + mock the fail_json method + """ + raise AnsibleFailJson(msg, kwargs) + + def public_method_for_pylint(self) -> Any: + """ + Add one public method to appease pylint + """ + + +# See the following for explanation of why fixtures are explicitely named +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html + + +@pytest.fixture(name="fabric_common") +def fabric_common_fixture(): + """ + mock FabricCommon + """ + instance = MockAnsibleModule() + return FabricCommon(instance) + + +@pytest.fixture(name="fabric_create") +def fabric_create_fixture(): + """ + mock FabricCreate + """ + instance = MockAnsibleModule() + instance.state = "merged" + return FabricCreate(instance) + + +@pytest.fixture(name="fabric_create_bulk") +def fabric_create_bulk_fixture(): + """ + mock FabricCreateBulk + """ + instance = MockAnsibleModule() + instance.state = "merged" + return FabricCreateBulk(instance) + + +@pytest.fixture(name="fabric_delete") +def fabric_delete_fixture(): + """ + mock FabricDelete + """ + instance = MockAnsibleModule() + instance.state = "deleted" + return FabricDelete(instance) + + +@pytest.fixture(name="fabric_query") +def fabric_query_fixture(): + """ + mock FabricQuery + """ + instance = MockAnsibleModule() + instance.state = "query" + return FabricQuery(instance) + + +@contextmanager +def does_not_raise(): + """ + A context manager that does not raise an exception. + """ + yield + + +def payloads_fabric_create(key: str) -> Dict[str, str]: + """ + Return payloads for FabricCreate + """ + data_file = "payloads_FabricCreate" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def payloads_fabric_create_bulk(key: str) -> Dict[str, str]: + """ + Return payloads for FabricCreateBulk + """ + data_file = "payloads_FabricCreateBulk" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_fabric_details(key: str) -> Dict[str, str]: + """ + Return responses for FabricDetails + """ + data_file = "responses_FabricDetails" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_fabric_common(key: str) -> Dict[str, str]: + """ + Return responses for FabricCommon + """ + data_file = "responses_FabricCommon" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_fabric_create(key: str) -> Dict[str, str]: + """ + Return responses for FabricCreate + """ + data_file = "responses_FabricCreate" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_fabric_create_bulk(key: str) -> Dict[str, str]: + """ + Return responses for FabricCreateBulk + """ + data_file = "responses_FabricCreateBulk" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_fabric_delete(key: str) -> Dict[str, str]: + """ + Return responses for FabricDelete + """ + data_file = "responses_FabricDelete" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def results_fabric_details(key: str) -> Dict[str, str]: + """ + Return results for FabricDetails + """ + data_file = "results_FabricDetails" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def results_fabric_common(key: str) -> Dict[str, str]: + """ + Return results for FabricCommon + """ + data_file = "results_FabricCommon" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def results_fabric_create_bulk(key: str) -> Dict[str, str]: + """ + Return results for FabricCreateBulk + """ + data_file = "results_FabricCreateBulk" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def results_fabric_delete(key: str) -> Dict[str, str]: + """ + Return results for FabricDelete + """ + data_file = "results_FabricDelete" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def rest_send_response_current(key: str) -> Dict[str, str]: + """ + Mocked return values for RestSend().response_current property + """ + data_file = "response_current_RestSend" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def rest_send_result_current(key: str) -> Dict[str, str]: + """ + Mocked return values for RestSend().result_current property + """ + data_file = "result_current_RestSend" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data From 2707ba4a7a029d2b181e21748e4fa92c1dec6a68 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 26 Mar 2024 11:02:52 -1000 Subject: [PATCH 016/228] Add unit tests for FabricUpdateBulk, more... - test_fabric_create_bulk.py: Update docstrings for a few tests - update.py: FabricUpdateCommon._send_payloads(), fix reference to Results() property that no longer exists - query.py: run thru black - fabric_summary.py: use a private copy of Results() - fabric_details.py: use a private copy of Results() - delete.py: run thru black - create.py: add type hinting to a few methods - common.py: fix bad f-string interpolation --- plugins/module_utils/fabric/common.py | 2 +- plugins/module_utils/fabric/create.py | 58 +- plugins/module_utils/fabric/delete.py | 2 +- plugins/module_utils/fabric/fabric_details.py | 40 +- plugins/module_utils/fabric/fabric_summary.py | 19 +- plugins/module_utils/fabric/query.py | 12 +- plugins/module_utils/fabric/update.py | 37 +- .../fixtures/payloads_FabricUpdateBulk.json | 39 ++ .../fixtures/responses_FabricDetails.json | 633 ++++++++++++++++++ .../fixtures/responses_FabricSummary.json | 31 + .../fixtures/responses_FabricUpdateBulk.json | 337 ++++++++++ .../dcnm_fabric/test_fabric_create_bulk.py | 30 +- .../dcnm_fabric/test_fabric_update_bulk.py | 499 ++++++++++++++ tests/unit/modules/dcnm/dcnm_fabric/utils.py | 48 +- 14 files changed, 1681 insertions(+), 106 deletions(-) create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json create mode 100644 tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index 7c9c43766..537e4be8f 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -205,7 +205,7 @@ def fabric_type(self, value): method_name = inspect.stack()[0][3] if value not in self._valid_fabric_types: msg = f"{self.class_name}.{method_name}: " - msg += f"FABRIC_TYPE must be one of " + msg += "FABRIC_TYPE must be one of " msg += f"{sorted(self._valid_fabric_types)}. " msg += f"Got {value}" self.ansible_module.fail_json(msg, **self.results.failed_result) diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index 65cb36fb0..a84232add 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -31,8 +31,9 @@ ApiEndpoints from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_playbook_params import \ - VerifyPlaybookParams + +# from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_playbook_params import \ +# VerifyPlaybookParams class FabricCreateCommon(FabricCommon): @@ -45,22 +46,22 @@ class FabricCreateCommon(FabricCommon): def __init__(self, ansible_module): super().__init__(ansible_module) self.class_name = self.__class__.__name__ - self.action = "create" + self.action: str = "create" self.log = logging.getLogger(f"dcnm.{self.class_name}") self.fabric_details = FabricDetailsByName(self.ansible_module) self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) - #self._verify_params = VerifyPlaybookParams(self.ansible_module) + # self._verify_params = VerifyPlaybookParams(self.ansible_module) # 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.path: str = None + self.verb: str = None - self._payloads_to_commit = [] + self._payloads_to_commit: list = [] self._mandatory_payload_keys = set() self._mandatory_payload_keys.add("FABRIC_NAME") @@ -72,7 +73,7 @@ def __init__(self, ansible_module): msg += f"state: {self.state}" self.log.debug(msg) - def _verify_payload(self, payload): + def _verify_payload(self, payload) -> None: """ Verify that the payload is a dict and contains all mandatory keys """ @@ -100,7 +101,7 @@ def _verify_payload(self, payload): msg += f"{sorted(missing_keys)}" self.ansible_module.fail_json(msg, **self.results.failed_result) - def _fixup_payloads_to_commit(self): + def _fixup_payloads_to_commit(self) -> None: """ Make any modifications to the payloads prior to sending them to the controller. @@ -115,7 +116,7 @@ def _fixup_payloads_to_commit(self): payload["ANYCAST_GW_MAC"] ) - def _build_payloads_to_commit(self): + def _build_payloads_to_commit(self) -> None: """ Build a list of payloads to commit. Skip any payloads that already exist on the controller. @@ -128,37 +129,12 @@ def _build_payloads_to_commit(self): """ self.fabric_details.refresh() - msg = f"self.fabric_details.all_data: {json.dumps(self.fabric_details.all_data, indent=4, sort_keys=True)}" - self.log.debug(msg) - self._payloads_to_commit = [] for payload in self.payloads: if payload.get("FABRIC_NAME", None) in self.fabric_details.all_data: continue - - msg = f"payload: {json.dumps(payload, indent=4, sort_keys=True)}" - self.log.debug(msg) - self._payloads_to_commit.append(copy.deepcopy(payload)) - msg = f"self._payloads_to_commit: {json.dumps(self._payloads_to_commit, indent=4, sort_keys=True)}" - self.log.debug(msg) - - def _get_endpoint(self): - """ - Get the endpoint for the fabric create API call. - """ - method_name = inspect.stack()[0][3] # pylint: disable=unused-variable - self.endpoints.fabric_name = self._payloads_to_commit[0].get("FABRIC_NAME") - self.endpoints.template_name = "Easy_Fabric" - try: - endpoint = self.endpoints.fabric_create - except ValueError as error: - self.ansible_module.fail_json(error) - - self.path = endpoint["path"] - self.verb = endpoint["verb"] - def _set_fabric_create_endpoint(self, payload): """ Set the endpoint for the fabric create API call. @@ -212,7 +188,9 @@ def _send_payloads(self): 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.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() @@ -313,14 +291,18 @@ def commit(self): self.ansible_module.fail_json(msg, **self.results.failed_result) self._build_payloads_to_commit() - msg = f"self._payloads_to_commit: {json.dumps(self._payloads_to_commit, indent=4, sort_keys=True)}" + + 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 self._fixup_payloads_to_commit() self._send_payloads() -class FabricCreate(FabricCommon): + +class FabricCreate(FabricCreateCommon): """ Create a VXLAN fabric on the controller. """ diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index 53f1193e6..2e6ec88c5 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -171,7 +171,7 @@ def commit(self): msg = "No fabrics to delete" self.results.response_current = {"RETURN_CODE": 200, "MESSAGE": msg} self.log.debug(msg) - + def _send_requests(self): """ If check_mode is False, send the requests to the controller diff --git a/plugins/module_utils/fabric/fabric_details.py b/plugins/module_utils/fabric/fabric_details.py index 23d857e71..4de0d504f 100644 --- a/plugins/module_utils/fabric/fabric_details.py +++ b/plugins/module_utils/fabric/fabric_details.py @@ -19,12 +19,14 @@ __author__ = "Allen Robel" import copy -import json import inspect +import json import logging from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend +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 \ @@ -50,6 +52,7 @@ def __init__(self, ansible_module): self.data = {} self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) + self.results = Results() # We always want to get the controller's current fabric state # so we set check_mode to False here so the request will be # sent to the controller @@ -83,14 +86,16 @@ def refresh_super(self): msg = f"self.data: {json.dumps(self.data, indent=4, sort_keys=True)}" self.log.debug(msg) - msg = f"self.rest_send.response_current: " - msg += f"{json.dumps(self.rest_send.response_current, indent=4, sort_keys=True)}" + msg = "self.rest_send.response_current: " + msg += ( + f"{json.dumps(self.rest_send.response_current, indent=4, sort_keys=True)}" + ) self.log.debug(msg) - self.response_current = self.rest_send.response_current - self.response = self.rest_send.response_current - self.result_current = self.rest_send.result_current - self.result = self.rest_send.result_current + 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 def _get(self, item): """ @@ -258,17 +263,17 @@ def _get(self, item): msg = f"{self.class_name}.{method_name}: " msg += "set instance.filter to a fabric name " msg += f"before accessing property {item}." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) if self.data_subclass.get(self.filter) is None: msg = f"{self.class_name}.{method_name}: " msg += f"{self.filter} does not exist on the controller." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) 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}." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) return self.make_none( self.make_boolean(self.data_subclass[self.filter].get(item)) @@ -290,19 +295,19 @@ def _get_nv_pair(self, item): msg = f"{self.class_name}.{method_name}: " msg += "set instance.filter to a fabric name " msg += f"before accessing property {item}." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) 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." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) 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}." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) return self.make_none( self.make_boolean(self.data_subclass[self.filter].get("nvPairs").get(item)) @@ -364,18 +369,15 @@ def refresh(self): if self.filter_key is None: msg = "set instance.filter_key to a nvPair key " msg += "before calling refresh()." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) if self.filter_value is None: msg = "set instance.filter_value to a nvPair value " msg += "before calling refresh()." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) self.refresh_super() for item, value in self.data.items(): - if ( - value.get("nvPairs", {}).get(self.filter_key) - == self.filter_value - ): + if value.get("nvPairs", {}).get(self.filter_key) == self.filter_value: self.data_subclass[item] = value @property diff --git a/plugins/module_utils/fabric/fabric_summary.py b/plugins/module_utils/fabric/fabric_summary.py index dae007090..96338dcda 100644 --- a/plugins/module_utils/fabric/fabric_summary.py +++ b/plugins/module_utils/fabric/fabric_summary.py @@ -25,6 +25,8 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.common.rest_send import \ RestSend +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 \ @@ -93,6 +95,7 @@ def __init__(self, ansible_module): self.data = None self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) + self.results = Results() self._init_properties() @@ -115,7 +118,7 @@ def _update_device_counts(self): if self.data is None: msg = f"{self.class_name}.{method_name}: " msg = f"refresh() must be called before accessing {method_name}." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) msg = f"{self.class_name}.{method_name}: " msg = f"self.data: {json.dumps(self.data, indent=4, sort_keys=True)}" @@ -143,7 +146,7 @@ def refresh(self): if self.fabric_name is None: msg = f"{self.class_name}.{method_name}: " msg += "fabric_name is required." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) try: self.endpoints.fabric_name = self.fabric_name @@ -153,7 +156,7 @@ def refresh(self): msg = "Error retrieving fabric_summary endpoint. " msg += f"Detail: {error}" self.log.debug(msg) - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) self.rest_send.commit() self.data = copy.deepcopy(self.rest_send.response_current.get("DATA", {})) @@ -161,10 +164,10 @@ def refresh(self): msg = f"self.data: {json.dumps(self.data, indent=4, sort_keys=True)}" self.log.debug(msg) - self.response_current = self.rest_send.response_current - self.response = self.rest_send.response_current - self.result_current = self.rest_send.result_current - self.result = self.rest_send.result_current + 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._update_device_counts() @@ -174,7 +177,7 @@ def verify_refresh_has_been_called(self, method_name): """ if self.data is None: msg = f"refresh() must be called before accessing {method_name}." - self.ansible_module.fail_json(msg, **self.failed_result) + self.ansible_module.fail_json(msg, **self.results.failed_result) @property def all_data(self) -> dict: diff --git a/plugins/module_utils/fabric/query.py b/plugins/module_utils/fabric/query.py index d47e855d0..ae1572a1b 100644 --- a/plugins/module_utils/fabric/query.py +++ b/plugins/module_utils/fabric/query.py @@ -134,9 +134,15 @@ def commit(self): 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]) + 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.response_current) - self.results.result_current = copy.deepcopy(self._fabric_details.result_current) + 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/update.py b/plugins/module_utils/fabric/update.py index 3af10b268..dfaf32704 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -51,14 +51,12 @@ def __init__(self, ansible_module): self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.fabric_details = FabricDetailsByName(self.ansible_module) self._fabric_summary = FabricSummary(self.ansible_module) self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) # self._verify_params = VerifyPlaybookParams(self.ansible_module) - # 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. @@ -189,11 +187,11 @@ def _send_payloads(self): self._send_payload(payload) # Skip config-save if any errors were encountered with fabric updates. - if len(self.results.result_nok) != 0: + if True in self.results.failed: return self._config_save() # Skip config-deploy if any errors were encountered with config-save. - if len(self.results.result_nok) != 0: + if True in self.results.failed: return self._config_deploy() @@ -279,7 +277,9 @@ def _send_payload(self, payload): else: self.results.diff_current = copy.deepcopy(payload) - self.send_payload_result[payload["FABRIC_NAME"]] = self.rest_send.result_current["success"] + 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 @@ -319,16 +319,23 @@ def _config_save(self): self.rest_send.payload = None self.rest_send.commit() - self.config_save_result[fabric_name] = self.rest_send.result_current["success"] + self.config_save_result[fabric_name] = self.rest_send.result_current[ + "success" + ] if self.rest_send.result_current["success"] is False: self.results.diff_current = {} else: - self.results.diff_current = {"FABRIC_NAME": fabric_name, "config_save": "OK"} + self.results.diff_current = { + "FABRIC_NAME": fabric_name, + "config_save": "OK", + } self.results.action = "config_save" 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.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() @@ -364,12 +371,17 @@ def _config_deploy(self): self.results.diff_current = {} self.results.diff = {"FABRIC_NAME": fabric_name, "config_deploy": "OK"} else: - self.results.diff_current = {"FABRIC_NAME": fabric_name, "config_deploy": "OK"} + self.results.diff_current = { + "FABRIC_NAME": fabric_name, + "config_deploy": "OK", + } self.results.action = "config_deploy" 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.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() @@ -467,13 +479,12 @@ def commit(self): self._build_payloads_to_commit() if len(self._payloads_to_commit) == 0: self.results.diff_current = {} - self.results.result_current = {"success": True} - msg = "No fabrics to create." + self.results.result_current = {"success": True, "changed": False} + msg = "No fabrics to update." self.results.response_current = {"RETURN_CODE": 200, "MESSAGE": msg} self.results.register_task_result() return self._send_payloads() - class FabricUpdate(FabricUpdateCommon): diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json new file mode 100644 index 000000000..4b7cb6f7b --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json @@ -0,0 +1,39 @@ +{ + "TEST_NOTES": [ + "Mocked payloads for FabricCreateBulk unit tests.", + "tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py" + ], + "test_fabric_update_bulk_00020a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ], + "test_fabric_update_bulk_00030a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ], + "test_fabric_update_bulk_00031a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ], + "test_fabric_update_bulk_00032a": [ + { + "BGP_AS": 65001, + "BOO": true, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN" + } + ] +} \ No newline at end of file diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json index b3bf03200..dd5e227d7 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json @@ -328,5 +328,638 @@ "METHOD": "GET", "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", "RETURN_CODE": 200 + }, + "test_fabric_update_bulk_00030a": { + "DATA": [], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + }, + "test_fabric_update_bulk_00031a": { + "DATA": [ + { + "asn": "65001", + "createdOn": 1711411093680, + "deviceType": "n9k", + "fabricId": "FABRIC-2", + "fabricName": "f1", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "modifiedOn": 1711411096857, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "AI_Fabric_QOS_400G", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "true", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "false", + "AUTO_SYMMETRIC_VRF_LITE": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "false", + "BANNER": "", + "BFD_AUTH_ENABLE": "false", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "false", + "BFD_ISIS_ENABLE": "false", + "BFD_OSPF_ENABLE": "false", + "BFD_PIM_ENABLE": "false", + "BGP_AS": "65001", + "BGP_AS_PREV": "65001", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "3", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "#Scope_Start_IP, Scope_End_IP, Scope_Default_Gateway, Scope_Subnet_Prefix", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "queuing_policy_default_8q_cloudscale", + "DEAFULT_QUEUING_POLICY_OTHER": "queuing_policy_default_other", + "DEAFULT_QUEUING_POLICY_R_SERIES": "queuing_policy_default_r_series", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "false", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "DOMAIN_NAME_INTERNAL": "", + "ENABLE_AAA": "false", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "false", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "false", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "false", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "spine_qos_for_fabric_vpc_peering", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "0001", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "false", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "level-2", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "", + "ISIS_P2P_ENABLE": "", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "UNDERLAY", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "0001", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "cli", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "", + "SITE_ID": "65001", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "notstarted", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "true", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "126", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "operStatus": "HEALTHY", + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "65001", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + }, + "test_fabric_update_bulk_00032a": { + "DATA": [ + { + "asn": "65001", + "createdOn": 1711411093680, + "deviceType": "n9k", + "fabricId": "FABRIC-2", + "fabricName": "f1", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "modifiedOn": 1711411096857, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "AI_Fabric_QOS_400G", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "true", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "false", + "AUTO_SYMMETRIC_VRF_LITE": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "false", + "BANNER": "", + "BFD_AUTH_ENABLE": "false", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "false", + "BFD_ISIS_ENABLE": "false", + "BFD_OSPF_ENABLE": "false", + "BFD_PIM_ENABLE": "false", + "BGP_AS": "65001", + "BGP_AS_PREV": "65001", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "3", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "#Scope_Start_IP, Scope_End_IP, Scope_Default_Gateway, Scope_Subnet_Prefix", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "queuing_policy_default_8q_cloudscale", + "DEAFULT_QUEUING_POLICY_OTHER": "queuing_policy_default_other", + "DEAFULT_QUEUING_POLICY_R_SERIES": "queuing_policy_default_r_series", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "false", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "DOMAIN_NAME_INTERNAL": "", + "ENABLE_AAA": "false", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "false", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "false", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "false", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "spine_qos_for_fabric_vpc_peering", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "0001", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "false", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "level-2", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "", + "ISIS_P2P_ENABLE": "", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "UNDERLAY", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "0001", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "cli", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "", + "SITE_ID": "65001", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "notstarted", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "true", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "126", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "operStatus": "HEALTHY", + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "65001", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 } } diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json new file mode 100644 index 000000000..748e30c28 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json @@ -0,0 +1,31 @@ +{ + "test_notes": [ + "Mocked responses for FabricSummary() class" + ], + "test_fabric_update_bulk_00031a": { + "DATA": { + "switchConfig": {}, + "switchHWVersions": {}, + "switchHealth": {}, + "switchRoles": {}, + "switchSWVersions": {} + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/switches/f1/overview", + "RETURN_CODE": 200 + }, + "test_fabric_update_bulk_00032a": { + "DATA": { + "switchConfig": {}, + "switchHWVersions": {}, + "switchHealth": {}, + "switchRoles": {}, + "switchSWVersions": {} + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/switches/f1/overview", + "RETURN_CODE": 200 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json new file mode 100644 index 000000000..194cf10e9 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json @@ -0,0 +1,337 @@ +{ + "test_fabric_update_bulk_00000a": { + "TEST_NOTES": [ + "We will never see this response with the dcnm_fabric module, ", + "since the module will call FabricCreateBulk if the fabric does not exist.", + "And, even if FabricUpdateBulk() happens to be called instead,", + "FabricUpdateCommon()._build_payloads_to_commit() will not include", + "the payload if the fabric does not exist.", + "Including this response for reference only." + ], + "DATA": { + "error": "Not Found", + "path": "/rest/control/fabrics/AS_Plain/Easy_Fabric", + "status": 404, + "timestamp": 1711478586044 + }, + "MESSAGE": "Not Found", + "METHOD": "PUT", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics/AS_Plain/Easy_Fabric", + "RETURN_CODE": 404 + }, + "test_fabric_update_bulk_00031a": { + "DATA": { + "asn": "65001", + "deviceType": "n9k", + "fabricId": "FABRIC-2", + "fabricName": "f1", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "", + "AUTO_SYMMETRIC_VRF_LITE": "", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "", + "BANNER": "", + "BFD_AUTH_ENABLE": "", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "", + "BFD_ISIS_ENABLE": "", + "BFD_OSPF_ENABLE": "", + "BFD_PIM_ENABLE": "", + "BGP_AS": "65001", + "BGP_AS_PREV": "", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "", + "DEAFULT_QUEUING_POLICY_OTHER": "", + "DEAFULT_QUEUING_POLICY_R_SERIES": "", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "ENABLE_AAA": "", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "", + "ISIS_P2P_ENABLE": "", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "", + "SITE_ID": "", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + }, + "MESSAGE": "OK", + "METHOD": "PUT", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics/f1/Easy_Fabric", + "RETURN_CODE": 200 + }, + "test_fabric_update_bulk_00032a": { + "DATA": "Failed to update the fabric, due to invalid field [BOO] in payload, please provide valid fields for fabric-settings", + "MESSAGE": "Internal Server Error", + "METHOD": "PUT", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics/f1/Easy_Fabric", + "RETURN_CODE": 500, + "sequence_number": 1 + } +} diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py index f6965de93..a71343761 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py @@ -209,24 +209,17 @@ def test_fabric_create_bulk_00030(monkeypatch, fabric_create_bulk) -> None: fabrics exist on the controller and the RestSend() RETURN_CODE is 200. Code Flow - - main.Merge() is instantiated and instantiates FabricQuery() - - FabricQuery() instantiates FabricDetailsByName() - - FabricQuery.fabric_names is set to contain one fabric_name (f1) + - FabricCreateBulk.payloads is set to contain one payload for a fabric (f1) that does not exist on the controller. - - FabricQuery().commit() calls FabricDetailsByName().refresh() which - calls FabricDetails.refresh_super() - - FabricDetails.refresh_super() calls RestSend().commit() which sets - RestSend().response_current to a dict with keys DATA == [], - RETURN_CODE == 200, MESSAGE="OK" - - Hence, FabricDetails().data is set to an empty dict: {} - - Hence, FabricDetailsByName().data_subclass is set to an empty dict: {} - - Since FabricDetails().all_data is an empty dict, FabricQuery().commit() sets: - - instance.results.diff_current to an empty dict - - instance.results.response_current to the RestSend().response_current - - instance.results.result_current to the RestSend().result_current - - FabricQuery.commit() calls Results().register_task_result() - - Results().register_task_result() adds sequence_number (with value 1) to - each of the results dicts + - FabricCreateBulk.commit() calls FabricCreate()._build_payloads_to_commit() + - FabricCreate()._build_payloads_to_commit() calls FabricDetails().refresh() + which returns a dict with keys DATA == [], RETURN_CODE == 200 + - FabricCreate()._build_payloads_to_commit() sets FabricCreate()._payloads_to_commit + to a list containing fabric f1 payload + - FabricCreateBulk.commit() calls RestSend().commit() which sets RestSend().response_current + to a dict with keys: + - DATA == {f1 fabric data dict} + RETURN_CODE == 200 """ key = "test_fabric_create_bulk_00030a" @@ -250,7 +243,6 @@ def mock_dcnm_send(*args, **kwargs): instance.fabric_details.rest_send.unit_test = True monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) - instance.fabric_details.results = Results() with does_not_raise(): instance.commit() @@ -340,7 +332,6 @@ def mock_dcnm_send(*args, **kwargs): instance.results = Results() instance.payloads = payloads_fabric_create_bulk(key) instance.fabric_details.rest_send.unit_test = True - instance.fabric_details.results = Results() monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) with does_not_raise(): @@ -413,7 +404,6 @@ def mock_dcnm_send(*args, **kwargs): instance.rest_send.unit_test = True monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) - instance.fabric_details.results = Results() with does_not_raise(): instance.commit() diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py new file mode 100644 index 000000000..8b195e070 --- /dev/null +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py @@ -0,0 +1,499 @@ +# 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. + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# Also, fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=invalid-name + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +__copyright__ = "Copyright (c) 2024 Cisco and/or its affiliates." +__author__ = "Allen Robel" + +import pytest +from ansible_collections.ansible.netcommon.tests.unit.modules.utils import \ + AnsibleFailJson +from ansible_collections.cisco.dcnm.plugins.module_utils.common.results import \ + Results +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ + FabricDetailsByName +from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( + GenerateResponses, does_not_raise, fabric_update_bulk_fixture, payloads_fabric_update_bulk, + responses_fabric_update_bulk, responses_fabric_details, responses_fabric_summary, + rest_send_response_current) + + +def test_fabric_update_bulk_00010(fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - FabricUpdateBulk + - __init__() + + Test + - Class attributes are initialized to expected values + - fail_json is not called + """ + with does_not_raise(): + instance = fabric_update_bulk + assert instance.class_name == "FabricUpdateBulk" + assert instance.action == "update" + assert instance.state == "merged" + assert isinstance(instance.fabric_details, FabricDetailsByName) + + +def test_fabric_update_bulk_00020(fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricUpdateBulk + - __init__() + + Test + - payloads is set to expected value + - fail_json is not called + """ + key = "test_fabric_update_bulk_00020a" + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + instance.payloads = payloads_fabric_update_bulk(key) + assert instance.payloads == payloads_fabric_update_bulk(key) + + +def test_fabric_update_bulk_00021(fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricUpdateBulk + - __init__() + + Test + - fail_json is called because payloads is not a list + - instance.payloads is not modified, hence it retains its initial value of None + """ + match = r"FabricUpdateBulk\.payloads: " + match += r"payloads must be a list of dict\." + + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + with pytest.raises(AnsibleFailJson, match=match): + instance.payloads = "NOT_A_LIST" + assert instance.payloads is None + + +def test_fabric_update_bulk_00022(fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricUpdateBulk + - __init__() + + Test + - fail_json is called because payloads is a list with a non-dict element + - instance.payloads is not modified, hence it retains its initial value of None + """ + match = r"FabricUpdateBulk._verify_payload: " + match += r"payload must be a dict\." + + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + with pytest.raises(AnsibleFailJson, match=match): + instance.payloads = [1, 2, 3] + assert instance.payloads is None + + +def test_fabric_update_bulk_00023(fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricUpdateBulk + - __init__() + + Summary + Verify behavior when payloads is not set prior to calling commit + + Test + - fail_json is called because payloads is not set prior to calling commit + - instance.payloads is not modified, hence it retains its initial value of None + """ + match = r"FabricUpdateBulk\.commit: " + match += r"payloads must be set prior to calling commit\." + + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + with pytest.raises(AnsibleFailJson, match=match): + instance.commit() + assert instance.payloads is None + + +def test_fabric_update_bulk_00024(fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricUpdateBulk + - __init__() + + Summary + Verify behavior when payloads is set to an empty list + + Setup + - FabricUpdatebulk().payloads is set to an empty list + + Test + - fail_json not called + - payloads is set to an empty list + + NOTES: + - element_spec is configured such that AnsibleModule will raise an exception when + config is not a list of dict. Hence, we do not test instance.commit() here since + it would never be reached. + """ + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + instance.payloads = [] + assert instance.payloads == [] + + +def test_fabric_update_bulk_00030(monkeypatch, fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - payloads setter + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricUpdateBulk + - __init__() + - commit() + + Summary + Verify behavior when user attempts to update a fabric and no + fabrics exist on the controller. + + Code Flow + - FabricUpdateBulk.payloads is set to contain one payload for a fabric (f1) + that does not exist on the controller. + - FabricUpdateBulk.commit() calls FabricUpdateCommon()._build_payloads_to_commit() + - FabricUpdateCommon()._build_payloads_to_commit() calls FabricDetails().refresh() + which returns a dict with keys DATA == [], RETURN_CODE == 200 + - FabricUpdateCommon()._build_payloads_to_commit() sets FabricUpdate()._payloads_to_commit + to an empty list. + - FabricUpdateBulk.commit() updates the following: + - instance.results.diff_current to an empty dict + - instance.results.response_current a synthesized response dict + { "RETURN_CODE": 200, "MESSAGE": "No fabrics to update." } + - instance.results.result_current to a synthesized result dict + {"success": True, "changed": False} + - FabricUpdateBulk.commit() calls Results().register_task_result() + """ + key = "test_fabric_update_bulk_00030a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + yield responses_fabric_update_bulk(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + instance.payloads = payloads_fabric_update_bulk(key) + instance.fabric_details.rest_send.unit_test = True + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + with does_not_raise(): + instance.commit() + + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.metadata) == 1 + assert len(instance.results.response) == 1 + assert len(instance.results.result) == 1 + + assert instance.results.diff[0].get("sequence_number", None) == 1 + + assert instance.results.metadata[0].get("action", None) == "update" + assert instance.results.metadata[0].get("check_mode", None) is False + assert instance.results.metadata[0].get("sequence_number", None) == 1 + assert instance.results.metadata[0].get("state", None) == "merged" + + assert instance.results.response[0].get("RETURN_CODE", None) == 200 + assert instance.results.response[0].get("MESSAGE", None) == "No fabrics to update." + + assert instance.results.result[0].get("changed", None) == False + assert instance.results.result[0].get("success", None) == True + + assert False in instance.results.failed + assert True not in instance.results.failed + assert False in instance.results.changed + assert True not in instance.results.changed + + +def test_fabric_update_bulk_00031(monkeypatch, fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricQuery + - __init__() + - fabric_names setter + - commit() + - FabricUpdateBulk() + - __init__() + - commit() + + Summary + Verify behavior when user attempts to update a fabric and the + fabric exists on the controller and the RestSend() RETURN_CODE is 200. + + Code Flow + - FabricUpdateBulk.payloads is set to contain one payload for a fabric (f1) + that exists on the controller. + - FabricUpdateBulk.commit() calls FabricUpdateCommon()._build_payloads_to_commit() + - FabricUpdateCommon()._build_payloads_to_commit() calls FabricDetails().refresh() + which returns a dict with fabric f1 information and RETURN_CODE == 200 + - FabricUpdateCommon()._build_payloads_to_commit() appends the payload in + FabricUpdateBulk.payloads to FabricUpdate()._payloads_to_commit + - FabricUpdateBulk.commit() updates the following: + - instance.results.diff_current to an empty dict + - instance.results.response_current a synthesized response dict + { "RETURN_CODE": 200, "MESSAGE": "No fabrics to update." } + - instance.results.result_current to a synthesized result dict + {"success": True, "changed": False} + - FabricUpdateBulk.commit() calls FabricUpdateCommon()._send_payloads() + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._build_fabrics_to_config_deploy() + - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls FabricUpdateCommon()._can_be_deployed() + - FabricUpdateCommon()._can_be_deployed() calls FabricSummary().refresh() and then references + FabricSummary().fabric_is_empty to determine if the fabric is empty. If the fabric is empty, + it can be deployed, otherwise it cannot. Hence, _can_be_deployed() returns either True or False. + In this testcase, the fabric is empty, so _can_be_deployed() returns True. + - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: + - FabricUpdateCommon()._fabrics_to_config_deploy + - FabricUpdateCommon()._fabrics_to_config_save + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._fixup_payloads_to_commit() + - FabricUpdateCommon()._fixup_payloads_to_commit() updates ANYCAST_GW_MAC, if present, to + conform with the controller's requirements. + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._send_payload() for each + fabric in FabricUpdateCommon()._payloads_to_commit + + """ + key = "test_fabric_update_bulk_00031a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + yield responses_fabric_summary(key) + yield responses_fabric_update_bulk(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + instance.payloads = payloads_fabric_update_bulk(key) + instance.fabric_details.rest_send.unit_test = True + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + with does_not_raise(): + instance.commit() + + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.metadata) == 1 + assert len(instance.results.response) == 1 + assert len(instance.results.result) == 1 + + assert instance.results.diff[0].get("sequence_number", None) == 1 + assert instance.results.diff[0].get("BGP_AS", None) == 65001 + assert instance.results.diff[0].get("FABRIC_NAME", None) == "f1" + + assert instance.results.metadata[0].get("action", None) == "update" + assert instance.results.metadata[0].get("check_mode", None) is False + assert instance.results.metadata[0].get("sequence_number", None) == 1 + assert instance.results.metadata[0].get("state", None) == "merged" + + assert instance.results.response[0].get("RETURN_CODE", None) == 200 + assert instance.results.response[0].get("DATA", {}).get("nvPairs", {}).get("BGP_AS", None) == "65001" + assert instance.results.response[0].get("METHOD", None) == "PUT" + + assert instance.results.result[0].get("changed", None) == True + assert instance.results.result[0].get("success", None) == True + + assert False in instance.results.failed + assert True not in instance.results.failed + assert True in instance.results.changed + assert False not in instance.results.changed + + +def test_fabric_update_bulk_00032(monkeypatch, fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricQuery + - __init__() + - fabric_names setter + - commit() + - FabricUpdateBulk() + - __init__() + - commit() + + Summary + Verify behavior when user attempts to update a fabric and the + fabric exists on the controller but the RestSend() RETURN_CODE is 500. + + Code Flow + - FabricUpdateBulk.payloads is set to contain one payload for a fabric (f1) + that exists on the controller. + - FabricUpdateBulk.commit() calls FabricUpdateCommon()._build_payloads_to_commit() + - FabricUpdateCommon()._build_payloads_to_commit() calls FabricDetails().refresh() + which returns a dict with fabric f1 information and RETURN_CODE == 200 + - FabricUpdateCommon()._build_payloads_to_commit() appends the payload in + FabricUpdateBulk.payloads to FabricUpdatee()._payloads_to_commit + - FabricUpdateBulk.commit() updates the following: + - instance.results.diff_current to an empty dict + - instance.results.response_current a synthesized response dict + { "RETURN_CODE": 200, "MESSAGE": "No fabrics to update." } + - instance.results.result_current to a synthesized result dict + {"success": True, "changed": False} + - FabricUpdateBulk.commit() calls FabricUpdateCommon()._send_payloads() + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._build_fabrics_to_config_deploy() + - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls FabricUpdateCommon()._can_be_deployed() + - FabricUpdateCommon()._can_be_deployed() calls FabricSummary().refresh() and then references + FabricSummary().fabric_is_empty to determine if the fabric is empty. If the fabric is empty, + it can be deployed, otherwise it cannot. Hence, _can_be_deployed() returns either True or False. + In this testcase, the fabric is empty, so _can_be_deployed() returns True. + - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: + - FabricUpdateCommon()._fabrics_to_config_deploy + - FabricUpdateCommon()._fabrics_to_config_save + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._fixup_payloads_to_commit() + - FabricUpdateCommon()._fixup_payloads_to_commit() updates ANYCAST_GW_MAC, if present, to + conform with the controller's requirements. + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._send_payload() for each + fabric in FabricUpdateCommon()._payloads_to_commit + + """ + key = "test_fabric_update_bulk_00032a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + yield responses_fabric_summary(key) + yield responses_fabric_update_bulk(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + instance.payloads = payloads_fabric_update_bulk(key) + instance.fabric_details.rest_send.unit_test = True + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + with does_not_raise(): + instance.rest_send.unit_test = True + instance.commit() + + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.metadata) == 1 + assert len(instance.results.response) == 1 + assert len(instance.results.result) == 1 + + assert instance.results.diff[0].get("sequence_number", None) == 1 + + assert instance.results.metadata[0].get("action", None) == "update" + assert instance.results.metadata[0].get("check_mode", None) is False + assert instance.results.metadata[0].get("sequence_number", None) == 1 + assert instance.results.metadata[0].get("state", None) == "merged" + + assert instance.results.response[0].get("RETURN_CODE", None) == 500 + error_message = "Failed to update the fabric, due to invalid field [BOO] " + error_message += f"in payload, please provide valid fields for fabric-settings" + assert instance.results.response[0].get("DATA", None) == error_message + assert instance.results.response[0].get("METHOD", None) == "PUT" + assert instance.results.response[0].get("MESSAGE", None) == "Internal Server Error" + + assert instance.results.result[0].get("changed", None) == False + assert instance.results.result[0].get("success", None) == False + + assert True in instance.results.failed + assert False not in instance.results.failed + assert False in instance.results.changed + assert True not in instance.results.changed diff --git a/tests/unit/modules/dcnm/dcnm_fabric/utils.py b/tests/unit/modules/dcnm/dcnm_fabric/utils.py index aef952fd2..f24be8ef2 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/utils.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/utils.py @@ -31,6 +31,8 @@ FabricDelete from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import \ FabricQuery +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import \ + FabricUpdateCommon, FabricUpdate, FabricUpdateBulk from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.fixture import \ load_fixture @@ -173,6 +175,16 @@ def fabric_query_fixture(): return FabricQuery(instance) +@pytest.fixture(name="fabric_update_bulk") +def fabric_update_bulk_fixture(): + """ + mock FabricUpdateBulk + """ + instance = MockAnsibleModule() + instance.state = "merged" + return FabricUpdateBulk(instance) + + @contextmanager def does_not_raise(): """ @@ -201,11 +213,11 @@ def payloads_fabric_create_bulk(key: str) -> Dict[str, str]: return data -def responses_fabric_details(key: str) -> Dict[str, str]: +def payloads_fabric_update_bulk(key: str) -> Dict[str, str]: """ - Return responses for FabricDetails + Return payloads for FabricUpdateBulk """ - data_file = "responses_FabricDetails" + data_file = "payloads_FabricUpdateBulk" data = load_fixture(data_file).get(key) print(f"{data_file}: {key} : {data}") return data @@ -251,6 +263,36 @@ def responses_fabric_delete(key: str) -> Dict[str, str]: return data +def responses_fabric_details(key: str) -> Dict[str, str]: + """ + Return responses for FabricDetails + """ + data_file = "responses_FabricDetails" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_fabric_summary(key: str) -> Dict[str, str]: + """ + Return responses for FabricSummary + """ + data_file = "responses_FabricSummary" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + +def responses_fabric_update_bulk(key: str) -> Dict[str, str]: + """ + Return responses for FabricUpdateBulk + """ + data_file = "responses_FabricUpdateBulk" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + def results_fabric_details(key: str) -> Dict[str, str]: """ Return results for FabricDetails From 41a0b1708c90e577542aa2fc399a6a91c72337f2 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 26 Mar 2024 15:55:51 -1000 Subject: [PATCH 017/228] Move _fixup_payloads_to_commit() to FabricCommon(), more... FabricCreateCommon() and FabricUpdateCommon(): move _fixup_payloads_to_commit() to FabricCommon() and modify it to raise ValueError if ANYCAST_GW_MAC is not convertable. test_fabric_update_bulk.py: Add unit test to verify behavior when ANYCAST_GW_MAC is not convertable. test_fabric_update_bulk.py: Modify unit test 00031 to include ANYCAST_GW_MAC that IS convertable. --- plugins/module_utils/fabric/common.py | 36 +- plugins/module_utils/fabric/create.py | 15 - plugins/module_utils/fabric/update.py | 15 - .../fixtures/payloads_FabricUpdateBulk.json | 12 +- .../fixtures/responses_FabricDetails.json | 313 ++++++++++++++++++ .../fixtures/responses_FabricSummary.json | 13 + .../fixtures/responses_FabricUpdateBulk.json | 2 +- .../dcnm_fabric/test_fabric_update_bulk.py | 108 ++++++ 8 files changed, 481 insertions(+), 33 deletions(-) diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index 537e4be8f..e0f3bd031 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -62,6 +62,40 @@ def __init__(self, ansible_module): self.fabric_type_to_template_name_map = {} self.fabric_type_to_template_name_map["VXLAN_EVPN"] = "Easy_Fabric" + def _fixup_payloads_to_commit(self) -> None: + """ + Make any modifications to the payloads prior to sending them + to the controller. + + Add any modifications to the list below. + + - Translate ANYCAST_GW_MAC to a format the controller understands + """ + method_name = inspect.stack()[0][3] + for payload in self._payloads_to_commit: + if not "ANYCAST_GW_MAC" in payload: + continue + try: + payload["ANYCAST_GW_MAC"] = self.translate_mac_address( + payload["ANYCAST_GW_MAC"] + ) + except ValueError as error: + fabric_name = "UNKNOWN" + anycast_gw_mac = "UNKNOWN" + if "FABRIC_NAME" in payload: + fabric_name = payload["FABRIC_NAME"] + if "ANYCAST_GW_MAC" in payload: + anycast_gw_mac = payload["ANYCAST_GW_MAC"] + 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}" + self.results.failed = True + self.results.changed = False + self.results.register_task_result() + self.ansible_module.fail_json(msg, **self.results.failed_result) + @staticmethod def translate_mac_address(mac_addr): """ @@ -73,7 +107,7 @@ def translate_mac_address(mac_addr): """ mac_addr = re.sub(r"[\W\s_]", "", mac_addr) if not re.search("^[A-Fa-f0-9]{12}$", mac_addr): - return False + raise ValueError(f"Invalid MAC address: {mac_addr}") return "".join((mac_addr[:4], ".", mac_addr[4:8], ".", mac_addr[8:])) def _handle_response(self, response, verb) -> Dict[str, Any]: diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index a84232add..47d4d5453 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -101,21 +101,6 @@ def _verify_payload(self, payload) -> None: msg += f"{sorted(missing_keys)}" self.ansible_module.fail_json(msg, **self.results.failed_result) - def _fixup_payloads_to_commit(self) -> None: - """ - Make any modifications to the payloads prior to sending them - to the controller. - - Add any modifications to the list below. - - - Translate ANYCAST_GW_MAC to a format the controller understands - """ - for payload in self._payloads_to_commit: - if "ANYCAST_GW_MAC" in payload: - payload["ANYCAST_GW_MAC"] = self.translate_mac_address( - payload["ANYCAST_GW_MAC"] - ) - def _build_payloads_to_commit(self) -> None: """ Build a list of payloads to commit. Skip any payloads that diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index dfaf32704..0ba945e48 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -157,21 +157,6 @@ def _build_payloads_to_commit(self): if payload.get("FABRIC_NAME", None) in self.fabric_details.all_data: self._payloads_to_commit.append(copy.deepcopy(payload)) - def _fixup_payloads_to_commit(self): - """ - Make any modifications to the payloads prior to sending them - to the controller. - - Add any modifications to the list below. - - - Translate ANYCAST_GW_MAC to a format the controller understands - """ - for payload in self._payloads_to_commit: - if "ANYCAST_GW_MAC" in payload: - payload["ANYCAST_GW_MAC"] = self.translate_mac_address( - payload["ANYCAST_GW_MAC"] - ) - def _send_payloads(self): """ If check_mode is False, send the payloads to the controller diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json index 4b7cb6f7b..8c6b2774d 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricUpdateBulk.json @@ -24,7 +24,8 @@ "BGP_AS": 65001, "DEPLOY": true, "FABRIC_NAME": "f1", - "FABRIC_TYPE": "VXLAN_EVPN" + "FABRIC_TYPE": "VXLAN_EVPN", + "ANYCAST_GW_MAC": "0001aabbccdd" } ], "test_fabric_update_bulk_00032a": [ @@ -35,5 +36,14 @@ "FABRIC_NAME": "f1", "FABRIC_TYPE": "VXLAN_EVPN" } + ], + "test_fabric_update_bulk_00033a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "VXLAN_EVPN", + "ANYCAST_GW_MAC": "0001zzbbccdd" + } ] } \ No newline at end of file diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json index dd5e227d7..9dba2ab66 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json @@ -961,5 +961,318 @@ "METHOD": "GET", "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", "RETURN_CODE": 200 + }, + "test_fabric_update_bulk_00033a": { + "DATA": [ + { + "asn": "65001", + "createdOn": 1711411093680, + "deviceType": "n9k", + "fabricId": "FABRIC-2", + "fabricName": "f1", + "fabricTechnology": "VXLANFabric", + "fabricTechnologyFriendly": "VXLAN EVPN", + "fabricType": "Switch_Fabric", + "fabricTypeFriendly": "Switch Fabric", + "id": 2, + "modifiedOn": 1711411096857, + "networkExtensionTemplate": "Default_Network_Extension_Universal", + "networkTemplate": "Default_Network_Universal", + "nvPairs": { + "AAA_REMOTE_IP_ENABLED": "false", + "AAA_SERVER_CONF": "", + "ACTIVE_MIGRATION": "false", + "ADVERTISE_PIP_BGP": "false", + "ADVERTISE_PIP_ON_BORDER": "true", + "AGENT_INTF": "eth0", + "AI_ML_QOS_POLICY": "AI_Fabric_QOS_400G", + "ALLOW_NXC": "true", + "ALLOW_NXC_PREV": "true", + "ANYCAST_BGW_ADVERTISE_PIP": "false", + "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_LB_ID": "", + "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", + "ANYCAST_RP_IP_RANGE_INTERNAL": "", + "AUTO_SYMMETRIC_DEFAULT_VRF": "false", + "AUTO_SYMMETRIC_VRF_LITE": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX": "false", + "AUTO_UNIQUE_VRF_LITE_IP_PREFIX_PREV": "false", + "AUTO_VRFLITE_IFC_DEFAULT_VRF": "false", + "BANNER": "", + "BFD_AUTH_ENABLE": "false", + "BFD_AUTH_KEY": "", + "BFD_AUTH_KEY_ID": "", + "BFD_ENABLE": "false", + "BFD_ENABLE_PREV": "", + "BFD_IBGP_ENABLE": "false", + "BFD_ISIS_ENABLE": "false", + "BFD_OSPF_ENABLE": "false", + "BFD_PIM_ENABLE": "false", + "BGP_AS": "65001", + "BGP_AS_PREV": "65001", + "BGP_AUTH_ENABLE": "false", + "BGP_AUTH_KEY": "", + "BGP_AUTH_KEY_TYPE": "3", + "BGP_LB_ID": "0", + "BOOTSTRAP_CONF": "", + "BOOTSTRAP_ENABLE": "false", + "BOOTSTRAP_ENABLE_PREV": "false", + "BOOTSTRAP_MULTISUBNET": "#Scope_Start_IP, Scope_End_IP, Scope_Default_Gateway, Scope_Subnet_Prefix", + "BOOTSTRAP_MULTISUBNET_INTERNAL": "", + "BRFIELD_DEBUG_FLAG": "Disable", + "BROWNFIELD_NETWORK_NAME_FORMAT": "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$", + "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS": "false", + "CDP_ENABLE": "false", + "COPP_POLICY": "strict", + "DCI_SUBNET_RANGE": "10.33.0.0/16", + "DCI_SUBNET_TARGET_MASK": "30", + "DEAFULT_QUEUING_POLICY_CLOUDSCALE": "queuing_policy_default_8q_cloudscale", + "DEAFULT_QUEUING_POLICY_OTHER": "queuing_policy_default_other", + "DEAFULT_QUEUING_POLICY_R_SERIES": "queuing_policy_default_r_series", + "DEFAULT_VRF_REDIS_BGP_RMAP": "", + "DEPLOYMENT_FREEZE": "false", + "DHCP_ENABLE": "false", + "DHCP_END": "", + "DHCP_END_INTERNAL": "", + "DHCP_IPV6_ENABLE": "", + "DHCP_IPV6_ENABLE_INTERNAL": "", + "DHCP_START": "", + "DHCP_START_INTERNAL": "", + "DNS_SERVER_IP_LIST": "", + "DNS_SERVER_VRF": "", + "DOMAIN_NAME_INTERNAL": "", + "ENABLE_AAA": "false", + "ENABLE_AGENT": "false", + "ENABLE_AI_ML_QOS_POLICY": "false", + "ENABLE_AI_ML_QOS_POLICY_FLAP": "false", + "ENABLE_DEFAULT_QUEUING_POLICY": "false", + "ENABLE_EVPN": "true", + "ENABLE_FABRIC_VPC_DOMAIN_ID": "false", + "ENABLE_FABRIC_VPC_DOMAIN_ID_PREV": "false", + "ENABLE_L3VNI_NO_VLAN": "false", + "ENABLE_MACSEC": "false", + "ENABLE_NETFLOW": "false", + "ENABLE_NETFLOW_PREV": "false", + "ENABLE_NGOAM": "true", + "ENABLE_NXAPI": "true", + "ENABLE_NXAPI_HTTP": "true", + "ENABLE_PBR": "false", + "ENABLE_PVLAN": "false", + "ENABLE_PVLAN_PREV": "false", + "ENABLE_SGT": "false", + "ENABLE_SGT_PREV": "false", + "ENABLE_TENANT_DHCP": "true", + "ENABLE_TRM": "false", + "ENABLE_VPC_PEER_LINK_NATIVE_VLAN": "false", + "ESR_OPTION": "PBR", + "EXTRA_CONF_INTRA_LINKS": "", + "EXTRA_CONF_LEAF": "", + "EXTRA_CONF_SPINE": "", + "EXTRA_CONF_TOR": "", + "EXT_FABRIC_TYPE": "", + "FABRIC_INTERFACE_TYPE": "p2p", + "FABRIC_MTU": "9216", + "FABRIC_MTU_PREV": "9216", + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "Switch_Fabric", + "FABRIC_VPC_DOMAIN_ID": "", + "FABRIC_VPC_DOMAIN_ID_PREV": "", + "FABRIC_VPC_QOS": "false", + "FABRIC_VPC_QOS_POLICY_NAME": "spine_qos_for_fabric_vpc_peering", + "FEATURE_PTP": "false", + "FEATURE_PTP_INTERNAL": "false", + "FF": "Easy_Fabric", + "GRFIELD_DEBUG_FLAG": "Disable", + "HD_TIME": "180", + "HOST_INTF_ADMIN_STATE": "true", + "IBGP_PEER_TEMPLATE": "", + "IBGP_PEER_TEMPLATE_LEAF": "", + "INBAND_DHCP_SERVERS": "", + "INBAND_MGMT": "false", + "INBAND_MGMT_PREV": "false", + "ISIS_AREA_NUM": "0001", + "ISIS_AREA_NUM_PREV": "", + "ISIS_AUTH_ENABLE": "false", + "ISIS_AUTH_KEY": "", + "ISIS_AUTH_KEYCHAIN_KEY_ID": "", + "ISIS_AUTH_KEYCHAIN_NAME": "", + "ISIS_LEVEL": "level-2", + "ISIS_OVERLOAD_ELAPSE_TIME": "", + "ISIS_OVERLOAD_ENABLE": "", + "ISIS_P2P_ENABLE": "", + "L2_HOST_INTF_MTU": "9216", + "L2_HOST_INTF_MTU_PREV": "9216", + "L2_SEGMENT_ID_RANGE": "30000-49000", + "L3VNI_MCAST_GROUP": "", + "L3_PARTITION_ID_RANGE": "50000-59000", + "LINK_STATE_ROUTING": "ospf", + "LINK_STATE_ROUTING_TAG": "UNDERLAY", + "LINK_STATE_ROUTING_TAG_PREV": "UNDERLAY", + "LOOPBACK0_IPV6_RANGE": "", + "LOOPBACK0_IP_RANGE": "10.2.0.0/22", + "LOOPBACK1_IPV6_RANGE": "", + "LOOPBACK1_IP_RANGE": "10.3.0.0/22", + "MACSEC_ALGORITHM": "", + "MACSEC_CIPHER_SUITE": "", + "MACSEC_FALLBACK_ALGORITHM": "", + "MACSEC_FALLBACK_KEY_STRING": "", + "MACSEC_KEY_STRING": "", + "MACSEC_REPORT_TIMER": "", + "MGMT_GW": "", + "MGMT_GW_INTERNAL": "", + "MGMT_PREFIX": "", + "MGMT_PREFIX_INTERNAL": "", + "MGMT_V6PREFIX": "", + "MGMT_V6PREFIX_INTERNAL": "", + "MPLS_HANDOFF": "false", + "MPLS_ISIS_AREA_NUM": "0001", + "MPLS_ISIS_AREA_NUM_PREV": "", + "MPLS_LB_ID": "", + "MPLS_LOOPBACK_IP_RANGE": "", + "MSO_CONNECTIVITY_DEPLOYED": "", + "MSO_CONTROLER_ID": "", + "MSO_SITE_GROUP_NAME": "", + "MSO_SITE_ID": "", + "MST_INSTANCE_RANGE": "", + "MULTICAST_GROUP_SUBNET": "239.1.1.0/25", + "NETFLOW_EXPORTER_LIST": "", + "NETFLOW_MONITOR_LIST": "", + "NETFLOW_RECORD_LIST": "", + "NETWORK_VLAN_RANGE": "2300-2999", + "NTP_SERVER_IP_LIST": "", + "NTP_SERVER_VRF": "", + "NVE_LB_ID": "1", + "NXAPI_HTTPS_PORT": "443", + "NXAPI_HTTP_PORT": "80", + "NXC_DEST_VRF": "", + "NXC_PROXY_PORT": "8080", + "NXC_PROXY_SERVER": "", + "NXC_SRC_INTF": "", + "OBJECT_TRACKING_NUMBER_RANGE": "100-299", + "OSPF_AREA_ID": "0.0.0.0", + "OSPF_AUTH_ENABLE": "false", + "OSPF_AUTH_KEY": "", + "OSPF_AUTH_KEY_ID": "", + "OVERLAY_MODE": "cli", + "OVERLAY_MODE_PREV": "cli", + "OVERWRITE_GLOBAL_NXC": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_PREV": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6": "false", + "PER_VRF_LOOPBACK_AUTO_PROVISION_V6_PREV": "false", + "PER_VRF_LOOPBACK_IP_RANGE": "", + "PER_VRF_LOOPBACK_IP_RANGE_V6": "", + "PHANTOM_RP_LB_ID1": "", + "PHANTOM_RP_LB_ID2": "", + "PHANTOM_RP_LB_ID3": "", + "PHANTOM_RP_LB_ID4": "", + "PIM_HELLO_AUTH_ENABLE": "false", + "PIM_HELLO_AUTH_KEY": "", + "PM_ENABLE": "false", + "PM_ENABLE_PREV": "false", + "POWER_REDUNDANCY_MODE": "ps-redundant", + "PREMSO_PARENT_FABRIC": "", + "PTP_DOMAIN_ID": "", + "PTP_LB_ID": "", + "REPLICATION_MODE": "Multicast", + "ROUTER_ID_RANGE": "", + "ROUTE_MAP_SEQUENCE_NUMBER_RANGE": "1-65534", + "RP_COUNT": "2", + "RP_LB_ID": "254", + "RP_MODE": "asm", + "RR_COUNT": "2", + "SEED_SWITCH_CORE_INTERFACES": "", + "SERVICE_NETWORK_VLAN_RANGE": "3000-3199", + "SGT_ID_RANGE": "", + "SGT_NAME_PREFIX": "", + "SGT_PREPROVISION": "", + "SITE_ID": "65001", + "SLA_ID_RANGE": "10000-19999", + "SNMP_SERVER_HOST_TRAP": "true", + "SPINE_COUNT": "0", + "SPINE_SWITCH_CORE_INTERFACES": "", + "SSPINE_ADD_DEL_DEBUG_FLAG": "Disable", + "SSPINE_COUNT": "0", + "STATIC_UNDERLAY_IP_ALLOC": "false", + "STP_BRIDGE_PRIORITY": "", + "STP_ROOT_OPTION": "unmanaged", + "STP_VLAN_RANGE": "", + "STRICT_CC_MODE": "false", + "SUBINTERFACE_RANGE": "2-511", + "SUBNET_RANGE": "10.4.0.0/16", + "SUBNET_TARGET_MASK": "30", + "SYSLOG_SERVER_IP_LIST": "", + "SYSLOG_SERVER_VRF": "", + "SYSLOG_SEV": "", + "TCAM_ALLOCATION": "true", + "TOPDOWN_CONFIG_RM_TRACKING": "notstarted", + "UNDERLAY_IS_V6": "false", + "UNNUM_BOOTSTRAP_LB_ID": "", + "UNNUM_DHCP_END": "", + "UNNUM_DHCP_END_INTERNAL": "", + "UNNUM_DHCP_START": "", + "UNNUM_DHCP_START_INTERNAL": "", + "UPGRADE_FROM_VERSION": "", + "USE_LINK_LOCAL": "true", + "V6_SUBNET_RANGE": "", + "V6_SUBNET_TARGET_MASK": "126", + "VPC_AUTO_RECOVERY_TIME": "360", + "VPC_DELAY_RESTORE": "150", + "VPC_DELAY_RESTORE_TIME": "60", + "VPC_DOMAIN_ID_RANGE": "1-1000", + "VPC_ENABLE_IPv6_ND_SYNC": "true", + "VPC_PEER_KEEP_ALIVE_OPTION": "management", + "VPC_PEER_LINK_PO": "500", + "VPC_PEER_LINK_VLAN": "3600", + "VRF_LITE_AUTOCONFIG": "Manual", + "VRF_VLAN_RANGE": "2000-2299", + "abstract_anycast_rp": "anycast_rp", + "abstract_bgp": "base_bgp", + "abstract_bgp_neighbor": "evpn_bgp_rr_neighbor", + "abstract_bgp_rr": "evpn_bgp_rr", + "abstract_dhcp": "base_dhcp", + "abstract_extra_config_bootstrap": "extra_config_bootstrap_11_1", + "abstract_extra_config_leaf": "extra_config_leaf", + "abstract_extra_config_spine": "extra_config_spine", + "abstract_extra_config_tor": "extra_config_tor", + "abstract_feature_leaf": "base_feature_leaf_upg", + "abstract_feature_spine": "base_feature_spine_upg", + "abstract_isis": "base_isis_level2", + "abstract_isis_interface": "isis_interface", + "abstract_loopback_interface": "int_fabric_loopback_11_1", + "abstract_multicast": "base_multicast_11_1", + "abstract_ospf": "base_ospf", + "abstract_ospf_interface": "ospf_interface_11_1", + "abstract_pim_interface": "pim_interface", + "abstract_route_map": "route_map", + "abstract_routed_host": "int_routed_host", + "abstract_trunk_host": "int_trunk_host", + "abstract_vlan_interface": "int_fabric_vlan_11_1", + "abstract_vpc_domain": "base_vpc_domain_11_1", + "default_network": "Default_Network_Universal", + "default_pvlan_sec_network": "", + "default_vrf": "Default_VRF_Universal", + "enableRealTimeBackup": "", + "enableScheduledBackup": "", + "network_extension_template": "Default_Network_Extension_Universal", + "scheduledTime": "", + "temp_anycast_gateway": "anycast_gateway", + "temp_vpc_domain_mgmt": "vpc_domain_mgmt", + "temp_vpc_peer_link": "int_vpc_peer_link_po", + "vrf_extension_template": "Default_VRF_Extension_Universal" + }, + "operStatus": "HEALTHY", + "provisionMode": "DCNMTopDown", + "replicationMode": "Multicast", + "siteId": "65001", + "templateName": "Easy_Fabric", + "vrfExtensionTemplate": "Default_VRF_Extension_Universal", + "vrfTemplate": "Default_VRF_Universal" + } + ], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 } } diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json index 748e30c28..665246958 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricSummary.json @@ -27,5 +27,18 @@ "METHOD": "GET", "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/switches/f1/overview", "RETURN_CODE": 200 + }, + "test_fabric_update_bulk_00033a": { + "DATA": { + "switchConfig": {}, + "switchHWVersions": {}, + "switchHealth": {}, + "switchRoles": {}, + "switchSWVersions": {} + }, + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/switches/f1/overview", + "RETURN_CODE": 200 } } diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json index 194cf10e9..e74690664 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricUpdateBulk.json @@ -43,7 +43,7 @@ "ALLOW_NXC": "true", "ALLOW_NXC_PREV": "", "ANYCAST_BGW_ADVERTISE_PIP": "false", - "ANYCAST_GW_MAC": "2020.0000.00aa", + "ANYCAST_GW_MAC": "0001.aabb.ccdd", "ANYCAST_LB_ID": "", "ANYCAST_RP_IP_RANGE": "10.254.254.0/24", "ANYCAST_RP_IP_RANGE_INTERNAL": "", diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py index 8b195e070..52f8ba41e 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py @@ -299,6 +299,9 @@ def test_fabric_update_bulk_00031(monkeypatch, fabric_update_bulk) -> None: Summary Verify behavior when user attempts to update a fabric and the fabric exists on the controller and the RestSend() RETURN_CODE is 200. + The fabric payload includes ANYCAST_GW_MAC, formatted to be incompatible + with the controller's requirements, but able to be fixed by + FabricUpdateCommon()._fixup_payloads_to_commit(). Code Flow - FabricUpdateBulk.payloads is set to contain one payload for a fabric (f1) @@ -497,3 +500,108 @@ def mock_dcnm_send(*args, **kwargs): assert False not in instance.results.failed assert False in instance.results.changed assert True not in instance.results.changed + + +def test_fabric_update_bulk_00033(monkeypatch, fabric_update_bulk) -> None: + """ + Classes and Methods + - FabricCommon() + - __init__() + - FabricDetails() + - __init__() + - refresh_super() + - FabricDetailsByName() + - __init__() + - refresh() + - FabricQuery + - __init__() + - fabric_names setter + - commit() + - FabricUpdateBulk() + - __init__() + - commit() + + Summary + Verify behavior when user attempts to update a fabric and the + fabric exists on the controller and the RestSend() RETURN_CODE is 500. + The fabric payload includes ANYCAST_GW_MAC, formatted to be incompatible + with the controller's requirements, and not able to be fixed by + FabricUpdateCommon()._fixup_payloads_to_commit(). + + Code Flow + - FabricUpdateBulk.payloads is set to contain one payload for a fabric (f1) + that exists on the controller. + - FabricUpdateBulk.commit() calls FabricUpdateCommon()._build_payloads_to_commit() + - FabricUpdateCommon()._build_payloads_to_commit() calls FabricDetails().refresh() + which returns a dict with fabric f1 information and RETURN_CODE == 200 + - FabricUpdateCommon()._build_payloads_to_commit() appends the payload in + FabricUpdateBulk.payloads to FabricUpdate()._payloads_to_commit + - FabricUpdateBulk.commit() updates the following: + - instance.results.action to self.action + - instance.results.state to self.state + - instance.results.check_mode to self.check_mode + - FabricUpdateBulk.commit() calls FabricUpdateCommon()._send_payloads() + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._build_fabrics_to_config_deploy() + - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls FabricUpdateCommon()._can_be_deployed() + - FabricUpdateCommon()._can_be_deployed() calls FabricSummary().refresh() and then references + FabricSummary().fabric_is_empty to determine if the fabric is empty. If the fabric is empty, + it can be deployed, otherwise it cannot. Hence, _can_be_deployed() returns either True or False. + In this testcase, the fabric is empty, so _can_be_deployed() returns True. + - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: + - FabricUpdateCommon()._fabrics_to_config_deploy + - FabricUpdateCommon()._fabrics_to_config_save + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._fixup_payloads_to_commit() + - FabricCommon()._fixup_payloads_to_commit() calls FabricCommon().translate_mac_address() + to update ANYCAST_GW_MAC to conform with the controller's requirements, but the + mac address is not convertable, so translate_mac_address() raises a ValueError. + - Responding to the ValueError FabricCommon()._fixup_payloads_to_commit() takes the except + path of its try/except block, which: + - Updates results and calls results.register_task_result() + - Calls fail_json() + + """ + key = "test_fabric_update_bulk_00033a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + yield responses_fabric_summary(key) + + gen = GenerateResponses(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_update_bulk + instance.results = Results() + instance.payloads = payloads_fabric_update_bulk(key) + instance.fabric_details.rest_send.unit_test = True + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + match = r"FabricUpdateBulk\._fixup_payloads_to_commit: " + match += r"Error translating ANYCAST_GW_MAC" + with pytest.raises(AnsibleFailJson, match=match): + instance.commit() + + assert isinstance(instance.results.diff, list) + assert isinstance(instance.results.result, list) + assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 + assert len(instance.results.metadata) == 1 + assert len(instance.results.response) == 1 + assert len(instance.results.result) == 1 + + assert instance.results.diff[0].get("sequence_number", None) == 1 + + assert instance.results.metadata[0].get("action", None) == "update" + assert instance.results.metadata[0].get("check_mode", None) is False + assert instance.results.metadata[0].get("sequence_number", None) == 1 + assert instance.results.metadata[0].get("state", None) == "merged" + + assert True in instance.results.failed + assert False not in instance.results.failed + assert False in instance.results.changed From 8495983dbb5256562944a086ce1e2598372c3d71 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Tue, 26 Mar 2024 16:00:17 -1000 Subject: [PATCH 018/228] FabricCommon()._fixup_payloads_to_commit(), simplify --- plugins/module_utils/fabric/common.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index e0f3bd031..0457ed311 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -80,20 +80,18 @@ def _fixup_payloads_to_commit(self) -> None: payload["ANYCAST_GW_MAC"] ) except ValueError as error: - fabric_name = "UNKNOWN" - anycast_gw_mac = "UNKNOWN" - if "FABRIC_NAME" in payload: - fabric_name = payload["FABRIC_NAME"] - if "ANYCAST_GW_MAC" in payload: - anycast_gw_mac = payload["ANYCAST_GW_MAC"] + fabric_name = payload.get("FABRIC_NAME", "UNKNOWN") + anycast_gw_mac = payload.get("ANYCAST_GW_MAC", "UNKNOWN") + + 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: {anycast_gw_mac}, " msg += f"Error detail: {error}" - self.results.failed = True - self.results.changed = False - self.results.register_task_result() self.ansible_module.fail_json(msg, **self.results.failed_result) @staticmethod From fee96d16b4492c291432d3091bc32bf958ee8f69 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 27 Mar 2024 07:05:00 -1000 Subject: [PATCH 019/228] Update docstrings, more... FabricCreate().commit(): Leverage FabricCreateCommon()._set_fabric_create_endpoint() FabricCreate().commit(): Set RestSend().check_mode and RestSend().timeout prior to sending the request. --- plugins/module_utils/fabric/common.py | 2 +- plugins/module_utils/fabric/create.py | 29 ++++++++++---------------- plugins/module_utils/fabric/delete.py | 30 +++++++++++++-------------- plugins/module_utils/fabric/update.py | 4 ++-- 4 files changed, 28 insertions(+), 37 deletions(-) diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index 0457ed311..86aa07b16 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -101,7 +101,7 @@ def translate_mac_address(mac_addr): into the dotted-quad format that the controller expects. Return mac address formatted for the controller on success - Return False on failure. + Raise ValueError on failure. """ mac_addr = re.sub(r"[\W\s_]", "", mac_addr) if not re.search("^[A-Fa-f0-9]{12}$", mac_addr): diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index 47d4d5453..f9c9adf7e 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -157,7 +157,7 @@ def _send_payloads(self): payload.pop("DEPLOY", None) # 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 + # 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 @@ -290,6 +290,11 @@ def commit(self): class FabricCreate(FabricCreateCommon): """ Create a VXLAN fabric on the controller. + + NOTES: + - FabricCreateBulk is used currently. + - FabricCreate may be useful in the future, but is not currently used + and could be deleted if not needed. """ def __init__(self, ansible_module): @@ -325,24 +330,12 @@ def commit(self): if len(self.payload) == 0: self.ansible_module.exit_json(**self.results.failed_result) - fabric_name = self.payload.get("FABRIC_NAME") - if fabric_name is None: - msg = f"{self.class_name}.{method_name}: " - msg += "payload is missing mandatory FABRIC_NAME key." - self.ansible_module.fail_json(msg, **self.results.failed_result) + self._set_fabric_create_endpoint(self.payload) - self.endpoints.fabric_name = fabric_name - self.endpoints.template_name = "Easy_Fabric" - try: - endpoint = self.endpoints.fabric_create - except ValueError as error: - self.ansible_module.fail_json(error, **self.results.failed_result) - - path = endpoint["path"] - verb = endpoint["verb"] - - self.rest_send.path = path - self.rest_send.verb = verb + self.rest_send.check_mode = self.check_mode + self.rest_send.timeout = 1 + self.rest_send.path = self.path + self.rest_send.verb = self.verb self.rest_send.payload = self.payload self.rest_send.commit() diff --git a/plugins/module_utils/fabric/delete.py b/plugins/module_utils/fabric/delete.py index 2e6ec88c5..d95eabdbd 100644 --- a/plugins/module_utils/fabric/delete.py +++ b/plugins/module_utils/fabric/delete.py @@ -129,7 +129,7 @@ def _can_fabric_be_deleted(self, fabric_name): def _set_fabric_delete_endpoint(self, fabric_name): """ - return the endpoint for the fabric_name + Set the fabric delete endpoint for fabric_name """ self._endpoints.fabric_name = fabric_name try: @@ -174,29 +174,27 @@ def commit(self): def _send_requests(self): """ - If check_mode is False, send the requests to the controller - If check_mode is True, do not send the requests to the controller - - In both cases, populate the following lists: - - - self.response_ok : list of controller responses associated with success result - - self.result_ok : list of results where success is True - - self.diff_ok : list of payloads for which the request succeeded - - self.response_nok : list of controller responses associated with failed result - - self.result_nok : list of results where success is False - - self.diff_nok : list of payloads for which the request failed + 1. Update RestSend() parameters: + - check_mode : Enables or disables sending the request + - timeout : Reduce to 1 second from default of 300 seconds + 2. Call _send_request() for each fabric to 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 - - # 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. self.rest_send.timeout = 1 for fabric_name in self._fabrics_to_delete: self._send_request(fabric_name) def _send_request(self, fabric_name): + """ + Send a delete request to the controller and register the result. + """ method_name = inspect.stack()[0][3] self._set_fabric_delete_endpoint(fabric_name) diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index 0ba945e48..9fe340dfd 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -141,8 +141,8 @@ def _verify_payload(self, payload): def _build_payloads_to_commit(self): """ - Build a list of payloads to commit. Skip any payloads that - already exist on the controller. + Build a list of payloads to commit. Skip payloads for fabrics + that do not 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. From f106a2d0f353ac0e6650b9fcdb00109038fd0bf2 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 27 Mar 2024 07:23:13 -1000 Subject: [PATCH 020/228] Rename GenerateResponses() to ResponseGenerator(), more... Breakup assert statements into sections for visual consistency across test cases. --- .../dcnm_fabric/test_fabric_create_bulk.py | 10 +++-- .../dcnm/dcnm_fabric/test_fabric_query.py | 38 ++++++++++++++----- .../dcnm_fabric/test_fabric_update_bulk.py | 17 +++++---- tests/unit/modules/dcnm/dcnm_fabric/utils.py | 14 ++++++- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py index a71343761..d986bdbfa 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py @@ -37,7 +37,7 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( - GenerateResponses, does_not_raise, fabric_create_bulk_fixture, payloads_fabric_create_bulk, + ResponseGenerator, does_not_raise, fabric_create_bulk_fixture, payloads_fabric_create_bulk, responses_fabric_create_bulk, responses_fabric_details, rest_send_response_current) @@ -230,7 +230,7 @@ def responses(): yield responses_fabric_details(key) yield responses_fabric_create_bulk(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -249,6 +249,7 @@ def mock_dcnm_send(*args, **kwargs): assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.metadata) == 1 assert len(instance.results.response) == 1 @@ -321,7 +322,7 @@ def test_fabric_create_bulk_00031(monkeypatch, fabric_create_bulk) -> None: def responses(): yield responses_fabric_details(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -390,7 +391,7 @@ def responses(): yield responses_fabric_details(key) yield responses_fabric_create_bulk(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -410,6 +411,7 @@ def mock_dcnm_send(*args, **kwargs): assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.metadata) == 1 assert len(instance.results.response) == 1 diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py index 621c59b5b..607955ef3 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py @@ -37,8 +37,7 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( - GenerateResponses, does_not_raise, fabric_query_fixture, - rest_send_response_current) + ResponseGenerator, does_not_raise, fabric_query_fixture, responses_fabric_query) def test_fabric_query_00010(fabric_query) -> None: @@ -225,9 +224,9 @@ def test_fabric_query_00030(monkeypatch, fabric_query) -> None: PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" def responses(): - yield rest_send_response_current(key) + yield responses_fabric_query(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -241,16 +240,20 @@ def mock_dcnm_send(*args, **kwargs): instance._fabric_details.results = Results() with does_not_raise(): instance.commit() + assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.result) == 1 assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.response[0].get("RETURN_CODE", None) == 200 assert instance.results.result[0].get("found", None) == True assert instance.results.result[0].get("success", None) == True + assert False in instance.results.failed assert True not in instance.results.failed assert False in instance.results.changed @@ -323,9 +326,9 @@ def test_fabric_query_00031(monkeypatch, fabric_query) -> None: PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" def responses(): - yield rest_send_response_current(key) + yield responses_fabric_query(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -339,16 +342,20 @@ def mock_dcnm_send(*args, **kwargs): instance._fabric_details.results = Results() with does_not_raise(): instance.commit() + assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.result) == 1 assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.response[0].get("RETURN_CODE", None) == 200 assert instance.results.result[0].get("found", None) == True assert instance.results.result[0].get("success", None) == True + assert False in instance.results.failed assert True not in instance.results.failed assert False in instance.results.changed @@ -413,9 +420,9 @@ def test_fabric_query_00032(monkeypatch, fabric_query) -> None: PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" def responses(): - yield rest_send_response_current(key) + yield responses_fabric_query(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -428,20 +435,25 @@ def mock_dcnm_send(*args, **kwargs): instance._fabric_details.rest_send.timeout = 1 instance._fabric_details.rest_send.send_interval = 1 instance.fabric_names = ["f1"] + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) instance._fabric_details.results = Results() with does_not_raise(): instance.commit() + assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.result) == 1 assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.response[0].get("RETURN_CODE", None) == 500 assert instance.results.result[0].get("found", None) == False assert instance.results.result[0].get("success", None) == False + assert True in instance.results.failed assert False not in instance.results.failed assert False in instance.results.changed @@ -500,9 +512,9 @@ def test_fabric_query_00033(monkeypatch, fabric_query) -> None: PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" def responses(): - yield rest_send_response_current(key) + yield responses_fabric_query(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -516,18 +528,24 @@ def mock_dcnm_send(*args, **kwargs): instance._fabric_details.results = Results() with does_not_raise(): instance.commit() + assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.result) == 1 assert len(instance.results.response) == 1 + assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.diff[0].get("f1", {}).get("asn", None) == "65001" assert instance.results.diff[0].get("f1", {}).get("nvPairs", {}).get("BGP_AS") == "65001" + assert instance.results.response[0].get("RETURN_CODE", None) == 200 + assert instance.results.result[0].get("found", None) == True assert instance.results.result[0].get("success", None) == True + assert False in instance.results.failed assert True not in instance.results.failed assert False in instance.results.changed diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py index 52f8ba41e..8c769cd3c 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py @@ -37,9 +37,8 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( - GenerateResponses, does_not_raise, fabric_update_bulk_fixture, payloads_fabric_update_bulk, - responses_fabric_update_bulk, responses_fabric_details, responses_fabric_summary, - rest_send_response_current) + ResponseGenerator, does_not_raise, fabric_update_bulk_fixture, payloads_fabric_update_bulk, + responses_fabric_update_bulk, responses_fabric_details, responses_fabric_summary) def test_fabric_update_bulk_00010(fabric_update_bulk) -> None: @@ -234,7 +233,7 @@ def responses(): yield responses_fabric_details(key) yield responses_fabric_update_bulk(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -253,6 +252,7 @@ def mock_dcnm_send(*args, **kwargs): assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.metadata) == 1 assert len(instance.results.response) == 1 @@ -344,7 +344,7 @@ def responses(): yield responses_fabric_summary(key) yield responses_fabric_update_bulk(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -363,6 +363,7 @@ def mock_dcnm_send(*args, **kwargs): assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.metadata) == 1 assert len(instance.results.response) == 1 @@ -454,7 +455,7 @@ def responses(): yield responses_fabric_summary(key) yield responses_fabric_update_bulk(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -474,6 +475,7 @@ def mock_dcnm_send(*args, **kwargs): assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.metadata) == 1 assert len(instance.results.response) == 1 @@ -569,7 +571,7 @@ def responses(): yield responses_fabric_details(key) yield responses_fabric_summary(key) - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) def mock_dcnm_send(*args, **kwargs): item = gen.next @@ -590,6 +592,7 @@ def mock_dcnm_send(*args, **kwargs): assert isinstance(instance.results.diff, list) assert isinstance(instance.results.result, list) assert isinstance(instance.results.response, list) + assert len(instance.results.diff) == 1 assert len(instance.results.metadata) == 1 assert len(instance.results.response) == 1 diff --git a/tests/unit/modules/dcnm/dcnm_fabric/utils.py b/tests/unit/modules/dcnm/dcnm_fabric/utils.py index f24be8ef2..5ff087c51 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/utils.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/utils.py @@ -37,7 +37,7 @@ load_fixture -class GenerateResponses: +class ResponseGenerator: """ Given a generator, return the items in the generator with each call to the next property @@ -52,7 +52,7 @@ def responses(): yield {"key1": "value1"} yield {"key2": "value2"} - gen = GenerateResponses(responses()) + gen = ResponseGenerator(responses()) print(gen.next) # {"key1": "value1"} print(gen.next) # {"key2": "value2"} @@ -273,6 +273,16 @@ def responses_fabric_details(key: str) -> Dict[str, str]: return data +def responses_fabric_query(key: str) -> Dict[str, str]: + """ + Return responses for FabricQuery + """ + data_file = "responses_FabricQuery" + data = load_fixture(data_file).get(key) + print(f"{data_file}: {key} : {data}") + return data + + def responses_fabric_summary(key: str) -> Dict[str, str]: """ Return responses for FabricSummary From 471a1c6122308c057f531c03f1815b8fb21facbd Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 27 Mar 2024 07:37:43 -1000 Subject: [PATCH 021/228] Run unit test files through black, isort, pylint --- .../dcnm_fabric/test_fabric_create_bulk.py | 29 +++-- .../dcnm/dcnm_fabric/test_fabric_query.py | 38 +++--- .../dcnm_fabric/test_fabric_update_bulk.py | 120 +++++++++++------- tests/unit/modules/dcnm/dcnm_fabric/utils.py | 6 +- 4 files changed, 117 insertions(+), 76 deletions(-) diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py index d986bdbfa..9f2db1692 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py @@ -37,8 +37,9 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( - ResponseGenerator, does_not_raise, fabric_create_bulk_fixture, payloads_fabric_create_bulk, - responses_fabric_create_bulk, responses_fabric_details, rest_send_response_current) + ResponseGenerator, does_not_raise, fabric_create_bulk_fixture, + payloads_fabric_create_bulk, responses_fabric_create_bulk, + responses_fabric_details, rest_send_response_current) def test_fabric_create_bulk_00010(fabric_create_bulk) -> None: @@ -265,11 +266,17 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.metadata[0].get("state", None) == "merged" assert instance.results.response[0].get("RETURN_CODE", None) == 200 - assert instance.results.response[0].get("DATA", {}).get("nvPairs", {}).get("BGP_AS", None) == "65001" + assert ( + instance.results.response[0] + .get("DATA", {}) + .get("nvPairs", {}) + .get("BGP_AS", None) + == "65001" + ) assert instance.results.response[0].get("METHOD", None) == "POST" - assert instance.results.result[0].get("changed", None) == True - assert instance.results.result[0].get("success", None) == True + assert instance.results.result[0].get("changed", None) is True + assert instance.results.result[0].get("success", None) is True assert False in instance.results.failed assert True not in instance.results.failed @@ -311,7 +318,7 @@ def test_fabric_create_bulk_00031(monkeypatch, fabric_create_bulk) -> None: - FabricCreate()._build_payloads_to_commit() sets FabricCreate()._payloads_to_commit to an empty list since fabric f1 already exists on the controller - FabricCreateBulk.commit() returns without doing anything. - + Test """ key = "test_fabric_create_bulk_00031a" @@ -425,14 +432,16 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.metadata[0].get("state", None) == "merged" assert instance.results.response[0].get("RETURN_CODE", None) == 500 - assert instance.results.response[0].get("DATA", {}) == "Error in validating provided name value pair: [BGP_AS]" + assert ( + instance.results.response[0].get("DATA", {}) + == "Error in validating provided name value pair: [BGP_AS]" + ) assert instance.results.response[0].get("METHOD", None) == "POST" - assert instance.results.result[0].get("changed", None) == False - assert instance.results.result[0].get("success", None) == False + assert instance.results.result[0].get("changed", None) is False + assert instance.results.result[0].get("success", None) is False assert True in instance.results.failed assert False not in instance.results.failed assert False in instance.results.changed assert True not in instance.results.changed - diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py index 607955ef3..fd7f53e14 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_query.py @@ -37,7 +37,8 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( - ResponseGenerator, does_not_raise, fabric_query_fixture, responses_fabric_query) + ResponseGenerator, does_not_raise, fabric_query_fixture, + responses_fabric_query) def test_fabric_query_00010(fabric_query) -> None: @@ -206,7 +207,7 @@ def test_fabric_query_00030(monkeypatch, fabric_query) -> None: - FabricQuery().commit() calls FabricDetailsByName().refresh() which calls FabricDetails.refresh_super() - FabricDetails.refresh_super() calls RestSend().commit() which sets - RestSend().response_current to a dict with keys DATA == [], + RestSend().response_current to a dict with keys DATA == [], RETURN_CODE == 200, MESSAGE="OK" - Hence, FabricDetails().data is set to an empty dict: {} - Hence, FabricDetailsByName().data_subclass is set to an empty dict: {} @@ -216,7 +217,7 @@ def test_fabric_query_00030(monkeypatch, fabric_query) -> None: - instance.results.result_current to the RestSend().result_current - FabricQuery.commit() calls Results().register_task_result() - Results().register_task_result() adds sequence_number (with value 1) to - each of the results dicts + each of the results dicts """ key = "test_fabric_query_00030a" @@ -251,8 +252,8 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.response[0].get("RETURN_CODE", None) == 200 - assert instance.results.result[0].get("found", None) == True - assert instance.results.result[0].get("success", None) == True + assert instance.results.result[0].get("found", None) is True + assert instance.results.result[0].get("success", None) is True assert False in instance.results.failed assert True not in instance.results.failed @@ -292,7 +293,7 @@ def test_fabric_query_00031(monkeypatch, fabric_query) -> None: - FabricQuery().commit() calls FabricDetailsByName().refresh() which calls FabricDetails.refresh_super() - FabricDetails.refresh_super() calls RestSend().commit() which sets - RestSend().response_current to a dict with keys DATA == [{f2 fabric data dict}], + RestSend().response_current to a dict with keys DATA == [{f2 fabric data dict}], RETURN_CODE == 200, MESSAGE="OK" - Hence, FabricDetails().data is set to: { "f2": {f2 fabric data dict} } - Hence, FabricDetailsByName().data_subclass is set to: { "f2": {f2 fabric data dict} } @@ -305,7 +306,7 @@ def test_fabric_query_00031(monkeypatch, fabric_query) -> None: - FabricQuery.commit() calls Results().register_task_result() - Results().register_task_result() adds sequence_number (with value 1) to each of the results dicts - + Test - FabricQuery.commit() calls instance._fabric_details() which sets instance._fabric_details.all_data to a list of dict containing @@ -353,8 +354,8 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.response[0].get("RETURN_CODE", None) == 200 - assert instance.results.result[0].get("found", None) == True - assert instance.results.result[0].get("success", None) == True + assert instance.results.result[0].get("found", None) is True + assert instance.results.result[0].get("success", None) is True assert False in instance.results.failed assert True not in instance.results.failed @@ -393,7 +394,7 @@ def test_fabric_query_00032(monkeypatch, fabric_query) -> None: - FabricQuery().commit() calls FabricDetailsByName().refresh() which calls FabricDetails.refresh_super() - FabricDetails.refresh_super() calls RestSend().commit() which sets - RestSend().response_current to a dict with keys DATA == [{f2 fabric data dict}], + RestSend().response_current to a dict with keys DATA == [{f2 fabric data dict}], RETURN_CODE == 200, MESSAGE="OK" - Hence, FabricDetails().data is set to: { "f2": {f2 fabric data dict} } - Hence, FabricDetailsByName().data_subclass is set to: { "f2": {f2 fabric data dict} } @@ -406,7 +407,7 @@ def test_fabric_query_00032(monkeypatch, fabric_query) -> None: - FabricQuery.commit() calls Results().register_task_result() - Results().register_task_result() adds sequence_number (with value 1) to each of the results dicts - + Setup - RestSend().commit() is mocked to return a dict with key RETURN_CODE == 500 - RestSend().timeout is set to 1 @@ -451,8 +452,8 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.response[0].get("RETURN_CODE", None) == 500 - assert instance.results.result[0].get("found", None) == False - assert instance.results.result[0].get("success", None) == False + assert instance.results.result[0].get("found", None) is False + assert instance.results.result[0].get("success", None) is False assert True in instance.results.failed assert False not in instance.results.failed @@ -492,7 +493,7 @@ def test_fabric_query_00033(monkeypatch, fabric_query) -> None: - FabricQuery().commit() calls FabricDetailsByName().refresh() which calls FabricDetails.refresh_super() - FabricDetails.refresh_super() calls RestSend().commit() which sets - RestSend().response_current to a dict with keys DATA == [{f1 fabric data dict}], + RestSend().response_current to a dict with keys DATA == [{f1 fabric data dict}], RETURN_CODE == 200, MESSAGE="OK" - Hence, FabricDetails().data is set to: { "f1": {f1 fabric data dict} } - Hence, FabricDetailsByName().data_subclass is set to: { "f1": {f1 fabric data dict} } @@ -539,12 +540,15 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.diff[0].get("sequence_number", None) == 1 assert instance.results.diff[0].get("f1", {}).get("asn", None) == "65001" - assert instance.results.diff[0].get("f1", {}).get("nvPairs", {}).get("BGP_AS") == "65001" + assert ( + instance.results.diff[0].get("f1", {}).get("nvPairs", {}).get("BGP_AS") + == "65001" + ) assert instance.results.response[0].get("RETURN_CODE", None) == 200 - assert instance.results.result[0].get("found", None) == True - assert instance.results.result[0].get("success", None) == True + assert instance.results.result[0].get("found", None) is True + assert instance.results.result[0].get("success", None) is True assert False in instance.results.failed assert True not in instance.results.failed diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py index 8c769cd3c..85c827845 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py @@ -37,8 +37,9 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.utils import ( - ResponseGenerator, does_not_raise, fabric_update_bulk_fixture, payloads_fabric_update_bulk, - responses_fabric_update_bulk, responses_fabric_details, responses_fabric_summary) + ResponseGenerator, does_not_raise, fabric_update_bulk_fixture, + payloads_fabric_update_bulk, responses_fabric_details, + responses_fabric_summary, responses_fabric_update_bulk) def test_fabric_update_bulk_00010(fabric_update_bulk) -> None: @@ -222,7 +223,7 @@ def test_fabric_update_bulk_00030(monkeypatch, fabric_update_bulk) -> None: { "RETURN_CODE": 200, "MESSAGE": "No fabrics to update." } - instance.results.result_current to a synthesized result dict {"success": True, "changed": False} - - FabricUpdateBulk.commit() calls Results().register_task_result() + - FabricUpdateBulk.commit() calls Results().register_task_result() """ key = "test_fabric_update_bulk_00030a" @@ -268,8 +269,8 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.response[0].get("RETURN_CODE", None) == 200 assert instance.results.response[0].get("MESSAGE", None) == "No fabrics to update." - assert instance.results.result[0].get("changed", None) == False - assert instance.results.result[0].get("success", None) == True + assert instance.results.result[0].get("changed", None) is False + assert instance.results.result[0].get("success", None) is True assert False in instance.results.failed assert True not in instance.results.failed @@ -318,20 +319,26 @@ def test_fabric_update_bulk_00031(monkeypatch, fabric_update_bulk) -> None: - instance.results.result_current to a synthesized result dict {"success": True, "changed": False} - FabricUpdateBulk.commit() calls FabricUpdateCommon()._send_payloads() - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._build_fabrics_to_config_deploy() - - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls FabricUpdateCommon()._can_be_deployed() - - FabricUpdateCommon()._can_be_deployed() calls FabricSummary().refresh() and then references - FabricSummary().fabric_is_empty to determine if the fabric is empty. If the fabric is empty, - it can be deployed, otherwise it cannot. Hence, _can_be_deployed() returns either True or False. + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._build_fabrics_to_config_deploy() + - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls + FabricUpdateCommon()._can_be_deployed() + - FabricUpdateCommon()._can_be_deployed() calls + FabricSummary().refresh() and then references + FabricSummary().fabric_is_empty to determine if the fabric is empty. + If the fabric is empty, it can be deployed, otherwise it cannot. + Hence, _can_be_deployed() returns either True or False. In this testcase, the fabric is empty, so _can_be_deployed() returns True. - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: - FabricUpdateCommon()._fabrics_to_config_deploy - FabricUpdateCommon()._fabrics_to_config_save - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._fixup_payloads_to_commit() - - FabricUpdateCommon()._fixup_payloads_to_commit() updates ANYCAST_GW_MAC, if present, to - conform with the controller's requirements. - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._send_payload() for each - fabric in FabricUpdateCommon()._payloads_to_commit + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._fixup_payloads_to_commit() + - FabricUpdateCommon()._fixup_payloads_to_commit() updates ANYCAST_GW_MAC, + if present, to conform with the controller's requirements. + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._send_payload() for each fabric in + FabricUpdateCommon()._payloads_to_commit """ key = "test_fabric_update_bulk_00031a" @@ -379,11 +386,17 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.metadata[0].get("state", None) == "merged" assert instance.results.response[0].get("RETURN_CODE", None) == 200 - assert instance.results.response[0].get("DATA", {}).get("nvPairs", {}).get("BGP_AS", None) == "65001" + assert ( + instance.results.response[0] + .get("DATA", {}) + .get("nvPairs", {}) + .get("BGP_AS", None) + == "65001" + ) assert instance.results.response[0].get("METHOD", None) == "PUT" - assert instance.results.result[0].get("changed", None) == True - assert instance.results.result[0].get("success", None) == True + assert instance.results.result[0].get("changed", None) is True + assert instance.results.result[0].get("success", None) is True assert False in instance.results.failed assert True not in instance.results.failed @@ -428,21 +441,30 @@ def test_fabric_update_bulk_00032(monkeypatch, fabric_update_bulk) -> None: { "RETURN_CODE": 200, "MESSAGE": "No fabrics to update." } - instance.results.result_current to a synthesized result dict {"success": True, "changed": False} - - FabricUpdateBulk.commit() calls FabricUpdateCommon()._send_payloads() - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._build_fabrics_to_config_deploy() - - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls FabricUpdateCommon()._can_be_deployed() - - FabricUpdateCommon()._can_be_deployed() calls FabricSummary().refresh() and then references - FabricSummary().fabric_is_empty to determine if the fabric is empty. If the fabric is empty, - it can be deployed, otherwise it cannot. Hence, _can_be_deployed() returns either True or False. - In this testcase, the fabric is empty, so _can_be_deployed() returns True. + - FabricUpdateBulk.commit() calls + FabricUpdateCommon()._send_payloads() + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._build_fabrics_to_config_deploy() + - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls + FabricUpdateCommon()._can_be_deployed() + - FabricUpdateCommon()._can_be_deployed() calls + FabricSummary().refresh() and then references + FabricSummary().fabric_is_empty to determine if the fabric is empty. + If the fabric is empty, it can be deployed, otherwise it cannot. + Hence, _can_be_deployed() returns either True or False. + In this testcase, the fabric is empty, so + FabricUpdateCommon()._can_be_deployed() returns True. - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: - FabricUpdateCommon()._fabrics_to_config_deploy - FabricUpdateCommon()._fabrics_to_config_save - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._fixup_payloads_to_commit() - - FabricUpdateCommon()._fixup_payloads_to_commit() updates ANYCAST_GW_MAC, if present, to - conform with the controller's requirements. - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._send_payload() for each - fabric in FabricUpdateCommon()._payloads_to_commit + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._fixup_payloads_to_commit() + - FabricUpdateCommon()._fixup_payloads_to_commit() updates + ANYCAST_GW_MAC, if present, to conform with the controller's + requirements. + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._send_payload() for each fabric in + FabricUpdateCommon()._payloads_to_commit """ key = "test_fabric_update_bulk_00032a" @@ -490,13 +512,13 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.response[0].get("RETURN_CODE", None) == 500 error_message = "Failed to update the fabric, due to invalid field [BOO] " - error_message += f"in payload, please provide valid fields for fabric-settings" + error_message += "in payload, please provide valid fields for fabric-settings" assert instance.results.response[0].get("DATA", None) == error_message assert instance.results.response[0].get("METHOD", None) == "PUT" assert instance.results.response[0].get("MESSAGE", None) == "Internal Server Error" - assert instance.results.result[0].get("changed", None) == False - assert instance.results.result[0].get("success", None) == False + assert instance.results.result[0].get("changed", None) is False + assert instance.results.result[0].get("success", None) is False assert True in instance.results.failed assert False not in instance.results.failed @@ -542,25 +564,31 @@ def test_fabric_update_bulk_00033(monkeypatch, fabric_update_bulk) -> None: - instance.results.action to self.action - instance.results.state to self.state - instance.results.check_mode to self.check_mode - - FabricUpdateBulk.commit() calls FabricUpdateCommon()._send_payloads() - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._build_fabrics_to_config_deploy() - - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls FabricUpdateCommon()._can_be_deployed() - - FabricUpdateCommon()._can_be_deployed() calls FabricSummary().refresh() and then references - FabricSummary().fabric_is_empty to determine if the fabric is empty. If the fabric is empty, - it can be deployed, otherwise it cannot. Hence, _can_be_deployed() returns either True or False. + - FabricUpdateBulk.commit() calls + FabricUpdateCommon()._send_payloads() + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._build_fabrics_to_config_deploy() + - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls + FabricUpdateCommon()._can_be_deployed() + - FabricUpdateCommon()._can_be_deployed() calls + FabricSummary().refresh() and then references + FabricSummary().fabric_is_empty to determine if the fabric is empty. + If the fabric is empty, it can be deployed, otherwise it cannot. + Hence, _can_be_deployed() returns either True or False. In this testcase, the fabric is empty, so _can_be_deployed() returns True. - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: - FabricUpdateCommon()._fabrics_to_config_deploy - FabricUpdateCommon()._fabrics_to_config_save - - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._fixup_payloads_to_commit() - - FabricCommon()._fixup_payloads_to_commit() calls FabricCommon().translate_mac_address() - to update ANYCAST_GW_MAC to conform with the controller's requirements, but the - mac address is not convertable, so translate_mac_address() raises a ValueError. - - Responding to the ValueError FabricCommon()._fixup_payloads_to_commit() takes the except - path of its try/except block, which: + - FabricUpdateCommon()._send_payloads() calls + FabricUpdateCommon()._fixup_payloads_to_commit() + - FabricCommon()._fixup_payloads_to_commit() calls + FabricCommon().translate_mac_address() to update ANYCAST_GW_MAC + to conform with the controller's requirements, but the mac address + is not convertable, so translate_mac_address() raises ValueError. + - FabricCommon()._fixup_payloads_to_commit() responds to the ValueError + by taking the except path of its try/except block, which: - Updates results and calls results.register_task_result() - Calls fail_json() - """ key = "test_fabric_update_bulk_00033a" diff --git a/tests/unit/modules/dcnm/dcnm_fabric/utils.py b/tests/unit/modules/dcnm/dcnm_fabric/utils.py index 5ff087c51..67914067d 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/utils.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/utils.py @@ -25,14 +25,14 @@ AnsibleFailJson from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.common import \ FabricCommon -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import \ - FabricCreateCommon, FabricCreate, FabricCreateBulk +from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.create import ( + FabricCreate, FabricCreateBulk) from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.delete import \ FabricDelete from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.query import \ FabricQuery from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.update import \ - FabricUpdateCommon, FabricUpdate, FabricUpdateBulk + FabricUpdateBulk from ansible_collections.cisco.dcnm.tests.unit.modules.dcnm.dcnm_fabric.fixture import \ load_fixture From c266517e483787c2507339c9bfe69ad6647089a6 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 27 Mar 2024 08:34:14 -1000 Subject: [PATCH 022/228] Consistent fixture file naming --- ...{response_current_RestSend.json => responses_FabricQuery.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/modules/dcnm/dcnm_fabric/fixtures/{response_current_RestSend.json => responses_FabricQuery.json} (100%) diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/response_current_RestSend.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricQuery.json similarity index 100% rename from tests/unit/modules/dcnm/dcnm_fabric/fixtures/response_current_RestSend.json rename to tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricQuery.json From 3cf0093bd049ce859b4867f7b123fd6fae3ce1e1 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 27 Mar 2024 11:27:59 -1000 Subject: [PATCH 023/228] FabricCreateBulk: Add unit test for invalid FABRIC_TYPE value --- .../fixtures/payloads_FabricCreateBulk.json | 8 ++++ .../fixtures/responses_FabricDetails.json | 7 +++ .../dcnm_fabric/test_fabric_create_bulk.py | 46 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json index 840fd61b9..a84ad9dcc 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/payloads_FabricCreateBulk.json @@ -11,6 +11,14 @@ "FABRIC_TYPE": "VXLAN_EVPN" } ], + "test_fabric_create_bulk_00025a": [ + { + "BGP_AS": 65001, + "DEPLOY": true, + "FABRIC_NAME": "f1", + "FABRIC_TYPE": "INVALID_FABRIC_TYPE" + } + ], "test_fabric_create_bulk_00030a": [ { "BGP_AS": 65001, diff --git a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json index 9dba2ab66..a3f30e4ee 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json +++ b/tests/unit/modules/dcnm/dcnm_fabric/fixtures/responses_FabricDetails.json @@ -2,6 +2,13 @@ "test_notes": [ "Mocked responses for FabricDetails() class" ], + "test_fabric_create_bulk_00025a": { + "DATA": [], + "MESSAGE": "OK", + "METHOD": "GET", + "REQUEST_PATH": "https://172.22.150.244:443/appcenter/cisco/ndfc/api/v1/rest/control/fabrics", + "RETURN_CODE": 200 + }, "test_fabric_create_bulk_00030a": { "DATA": [], "MESSAGE": "OK", diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py index 9f2db1692..eb6bedadd 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_create_bulk.py @@ -189,6 +189,52 @@ def test_fabric_create_bulk_00024(fabric_create_bulk) -> None: assert instance.payloads == [] +def test_fabric_create_bulk_00025(monkeypatch, fabric_create_bulk) -> None: + """ + Classes and Methods + - FabricCommon + - __init__() + - payloads setter + - FabricCreateBulk + - __init__() + + Summary + Verify behavior when payloads contains a dict with an unexpected + value for the FABRIC_TYPE key. + + Setup + - FabricCreatebulk().payloads is set to contain a dict with FABRIC_TYPE + set to "INVALID_FABRIC_TYPE" + + Test + - fail_json is called because the value of FABRIC_TYPE is invalid + """ + key = "test_fabric_create_bulk_00025a" + + PATCH_DCNM_SEND = "ansible_collections.cisco.dcnm.plugins." + PATCH_DCNM_SEND += "module_utils.common.rest_send.dcnm_send" + + def responses(): + yield responses_fabric_details(key) + + gen = ResponseGenerator(responses()) + + def mock_dcnm_send(*args, **kwargs): + item = gen.next + return item + + with does_not_raise(): + instance = fabric_create_bulk + instance.results = Results() + instance.payloads = payloads_fabric_create_bulk(key) + + monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) + + match = r"FabricCreateBulk\.fabric_type: FABRIC_TYPE must be one of" + with pytest.raises(AnsibleFailJson, match=match): + instance.commit() + + def test_fabric_create_bulk_00030(monkeypatch, fabric_create_bulk) -> None: """ Classes and Methods From c1d7db2e7dab2a2fd09866a5f7f89c2969f65fff Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 27 Mar 2024 14:28:11 -1000 Subject: [PATCH 024/228] FabricUpdateBulk: Support idempotence when payload == controller config FabricUpdateBulk: Send payload only when it would change the controller fabric config. FabricUpdate: Remove this class for now since it's not used. --- plugins/module_utils/fabric/common.py | 28 +++ plugins/module_utils/fabric/update.py | 213 +++++++++++------- .../dcnm_fabric/test_fabric_update_bulk.py | 112 +++------ 3 files changed, 189 insertions(+), 164 deletions(-) diff --git a/plugins/module_utils/fabric/common.py b/plugins/module_utils/fabric/common.py index 86aa07b16..fc1ed3d67 100644 --- a/plugins/module_utils/fabric/common.py +++ b/plugins/module_utils/fabric/common.py @@ -62,6 +62,34 @@ def __init__(self, ansible_module): self.fabric_type_to_template_name_map = {} self.fabric_type_to_template_name_map["VXLAN_EVPN"] = "Easy_Fabric" + self._build_key_translations() + + def _build_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 _fixup_payloads_to_commit(self) -> None: """ Make any modifications to the payloads prior to sending them diff --git a/plugins/module_utils/fabric/update.py b/plugins/module_utils/fabric/update.py index 9fe340dfd..30214af21 100644 --- a/plugins/module_utils/fabric/update.py +++ b/plugins/module_utils/fabric/update.py @@ -33,8 +33,6 @@ FabricDetailsByName from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_summary import \ FabricSummary -from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_playbook_params import \ - VerifyPlaybookParams class FabricUpdateCommon(FabricCommon): @@ -99,6 +97,10 @@ def _can_fabric_be_deployed(self, fabric_name): """ return True if the fabric configuration can be saved and deployed return False otherwise + + NOTES: + - If the fabric is empty, the controller will throw an error when + attempting to deploy the fabric. """ method_name = inspect.stack()[0][3] msg = f"{self.class_name}.{method_name}: " @@ -110,7 +112,7 @@ def _can_fabric_be_deployed(self, fabric_name): msg += f"{self._fabric_summary.fabric_is_empty}" self.log.debug(msg) if self._fabric_summary.fabric_is_empty is True: - self.cannot_deploy_fabric_reason = "Fabric is not empty" + self.cannot_deploy_fabric_reason = "Fabric is empty" return False return True @@ -139,6 +141,129 @@ def _verify_payload(self, payload): msg += f"payload: {sorted(payload)}" self.ansible_module.fail_json(msg, **self.results.failed_result) + def _prepare_payload_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 _prepare_anycast_gw_mac_for_comparison(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 + - Call fail_json() + """ + method_name = inspect.stack()[0][3] + try: + mac_address = self.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.ansible_module.fail_json(msg, **self.results.failed_result) + return mac_address + + def _fabric_needs_update(self, payload): + """ + - Return True if the fabric needs to be updated. + - Return False otherwise. + - Call fail_json() if any payload key would raise an + error on the controller. + + The fabric needs to be updated if any of the following are true: + - A key in the payload has a different value than the corresponding + key in fabric configuration on the controller. + """ + method_name = inspect.stack()[0][3] + fabric_name = payload.get("FABRIC_NAME", None) + if fabric_name is None: + return False + + if fabric_name not in self.fabric_details.all_data: + return False + + nv_pairs = self.fabric_details.all_data[fabric_name].get("nvPairs", {}) + + 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: + key = self._key_translations[payload_key] + else: + key = payload_key + + # 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 key == "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 key is None: + continue + + # If a key is in the payload that is not in the fabric + # configuration on the controller: + # - Update Results() + # - Call fail_json() + if nv_pairs.get(key) is None: + self.results.diff_current = {} + self.results.result_current = {"success": False, "changed": False} + self.results.failed = True + self.results.changed = False + self.results.failed_result["msg"] = ( + f"Key {key} not found in fabric configuration for " + f"fabric {fabric_name}" + ) + self.results.register_task_result() + msg = f"{self.class_name}.{method_name}: " + msg += f"Invalid key: {key} found in payload for " + msg += f"fabric {fabric_name}" + self.log.debug(msg) + self.ansible_module.fail_json(msg, **self.results.failed_result) + + value = self._prepare_payload_value_for_comparison(payload_value) + + if key == "ANYCAST_GW_MAC": + value = self._prepare_anycast_gw_mac_for_comparison(fabric_name, value) + + if value != nv_pairs.get(key): + msg = f"{self.class_name}.{method_name}: " + msg += f"key {key}: " + msg += f"payload_value [{value}] != " + msg += f"fabric_value: [{nv_pairs.get(key)}]: " + msg += "Fabric needs update." + self.log.debug(msg) + return True + return False + def _build_payloads_to_commit(self): """ Build a list of payloads to commit. Skip payloads for fabrics @@ -155,6 +280,8 @@ def _build_payloads_to_commit(self): self._payloads_to_commit = [] for payload in self.payloads: if payload.get("FABRIC_NAME", None) in self.fabric_details.all_data: + if self._fabric_needs_update(payload) is False: + continue self._payloads_to_commit.append(copy.deepcopy(payload)) def _send_payloads(self): @@ -470,83 +597,3 @@ def commit(self): self.results.register_task_result() return self._send_payloads() - - -class FabricUpdate(FabricUpdateCommon): - """ - Update a fabric on the controller. - """ - - def __init__(self, ansible_module): - super().__init__(ansible_module) - self.class_name = self.__class__.__name__ - - self.log = logging.getLogger(f"dcnm.{self.class_name}") - self.log.debug("ENTERED FabricUpdate()") - - self.data = {} - self.endpoints = ApiEndpoints() - self.rest_send = RestSend(self.ansible_module) - - self._init_properties() - - def _init_properties(self): - # self.properties is already initialized in the parent class - self.properties["payload"] = None - - def commit(self): - """ - Send the fabric create request to the controller. - """ - method_name = inspect.stack()[0][3] - if self.payload is None: - msg = f"{self.class_name}.{method_name}: " - msg += "Exiting. Missing mandatory property: payload" - self.ansible_module.fail_json(msg) - - if len(self.payload) == 0: - self.ansible_module.exit_json(**self.results.failed_result) - - fabric_name = self.payload.get("FABRIC_NAME") - if fabric_name is None: - msg = f"{self.class_name}.{method_name}: " - msg += "payload is missing mandatory FABRIC_NAME key." - self.ansible_module.fail_json(msg) - - self.endpoints.fabric_name = fabric_name - self.endpoints.template_name = "Easy_Fabric" - try: - endpoint = self.endpoints.fabric_create - except ValueError as error: - self.ansible_module.fail_json(error) - - path = endpoint["path"] - verb = endpoint["verb"] - - self.rest_send.path = path - self.rest_send.verb = verb - self.rest_send.payload = self.payload - self.rest_send.commit() - - if self.rest_send.result_current["success"] is False: - self.results.diff_current = {} - else: - self.results.diff_current = self.payload - - self.results.action = self.action - self.results.check_mode = self.check_mode - self.results.state = self.state - self.results.result_current = self.rest_send.result_current - self.results.response_current = self.rest_send.response_current - self.results.register_task_result() - - @property - def payload(self): - """ - Return a fabric create payload. - """ - return self.properties["payload"] - - @payload.setter - def payload(self, value): - self.properties["payload"] = value diff --git a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py index 85c827845..0010b8d34 100644 --- a/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py +++ b/tests/unit/modules/dcnm/dcnm_fabric/test_fabric_update_bulk.py @@ -319,7 +319,7 @@ def test_fabric_update_bulk_00031(monkeypatch, fabric_update_bulk) -> None: - instance.results.result_current to a synthesized result dict {"success": True, "changed": False} - FabricUpdateBulk.commit() calls FabricUpdateCommon()._send_payloads() - - FabricUpdateCommon()._send_payloads() calls + - FabricUpdateCommon()._send_payloads() calls FabricUpdateCommon()._build_fabrics_to_config_deploy() - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls FabricUpdateCommon()._can_be_deployed() @@ -433,39 +433,14 @@ def test_fabric_update_bulk_00032(monkeypatch, fabric_update_bulk) -> None: - FabricUpdateBulk.commit() calls FabricUpdateCommon()._build_payloads_to_commit() - FabricUpdateCommon()._build_payloads_to_commit() calls FabricDetails().refresh() which returns a dict with fabric f1 information and RETURN_CODE == 200 - - FabricUpdateCommon()._build_payloads_to_commit() appends the payload in - FabricUpdateBulk.payloads to FabricUpdatee()._payloads_to_commit - - FabricUpdateBulk.commit() updates the following: - - instance.results.diff_current to an empty dict - - instance.results.response_current a synthesized response dict - { "RETURN_CODE": 200, "MESSAGE": "No fabrics to update." } - - instance.results.result_current to a synthesized result dict - {"success": True, "changed": False} - - FabricUpdateBulk.commit() calls - FabricUpdateCommon()._send_payloads() - - FabricUpdateCommon()._send_payloads() calls - FabricUpdateCommon()._build_fabrics_to_config_deploy() - - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls - FabricUpdateCommon()._can_be_deployed() - - FabricUpdateCommon()._can_be_deployed() calls - FabricSummary().refresh() and then references - FabricSummary().fabric_is_empty to determine if the fabric is empty. - If the fabric is empty, it can be deployed, otherwise it cannot. - Hence, _can_be_deployed() returns either True or False. - In this testcase, the fabric is empty, so - FabricUpdateCommon()._can_be_deployed() returns True. - - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: - - FabricUpdateCommon()._fabrics_to_config_deploy - - FabricUpdateCommon()._fabrics_to_config_save - - FabricUpdateCommon()._send_payloads() calls - FabricUpdateCommon()._fixup_payloads_to_commit() - - FabricUpdateCommon()._fixup_payloads_to_commit() updates - ANYCAST_GW_MAC, if present, to conform with the controller's - requirements. - - FabricUpdateCommon()._send_payloads() calls - FabricUpdateCommon()._send_payload() for each fabric in - FabricUpdateCommon()._payloads_to_commit - + - FabricUpdateCommon()._build_payloads_to_commit() calls + FabricUpdateCommon()._fabric_needs_update() which updates: + - Results().result_current to add a synthesized failed result dict + - Results().changed adding False + - Results().failed adding True + - Results().failed_result to add a message indicating the reason for the failure + And calls Results().register_task_result() + It then calls fail_json() because the payload contains an invalid key. """ key = "test_fabric_update_bulk_00032a" @@ -488,10 +463,13 @@ def mock_dcnm_send(*args, **kwargs): instance.results = Results() instance.payloads = payloads_fabric_update_bulk(key) instance.fabric_details.rest_send.unit_test = True + instance.rest_send.unit_test = True monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) - with does_not_raise(): - instance.rest_send.unit_test = True + + match = r"FabricUpdateBulk\._fabric_needs_update: Invalid key:.*found in payload for fabric.*" + + with pytest.raises(AnsibleFailJson, match=match): instance.commit() assert isinstance(instance.results.diff, list) @@ -510,13 +488,6 @@ def mock_dcnm_send(*args, **kwargs): assert instance.results.metadata[0].get("sequence_number", None) == 1 assert instance.results.metadata[0].get("state", None) == "merged" - assert instance.results.response[0].get("RETURN_CODE", None) == 500 - error_message = "Failed to update the fabric, due to invalid field [BOO] " - error_message += "in payload, please provide valid fields for fabric-settings" - assert instance.results.response[0].get("DATA", None) == error_message - assert instance.results.response[0].get("METHOD", None) == "PUT" - assert instance.results.response[0].get("MESSAGE", None) == "Internal Server Error" - assert instance.results.result[0].get("changed", None) is False assert instance.results.result[0].get("success", None) is False @@ -546,49 +517,28 @@ def test_fabric_update_bulk_00033(monkeypatch, fabric_update_bulk) -> None: - commit() Summary - Verify behavior when user attempts to update a fabric and the - fabric exists on the controller and the RestSend() RETURN_CODE is 500. - The fabric payload includes ANYCAST_GW_MAC, formatted to be incompatible - with the controller's requirements, and not able to be fixed by + Verify behavior when user attempts to update a fabric when the payload + includes ANYCAST_GW_MAC, formatted to be incompatible with the controller's + expectations, and not able to be fixed by FabricUpdateCommon()._fixup_payloads_to_commit(). + Setup + - FabricUpdateBulk().payloads is set to contain one payload for a fabric (f1) + that exists on the controller, and the payload includes ANYCAST_GW_MAC + formatted to be incompatible with the controller's expectations. + Code Flow - FabricUpdateBulk.payloads is set to contain one payload for a fabric (f1) that exists on the controller. - FabricUpdateBulk.commit() calls FabricUpdateCommon()._build_payloads_to_commit() - - FabricUpdateCommon()._build_payloads_to_commit() calls FabricDetails().refresh() - which returns a dict with fabric f1 information and RETURN_CODE == 200 - - FabricUpdateCommon()._build_payloads_to_commit() appends the payload in - FabricUpdateBulk.payloads to FabricUpdate()._payloads_to_commit - - FabricUpdateBulk.commit() updates the following: - - instance.results.action to self.action - - instance.results.state to self.state - - instance.results.check_mode to self.check_mode - - FabricUpdateBulk.commit() calls - FabricUpdateCommon()._send_payloads() - - FabricUpdateCommon()._send_payloads() calls - FabricUpdateCommon()._build_fabrics_to_config_deploy() - - FabricUpdateCommon()._build_fabrics_to_config_deploy() calls - FabricUpdateCommon()._can_be_deployed() - - FabricUpdateCommon()._can_be_deployed() calls - FabricSummary().refresh() and then references - FabricSummary().fabric_is_empty to determine if the fabric is empty. - If the fabric is empty, it can be deployed, otherwise it cannot. - Hence, _can_be_deployed() returns either True or False. - In this testcase, the fabric is empty, so _can_be_deployed() returns True. - - FabricUpdateCommon()._build_fabrics_to_config_deploy() appends fabric f1 to both: - - FabricUpdateCommon()._fabrics_to_config_deploy - - FabricUpdateCommon()._fabrics_to_config_save - - FabricUpdateCommon()._send_payloads() calls - FabricUpdateCommon()._fixup_payloads_to_commit() - - FabricCommon()._fixup_payloads_to_commit() calls - FabricCommon().translate_mac_address() to update ANYCAST_GW_MAC - to conform with the controller's requirements, but the mac address - is not convertable, so translate_mac_address() raises ValueError. - - FabricCommon()._fixup_payloads_to_commit() responds to the ValueError - by taking the except path of its try/except block, which: - - Updates results and calls results.register_task_result() - - Calls fail_json() + - FabricUpdateCommon()._build_payloads_to_commit() calls + FabricUpdateCommon()._fabric_needs_update() + - FabricUpdateCommon()._fabric_needs_update() calls + FabricUpdateCommon()._prepare_anycast_gw_mac_for_comparison() because ANYCAST_GW_MAC + key is present in the payload. + - FabricUpdateCommon()._prepare_anycast_gw_mac_for_comparison(): + - Updates Results() + - Calls fail_json() because the mac address is not convertable. """ key = "test_fabric_update_bulk_00033a" @@ -612,7 +562,7 @@ def mock_dcnm_send(*args, **kwargs): instance.fabric_details.rest_send.unit_test = True monkeypatch.setattr(PATCH_DCNM_SEND, mock_dcnm_send) - match = r"FabricUpdateBulk\._fixup_payloads_to_commit: " + match = r"FabricUpdateBulk\._prepare_anycast_gw_mac_for_comparison: " match += r"Error translating ANYCAST_GW_MAC" with pytest.raises(AnsibleFailJson, match=match): instance.commit() From e87f7e388e705512b47a92fc707c39fad0afa58a Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 1 Apr 2024 09:36:05 -1000 Subject: [PATCH 025/228] Initial cut at dynamic parameter validation from template module_utils/fabric/ruleset.py: TODO: We still need to handle the case where "and" and "or" are present in a rule. This impacts handling of one rule in the Easy_Fabric template. Specifically for ANYCAST_RP_IP_RANGE: Rule: ( STATIC_UNDERLAY_IP_ALLOC == ' False' and UNDERLAY_IS_V6 == ' False' and REPLICATION_MODE == 'Multicast' ) or ( STATIC_UNDERLAY_IP_ALLOC == ' True' and UNDERLAY_IS_V6 == ' False' and REPLICATION_MODE == 'Multicast' and RP_MODE == 'bidir' ) --- plugins/module_utils/fabric/create.py | 4 - .../module_utils/fabric/fabric_defaults.py | 370 +++++++++++ plugins/module_utils/fabric/ruleset.py | 234 +++++++ plugins/module_utils/fabric/template_get.py | 2 +- .../module_utils/fabric/template_get_all.py | 4 +- .../fabric/template_parse_common.py | 437 ------------- plugins/module_utils/fabric/update.py | 1 - .../{vxlan => }/verify_fabric_params.py | 598 ++++++++++++++---- .../fabric/verify_playbook_params.py | 294 +++++++++ plugins/module_utils/fabric/vxlan/__init__.py | 0 .../module_utils/fabric/vxlan/params_spec.py | 188 ------ .../vxlan/template_parse_easy_fabric.py | 296 --------- .../fabric/vxlan/verify_playbook_params.py | 112 ---- plugins/modules/dcnm_fabric.py | 17 +- 14 files changed, 1373 insertions(+), 1184 deletions(-) create mode 100644 plugins/module_utils/fabric/fabric_defaults.py create mode 100644 plugins/module_utils/fabric/ruleset.py delete mode 100755 plugins/module_utils/fabric/template_parse_common.py rename plugins/module_utils/fabric/{vxlan => }/verify_fabric_params.py (67%) create mode 100644 plugins/module_utils/fabric/verify_playbook_params.py delete mode 100644 plugins/module_utils/fabric/vxlan/__init__.py delete mode 100644 plugins/module_utils/fabric/vxlan/params_spec.py delete mode 100644 plugins/module_utils/fabric/vxlan/template_parse_easy_fabric.py delete mode 100644 plugins/module_utils/fabric/vxlan/verify_playbook_params.py diff --git a/plugins/module_utils/fabric/create.py b/plugins/module_utils/fabric/create.py index f9c9adf7e..0b2937b4d 100644 --- a/plugins/module_utils/fabric/create.py +++ b/plugins/module_utils/fabric/create.py @@ -32,9 +32,6 @@ from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.fabric_details import \ FabricDetailsByName -# from ansible_collections.cisco.dcnm.plugins.module_utils.fabric.vxlan.verify_playbook_params import \ -# VerifyPlaybookParams - class FabricCreateCommon(FabricCommon): """ @@ -53,7 +50,6 @@ def __init__(self, ansible_module): self.fabric_details = FabricDetailsByName(self.ansible_module) self.endpoints = ApiEndpoints() self.rest_send = RestSend(self.ansible_module) - # self._verify_params = VerifyPlaybookParams(self.ansible_module) # 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 diff --git a/plugins/module_utils/fabric/fabric_defaults.py b/plugins/module_utils/fabric/fabric_defaults.py new file mode 100644 index 000000000..50f3db970 --- /dev/null +++ b/plugins/module_utils/fabric/fabric_defaults.py @@ -0,0 +1,370 @@ +import json +import logging + + +class FabricDefaults: + def __init__(self): + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self._default_nv_pairs = {} + self._build_properties() + + def _build_properties(self): + self.properties = {} + self.properties["template"] = None + self.properties["defaults"] = {} + + @property + def template(self): + return self.properties["template"] + + @template.setter + def template(self, value): + self.properties["template"] = value + + def refresh(self): + """ + Refresh the defaults based on the template + """ + 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_default_nv_pairs() + # self._build_default_fabric_params() + + def parameter(self, value): + try: + return self._default_nv_pairs[value] + except KeyError: + raise KeyError(f"Parameter {value} not found in default NvPairs") + + @staticmethod + def make_boolean(value): + if value in ("true", "True", True): + return True + if value in ("false", "False", False): + return False + return value + + def _build_default_nv_pairs(self): + """ + Caller: __init__() + + Build a dict() of default fabric nvPairs that will be sent to NDFC. + The values for these items are what NDFC currently (as of 12.1.2e) + uses for defaults. Items that are supported by this module may be + modified by the user's playbook. + """ + self._default_nv_pairs = {} + for parameter in self.template.get("parameters", []): + key = parameter["name"] + value = parameter.get("metaProperties", {}).get("defaultValue", None) + self._default_nv_pairs[key] = self.make_boolean(value) + + msg = f"self._default_nv_pairs: {json.dumps(self._default_nv_pairs, indent=4, sort_keys=True)}" + msg = f"self._default_nv_pairs: {self._default_nv_pairs}" + self.log.debug(msg) + + def vxlan_evpn_nv_pairs(self): + self._default_nv_pairs = {} + self._default_nv_pairs["AAA_REMOTE_IP_ENABLED"] = False + self._default_nv_pairs["AAA_SERVER_CONF"] = "" + self._default_nv_pairs["ACTIVE_MIGRATION"] = False + self._default_nv_pairs["ADVERTISE_PIP_BGP"] = False + self._default_nv_pairs["AGENT_INTF"] = "eth0" + self._default_nv_pairs["ANYCAST_BGW_ADVERTISE_PIP"] = False + self._default_nv_pairs["ANYCAST_GW_MAC"] = "2020.0000.00aa" + self._default_nv_pairs["ANYCAST_LB_ID"] = "" + # self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "10.254.254.0/24" + # self._default_nv_pairs["ANYCAST_RP_IP_RANGE"] = "" + # self._default_nv_pairs["ANYCAST_RP_IP_RANGE_INTERNAL"] = "" + self._default_nv_pairs["AUTO_SYMMETRIC_DEFAULT_VRF"] = False + self._default_nv_pairs["AUTO_SYMMETRIC_VRF_LITE"] = False + self._default_nv_pairs["AUTO_VRFLITE_IFC_DEFAULT_VRF"] = False + self._default_nv_pairs["BFD_AUTH_ENABLE"] = False + self._default_nv_pairs["BFD_AUTH_KEY"] = "" + self._default_nv_pairs["BFD_AUTH_KEY_ID"] = "" + self._default_nv_pairs["BFD_ENABLE"] = False + self._default_nv_pairs["BFD_IBGP_ENABLE"] = False + self._default_nv_pairs["BFD_ISIS_ENABLE"] = False + self._default_nv_pairs["BFD_OSPF_ENABLE"] = False + self._default_nv_pairs["BFD_PIM_ENABLE"] = False + self._default_nv_pairs["BGP_AS"] = "1" + self._default_nv_pairs["BGP_AS_PREV"] = "" + self._default_nv_pairs["BGP_AUTH_ENABLE"] = False + self._default_nv_pairs["BGP_AUTH_KEY"] = "" + self._default_nv_pairs["BGP_AUTH_KEY_TYPE"] = "" + self._default_nv_pairs["BGP_LB_ID"] = "0" + self._default_nv_pairs["BOOTSTRAP_CONF"] = "" + self._default_nv_pairs["BOOTSTRAP_ENABLE"] = False + self._default_nv_pairs["BOOTSTRAP_ENABLE_PREV"] = False + self._default_nv_pairs["BOOTSTRAP_MULTISUBNET"] = "" + self._default_nv_pairs["BOOTSTRAP_MULTISUBNET_INTERNAL"] = "" + self._default_nv_pairs["BRFIELD_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs["BROWNFIELD_NETWORK_NAME_FORMAT"] = ( + "Auto_Net_VNI$$VNI$$_VLAN$$VLAN_ID$$" + ) + key = "BROWNFIELD_SKIP_OVERLAY_NETWORK_ATTACHMENTS" + self._default_nv_pairs[key] = False + self._default_nv_pairs["CDP_ENABLE"] = False + self._default_nv_pairs["COPP_POLICY"] = "strict" + self._default_nv_pairs["DCI_SUBNET_RANGE"] = "10.33.0.0/16" + self._default_nv_pairs["DCI_SUBNET_TARGET_MASK"] = "30" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_CLOUDSCALE"] = "" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_OTHER"] = "" + self._default_nv_pairs["DEAFULT_QUEUING_POLICY_R_SERIES"] = "" + self._default_nv_pairs["DEFAULT_VRF_REDIS_BGP_RMAP"] = "" + self._default_nv_pairs["DEPLOYMENT_FREEZE"] = False + self._default_nv_pairs["DHCP_ENABLE"] = False + self._default_nv_pairs["DHCP_END"] = "" + self._default_nv_pairs["DHCP_END_INTERNAL"] = "" + self._default_nv_pairs["DHCP_IPV6_ENABLE"] = "" + self._default_nv_pairs["DHCP_IPV6_ENABLE_INTERNAL"] = "" + self._default_nv_pairs["DHCP_START"] = "" + self._default_nv_pairs["DHCP_START_INTERNAL"] = "" + self._default_nv_pairs["DNS_SERVER_IP_LIST"] = "" + self._default_nv_pairs["DNS_SERVER_VRF"] = "" + self._default_nv_pairs["ENABLE_AAA"] = False + self._default_nv_pairs["ENABLE_AGENT"] = False + self._default_nv_pairs["ENABLE_DEFAULT_QUEUING_POLICY"] = False + self._default_nv_pairs["ENABLE_EVPN"] = True + self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID"] = False + self._default_nv_pairs["ENABLE_FABRIC_VPC_DOMAIN_ID_PREV"] = "" + self._default_nv_pairs["ENABLE_MACSEC"] = False + self._default_nv_pairs["ENABLE_NETFLOW"] = False + self._default_nv_pairs["ENABLE_NETFLOW_PREV"] = "" + self._default_nv_pairs["ENABLE_NGOAM"] = True + self._default_nv_pairs["ENABLE_NXAPI"] = True + self._default_nv_pairs["ENABLE_NXAPI_HTTP"] = True + self._default_nv_pairs["ENABLE_PBR"] = False + self._default_nv_pairs["ENABLE_PVLAN"] = False + self._default_nv_pairs["ENABLE_PVLAN_PREV"] = False + self._default_nv_pairs["ENABLE_TENANT_DHCP"] = True + self._default_nv_pairs["ENABLE_TRM"] = False + self._default_nv_pairs["ENABLE_VPC_PEER_LINK_NATIVE_VLAN"] = False + self._default_nv_pairs["EXTRA_CONF_INTRA_LINKS"] = "" + self._default_nv_pairs["EXTRA_CONF_LEAF"] = "" + self._default_nv_pairs["EXTRA_CONF_SPINE"] = "" + self._default_nv_pairs["EXTRA_CONF_TOR"] = "" + self._default_nv_pairs["FABRIC_INTERFACE_TYPE"] = "p2p" + self._default_nv_pairs["FABRIC_MTU"] = "9216" + self._default_nv_pairs["FABRIC_MTU_PREV"] = "9216" + self._default_nv_pairs["FABRIC_NAME"] = "easy-fabric" + self._default_nv_pairs["FABRIC_TYPE"] = "Switch_Fabric" + self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID"] = "" + self._default_nv_pairs["FABRIC_VPC_DOMAIN_ID_PREV"] = "" + self._default_nv_pairs["FABRIC_VPC_QOS"] = False + self._default_nv_pairs["FABRIC_VPC_QOS_POLICY_NAME"] = "" + self._default_nv_pairs["FEATURE_PTP"] = False + self._default_nv_pairs["FEATURE_PTP_INTERNAL"] = False + self._default_nv_pairs["FF"] = "Easy_Fabric" + self._default_nv_pairs["GRFIELD_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs["HD_TIME"] = "180" + self._default_nv_pairs["HOST_INTF_ADMIN_STATE"] = True + self._default_nv_pairs["IBGP_PEER_TEMPLATE"] = "" + self._default_nv_pairs["IBGP_PEER_TEMPLATE_LEAF"] = "" + self._default_nv_pairs["INBAND_DHCP_SERVERS"] = "" + self._default_nv_pairs["INBAND_MGMT"] = False + self._default_nv_pairs["INBAND_MGMT_PREV"] = False + self._default_nv_pairs["ISIS_AUTH_ENABLE"] = False + self._default_nv_pairs["ISIS_AUTH_KEY"] = "" + self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_KEY_ID"] = "" + self._default_nv_pairs["ISIS_AUTH_KEYCHAIN_NAME"] = "" + self._default_nv_pairs["ISIS_LEVEL"] = "" + self._default_nv_pairs["ISIS_OVERLOAD_ELAPSE_TIME"] = "" + self._default_nv_pairs["ISIS_OVERLOAD_ENABLE"] = "" + # self._default_nv_pairs["ISIS_P2P_ENABLE"] = False + self._default_nv_pairs["ISIS_P2P_ENABLE"] = "" + self._default_nv_pairs["L2_HOST_INTF_MTU"] = "9216" + self._default_nv_pairs["L2_HOST_INTF_MTU_PREV"] = "9216" + self._default_nv_pairs["L2_SEGMENT_ID_RANGE"] = "30000-49000" + self._default_nv_pairs["L3VNI_MCAST_GROUP"] = "" + self._default_nv_pairs["L3_PARTITION_ID_RANGE"] = "50000-59000" + self._default_nv_pairs["LINK_STATE_ROUTING"] = "ospf" + self._default_nv_pairs["LINK_STATE_ROUTING_TAG"] = "UNDERLAY" + self._default_nv_pairs["LINK_STATE_ROUTING_TAG_PREV"] = "" + self._default_nv_pairs["LOOPBACK0_IPV6_RANGE"] = "" + self._default_nv_pairs["LOOPBACK0_IP_RANGE"] = "10.2.0.0/22" + self._default_nv_pairs["LOOPBACK1_IPV6_RANGE"] = "" + self._default_nv_pairs["LOOPBACK1_IP_RANGE"] = "10.3.0.0/22" + self._default_nv_pairs["MACSEC_ALGORITHM"] = "" + self._default_nv_pairs["MACSEC_CIPHER_SUITE"] = "" + self._default_nv_pairs["MACSEC_FALLBACK_ALGORITHM"] = "" + self._default_nv_pairs["MACSEC_FALLBACK_KEY_STRING"] = "" + self._default_nv_pairs["MACSEC_KEY_STRING"] = "" + self._default_nv_pairs["MACSEC_REPORT_TIMER"] = "" + self._default_nv_pairs["MGMT_GW"] = "" + self._default_nv_pairs["MGMT_GW_INTERNAL"] = "" + self._default_nv_pairs["MGMT_PREFIX"] = "" + self._default_nv_pairs["MGMT_PREFIX_INTERNAL"] = "" + self._default_nv_pairs["MGMT_V6PREFIX"] = "64" + self._default_nv_pairs["MGMT_V6PREFIX_INTERNAL"] = "" + self._default_nv_pairs["MPLS_HANDOFF"] = False + self._default_nv_pairs["MPLS_LB_ID"] = "" + self._default_nv_pairs["MPLS_LOOPBACK_IP_RANGE"] = "" + self._default_nv_pairs["MSO_CONNECTIVITY_DEPLOYED"] = "" + self._default_nv_pairs["MSO_CONTROLER_ID"] = "" + self._default_nv_pairs["MSO_SITE_GROUP_NAME"] = "" + self._default_nv_pairs["MSO_SITE_ID"] = "" + self._default_nv_pairs["MST_INSTANCE_RANGE"] = "" + self._default_nv_pairs["MULTICAST_GROUP_SUBNET"] = "239.1.1.0/25" + self._default_nv_pairs["NETFLOW_EXPORTER_LIST"] = "" + self._default_nv_pairs["NETFLOW_MONITOR_LIST"] = "" + self._default_nv_pairs["NETFLOW_RECORD_LIST"] = "" + self._default_nv_pairs["NETWORK_VLAN_RANGE"] = "2300-2999" + self._default_nv_pairs["NTP_SERVER_IP_LIST"] = "" + self._default_nv_pairs["NTP_SERVER_VRF"] = "" + self._default_nv_pairs["NVE_LB_ID"] = "1" + self._default_nv_pairs["OSPF_AREA_ID"] = "0.0.0.0" + self._default_nv_pairs["OSPF_AUTH_ENABLE"] = False + self._default_nv_pairs["OSPF_AUTH_KEY"] = "" + self._default_nv_pairs["OSPF_AUTH_KEY_ID"] = "" + self._default_nv_pairs["OVERLAY_MODE"] = "config-profile" + self._default_nv_pairs["OVERLAY_MODE_PREV"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID1"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID2"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID3"] = "" + self._default_nv_pairs["PHANTOM_RP_LB_ID4"] = "" + self._default_nv_pairs["PIM_HELLO_AUTH_ENABLE"] = False + self._default_nv_pairs["PIM_HELLO_AUTH_KEY"] = "" + self._default_nv_pairs["PM_ENABLE"] = False + self._default_nv_pairs["PM_ENABLE_PREV"] = False + self._default_nv_pairs["POWER_REDUNDANCY_MODE"] = "ps-redundant" + self._default_nv_pairs["PREMSO_PARENT_FABRIC"] = "" + self._default_nv_pairs["PTP_DOMAIN_ID"] = "" + self._default_nv_pairs["PTP_LB_ID"] = "" + self._default_nv_pairs["REPLICATION_MODE"] = "Multicast" + self._default_nv_pairs["ROUTER_ID_RANGE"] = "" + self._default_nv_pairs["ROUTE_MAP_SEQUENCE_NUMBER_RANGE"] = "1-65534" + self._default_nv_pairs["RP_COUNT"] = "2" + self._default_nv_pairs["RP_LB_ID"] = "254" + self._default_nv_pairs["RP_MODE"] = "asm" + self._default_nv_pairs["RR_COUNT"] = "2" + self._default_nv_pairs["SEED_SWITCH_CORE_INTERFACES"] = "" + self._default_nv_pairs["SERVICE_NETWORK_VLAN_RANGE"] = "3000-3199" + self._default_nv_pairs["SITE_ID"] = "" + self._default_nv_pairs["SNMP_SERVER_HOST_TRAP"] = True + self._default_nv_pairs["SPINE_COUNT"] = "0" + self._default_nv_pairs["SPINE_SWITCH_CORE_INTERFACES"] = "" + self._default_nv_pairs["SSPINE_ADD_DEL_DEBUG_FLAG"] = "Disable" + self._default_nv_pairs["SSPINE_COUNT"] = "0" + self._default_nv_pairs["STATIC_UNDERLAY_IP_ALLOC"] = False + self._default_nv_pairs["STP_BRIDGE_PRIORITY"] = "" + self._default_nv_pairs["STP_ROOT_OPTION"] = "unmanaged" + self._default_nv_pairs["STP_VLAN_RANGE"] = "" + self._default_nv_pairs["STRICT_CC_MODE"] = False + self._default_nv_pairs["SUBINTERFACE_RANGE"] = "2-511" + self._default_nv_pairs["SUBNET_RANGE"] = "10.4.0.0/16" + self._default_nv_pairs["SUBNET_TARGET_MASK"] = "30" + self._default_nv_pairs["SYSLOG_SERVER_IP_LIST"] = "" + self._default_nv_pairs["SYSLOG_SERVER_VRF"] = "" + self._default_nv_pairs["SYSLOG_SEV"] = "" + self._default_nv_pairs["TCAM_ALLOCATION"] = True + self._default_nv_pairs["UNDERLAY_IS_V6"] = False + self._default_nv_pairs["UNNUM_BOOTSTRAP_LB_ID"] = "" + self._default_nv_pairs["UNNUM_DHCP_END"] = "" + self._default_nv_pairs["UNNUM_DHCP_END_INTERNAL"] = "" + self._default_nv_pairs["UNNUM_DHCP_START"] = "" + self._default_nv_pairs["UNNUM_DHCP_START_INTERNAL"] = "" + self._default_nv_pairs["USE_LINK_LOCAL"] = "" + self._default_nv_pairs["V6_SUBNET_RANGE"] = "" + self._default_nv_pairs["V6_SUBNET_TARGET_MASK"] = "" + self._default_nv_pairs["VPC_AUTO_RECOVERY_TIME"] = "360" + self._default_nv_pairs["VPC_DELAY_RESTORE"] = "150" + self._default_nv_pairs["VPC_DELAY_RESTORE_TIME"] = "60" + self._default_nv_pairs["VPC_DOMAIN_ID_RANGE"] = "1-1000" + self._default_nv_pairs["VPC_ENABLE_IPv6_ND_SYNC"] = True + self._default_nv_pairs["VPC_PEER_KEEP_ALIVE_OPTION"] = "management" + self._default_nv_pairs["VPC_PEER_LINK_PO"] = "500" + self._default_nv_pairs["VPC_PEER_LINK_VLAN"] = "3600" + self._default_nv_pairs["VRF_LITE_AUTOCONFIG"] = "Manual" + self._default_nv_pairs["VRF_VLAN_RANGE"] = "2000-2299" + self._default_nv_pairs["abstract_anycast_rp"] = "anycast_rp" + self._default_nv_pairs["abstract_bgp"] = "base_bgp" + value = "evpn_bgp_rr_neighbor" + self._default_nv_pairs["abstract_bgp_neighbor"] = value + self._default_nv_pairs["abstract_bgp_rr"] = "evpn_bgp_rr" + self._default_nv_pairs["abstract_dhcp"] = "base_dhcp" + self._default_nv_pairs["abstract_extra_config_bootstrap"] = ( + "extra_config_bootstrap_11_1" + ) + value = "extra_config_leaf" + self._default_nv_pairs["abstract_extra_config_leaf"] = value + value = "extra_config_spine" + self._default_nv_pairs["abstract_extra_config_spine"] = value + value = "extra_config_tor" + self._default_nv_pairs["abstract_extra_config_tor"] = value + value = "base_feature_leaf_upg" + self._default_nv_pairs["abstract_feature_leaf"] = value + value = "base_feature_spine_upg" + self._default_nv_pairs["abstract_feature_spine"] = value + self._default_nv_pairs["abstract_isis"] = "base_isis_level2" + self._default_nv_pairs["abstract_isis_interface"] = "isis_interface" + self._default_nv_pairs["abstract_loopback_interface"] = ( + "int_fabric_loopback_11_1" + ) + self._default_nv_pairs["abstract_multicast"] = "base_multicast_11_1" + self._default_nv_pairs["abstract_ospf"] = "base_ospf" + value = "ospf_interface_11_1" + self._default_nv_pairs["abstract_ospf_interface"] = value + self._default_nv_pairs["abstract_pim_interface"] = "pim_interface" + self._default_nv_pairs["abstract_route_map"] = "route_map" + self._default_nv_pairs["abstract_routed_host"] = "int_routed_host" + self._default_nv_pairs["abstract_trunk_host"] = "int_trunk_host" + value = "int_fabric_vlan_11_1" + self._default_nv_pairs["abstract_vlan_interface"] = value + self._default_nv_pairs["abstract_vpc_domain"] = "base_vpc_domain_11_1" + value = "Default_Network_Universal" + self._default_nv_pairs["default_network"] = value + self._default_nv_pairs["default_pvlan_sec_network"] = "" + self._default_nv_pairs["default_vrf"] = "Default_VRF_Universal" + self._default_nv_pairs["enableRealTimeBackup"] = "" + self._default_nv_pairs["enableScheduledBackup"] = "" + self._default_nv_pairs["network_extension_template"] = ( + "Default_Network_Extension_Universal" + ) + self._default_nv_pairs["scheduledTime"] = "" + self._default_nv_pairs["temp_anycast_gateway"] = "anycast_gateway" + self._default_nv_pairs["temp_vpc_domain_mgmt"] = "vpc_domain_mgmt" + self._default_nv_pairs["temp_vpc_peer_link"] = "int_vpc_peer_link_po" + self._default_nv_pairs["vrf_extension_template"] = ( + "Default_VRF_Extension_Universal" + ) + + def _build_default_fabric_params(self): + """ + Caller: __init__() + + Initialize default NDFC top-level parameters + See also: self._build_default_nv_pairs() + """ + # TODO:3 We may need translation methods for these as well. See the + # method for nvPair transation: _translate_to_ndfc_nv_pairs + self._default_fabric_params = {} + self._default_fabric_params["deviceType"] = "n9k" + self._default_fabric_params["fabricTechnology"] = "VXLANFabric" + self._default_fabric_params["fabricTechnologyFriendly"] = "VXLAN Fabric" + self._default_fabric_params["fabricType"] = "Switch_Fabric" + self._default_fabric_params["fabricTypeFriendly"] = "Switch Fabric" + self._default_fabric_params["networkExtensionTemplate"] = ( + "Default_Network_Extension_Universal" + ) + value = "Default_Network_Universal" + self._default_fabric_params["networkTemplate"] = value + self._default_fabric_params["provisionMode"] = "DCNMTopDown" + self._default_fabric_params["replicationMode"] = "Multicast" + self._default_fabric_params["siteId"] = "" + self._default_fabric_params["templateName"] = "Easy_Fabric" + self._default_fabric_params["vrfExtensionTemplate"] = ( + "Default_VRF_Extension_Universal" + ) + self._default_fabric_params["vrfTemplate"] = "Default_VRF_Universal" diff --git a/plugins/module_utils/fabric/ruleset.py b/plugins/module_utils/fabric/ruleset.py new file mode 100644 index 000000000..7259e4ea3 --- /dev/null +++ b/plugins/module_utils/fabric/ruleset.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python +import json +import logging +import re + +class RuleSetCommon: + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"dcnm.{self.class_name}") + + self.properties = {} + self.properties["template"] = None + self.properties["ruleset"] = {} + + def clean_rule(self): + method_name = "clean_rule" + msg = f"{self.class_name}.{method_name}: " + msg += f"PRE1 : RULE: {self.rule}" + self.log.debug(msg) + 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) + msg = f"{self.class_name}.{method_name}: " + msg += f"PRE2 : RULE: {self.rule}" + self.log.debug(msg) + + @property + def ruleset(self): + return self.properties["ruleset"] + @ruleset.setter + def ruleset(self, value): + self.properties["ruleset"] = value + + @property + def template(self): + return self.properties["template"] + @template.setter + def template(self, value): + self.properties["template"] = value + + @staticmethod + def make_boolean(value): + if value in ("true", "True", True): + return True + if value in ("false", "False", False): + return False + return value + + @staticmethod + def get_annotations(parameter): + 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): + annotations = self.get_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): + annotations = self.get_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): + annotations = self.get_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): + annotations = self.get_annotations(parameter) + if annotations is None: + return "" + if annotations.get("Section") is None: + return "" + return annotations["Section"] + + @staticmethod + def name(parameter): + if parameter.get("name") is None: + return None + return parameter["name"] + +class RuleSet(RuleSetCommon): + """ + Usage: + + ruleset = RuleSet() + ruleset.template =