|
| 1 | +from __future__ import absolute_import, division, print_function |
| 2 | + |
| 3 | + |
| 4 | +__metaclass__ = type |
| 5 | + |
| 6 | +from pprint import pprint |
| 7 | +import json |
| 8 | +from ansible.utils.display import Display |
| 9 | +from ansible.plugins.action import ActionBase |
| 10 | +from ansible.module_utils.six import raise_from |
| 11 | +from ansible.errors import AnsibleError |
| 12 | +from ansible.module_utils.common.text.converters import to_native |
| 13 | +from ..plugin_utils.tools import load_yaml_file, process_deepdiff |
| 14 | +from ..plugin_utils.pydantic_schemas.dcnm_interface.schemas import DcnmInterfaceQuerySchema |
| 15 | + |
| 16 | +try: |
| 17 | + from deepdiff import DeepDiff |
| 18 | +except ImportError as imp_exc: |
| 19 | + DEEPDIFF_IMPORT_ERROR = imp_exc |
| 20 | +else: |
| 21 | + DEEPDIFF_IMPORT_ERROR = None |
| 22 | + |
| 23 | +if DEEPDIFF_IMPORT_ERROR: |
| 24 | + raise_from( |
| 25 | + AnsibleError('DeepDiff must be installed to use this plugin. Use pip or install test-requirements.'), |
| 26 | + DEEPDIFF_IMPORT_ERROR) |
| 27 | + |
| 28 | +display = Display() |
| 29 | + |
| 30 | + |
| 31 | +class ActionModule(ActionBase): |
| 32 | + |
| 33 | + def run(self, tmp=None, task_vars=None): |
| 34 | + results = super(ActionModule, self).run(tmp, task_vars) |
| 35 | + results['failed'] = False |
| 36 | + |
| 37 | + ndfc_data = self._task.args.get('ndfc_data', None) |
| 38 | + test_data = self._task.args.get('test_data', None) |
| 39 | + config_path = self._task.args.get('config_path', None) |
| 40 | + check_deleted = self._task.args.get('check_deleted', False) |
| 41 | + ignore_fields = list(self._task.args.get('ignore_fields', [])) |
| 42 | + |
| 43 | + for input_item in [ndfc_data, test_data, config_path]: |
| 44 | + if input_item is None: |
| 45 | + results['failed'] = True |
| 46 | + results['msg'] = f"Required input parameter not found: '{input_item}'" |
| 47 | + return results |
| 48 | + |
| 49 | + # Get switch mapping from test_data.sw_sn |
| 50 | + switch_ip_sn_mapping = test_data.get('sw_sn', {}) |
| 51 | + |
| 52 | + # removes ansible embeddings and converts to native python types |
| 53 | + native_ndfc_data = json.loads(json.dumps(ndfc_data, default=to_native)) |
| 54 | + |
| 55 | + test_fabric = test_data['fabric'] |
| 56 | + |
| 57 | + expected_data_parsed = None |
| 58 | + if config_path != "": |
| 59 | + # only parse if config file exists |
| 60 | + expected_config_data = load_yaml_file(config_path) |
| 61 | + expected_data = DcnmInterfaceQuerySchema.yaml_config_to_dict(expected_config_data, test_fabric, switch_ip_sn_mapping) |
| 62 | + expected_data_parsed = DcnmInterfaceQuerySchema.model_validate(expected_data).model_dump(exclude_none=True) |
| 63 | + |
| 64 | + # Parse NDFC data through schema to normalize it (case conversion, tuple handling, etc.) |
| 65 | + ndfc_data_parsed = DcnmInterfaceQuerySchema.model_validate(native_ndfc_data).model_dump(exclude_none=True) |
| 66 | + |
| 67 | + if deleted_results := self.verify_deleted(results, check_deleted, expected_data_parsed, ndfc_data_parsed, config_path): |
| 68 | + return deleted_results |
| 69 | + |
| 70 | + validity = DeepDiff( |
| 71 | + expected_data_parsed, |
| 72 | + ndfc_data_parsed, |
| 73 | + ignore_order=True, |
| 74 | + cutoff_distance_for_pairs=0, |
| 75 | + cutoff_intersection_for_pairs=0, |
| 76 | + report_repetition=True |
| 77 | + ) |
| 78 | + |
| 79 | + # Process the output of deepdiff to make it easier to read |
| 80 | + # Effects the iterable_item_added and iterable_item_removed to remove unneeded fields |
| 81 | + # ignore_extra_fields=True will ignore dictionary_item_added changes |
| 82 | + # This is useful when the actual data has more fields than the expected data |
| 83 | + # keys_to_ignore is a list of fields to ignore, useful for auto provisioned fields which are not known |
| 84 | + processed_validity = process_deepdiff(validity, keys_to_ignore=ignore_fields, ignore_extra_fields=True) |
| 85 | + if processed_validity == {}: |
| 86 | + results['failed'] = False |
| 87 | + results['msg'] = f'Data is valid. \n\n Expected data: \n\n{expected_data_parsed}\n\nActual data: \n\n{ndfc_data_parsed}' |
| 88 | + else: |
| 89 | + results['failed'] = True |
| 90 | + print("\n\nExpected: ") |
| 91 | + pprint(expected_data_parsed) |
| 92 | + print("\n\nActual: ") |
| 93 | + pprint(ndfc_data_parsed) |
| 94 | + print("\n\nDifferences: ") |
| 95 | + pprint(processed_validity) |
| 96 | + results['msg'] = 'Data is not valid.' |
| 97 | + |
| 98 | + return results |
| 99 | + |
| 100 | + def _normalize_interface_id_fields(self, expected_data, ndfc_data): |
| 101 | + """ |
| 102 | + Normalize PO_ID and INTF_NAME fields in expected data based on interface type and what's present in NDFC data. |
| 103 | + For port-channel interfaces, use PO_ID. For other interfaces, use INTF_NAME. |
| 104 | + Also handle special cases like V6IP vs IPv6 for loopback interfaces. |
| 105 | + Remove the incorrect field from expected data if it's not present in the actual NDFC data. |
| 106 | + """ |
| 107 | + for expected_policy in expected_data.get("response", []): |
| 108 | + for expected_interface in expected_policy.get("interfaces", []): |
| 109 | + expected_nvpairs = expected_interface.get("nvPairs", {}) |
| 110 | + |
| 111 | + # Find corresponding interface in NDFC data |
| 112 | + ndfc_interface = self._find_matching_interface(expected_interface, ndfc_data) |
| 113 | + if not ndfc_interface: |
| 114 | + continue |
| 115 | + |
| 116 | + ndfc_nvpairs = ndfc_interface.get("nvPairs", {}) |
| 117 | + |
| 118 | + # Determine if this is a port-channel or loopback interface |
| 119 | + ifname = expected_interface.get("ifName", "").lower() |
| 120 | + is_port_channel = ifname.startswith("port-channel") |
| 121 | + is_loopback = ifname.startswith("loopback") |
| 122 | + |
| 123 | + if is_port_channel: |
| 124 | + # For port-channel interfaces, use PO_ID |
| 125 | + if "PO_ID" not in ndfc_nvpairs and "PO_ID" in expected_nvpairs: |
| 126 | + del expected_nvpairs["PO_ID"] |
| 127 | + display.vvv(f"Removed PO_ID from expected data for port-channel interface {ifname}") |
| 128 | + |
| 129 | + # Remove INTF_NAME if present (shouldn't be used for port-channel) |
| 130 | + if "INTF_NAME" in expected_nvpairs: |
| 131 | + del expected_nvpairs["INTF_NAME"] |
| 132 | + display.vvv(f"Removed INTF_NAME from expected data for port-channel interface {ifname}") |
| 133 | + else: |
| 134 | + # For other interfaces, use INTF_NAME |
| 135 | + if "INTF_NAME" not in ndfc_nvpairs and "INTF_NAME" in expected_nvpairs: |
| 136 | + del expected_nvpairs["INTF_NAME"] |
| 137 | + display.vvv(f"Removed INTF_NAME from expected data for interface {ifname}") |
| 138 | + |
| 139 | + # Remove PO_ID if present (shouldn't be used for non-port-channel) |
| 140 | + if "PO_ID" in expected_nvpairs: |
| 141 | + del expected_nvpairs["PO_ID"] |
| 142 | + display.vvv(f"Removed PO_ID from expected data for non-port-channel interface {ifname}") |
| 143 | + |
| 144 | + # Handle special IPv6 field mapping for loopback interfaces |
| 145 | + if is_loopback: |
| 146 | + # For loopback interfaces, NDFC uses V6IP instead of IPv6 |
| 147 | + if "IPv6" in expected_nvpairs and "V6IP" in ndfc_nvpairs: |
| 148 | + # Move IPv6 value to V6IP in expected data |
| 149 | + expected_nvpairs["V6IP"] = expected_nvpairs["IPv6"] |
| 150 | + del expected_nvpairs["IPv6"] |
| 151 | + display.vvv(f"Moved IPv6 to V6IP for loopback interface {ifname}") |
| 152 | + elif "IPv6" in expected_nvpairs and "V6IP" not in ndfc_nvpairs: |
| 153 | + # Remove IPv6 if NDFC doesn't have V6IP |
| 154 | + del expected_nvpairs["IPv6"] |
| 155 | + display.vvv(f"Removed IPv6 from expected data for loopback interface {ifname}") |
| 156 | + |
| 157 | + # For loopback interfaces, NDFC uses ROUTE_MAP_TAG instead of ROUTING_TAG |
| 158 | + if "ROUTING_TAG" in expected_nvpairs and "ROUTE_MAP_TAG" in ndfc_nvpairs: |
| 159 | + # Move ROUTING_TAG value to ROUTE_MAP_TAG in expected data |
| 160 | + expected_nvpairs["ROUTE_MAP_TAG"] = expected_nvpairs["ROUTING_TAG"] |
| 161 | + del expected_nvpairs["ROUTING_TAG"] |
| 162 | + display.vvv(f"Moved ROUTING_TAG to ROUTE_MAP_TAG for loopback interface {ifname}") |
| 163 | + elif "ROUTING_TAG" in expected_nvpairs and "ROUTE_MAP_TAG" not in ndfc_nvpairs: |
| 164 | + # Remove ROUTING_TAG if NDFC doesn't have ROUTE_MAP_TAG |
| 165 | + del expected_nvpairs["ROUTING_TAG"] |
| 166 | + display.vvv(f"Removed ROUTING_TAG from expected data for loopback interface {ifname}") |
| 167 | + else: |
| 168 | + # For non-loopback interfaces, remove V6IP if present |
| 169 | + if "V6IP" in expected_nvpairs: |
| 170 | + del expected_nvpairs["V6IP"] |
| 171 | + display.vvv(f"Removed V6IP from expected data for non-loopback interface {ifname}") |
| 172 | + |
| 173 | + # For non-loopback interfaces, remove ROUTE_MAP_TAG if present |
| 174 | + if "ROUTE_MAP_TAG" in expected_nvpairs: |
| 175 | + del expected_nvpairs["ROUTE_MAP_TAG"] |
| 176 | + display.vvv(f"Removed ROUTE_MAP_TAG from expected data for non-loopback interface {ifname}") |
| 177 | + |
| 178 | + def _find_matching_interface(self, expected_interface, ndfc_data): |
| 179 | + """ |
| 180 | + Find the matching interface in NDFC data based on ifName and serialNumber. |
| 181 | + """ |
| 182 | + expected_ifname = expected_interface.get("ifName", "").lower() |
| 183 | + expected_serial = expected_interface.get("serialNumber", "") |
| 184 | + |
| 185 | + for ndfc_policy in ndfc_data.get("response", []): |
| 186 | + for ndfc_interface in ndfc_policy.get("interfaces", []): |
| 187 | + ndfc_ifname = ndfc_interface.get("ifName", "").lower() |
| 188 | + ndfc_serial = ndfc_interface.get("serialNumber", "") |
| 189 | + |
| 190 | + if expected_ifname == ndfc_ifname and expected_serial == ndfc_serial: |
| 191 | + return ndfc_interface |
| 192 | + |
| 193 | + return None |
| 194 | + |
| 195 | + def verify_deleted(self, results, check_deleted, expected_data, ndfc_data, config_path): |
| 196 | + if not check_deleted: |
| 197 | + return None |
| 198 | + |
| 199 | + existing_interfaces = set() |
| 200 | + for policy in ndfc_data["response"]: |
| 201 | + for interface in policy["interfaces"]: |
| 202 | + existing_interfaces.add((interface["serialNumber"], interface["ifName"])) |
| 203 | + |
| 204 | + if config_path == "": |
| 205 | + # check for full delete |
| 206 | + if not ndfc_data["failed"] and len(existing_interfaces) == 0: |
| 207 | + results['msg'] = 'All interfaces are deleted' |
| 208 | + else: |
| 209 | + print("Interfaces still existing: ") |
| 210 | + print(existing_interfaces) |
| 211 | + results['failed'] = True |
| 212 | + results['msg'] = 'Error: Expected full delete as config_path is empty but interfaces still exist.' |
| 213 | + if ndfc_data["failed"]: |
| 214 | + results['msg'] += '\n\nError: ' + ndfc_data["error"] |
| 215 | + return results |
| 216 | + return results |
| 217 | + |
| 218 | + # checks for a partial delete |
| 219 | + deleted_interfaces = set() |
| 220 | + for policy in expected_data["response"]: |
| 221 | + for interface in policy["interfaces"]: |
| 222 | + deleted_interfaces.add((interface["serialNumber"], interface["ifName"])) |
| 223 | + |
| 224 | + remaining_interfaces = existing_interfaces.intersection(deleted_interfaces) |
| 225 | + if len(remaining_interfaces) > 0: |
| 226 | + results['failed'] = True |
| 227 | + print("Expected interfaces to be deleted: ") |
| 228 | + print(deleted_interfaces) |
| 229 | + print("\nInterfaces present in NDFC: ") |
| 230 | + print(existing_interfaces) |
| 231 | + print("\nInterfaces still not deleted: ") |
| 232 | + print(remaining_interfaces) |
| 233 | + results['msg'] = 'All interfaces are not deleted' |
| 234 | + return results |
| 235 | + |
| 236 | + print("Expected interfaces to be deleted: ") |
| 237 | + print(deleted_interfaces) |
| 238 | + print("\n\nInterfaces present in NDFC: ") |
| 239 | + print(existing_interfaces) |
| 240 | + print("Interfaces still not deleted: ") |
| 241 | + print(remaining_interfaces) |
| 242 | + results['failed'] = False |
| 243 | + results['msg'] = 'Provided interfaces are deleted' |
| 244 | + return results |
0 commit comments