From 971407cdb1567ea9a0c3e4f46f4ec91e0af92c71 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Mon, 13 Oct 2025 10:18:37 +0200 Subject: [PATCH 01/16] Fix for #631 --- .../ndfc_networks/dc_vxlan_fabric/dc_vxlan_fabric_networks.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a29e02956..9a2f64d44 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 @@ -55,7 +55,7 @@ int_desc: {{ net['int_desc'] | default(defaults.vxlan.overlay.networks.net_description) }} l3gw_on_border: {{ net['l3gw_on_border'] | default(defaults.vxlan.overlay.networks.l3gw_on_border) }} mtu_l3intf: {{ net['mtu_l3intf'] | default(defaults.vxlan.overlay.networks.mtu_l3intf) }} -{% if (MD_Extended.vxlan.underlay.general.replication_mode | lower) == 'multicast' %} +{% if (MD_Extended.vxlan.underlay.general.replication_mode | default(defaults.vxlan.underlay.general.replication_mode) | lower) == 'multicast' %} multicast_group_address: {{ net['multicast_group_address'] | default(defaults.vxlan.overlay.networks.multicast_group_address) }} {% endif %} netflow_enable: {{ net['netflow_enable'] | default(defaults.vxlan.overlay.networks.netflow_enable) }} From d2c22bec5979fbff0ef1f5e2794ac9990a7a9961 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Mon, 13 Oct 2025 12:09:55 +0200 Subject: [PATCH 02/16] TOR_Pairing - First_try --- .../prepare_plugins/prep_110_tor_pairing.py | 235 ++++++++++++++++++ roles/common_global/vars/main.yml | 6 + .../common/tasks/common/ndfc_tor_pairing.yml | 64 +++++ .../dtc/common/tasks/sub_main_ebgp_vxlan.yml | 10 + roles/dtc/common/tasks/sub_main_vxlan.yml | 10 + .../dtc/common/templates/ndfc_tor_pairing.j2 | 16 ++ roles/dtc/create/tasks/common/tor_pairing.yml | 47 ++++ roles/dtc/create/tasks/sub_main_vxlan.yml | 8 + 8 files changed, 396 insertions(+) create mode 100644 plugins/action/common/prepare_plugins/prep_110_tor_pairing.py create mode 100644 roles/dtc/common/tasks/common/ndfc_tor_pairing.yml create mode 100644 roles/dtc/common/templates/ndfc_tor_pairing.j2 create mode 100644 roles/dtc/create/tasks/common/tor_pairing.yml 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..0101eda42 --- /dev/null +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -0,0 +1,235 @@ +# 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 + +import re + + +class PreparePlugin: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.keys = ['vxlan', 'topology', 'tor_peers'] + + def _get_switch(self, name, expected_role, switches, errors): + switch = switches.get(name) + if not switch: + errors.append(f"Switch '{name}' referenced in tor_peers is not defined in vxlan.topology.switches") + return None + role = switch.get('role') + if role != expected_role: + errors.append( + f"Switch '{name}' referenced in tor_peers must have role '{expected_role}', current role is '{role}'" + ) + if not switch.get('serial_number'): + errors.append(f"Switch '{name}' must define serial_number for tor pairing support") + return switch + + def _resolve_port_channel_id(self, member, switch, errors): + port_channel_id = member.get('port_channel_id') + interface = member.get('interface') + interface_name = None + if isinstance(interface, dict): + interface_name = interface.get('name') + elif isinstance(interface, str): + interface_name = interface + if port_channel_id is None and interface_name: + match = re.search(r'(\d+)$', interface_name) + if match: + port_channel_id = int(match.group(1)) + if port_channel_id is None: + errors.append( + f"Port-channel identifier is required for switch '{member.get('name')}' in tor_peers. " + "Provide port_channel_id or interface name in the data model." + ) + return None + return int(port_channel_id) + + 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 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 + + leaf1_name = parent_leaf1.get('name') + tor1_name = tor1.get('name') + 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') + + leaf2_name = parent_leaf2.get('name') if parent_leaf2 else None + tor2_name = tor2.get('name') 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) + + tor_vpc_peer = peer.get('tor_vpc_peer', peer.get('vpc_peer', False)) + + if tor_vpc_peer and not tor2: + errors.append( + f"tor_peers entry pairing '{leaf1_name}' to '{tor1_name}' is marked as tor_vpc_peer but tor2 is not provided" + ) + if tor2 and not tor_vpc_peer: + errors.append( + f"tor_peers entry pairing '{leaf1_name}' to '{tor1_name}' defines tor2 but tor_vpc_peer is false" + ) + + leaf_vpc_domain = self._resolve_vpc_domain(peer, 'leaf_vpc_domain_id', leaf1_name, leaf2_name, topology) + tor_vpc_domain = self._resolve_vpc_domain(peer, 'tor_vpc_domain_id', tor1_name, tor2_name, topology) + + leaf_is_vpc = bool(leaf2_switch and leaf_vpc_domain) + tor_is_vpc = bool(tor_vpc_peer and tor2_switch and tor_vpc_domain) + + if parent_leaf2 and not leaf_is_vpc: + errors.append( + f"tor_peers entry referencing leaves '{leaf1_name}' and '{leaf2_name}' requires a vPC domain ID." + ) + if tor_vpc_peer and not tor_is_vpc: + errors.append( + f"tor_peers entry referencing tors '{tor1_name}' and '{tor2_name}' requires a tor_vpc_domain_id." + ) + + scenario = 'leaf_standalone_tor_standalone' + if leaf_is_vpc and tor_is_vpc: + scenario = 'leaf_vpc_tor_vpc' + elif leaf_is_vpc and not tor_is_vpc: + scenario = 'leaf_vpc_tor_standalone' + elif not leaf_is_vpc and tor_is_vpc: + errors.append( + f"Unsupported ToR pairing scenario: tor vPC with standalone leafs for '{tor1_name}'." + ) + + 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) + + if leaf1_switch: + leaf1_po = self._resolve_port_channel_id(parent_leaf1, leaf1_switch, errors) + leaf1_serial = leaf1_switch.get('serial_number') if leaf1_switch else None + else: + leaf1_po = None + leaf1_serial = None + + if tor1_switch: + tor1_po = self._resolve_port_channel_id(tor1, tor1_switch, errors) + tor1_serial = tor1_switch.get('serial_number') if tor1_switch else None + else: + tor1_po = None + tor1_serial = None + + leaf2_po = None + leaf2_serial = '' + if leaf_is_vpc and leaf2_switch: + leaf2_po = self._resolve_port_channel_id(parent_leaf2, leaf2_switch, errors) + leaf2_serial = leaf2_switch.get('serial_number') if leaf2_switch else '' + + tor2_po = None + tor2_serial = '' + if tor_is_vpc and tor2_switch: + tor2_po = self._resolve_port_channel_id(tor2, tor2_switch, errors) + tor2_serial = tor2_switch.get('serial_number') if tor2_switch else '' + + 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." + ) + + if scenario != 'leaf_standalone_tor_standalone' and not leaf_is_vpc: + # scenario with additional members but no vpc support already logged + continue + + po_map = {} + if leaf1_serial and leaf1_po is not None: + po_map[f"{leaf1_serial}_PO"] = str(leaf1_po) + if leaf2_serial and leaf2_po is not None: + po_map[f"{leaf2_serial}_PO"] = str(leaf2_po) + if tor1_serial and tor1_po is not None: + po_map[f"{tor1_serial}_PO"] = str(tor1_po) + if tor2_serial and tor2_po is not None: + po_map[f"{tor2_serial}_PO"] = str(tor2_po) + if leaf_is_vpc and leaf1_serial and leaf2_serial and leaf_vpc_domain: + po_map[f"{leaf1_serial}~{leaf2_serial}_VPC"] = str(leaf_vpc_domain) + if tor_is_vpc and tor1_serial and tor2_serial and tor_vpc_domain: + po_map[f"{tor1_serial}~{tor2_serial}_VPC"] = str(tor_vpc_domain) + + if not po_map: + errors.append( + f"No port-channel mapping could be derived for ToR pairing '{pairing_id}'." + ) + 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 + + model_data['vxlan']['topology']['tor_pairing'] = processed_pairs + results['model_extended'] = model_data + return results diff --git a/roles/common_global/vars/main.yml b/roles/common_global/vars/main.yml index bb5a06a96..87a7a13a3 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 @@ -81,6 +82,7 @@ nac_tags: - cr_manage_vrfs_networks - cr_manage_policy - cr_manage_edge_connections + - cr_manage_tor_pairing - rr_manage_edge_connections - rr_manage_interfaces - rr_manage_networks @@ -103,6 +105,7 @@ nac_tags: - cr_manage_vrfs_networks - cr_manage_policy - cr_manage_edge_connections + - cr_manage_tor_pairing - rr_manage_edge_connections - rr_manage_interfaces - rr_manage_networks @@ -121,8 +124,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: 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..d7c68353d --- /dev/null +++ b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml @@ -0,0 +1,64 @@ +--- +- name: Initialize tor pairing change flag + ansible.builtin.set_fact: + changes_detected_tor_pairing: false + delegate_to: localhost + +- name: Set tor pairing file name + ansible.builtin.set_fact: + file_name: "ndfc_tor_pairing.yml" + 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: Set tor_pairing fact default + ansible.builtin.set_fact: + tor_pairing: [] + delegate_to: localhost + +- name: Load ToR pairing payloads + ansible.builtin.set_fact: + tor_pairing: "{{ lookup('file', path_name + file_name) | from_yaml | default([], true) }}" + delegate_to: localhost + when: (lookup('file', path_name + file_name) | length) > 0 + +- 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 diff --git a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml index 2704bbe81..2dcd5582b 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 # -------------------------------------------------------------------- @@ -235,6 +242,7 @@ changes_detected_vpc_peering: "{{ changes_detected_vpc_peering }}" changes_detected_vpc_domain_id_resource: "{{ changes_detected_vpc_domain_id_resource }}" changes_detected_vrfs: "{{ changes_detected_vrfs }}" + changes_detected_tor_pairing: "{{ changes_detected_tor_pairing }}" fabric_config: "{{ fabric_config }}" # fabric_links: "{{ fabric_links }}" interface_breakout: "{{ interface_breakout }}" @@ -253,6 +261,7 @@ inv_config: "{{ inv_config }}" link_vpc_peering: "{{ link_vpc_peering }}" net_config: "{{ net_config }}" + tor_pairing: "{{ tor_pairing }}" poap_data: "{{ poap_data }}" policy_config: "{{ policy_config }}" sub_interface_routed: "{{ sub_interface_routed }}" @@ -272,6 +281,7 @@ - "+ vPC Link Peer Changes Detected - [ {{ vars_common_ebgp_vxlan.changes_detected_link_vpc_peering }} ]" - "+ vPC Peer Changes Detected - [ {{ vars_common_ebgp_vxlan.changes_detected_vpc_peering }} ]" - "+ vPC Domain ID Detected - [ {{ vars_common_ebgp_vxlan.changes_detected_vpc_domain_id_resource }} ]" + - "+ ToR Pairing Changes Detected - [ {{ vars_common_ebgp_vxlan.changes_detected_tor_pairing }} ]" - "+ ----- Interfaces -----" - "+ Interface breakout Changes Detected - [ {{ vars_common_ebgp_vxlan.changes_detected_interface_breakout }} ]" - "+ Interface PreProv breakout Changes Detected - [ {{ vars_common_ebgp_vxlan.changes_detected_interface_breakout_preprov }} ]" diff --git a/roles/dtc/common/tasks/sub_main_vxlan.yml b/roles/dtc/common/tasks/sub_main_vxlan.yml index bfdbd4357..078b28a24 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 # ------------------------------------------------------------------------ @@ -243,6 +250,7 @@ changes_detected_vpc_domain_id_resource: "{{ changes_detected_vpc_domain_id_resource }}" changes_detected_vrfs: "{{ changes_detected_vrfs }}" changes_detected_underlay_ip_address: "{{ changes_detected_underlay_ip_address }}" + changes_detected_tor_pairing: "{{ changes_detected_tor_pairing }}" fabric_config: "{{ fabric_config }}" fabric_links: "{{ fabric_links }}" edge_connections: "{{ edge_connections }}" @@ -261,6 +269,7 @@ inv_config: "{{ inv_config }}" link_vpc_peering: "{{ link_vpc_peering }}" net_config: "{{ net_config }}" + tor_pairing: "{{ tor_pairing }}" poap_data: "{{ poap_data }}" policy_config: "{{ policy_config }}" sub_interface_routed: "{{ sub_interface_routed }}" @@ -281,6 +290,7 @@ - "+ vPC Link Peer Changes Detected - [ {{ vars_common_vxlan.changes_detected_link_vpc_peering }} ]" - "+ vPC Peer Changes Detected - [ {{ vars_common_vxlan.changes_detected_vpc_peering }} ]" - "+ vPC Domain ID Detected - [ {{ vars_common_vxlan.changes_detected_vpc_domain_id_resource }} ]" + - "+ ToR Pairing Changes Detected - [ {{ vars_common_vxlan.changes_detected_tor_pairing }} ]" - "+ ----- Interfaces -----" - "+ Interface breakout Changes Detected - [ {{ vars_common_vxlan.changes_detected_interface_breakout }} ]" - "+ Interface PreProv breakout Changes Detected - [ {{ vars_common_vxlan.changes_detected_interface_breakout_preprov }} ]" 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..e3b9955ff --- /dev/null +++ b/roles/dtc/common/templates/ndfc_tor_pairing.j2 @@ -0,0 +1,16 @@ +--- +# 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 }}" + torSN1: "{{ pair.payload.torSN1 }}" + torSN2: "{{ pair.payload.torSN2 }}" + po_map: {{ pair.po_map | to_nice_json }} +{% 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..21d3031a8 --- /dev/null +++ b/roles/dtc/create/tasks/common/tor_pairing.yml @@ -0,0 +1,47 @@ +--- +- 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 }}" + - "----------------------------------------------------------------" + +- 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/tor_testing/switches/pair/custom-id" + json_data: "{{ {'leafSN1': item.payload.leafSN1, 'leafSN2': item.payload.leafSN2, 'torSN1': item.payload.torSN1, 'torSN2': item.payload.torSN2, 'poVpc': item.po_map | to_json } | to_json }}" + loop: "{{ vars_common_local.tor_pairing }}" + loop_control: + label: "{{ item.pairing_id }}" + when: + - item.payload.leafSN1 + - item.payload.torSN1 + vars: + ansible_command_timeout: 300 + ansible_connect_timeout: 300 diff --git a/roles/dtc/create/tasks/sub_main_vxlan.yml b/roles/dtc/create/tasks/sub_main_vxlan.yml index 9f8d7ef24..f88af8af7 100644 --- a/roles/dtc/create/tasks/sub_main_vxlan.yml +++ b/roles/dtc/create/tasks/sub_main_vxlan.yml @@ -73,6 +73,14 @@ msg: "{{ config_save.msg.DATA }}" when: config_save.msg.RETURN_CODE is defined and config_save.msg.RETURN_CODE == 500 +- 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: From 57ef3ce6530cf2b732fc517e73ffc3593e35578a Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Tue, 14 Oct 2025 14:58:51 +0200 Subject: [PATCH 03/16] Tor pairing, fix 1 --- .../prepare_plugins/prep_110_tor_pairing.py | 42 ++++------ roles/common_global/vars/main.yml | 7 ++ roles/dtc/common/tasks/cleanup_files.yml | 1 + .../common/tasks/common/ndfc_tor_pairing.yml | 37 ++++++++- roles/dtc/common/tasks/main.yml | 2 + .../dtc/common/tasks/sub_main_ebgp_vxlan.yml | 12 ++- roles/dtc/common/tasks/sub_main_vxlan.yml | 12 ++- roles/dtc/create/tasks/common/tor_pairing.yml | 4 +- roles/dtc/remove/tasks/common/tor_pairing.yml | 80 +++++++++++++++++++ roles/dtc/remove/tasks/main.yml | 6 +- .../dtc/remove/tasks/sub_main_ebgp_vxlan.yml | 6 ++ roles/dtc/remove/tasks/sub_main_vxlan.yml | 6 ++ 12 files changed, 179 insertions(+), 36 deletions(-) create mode 100644 roles/dtc/remove/tasks/common/tor_pairing.yml diff --git a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py index 0101eda42..dfb2b73d3 100644 --- a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -18,8 +18,6 @@ # # SPDX-License-Identifier: MIT -import re - class PreparePlugin: def __init__(self, **kwargs): @@ -40,25 +38,15 @@ def _get_switch(self, name, expected_role, switches, errors): errors.append(f"Switch '{name}' must define serial_number for tor pairing support") return switch - def _resolve_port_channel_id(self, member, switch, errors): - port_channel_id = member.get('port_channel_id') - interface = member.get('interface') - interface_name = None - if isinstance(interface, dict): - interface_name = interface.get('name') - elif isinstance(interface, str): - interface_name = interface - if port_channel_id is None and interface_name: - match = re.search(r'(\d+)$', interface_name) - if match: - port_channel_id = int(match.group(1)) - if port_channel_id is None: - errors.append( - f"Port-channel identifier is required for switch '{member.get('name')}' in tor_peers. " - "Provide port_channel_id or interface name in the data model." - ) + 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 - return int(port_channel_id) def _resolve_vpc_domain(self, peer, key, name_a, name_b, topology): if peer.get(key) is not None: @@ -124,8 +112,8 @@ def prepare(self): f"tor_peers entry pairing '{leaf1_name}' to '{tor1_name}' defines tor2 but tor_vpc_peer is false" ) - leaf_vpc_domain = self._resolve_vpc_domain(peer, 'leaf_vpc_domain_id', leaf1_name, leaf2_name, topology) - tor_vpc_domain = self._resolve_vpc_domain(peer, 'tor_vpc_domain_id', tor1_name, tor2_name, topology) + 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) leaf_is_vpc = bool(leaf2_switch and leaf_vpc_domain) tor_is_vpc = bool(tor_vpc_peer and tor2_switch and tor_vpc_domain) @@ -136,7 +124,7 @@ def prepare(self): ) if tor_vpc_peer and not tor_is_vpc: errors.append( - f"tor_peers entry referencing tors '{tor1_name}' and '{tor2_name}' requires a tor_vpc_domain_id." + f"tor_peers entry referencing tors '{tor1_name}' and '{tor2_name}' requires a tor_vpc_id." ) scenario = 'leaf_standalone_tor_standalone' @@ -155,14 +143,14 @@ def prepare(self): pairing_ids.add(pairing_id) if leaf1_switch: - leaf1_po = self._resolve_port_channel_id(parent_leaf1, leaf1_switch, errors) + leaf1_po = self._normalize_vpc_id(leaf_vpc_domain, "leaf_vpc_id", errors) leaf1_serial = leaf1_switch.get('serial_number') if leaf1_switch else None else: leaf1_po = None leaf1_serial = None if tor1_switch: - tor1_po = self._resolve_port_channel_id(tor1, tor1_switch, errors) + tor1_po = self._normalize_vpc_id(tor_vpc_domain, "tor_vpc_id", errors) tor1_serial = tor1_switch.get('serial_number') if tor1_switch else None else: tor1_po = None @@ -171,13 +159,13 @@ def prepare(self): leaf2_po = None leaf2_serial = '' if leaf_is_vpc and leaf2_switch: - leaf2_po = self._resolve_port_channel_id(parent_leaf2, leaf2_switch, errors) + leaf2_po = self._normalize_vpc_id(leaf_vpc_domain, "leaf_vpc_id", errors) leaf2_serial = leaf2_switch.get('serial_number') if leaf2_switch else '' tor2_po = None tor2_serial = '' if tor_is_vpc and tor2_switch: - tor2_po = self._resolve_port_channel_id(tor2, tor2_switch, errors) + tor2_po = self._normalize_vpc_id(tor_vpc_domain, "tor_vpc_id", errors) tor2_serial = tor2_switch.get('serial_number') if tor2_switch else '' required_serials = [leaf1_serial, tor1_serial] diff --git a/roles/common_global/vars/main.yml b/roles/common_global/vars/main.yml index 87a7a13a3..c005edc7e 100644 --- a/roles/common_global/vars/main.yml +++ b/roles/common_global/vars/main.yml @@ -43,6 +43,7 @@ nac_tags: - rr_manage_edge_connections - rr_manage_switches - rr_manage_policy + - rr_manage_tor_pairing # ------------------------- - role_validate - role_create @@ -69,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: @@ -91,6 +93,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: @@ -114,6 +117,7 @@ nac_tags: - rr_manage_links - rr_manage_switches - rr_manage_policy + - rr_manage_tor_pairing # All Create Tags create: - cr_manage_fabric @@ -153,6 +157,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: @@ -169,5 +174,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/cleanup_files.yml b/roles/dtc/common/tasks/cleanup_files.yml index 8099115c7..e2028ada2 100644 --- a/roles/dtc/common/tasks/cleanup_files.yml +++ b/roles/dtc/common/tasks/cleanup_files.yml @@ -26,6 +26,7 @@ state: absent path: "{{ path_name }}" delegate_to: localhost + when: cleanup_enabled | default(true) - name: Recreate the directory ansible.builtin.file: diff --git a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml index d7c68353d..6c46f6d8d 100644 --- a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml +++ b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml @@ -37,17 +37,43 @@ mode: '0644' delegate_to: localhost -- name: Set tor_pairing fact default +- name: Initialize ToR pairing facts ansible.builtin.set_fact: tor_pairing: [] + tor_pairing_removed: [] + tor_pairing_previous_list: [] + tor_pairing_current_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: Load ToR pairing payloads ansible.builtin.set_fact: - tor_pairing: "{{ lookup('file', path_name + file_name) | from_yaml | default([], true) }}" + tor_pairing_current_list: "{{ lookup('file', path_name + file_name) | from_yaml | default([], true) }}" delegate_to: localhost when: (lookup('file', path_name + file_name) | length) > 0 +- name: Publish current ToR pairing list + ansible.builtin.set_fact: + tor_pairing: "{{ tor_pairing_current_list }}" + delegate_to: localhost + +- name: Compute removed ToR pairings + ansible.builtin.set_fact: + tor_pairing_removed: >- + {{ tor_pairing_previous_list + | selectattr('pairing_id', 'in', + (tor_pairing_previous_list | map(attribute='pairing_id') | list) + | difference(tor_pairing_current_list | map(attribute='pairing_id') | list)) + | list }} + delegate_to: localhost + when: + - tor_pairing_previous_list | length > 0 + - name: Diff ToR pairing payload file cisco.nac_dc_vxlan.dtc.diff_model_changes: file_name_previous: "{{ path_name }}{{ file_name }}.old" @@ -62,3 +88,10 @@ - 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 diff --git a/roles/dtc/common/tasks/main.yml b/roles/dtc/common/tasks/main.yml index 6e403d010..c2d071643 100644 --- a/roles/dtc/common/tasks/main.yml +++ b/roles/dtc/common/tasks/main.yml @@ -48,6 +48,7 @@ changes_detected_vpc_peering: false changes_detected_vrfs: false changes_detected_underlay_ip_address: false + changes_detected_tor_pairing: false vars_common_isn: changes_detected_fabric: false changes_detected_fabric_links: false @@ -106,6 +107,7 @@ changes_detected_sub_interface_routed: false changes_detected_vpc_peering: false changes_detected_vrfs: false + changes_detected_tor_pairing: false tags: "{{ nac_tags.common_role }}" # Tags defined in roles/common_global/vars/main.yml - name: Import Role Tasks for iBGP VXLAN Fabric diff --git a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml index 2dcd5582b..141609718 100644 --- a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml @@ -38,7 +38,16 @@ - name: Cleanup Files from Previous Run if run_map requires it ansible.builtin.import_tasks: cleanup_files.yml when: - - not run_map_read_result.diff_run or ((force_run_all is defined) and (force_run_all is true|bool)) + - cleanup_enabled | default(true) + vars: + cleanup_enabled: (not run_map_read_result.diff_run) and not ((force_run_all | default(false)) | bool) + +- name: Ensure ToR artifact directory exists + ansible.builtin.file: + path: "{{ path_name }}" + state: directory + mode: '0755' + delegate_to: localhost # -------------------------------------------------------------------- # Build Create eBGP VXLAN Fabric parameter List From Template @@ -263,6 +272,7 @@ 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 }}" diff --git a/roles/dtc/common/tasks/sub_main_vxlan.yml b/roles/dtc/common/tasks/sub_main_vxlan.yml index 078b28a24..997072e3f 100644 --- a/roles/dtc/common/tasks/sub_main_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_vxlan.yml @@ -38,7 +38,16 @@ - name: Cleanup Files from Previous Run if run_map requires it ansible.builtin.import_tasks: cleanup_files.yml when: - - not run_map_read_result.diff_run or ((force_run_all is defined) and (force_run_all is true|bool)) + - cleanup_enabled | default(true) + vars: + cleanup_enabled: (not run_map_read_result.diff_run) and not ((force_run_all | default(false)) | bool) + +- name: Ensure ToR artifact directory exists + ansible.builtin.file: + path: "{{ path_name }}" + state: directory + mode: '0755' + delegate_to: localhost # ------------------------------------------------------------------------ # Build iBGP VXLAN Fabric List From Template @@ -271,6 +280,7 @@ 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 }}" diff --git a/roles/dtc/create/tasks/common/tor_pairing.yml b/roles/dtc/create/tasks/common/tor_pairing.yml index 21d3031a8..2be021b36 100644 --- a/roles/dtc/create/tasks/common/tor_pairing.yml +++ b/roles/dtc/create/tasks/common/tor_pairing.yml @@ -42,6 +42,4 @@ when: - item.payload.leafSN1 - item.payload.torSN1 - vars: - ansible_command_timeout: 300 - ansible_connect_timeout: 300 + 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..bde7a01bf --- /dev/null +++ b/roles/dtc/remove/tasks/common/tor_pairing.yml @@ -0,0 +1,80 @@ +--- +- 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) + - leaf_serials | length > 0 + - tor_value | length > 0 + vars: + ansible_command_timeout: 300 + ansible_connect_timeout: 300 + leaf_serials: "{{ [item.payload.leafSN1 | default('', true), item.payload.leafSN2 | default('', true)] | map('regex_replace', '\\s+$', '') | reject('equalto', '') | list }}" + tor_serials: "{{ [item.payload.torSN1 | default('', true), item.payload.torSN2 | default('', true)] | map('regex_replace', '\\s+$', '') | reject('equalto', '') | list }}" + tor_value_vpc: "{{ tor_serials | join(',') }}" + tor_value_single: "{{ tor_serials | first | default('', true) }}" + tor_value: "{{ tor_value_vpc if item.scenario == 'leaf_vpc_tor_vpc' else tor_value_single }}" + leaftor_values: "{{ [tor_value] * (leaf_serials | length) }}" + leaftor_map: "{{ dict(leaf_serials | zip(leaftor_values)) }}" + leaftor_query: "{{ leaftor_map | to_json | urlencode }}" + register: tor_pairing_removal_result + +- name: Debug NDFC server response for ToR pairing removal + ansible.builtin.debug: + msg: + - "NDFC Response Status: {{ tor_pairing_removal_result.response.status | default('N/A') }}" + - "NDFC Response Headers: {{ tor_pairing_removal_result.response.headers | default('N/A') }}" + - "NDFC Response Body: {{ tor_pairing_removal_result.response.json | default(tor_pairing_removal_result.response.body | default('N/A')) }}" + - "NDFC Response Message: {{ tor_pairing_removal_result.response.message | default('N/A') }}" + when: + - ansible_verbosity >= 3 + - tor_pairing_removal_result is defined + - (tor_pairing_delete_mode is defined) and (tor_pairing_delete_mode | bool) + loop: "{{ tor_pairing_removal_result.results | default([]) }}" + loop_control: + label: "Pairing {{ ansible_loop.index }}" + +- 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/main.yml b/roles/dtc/remove/tasks/main.yml index 761b812a3..5a2ced818 100644 --- a/roles/dtc/remove/tasks/main.yml +++ b/roles/dtc/remove/tasks/main.yml @@ -34,7 +34,8 @@ vars_common_vxlan.changes_detected_policy or vars_common_vxlan.changes_detected_vpc_peering or vars_common_vxlan.changes_detected_vrfs or - vars_common_vxlan.changes_detected_edge_connections) + vars_common_vxlan.changes_detected_edge_connections or + vars_common_vxlan.changes_detected_tor_pairing) - name: Import MSD Fabric Role Tasks ansible.builtin.import_tasks: sub_main_msd.yml @@ -73,7 +74,8 @@ vars_common_ebgp_vxlan.changes_detected_interfaces or vars_common_ebgp_vxlan.changes_detected_policy or vars_common_ebgp_vxlan.changes_detected_inventory or - vars_common_ebgp_vxlan.changes_detected_networks) + vars_common_ebgp_vxlan.changes_detected_networks or + vars_common_ebgp_vxlan.changes_detected_tor_pairing) # Additional conditions to be added when needed: - name: Mark Stage Role Remove Completed diff --git a/roles/dtc/remove/tasks/sub_main_ebgp_vxlan.yml b/roles/dtc/remove/tasks/sub_main_ebgp_vxlan.yml index 2326c290b..a06ade906 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: - vars_common_ebgp_vxlan.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 cf1d6a38e..36350f916 100644 --- a/roles/dtc/remove/tasks/sub_main_vxlan.yml +++ b/roles/dtc/remove/tasks/sub_main_vxlan.yml @@ -82,6 +82,12 @@ when: - vars_common_vxlan.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 }}" From 103fcd4b4262500816283bde8bbd868e2e1d55e7 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Tue, 14 Oct 2025 15:33:44 +0200 Subject: [PATCH 04/16] fix for pairing removing --- roles/dtc/common/tasks/cleanup_files.yml | 1 - roles/dtc/common/tasks/sub_main_vxlan.yml | 11 +---------- roles/dtc/remove/tasks/common/tor_pairing.yml | 15 --------------- 3 files changed, 1 insertion(+), 26 deletions(-) diff --git a/roles/dtc/common/tasks/cleanup_files.yml b/roles/dtc/common/tasks/cleanup_files.yml index e2028ada2..8099115c7 100644 --- a/roles/dtc/common/tasks/cleanup_files.yml +++ b/roles/dtc/common/tasks/cleanup_files.yml @@ -26,7 +26,6 @@ state: absent path: "{{ path_name }}" delegate_to: localhost - when: cleanup_enabled | default(true) - name: Recreate the directory ansible.builtin.file: diff --git a/roles/dtc/common/tasks/sub_main_vxlan.yml b/roles/dtc/common/tasks/sub_main_vxlan.yml index 997072e3f..b8223bdd9 100644 --- a/roles/dtc/common/tasks/sub_main_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_vxlan.yml @@ -38,16 +38,7 @@ - name: Cleanup Files from Previous Run if run_map requires it ansible.builtin.import_tasks: cleanup_files.yml when: - - cleanup_enabled | default(true) - vars: - cleanup_enabled: (not run_map_read_result.diff_run) and not ((force_run_all | default(false)) | bool) - -- name: Ensure ToR artifact directory exists - ansible.builtin.file: - path: "{{ path_name }}" - state: directory - mode: '0755' - delegate_to: localhost + - not run_map_read_result.diff_run or ((force_run_all is defined) and (force_run_all is true|bool)) # ------------------------------------------------------------------------ # Build iBGP VXLAN Fabric List From Template diff --git a/roles/dtc/remove/tasks/common/tor_pairing.yml b/roles/dtc/remove/tasks/common/tor_pairing.yml index bde7a01bf..d16d5cb6e 100644 --- a/roles/dtc/remove/tasks/common/tor_pairing.yml +++ b/roles/dtc/remove/tasks/common/tor_pairing.yml @@ -56,21 +56,6 @@ leaftor_query: "{{ leaftor_map | to_json | urlencode }}" register: tor_pairing_removal_result -- name: Debug NDFC server response for ToR pairing removal - ansible.builtin.debug: - msg: - - "NDFC Response Status: {{ tor_pairing_removal_result.response.status | default('N/A') }}" - - "NDFC Response Headers: {{ tor_pairing_removal_result.response.headers | default('N/A') }}" - - "NDFC Response Body: {{ tor_pairing_removal_result.response.json | default(tor_pairing_removal_result.response.body | default('N/A')) }}" - - "NDFC Response Message: {{ tor_pairing_removal_result.response.message | default('N/A') }}" - when: - - ansible_verbosity >= 3 - - tor_pairing_removal_result is defined - - (tor_pairing_delete_mode is defined) and (tor_pairing_delete_mode | bool) - loop: "{{ tor_pairing_removal_result.results | default([]) }}" - loop_control: - label: "Pairing {{ ansible_loop.index }}" - - name: Skip ToR pairing removal when tor_pairing_delete_mode is False ansible.builtin.debug: msg: From aac57f7286474af10a9d25ebabe194831e6ed598 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Wed, 15 Oct 2025 10:21:42 +0200 Subject: [PATCH 05/16] fix for eBGP fabric type --- roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml index 141609718..2e9c93323 100644 --- a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml @@ -38,16 +38,7 @@ - name: Cleanup Files from Previous Run if run_map requires it ansible.builtin.import_tasks: cleanup_files.yml when: - - cleanup_enabled | default(true) - vars: - cleanup_enabled: (not run_map_read_result.diff_run) and not ((force_run_all | default(false)) | bool) - -- name: Ensure ToR artifact directory exists - ansible.builtin.file: - path: "{{ path_name }}" - state: directory - mode: '0755' - delegate_to: localhost + - not run_map_read_result.diff_run or ((force_run_all is defined) and (force_run_all is true|bool)) # -------------------------------------------------------------------- # Build Create eBGP VXLAN Fabric parameter List From Template From 3924bab460ed381ce760145f23851f0ad2df03b7 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Tue, 21 Oct 2025 10:15:44 +0200 Subject: [PATCH 06/16] TOR support --- .../prepare_plugins/prep_110_tor_pairing.py | 13 +- ...1_topology_tor_peer_network_attachments.py | 149 +++++++++++ .../312_topology_tor_pairing_validation.py | 243 ++++++++++++++++++ 3 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py create mode 100644 roles/validate/files/rules/common/312_topology_tor_pairing_validation.py diff --git a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py index dfb2b73d3..13d22efd1 100644 --- a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -25,17 +25,16 @@ def __init__(self, **kwargs): self.keys = ['vxlan', 'topology', 'tor_peers'] def _get_switch(self, name, expected_role, switches, errors): + """ + Get switch from switches map. + Note: Basic validation is now handled by validation rule 312. + 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 - role = switch.get('role') - if role != expected_role: - errors.append( - f"Switch '{name}' referenced in tor_peers must have role '{expected_role}', current role is '{role}'" - ) - if not switch.get('serial_number'): - errors.append(f"Switch '{name}' must define serial_number for tor pairing support") return switch def _normalize_vpc_id(self, value, label, errors): diff --git a/roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py b/roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py new file mode 100644 index 000000000..57346fc28 --- /dev/null +++ b/roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py @@ -0,0 +1,149 @@ +class Rule: + id = "311" + description = "Verify that ToRs being removed do not have networks attached" + severity = "HIGH" + + @classmethod + def match(cls, data_model): + """ + Check if any networks are attached to ToR switches that are being removed. + ToRs are considered "being removed" if they appear in tor_peers but not in + the switches inventory. + """ + results = [] + + # Get the list of switches in the topology + switches_keys = ['vxlan', 'topology', 'switches'] + dm_check = cls.data_model_key_check(data_model, switches_keys) + if 'switches' not in dm_check['keys_data']: + return results + + switches = data_model['vxlan']['topology']['switches'] + switch_names = {sw['name'] for sw in switches if 'name' in sw} + + # Get ToR peers configuration + tor_peers_keys = ['vxlan', 'topology', 'tor_peers'] + dm_check = cls.data_model_key_check(data_model, tor_peers_keys) + + # If no tor_peers defined, nothing to check + if 'tor_peers' not in dm_check['keys_data']: + return results + + tor_peers = data_model['vxlan']['topology']['tor_peers'] + + # Collect all ToR switch names referenced in tor_peers + tor_switch_names = set() + for pairing in tor_peers: + if 'tor1' in pairing: + tor1_name = pairing['tor1'].get('name') if isinstance(pairing['tor1'], dict) else pairing['tor1'] + if tor1_name: + tor_switch_names.add(tor1_name) + + if 'tor2' in pairing: + tor2_name = pairing['tor2'].get('name') if isinstance(pairing['tor2'], dict) else pairing['tor2'] + if tor2_name: + tor_switch_names.add(tor2_name) + + # Identify ToRs that are being removed (in tor_peers but NOT in switches inventory) + removed_tors = tor_switch_names - switch_names + + # If no ToRs are being removed, no validation needed + if not removed_tors: + return results + + # Check for network attachments to removed ToRs + # Check both overlay.networks and multisite.overlay.networks + networks_to_check = [] + + # Standard overlay path + overlay_networks_keys = ['vxlan', 'overlay', 'networks'] + dm_check = cls.data_model_key_check(data_model, overlay_networks_keys) + if 'networks' in dm_check['keys_data']: + networks_to_check.extend(data_model['vxlan']['overlay']['networks']) + + # Multisite overlay path + multisite_networks_keys = ['vxlan', 'multisite', 'overlay', 'networks'] + dm_check = cls.data_model_key_check(data_model, multisite_networks_keys) + if 'networks' in dm_check['keys_data']: + networks_to_check.extend(data_model['vxlan']['multisite']['overlay']['networks']) + + # Check network attach groups + network_attach_groups = [] + + # Standard overlay attach groups + overlay_groups_keys = ['vxlan', 'overlay', 'network_attach_groups'] + dm_check = cls.data_model_key_check(data_model, overlay_groups_keys) + if 'network_attach_groups' in dm_check['keys_data']: + network_attach_groups.extend(data_model['vxlan']['overlay']['network_attach_groups']) + + # Multisite overlay attach groups + multisite_groups_keys = ['vxlan', 'multisite', 'overlay', 'network_attach_groups'] + dm_check = cls.data_model_key_check(data_model, multisite_groups_keys) + if 'network_attach_groups' in dm_check['keys_data']: + network_attach_groups.extend(data_model['vxlan']['multisite']['overlay']['network_attach_groups']) + + # Build a map of network_attach_group name to the networks using it + group_to_networks = {} + for network in networks_to_check: + if 'network_attach_group' in network: + group_name = network['network_attach_group'] + if group_name not in group_to_networks: + group_to_networks[group_name] = [] + group_to_networks[group_name].append(network['name']) + + # Check each attach group for ToR attachments + for group in network_attach_groups: + group_name = group.get('name') + if not group_name: + continue + + # Get switches in this group + group_switches = group.get('switches', []) + + for switch_entry in group_switches: + hostname = switch_entry.get('hostname') + + # Check if this hostname is a ToR being removed + if hostname in removed_tors: + # Get the networks using this attach group + affected_networks = group_to_networks.get(group_name, []) + for network_name in affected_networks: + results.append( + f"Network '{network_name}' is attached to ToR switch '{hostname}' " + f"which is being removed. Remove network attachment from " + f"vxlan.overlay.network_attach_groups.{group_name} before removing the ToR pairing." + ) + + # Also check tors within the switch entry + tors = switch_entry.get('tors', []) + for tor_entry in tors: + tor_hostname = tor_entry.get('hostname') + if tor_hostname in removed_tors: + affected_networks = group_to_networks.get(group_name, []) + for network_name in affected_networks: + results.append( + f"Network '{network_name}' has ports attached to ToR switch '{tor_hostname}' " + f"which is being removed. Remove network attachment from " + f"vxlan.overlay.network_attach_groups.{group_name}.switches.{hostname}.tors " + f"before removing the ToR pairing." + ) + + return results + + @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 diff --git a/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py b/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py new file mode 100644 index 000000000..9fdcb48b8 --- /dev/null +++ b/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py @@ -0,0 +1,243 @@ +class Rule: + id = "312" + description = "Verify ToR pairing configuration is valid and complete" + severity = "HIGH" + + @classmethod + def match(cls, data_model): + """ + Validate ToR pairing entries before they are processed by prepare plugins. + This catches configuration errors early in the validation phase. + """ + 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 = {} + for sw in switches: + if 'name' in sw: + switch_map[sw['name']] = sw + + # 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 + + # Track pairing IDs for duplicate detection + pairing_ids = set() + + # Validate each tor_peers entry + for idx, peer in enumerate(tor_peers): + entry_label = f"vxlan.topology.tor_peers[{idx}]" + + # Extract names from the peer entry + parent_leaf1 = peer.get('parent_leaf1') + parent_leaf2 = peer.get('parent_leaf2') + tor1 = peer.get('tor1') + tor2 = peer.get('tor2') + + # Check required fields + if not parent_leaf1: + results.append(f"{entry_label}: 'parent_leaf1' is required") + continue + if not tor1: + results.append(f"{entry_label}: 'tor1' is required") + continue + + # Handle both dict and string formats for switch references + leaf1_name = parent_leaf1.get('name') if isinstance(parent_leaf1, dict) else parent_leaf1 + leaf2_name = parent_leaf2.get('name') if isinstance(parent_leaf2, dict) and parent_leaf2 else None + tor1_name = tor1.get('name') if isinstance(tor1, dict) else tor1 + tor2_name = tor2.get('name') if isinstance(tor2, dict) and tor2 else None + + # Validate leaf1 exists and has correct role + if leaf1_name not in switch_map: + results.append( + f"{entry_label}: parent_leaf1 switch '{leaf1_name}' not found in vxlan.topology.switches" + ) + else: + leaf1_sw = switch_map[leaf1_name] + if leaf1_sw.get('role') != 'leaf': + results.append( + f"{entry_label}: parent_leaf1 switch '{leaf1_name}' must have role 'leaf', " + f"current role is '{leaf1_sw.get('role')}'" + ) + if not leaf1_sw.get('serial_number'): + results.append( + f"{entry_label}: parent_leaf1 switch '{leaf1_name}' must have a serial_number defined" + ) + + # Validate leaf2 if provided + if leaf2_name: + if leaf2_name not in switch_map: + results.append( + f"{entry_label}: parent_leaf2 switch '{leaf2_name}' not found in vxlan.topology.switches" + ) + else: + leaf2_sw = switch_map[leaf2_name] + if leaf2_sw.get('role') != 'leaf': + results.append( + f"{entry_label}: parent_leaf2 switch '{leaf2_name}' must have role 'leaf', " + f"current role is '{leaf2_sw.get('role')}'" + ) + if not leaf2_sw.get('serial_number'): + results.append( + f"{entry_label}: parent_leaf2 switch '{leaf2_name}' must have a serial_number defined" + ) + + # Validate tor1 exists and has correct role + if tor1_name not in switch_map: + results.append( + f"{entry_label}: tor1 switch '{tor1_name}' not found in vxlan.topology.switches" + ) + else: + tor1_sw = switch_map[tor1_name] + if tor1_sw.get('role') != 'tor': + results.append( + f"{entry_label}: tor1 switch '{tor1_name}' must have role 'tor', " + f"current role is '{tor1_sw.get('role')}'" + ) + if not tor1_sw.get('serial_number'): + results.append( + f"{entry_label}: tor1 switch '{tor1_name}' must have a serial_number defined" + ) + + # Validate tor2 if provided + if tor2_name: + if tor2_name not in switch_map: + results.append( + f"{entry_label}: tor2 switch '{tor2_name}' not found in vxlan.topology.switches" + ) + else: + tor2_sw = switch_map[tor2_name] + if tor2_sw.get('role') != 'tor': + results.append( + f"{entry_label}: tor2 switch '{tor2_name}' must have role 'tor', " + f"current role is '{tor2_sw.get('role')}'" + ) + if not tor2_sw.get('serial_number'): + results.append( + f"{entry_label}: tor2 switch '{tor2_name}' must have a serial_number defined" + ) + + # Validate tor_vpc_peer consistency + tor_vpc_peer = peer.get('tor_vpc_peer', peer.get('vpc_peer', False)) + + if tor_vpc_peer and not tor2: + results.append( + f"{entry_label}: tor_vpc_peer is true but tor2 is not provided. " + f"ToR vPC requires both tor1 and tor2." + ) + elif tor2 and not tor_vpc_peer: + results.append( + f"{entry_label}: tor2 is defined but tor_vpc_peer is false. " + f"Set tor_vpc_peer to true when defining a ToR vPC pair." + ) + + # Validate VPC domain IDs + leaf_vpc_id = peer.get('leaf_vpc_id') + tor_vpc_id = peer.get('tor_vpc_id') + + # Check if leaf VPC domain is needed + if leaf2_name: + # Leaf vPC scenario - need domain ID + if not leaf_vpc_id: + # Try to find it from vpc_peers + if leaf1_name and leaf2_name: + domain_id = vpc_domain_map.get((leaf1_name, leaf2_name)) + if not domain_id: + results.append( + f"{entry_label}: parent_leaf1 '{leaf1_name}' and parent_leaf2 '{leaf2_name}' " + f"form a vPC but no leaf_vpc_id is defined and no matching entry found in " + f"vxlan.topology.vpc_peers" + ) + else: + # Verify leaf_vpc_id is an integer + if not isinstance(leaf_vpc_id, int): + results.append( + f"{entry_label}: leaf_vpc_id must be an integer, got {type(leaf_vpc_id).__name__}" + ) + + # Check if tor VPC domain is needed + if tor_vpc_peer and tor2_name: + if not tor_vpc_id: + # Try to find it from vpc_peers + if tor1_name and tor2_name: + domain_id = vpc_domain_map.get((tor1_name, tor2_name)) + if not domain_id: + results.append( + f"{entry_label}: tor1 '{tor1_name}' and tor2 '{tor2_name}' " + f"form a vPC but no tor_vpc_id is defined and no matching entry found in " + f"vxlan.topology.vpc_peers" + ) + else: + # Verify tor_vpc_id is an integer + if not isinstance(tor_vpc_id, int): + results.append( + f"{entry_label}: tor_vpc_id must be an integer, got {type(tor_vpc_id).__name__}" + ) + + # Validate supported scenarios + # Scenario: tor vPC with standalone leaf is NOT supported + if tor_vpc_peer and not leaf2_name: + results.append( + f"{entry_label}: Unsupported ToR pairing scenario - ToR vPC with standalone leaf. " + f"ToR vPC (tor1='{tor1_name}', tor2='{tor2_name}') requires a leaf vPC " + f"(both parent_leaf1 and parent_leaf2 must be defined)." + ) + + # Validate unique pairing ID + pairing_id = peer.get('pairing_id') + if not pairing_id and leaf1_name and tor1_name: + # Generate default pairing_id + pairing_id = f"{leaf1_name}-{tor1_name}" + + if pairing_id: + if pairing_id in pairing_ids: + results.append( + f"{entry_label}: Duplicate pairing_id '{pairing_id}'. " + f"Each ToR pairing must have a unique pairing_id." + ) + else: + pairing_ids.add(pairing_id) + + return results + + @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 From 210de831c5a7c4bd9a6950719b1268c486a2843e Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Mon, 27 Oct 2025 15:59:48 +0100 Subject: [PATCH 07/16] Enhance ToR pairing validation and configuration handling with scenario detection and improved error reporting --- .../prepare_plugins/prep_110_tor_pairing.py | 133 +++++++--- .../dtc/common/templates/ndfc_tor_pairing.j2 | 5 +- roles/dtc/create/tasks/common/tor_pairing.yml | 4 +- roles/dtc/remove/tasks/common/tor_pairing.yml | 28 +- .../312_topology_tor_pairing_validation.py | 250 +++++++----------- 5 files changed, 211 insertions(+), 209 deletions(-) diff --git a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py index 13d22efd1..695a169d9 100644 --- a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -59,6 +59,57 @@ def _resolve_vpc_domain(self, peer, key, name_a, name_b, topology): 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'] @@ -81,16 +132,18 @@ def prepare(self): errors.append("Each tor_peers entry requires parent_leaf1 and tor1 definitions") continue - leaf1_name = parent_leaf1.get('name') - tor1_name = tor1.get('name') + # 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') - leaf2_name = parent_leaf2.get('name') if parent_leaf2 else None - tor2_name = tor2.get('name') if tor2 else None + # 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 @@ -100,40 +153,39 @@ def prepare(self): if tor2: tor2_switch = self._get_switch(tor2_name, 'tor', switches, errors) - tor_vpc_peer = peer.get('tor_vpc_peer', peer.get('vpc_peer', False)) - - if tor_vpc_peer and not tor2: - errors.append( - f"tor_peers entry pairing '{leaf1_name}' to '{tor1_name}' is marked as tor_vpc_peer but tor2 is not provided" - ) - if tor2 and not tor_vpc_peer: - errors.append( - f"tor_peers entry pairing '{leaf1_name}' to '{tor1_name}' defines tor2 but tor_vpc_peer is false" - ) - + # 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(tor_vpc_peer and tor2_switch and tor_vpc_domain) + tor_is_vpc = bool(tor2_switch and tor_vpc_domain) - if parent_leaf2 and not leaf_is_vpc: + # 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"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 tor_vpc_peer and not tor_is_vpc: + if tor2 and not tor_vpc_domain: errors.append( - f"tor_peers entry referencing tors '{tor1_name}' and '{tor2_name}' requires a tor_vpc_id." + 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." ) - scenario = 'leaf_standalone_tor_standalone' + # Determine scenario based on configuration + scenario = 'standalone_to_standalone' if leaf_is_vpc and tor_is_vpc: - scenario = 'leaf_vpc_tor_vpc' + scenario = 'vpc_to_vpc' elif leaf_is_vpc and not tor_is_vpc: - scenario = 'leaf_vpc_tor_standalone' + scenario = 'vpc_to_standalone' elif not leaf_is_vpc and tor_is_vpc: errors.append( - f"Unsupported ToR pairing scenario: tor vPC with standalone leafs for '{tor1_name}'." + 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}" @@ -141,31 +193,39 @@ def prepare(self): errors.append(f"Duplicate tor pairing identifier '{pairing_id}' detected") pairing_ids.add(pairing_id) + # Collect serial numbers if leaf1_switch: - leaf1_po = self._normalize_vpc_id(leaf_vpc_domain, "leaf_vpc_id", errors) - leaf1_serial = leaf1_switch.get('serial_number') if leaf1_switch else None + leaf1_serial = leaf1_switch.get('serial_number') else: - leaf1_po = None leaf1_serial = None if tor1_switch: - tor1_po = self._normalize_vpc_id(tor_vpc_domain, "tor_vpc_id", errors) - tor1_serial = tor1_switch.get('serial_number') if tor1_switch else None + tor1_serial = tor1_switch.get('serial_number') else: - tor1_po = None 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_po = self._normalize_vpc_id(leaf_vpc_domain, "leaf_vpc_id", errors) - leaf2_serial = leaf2_switch.get('serial_number') if leaf2_switch else '' + leaf2_serial = leaf2_switch.get('serial_number') or '' - tor2_po = None tor2_serial = '' if tor_is_vpc and tor2_switch: - tor2_po = self._normalize_vpc_id(tor_vpc_domain, "tor_vpc_id", errors) - tor2_serial = tor2_switch.get('serial_number') if tor2_switch else '' + tor2_serial = tor2_switch.get('serial_number') or '' required_serials = [leaf1_serial, tor1_serial] if any(serial is None for serial in required_serials): @@ -173,7 +233,8 @@ def prepare(self): f"Serial numbers must be defined for all ToR pairing members. Pairing '{pairing_id}' is missing values." ) - if scenario != 'leaf_standalone_tor_standalone' and not leaf_is_vpc: + # 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 diff --git a/roles/dtc/common/templates/ndfc_tor_pairing.j2 b/roles/dtc/common/templates/ndfc_tor_pairing.j2 index e3b9955ff..bbb8893ed 100644 --- a/roles/dtc/common/templates/ndfc_tor_pairing.j2 +++ b/roles/dtc/common/templates/ndfc_tor_pairing.j2 @@ -8,9 +8,8 @@ scenario: "{{ pair.scenario }}" payload: leafSN1: "{{ pair.payload.leafSN1 }}" - leafSN2: "{{ pair.payload.leafSN2 }}" + leafSN2: "{{ pair.payload.leafSN2 | default('') }}" torSN1: "{{ pair.payload.torSN1 }}" - torSN2: "{{ pair.payload.torSN2 }}" - po_map: {{ pair.po_map | to_nice_json }} + 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 index 2be021b36..8b206c429 100644 --- a/roles/dtc/create/tasks/common/tor_pairing.yml +++ b/roles/dtc/create/tasks/common/tor_pairing.yml @@ -34,8 +34,8 @@ - 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/tor_testing/switches/pair/custom-id" - json_data: "{{ {'leafSN1': item.payload.leafSN1, 'leafSN2': item.payload.leafSN2, 'torSN1': item.payload.torSN1, 'torSN2': item.payload.torSN2, 'poVpc': item.po_map | to_json } | to_json }}" + 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 }}" diff --git a/roles/dtc/remove/tasks/common/tor_pairing.yml b/roles/dtc/remove/tasks/common/tor_pairing.yml index d16d5cb6e..d67277a7d 100644 --- a/roles/dtc/remove/tasks/common/tor_pairing.yml +++ b/roles/dtc/remove/tasks/common/tor_pairing.yml @@ -41,20 +41,30 @@ label: "{{ item.pairing_id }}" when: - (tor_pairing_delete_mode is defined) and (tor_pairing_delete_mode | bool) - - leaf_serials | length > 0 - - tor_value | length > 0 + - changes_detected_tor_pairing | default(false) + - item.payload.leafSN1 | default('') + - item.payload.torSN1 | default('') vars: ansible_command_timeout: 300 ansible_connect_timeout: 300 - leaf_serials: "{{ [item.payload.leafSN1 | default('', true), item.payload.leafSN2 | default('', true)] | map('regex_replace', '\\s+$', '') | reject('equalto', '') | list }}" - tor_serials: "{{ [item.payload.torSN1 | default('', true), item.payload.torSN2 | default('', true)] | map('regex_replace', '\\s+$', '') | reject('equalto', '') | list }}" - tor_value_vpc: "{{ tor_serials | join(',') }}" - tor_value_single: "{{ tor_serials | first | default('', true) }}" - tor_value: "{{ tor_value_vpc if item.scenario == 'leaf_vpc_tor_vpc' else tor_value_single }}" - leaftor_values: "{{ [tor_value] * (leaf_serials | length) }}" - leaftor_map: "{{ dict(leaf_serials | zip(leaftor_values)) }}" + # 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: diff --git a/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py b/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py index 9fdcb48b8..0a4c2a2d8 100644 --- a/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py +++ b/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py @@ -1,6 +1,6 @@ class Rule: id = "312" - description = "Verify ToR pairing configuration is valid and complete" + description = "Verify ToR pairing configuration with scenario-specific validation" severity = "HIGH" @classmethod @@ -51,179 +51,111 @@ def match(cls, data_model): for idx, peer in enumerate(tor_peers): entry_label = f"vxlan.topology.tor_peers[{idx}]" - # Extract names from the peer entry - parent_leaf1 = peer.get('parent_leaf1') - parent_leaf2 = peer.get('parent_leaf2') - tor1 = peer.get('tor1') - tor2 = peer.get('tor2') + # 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')) - # Check required fields - if not parent_leaf1: - results.append(f"{entry_label}: 'parent_leaf1' is required") - continue - if not tor1: - results.append(f"{entry_label}: 'tor1' is required") + if not leaf1_name or not tor1_name: + results.append(f"{entry_label}: 'parent_leaf1' and 'tor1' are required") continue - # Handle both dict and string formats for switch references - leaf1_name = parent_leaf1.get('name') if isinstance(parent_leaf1, dict) else parent_leaf1 - leaf2_name = parent_leaf2.get('name') if isinstance(parent_leaf2, dict) and parent_leaf2 else None - tor1_name = tor1.get('name') if isinstance(tor1, dict) else tor1 - tor2_name = tor2.get('name') if isinstance(tor2, dict) and tor2 else None + # Determine scenario + scenario = cls._detect_scenario(leaf1_name, leaf2_name, tor1_name, tor2_name) - # Validate leaf1 exists and has correct role - if leaf1_name not in switch_map: - results.append( - f"{entry_label}: parent_leaf1 switch '{leaf1_name}' not found in vxlan.topology.switches" - ) - else: - leaf1_sw = switch_map[leaf1_name] - if leaf1_sw.get('role') != 'leaf': + # 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}: parent_leaf1 switch '{leaf1_name}' must have role 'leaf', " - f"current role is '{leaf1_sw.get('role')}'" + f"{entry_label}: vpc-to-vpc scenario requires leafs '{leaf1_name}' and '{leaf2_name}' " + f"to be VPC paired in vxlan.topology.vpc_peers" ) - if not leaf1_sw.get('serial_number'): + # Validate: tors must be VPC paired + if not cls._find_vpc_domain(tor1_name, tor2_name, vpc_domain_map): results.append( - f"{entry_label}: parent_leaf1 switch '{leaf1_name}' must have a serial_number defined" + 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 leaf2 if provided - if leaf2_name: - if leaf2_name not in switch_map: + # 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}: parent_leaf2 switch '{leaf2_name}' not found in vxlan.topology.switches" + f"{entry_label}: vpc-to-standalone scenario requires leafs '{leaf1_name}' and '{leaf2_name}' " + f"to be VPC paired in vxlan.topology.vpc_peers" ) - else: - leaf2_sw = switch_map[leaf2_name] - if leaf2_sw.get('role') != 'leaf': - results.append( - f"{entry_label}: parent_leaf2 switch '{leaf2_name}' must have role 'leaf', " - f"current role is '{leaf2_sw.get('role')}'" - ) - if not leaf2_sw.get('serial_number'): - results.append( - f"{entry_label}: parent_leaf2 switch '{leaf2_name}' must have a serial_number defined" - ) - - # Validate tor1 exists and has correct role - if tor1_name not in switch_map: - results.append( - f"{entry_label}: tor1 switch '{tor1_name}' not found in vxlan.topology.switches" - ) + # 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: - tor1_sw = switch_map[tor1_name] - if tor1_sw.get('role') != 'tor': - results.append( - f"{entry_label}: tor1 switch '{tor1_name}' must have role 'tor', " - f"current role is '{tor1_sw.get('role')}'" - ) - if not tor1_sw.get('serial_number'): - results.append( - f"{entry_label}: tor1 switch '{tor1_name}' must have a serial_number defined" - ) - - # Validate tor2 if provided - if tor2_name: - if tor2_name not in switch_map: - results.append( - f"{entry_label}: tor2 switch '{tor2_name}' not found in vxlan.topology.switches" - ) - else: - tor2_sw = switch_map[tor2_name] - if tor2_sw.get('role') != 'tor': - results.append( - f"{entry_label}: tor2 switch '{tor2_name}' must have role 'tor', " - f"current role is '{tor2_sw.get('role')}'" - ) - if not tor2_sw.get('serial_number'): - results.append( - f"{entry_label}: tor2 switch '{tor2_name}' must have a serial_number defined" - ) - - # Validate tor_vpc_peer consistency - tor_vpc_peer = peer.get('tor_vpc_peer', peer.get('vpc_peer', False)) - - if tor_vpc_peer and not tor2: - results.append( - f"{entry_label}: tor_vpc_peer is true but tor2 is not provided. " - f"ToR vPC requires both tor1 and tor2." - ) - elif tor2 and not tor_vpc_peer: results.append( - f"{entry_label}: tor2 is defined but tor_vpc_peer is false. " - f"Set tor_vpc_peer to true when defining a ToR vPC pair." + 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" ) - - # Validate VPC domain IDs - leaf_vpc_id = peer.get('leaf_vpc_id') - tor_vpc_id = peer.get('tor_vpc_id') - - # Check if leaf VPC domain is needed - if leaf2_name: - # Leaf vPC scenario - need domain ID - if not leaf_vpc_id: - # Try to find it from vpc_peers - if leaf1_name and leaf2_name: - domain_id = vpc_domain_map.get((leaf1_name, leaf2_name)) - if not domain_id: - results.append( - f"{entry_label}: parent_leaf1 '{leaf1_name}' and parent_leaf2 '{leaf2_name}' " - f"form a vPC but no leaf_vpc_id is defined and no matching entry found in " - f"vxlan.topology.vpc_peers" - ) - else: - # Verify leaf_vpc_id is an integer - if not isinstance(leaf_vpc_id, int): - results.append( - f"{entry_label}: leaf_vpc_id must be an integer, got {type(leaf_vpc_id).__name__}" - ) - - # Check if tor VPC domain is needed - if tor_vpc_peer and tor2_name: - if not tor_vpc_id: - # Try to find it from vpc_peers - if tor1_name and tor2_name: - domain_id = vpc_domain_map.get((tor1_name, tor2_name)) - if not domain_id: - results.append( - f"{entry_label}: tor1 '{tor1_name}' and tor2 '{tor2_name}' " - f"form a vPC but no tor_vpc_id is defined and no matching entry found in " - f"vxlan.topology.vpc_peers" - ) - else: - # Verify tor_vpc_id is an integer - if not isinstance(tor_vpc_id, int): - results.append( - f"{entry_label}: tor_vpc_id must be an integer, got {type(tor_vpc_id).__name__}" - ) - - # Validate supported scenarios - # Scenario: tor vPC with standalone leaf is NOT supported - if tor_vpc_peer and not leaf2_name: - results.append( - f"{entry_label}: Unsupported ToR pairing scenario - ToR vPC with standalone leaf. " - f"ToR vPC (tor1='{tor1_name}', tor2='{tor2_name}') requires a leaf vPC " - f"(both parent_leaf1 and parent_leaf2 must be defined)." - ) - - # Validate unique pairing ID - pairing_id = peer.get('pairing_id') - if not pairing_id and leaf1_name and tor1_name: - # Generate default pairing_id - pairing_id = f"{leaf1_name}-{tor1_name}" - - if pairing_id: - if pairing_id in pairing_ids: - results.append( - f"{entry_label}: Duplicate pairing_id '{pairing_id}'. " - f"Each ToR pairing must have a unique pairing_id." - ) - else: - pairing_ids.add(pairing_id) 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): """ From 8bb6b188a3d97a31fe01bfd643c42ad4e8a960f4 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Tue, 28 Oct 2025 13:06:34 +0100 Subject: [PATCH 08/16] Fix for po mismatch --- .../prepare_plugins/prep_110_tor_pairing.py | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py index 695a169d9..f1f7a9559 100644 --- a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -238,25 +238,26 @@ def prepare(self): # scenario with additional members but no vpc support already logged continue - po_map = {} - if leaf1_serial and leaf1_po is not None: - po_map[f"{leaf1_serial}_PO"] = str(leaf1_po) - if leaf2_serial and leaf2_po is not None: - po_map[f"{leaf2_serial}_PO"] = str(leaf2_po) - if tor1_serial and tor1_po is not None: - po_map[f"{tor1_serial}_PO"] = str(tor1_po) - if tor2_serial and tor2_po is not None: - po_map[f"{tor2_serial}_PO"] = str(tor2_po) - if leaf_is_vpc and leaf1_serial and leaf2_serial and leaf_vpc_domain: - po_map[f"{leaf1_serial}~{leaf2_serial}_VPC"] = str(leaf_vpc_domain) - if tor_is_vpc and tor1_serial and tor2_serial and tor_vpc_domain: - po_map[f"{tor1_serial}~{tor2_serial}_VPC"] = str(tor_vpc_domain) - - if not po_map: - errors.append( - f"No port-channel mapping could be derived for ToR pairing '{pairing_id}'." - ) - continue + # po_map = {} + # if leaf1_serial and leaf1_po is not None: + # po_map[f"{leaf1_serial}_PO"] = str(leaf1_po) + # if leaf2_serial and leaf2_po is not None: + # po_map[f"{leaf2_serial}_PO"] = str(leaf2_po) + # if tor1_serial and tor1_po is not None: + # po_map[f"{tor1_serial}_PO"] = str(tor1_po) + # if tor2_serial and tor2_po is not None: + # po_map[f"{tor2_serial}_PO"] = str(tor2_po) + # if leaf_is_vpc and leaf1_serial and leaf2_serial and leaf_vpc_domain: + # po_map[f"{leaf1_serial}~{leaf2_serial}_VPC"] = str(leaf_vpc_domain) + # if tor_is_vpc and tor1_serial and tor2_serial and tor_vpc_domain: + # po_map[f"{tor1_serial}~{tor2_serial}_VPC"] = str(tor_vpc_domain) + + # # For standalone-to-standalone scenario, po_map can be empty (no VPC/port-channel needed) + # if not po_map and scenario != 'standalone_to_standalone': + # errors.append( + # f"No port-channel mapping could be derived for ToR pairing '{pairing_id}'." + # ) + # continue if len(errors) > error_count_start: continue @@ -269,8 +270,8 @@ def prepare(self): 'leafSN2': leaf2_serial or '', 'torSN1': tor1_serial or '', 'torSN2': tor2_serial or '' - }, - 'po_map': po_map + } + # 'po_map': po_map }) if errors: From 511aacede0790851652c09187886d653eadf173e Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Tue, 28 Oct 2025 23:50:03 +0100 Subject: [PATCH 09/16] Support for tor pair removing without *.old files --- .../prep_115_tor_pairing_diff.py | 120 ++++++++++++++ plugins/filter/tor_pairing_diff.py | 117 ++++++++++++++ .../common/tasks/common/ndfc_tor_pairing.yml | 149 +++++++++++++++++- roles/dtc/create/tasks/common/tor_pairing.yml | 3 + roles/dtc/create/tasks/main.yml | 6 +- 5 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 plugins/action/common/prepare_plugins/prep_115_tor_pairing_diff.py create mode 100644 plugins/filter/tor_pairing_diff.py diff --git a/plugins/action/common/prepare_plugins/prep_115_tor_pairing_diff.py b/plugins/action/common/prepare_plugins/prep_115_tor_pairing_diff.py new file mode 100644 index 000000000..eae868876 --- /dev/null +++ b/plugins/action/common/prepare_plugins/prep_115_tor_pairing_diff.py @@ -0,0 +1,120 @@ +# 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: + """ + Compares previous and current ToR pairings to determine removals. + Handles order-independent serial number matching for brownfield scenarios. + + This plugin runs after prep_110_tor_pairing.py and performs efficient + diff detection using normalized serial number sets. + """ + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.keys = ['vxlan', 'topology', 'tor_pairing'] + + 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 prepare(self): + """ + Compare current and previous ToR pairings to identify removals. + + Previous pairings come from: + 1. Artifact .old file (normal workflow) + 2. NDFC API discovery (brownfield mode) + + Current pairings come from prep_110_tor_pairing.py output. + + Returns: + results dict with tor_pairing_removed added to model_extended + """ + results = self.kwargs['results'] + model_data = results.get('model_extended', {}) + + # Get current pairings from prep_110_tor_pairing output + topology = model_data.get('vxlan', {}).get('topology', {}) + current_pairings = topology.get('tor_pairing', []) + + # Get previous pairings (passed from ndfc_tor_pairing.yml) + previous_pairings = self.kwargs.get('tor_pairing_previous_list', []) + + if not previous_pairings: + # No previous state, nothing to remove + topology['tor_pairing_removed'] = [] + results['model_extended'] = model_data + return results + + # Build lookup set of current pairing serials + current_serial_sets = {} + for pairing in current_pairings: + 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 + topology['tor_pairing_removed'] = removed + results['model_extended'] = model_data + + # Add debug information + results['tor_pairing_diff_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] + } + + 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/dtc/common/tasks/common/ndfc_tor_pairing.yml b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml index 6c46f6d8d..26e4ab6e7 100644 --- a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml +++ b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml @@ -51,6 +51,116 @@ delegate_to: localhost when: tor_pairing_previous.stat.exists +# ============================================================ +# Brownfield Support: Query NDFC for existing ToR pairings +# when artifact file is missing +# ============================================================ + +- name: Get list of leaf switches for ToR pairing discovery + ansible.builtin.set_fact: + leaf_switches_for_query: >- + {{ MD_Extended.vxlan.topology.switches + | selectattr('role', 'equalto', 'leaf') + | list }} + delegate_to: localhost + when: not 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: Verify NDFC ToR discovery query succeeded + ansible.builtin.assert: + that: + - ndfc_tor_discovery is defined + - not (ndfc_tor_discovery.failed | default(false)) + fail_msg: "Failed to query ToR pairings from NDFC. Cannot proceed with brownfield removal detection." + success_msg: "Successfully queried NDFC for existing ToR pairings" + delegate_to: localhost + when: + - not tor_pairing_previous.stat.exists + - leaf_switches_for_query is defined + - leaf_switches_for_query | length > 0 + +- name: Initialize NDFC discovered pairings list + ansible.builtin.set_fact: + ndfc_discovered_pairings: [] + delegate_to: localhost + when: not tor_pairing_previous.stat.exists + +- name: Extract existing ToR pairings from NDFC response + ansible.builtin.set_fact: + ndfc_discovered_pairings: >- + {{ ndfc_discovered_pairings + [ + { + '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 + - 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_discovered_pairings is defined + - ndfc_discovered_pairings | length > 0 + +- name: Use NDFC discovered pairings as previous state (brownfield mode) + ansible.builtin.set_fact: + tor_pairing_previous_list: "{{ ndfc_discovered_pairings }}" + delegate_to: localhost + when: + - not tor_pairing_previous.stat.exists + - ndfc_discovered_pairings is defined + - ndfc_discovered_pairings | length > 0 + +- name: Debug ToR pairing brownfield discovery + ansible.builtin.debug: + msg: + - "==================== ToR Pairing Discovery ====================" + - "Source: {{ '.old artifact file' if tor_pairing_previous.stat.exists else 'NDFC API query (brownfield)' }}" + - "Leaf switches available: {{ leaf_switches_for_query | default([]) | length }}" + - "NDFC discovered pairings: {{ ndfc_discovered_pairings | default([]) | length }}" + - "Previous list count: {{ tor_pairing_previous_list | length }}" + - "Discovered pairing IDs: {{ ndfc_discovered_pairings | default([]) | map(attribute='pairing_id') | list }}" + - "==============================================================" + delegate_to: localhost + when: + - not tor_pairing_previous.stat.exists + - ndfc_discovered_pairings is defined + - name: Load ToR pairing payloads ansible.builtin.set_fact: tor_pairing_current_list: "{{ lookup('file', path_name + file_name) | from_yaml | default([], true) }}" @@ -62,14 +172,39 @@ tor_pairing: "{{ tor_pairing_current_list }}" delegate_to: localhost -- name: Compute removed ToR pairings +- name: Run ToR pairing diff analysis (Python filter - O(n+m) performance) ansible.builtin.set_fact: - tor_pairing_removed: >- - {{ tor_pairing_previous_list - | selectattr('pairing_id', 'in', - (tor_pairing_previous_list | map(attribute='pairing_id') | list) - | difference(tor_pairing_current_list | map(attribute='pairing_id') | list)) - | list }} + 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: Extract removed 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: Initialize removed ToR pairings list (no previous state) + ansible.builtin.set_fact: + tor_pairing_removed: [] + delegate_to: localhost + when: + - tor_pairing_previous_list | length == 0 + +- name: Debug ToR pairing removal results + ansible.builtin.debug: + msg: + - "==================== ToR Pairing Removal Analysis ====================" + - "Previous pairings count: {{ tor_pairing_diff_result.stats.previous_count | default(tor_pairing_previous_list | length) }}" + - "Current pairings count: {{ tor_pairing_diff_result.stats.current_count | default(tor_pairing_current_list | length) }}" + - "Pairings to remove: {{ tor_pairing_diff_result.stats.removed_count | default(tor_pairing_removed | length) }}" + - "Previous pairing IDs: {{ tor_pairing_diff_result.stats.previous_ids | default(tor_pairing_previous_list | map(attribute='pairing_id') | list) }}" + - "Current pairing IDs: {{ tor_pairing_diff_result.stats.current_ids | default(tor_pairing_current_list | map(attribute='pairing_id') | list) }}" + - "Removed pairing IDs: {{ tor_pairing_diff_result.stats.removed_ids | default(tor_pairing_removed | map(attribute='pairing_id') | list) }}" + - "=====================================================================" delegate_to: localhost when: - tor_pairing_previous_list | length > 0 diff --git a/roles/dtc/create/tasks/common/tor_pairing.yml b/roles/dtc/create/tasks/common/tor_pairing.yml index 8b206c429..82d2b97c0 100644 --- a/roles/dtc/create/tasks/common/tor_pairing.yml +++ b/roles/dtc/create/tasks/common/tor_pairing.yml @@ -30,6 +30,8 @@ - "----------------------------------------------------------------" - "+ 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: @@ -40,6 +42,7 @@ 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/main.yml b/roles/dtc/create/tasks/main.yml index 0afdf633d..79e0a720a 100644 --- a/roles/dtc/create/tasks/main.yml +++ b/roles/dtc/create/tasks/main.yml @@ -35,7 +35,8 @@ (vars_common_vxlan.changes_detected_policy) or (vars_common_vxlan.changes_detected_edge_connections) or (vars_common_vxlan.changes_detected_fabric_links) or - (vars_common_vxlan.changes_detected_underlay_ip_address) + (vars_common_vxlan.changes_detected_underlay_ip_address) or + (vars_common_vxlan.changes_detected_tor_pairing) - name: Import eBGP VXLAN Fabric Role Tasks ansible.builtin.import_tasks: sub_main_ebgp_vxlan.yml @@ -48,7 +49,8 @@ (vars_common_ebgp_vxlan.changes_detected_policy) or (vars_common_ebgp_vxlan.changes_detected_interfaces) or (vars_common_ebgp_vxlan.changes_detected_vrfs) or - (vars_common_ebgp_vxlan.changes_detected_networks) + (vars_common_ebgp_vxlan.changes_detected_networks) or + (vars_common_vxlan.changes_detected_tor_pairing) - name: Import ISN Fabric Role Tasks ansible.builtin.import_tasks: sub_main_isn.yml From 4f615d880acb47c90d6891acb2ec37eba5c0dc44 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Wed, 29 Oct 2025 14:15:34 +0100 Subject: [PATCH 10/16] simplifying plugins --- .../prepare_plugins/prep_110_tor_pairing.py | 75 ++++++++- .../prep_115_tor_pairing_diff.py | 120 -------------- ...idation.py => 311_topology_tor_pairing.py} | 117 +++++++++++++- ...1_topology_tor_peer_network_attachments.py | 149 ------------------ 4 files changed, 186 insertions(+), 275 deletions(-) delete mode 100644 plugins/action/common/prepare_plugins/prep_115_tor_pairing_diff.py rename roles/validate/files/rules/common/{312_topology_tor_pairing_validation.py => 311_topology_tor_pairing.py} (57%) delete mode 100644 roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py diff --git a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py index f1f7a9559..0ff6aec06 100644 --- a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -20,14 +20,51 @@ 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 312. + Note: Basic validation is now handled by validation rule 311. This method focuses on data retrieval for payload generation. """ switch = switches.get(name) @@ -279,6 +316,42 @@ def prepare(self): 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/action/common/prepare_plugins/prep_115_tor_pairing_diff.py b/plugins/action/common/prepare_plugins/prep_115_tor_pairing_diff.py deleted file mode 100644 index eae868876..000000000 --- a/plugins/action/common/prepare_plugins/prep_115_tor_pairing_diff.py +++ /dev/null @@ -1,120 +0,0 @@ -# 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: - """ - Compares previous and current ToR pairings to determine removals. - Handles order-independent serial number matching for brownfield scenarios. - - This plugin runs after prep_110_tor_pairing.py and performs efficient - diff detection using normalized serial number sets. - """ - - def __init__(self, **kwargs): - self.kwargs = kwargs - self.keys = ['vxlan', 'topology', 'tor_pairing'] - - 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 prepare(self): - """ - Compare current and previous ToR pairings to identify removals. - - Previous pairings come from: - 1. Artifact .old file (normal workflow) - 2. NDFC API discovery (brownfield mode) - - Current pairings come from prep_110_tor_pairing.py output. - - Returns: - results dict with tor_pairing_removed added to model_extended - """ - results = self.kwargs['results'] - model_data = results.get('model_extended', {}) - - # Get current pairings from prep_110_tor_pairing output - topology = model_data.get('vxlan', {}).get('topology', {}) - current_pairings = topology.get('tor_pairing', []) - - # Get previous pairings (passed from ndfc_tor_pairing.yml) - previous_pairings = self.kwargs.get('tor_pairing_previous_list', []) - - if not previous_pairings: - # No previous state, nothing to remove - topology['tor_pairing_removed'] = [] - results['model_extended'] = model_data - return results - - # Build lookup set of current pairing serials - current_serial_sets = {} - for pairing in current_pairings: - 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 - topology['tor_pairing_removed'] = removed - results['model_extended'] = model_data - - # Add debug information - results['tor_pairing_diff_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] - } - - return results diff --git a/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py b/roles/validate/files/rules/common/311_topology_tor_pairing.py similarity index 57% rename from roles/validate/files/rules/common/312_topology_tor_pairing_validation.py rename to roles/validate/files/rules/common/311_topology_tor_pairing.py index 0a4c2a2d8..01aaf8a82 100644 --- a/roles/validate/files/rules/common/312_topology_tor_pairing_validation.py +++ b/roles/validate/files/rules/common/311_topology_tor_pairing.py @@ -1,12 +1,15 @@ class Rule: - id = "312" - description = "Verify ToR pairing configuration with scenario-specific validation" + id = "311" + description = "Validate ToR pairing configuration and verify no network attachments on ToRs being removed" severity = "HIGH" @classmethod def match(cls, data_model): """ - Validate ToR pairing entries before they are processed by prepare plugins. + Comprehensive ToR pairing validation: + 1. Validate ToR pairing entries before they are processed by prepare plugins + 2. Verify that ToRs being removed do not have networks attached + This catches configuration errors early in the validation phase. """ results = [] @@ -29,9 +32,11 @@ def match(cls, data_model): # 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 = {} @@ -44,8 +49,8 @@ def match(cls, data_model): vpc_domain_map[(peer1, peer2)] = domain_id vpc_domain_map[(peer2, peer1)] = domain_id - # Track pairing IDs for duplicate detection - pairing_ids = set() + # 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): @@ -57,6 +62,13 @@ def match(cls, data_model): 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 @@ -109,6 +121,101 @@ def match(cls, data_model): f"Unsupported: standalone leaf with VPC TORs" ) + # Now check for network attachments on ToRs being removed + # Identify ToRs that are being removed (in tor_peers but NOT in switches inventory) + removed_tors = tor_switch_names - switch_names + + # If no ToRs are being removed, skip network attachment validation + if removed_tors: + network_attachment_errors = cls._validate_network_attachments(data_model, removed_tors) + results.extend(network_attachment_errors) + + return results + + @classmethod + def _validate_network_attachments(cls, data_model, removed_tors): + """ + Check if any networks are attached to ToR switches that are being removed. + """ + results = [] + + # Check for network attachments to removed ToRs + # Check both overlay.networks and multisite.overlay.networks + networks_to_check = [] + + # Standard overlay path + overlay_networks_keys = ['vxlan', 'overlay', 'networks'] + dm_check = cls.data_model_key_check(data_model, overlay_networks_keys) + if 'networks' in dm_check['keys_data']: + networks_to_check.extend(data_model['vxlan']['overlay']['networks']) + + # Multisite overlay path + multisite_networks_keys = ['vxlan', 'multisite', 'overlay', 'networks'] + dm_check = cls.data_model_key_check(data_model, multisite_networks_keys) + if 'networks' in dm_check['keys_data']: + networks_to_check.extend(data_model['vxlan']['multisite']['overlay']['networks']) + + # Check network attach groups + network_attach_groups = [] + + # Standard overlay attach groups + overlay_groups_keys = ['vxlan', 'overlay', 'network_attach_groups'] + dm_check = cls.data_model_key_check(data_model, overlay_groups_keys) + if 'network_attach_groups' in dm_check['keys_data']: + network_attach_groups.extend(data_model['vxlan']['overlay']['network_attach_groups']) + + # Multisite overlay attach groups + multisite_groups_keys = ['vxlan', 'multisite', 'overlay', 'network_attach_groups'] + dm_check = cls.data_model_key_check(data_model, multisite_groups_keys) + if 'network_attach_groups' in dm_check['keys_data']: + network_attach_groups.extend(data_model['vxlan']['multisite']['overlay']['network_attach_groups']) + + # Build a map of network_attach_group name to the networks using it + group_to_networks = {} + for network in networks_to_check: + if 'network_attach_group' in network: + group_name = network['network_attach_group'] + if group_name not in group_to_networks: + group_to_networks[group_name] = [] + group_to_networks[group_name].append(network['name']) + + # Check each attach group for ToR attachments + for group in network_attach_groups: + group_name = group.get('name') + if not group_name: + continue + + # Get switches in this group + group_switches = group.get('switches', []) + + for switch_entry in group_switches: + hostname = switch_entry.get('hostname') + + # Check if this hostname is a ToR being removed + if hostname in removed_tors: + # Get the networks using this attach group + affected_networks = group_to_networks.get(group_name, []) + for network_name in affected_networks: + results.append( + f"Network '{network_name}' is attached to ToR switch '{hostname}' " + f"which is being removed. Remove network attachment from " + f"vxlan.overlay.network_attach_groups.{group_name} before removing the ToR pairing." + ) + + # Also check tors within the switch entry + tors = switch_entry.get('tors', []) + for tor_entry in tors: + tor_hostname = tor_entry.get('hostname') + if tor_hostname in removed_tors: + affected_networks = group_to_networks.get(group_name, []) + for network_name in affected_networks: + results.append( + f"Network '{network_name}' has ports attached to ToR switch '{tor_hostname}' " + f"which is being removed. Remove network attachment from " + f"vxlan.overlay.network_attach_groups.{group_name}.switches.{hostname}.tors " + f"before removing the ToR pairing." + ) + return results @classmethod diff --git a/roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py b/roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py deleted file mode 100644 index 57346fc28..000000000 --- a/roles/validate/files/rules/common/311_topology_tor_peer_network_attachments.py +++ /dev/null @@ -1,149 +0,0 @@ -class Rule: - id = "311" - description = "Verify that ToRs being removed do not have networks attached" - severity = "HIGH" - - @classmethod - def match(cls, data_model): - """ - Check if any networks are attached to ToR switches that are being removed. - ToRs are considered "being removed" if they appear in tor_peers but not in - the switches inventory. - """ - results = [] - - # Get the list of switches in the topology - switches_keys = ['vxlan', 'topology', 'switches'] - dm_check = cls.data_model_key_check(data_model, switches_keys) - if 'switches' not in dm_check['keys_data']: - return results - - switches = data_model['vxlan']['topology']['switches'] - switch_names = {sw['name'] for sw in switches if 'name' in sw} - - # Get ToR peers configuration - tor_peers_keys = ['vxlan', 'topology', 'tor_peers'] - dm_check = cls.data_model_key_check(data_model, tor_peers_keys) - - # If no tor_peers defined, nothing to check - if 'tor_peers' not in dm_check['keys_data']: - return results - - tor_peers = data_model['vxlan']['topology']['tor_peers'] - - # Collect all ToR switch names referenced in tor_peers - tor_switch_names = set() - for pairing in tor_peers: - if 'tor1' in pairing: - tor1_name = pairing['tor1'].get('name') if isinstance(pairing['tor1'], dict) else pairing['tor1'] - if tor1_name: - tor_switch_names.add(tor1_name) - - if 'tor2' in pairing: - tor2_name = pairing['tor2'].get('name') if isinstance(pairing['tor2'], dict) else pairing['tor2'] - if tor2_name: - tor_switch_names.add(tor2_name) - - # Identify ToRs that are being removed (in tor_peers but NOT in switches inventory) - removed_tors = tor_switch_names - switch_names - - # If no ToRs are being removed, no validation needed - if not removed_tors: - return results - - # Check for network attachments to removed ToRs - # Check both overlay.networks and multisite.overlay.networks - networks_to_check = [] - - # Standard overlay path - overlay_networks_keys = ['vxlan', 'overlay', 'networks'] - dm_check = cls.data_model_key_check(data_model, overlay_networks_keys) - if 'networks' in dm_check['keys_data']: - networks_to_check.extend(data_model['vxlan']['overlay']['networks']) - - # Multisite overlay path - multisite_networks_keys = ['vxlan', 'multisite', 'overlay', 'networks'] - dm_check = cls.data_model_key_check(data_model, multisite_networks_keys) - if 'networks' in dm_check['keys_data']: - networks_to_check.extend(data_model['vxlan']['multisite']['overlay']['networks']) - - # Check network attach groups - network_attach_groups = [] - - # Standard overlay attach groups - overlay_groups_keys = ['vxlan', 'overlay', 'network_attach_groups'] - dm_check = cls.data_model_key_check(data_model, overlay_groups_keys) - if 'network_attach_groups' in dm_check['keys_data']: - network_attach_groups.extend(data_model['vxlan']['overlay']['network_attach_groups']) - - # Multisite overlay attach groups - multisite_groups_keys = ['vxlan', 'multisite', 'overlay', 'network_attach_groups'] - dm_check = cls.data_model_key_check(data_model, multisite_groups_keys) - if 'network_attach_groups' in dm_check['keys_data']: - network_attach_groups.extend(data_model['vxlan']['multisite']['overlay']['network_attach_groups']) - - # Build a map of network_attach_group name to the networks using it - group_to_networks = {} - for network in networks_to_check: - if 'network_attach_group' in network: - group_name = network['network_attach_group'] - if group_name not in group_to_networks: - group_to_networks[group_name] = [] - group_to_networks[group_name].append(network['name']) - - # Check each attach group for ToR attachments - for group in network_attach_groups: - group_name = group.get('name') - if not group_name: - continue - - # Get switches in this group - group_switches = group.get('switches', []) - - for switch_entry in group_switches: - hostname = switch_entry.get('hostname') - - # Check if this hostname is a ToR being removed - if hostname in removed_tors: - # Get the networks using this attach group - affected_networks = group_to_networks.get(group_name, []) - for network_name in affected_networks: - results.append( - f"Network '{network_name}' is attached to ToR switch '{hostname}' " - f"which is being removed. Remove network attachment from " - f"vxlan.overlay.network_attach_groups.{group_name} before removing the ToR pairing." - ) - - # Also check tors within the switch entry - tors = switch_entry.get('tors', []) - for tor_entry in tors: - tor_hostname = tor_entry.get('hostname') - if tor_hostname in removed_tors: - affected_networks = group_to_networks.get(group_name, []) - for network_name in affected_networks: - results.append( - f"Network '{network_name}' has ports attached to ToR switch '{tor_hostname}' " - f"which is being removed. Remove network attachment from " - f"vxlan.overlay.network_attach_groups.{group_name}.switches.{hostname}.tors " - f"before removing the ToR pairing." - ) - - return results - - @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 From aa18c31db09b4113a1e4103538b52bf097f04c87 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Tue, 4 Nov 2025 11:30:59 +0100 Subject: [PATCH 11/16] Fix for network attach for TORs --- .../prep_105_fabric_overlay.py | 23 +++++++++++++++++++ .../dc_vxlan_fabric_networks.j2 | 9 ++++++++ 2 files changed, 32 insertions(+) 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/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 %} From 5547d840188c30f2a3f15c8cedc62d9d180f5ccd Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Wed, 5 Nov 2025 18:36:12 +0100 Subject: [PATCH 12/16] Add change detection flags for ToR pairing validation --- .../dtc/common/tasks/sub_main_ebgp_vxlan.yml | 2 + roles/dtc/common/tasks/sub_main_vxlan.yml | 2 + .../rules/common/311_topology_tor_pairing.py | 114 ++---------------- 3 files changed, 17 insertions(+), 101 deletions(-) diff --git a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml index ffb7481bf..e848d652a 100644 --- a/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_ebgp_vxlan.yml @@ -256,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 6222e87b9..5ad12c7a2 100644 --- a/roles/dtc/common/tasks/sub_main_vxlan.yml +++ b/roles/dtc/common/tasks/sub_main_vxlan.yml @@ -265,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/validate/files/rules/common/311_topology_tor_pairing.py b/roles/validate/files/rules/common/311_topology_tor_pairing.py index 01aaf8a82..0bc633a80 100644 --- a/roles/validate/files/rules/common/311_topology_tor_pairing.py +++ b/roles/validate/files/rules/common/311_topology_tor_pairing.py @@ -1,16 +1,23 @@ class Rule: id = "311" - description = "Validate ToR pairing configuration and verify no network attachments on ToRs being removed" + description = "Validate ToR pairing configuration (scenarios, VPC requirements, switch roles)" severity = "HIGH" @classmethod def match(cls, data_model): """ - Comprehensive ToR pairing validation: - 1. Validate ToR pairing entries before they are processed by prepare plugins - 2. Verify that ToRs being removed do not have networks attached - - This catches configuration errors early in the validation phase. + 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 = [] @@ -121,101 +128,6 @@ def match(cls, data_model): f"Unsupported: standalone leaf with VPC TORs" ) - # Now check for network attachments on ToRs being removed - # Identify ToRs that are being removed (in tor_peers but NOT in switches inventory) - removed_tors = tor_switch_names - switch_names - - # If no ToRs are being removed, skip network attachment validation - if removed_tors: - network_attachment_errors = cls._validate_network_attachments(data_model, removed_tors) - results.extend(network_attachment_errors) - - return results - - @classmethod - def _validate_network_attachments(cls, data_model, removed_tors): - """ - Check if any networks are attached to ToR switches that are being removed. - """ - results = [] - - # Check for network attachments to removed ToRs - # Check both overlay.networks and multisite.overlay.networks - networks_to_check = [] - - # Standard overlay path - overlay_networks_keys = ['vxlan', 'overlay', 'networks'] - dm_check = cls.data_model_key_check(data_model, overlay_networks_keys) - if 'networks' in dm_check['keys_data']: - networks_to_check.extend(data_model['vxlan']['overlay']['networks']) - - # Multisite overlay path - multisite_networks_keys = ['vxlan', 'multisite', 'overlay', 'networks'] - dm_check = cls.data_model_key_check(data_model, multisite_networks_keys) - if 'networks' in dm_check['keys_data']: - networks_to_check.extend(data_model['vxlan']['multisite']['overlay']['networks']) - - # Check network attach groups - network_attach_groups = [] - - # Standard overlay attach groups - overlay_groups_keys = ['vxlan', 'overlay', 'network_attach_groups'] - dm_check = cls.data_model_key_check(data_model, overlay_groups_keys) - if 'network_attach_groups' in dm_check['keys_data']: - network_attach_groups.extend(data_model['vxlan']['overlay']['network_attach_groups']) - - # Multisite overlay attach groups - multisite_groups_keys = ['vxlan', 'multisite', 'overlay', 'network_attach_groups'] - dm_check = cls.data_model_key_check(data_model, multisite_groups_keys) - if 'network_attach_groups' in dm_check['keys_data']: - network_attach_groups.extend(data_model['vxlan']['multisite']['overlay']['network_attach_groups']) - - # Build a map of network_attach_group name to the networks using it - group_to_networks = {} - for network in networks_to_check: - if 'network_attach_group' in network: - group_name = network['network_attach_group'] - if group_name not in group_to_networks: - group_to_networks[group_name] = [] - group_to_networks[group_name].append(network['name']) - - # Check each attach group for ToR attachments - for group in network_attach_groups: - group_name = group.get('name') - if not group_name: - continue - - # Get switches in this group - group_switches = group.get('switches', []) - - for switch_entry in group_switches: - hostname = switch_entry.get('hostname') - - # Check if this hostname is a ToR being removed - if hostname in removed_tors: - # Get the networks using this attach group - affected_networks = group_to_networks.get(group_name, []) - for network_name in affected_networks: - results.append( - f"Network '{network_name}' is attached to ToR switch '{hostname}' " - f"which is being removed. Remove network attachment from " - f"vxlan.overlay.network_attach_groups.{group_name} before removing the ToR pairing." - ) - - # Also check tors within the switch entry - tors = switch_entry.get('tors', []) - for tor_entry in tors: - tor_hostname = tor_entry.get('hostname') - if tor_hostname in removed_tors: - affected_networks = group_to_networks.get(group_name, []) - for network_name in affected_networks: - results.append( - f"Network '{network_name}' has ports attached to ToR switch '{tor_hostname}' " - f"which is being removed. Remove network attachment from " - f"vxlan.overlay.network_attach_groups.{group_name}.switches.{hostname}.tors " - f"before removing the ToR pairing." - ) - return results @classmethod From 94770acea6084b00b02b0f86887fdbd8bd5c8135 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Thu, 6 Nov 2025 15:52:12 +0100 Subject: [PATCH 13/16] Add ToR pairing change detection flag updates --- plugins/action/common/change_flag_manager.py | 2 ++ roles/dtc/common/tasks/common/ndfc_tor_pairing.yml | 12 ++++++++++++ 2 files changed, 14 insertions(+) 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/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml index 26e4ab6e7..1c7ada9e6 100644 --- a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml +++ b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml @@ -230,3 +230,15 @@ 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) From 322ef0e7f8279d906911e26cb3d71a7b7de81cd4 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Fri, 7 Nov 2025 08:37:33 +0100 Subject: [PATCH 14/16] Removing ToR pairing tasks for improved clarity and efficiency --- .../common/tasks/common/ndfc_tor_pairing.yml | 117 ++++-------------- 1 file changed, 21 insertions(+), 96 deletions(-) diff --git a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml index 1c7ada9e6..a26a4dfed 100644 --- a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml +++ b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml @@ -1,12 +1,8 @@ --- -- name: Initialize tor pairing change flag - ansible.builtin.set_fact: - changes_detected_tor_pairing: false - delegate_to: localhost - -- name: Set tor pairing file name +- 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 @@ -43,6 +39,10 @@ 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 @@ -51,54 +51,27 @@ delegate_to: localhost when: tor_pairing_previous.stat.exists -# ============================================================ -# Brownfield Support: Query NDFC for existing ToR pairings -# when artifact file is missing -# ============================================================ - -- name: Get list of leaf switches for ToR pairing discovery - ansible.builtin.set_fact: - leaf_switches_for_query: >- - {{ MD_Extended.vxlan.topology.switches - | selectattr('role', 'equalto', 'leaf') - | list }} - delegate_to: localhost - when: not 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: Verify NDFC ToR discovery query succeeded - ansible.builtin.assert: - that: - - ndfc_tor_discovery is defined - - not (ndfc_tor_discovery.failed | default(false)) - fail_msg: "Failed to query ToR pairings from NDFC. Cannot proceed with brownfield removal detection." - success_msg: "Successfully queried NDFC for existing ToR pairings" - delegate_to: localhost + failed_when: >- + (not tor_pairing_previous.stat.exists) + and (leaf_switches_for_query | length > 0) + and (ndfc_tor_discovery.failed | default(false)) when: - not tor_pairing_previous.stat.exists - leaf_switches_for_query is defined - leaf_switches_for_query | length > 0 -- name: Initialize NDFC discovered pairings list - ansible.builtin.set_fact: - ndfc_discovered_pairings: [] - delegate_to: localhost - when: not tor_pairing_previous.stat.exists - - name: Extract existing ToR pairings from NDFC response ansible.builtin.set_fact: - ndfc_discovered_pairings: >- - {{ ndfc_discovered_pairings + [ + 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': ( @@ -137,49 +110,23 @@ - ndfc_discovered_pairings is defined - ndfc_discovered_pairings | length > 0 -- name: Use NDFC discovered pairings as previous state (brownfield mode) - ansible.builtin.set_fact: - tor_pairing_previous_list: "{{ ndfc_discovered_pairings }}" - delegate_to: localhost - when: - - not tor_pairing_previous.stat.exists - - ndfc_discovered_pairings is defined - - ndfc_discovered_pairings | length > 0 - -- name: Debug ToR pairing brownfield discovery - ansible.builtin.debug: - msg: - - "==================== ToR Pairing Discovery ====================" - - "Source: {{ '.old artifact file' if tor_pairing_previous.stat.exists else 'NDFC API query (brownfield)' }}" - - "Leaf switches available: {{ leaf_switches_for_query | default([]) | length }}" - - "NDFC discovered pairings: {{ ndfc_discovered_pairings | default([]) | length }}" - - "Previous list count: {{ tor_pairing_previous_list | length }}" - - "Discovered pairing IDs: {{ ndfc_discovered_pairings | default([]) | map(attribute='pairing_id') | list }}" - - "==============================================================" - delegate_to: localhost - when: - - not tor_pairing_previous.stat.exists - - ndfc_discovered_pairings is defined - - name: Load ToR pairing payloads ansible.builtin.set_fact: - tor_pairing_current_list: "{{ lookup('file', path_name + file_name) | from_yaml | default([], true) }}" + 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: Publish current ToR pairing list - ansible.builtin.set_fact: - tor_pairing: "{{ tor_pairing_current_list }}" - delegate_to: localhost - -- name: Run ToR pairing diff analysis (Python filter - O(n+m) performance) +- 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: Extract removed pairings +- name: Compute removed ToR pairings ansible.builtin.set_fact: tor_pairing_removed: "{{ tor_pairing_diff_result.removed | default([]) }}" delegate_to: localhost @@ -187,28 +134,6 @@ - tor_pairing_previous_list | length > 0 - tor_pairing_diff_result is defined -- name: Initialize removed ToR pairings list (no previous state) - ansible.builtin.set_fact: - tor_pairing_removed: [] - delegate_to: localhost - when: - - tor_pairing_previous_list | length == 0 - -- name: Debug ToR pairing removal results - ansible.builtin.debug: - msg: - - "==================== ToR Pairing Removal Analysis ====================" - - "Previous pairings count: {{ tor_pairing_diff_result.stats.previous_count | default(tor_pairing_previous_list | length) }}" - - "Current pairings count: {{ tor_pairing_diff_result.stats.current_count | default(tor_pairing_current_list | length) }}" - - "Pairings to remove: {{ tor_pairing_diff_result.stats.removed_count | default(tor_pairing_removed | length) }}" - - "Previous pairing IDs: {{ tor_pairing_diff_result.stats.previous_ids | default(tor_pairing_previous_list | map(attribute='pairing_id') | list) }}" - - "Current pairing IDs: {{ tor_pairing_diff_result.stats.current_ids | default(tor_pairing_current_list | map(attribute='pairing_id') | list) }}" - - "Removed pairing IDs: {{ tor_pairing_diff_result.stats.removed_ids | default(tor_pairing_removed | map(attribute='pairing_id') | list) }}" - - "=====================================================================" - delegate_to: localhost - when: - - tor_pairing_previous_list | length > 0 - - name: Diff ToR pairing payload file cisco.nac_dc_vxlan.dtc.diff_model_changes: file_name_previous: "{{ path_name }}{{ file_name }}.old" From b66d5ad99456df1f374a357155529082edea7773 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Fri, 7 Nov 2025 08:38:44 +0100 Subject: [PATCH 15/16] Remove commented-out code --- .../prepare_plugins/prep_110_tor_pairing.py | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py index 0ff6aec06..6d53da4e4 100644 --- a/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py +++ b/plugins/action/common/prepare_plugins/prep_110_tor_pairing.py @@ -275,27 +275,6 @@ def prepare(self): # scenario with additional members but no vpc support already logged continue - # po_map = {} - # if leaf1_serial and leaf1_po is not None: - # po_map[f"{leaf1_serial}_PO"] = str(leaf1_po) - # if leaf2_serial and leaf2_po is not None: - # po_map[f"{leaf2_serial}_PO"] = str(leaf2_po) - # if tor1_serial and tor1_po is not None: - # po_map[f"{tor1_serial}_PO"] = str(tor1_po) - # if tor2_serial and tor2_po is not None: - # po_map[f"{tor2_serial}_PO"] = str(tor2_po) - # if leaf_is_vpc and leaf1_serial and leaf2_serial and leaf_vpc_domain: - # po_map[f"{leaf1_serial}~{leaf2_serial}_VPC"] = str(leaf_vpc_domain) - # if tor_is_vpc and tor1_serial and tor2_serial and tor_vpc_domain: - # po_map[f"{tor1_serial}~{tor2_serial}_VPC"] = str(tor_vpc_domain) - - # # For standalone-to-standalone scenario, po_map can be empty (no VPC/port-channel needed) - # if not po_map and scenario != 'standalone_to_standalone': - # errors.append( - # f"No port-channel mapping could be derived for ToR pairing '{pairing_id}'." - # ) - # continue - if len(errors) > error_count_start: continue From 3be086670ac9353198c060ed27215d3773982f86 Mon Sep 17 00:00:00 2001 From: "Slawomir Kaszlikowski (skaszlik)" Date: Fri, 14 Nov 2025 14:52:55 +0100 Subject: [PATCH 16/16] Refactor ToR pairing discovery failure conditions for improved error handling --- roles/dtc/common/tasks/common/ndfc_tor_pairing.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml index a26a4dfed..be970d967 100644 --- a/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml +++ b/roles/dtc/common/tasks/common/ndfc_tor_pairing.yml @@ -56,10 +56,7 @@ 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: >- - (not tor_pairing_previous.stat.exists) - and (leaf_switches_for_query | length > 0) - and (ndfc_tor_discovery.failed | default(false)) + failed_when: false when: - not tor_pairing_previous.stat.exists - leaf_switches_for_query is defined @@ -95,6 +92,7 @@ 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 @@ -107,6 +105,8 @@ 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