diff --git a/plugins/action/common/change_flag_manager.py b/plugins/action/common/change_flag_manager.py index f01c6be2d..331172735 100644 --- a/plugins/action/common/change_flag_manager.py +++ b/plugins/action/common/change_flag_manager.py @@ -67,6 +67,7 @@ def initialize_flags(self): 'changes_detected_interface_breakout_preprov': False, 'changes_detected_inventory': False, 'changes_detected_link_vpc_peering': False, + 'changes_detected_tor_pairing': False, 'changes_detected_networks': False, 'changes_detected_policy': False, 'changes_detected_sub_interface_routed': False, @@ -147,6 +148,7 @@ def initialize_flags(self): 'changes_detected_interface_breakout_preprov': False, 'changes_detected_inventory': False, 'changes_detected_link_vpc_peering': False, + 'changes_detected_tor_pairing': False, 'changes_detected_networks': False, 'changes_detected_policy': False, 'changes_detected_sub_interface_routed': False, diff --git a/plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py b/plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py index 92aecef5c..1c9c08ff6 100644 --- a/plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py +++ b/plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py @@ -75,6 +75,17 @@ def prepare(self): switch['mgmt_ip_address'] = found_switch['management']['management_ipv4_address'] elif found_switch.get('management').get('management_ipv6_address'): switch['mgmt_ip_address'] = found_switch['management']['management_ipv6_address'] + + # Process nested TOR entries and resolve their management IPs + if 'tors' in switch and switch['tors']: + for tor in switch['tors']: + tor_hostname = tor.get('hostname') + if tor_hostname and any(sw['name'] == tor_hostname for sw in switches): + found_tor = next((item for item in switches if item["name"] == tor_hostname)) + if found_tor.get('management').get('management_ipv4_address'): + tor['mgmt_ip_address'] = found_tor['management']['management_ipv4_address'] + elif found_tor.get('management').get('management_ipv6_address'): + tor['mgmt_ip_address'] = found_tor['management']['management_ipv6_address'] # Remove network_attach_group from net if the group_name is not defined for net in model_data['vxlan']['overlay']['networks']: @@ -131,6 +142,18 @@ def prepare(self): switch['mgmt_ip_address'] = found_switch['management']['management_ipv4_address'] elif found_switch.get('management').get('management_ipv6_address'): switch['mgmt_ip_address'] = found_switch['management']['management_ipv6_address'] + + # Process nested TOR entries and resolve their management IPs + if 'tors' in switch and switch['tors']: + for tor in switch['tors']: + tor_hostname = tor.get('hostname') + if tor_hostname and any(sw['name'] == tor_hostname for sw in switches): + found_tor = next((item for item in switches if item["name"] == tor_hostname)) + if found_tor.get('management').get('management_ipv4_address'): + tor['mgmt_ip_address'] = found_tor['management']['management_ipv4_address'] + elif found_tor.get('management').get('management_ipv6_address'): + tor['mgmt_ip_address'] = found_tor['management']['management_ipv6_address'] + # Append switch to a flat list of switches for cross comparison later when we query the # MSD fabric information. We need to stop execution if the list returned by the MSD query # does not include one of these switches. diff --git a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py new file mode 100644 index 000000000..6d53da4e4 --- /dev/null +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -0,0 +1,336 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + + +class PreparePlugin: + """ + ToR Pairing Prepare Plugin. + + Transforms user YAML configuration into NDFC API payloads and optionally + performs diff detection for removal scenarios. + + """ + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.keys = ['vxlan', 'topology', 'tor_peers'] + + def _normalize_serials(self, payload): + """ + Create order-independent serial tuple for comparison. + + Handles VPC pairs where serial numbers can appear in any order + between NDFC response and prepare plugin output. + + Args: + payload: dict with leafSN1, leafSN2, torSN1, torSN2 keys + + Returns: + tuple: ((sorted_tor_serials), (sorted_leaf_serials)) + """ + # Extract and filter empty strings + tor_serials = [ + payload.get('torSN1', ''), + payload.get('torSN2', '') + ] + tor_serials = [s for s in tor_serials if s] + + leaf_serials = [ + payload.get('leafSN1', ''), + payload.get('leafSN2', '') + ] + leaf_serials = [s for s in leaf_serials if s] + + # Sort for order-independent comparison + return (tuple(sorted(tor_serials)), tuple(sorted(leaf_serials))) + + def _get_switch(self, name, expected_role, switches, errors): + """ + Get switch from switches map. + Note: Basic validation is now handled by validation rule 311. + This method focuses on data retrieval for payload generation. + """ + switch = switches.get(name) + if not switch: + # Validation rule should have caught this + errors.append(f"Switch '{name}' referenced in tor_peers is not defined in vxlan.topology.switches") + return None + return switch + + def _normalize_vpc_id(self, value, label, errors): + if value is None: + errors.append(f"{label} is required when defining tor pairing entries.") + return None + try: + return int(value) + except (TypeError, ValueError): + errors.append(f"{label} must be an integer value. Current value: {value!r}") + return None + + def _resolve_vpc_domain(self, peer, key, name_a, name_b, topology): + if peer.get(key) is not None: + return peer.get(key) + if not (name_a and name_b): + return None + vpc_peers = topology.get('vpc_peers') or [] + for candidate in vpc_peers: + peers = {candidate.get('peer1'), candidate.get('peer2')} + if {name_a, name_b} == peers: + return candidate.get('domain_id') + return None + + def _detect_scenario(self, peer, topology): + """ + Auto-detect ToR pairing scenario based on configuration. + + Returns: tuple (scenario, leaf_vpc_domain, tor_vpc_domain) + """ + parent_leaf1 = peer.get('parent_leaf1') + parent_leaf2 = peer.get('parent_leaf2') + tor1 = peer.get('tor1') + tor2 = peer.get('tor2') + + # Simple string handling (new model) + leaf1_name = parent_leaf1 if isinstance(parent_leaf1, str) else parent_leaf1.get('name') + leaf2_name = parent_leaf2 if isinstance(parent_leaf2, str) else parent_leaf2.get('name') if parent_leaf2 else None + tor1_name = tor1 if isinstance(tor1, str) else tor1.get('name') + tor2_name = tor2 if isinstance(tor2, str) else tor2.get('name') if tor2 else None + + # Auto-resolve VPC domains from vpc_peers + leaf_vpc_domain = self._resolve_vpc_domain_auto(leaf1_name, leaf2_name, topology) if leaf2_name else None + tor_vpc_domain = self._resolve_vpc_domain_auto(tor1_name, tor2_name, topology) if tor2_name else None + + # Determine scenario + if leaf2_name and tor2_name: + if not leaf_vpc_domain or not tor_vpc_domain: + return None, None, None # Invalid configuration + return 'vpc_to_vpc', leaf_vpc_domain, tor_vpc_domain + elif leaf2_name and not tor2_name: + if not leaf_vpc_domain: + return None, None, None # Invalid configuration + return 'vpc_to_standalone', leaf_vpc_domain, None + elif not leaf2_name and not tor2_name: + return 'standalone_to_standalone', None, None + else: + # Unsupported: standalone leaf with vpc tor + return None, None, None + + def _resolve_vpc_domain_auto(self, switch1_name, switch2_name, topology): + """ + Auto-resolve VPC domain ID from vpc_peers configuration. + """ + if not (switch1_name and switch2_name): + return None + + vpc_peers = topology.get('vpc_peers', []) + for vpc_pair in vpc_peers: + peer1 = vpc_pair.get('peer1') + peer2 = vpc_pair.get('peer2') + if {peer1, peer2} == {switch1_name, switch2_name}: + return vpc_pair.get('domain_id') + return None + + def prepare(self): + results = self.kwargs['results'] + model_data = results['model_extended'] + topology = model_data.get('vxlan', {}).get('topology', {}) + tor_peers = topology.get('tor_peers') + + if not tor_peers: + return results + + switches = {sw.get('name'): sw for sw in topology.get('switches', []) if sw.get('name')} + processed_pairs = [] + errors = [] + pairing_ids = set() + + for peer in tor_peers: + error_count_start = len(errors) + parent_leaf1 = peer.get('parent_leaf1') + tor1 = peer.get('tor1') + if not parent_leaf1 or not tor1: + errors.append("Each tor_peers entry requires parent_leaf1 and tor1 definitions") + continue + + # Handle both dict and string formats for switch references + leaf1_name = parent_leaf1.get('name') if isinstance(parent_leaf1, dict) else parent_leaf1 + tor1_name = tor1.get('name') if isinstance(tor1, dict) else tor1 + leaf1_switch = self._get_switch(leaf1_name, 'leaf', switches, errors) + tor1_switch = self._get_switch(tor1_name, 'tor', switches, errors) + + parent_leaf2 = peer.get('parent_leaf2') + tor2 = peer.get('tor2') + + # Handle both dict and string formats for optional switches + leaf2_name = parent_leaf2.get('name') if isinstance(parent_leaf2, dict) else parent_leaf2 if parent_leaf2 else None + tor2_name = tor2.get('name') if isinstance(tor2, dict) else tor2 if tor2 else None + + leaf2_switch = None + tor2_switch = None + + if parent_leaf2: + leaf2_switch = self._get_switch(leaf2_name, 'leaf', switches, errors) + if tor2: + tor2_switch = self._get_switch(tor2_name, 'tor', switches, errors) + + # Auto-detect VPC scenarios based on presence of tor2/leaf2 + # No need for explicit tor_vpc_peer flag with new simplified model + + # Auto-resolve VPC domain IDs from vpc_peers configuration + leaf_vpc_domain = self._resolve_vpc_domain(peer, 'leaf_vpc_id', leaf1_name, leaf2_name, topology) + tor_vpc_domain = self._resolve_vpc_domain(peer, 'tor_vpc_id', tor1_name, tor2_name, topology) + + # Determine if this is a VPC scenario based on switch definitions + leaf_is_vpc = bool(leaf2_switch and leaf_vpc_domain) + tor_is_vpc = bool(tor2_switch and tor_vpc_domain) + + # Validate VPC domain IDs are present when needed + if parent_leaf2 and not leaf_vpc_domain: + errors.append( + f"tor_peers entry referencing leaves '{leaf1_name}' and '{leaf2_name}' requires a vPC domain ID. " + f"Ensure these switches are defined in vxlan.topology.vpc_peers." + ) + if tor2 and not tor_vpc_domain: + errors.append( + f"tor_peers entry referencing tors '{tor1_name}' and '{tor2_name}' requires a vPC domain ID. " + f"Ensure these switches are defined in vxlan.topology.vpc_peers." + ) + + # Determine scenario based on configuration + scenario = 'standalone_to_standalone' + if leaf_is_vpc and tor_is_vpc: + scenario = 'vpc_to_vpc' + elif leaf_is_vpc and not tor_is_vpc: + scenario = 'vpc_to_standalone' + elif not leaf_is_vpc and tor_is_vpc: + errors.append( + f"Unsupported ToR pairing scenario: ToR vPC with standalone leaf for '{tor1_name}'. " + f"ToR vPC requires both parent_leaf1 and parent_leaf2 to be defined." + ) + + pairing_id = peer.get('pairing_id') or f"{leaf1_name}-{tor1_name}" + if pairing_id in pairing_ids: + errors.append(f"Duplicate tor pairing identifier '{pairing_id}' detected") + pairing_ids.add(pairing_id) + + # Collect serial numbers + if leaf1_switch: + leaf1_serial = leaf1_switch.get('serial_number') + else: + leaf1_serial = None + + if tor1_switch: + tor1_serial = tor1_switch.get('serial_number') + else: + tor1_serial = None + + # For VPC scenarios, normalize VPC domain IDs + # Only normalize if we actually have a VPC (domain_id exists) + leaf1_po = None + leaf2_po = None + tor1_po = None + tor2_po = None + + if leaf_is_vpc: + leaf1_po = self._normalize_vpc_id(leaf_vpc_domain, "leaf_vpc_id", errors) + leaf2_po = leaf1_po # Same VPC domain for both leafs + + if tor_is_vpc: + tor1_po = self._normalize_vpc_id(tor_vpc_domain, "tor_vpc_id", errors) + tor2_po = tor1_po # Same VPC domain for both tors + + leaf2_serial = '' + if leaf_is_vpc and leaf2_switch: + leaf2_serial = leaf2_switch.get('serial_number') or '' + + tor2_serial = '' + if tor_is_vpc and tor2_switch: + tor2_serial = tor2_switch.get('serial_number') or '' + + required_serials = [leaf1_serial, tor1_serial] + if any(serial is None for serial in required_serials): + errors.append( + f"Serial numbers must be defined for all ToR pairing members. Pairing '{pairing_id}' is missing values." + ) + + # Skip if scenario validation already failed + if scenario != 'standalone_to_standalone' and not leaf_is_vpc: + # scenario with additional members but no vpc support already logged + continue + + if len(errors) > error_count_start: + continue + + processed_pairs.append({ + 'pairing_id': pairing_id, + 'scenario': scenario, + 'payload': { + 'leafSN1': leaf1_serial or '', + 'leafSN2': leaf2_serial or '', + 'torSN1': tor1_serial or '', + 'torSN2': tor2_serial or '' + } + # 'po_map': po_map + }) + + if errors: + results['failed'] = True + results['msg'] = '\n'.join(errors) + return results + + # Store processed pairings in model_extended + model_data['vxlan']['topology']['tor_pairing'] = processed_pairs + + # Perform diff detection for removals (merged from prep_115) + # Get previous pairings (passed from ndfc_tor_pairing.yml) + previous_pairings = self.kwargs.get('tor_pairing_previous_list', []) + + if previous_pairings: + # Build lookup set of current pairing serials + current_serial_sets = {} + for pairing in processed_pairs: + serial_key = self._normalize_serials(pairing['payload']) + current_serial_sets[serial_key] = pairing + + # Find removals by checking which previous pairings no longer exist + removed = [] + for prev_pairing in previous_pairings: + prev_serial_key = self._normalize_serials(prev_pairing['payload']) + if prev_serial_key not in current_serial_sets: + removed.append(prev_pairing) + + # Store results in model_extended for downstream tasks + model_data['vxlan']['topology']['tor_pairing_removed'] = removed + + # Add debug information + results['tor_pairing_diff_stats'] = { + 'previous_count': len(previous_pairings), + 'current_count': len(processed_pairs), + 'removed_count': len(removed), + 'previous_ids': [p.get('pairing_id', 'unknown') for p in previous_pairings], + 'current_ids': [p.get('pairing_id', 'unknown') for p in processed_pairs], + 'removed_ids': [p.get('pairing_id', 'unknown') for p in removed] + } + else: + # No previous state, nothing to remove + model_data['vxlan']['topology']['tor_pairing_removed'] = [] + + results['model_extended'] = model_data + return results diff --git a/plugins/filter/tor_pairing_diff.py b/plugins/filter/tor_pairing_diff.py new file mode 100644 index 000000000..7e9ad8e86 --- /dev/null +++ b/plugins/filter/tor_pairing_diff.py @@ -0,0 +1,117 @@ +# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# SPDX-License-Identifier: MIT + + +def normalize_serials(payload): + """ + Create order-independent serial tuple for comparison. + + Handles VPC pairs where serial numbers can appear in any order + between NDFC response and prepare plugin output. + + Args: + payload: dict with leafSN1, leafSN2, torSN1, torSN2 keys + + Returns: + tuple: ((sorted_tor_serials), (sorted_leaf_serials)) + """ + # Extract and filter empty strings + tor_serials = [ + payload.get('torSN1', ''), + payload.get('torSN2', '') + ] + tor_serials = [s for s in tor_serials if s] + + leaf_serials = [ + payload.get('leafSN1', ''), + payload.get('leafSN2', '') + ] + leaf_serials = [s for s in leaf_serials if s] + + # Sort for order-independent comparison + return (tuple(sorted(tor_serials)), tuple(sorted(leaf_serials))) + + +def tor_pairing_diff(current_pairings, previous_pairings): + """ + Compare current and previous ToR pairings to identify removals. + + Uses order-independent serial number matching to correctly identify + pairings that should be removed (exist in previous but not in current). + + Performance: O(n+m) where n=current count, m=previous count + (vs O(n*m) for nested loop approach) + + Args: + current_pairings: list of current ToR pairing dicts + previous_pairings: list of previous ToR pairing dicts + + Returns: + dict with keys: + - removed: list of pairings to remove + - stats: dict with counts and IDs for debugging + """ + if not previous_pairings: + return { + 'removed': [], + 'stats': { + 'previous_count': 0, + 'current_count': len(current_pairings), + 'removed_count': 0 + } + } + + # Build lookup set of current pairing serials + # Time complexity: O(n) where n = current pairing count + current_serial_sets = {} + for pairing in current_pairings: + serial_key = normalize_serials(pairing['payload']) + current_serial_sets[serial_key] = pairing + + # Find removals by checking which previous pairings no longer exist + # Time complexity: O(m) where m = previous pairing count + removed = [] + for prev_pairing in previous_pairings: + prev_serial_key = normalize_serials(prev_pairing['payload']) + if prev_serial_key not in current_serial_sets: + removed.append(prev_pairing) + + # Return results with statistics for debugging + return { + 'removed': removed, + 'stats': { + 'previous_count': len(previous_pairings), + 'current_count': len(current_pairings), + 'removed_count': len(removed), + 'previous_ids': [p.get('pairing_id', 'unknown') for p in previous_pairings], + 'current_ids': [p.get('pairing_id', 'unknown') for p in current_pairings], + 'removed_ids': [p.get('pairing_id', 'unknown') for p in removed] + } + } + + +class FilterModule(object): + """Ansible filter plugin for ToR pairing diff operations.""" + + def filters(self): + return { + 'tor_pairing_diff': tor_pairing_diff, + 'normalize_tor_serials': normalize_serials + } diff --git a/roles/common_global/vars/main.yml b/roles/common_global/vars/main.yml index ef51aa27a..6beaac16e 100644 --- a/roles/common_global/vars/main.yml +++ b/roles/common_global/vars/main.yml @@ -33,6 +33,7 @@ nac_tags: - cr_manage_policy - cr_manage_links - cr_manage_edge_connections + - cr_manage_tor_pairing # ------------------------- - rr_manage_interfaces - rr_manage_networks @@ -42,6 +43,7 @@ nac_tags: - rr_manage_edge_connections - rr_manage_switches - rr_manage_policy + - rr_manage_tor_pairing # ------------------------- - role_validate - role_create @@ -68,6 +70,7 @@ nac_tags: - rr_manage_links - rr_manage_switches - rr_manage_policy + - rr_manage_tor_pairing # We need the ability to pass tags to the common role but we don't need the following # - validate, cc_verify common_role: @@ -82,6 +85,7 @@ nac_tags: - cr_manage_policy - cr_manage_links - cr_manage_edge_connections + - cr_manage_tor_pairing - rr_manage_edge_connections - rr_manage_interfaces - rr_manage_networks @@ -90,6 +94,7 @@ nac_tags: - rr_manage_links - rr_manage_switches - rr_manage_policy + - rr_manage_tor_pairing # We need the ability to pass tags to the validate role but we don't need the following # - cc_verify validate_role: @@ -105,6 +110,7 @@ nac_tags: - cr_manage_policy - cr_manage_links - cr_manage_edge_connections + - cr_manage_tor_pairing - rr_manage_edge_connections - rr_manage_interfaces - rr_manage_networks @@ -113,6 +119,7 @@ nac_tags: - rr_manage_links - rr_manage_switches - rr_manage_policy + - rr_manage_tor_pairing # All Create Tags create: - cr_manage_fabric @@ -123,8 +130,11 @@ nac_tags: - cr_manage_policy - cr_manage_links - cr_manage_edge_connections + - cr_manage_tor_pairing create_edge_connections: - cr_manage_edge_connections + create_tor_pairing: + - cr_manage_tor_pairing create_fabric: - cr_manage_fabric create_switches: @@ -149,6 +159,7 @@ nac_tags: - rr_manage_switches - rr_manage_policy - rr_manage_edge_connections + - rr_manage_tor_pairing remove_edge_connections: - rr_manage_edge_connections remove_interfaces: @@ -165,5 +176,7 @@ nac_tags: - rr_manage_switches remove_policy: - rr_manage_policy + remove_tor_pairing: + - rr_manage_tor_pairing deploy: - role_deploy diff --git a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml new file mode 100644 index 000000000..be970d967 --- /dev/null +++ b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml @@ -0,0 +1,169 @@ +--- +- name: Initialize ToR pairing context + ansible.builtin.set_fact: + file_name: "ndfc_tor_pairing.yml" + changes_detected_tor_pairing: false + delegate_to: localhost + +- name: Stat previous tor pairing file if it exists + ansible.builtin.stat: + path: "{{ path_name }}{{ file_name }}" + register: tor_pairing_previous + delegate_to: localhost + +- name: Backup previous tor pairing file if it exists + ansible.builtin.copy: + src: "{{ path_name }}{{ file_name }}" + dest: "{{ path_name }}{{ file_name }}.old" + mode: preserve + when: tor_pairing_previous.stat.exists + delegate_to: localhost + +- name: Delete previous tor pairing file if it exists + ansible.builtin.file: + state: absent + path: "{{ path_name }}{{ file_name }}" + when: tor_pairing_previous.stat.exists + delegate_to: localhost + +- name: Build ToR pairing payloads + ansible.builtin.template: + src: ndfc_tor_pairing.j2 + dest: "{{ path_name }}{{ file_name }}" + mode: '0644' + delegate_to: localhost + +- name: Initialize ToR pairing facts + ansible.builtin.set_fact: + tor_pairing: [] + tor_pairing_removed: [] + tor_pairing_previous_list: [] + tor_pairing_current_list: [] + leaf_switches_for_query: >- + {{ MD_Extended.vxlan.topology.switches + | selectattr('role', 'equalto', 'leaf') + | list }} + delegate_to: localhost + +- name: Load previous ToR pairing payloads + ansible.builtin.set_fact: + tor_pairing_previous_list: "{{ lookup('file', path_name + file_name + '.old') | from_yaml | default([], true) }}" + delegate_to: localhost + when: tor_pairing_previous.stat.exists + +- name: Query NDFC from first leaf switch for ToR pairing discovery + cisco.dcnm.dcnm_rest: + method: GET + path: "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/tor/fabrics/{{ MD_Extended.vxlan.fabric.name }}/switches/{{ leaf_switches_for_query[0].serial_number }}" + register: ndfc_tor_discovery + failed_when: false + when: + - not tor_pairing_previous.stat.exists + - leaf_switches_for_query is defined + - leaf_switches_for_query | length > 0 + +- name: Extract existing ToR pairings from NDFC response + ansible.builtin.set_fact: + ndfc_discovered_pairings: "{{ _updated_pairings }}" + tor_pairing_previous_list: "{{ _updated_pairings }}" + vars: + _updated_pairings: >- + {{ (ndfc_discovered_pairings | default([])) + [ + { + 'pairing_id': (tor_pair.torName | replace('~', '-')), + 'scenario': ( + 'vpc_to_vpc' if (tor_pair.torPeerSN is not none and ',' in (tor_pair.leafSNs | default(''))) + else 'vpc_to_standalone' if (',' in (tor_pair.leafSNs | default('')) and tor_pair.torPeerSN is none) + else 'standalone_to_standalone' + ), + 'payload': { + 'leafSN1': (tor_pair.leafSNs.split(',')[0] if tor_pair.leafSNs and ',' in tor_pair.leafSNs else tor_pair.leafSNs | default('')), + 'leafSN2': (tor_pair.leafSNs.split(',')[1] if tor_pair.leafSNs and ',' in tor_pair.leafSNs and tor_pair.leafSNs.split(',') | length > 1 else ''), + 'torSN1': (tor_pair.torSN.split(',')[0] if ',' in (tor_pair.torSN | default('')) else tor_pair.torSN | default('')), + 'torSN2': (tor_pair.torSN.split(',')[1] if ',' in (tor_pair.torSN | default('')) and tor_pair.torSN.split(',') | length > 1 else '') + } + } + ] }} + loop: "{{ ndfc_tor_discovery.response.DATA.torPairs | default([]) | selectattr('remarks', 'search', 'Already paired') }}" + loop_control: + loop_var: tor_pair + label: "{{ tor_pair.torName }} - {{ tor_pair.remarks }}" + delegate_to: localhost + when: + - not tor_pairing_previous.stat.exists + - ndfc_tor_discovery is defined + - not (ndfc_tor_discovery.failed | default(false)) + - ndfc_tor_discovery.response is defined + - ndfc_tor_discovery.response.DATA is defined + - ndfc_tor_discovery.response.DATA.torPairs is defined + +- name: Cache NDFC discovered pairings for future reference + ansible.builtin.copy: + content: "{{ ndfc_discovered_pairings | to_nice_yaml }}" + dest: "{{ path_name }}ndfc_tor_pairing_discovered.yml" + mode: '0644' + delegate_to: localhost + when: + - not tor_pairing_previous.stat.exists + - ndfc_tor_discovery is defined + - not (ndfc_tor_discovery.failed | default(false)) + - ndfc_discovered_pairings is defined + - ndfc_discovered_pairings | length > 0 + +- name: Load ToR pairing payloads + ansible.builtin.set_fact: + tor_pairing_current_list: "{{ _current_payloads }}" + tor_pairing: "{{ _current_payloads }}" + vars: + _current_payloads: "{{ lookup('file', path_name + file_name) | from_yaml | default([], true) }}" + delegate_to: localhost + when: (lookup('file', path_name + file_name) | length) > 0 + +- name: Compute ToR pairing diff + ansible.builtin.set_fact: + tor_pairing_diff_result: "{{ tor_pairing_current_list | cisco.nac_dc_vxlan.tor_pairing_diff(tor_pairing_previous_list) }}" + delegate_to: localhost + when: + - tor_pairing_previous_list | length > 0 + +- name: Compute removed ToR pairings + ansible.builtin.set_fact: + tor_pairing_removed: "{{ tor_pairing_diff_result.removed | default([]) }}" + delegate_to: localhost + when: + - tor_pairing_previous_list | length > 0 + - tor_pairing_diff_result is defined + +- name: Diff ToR pairing payload file + cisco.nac_dc_vxlan.dtc.diff_model_changes: + file_name_previous: "{{ path_name }}{{ file_name }}.old" + file_name_current: "{{ path_name }}{{ file_name }}" + register: tor_pairing_diff + delegate_to: localhost + +- name: Flag ToR pairing changes when detected + ansible.builtin.set_fact: + changes_detected_tor_pairing: true + when: + - tor_pairing_diff.file_data_changed + - check_roles['save_previous'] + delegate_to: localhost + +- name: Flag ToR pairing removal when differences identified + ansible.builtin.set_fact: + changes_detected_tor_pairing: true + when: + - tor_pairing_removed | length > 0 + delegate_to: localhost + +- name: Update change detection flag for ToR pairing changes + cisco.nac_dc_vxlan.common.change_flag_manager: + fabric_type: "{{ MD_Extended.vxlan.fabric.type }}" + fabric_name: "{{ MD_Extended.vxlan.fabric.name }}" + role_path: "{{ common_role_path }}" + operation: update + change_flag: changes_detected_tor_pairing + flag_value: true + delegate_to: localhost + when: + - changes_detected_tor_pairing | default(false) diff --git a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml index 3261393a9..e848d652a 100644 --- a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml @@ -79,6 +79,13 @@ - name: Build vPC Peering Parameters ansible.builtin.import_tasks: common/ndfc_vpc_peering_pairs.yml +# -------------------------------------------------------------------- +# Build eBGP VXLAN ToR Pairing Payloads From Template +# -------------------------------------------------------------------- + +- name: Build eBGP VXLAN Fabric ToR Pairing Payloads From Template + ansible.builtin.import_tasks: common/ndfc_tor_pairing.yml + # -------------------------------------------------------------------- # Build NDFC Fabric VRFs Attach List From Template # -------------------------------------------------------------------- @@ -232,7 +239,9 @@ inv_config: "{{ inv_config }}" link_vpc_peering: "{{ link_vpc_peering }}" net_config: "{{ net_config }}" + tor_pairing: "{{ tor_pairing }}" poap_data: "{{ poap_data }}" + tor_pairing_removed: "{{ tor_pairing_removed | default([], true) }}" policy_config: "{{ policy_config }}" sub_interface_routed: "{{ sub_interface_routed }}" updated_inv_config: "{{ updated_inv_config }}" @@ -247,3 +256,5 @@ vpc_peering_diff_result: "{{ vpc_peering_diff_result }}" vpc_domain_id_resource_diff_result: "{{ vpc_domain_id_resource_diff_result }}" vrf_diff_result: "{{ vrf_diff_result }}" + # Change detection flags + changes_detected_tor_pairing: "{{ changes_detected_tor_pairing | default(false) }}" diff --git a/roles/dtc/common/tasks/sub_main_vxlan.yml b/roles/dtc/common/tasks/sub_main_vxlan.yml index 3c704bee1..5ad12c7a2 100644 --- a/roles/dtc/common/tasks/sub_main_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_vxlan.yml @@ -80,6 +80,13 @@ - name: Build iBGP VXLAN Fabric vPC Peering Template ansible.builtin.import_tasks: common/ndfc_vpc_peering_pairs.yml +# ------------------------------------------------------------------------ +# Build iBGP VXLAN Fabric ToR Pairing Payloads From Template +# ------------------------------------------------------------------------ + +- name: Build iBGP VXLAN Fabric ToR Pairing Payloads From Template + ansible.builtin.import_tasks: common/ndfc_tor_pairing.yml + # ------------------------------------------------------------------------ # Build iBGP VXLAN Fabric VRFs and Attach List From Template # ------------------------------------------------------------------------ @@ -239,7 +246,9 @@ inv_config: "{{ inv_config }}" link_vpc_peering: "{{ link_vpc_peering }}" net_config: "{{ net_config }}" + tor_pairing: "{{ tor_pairing }}" poap_data: "{{ poap_data }}" + tor_pairing_removed: "{{ tor_pairing_removed | default([], true) }}" policy_config: "{{ policy_config }}" updated_inv_config: "{{ updated_inv_config }}" updated_inv_config_no_bootstrap: "{{ updated_inv_config_no_bootstrap }}" @@ -256,3 +265,5 @@ vpc_domain_id_resource_diff_result: "{{ vpc_domain_id_resource_diff_result }}" vrf_diff_result: "{{ vrf_diff_result }}" underlay_ip_address_diff_result: "{{ underlay_ip_address_diff_result }}" + # Change detection flags + changes_detected_tor_pairing: "{{ changes_detected_tor_pairing | default(false) }}" diff --git a/roles/dtc/common/templates/ndfc_networks/dc_vxlan_fabric/dc_vxlan_fabric_networks.j2 b/roles/dtc/common/templates/ndfc_networks/dc_vxlan_fabric/dc_vxlan_fabric_networks.j2 index 9a2f64d44..22c9484af 100644 --- a/roles/dtc/common/templates/ndfc_networks/dc_vxlan_fabric/dc_vxlan_fabric_networks.j2 +++ b/roles/dtc/common/templates/ndfc_networks/dc_vxlan_fabric/dc_vxlan_fabric_networks.j2 @@ -79,6 +79,15 @@ {% if attach['ports'] is defined %} ports: {{ attach['ports'] }} {% endif %} +{% if 'tors' in attach and attach['tors'] %} + tor_ports: +{% for tor in attach['tors'] %} + - ip_address: {{ tor['mgmt_ip_address'] | default(tor['hostname']) }} +{% if tor['ports'] is defined %} + ports: {{ tor['ports'] }} +{% endif %} +{% endfor %} +{% endif %} {% endfor %} deploy: false {% endif %} diff --git a/roles/dtc/common/templates/ndfc_tor_pairing.j2 b/roles/dtc/common/templates/ndfc_tor_pairing.j2 new file mode 100644 index 000000000..bbb8893ed --- /dev/null +++ b/roles/dtc/common/templates/ndfc_tor_pairing.j2 @@ -0,0 +1,15 @@ +--- +# This NDFC ToR pairing config data structure is auto-generated +# DO NOT EDIT MANUALLY +# +{% if MD_Extended.vxlan.topology.tor_pairing is defined and MD_Extended.vxlan.topology.tor_pairing %} +{% for pair in MD_Extended.vxlan.topology.tor_pairing %} +- pairing_id: "{{ pair.pairing_id }}" + scenario: "{{ pair.scenario }}" + payload: + leafSN1: "{{ pair.payload.leafSN1 }}" + leafSN2: "{{ pair.payload.leafSN2 | default('') }}" + torSN1: "{{ pair.payload.torSN1 }}" + torSN2: "{{ pair.payload.torSN2 | default('') }}" +{% endfor %} +{% endif %} diff --git a/roles/dtc/create/tasks/common/tor_pairing.yml b/roles/dtc/create/tasks/common/tor_pairing.yml new file mode 100644 index 000000000..82d2b97c0 --- /dev/null +++ b/roles/dtc/create/tasks/common/tor_pairing.yml @@ -0,0 +1,48 @@ +--- +- name: Set vars_common Based On Fabric Type - VXLAN_EVPN/iBGP_VXLAN + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_vxlan }}" + when: MD_Extended.vxlan.fabric.type == "VXLAN_EVPN" + +- name: Set vars_common Based On Fabric Type - eBGP_VXLAN + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_ebgp_vxlan }}" + when: MD_Extended.vxlan.fabric.type == "eBGP_VXLAN" + +- name: Set vars_common Based On Fabric Type - MSD + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_msd }}" + when: MD_Extended.vxlan.fabric.type == "MSD" + +- name: Set vars_common Based On Fabric Type - ISN + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_isn }}" + when: MD_Extended.vxlan.fabric.type == "ISN" + +- name: Set vars_common Based On Fabric Type - External + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_external }}" + when: MD_Extended.vxlan.fabric.type == "External" + +- name: Manage ToR Pairing Entry Point + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Manage Fabric ToR Pairing {{ MD_Extended.vxlan.fabric.name }}" + - "----------------------------------------------------------------" + when: + - vars_common_local.changes_detected_tor_pairing | default(false) + +- name: Pair ToR switches in Nexus Dashboard + cisco.dcnm.dcnm_rest: + method: POST + path: "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/tor/fabrics/{{ MD_Extended.vxlan.fabric.name }}/switches/pair/custom-id" + json_data: "{{ item.payload | to_json }}" + loop: "{{ vars_common_local.tor_pairing }}" + loop_control: + label: "{{ item.pairing_id }}" + when: + - vars_common_local.changes_detected_tor_pairing | default(false) + - item.payload.leafSN1 + - item.payload.torSN1 + diff --git a/roles/dtc/create/tasks/sub_main_vxlan.yml b/roles/dtc/create/tasks/sub_main_vxlan.yml index a604a0a16..17c45274c 100644 --- a/roles/dtc/create/tasks/sub_main_vxlan.yml +++ b/roles/dtc/create/tasks/sub_main_vxlan.yml @@ -77,6 +77,14 @@ - config_save.msg.RETURN_CODE is defined and config_save.msg.RETURN_CODE == 500 - change_flags.changes_detected_vpc_peering or change_flags.changes_detected_vpc_domain_id_resource +- name: Manage iBGP VXLAN Fabric ToR Pairing in Nexus Dashboard + ansible.builtin.import_tasks: common/tor_pairing.yml + when: + - vars_common_vxlan.tor_pairing is defined + - vars_common_vxlan.tor_pairing | length > 0 + - vars_common_vxlan.changes_detected_tor_pairing + tags: "{{ nac_tags.create_tor_pairing }}" + - name: Manage iBGP VXLAN Fabric Interfaces in Nexus Dashboard ansible.builtin.import_tasks: common/interfaces.yml when: diff --git a/roles/dtc/remove/tasks/common/tor_pairing.yml b/roles/dtc/remove/tasks/common/tor_pairing.yml new file mode 100644 index 000000000..d67277a7d --- /dev/null +++ b/roles/dtc/remove/tasks/common/tor_pairing.yml @@ -0,0 +1,75 @@ +--- +- name: Set vars_common Based On Fabric Type - VXLAN_EVPN/iBGP_VXLAN + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_vxlan }}" + when: MD_Extended.vxlan.fabric.type == "VXLAN_EVPN" + +- name: Set vars_common Based On Fabric Type - eBGP_VXLAN + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_ebgp_vxlan }}" + when: MD_Extended.vxlan.fabric.type == "eBGP_VXLAN" + +- name: Set vars_common Based On Fabric Type - MSD + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_msd }}" + when: MD_Extended.vxlan.fabric.type == "MSD" + +- name: Set vars_common Based On Fabric Type - ISN + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_isn }}" + when: MD_Extended.vxlan.fabric.type == "ISN" + +- name: Set vars_common Based On Fabric Type - External + ansible.builtin.set_fact: + vars_common_local: "{{ vars_common_external }}" + when: MD_Extended.vxlan.fabric.type == "External" + +- name: Manage ToR Pairing Removal Entry Point + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Remove Fabric ToR Pairing {{ MD_Extended.vxlan.fabric.name }}" + - "----------------------------------------------------------------" + when: (tor_pairing_delete_mode is defined) and (tor_pairing_delete_mode | bool) + +- name: Remove ToR pairings from Nexus Dashboard + cisco.dcnm.dcnm_rest: + method: DELETE + path: "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/tor/fabrics/{{ MD_Extended.vxlan.fabric.name }}/switches/pair?leaftor={{ leaftor_query }}" + loop: "{{ vars_common_local.tor_pairing_removed | default([], true) }}" + loop_control: + label: "{{ item.pairing_id }}" + when: + - (tor_pairing_delete_mode is defined) and (tor_pairing_delete_mode | bool) + - changes_detected_tor_pairing | default(false) + - item.payload.leafSN1 | default('') + - item.payload.torSN1 | default('') + vars: + ansible_command_timeout: 300 + ansible_connect_timeout: 300 + # Build leaftor map: {leafSN1: torSN1,torSN2, leafSN2: torSN1,torSN2} + tor_list: "{{ [item.payload.torSN1, item.payload.torSN2 | default('')] | reject('equalto', '') | list }}" + tor_string: "{{ tor_list | join(',') }}" + leaf_list: "{{ [item.payload.leafSN1, item.payload.leafSN2 | default('')] | reject('equalto', '') | list }}" + leaftor_map: "{{ dict(leaf_list | map('regex_replace', '^(.*)$', '\\1') | zip([tor_string] * (leaf_list | length))) }}" + leaftor_query: "{{ leaftor_map | to_json | urlencode }}" + register: tor_pairing_removal_result + failed_when: + - tor_pairing_removal_result is failed + - "'not found' not in (tor_pairing_removal_result.msg | default('') | lower)" + - "'does not exist' not in (tor_pairing_removal_result.msg | default('') | lower)" + +- name: Report ToR pairing removal results + ansible.builtin.debug: + msg: "Removed {{ vars_common_local.tor_pairing_removed | default([]) | length }} ToR pairing(s)" + when: + - (tor_pairing_delete_mode is defined) and (tor_pairing_delete_mode | bool) + - changes_detected_tor_pairing | default(false) + +- name: Skip ToR pairing removal when tor_pairing_delete_mode is False + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------------------------------------------" + - "+ SKIPPING ToR pairing removal because tor_pairing_delete_mode flag is set to False +" + - "----------------------------------------------------------------------------------------------------" + when: not ((tor_pairing_delete_mode is defined) and (tor_pairing_delete_mode | bool)) diff --git a/roles/dtc/remove/tasks/sub_main_ebgp_vxlan.yml b/roles/dtc/remove/tasks/sub_main_ebgp_vxlan.yml index ef0088fb4..5f0332e44 100644 --- a/roles/dtc/remove/tasks/sub_main_ebgp_vxlan.yml +++ b/roles/dtc/remove/tasks/sub_main_ebgp_vxlan.yml @@ -46,6 +46,12 @@ when: - change_flags.changes_detected_vpc_peering +- name: Remove eBGP VXLAN Fabric ToR Pairing from Nexus Dashboard + ansible.builtin.import_tasks: common/tor_pairing.yml + tags: "{{ nac_tags.remove_tor_pairing }}" + when: + - vars_common_ebgp_vxlan.changes_detected_tor_pairing + - name: Remove eBGP VXLAN Fabric Policy from Nexus Dashboard ansible.builtin.import_tasks: common/policy.yml tags: "{{ nac_tags.remove_policy }}" diff --git a/roles/dtc/remove/tasks/sub_main_vxlan.yml b/roles/dtc/remove/tasks/sub_main_vxlan.yml index de6ab058f..328efd732 100644 --- a/roles/dtc/remove/tasks/sub_main_vxlan.yml +++ b/roles/dtc/remove/tasks/sub_main_vxlan.yml @@ -82,6 +82,12 @@ when: - change_flags.changes_detected_vpc_peering +- name: Remove iBGP VXLAN Fabric ToR Pairing from Nexus Dashboard + ansible.builtin.import_tasks: common/tor_pairing.yml + tags: "{{ nac_tags.remove_tor_pairing }}" + when: + - vars_common_vxlan.changes_detected_tor_pairing + - name: Remove iBGP VXLAN Fabric Switches from Nexus Dashboard ansible.builtin.import_tasks: common/switches.yml tags: "{{ nac_tags.remove_switches }}" diff --git a/roles/validate/files/rules/common/311_topology_tor_pairing.py b/roles/validate/files/rules/common/311_topology_tor_pairing.py new file mode 100644 index 000000000..0bc633a80 --- /dev/null +++ b/roles/validate/files/rules/common/311_topology_tor_pairing.py @@ -0,0 +1,194 @@ +class Rule: + id = "311" + description = "Validate ToR pairing configuration (scenarios, VPC requirements, switch roles)" + severity = "HIGH" + + @classmethod + def match(cls, data_model): + """ + Validate ToR pairing configuration before it's processed by prepare plugins. + + Checks: + 1. Required fields (parent_leaf1, tor1) are present + 2. Scenario detection (vpc-to-vpc, vpc-to-standalone, standalone-to-standalone) + 3. VPC requirements (leafs/ToRs must be VPC paired per scenario) + 4. Switch existence and role validation + 5. Serial number presence + + Note: Network attachment validation for ToR removal cannot be done here + because validation runs before diff detection. That check happens in the + common phase after diff is computed. + """ + results = [] + + # Get topology + topology_keys = ['vxlan', 'topology'] + dm_check = cls.data_model_key_check(data_model, topology_keys) + if 'topology' not in dm_check['keys_data']: + return results + + topology = data_model['vxlan']['topology'] + + # Get tor_peers + if 'tor_peers' not in topology or not topology['tor_peers']: + return results + + tor_peers = topology['tor_peers'] + switches = topology.get('switches', []) + vpc_peers = topology.get('vpc_peers', []) + + # Build switch lookup map + switch_map = {} + switch_names = set() + for sw in switches: + if 'name' in sw: + switch_map[sw['name']] = sw + switch_names.add(sw['name']) + + # Build VPC domain lookup map + vpc_domain_map = {} + for vpc_pair in vpc_peers: + peer1 = vpc_pair.get('peer1') + peer2 = vpc_pair.get('peer2') + domain_id = vpc_pair.get('domain_id') + if peer1 and peer2 and domain_id: + # Store both directions for easy lookup + vpc_domain_map[(peer1, peer2)] = domain_id + vpc_domain_map[(peer2, peer1)] = domain_id + + # Collect all ToR switch names referenced in tor_peers + tor_switch_names = set() + + # Validate each tor_peers entry + for idx, peer in enumerate(tor_peers): + entry_label = f"vxlan.topology.tor_peers[{idx}]" + + # Handle both dict and string formats (backward compatibility) + leaf1_name = cls._extract_name(peer.get('parent_leaf1')) + leaf2_name = cls._extract_name(peer.get('parent_leaf2')) + tor1_name = cls._extract_name(peer.get('tor1')) + tor2_name = cls._extract_name(peer.get('tor2')) + + # Track ToR names + if tor1_name: + tor_switch_names.add(tor1_name) + if tor2_name: + tor_switch_names.add(tor2_name) + + # Basic required field check + if not leaf1_name or not tor1_name: + results.append(f"{entry_label}: 'parent_leaf1' and 'tor1' are required") + continue + + # Determine scenario + scenario = cls._detect_scenario(leaf1_name, leaf2_name, tor1_name, tor2_name) + + # Scenario-specific validation + if scenario == 'vpc_to_vpc': + # Validate: leafs must be VPC paired + if not cls._find_vpc_domain(leaf1_name, leaf2_name, vpc_domain_map): + results.append( + f"{entry_label}: vpc-to-vpc scenario requires leafs '{leaf1_name}' and '{leaf2_name}' " + f"to be VPC paired in vxlan.topology.vpc_peers" + ) + # Validate: tors must be VPC paired + if not cls._find_vpc_domain(tor1_name, tor2_name, vpc_domain_map): + results.append( + f"{entry_label}: vpc-to-vpc scenario requires TORs '{tor1_name}' and '{tor2_name}' " + f"to be VPC paired in vxlan.topology.vpc_peers" + ) + # Validate: all 4 switches must exist with correct roles + cls._validate_switch_role(leaf1_name, 'leaf', switch_map, results, entry_label) + cls._validate_switch_role(leaf2_name, 'leaf', switch_map, results, entry_label) + cls._validate_switch_role(tor1_name, 'tor', switch_map, results, entry_label) + cls._validate_switch_role(tor2_name, 'tor', switch_map, results, entry_label) + + elif scenario == 'vpc_to_standalone': + # Validate: leafs must be VPC paired + if not cls._find_vpc_domain(leaf1_name, leaf2_name, vpc_domain_map): + results.append( + f"{entry_label}: vpc-to-standalone scenario requires leafs '{leaf1_name}' and '{leaf2_name}' " + f"to be VPC paired in vxlan.topology.vpc_peers" + ) + # Validate: switches must exist with correct roles + cls._validate_switch_role(leaf1_name, 'leaf', switch_map, results, entry_label) + cls._validate_switch_role(leaf2_name, 'leaf', switch_map, results, entry_label) + cls._validate_switch_role(tor1_name, 'tor', switch_map, results, entry_label) + + elif scenario == 'standalone_to_standalone': + # Validate: switches must exist with correct roles + cls._validate_switch_role(leaf1_name, 'leaf', switch_map, results, entry_label) + cls._validate_switch_role(tor1_name, 'tor', switch_map, results, entry_label) + + else: + results.append( + f"{entry_label}: Invalid ToR pairing scenario. " + f"Supported: vpc-to-vpc (2 leafs + 2 TORs), vpc-to-standalone (2 leafs + 1 TOR), " + f"standalone-to-standalone (1 leaf + 1 TOR). " + f"Unsupported: standalone leaf with VPC TORs" + ) + + return results + + @classmethod + def _extract_name(cls, value): + """Extract switch name from dict or string.""" + if not value: + return None + return value.get('name') if isinstance(value, dict) else value + + @classmethod + def _detect_scenario(cls, leaf1, leaf2, tor1, tor2): + """Detect ToR pairing scenario.""" + if leaf2 and tor2: + return 'vpc_to_vpc' + elif leaf2 and not tor2: + return 'vpc_to_standalone' + elif not leaf2 and not tor2: + return 'standalone_to_standalone' + else: + return 'invalid' # standalone leaf + vpc tor + + @classmethod + def _find_vpc_domain(cls, switch1, switch2, vpc_domain_map): + """Check if two switches form a VPC pair.""" + return vpc_domain_map.get((switch1, switch2)) is not None + + @classmethod + def _validate_switch_role(cls, switch_name, expected_role, switch_map, results, entry_label): + """Validate switch exists and has correct role.""" + if switch_name not in switch_map: + results.append( + f"{entry_label}: Switch '{switch_name}' not found in vxlan.topology.switches" + ) + return + + switch = switch_map[switch_name] + if switch.get('role') != expected_role: + results.append( + f"{entry_label}: Switch '{switch_name}' must have role '{expected_role}', " + f"found '{switch.get('role')}'" + ) + + if not switch.get('serial_number'): + results.append( + f"{entry_label}: Switch '{switch_name}' must have serial_number defined" + ) + + @classmethod + def data_model_key_check(cls, tested_object, keys): + """ + Navigate through nested dictionary keys and track which keys exist and contain data. + """ + dm_key_dict = {'keys_found': [], 'keys_not_found': [], 'keys_data': [], 'keys_no_data': []} + for key in keys: + if tested_object and key in tested_object: + dm_key_dict['keys_found'].append(key) + tested_object = tested_object[key] + if tested_object: + dm_key_dict['keys_data'].append(key) + else: + dm_key_dict['keys_no_data'].append(key) + else: + dm_key_dict['keys_not_found'].append(key) + return dm_key_dict