diff --git a/playbooks/sda_host_port_migration_playbook_config_generator.yml b/playbooks/sda_host_port_migration_playbook_config_generator.yml new file mode 100644 index 000000000..4c5e23179 --- /dev/null +++ b/playbooks/sda_host_port_migration_playbook_config_generator.yml @@ -0,0 +1,51 @@ +--- +- name: SDA Host Port Assignment Migration Playbook Generator Example + hosts: localhost + gather_facts: false + vars_files: + - credentials.yml + tasks: + - name: Generate host port assignment and port channel migration configuration + cisco.catalystcenter.sda_host_port_migration_playbook_config_generator: + catalystcenter_host: "{{ catalystcenter_host }}" + catalystcenter_username: "{{ catalystcenter_username }}" + catalystcenter_password: "{{ catalystcenter_password }}" + catalystcenter_verify: "{{ catalystcenter_verify }}" + catalystcenter_port: "{{ catalystcenter_port }}" + catalystcenter_version: "{{ catalystcenter_version }}" + catalystcenter_debug: "{{ catalystcenter_debug }}" + catalystcenter_log: true + catalystcenter_log_level: DEBUG + state: gathered + file_path: "host_port_migration_playbook.yml" + file_mode: "overwrite" + config: + component_specific_filters: + port_assignments: + - fabric_site_name_hierarchy: "Global/US/California/San Jose/SJC21" + source_device_ip: "12.12.12.30" + destination_device_ip: "10.0.0.2" + interface_mappings: + - source_interface_name: "GigabitEthernet3/0/24" + destination_interface_name: "TenGigabitEthernet3/0/24" + - fabric_site_name_hierarchy: "Global/US/California/San Jose/SJC24" + source_device_ip: "12.12.12.20" + destination_device_ip: "10.0.0.4" + interface_mappings: + - source_interface_name: "GigabitEthernet3/0/6" + destination_interface_name: "TenGigabitEthernet3/0/6" + port_channels: + - fabric_site_name_hierarchy: "Global/US/California/San Jose/SJC21" + source_device_ip: "12.12.12.30" + destination_device_ip: "10.0.0.2" + interface_mappings: + - source_interface_name: "GigabitEthernet2/0/3" + destination_interface_name: "TenGigabitEthernet2/0/3" + - source_interface_name: "GigabitEthernet3/0/3" + destination_interface_name: "TenGigabitEthernet3/0/3" + - fabric_site_name_hierarchy: "Global/US/California/San Jose/SJC4" + source_device_ip: "31.1.39.11" + destination_device_ip: "10.0.0.3" + interface_mappings: + - source_interface_name: "GigabitEthernet1/0/1" + destination_interface_name: "TenGigabitEthernet1/0/1" diff --git a/playbooks/switch_refresh_cleanup_old.yml b/playbooks/switch_refresh_cleanup_old.yml new file mode 100644 index 000000000..7486a6547 --- /dev/null +++ b/playbooks/switch_refresh_cleanup_old.yml @@ -0,0 +1,14 @@ +--- +- name: Clean up old switch after Catalyst Center switch refresh validation + hosts: localhost + connection: local + gather_facts: false + vars_files: + - vars/switch_refresh_usecase.yml + vars: + switch_refresh_phase: cleanup_old + switch_refresh_cleanup_old: true + roles: + - role: switch_refresh + tags: + - switch_refresh_cleanup_old diff --git a/playbooks/switch_refresh_prepare.yml b/playbooks/switch_refresh_prepare.yml new file mode 100644 index 000000000..10a95c45b --- /dev/null +++ b/playbooks/switch_refresh_prepare.yml @@ -0,0 +1,14 @@ +--- +- name: Prepare replacement switch for Catalyst Center switch refresh + hosts: localhost + connection: local + gather_facts: false + vars_files: + - vars/switch_refresh_usecase.yml + vars: + switch_refresh_phase: prepare + switch_refresh_cleanup_old: false + roles: + - role: switch_refresh + tags: + - switch_refresh_prepare diff --git a/playbooks/vars/switch_refresh_usecase.yml b/playbooks/vars/switch_refresh_usecase.yml new file mode 100644 index 000000000..bf4f5bb75 --- /dev/null +++ b/playbooks/vars/switch_refresh_usecase.yml @@ -0,0 +1,143 @@ +--- +# Sample Catalyst Center connection values. Replace with vault variables or +# inventory variables in production. +catalystcenter_host: "" +catalystcenter_port: 443 +catalystcenter_username: "" +catalystcenter_password: "" +catalystcenter_version: "2.3.7.9" +catalystcenter_verify: false +catalystcenter_debug: false +catalystcenter_log: false +catalystcenter_log_level: INFO + +switch_refresh_work_dir: /tmp/catalystcenter_switch_refresh +switch_refresh_onboarding_method: discovery +switch_refresh_device_info_lookup_enabled: true +switch_refresh_fabric_validation_enabled: true +switch_refresh_sda_host_port_onboarding_config_verify: true +switch_refresh_sda_fabric_devices_config_verify: true +switch_refresh_provision_config_verify: true + +switch_refresh_devices: + - name: sjc-edge-01-refresh + + # Required topology input. + fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + onboarding_method: discovery + + # Minimal old-switch input: provide any one of management_ip, hostname, + # serial_number, or mac_address. The role resolves management IP with + # network_devices_info. + old: + hostname: SJ-EDGE-OLD.cisco.local + + # Minimal new-switch input. The role auto-builds SINGLE discovery config + # using Catalyst Center global credentials, then auto-builds provision_config. + new: + management_ip: 10.10.10.20 + + # Optional discovery overrides. + # discovery_name: switch-refresh-sjc-edge-01 + # protocol_order: ssh + # discovery_retry: 2 + # use_global_credentials: true + + # Optional. Pass full discovery_config only when you need custom discovery + # credentials or a non-SINGLE discovery type. + # discovery_config: + # single: + # - discovery_name: switch-refresh-sjc-edge-01 + # discovery_type: SINGLE + # ip_address_list: + # - 10.10.10.20 + # protocol_order: ssh + # retry: 2 + # discovery_specific_credentials: + # cli_credentials_list: + # - username: "" + # password: "" + # enable_password: "" + + # Optional. Defaults to EDGE_NODE. + # device_roles: + # - EDGE_NODE + + # Optional. Only use when you need a custom provision payload. + # provision_config: + # - site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + # management_ip_address: 10.10.10.20 + + # Optional. Only use when you need a custom fabric device payload. + # fabric_devices_config: + # - fabric_devices: + # fabric_name: Global/USA/SAN-JOSE/BLDG23 + # device_config: + # - device_ip: 10.10.10.20 + # device_roles: + # - EDGE_NODE + + # Provide mappings only when the new switch uses different interface names + # or a different port layout. Unmapped source interfaces are migrated 1:1. + interface_mappings: + - source_interface_name: GigabitEthernet1/0/1 + destination_interface_name: TenGigabitEthernet1/0/1 + - source_interface_name: GigabitEthernet1/0/2 + destination_interface_name: TenGigabitEthernet1/0/2 + - source_interface_name: GigabitEthernet1/0/47 + destination_interface_name: TenGigabitEthernet1/1/1 + - source_interface_name: GigabitEthernet1/0/48 + destination_interface_name: TenGigabitEthernet1/1/2 + + - name: sjc-edge-02-refresh-1-to-1 + fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + onboarding_method: discovery + old: + serial_number: FOC1234ABCD + new: + management_ip: 10.10.10.21 + + # Empty mapping means the generator keeps old interface names as-is. + interface_mappings: [] + + # LAN Automation onboarding option. + # Use this instead of discovery when the replacement switch should be + # onboarded through Catalyst Center LAN Automation. Keep new.management_ip set + # to the expected management IP after LAN Automation so later steps can find + # the switch in inventory. + # + # - name: sjc-edge-03-refresh-lan-auto + # fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + # onboarding_method: lan_automation + # old: + # hostname: SJ-EDGE-OLD-03.cisco.local + # new: + # management_ip: 204.1.1.13 + # lan_automation_config: + # - lan_automation: + # discovered_device_site_name_hierarchy: Global/USA/SAN JOSE + # peer_device_management_ip_address: 91.1.1.2 + # primary_device_management_ip_address: 204.1.1.4 + # primary_device_interface_names: + # - HundredGigE1/0/2 + # ip_pools: + # - ip_pool_name: underlay_sub + # ip_pool_role: MAIN_POOL + # - ip_pool_name: underlay_sub_small + # ip_pool_role: PHYSICAL_LINK_POOL + # multicast_enabled: true + # redistribute_isis_to_bgp: true + # host_name_prefix: San-Jose + # isis_domain_pwd: cisco + # discovery_level: 5 + # discovery_timeout: 40 + # discovery_devices: + # - device_serial_number: FXS2429Q0WE + # device_host_name: SR-LAN-9400X-EDGE1 + # device_site_name_hierarchy: Global/USA/SAN JOSE/BLD20/BLD20_FLOOR1 + # device_management_ip_address: 204.1.1.13 + # launch_and_wait: true + # pnp_authorization: false + # interface_mappings: + # - source_interface_name: GigabitEthernet1/0/1 + # destination_interface_name: TenGigabitEthernet1/0/1 diff --git a/plugins/modules/sda_host_port_migration_playbook_config_generator.py b/plugins/modules/sda_host_port_migration_playbook_config_generator.py new file mode 100644 index 000000000..595b7518e --- /dev/null +++ b/plugins/modules/sda_host_port_migration_playbook_config_generator.py @@ -0,0 +1,1828 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2026, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Ansible module for YAML playbook generation of SDA host port migration configurations. + +The module exports port assignments and port channels from a source SDA fabric +device and writes an onboarding-compatible YAML payload for the destination +device. Optional interface mappings can remap selected source interfaces while +all unmapped source interfaces remain 1:1. +""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Vivek Raj, Madhan Sankaranarayanan" + +DOCUMENTATION = r""" +--- +module: sda_host_port_migration_playbook_config_generator +short_description: Generate SDA host port migration YAML configurations. +description: +- Generates YAML configuration compatible with + C(sda_host_port_onboarding_workflow_manager) by reading port assignments and + port channels from a source device and targeting a destination device. +- Supports 1:1 interface migration by default. +- Supports partial interface remap with C(interface_mappings). Source interfaces + listed in mappings are remapped only when they exist in the source device's + extracted port assignments or port channel member interfaces. Unmapped + interfaces keep the same interface name. +version_added: 6.44.0 +extends_documentation_fragment: +- cisco.catalystcenter.workflow_manager_params +author: +- Vivek Raj (@vivekraj2000) +- Madhan Sankaranarayanan (@madhansansel) +options: + state: + description: + - Desired state for YAML playbook generation workflow. + - Only C(gathered) is supported. + type: str + choices: [gathered] + default: gathered + file_path: + description: + - Path for YAML configuration file output. + - If omitted, a timestamped default filename is generated. + type: str + file_mode: + description: + - Controls how config is written to the YAML file. + - C(overwrite) replaces existing file content. + - C(append) appends generated YAML content to the existing file. + type: str + choices: [overwrite, append] + default: overwrite + config: + description: + - Dictionary of component filters for generating migration YAML. + - Supports C(port_assignments), C(port_channels), or both through + C(component_specific_filters). + type: dict + required: true + suboptions: + component_specific_filters: + description: + - Component-specific migration filters. + - Provide C(port_assignments), C(port_channels), or both. + - Requested components are inferred from the component keys present in + this dictionary. + type: dict + required: true + suboptions: + port_assignments: + description: + - Source-to-destination migration filters for SDA host port + assignments. + - Provide this key to generate port assignment migration payloads. + - Each entry selects a source fabric site and source device, then + generates destination port assignment payload for + C(destination_device_ip). + type: list + elements: dict + required: false + suboptions: + fabric_site_name_hierarchy: + description: + - Fabric site hierarchy that contains the source device port + assignments. + - Must match the full Catalyst Center fabric site hierarchy. + type: str + required: true + source_device_ip: + description: + - Source device management IP address to read port assignments + from. + type: str + required: true + destination_device_ip: + description: + - Destination device management IP address to use as + C(ip_address) in the generated onboarding payload. + type: str + required: true + interface_mappings: + description: + - Optional source-to-destination interface remap list. + - Source interfaces listed here are remapped only when they + exist in the source device port assignment payload. + - Source interfaces not listed here keep their original + interface name for 1:1 migration. + type: list + elements: dict + required: false + suboptions: + source_interface_name: + description: + - Interface name in the source device port assignment + payload. + type: str + required: true + destination_interface_name: + description: + - Interface name to use in the destination device port + assignment payload. + type: str + required: true + port_channels: + description: + - Source-to-destination migration filters for SDA host port channels. + - Provide this key to generate port channel migration payloads. + - Each entry selects a source fabric site and source device, then + generates destination port channel payload for + C(destination_device_ip). + type: list + elements: dict + required: false + suboptions: + fabric_site_name_hierarchy: + description: + - Fabric site hierarchy that contains the source device port + channels. + - Must match the full Catalyst Center fabric site hierarchy. + type: str + required: true + source_device_ip: + description: + - Source device management IP address to read port channels from. + type: str + required: true + destination_device_ip: + description: + - Destination device management IP address to use as + C(ip_address) in the generated onboarding payload. + type: str + required: true + interface_mappings: + description: + - Optional source-to-destination member interface remap list. + - Source interfaces listed here are remapped only when they + exist in the source device port channel member interface list. + - Source interfaces not listed here keep their original + interface name for 1:1 migration. + type: list + elements: dict + required: false + suboptions: + source_interface_name: + description: + - Member interface name in the source device port channel + payload. + type: str + required: true + destination_interface_name: + description: + - Member interface name to use in the destination device + port channel payload. + type: str + required: true +""" + +EXAMPLES = r""" +- name: Generate host port assignment and port channel migration configuration + cisco.catalystcenter.sda_host_port_migration_playbook_config_generator: + catalystcenter_host: "{{ catalystcenter_host }}" + catalystcenter_username: "{{ catalystcenter_username }}" + catalystcenter_password: "{{ catalystcenter_password }}" + catalystcenter_verify: "{{ catalystcenter_verify }}" + catalystcenter_port: "{{ catalystcenter_port }}" + catalystcenter_version: "{{ catalystcenter_version }}" + state: gathered + file_path: "host_port_migration_playbook.yml" + file_mode: overwrite + config: + component_specific_filters: + port_assignments: + - fabric_site_name_hierarchy: "Global/California/23" + source_device_ip: "10.0.0.1" + destination_device_ip: "10.0.0.2" + interface_mappings: + - source_interface_name: "GigabitEthernet1/0/1" + destination_interface_name: "GigabitEthernet1/0/25" + port_channels: + - fabric_site_name_hierarchy: "Global/California/23" + source_device_ip: "10.0.0.1" + destination_device_ip: "10.0.0.2" + interface_mappings: + - source_interface_name: "GigabitEthernet1/0/2" + destination_interface_name: "GigabitEthernet1/0/26" +""" + +RETURN = r""" +response: + description: Result of YAML generation. + returned: always + type: dict + sample: > + { + "status": "success", + "msg": { + "message": "YAML configuration file generated successfully", + "file_path": "host_port_migration_playbook.yml", + "components_processed": 1, + "components_skipped": 0, + "configurations_count": 1 + }, + "changed": true + } +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.catalystcenter.plugins.module_utils.brownfield_helper import ( + BrownFieldHelper, +) +from ansible_collections.cisco.catalystcenter.plugins.module_utils.catalystcenter import ( + CatalystCenterBase, +) +from collections import OrderedDict +import time + + +class SdaHostPortMigrationPlaybookConfigGenerator(CatalystCenterBase, BrownFieldHelper): + """ + Brownfield generator for SDA host port migration payloads. + + This generator reads existing SDA host-facing port assignments and port + channels from a source fabric device and writes an onboarding-compatible + YAML payload for a destination device. The generated payload uses + destination_device_ip as the final device ip_address and optionally remaps + source interface names to destination interface names. + + The class keeps the migration-specific lookup and remap logic locally, while + relying on BrownFieldHelper for common behavior such as fabric-site mapping, + config validation helpers, response transformation, header generation, file + writing, and yaml_config_generator orchestration. + """ + + values_to_nullify = ["NOT CONFIGURED"] + + def __init__(self, module): + """ + Initialize the migration playbook config generator. + + Args: + module (AnsibleModule): Module instance containing user parameters, + result handling, check mode state, and Catalyst Center connection + options. + + Returns: + None. The method initializes instance attributes used by the gathered + workflow. + + Initialization Details: + - Declares the supported state as gathered. + - Builds the component schema used by BrownFieldHelper. + - Retrieves fabric site name to ID mappings once for the run. + - Initializes merged output storage keyed by destination device and + fabric site. + """ + self.supported_states = ["gathered"] + super().__init__(module) + self.module_schema = self.get_workflow_filters_schema() + ( + self.fabric_site_name_to_id_mapping, + self.fabric_site_id_to_name_mapping, + ) = self.get_fabric_site_and_zone_name_to_id_mapping() + self.module_name = "sda_host_port_migration_workflow_manager" + self.migration_warnings = [] + self._migration_output_by_key = OrderedDict() + + def get_workflow_filters_schema(self): + """ + Build the BrownFieldHelper component schema for migration generation. + + This schema defines the migration components supported by the generator + and tells yaml_config_generator which retrieval method should handle + each component. Each component entry contains the input filters accepted + from config.component_specific_filters, the Catalyst Center SDK API used + to read source data, and the local method that transforms source data + into destination onboarding payload. + + Returns: + dict: Schema with a network_elements section containing: + - port_assignments: Source device port assignment migration. + Required filters are fabric_site_name_hierarchy, + source_device_ip, and destination_device_ip. Optional + interface_mappings remap selected assignment interfaces. + - port_channels: Source device port channel migration. Required + filters match port_assignments. Optional interface_mappings + remap selected member interfaces. + + Workflow Integration: + BrownFieldHelper.yaml_config_generator iterates the supported + component schema and invokes each component's get_function_name with + the component schema and its component-specific filters. The + reverse_mapping_function entries mirror the onboarding generator and + are reused by the migration lookup logic before interface remapping. + """ + return { + "network_elements": { + "port_assignments": { + "filters": { + "fabric_site_name_hierarchy": {"type": "str", "required": True}, + "source_device_ip": {"type": "str", "required": True}, + "destination_device_ip": {"type": "str", "required": True}, + "interface_mappings": { + "type": "list", + "elements": "dict", + "required": False, + }, + }, + "reverse_mapping_function": self.port_assignments_temp_spec, + "api_function": "get_port_assignments", + "api_family": "sda", + "get_function_name": self.get_port_assignments_configuration, + }, + "port_channels": { + "filters": { + "fabric_site_name_hierarchy": {"type": "str", "required": True}, + "source_device_ip": {"type": "str", "required": True}, + "destination_device_ip": {"type": "str", "required": True}, + "interface_mappings": { + "type": "list", + "elements": "dict", + "required": False, + }, + }, + "reverse_mapping_function": self.port_channels_temp_spec, + "api_function": "get_port_channels", + "api_family": "sda", + "get_function_name": self.get_port_channels_configuration, + }, + } + } + + def validate_input(self): + """ + Validate module input and normalize it into BrownFieldHelper format. + + The generator accepts config.component_specific_filters input with + port_assignments, port_channels, or both. Requested components are + inferred from the component filter keys present under + component_specific_filters. + + Returns: + self: Current instance with operation status updated. On success, + self.validated_config is populated with normalized + component_specific_filters. On validation failure, self.status, + self.msg, and self.result are updated with failure details. + + Validation Behavior: + - Ensures component_specific_filters exists for the preferred input. + - Uses BrownFieldHelper validation helpers for top-level config + structure and invalid key detection. + - Validates that each provided component filter key is supported by + this module schema. + - Normalizes all requested migration entries into OrderedDict values + for stable YAML output. + """ + config = self.config or {} + + if not config: + self.msg = ( + "Validation Error: provide 'config.component_specific_filters' " + "with port_assignments, port_channels, or both." + ) + self.set_operation_result("failed", False, self.msg, "ERROR") + return self + + temp_spec = { + "component_specific_filters": { + "type": "dict", + "required": True, + } + } + valid_temp = self.validate_config_dict(config, temp_spec) + self.validate_invalid_params(config, temp_spec.keys()) + + component_specific_filters = valid_temp.get("component_specific_filters") + if not component_specific_filters: + self.msg = ( + "Validation Error: 'component_specific_filters' is required." + ) + self.set_operation_result("failed", False, self.msg, "ERROR") + return self + + valid_components = list(self.module_schema.get("network_elements", {}).keys()) + invalid_components = [ + component + for component in component_specific_filters + if component not in valid_components + ] + if invalid_components: + self.msg = ( + "Validation Error: invalid component filter keys: {0}. " + "Valid components are: {1}." + ).format(invalid_components, sorted(valid_components)) + self.set_operation_result("failed", False, self.msg, "ERROR") + return self + + requested_components = [ + component + for component in valid_components + if component in component_specific_filters + ] + if not requested_components: + self.msg = ( + "Validation Error: provide at least one component filter under " + "'component_specific_filters'. Valid components are: {0}." + ).format(sorted(valid_components)) + self.set_operation_result("failed", False, self.msg, "ERROR") + return self + + normalized_component_filters = {} + validation_errors = [] + for component_name in requested_components: + component_entries = component_specific_filters.get(component_name, []) + component_errors, normalized_entries = self._normalize_migration_entries( + component_entries, component_name + ) + validation_errors.extend(component_errors) + normalized_component_filters[component_name] = normalized_entries + + if validation_errors: + self.msg = "Validation Error: {0}".format("; ".join(validation_errors)) + self.set_operation_result("failed", False, self.msg, "ERROR") + return self + + self.validated_config = { + "component_specific_filters": normalized_component_filters + } + self.msg = "Successfully validated SDA host port migration input." + self.set_operation_result("success", False, self.msg, "INFO") + return self + + def get_fabric_site_and_zone_name_to_id_mapping(self): + """ + Build a bidirectional mapping for fabric sites and fabric zones. + + The shared helper maps fabric sites from the sda.get_fabric_sites API. + Host port onboarding configurations can also be attached to fabric zones, + so this module augments the mapping with sda.get_fabric_zones results. + + Returns: + tuple: (fabric_name_to_id_mapping, fabric_id_to_name_mapping) + """ + fabric_site_name_to_id_mapping, fabric_site_id_to_name_mapping = ( + self.get_fabric_site_name_to_id_mapping() + ) + + self.log( + "Retrieving fabric zones to include zone-level host port onboarding " + "configurations under fabric site filters.", + "DEBUG", + ) + + try: + fabric_zones = self.execute_get_with_pagination( + api_family="sda", + api_function="get_fabric_zones", + params={}, + ) + except Exception as e: + self.log( + "Unable to retrieve fabric zones. Continuing with fabric site " + "mapping only. Error: {0}".format(e), + "WARNING", + ) + return fabric_site_name_to_id_mapping, fabric_site_id_to_name_mapping + + if not fabric_zones: + self.log( + "No fabric zones found. Fabric site mapping will be used as-is.", + "INFO", + ) + return fabric_site_name_to_id_mapping, fabric_site_id_to_name_mapping + + zone_site_ids = [ + fabric_zone.get("siteId") + for fabric_zone in fabric_zones + if fabric_zone.get("siteId") + ] + site_id_name_mapping = self.get_site_id_name_mapping(zone_site_ids) + + for fabric_zone in fabric_zones: + fabric_zone_id = fabric_zone.get("id") + site_id = fabric_zone.get("siteId") + + if not fabric_zone_id or not site_id: + self.log( + "Skipping fabric zone with missing IDs - fabric_zone_id: " + "{0}, site_id: {1}".format(fabric_zone_id, site_id), + "WARNING", + ) + continue + + site_name = site_id_name_mapping.get(site_id) + if not site_name: + self.log( + "Skipping fabric zone '{0}' because site hierarchy was not " + "found for site ID '{1}'.".format(fabric_zone_id, site_id), + "WARNING", + ) + continue + + existing_fabric_id = fabric_site_name_to_id_mapping.get(site_name) + if existing_fabric_id and existing_fabric_id != fabric_zone_id: + self.log( + "Fabric hierarchy '{0}' is already mapped to fabric ID '{1}'. " + "Keeping existing mapping and skipping zone ID '{2}'.".format( + site_name, existing_fabric_id, fabric_zone_id + ), + "WARNING", + ) + continue + + fabric_site_name_to_id_mapping[site_name] = fabric_zone_id + fabric_site_id_to_name_mapping[fabric_zone_id] = site_name + self.log( + "Mapped fabric zone hierarchy '{0}' to fabric zone ID '{1}'.".format( + site_name, fabric_zone_id + ), + "DEBUG", + ) + + self.log( + "Fabric site and zone bidirectional mapping completed. Total fabric " + "hierarchies mapped: {0}".format(len(fabric_site_name_to_id_mapping)), + "INFO", + ) + return fabric_site_name_to_id_mapping, fabric_site_id_to_name_mapping + + def _normalize_migration_entries(self, migration_entries, component_name): + """ + Validate and normalize migration entries for a single component. + + Args: + migration_entries (list): User-provided migration entries for one + component. Each entry identifies the source fabric site, source + device IP, destination device IP, and optional interface mappings. + component_name (str): Component being normalized. Expected values are + port_assignments or port_channels. + Returns: + tuple: A two-item tuple: + - validation_errors (list): Human-readable validation errors. + - normalized_entries (list): OrderedDict entries ready for + BrownFieldHelper component processing. + + Validation Behavior: + - Requires migration_entries to be a non-empty list. + - Requires fabric_site_name_hierarchy, source_device_ip, and + destination_device_ip to be non-empty strings. + - Rejects entries where source and destination IPs are identical. + - Validates interface_mappings as a list of dictionaries with + source_interface_name and destination_interface_name. + - Rejects duplicate source or destination interface names within one + mapping list to avoid ambiguous remaps. + """ + validation_errors = [] + normalized_entries = [] + + if not isinstance(migration_entries, list): + return [ + "component '{0}' must be a list of migration entries".format( + component_name + ) + ], normalized_entries + + if not migration_entries: + return [ + "component '{0}' must contain at least one migration entry".format( + component_name + ) + ], normalized_entries + + for index, entry in enumerate(migration_entries, start=1): + if not isinstance(entry, dict): + validation_errors.append( + "component '{0}' entry {1}: expected dict, got {2}".format( + component_name, index, type(entry).__name__ + ) + ) + continue + + fabric_site = entry.get("fabric_site_name_hierarchy") + source_device_ip = entry.get("source_device_ip") + destination_device_ip = entry.get("destination_device_ip") + interface_mappings = entry.get("interface_mappings") or [] + + for key, value in ( + ("fabric_site_name_hierarchy", fabric_site), + ("source_device_ip", source_device_ip), + ("destination_device_ip", destination_device_ip), + ): + if not value or not isinstance(value, str): + validation_errors.append( + "component '{0}' entry {1}: '{2}' is required and must be " + "a non-empty string".format( + component_name, index, key + ) + ) + + if source_device_ip and destination_device_ip and source_device_ip == destination_device_ip: + validation_errors.append( + "component '{0}' entry {1}: source_device_ip and " + "destination_device_ip must be different".format( + component_name, index + ) + ) + + if not isinstance(interface_mappings, list): + validation_errors.append( + "component '{0}' entry {1}: 'interface_mappings' must be a " + "list when provided".format( + component_name, index + ) + ) + interface_mappings = [] + + normalized_mappings = [] + source_names = [] + destination_names = [] + for mapping_index, mapping in enumerate(interface_mappings, start=1): + if not isinstance(mapping, dict): + validation_errors.append( + "component '{0}' entry {1} mapping {2}: expected dict, " + "got {3}".format( + component_name, index, mapping_index, type(mapping).__name__ + ) + ) + continue + + source_interface_name = mapping.get("source_interface_name") + destination_interface_name = mapping.get("destination_interface_name") + if not source_interface_name or not isinstance(source_interface_name, str): + validation_errors.append( + "component '{0}' entry {1} mapping {2}: " + "'source_interface_name' is required and must be a " + "non-empty string".format(component_name, index, mapping_index) + ) + if not destination_interface_name or not isinstance(destination_interface_name, str): + validation_errors.append( + "component '{0}' entry {1} mapping {2}: " + "'destination_interface_name' is required and must be a " + "non-empty string".format(component_name, index, mapping_index) + ) + + if source_interface_name and destination_interface_name: + source_names.append(source_interface_name) + destination_names.append(destination_interface_name) + normalized_mappings.append( + OrderedDict( + [ + ("source_interface_name", source_interface_name), + ("destination_interface_name", destination_interface_name), + ] + ) + ) + + duplicate_sources = sorted( + name for name in set(source_names) if source_names.count(name) > 1 + ) + duplicate_destinations = sorted( + name for name in set(destination_names) if destination_names.count(name) > 1 + ) + if duplicate_sources: + validation_errors.append( + "component '{0}' entry {1}: duplicate source_interface_name " + "values in interface_mappings: {2}".format( + component_name, index, duplicate_sources + ) + ) + if duplicate_destinations: + validation_errors.append( + "component '{0}' entry {1}: duplicate destination_interface_name " + "values in interface_mappings: {2}".format( + component_name, index, duplicate_destinations + ) + ) + + normalized_entry = OrderedDict( + [ + ("fabric_site_name_hierarchy", fabric_site), + ("source_device_ip", source_device_ip), + ("destination_device_ip", destination_device_ip), + ] + ) + if normalized_mappings: + normalized_entry["interface_mappings"] = normalized_mappings + normalized_entries.append(normalized_entry) + + return validation_errors, normalized_entries + + def port_assignments_temp_spec(self): + """ + Build the reverse mapping specification for port assignment API data. + + The returned specification is consumed by BrownFieldHelper.modify_parameters + to transform Catalyst Center API response keys from camelCase into the + snake_case keys expected by sda_host_port_onboarding_workflow_manager. + + Returns: + OrderedDict: Mapping specification for port assignment fields such as + interface_name, connected_device_type, VLAN fields, security group, + authentication template, interface description, native VLAN, and + allowed VLAN ranges. + """ + return OrderedDict( + { + "interface_name": {"type": "str", "source_key": "interfaceName"}, + "connected_device_type": {"type": "str", "source_key": "connectedDeviceType"}, + "data_vlan_name": {"type": "str", "source_key": "dataVlanName"}, + "voice_vlan_name": {"type": "str", "source_key": "voiceVlanName"}, + "security_group_name": {"type": "str", "source_key": "securityGroupName"}, + "authentication_template_name": {"type": "str", "source_key": "authenticateTemplateName"}, + "interface_description": {"type": "str", "source_key": "interfaceDescription"}, + "native_vlan_id": {"type": "int", "source_key": "nativeVlanId"}, + "allowed_vlan_ranges": {"type": "str", "source_key": "allowedVlanRanges"}, + } + ) + + def port_channels_temp_spec(self): + """ + Build the reverse mapping specification for port channel API data. + + The returned specification is consumed by BrownFieldHelper.modify_parameters + to transform Catalyst Center API response keys from camelCase into the + snake_case keys expected by sda_host_port_onboarding_workflow_manager. + + Returns: + OrderedDict: Mapping specification for port channel fields including + interface_names, connected_device_type, protocol, description, + native VLAN, and allowed VLAN ranges. + """ + return OrderedDict( + { + "interface_names": { + "type": "list", + "elements": "str", + "source_key": "interfaceNames", + }, + "connected_device_type": {"type": "str", "source_key": "connectedDeviceType"}, + "protocol": {"type": "str", "source_key": "protocol"}, + "port_channel_description": {"type": "str", "source_key": "description"}, + "native_vlan_id": {"type": "int", "source_key": "nativeVlanId"}, + "allowed_vlan_ranges": {"type": "str", "source_key": "allowedVlanRanges"}, + } + ) + + def get_fabric_site_names_and_migration_details_mapping(self, component_specific_filters): + """ + Extract fabric site names and migration entries from component filters. + + This follows the same purpose as the onboarding generator's + get_fabric_site_names_and_device_details_mapping() helper, but the + per-site data is the migration entry list instead of device IP, serial + number, and hostname filter sets. + + Args: + component_specific_filters (list[dict]): Migration filters for one + component. Each entry contains fabric_site_name_hierarchy, + source_device_ip, destination_device_ip, and optional + interface_mappings. + + Returns: + tuple: A two-item tuple containing: + - fabric_site_name_hierarchies (list): Fabric site names in the + same order provided by the user. + - fabric_site_name_migration_mapping (dict): Mapping of fabric + site name to all migration entries for that site. + """ + self.log( + "Extracting fabric site name hierarchies and migration details from " + "component-specific filters for targeted migration extraction.", + "DEBUG", + ) + fabric_site_name_hierarchies = [] + fabric_site_name_migration_mapping = OrderedDict() + + for filter_index, filter_item in enumerate(component_specific_filters, start=1): + fabric_site_name = filter_item.get("fabric_site_name_hierarchy") + self.log( + "Processing migration filter {0}/{1}: " + "fabric_site_name_hierarchy='{2}', source_device_ip='{3}', " + "destination_device_ip='{4}'.".format( + filter_index, + len(component_specific_filters), + fabric_site_name, + filter_item.get("source_device_ip"), + filter_item.get("destination_device_ip"), + ), + "DEBUG", + ) + fabric_site_name_hierarchies.append(fabric_site_name) + fabric_site_name_migration_mapping.setdefault(fabric_site_name, []).append( + filter_item + ) + + self.log( + "Completed extraction of {0} fabric site migration filter(s).".format( + len(fabric_site_name_hierarchies) + ), + "DEBUG", + ) + return fabric_site_name_hierarchies, fabric_site_name_migration_mapping + + def build_destination_port_assignments(self, migration_entry, source_entry): + """ + Build destination port assignments by applying optional interface remaps. + + Args: + migration_entry (dict): Normalized migration request containing + destination_device_ip and optional interface_mappings. + source_entry (dict): Transformed source payload containing + port_assignments from the source device. + + Returns: + list: Destination port assignment dictionaries. All source + assignments are retained. Interfaces listed in interface_mappings are + renamed when the source interface exists; unmapped interfaces keep + their original source name for 1:1 migration. + + Raises: + SystemExit: Via fail_and_exit when remapping produces duplicate + destination assignment interfaces. + + Notes: + Mappings that reference interfaces not present on the source device + are ignored and logged as warnings. + """ + source_assignments = source_entry.get("port_assignments", []) + source_interface_names = [ + assignment.get("interface_name") + for assignment in source_assignments + if assignment.get("interface_name") + ] + source_interface_name_set = set(source_interface_names) + mapping_lookup = {} + + for mapping in migration_entry.get("interface_mappings", []): + source_interface_name = mapping.get("source_interface_name") + destination_interface_name = mapping.get("destination_interface_name") + if source_interface_name in source_interface_name_set: + mapping_lookup[source_interface_name] = destination_interface_name + else: + warning = ( + "Mapping for source interface '{0}' was skipped because it does not " + "exist in source device '{1}' port assignments.".format( + source_interface_name, migration_entry.get("source_device_ip") + ) + ) + self.log(warning, "WARNING") + self.migration_warnings.append(warning) + + destination_assignments = [] + for assignment in source_assignments: + destination_assignment = OrderedDict(assignment) + interface_name = destination_assignment.get("interface_name") + if interface_name in mapping_lookup: + destination_assignment["interface_name"] = mapping_lookup[interface_name] + destination_assignments.append(destination_assignment) + + destination_interface_names = [ + assignment.get("interface_name") + for assignment in destination_assignments + if assignment.get("interface_name") + ] + duplicate_destination_interfaces = sorted( + name + for name in set(destination_interface_names) + if destination_interface_names.count(name) > 1 + ) + if duplicate_destination_interfaces: + self.msg = ( + "Validation Error: destination interfaces are duplicated after remap " + "for source '{0}' to destination '{1}': {2}".format( + migration_entry.get("source_device_ip"), + migration_entry.get("destination_device_ip"), + duplicate_destination_interfaces, + ) + ) + self.fail_and_exit(self.msg) + + return destination_assignments + + def build_destination_port_channels(self, migration_entry, source_entry): + """ + Build destination port channels by applying optional member interface remaps. + + Args: + migration_entry (dict): Normalized migration request containing + destination_device_ip and optional interface_mappings. + source_entry (dict): Transformed source payload containing + port_channels from the source device. + + Returns: + list: Destination port channel dictionaries. All source port channels + are retained. Member interfaces listed in interface_mappings are + renamed when the source member interface exists; unmapped members + keep their original source name for 1:1 migration. + + Raises: + SystemExit: Via fail_and_exit when remapping produces duplicate + destination port channel member interfaces. + + Notes: + This method remaps the interface_names list only. Other port channel + attributes, such as protocol and connected device type, are preserved + from the source payload. + """ + source_channels = source_entry.get("port_channels", []) + source_interface_names = [ + interface_name + for channel in source_channels + for interface_name in channel.get("interface_names", []) + if interface_name + ] + source_interface_name_set = set(source_interface_names) + mapping_lookup = {} + + for mapping in migration_entry.get("interface_mappings", []): + source_interface_name = mapping.get("source_interface_name") + destination_interface_name = mapping.get("destination_interface_name") + if source_interface_name in source_interface_name_set: + mapping_lookup[source_interface_name] = destination_interface_name + else: + warning = ( + "Mapping for source interface '{0}' was skipped because it does " + "not exist in source device '{1}' port channel member " + "interfaces.".format( + source_interface_name, migration_entry.get("source_device_ip") + ) + ) + self.log(warning, "WARNING") + self.migration_warnings.append(warning) + + destination_channels = [] + for channel in source_channels: + destination_channel = OrderedDict(channel) + destination_channel["interface_names"] = [ + mapping_lookup.get(interface_name, interface_name) + for interface_name in channel.get("interface_names", []) + ] + destination_channels.append(destination_channel) + + destination_interface_names = [ + interface_name + for channel in destination_channels + for interface_name in channel.get("interface_names", []) + if interface_name + ] + duplicate_destination_interfaces = sorted( + name + for name in set(destination_interface_names) + if destination_interface_names.count(name) > 1 + ) + if duplicate_destination_interfaces: + self.msg = ( + "Validation Error: destination port channel interfaces are " + "duplicated after remap for source '{0}' to destination '{1}': " + "{2}".format( + migration_entry.get("source_device_ip"), + migration_entry.get("destination_device_ip"), + duplicate_destination_interfaces, + ) + ) + self.fail_and_exit(self.msg) + + return destination_channels + + def _get_output_key(self, migration_entry, fabric_site_name): + """ + Build the merged output key for destination device and fabric site. + + Args: + migration_entry (dict): Normalized migration request containing + destination_device_ip and fabric_site_name_hierarchy. + fabric_site_name (str): Resolved source fabric site name. + + Returns: + tuple: Key in the form (destination_device_ip, fabric_site_name). + + Notes: + The key allows port assignments and port channels requested in + separate component sections to merge into one onboarding config block + for the same destination device and fabric site. + """ + return ( + migration_entry.get("destination_device_ip"), + fabric_site_name or migration_entry.get("fabric_site_name_hierarchy"), + ) + + def _merge_destination_component_entry( + self, component_name, migration_entry, source_entry, destination_payload + ): + """ + Merge component payload into the destination onboarding config block. + + Args: + component_name (str): Component being merged, such as + port_assignments or port_channels. + migration_entry (dict): Normalized migration request containing the + destination device IP. + source_entry (dict): Transformed source payload containing the + resolved fabric site name. + destination_payload (list): Destination component entries generated + from the source component. + + Returns: + None. The method mutates self._migration_output_by_key. + + Merge Behavior: + - Creates a new destination config block when the destination/fabric + key is first seen. + - Appends component data when another component targets the same + destination/fabric key. + - Preserves insertion order so generated YAML remains stable. + """ + fabric_site_name = ( + source_entry.get("fabric_site_name_hierarchy") + or migration_entry.get("fabric_site_name_hierarchy") + ) + output_key = self._get_output_key(migration_entry, fabric_site_name) + + if output_key not in self._migration_output_by_key: + self._migration_output_by_key[output_key] = OrderedDict( + [ + ("ip_address", migration_entry.get("destination_device_ip")), + ("fabric_site_name_hierarchy", fabric_site_name), + ] + ) + + self._migration_output_by_key[output_key].setdefault(component_name, []) + self._migration_output_by_key[output_key][component_name].extend( + destination_payload + ) + + def _is_final_schema_component(self, component_name): + """ + Determine whether the current schema component should return merged output. + + Args: + component_name (str): Component currently being processed by + BrownFieldHelper.yaml_config_generator. + + Returns: + bool: True when component_name is the final component in this + module's schema order; otherwise False. + + Notes: + BrownFieldHelper appends each component function's return value to + the final YAML list. This generator accumulates component data across + component calls, so only the final schema component returns the + merged destination payload. + """ + schema_components = list(self.module_schema.get("network_elements", {}).keys()) + return bool(schema_components) and component_name == schema_components[-1] + + def _validate_merged_output_interfaces(self): + """ + Ensure merged assignment and port-channel interfaces do not conflict. + + Returns: + None. The method validates self._migration_output_by_key in place. + + Raises: + SystemExit: Via fail_and_exit when a destination interface appears + both as a port assignment interface_name and as a port channel member + interface_names value for the same destination device and fabric site. + + Notes: + This validation catches remap combinations that would create an + invalid onboarding payload by assigning the same destination interface + to two different host-port constructs. + """ + for output_key, entry in self._migration_output_by_key.items(): + assignment_interfaces = [ + item.get("interface_name") + for item in entry.get("port_assignments", []) + if item.get("interface_name") + ] + channel_interfaces = [ + interface_name + for channel in entry.get("port_channels", []) + for interface_name in channel.get("interface_names", []) + if interface_name + ] + duplicate_interfaces = sorted( + set(assignment_interfaces).intersection(channel_interfaces) + ) + if duplicate_interfaces: + self.fail_and_exit( + "Validation Error: destination interfaces are present in both " + "port_assignments and port_channels for destination '{0}' in " + "fabric site '{1}': {2}".format( + output_key[0], output_key[1], duplicate_interfaces + ) + ) + + def _return_merged_migration_output_if_final_component(self, component_name): + """ + Return merged migration output only after the final schema component. + + Args: + component_name (str): Component currently being processed by + yaml_config_generator. + + Returns: + list: Empty list for intermediate components, or the final merged + destination payload after the last schema component has run. + """ + if self.migration_warnings: + self.log( + "Migration config generation completed with warnings: {0}".format( + self.migration_warnings + ), + "WARNING", + ) + + if not self._is_final_schema_component(component_name): + return [] + + self._validate_merged_output_interfaces() + return list(self._migration_output_by_key.values()) + + def get_port_assignments_configuration(self, network_element, filters): + """ + Retrieve and transform port assignment migration entries. + + The method mirrors the onboarding generator's port assignment retrieval + workflow: read all port assignments, resolve requested fabric sites, + group records by fabric and network device, transform API fields with + modify_parameters(), resolve device management IP addresses, and build + final YAML payload blocks. The migration differences are that only + records from source_device_ip are selected, ip_address is populated from + destination_device_ip, and optional interface_mappings are applied. + + Args: + network_element (dict): Network element configuration containing + api_family, api_function, reverse_mapping_function, and filters. + filters (dict): Filter wrapper containing component_specific_filters + for port assignment migration entries. + + Returns: + list: Empty list until the final schema component is processed, then + the merged destination onboarding payload. + """ + self.log( + "Starting port assignments configuration retrieval and transformation " + "workflow for migration.", + "DEBUG", + ) + + component_specific_filters = filters.get("component_specific_filters") + if not component_specific_filters: + self.log( + "No component_specific_filters provided for port assignments. " + "Skipping port assignment migration processing.", + "DEBUG", + ) + return self._return_merged_migration_output_if_final_component( + "port_assignments" + ) + + api_family = network_element.get("api_family") + api_function = network_element.get("api_function") + self.log( + "API configuration extracted - family: {0}, function: {1}. " + "Executing API call to retrieve all port assignments from Catalyst " + "Center.".format(api_family, api_function), + "DEBUG", + ) + + try: + response = self.catalystcenter._exec( + family=api_family, + function=api_function, + op_modifies=False, + ) + except Exception as e: + self.log( + "Failed to retrieve port assignments using {0}.{1}: {2}".format( + api_family, api_function, e + ), + "ERROR", + ) + raise RuntimeError( + "Port assignments API call failed for {0}.{1}: {2}".format( + api_family, api_function, e + ) + ) from e + + all_port_assignments = response.get("response", []) + self.log( + "Port assignments API call completed successfully. Retrieved {0} " + "port assignment(s) from Catalyst Center.".format( + len(all_port_assignments) + ), + "INFO", + ) + + fabric_ids = [] + ( + fabric_site_name_hierarchies, + fabric_site_name_migration_mapping, + ) = self.get_fabric_site_names_and_migration_details_mapping( + component_specific_filters + ) + + for hierarchy_index, fabric_site_name_hierarchy in enumerate( + fabric_site_name_hierarchies, start=1 + ): + self.log( + "Resolving fabric site name hierarchy {0}/{1}: '{2}' for port " + "assignments.".format( + hierarchy_index, + len(fabric_site_name_hierarchies), + fabric_site_name_hierarchy, + ), + "DEBUG", + ) + fabric_id = self.fabric_site_name_to_id_mapping.get( + fabric_site_name_hierarchy + ) + if not fabric_id: + warning = ( + "Fabric site name '{0}' was not found in cached mapping. " + "Skipping this fabric site for port assignments.".format( + fabric_site_name_hierarchy + ) + ) + self.log(warning, "WARNING") + self.migration_warnings.append(warning) + continue + fabric_ids.append(fabric_id) + + self.log( + "Fabric site ID resolution completed. Will process {0} fabric " + "site(s): {1}.".format(len(fabric_ids), fabric_ids), + "INFO", + ) + + fabric_ids_set = set(fabric_ids) + fabric_port_assignments_dict = {} + for port_assignment_index, port_assignment in enumerate( + all_port_assignments, start=1 + ): + fabric_id = port_assignment.get("fabricId") + self.log( + "Processing port assignment {0}/{1} with fabric ID '{2}'.".format( + port_assignment_index, len(all_port_assignments), fabric_id + ), + "DEBUG", + ) + if fabric_id in fabric_ids_set: + if fabric_id not in fabric_port_assignments_dict: + fabric_port_assignments_dict[fabric_id] = [] + fabric_port_assignments_dict[fabric_id].append(port_assignment) + + for fabric_index, (fabric_id, port_assignments) in enumerate( + fabric_port_assignments_dict.items(), start=1 + ): + self.log( + "Processing fabric site {0}/{1} with ID '{2}'. Contains {3} " + "port assignment(s).".format( + fabric_index, + len(fabric_port_assignments_dict), + fabric_id, + len(port_assignments), + ), + "DEBUG", + ) + port_assignments_temp_spec = self.port_assignments_temp_spec() + modified_port_assignments = self.modify_parameters( + port_assignments_temp_spec, port_assignments + ) + + device_port_assignments = {} + for idx, port_assignment in enumerate(port_assignments): + network_device_id = port_assignment.get("networkDeviceId") + if network_device_id not in device_port_assignments: + device_port_assignments[network_device_id] = [] + device_port_assignments[network_device_id].append( + modified_port_assignments[idx] + ) + + for device_index, (network_device_id, device_ports) in enumerate( + device_port_assignments.items(), start=1 + ): + self.log( + "Processing device {0}/{1} with ID '{2}'. Fetching device " + "details to resolve management IP address.".format( + device_index, len(device_port_assignments), network_device_id + ), + "DEBUG", + ) + try: + device_response = self.catalystcenter._exec( + family="devices", + function="get_device_by_id", + op_modifies=False, + params={"id": network_device_id}, + ) + except Exception as e: + self.log( + "Failed to resolve device details for device ID '{0}': " + "{1}".format(network_device_id, e), + "ERROR", + ) + raise RuntimeError( + "Device lookup failed for device ID '{0}': {1}".format( + network_device_id, e + ) + ) from e + + device_info = device_response.get("response", {}) + management_ip = device_info.get("managementIpAddress", "") + fabric_site_name = self.fabric_site_id_to_name_mapping.get(fabric_id) + migration_entries = fabric_site_name_migration_mapping.get( + fabric_site_name, [] + ) + matching_migration_entries = [ + entry + for entry in migration_entries + if entry.get("source_device_ip") == management_ip + ] + + if not matching_migration_entries: + self.log( + "Resolved management IP '{0}' for device ID '{1}' does not " + "match any source_device_ip filter for fabric site '{2}'.".format( + management_ip, network_device_id, fabric_site_name + ), + "DEBUG", + ) + continue + + source_entry = OrderedDict( + [ + ("ip_address", management_ip), + ("fabric_site_name_hierarchy", fabric_site_name), + ("port_assignments", device_ports), + ] + ) + + for migration_entry in matching_migration_entries: + destination_payload = self.build_destination_port_assignments( + migration_entry, source_entry + ) + if not destination_payload: + warning = ( + "No destination port_assignments generated for source " + "device '{0}'.".format( + migration_entry.get("source_device_ip") + ) + ) + self.log(warning, "WARNING") + self.migration_warnings.append(warning) + continue + + self._merge_destination_component_entry( + "port_assignments", + migration_entry, + source_entry, + destination_payload, + ) + + self.log( + "Port assignments migration retrieval completed successfully. " + "Merged destination configuration count: {0}.".format( + len(self._migration_output_by_key) + ), + "INFO", + ) + return self._return_merged_migration_output_if_final_component( + "port_assignments" + ) + + def get_port_channels_configuration(self, network_element, filters): + """ + Retrieve and transform port channel migration entries. + + The method mirrors the onboarding generator's port channel retrieval + workflow: read all port channels, resolve requested fabric sites, group + records by fabric and network device, transform API fields with + modify_parameters(), resolve device management IP addresses, and build + final YAML payload blocks. The migration differences are that only + records from source_device_ip are selected, ip_address is populated from + destination_device_ip, and optional member interface_mappings are applied. + + Args: + network_element (dict): Network element configuration containing + api_family, api_function, reverse_mapping_function, and filters. + filters (dict): Filter wrapper containing component_specific_filters + for port channel migration entries. + + Returns: + list: Empty list until the final schema component is processed, then + the merged destination onboarding payload. + """ + self.log( + "Starting port channels configuration retrieval and transformation " + "workflow for migration.", + "DEBUG", + ) + + component_specific_filters = filters.get("component_specific_filters") + if not component_specific_filters: + self.log( + "No component_specific_filters provided for port channels. " + "Skipping port channel migration processing.", + "DEBUG", + ) + return self._return_merged_migration_output_if_final_component( + "port_channels" + ) + + api_family = network_element.get("api_family") + api_function = network_element.get("api_function") + self.log( + "API configuration extracted - family: {0}, function: {1}. " + "Executing API call to retrieve all port channels from Catalyst " + "Center.".format(api_family, api_function), + "DEBUG", + ) + + try: + response = self.catalystcenter._exec( + family=api_family, + function=api_function, + op_modifies=False, + ) + except Exception as e: + self.log( + "Failed to retrieve port channels using {0}.{1}: {2}".format( + api_family, api_function, e + ), + "ERROR", + ) + raise RuntimeError( + "Port channels API call failed for {0}.{1}: {2}".format( + api_family, api_function, e + ) + ) from e + + all_port_channels = response.get("response", []) + self.log( + "Port channels API call completed successfully. Retrieved {0} " + "port channel(s) from Catalyst Center.".format(len(all_port_channels)), + "INFO", + ) + + fabric_ids = [] + ( + fabric_site_name_hierarchies, + fabric_site_name_migration_mapping, + ) = self.get_fabric_site_names_and_migration_details_mapping( + component_specific_filters + ) + + for hierarchy_index, fabric_site_name_hierarchy in enumerate( + fabric_site_name_hierarchies, start=1 + ): + self.log( + "Resolving fabric site name hierarchy {0}/{1}: '{2}' for port " + "channels.".format( + hierarchy_index, + len(fabric_site_name_hierarchies), + fabric_site_name_hierarchy, + ), + "DEBUG", + ) + fabric_id = self.fabric_site_name_to_id_mapping.get( + fabric_site_name_hierarchy + ) + if not fabric_id: + warning = ( + "Fabric site name '{0}' was not found in cached mapping. " + "Skipping this fabric site for port channels.".format( + fabric_site_name_hierarchy + ) + ) + self.log(warning, "WARNING") + self.migration_warnings.append(warning) + continue + fabric_ids.append(fabric_id) + + self.log( + "Fabric site ID resolution completed. Will process {0} fabric " + "site(s): {1}.".format(len(fabric_ids), fabric_ids), + "INFO", + ) + + fabric_ids_set = set(fabric_ids) + fabric_port_channels_dict = {} + for port_channel_index, port_channel in enumerate(all_port_channels, start=1): + fabric_id = port_channel.get("fabricId") + self.log( + "Processing port channel {0}/{1} with fabric ID '{2}'.".format( + port_channel_index, len(all_port_channels), fabric_id + ), + "DEBUG", + ) + if fabric_id in fabric_ids_set: + if fabric_id not in fabric_port_channels_dict: + fabric_port_channels_dict[fabric_id] = [] + fabric_port_channels_dict[fabric_id].append(port_channel) + + for fabric_index, (fabric_id, port_channels) in enumerate( + fabric_port_channels_dict.items(), start=1 + ): + self.log( + "Processing fabric site {0}/{1} with ID '{2}'. Contains {3} " + "port channel(s).".format( + fabric_index, + len(fabric_port_channels_dict), + fabric_id, + len(port_channels), + ), + "DEBUG", + ) + port_channels_temp_spec = self.port_channels_temp_spec() + modified_port_channels = self.modify_parameters( + port_channels_temp_spec, port_channels + ) + + device_port_channels = {} + for idx, port_channel in enumerate(port_channels): + network_device_id = port_channel.get("networkDeviceId") + if network_device_id not in device_port_channels: + device_port_channels[network_device_id] = [] + device_port_channels[network_device_id].append( + modified_port_channels[idx] + ) + + for device_index, (network_device_id, device_port_channels_list) in enumerate( + device_port_channels.items(), start=1 + ): + self.log( + "Processing device {0}/{1} with ID '{2}'. Fetching device " + "details to resolve management IP address.".format( + device_index, len(device_port_channels), network_device_id + ), + "DEBUG", + ) + try: + device_response = self.catalystcenter._exec( + family="devices", + function="get_device_by_id", + op_modifies=False, + params={"id": network_device_id}, + ) + except Exception as e: + self.log( + "Failed to resolve device details for device ID '{0}': " + "{1}".format(network_device_id, e), + "ERROR", + ) + raise RuntimeError( + "Device lookup failed for device ID '{0}': {1}".format( + network_device_id, e + ) + ) from e + + device_info = device_response.get("response", {}) + management_ip = device_info.get("managementIpAddress", "") + fabric_site_name = self.fabric_site_id_to_name_mapping.get(fabric_id) + migration_entries = fabric_site_name_migration_mapping.get( + fabric_site_name, [] + ) + matching_migration_entries = [ + entry + for entry in migration_entries + if entry.get("source_device_ip") == management_ip + ] + + if not matching_migration_entries: + self.log( + "Resolved management IP '{0}' for device ID '{1}' does not " + "match any source_device_ip filter for fabric site '{2}'.".format( + management_ip, network_device_id, fabric_site_name + ), + "DEBUG", + ) + continue + + source_entry = OrderedDict( + [ + ("ip_address", management_ip), + ("fabric_site_name_hierarchy", fabric_site_name), + ("port_channels", device_port_channels_list), + ] + ) + + for migration_entry in matching_migration_entries: + destination_payload = self.build_destination_port_channels( + migration_entry, source_entry + ) + if not destination_payload: + warning = ( + "No destination port_channels generated for source " + "device '{0}'.".format( + migration_entry.get("source_device_ip") + ) + ) + self.log(warning, "WARNING") + self.migration_warnings.append(warning) + continue + + self._merge_destination_component_entry( + "port_channels", + migration_entry, + source_entry, + destination_payload, + ) + + self.log( + "Port channels migration retrieval completed successfully. " + "Merged destination configuration count: {0}.".format( + len(self._migration_output_by_key) + ), + "INFO", + ) + return self._return_merged_migration_output_if_final_component( + "port_channels" + ) + + def get_diff_gathered(self): + """ + Execute YAML migration config generation for the gathered state. + + This method delegates final YAML orchestration to + BrownFieldHelper.yaml_config_generator. The helper processes + component_specific_filters, invokes the component migration getters, and + writes the resulting config list to the requested file path. + + Returns: + self: Current instance with result status updated by + yaml_config_generator. + + Workflow Behavior: + Uses the same workflow operation loop as the onboarding generator. + When yaml_config_generator parameters are present, the helper writes + the generated YAML file. If the operation parameters are absent, the + operation is skipped and logged. + """ + start_time = time.time() + self.log("Starting 'get_diff_gathered' operation.", "DEBUG") + + workflow_operations = [ + ( + "yaml_config_generator", + "YAML Config Generator", + self.yaml_config_generator, + ) + ] + + operations_executed = 0 + operations_skipped = 0 + + self.log("Beginning iteration over defined workflow operations for processing.", "DEBUG") + for index, (param_key, operation_name, operation_func) in enumerate( + workflow_operations, start=1 + ): + self.log( + "Iteration {0}: Checking parameters for {1} operation with " + "param_key '{2}'.".format(index, operation_name, param_key), + "DEBUG", + ) + params = self.want.get(param_key) + if params: + self.log( + "Iteration {0}: Parameters found for {1}. Starting processing.".format( + index, operation_name + ), + "INFO", + ) + + try: + operation_func(params).check_return_status() + operations_executed += 1 + self.log( + "{0} operation completed successfully".format(operation_name), + "DEBUG", + ) + except Exception as e: + self.log( + "{0} operation failed with error: {1}".format( + operation_name, str(e) + ), + "ERROR", + ) + self.set_operation_result( + "failed", + True, + "{0} operation failed: {1}".format(operation_name, str(e)), + "ERROR", + ).check_return_status() + else: + operations_skipped += 1 + self.log( + "Iteration {0}: No parameters found for {1}. Skipping operation.".format( + index, operation_name + ), + "WARNING", + ) + + end_time = time.time() + self.log( + "Completed 'get_diff_gathered' operation in {0:.2f} seconds.".format( + end_time - start_time + ), + "DEBUG", + ) + return self + + +def main(): + """ + Main entry point for SDA host port migration playbook config generation. + + This function defines the Ansible argument specification, initializes the + migration generator, validates Catalyst Center version support, validates the + requested state, normalizes user input, and invokes the gathered workflow. + + Workflow: + 1. Build the AnsibleModule argument_spec for Catalyst Center connection + settings, output file controls, and config input. + 2. Initialize SdaHostPortMigrationPlaybookConfigGenerator. + 3. Enforce the minimum supported Catalyst Center version. + 4. Validate that the requested state is supported. + 5. Validate and normalize module input. + 6. Store normalized config in self.want using BrownFieldHelper.get_want. + 7. Execute the gathered workflow and return module results. + + Returns: + None. The function exits through module.exit_json on success or through + check_return_status/fail_json on validation or execution failure. + """ + module_start_time = time.time() + element_spec = { + "catalystcenter_host": {"required": True, "type": "str"}, + "catalystcenter_port": {"type": "str", "default": "443"}, + "catalystcenter_username": {"type": "str", "default": "admin"}, + "catalystcenter_password": {"type": "str", "no_log": True}, + "catalystcenter_verify": {"type": "bool", "default": True}, + "catalystcenter_version": {"type": "str", "default": "2.3.7.6"}, + "catalystcenter_api_task_timeout": {"type": "int", "default": 1200}, + "catalystcenter_task_poll_interval": {"type": "int", "default": 2}, + "validate_response_schema": {"type": "bool", "default": True}, + "catalystcenter_debug": {"type": "bool", "default": False}, + "catalystcenter_log_level": {"type": "str", "default": "WARNING"}, + "catalystcenter_log_file_path": {"type": "str", "default": "catalystcenter.log"}, + "catalystcenter_log_append": {"type": "bool", "default": True}, + "catalystcenter_log": {"type": "bool", "default": False}, + "config": {"required": True, "type": "dict"}, + "file_path": {"type": "str", "required": False}, + "file_mode": { + "type": "str", + "default": "overwrite", + "choices": ["overwrite", "append"], + }, + "state": {"type": "str", "default": "gathered", "choices": ["gathered"]}, + } + + module = AnsibleModule(argument_spec=element_spec, supports_check_mode=True) + generator = SdaHostPortMigrationPlaybookConfigGenerator(module) + initialization_timestamp = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(module_start_time) + ) + generator.log( + "Starting Ansible module execution for SDA host port migration " + "playbook config generator at timestamp {0}".format(initialization_timestamp), + "INFO", + ) + + if generator.compare_catalystcenter_versions(generator.get_ccc_version(), "2.3.7.9") < 0: + generator.msg = ( + "The specified Catalyst Center version '{0}' does not support YAML " + "playbook generation for SDA Host Port Migration. " + "Supported versions start from '2.3.7.9' onwards.".format( + generator.get_ccc_version() + ) + ) + generator.set_operation_result("failed", False, generator.msg, "ERROR").check_return_status() + + state = generator.params.get("state") + if state not in generator.supported_states: + generator.status = "invalid" + generator.msg = ( + "State '{0}' is invalid for this module. Supported states are: {1}.".format( + state, generator.supported_states + ) + ) + generator.check_return_status() + + generator.validate_input().check_return_status() + config = generator.validated_config + generator.get_want(config, state).check_return_status() + generator.get_diff_state_apply[state]().check_return_status() + + module.exit_json(**generator.result) + + +if __name__ == "__main__": + main() diff --git a/roles/sda_host_port_migration_config_generator/README.md b/roles/sda_host_port_migration_config_generator/README.md new file mode 100644 index 000000000..8d529b08f --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/README.md @@ -0,0 +1,68 @@ +# Ansible Role: sda_host_port_migration_config_generator + +This role wraps the +`cisco.catalystcenter.sda_host_port_migration_playbook_config_generator` module. +It generates an SDA host-port onboarding-compatible YAML payload by reading +port assignments and port channels from a source SDA fabric device and targeting +them to a destination device. + +## Requirements + +- `cisco.catalystcenter` collection installed +- Catalyst Center SDK compatible with this collection +- Source device must already have SDA host port assignments or port channels + +## Role Variables + +Connection variables: + +- `catalystcenter_host` +- `catalystcenter_username` +- `catalystcenter_password` +- `catalystcenter_verify` +- `catalystcenter_port` +- `catalystcenter_version` +- `catalystcenter_debug` +- `catalystcenter_log_level` +- `catalystcenter_log` +- `catalystcenter_log_append` +- `catalystcenter_log_file_path` + +Role-specific variables: + +- `sda_host_port_migration_config_generator_state`: only `gathered` is supported +- `sda_host_port_migration_config_generator_file_path`: output YAML file path +- `sda_host_port_migration_config_generator_file_mode`: `overwrite` or `append` +- `sda_host_port_migration_config_generator_config`: migration filters + +## Example Playbook + +```yaml +- hosts: localhost + connection: local + gather_facts: false + roles: + - role: sda_host_port_migration_config_generator + vars: + sda_host_port_migration_config_generator_file_path: /tmp/host_port_migration.yml + sda_host_port_migration_config_generator_file_mode: overwrite + sda_host_port_migration_config_generator_config: + component_specific_filters: + port_assignments: + - fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + source_device_ip: 10.10.10.10 + destination_device_ip: 10.10.10.20 + interface_mappings: + - source_interface_name: GigabitEthernet1/0/1 + destination_interface_name: TenGigabitEthernet1/0/1 + port_channels: + - fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + source_device_ip: 10.10.10.10 + destination_device_ip: 10.10.10.20 + interface_mappings: + - source_interface_name: GigabitEthernet1/0/47 + destination_interface_name: TenGigabitEthernet1/1/1 +``` + +The generated file contains a top-level `config` key that can be loaded and +passed to the `sda_host_port_onboarding` role. diff --git a/roles/sda_host_port_migration_config_generator/defaults/main.yml b/roles/sda_host_port_migration_config_generator/defaults/main.yml new file mode 100644 index 000000000..f2809dcdd --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/defaults/main.yml @@ -0,0 +1,18 @@ +--- +# defaults file for sda_host_port_migration_config_generator +catalystcenter_host: "" +catalystcenter_username: "" +catalystcenter_password: "" +catalystcenter_verify: false +catalystcenter_port: 443 +catalystcenter_version: "2.3.7.9" +catalystcenter_debug: false +catalystcenter_log_level: "INFO" +catalystcenter_log: false +catalystcenter_log_append: false +catalystcenter_log_file_path: catalystcenter.log + +sda_host_port_migration_config_generator_state: gathered +sda_host_port_migration_config_generator_file_path: null +sda_host_port_migration_config_generator_file_mode: overwrite +sda_host_port_migration_config_generator_config: null diff --git a/roles/sda_host_port_migration_config_generator/files/.gitkeep b/roles/sda_host_port_migration_config_generator/files/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/files/.gitkeep @@ -0,0 +1 @@ + diff --git a/roles/sda_host_port_migration_config_generator/handlers/main.yml b/roles/sda_host_port_migration_config_generator/handlers/main.yml new file mode 100644 index 000000000..350bc6102 --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/handlers/main.yml @@ -0,0 +1,2 @@ +--- +# handlers file for sda_host_port_migration_config_generator diff --git a/roles/sda_host_port_migration_config_generator/meta/main.yml b/roles/sda_host_port_migration_config_generator/meta/main.yml new file mode 100644 index 000000000..f02cf4819 --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/meta/main.yml @@ -0,0 +1,19 @@ +--- +galaxy_info: + author: Cisco Systems + description: Ansible role for generating SDA host port migration configurations + company: Cisco Systems + license: GPL-3.0-or-later + min_ansible_version: "2.14" + platforms: + - name: Any + versions: + - all + galaxy_tags: + - cisco + - catalystcenter + - sda + - hostport + - migration + +dependencies: [] diff --git a/roles/sda_host_port_migration_config_generator/tasks/main.yml b/roles/sda_host_port_migration_config_generator/tasks/main.yml new file mode 100644 index 000000000..309c37dd6 --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/tasks/main.yml @@ -0,0 +1,25 @@ +--- +# tasks file for sda_host_port_migration_config_generator +- name: Generate SDA host port migration playbook configuration + cisco.catalystcenter.sda_host_port_migration_playbook_config_generator: + catalystcenter_host: "{{ catalystcenter_host }}" + catalystcenter_username: "{{ catalystcenter_username }}" + catalystcenter_password: "{{ catalystcenter_password }}" + catalystcenter_verify: "{{ catalystcenter_verify | default(false) }}" + catalystcenter_port: "{{ catalystcenter_port | default(443) }}" + catalystcenter_version: "{{ catalystcenter_version | default('2.3.7.9') }}" + catalystcenter_debug: "{{ catalystcenter_debug | default(false) }}" + catalystcenter_log_level: "{{ catalystcenter_log_level | default('INFO') }}" + catalystcenter_log: "{{ catalystcenter_log | default(false) }}" + catalystcenter_log_append: "{{ catalystcenter_log_append | default(false) }}" + catalystcenter_log_file_path: "{{ catalystcenter_log_file_path | default('catalystcenter.log') }}" + state: "{{ sda_host_port_migration_config_generator_state }}" + file_path: "{{ sda_host_port_migration_config_generator_file_path if sda_host_port_migration_config_generator_file_path is not none else omit }}" + file_mode: "{{ sda_host_port_migration_config_generator_file_mode }}" + config: "{{ sda_host_port_migration_config_generator_config if sda_host_port_migration_config_generator_config is not none else omit }}" + register: sda_host_port_migration_config_generator_result + +- name: Display SDA host port migration generator result + ansible.builtin.debug: + var: sda_host_port_migration_config_generator_result + when: catalystcenter_debug | default(false) diff --git a/roles/sda_host_port_migration_config_generator/templates/.gitkeep b/roles/sda_host_port_migration_config_generator/templates/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/templates/.gitkeep @@ -0,0 +1 @@ + diff --git a/roles/sda_host_port_migration_config_generator/tests/inventory b/roles/sda_host_port_migration_config_generator/tests/inventory new file mode 100644 index 000000000..2fbb50c4a --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/sda_host_port_migration_config_generator/vars/main.yml b/roles/sda_host_port_migration_config_generator/vars/main.yml new file mode 100644 index 000000000..dd16c8657 --- /dev/null +++ b/roles/sda_host_port_migration_config_generator/vars/main.yml @@ -0,0 +1,2 @@ +--- +# vars file for sda_host_port_migration_config_generator diff --git a/roles/switch_refresh/README.md b/roles/switch_refresh/README.md new file mode 100644 index 000000000..3fa26fe20 --- /dev/null +++ b/roles/switch_refresh/README.md @@ -0,0 +1,241 @@ +# Ansible Role: switch_refresh + +This role orchestrates a Catalyst Center switch refresh where an old SDA edge +switch is replaced by a new switch and the host-facing port configuration is +migrated from the old switch to the new switch. + +The role is intentionally split into two phases: + +1. `prepare`: onboard the new switch through either Discovery or LAN Automation, + provision it, add it to fabric as an edge device, generate host-port + migration config from the old switch, and push the generated host onboarding + config to the new switch. +2. `cleanup_old`: after validation and cutover, delete the old switch port + assignments and port channels, remove the old switch from fabric, and + unprovision it. + +## Requirements + +- `cisco.catalystcenter` collection installed +- Catalyst Center SDK compatible with this collection +- Old switch must still be present in Catalyst Center during prepare and cleanup +- New switch discovery/provisioning inputs must be supplied by the user + +## Important Behavior + +- The new switch may have a different port layout. Provide `interface_mappings` + when source and destination interface names differ. +- Replacement switch onboarding supports two methods: + - `discovery`: use the existing `discovery` role to discover a reachable + replacement switch. This is the default. + - `lan_automation`: use the existing `lan_automation` role to run Catalyst + Center LAN Automation, then continue with inventory lookup, provisioning, + fabric add, and host-port migration. +- The host-port migration generator handles `port_assignments` and + `port_channels` only in this role through the + `sda_host_port_migration_config_generator` role wrapper. +- Fabric membership is validated with the `fabric_devices_info` role after + adding the replacement switch and after removing the old switch. +- Old and replacement switch inventory details are resolved with the + `network_devices_info` role. The user can provide hostname, serial number, + MAC address, or management IP for the old switch. +- Old-switch cleanup is gated by `switch_refresh_cleanup_old: true` and is + normally run through the separate cleanup playbook. +- The replacement switch can later take over the old switch identity after + cutover, but this role uses the current management IPs for Catalyst Center + operations. + +## Role Variables + +Connection variables: + +- `catalystcenter_host` +- `catalystcenter_username` +- `catalystcenter_password` +- `catalystcenter_verify` +- `catalystcenter_port` +- `catalystcenter_version` +- `catalystcenter_debug` +- `catalystcenter_log_level` +- `catalystcenter_log` + +Control variables: + +- `switch_refresh_phase`: `prepare`, `cleanup_old`, or `all` +- `switch_refresh_onboarding_method`: replacement onboarding method, + `discovery` or `lan_automation` +- `switch_refresh_cleanup_old`: must be `true` for cleanup +- `switch_refresh_work_dir`: directory for generated migration files +- `switch_refresh_devices`: list of switch refresh entries +- `switch_refresh_device_info_lookup_enabled`: resolve device details with + `network_devices_info` +- `switch_refresh_device_info_lookup_timeout`: inventory lookup timeout +- `switch_refresh_device_info_lookup_retries`: inventory lookup retry count +- `switch_refresh_device_info_lookup_interval`: inventory lookup retry interval +- `switch_refresh_fabric_validation_enabled`: validate fabric presence/absence + with `fabric_devices_info` +- `switch_refresh_fabric_validation_timeout`: validation timeout in seconds +- `switch_refresh_fabric_validation_retries`: validation retry count +- `switch_refresh_fabric_validation_interval`: validation retry interval +- `switch_refresh_allow_empty_host_port_config`: allow empty generated host-port + payloads when set to `true` + +Stage toggles: + +- `switch_refresh_discovery_enabled` +- `switch_refresh_lan_automation_enabled` +- `switch_refresh_provision_enabled` +- `switch_refresh_fabric_add_enabled` +- `switch_refresh_host_onboarding_enabled` +- `switch_refresh_cleanup_host_onboarding_enabled` +- `switch_refresh_cleanup_fabric_enabled` +- `switch_refresh_cleanup_unprovision_enabled` + +## Input Model + +```yaml +switch_refresh_devices: + - name: sjc-edge-01-refresh + fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + old: + hostname: SJ-EDGE-OLD.cisco.local + onboarding_method: discovery + new: + management_ip: 10.10.10.20 + interface_mappings: + - source_interface_name: GigabitEthernet1/0/1 + destination_interface_name: TenGigabitEthernet1/0/1 +``` + +`old` can use any one of `management_ip`, `hostname`, `serial_number`, or +`mac_address`. The role resolves the old management IP with +`network_devices_info`. + +Set `onboarding_method` per device when the replacement switch should use a +method different from the global `switch_refresh_onboarding_method`. + +## Replacement Switch Onboarding Options + +### Option 1: Discovery + +Use `discovery` when the replacement switch already has a reachable management +IP and Catalyst Center can log in to it. + +If `new.discovery_config` is omitted, the role builds a SINGLE discovery payload +from `new.management_ip` using global Catalyst Center credentials: + +```yaml +single: + - discovery_name: switch-refresh- + discovery_type: SINGLE + ip_address_list: + - "{{ new.management_ip }}" + protocol_order: ssh + retry: 2 + use_global_credentials: true +``` + +Pass `new.discovery_config` when you need custom discovery credentials, CDP, +LLDP, RANGE, CIDR, or any non-default discovery behavior. The role derives the +new management IP from `new.management_ip`, `new.discovery_ip`, or the first +`ip_address_list` entry in a `single` discovery config. + +```yaml +switch_refresh_devices: + - name: sjc-edge-01-refresh + fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + onboarding_method: discovery + old: + hostname: SJ-EDGE-OLD.cisco.local + new: + management_ip: 10.10.10.20 +``` + +### Option 2: LAN Automation + +Use `lan_automation` when the replacement switch should be onboarded through +Catalyst Center LAN Automation instead of normal Discovery. This requires the +full `lan_automation_config` expected by the `lan_automation` role. The +replacement switch still needs `new.management_ip` so the switch refresh role can +resolve it after LAN Automation completes and continue with provisioning, fabric +add, and host-port migration. + +```yaml +switch_refresh_devices: + - name: sjc-edge-01-refresh-lan-auto + fabric_site_name_hierarchy: Global/USA/SAN-JOSE/BLDG23 + onboarding_method: lan_automation + old: + hostname: SJ-EDGE-OLD.cisco.local + new: + management_ip: 204.1.1.13 + lan_automation_config: + - lan_automation: + discovered_device_site_name_hierarchy: Global/USA/SAN JOSE + peer_device_management_ip_address: 91.1.1.2 + primary_device_management_ip_address: 204.1.1.4 + primary_device_interface_names: + - HundredGigE1/0/2 + ip_pools: + - ip_pool_name: underlay_sub + ip_pool_role: MAIN_POOL + - ip_pool_name: underlay_sub_small + ip_pool_role: PHYSICAL_LINK_POOL + discovery_level: 5 + discovery_timeout: 40 + discovery_devices: + - device_serial_number: FXS2429Q0WE + device_host_name: SR-LAN-9400X-EDGE1 + device_site_name_hierarchy: Global/USA/SAN JOSE/BLD20/BLD20_FLOOR1 + device_management_ip_address: 204.1.1.13 + launch_and_wait: true +``` + +If `new.provision_config` is omitted, the role builds it automatically: + +```yaml +provision_config: + - site_name_hierarchy: "{{ fabric_site_name_hierarchy }}" + management_ip_address: "{{ resolved_new_management_ip }}" +``` + +If `new.fabric_devices_config` is omitted, the role builds a default SDA fabric +device payload using the resolved new management IP and `EDGE_NODE`. + +Set `provision_site_name_hierarchy` when the provisioning site differs from the +SDA fabric site. + +Use separate mappings when port assignment mappings differ from port channel +member mappings: + +```yaml +port_assignment_interface_mappings: + - source_interface_name: GigabitEthernet1/0/10 + destination_interface_name: GigabitEthernet1/0/20 +port_channel_interface_mappings: + - source_interface_name: GigabitEthernet1/0/47 + destination_interface_name: TenGigabitEthernet1/1/1 +``` + +## Example Playbooks + +Prepare the replacement switch: + +```bash +ansible-playbook playbooks/switch_refresh_prepare.yml +``` + +Clean up the old switch after validation and cutover: + +```bash +ansible-playbook playbooks/switch_refresh_cleanup_old.yml +``` + +You can also run the role with tags from your own playbook: + +```bash +ansible-playbook my_switch_refresh.yml --tags switch_refresh_prepare +ansible-playbook my_switch_refresh.yml --tags switch_refresh_cleanup_old -e switch_refresh_cleanup_old=true +``` + +See `playbooks/vars/switch_refresh_usecase.yml` for a complete sample use case. diff --git a/roles/switch_refresh/defaults/main.yml b/roles/switch_refresh/defaults/main.yml new file mode 100644 index 000000000..c6d8a375e --- /dev/null +++ b/roles/switch_refresh/defaults/main.yml @@ -0,0 +1,45 @@ +--- +# defaults file for switch_refresh +catalystcenter_host: "" +catalystcenter_username: "" +catalystcenter_password: "" +catalystcenter_verify: false +catalystcenter_port: 443 +catalystcenter_version: "2.3.7.9" +catalystcenter_debug: false +catalystcenter_log_level: "INFO" +catalystcenter_log: false +catalystcenter_log_append: false +catalystcenter_log_file_path: catalystcenter.log + +switch_refresh_phase: prepare +switch_refresh_devices: [] +switch_refresh_cleanup_old: false +switch_refresh_work_dir: /tmp/catalystcenter_switch_refresh +switch_refresh_onboarding_method: discovery + +switch_refresh_discovery_enabled: true +switch_refresh_lan_automation_enabled: true +switch_refresh_provision_enabled: true +switch_refresh_fabric_add_enabled: true +switch_refresh_fabric_validation_enabled: true +switch_refresh_host_onboarding_enabled: true +switch_refresh_cleanup_host_onboarding_enabled: true +switch_refresh_cleanup_fabric_enabled: true +switch_refresh_cleanup_unprovision_enabled: true + +switch_refresh_device_info_lookup_enabled: true +switch_refresh_device_info_lookup_timeout: 120 +switch_refresh_device_info_lookup_retries: 6 +switch_refresh_device_info_lookup_interval: 10 +switch_refresh_discovery_config_verify: false +switch_refresh_lan_automation_config_verify: false +switch_refresh_provision_config_verify: false +switch_refresh_sda_fabric_devices_config_verify: false +switch_refresh_sda_host_port_onboarding_config_verify: false +switch_refresh_sda_fabric_port_channel_limit: 20 +switch_refresh_fabric_validation_timeout: 120 +switch_refresh_fabric_validation_retries: 6 +switch_refresh_fabric_validation_interval: 10 +switch_refresh_migration_file_mode: overwrite +switch_refresh_allow_empty_host_port_config: false diff --git a/roles/switch_refresh/meta/main.yml b/roles/switch_refresh/meta/main.yml new file mode 100644 index 000000000..cf672577b --- /dev/null +++ b/roles/switch_refresh/meta/main.yml @@ -0,0 +1,19 @@ +--- +galaxy_info: + author: Cisco Systems + description: Ansible role for orchestrating Catalyst Center switch refresh workflows + company: Cisco Systems + license: GPL-3.0-or-later + min_ansible_version: "2.14" + platforms: + - name: Any + versions: + - all + galaxy_tags: + - cisco + - catalystcenter + - sda + - switch + - refresh + +dependencies: [] diff --git a/roles/switch_refresh/tasks/cleanup_one.yml b/roles/switch_refresh/tasks/cleanup_one.yml new file mode 100644 index 000000000..925b08088 --- /dev/null +++ b/roles/switch_refresh/tasks/cleanup_one.yml @@ -0,0 +1,227 @@ +--- +- name: Validate cleanup input for switch refresh item + ansible.builtin.assert: + that: + - refresh_item.fabric_site_name_hierarchy is defined + - refresh_item.old is defined + - refresh_item.old.management_ip is defined or refresh_item.old.hostname is defined or refresh_item.old.serial_number is defined or refresh_item.old.mac_address is defined + fail_msg: >- + Each switch_refresh_devices entry needs fabric_site_name_hierarchy and an + old device identifier (management_ip, hostname, serial_number, or + mac_address) for old-switch cleanup. + +- name: Build old switch inventory identifier + ansible.builtin.set_fact: + switch_refresh_old_device_identifier: >- + {{ + {'ip_address': [refresh_item.old.management_ip]} if (refresh_item.old.management_ip is defined) else + {'hostname': [refresh_item.old.hostname]} if (refresh_item.old.hostname is defined) else + {'serial_number': [refresh_item.old.serial_number]} if (refresh_item.old.serial_number is defined) else + {'mac_address': [refresh_item.old.mac_address]} if (refresh_item.old.mac_address is defined) else + {} + }} + +- name: Resolve old switch inventory details + ansible.builtin.include_role: + name: network_devices_info + apply: + tags: + - switch_refresh_cleanup_old + vars: + network_devices_info_state: gathered + network_devices_info_config_verify: false + network_devices_info_config: + - network_devices: + - device_identifier: + - "{{ switch_refresh_old_device_identifier }}" + timeout: "{{ refresh_item.device_info_lookup_timeout | default(switch_refresh_device_info_lookup_timeout) }}" + retries: "{{ refresh_item.device_info_lookup_retries | default(switch_refresh_device_info_lookup_retries) }}" + interval: "{{ refresh_item.device_info_lookup_interval | default(switch_refresh_device_info_lookup_interval) }}" + requested_info: + - device_info + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Validate old switch inventory lookup result + ansible.builtin.assert: + that: + - network_devices_info_result.response is defined + - network_devices_info_result.response | length > 1 + - network_devices_info_result.response[1] | length > 0 + - network_devices_info_result.response[1][0].device_info is defined + - network_devices_info_result.response[1][0].device_info | length == 1 + - network_devices_info_result.response[1][0].device_info[0].device_details | length == 1 + fail_msg: >- + Unable to resolve exactly one old switch from Catalyst Center inventory + using {{ switch_refresh_old_device_identifier }}. + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Set old switch resolved details + ansible.builtin.set_fact: + switch_refresh_old_device_details: "{{ network_devices_info_result.response[1][0].device_info[0].device_details[0] }}" + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Set old switch management IP + ansible.builtin.set_fact: + switch_refresh_old_management_ip: >- + {{ + refresh_item.old.management_ip if (refresh_item.old.management_ip is defined) + else (switch_refresh_old_device_details.managementIpAddress | default('')) + }} + +- name: Validate old switch management IP is known + ansible.builtin.assert: + that: + - switch_refresh_old_management_ip | length > 0 + fail_msg: >- + Unable to determine the old switch management IP. Provide old.management_ip + or enable inventory lookup with hostname, serial_number, or mac_address. + +- name: Build switch refresh cleanup context + ansible.builtin.set_fact: + switch_refresh_item_name: "{{ refresh_item.name | default(refresh_item.old.hostname | default(switch_refresh_old_management_ip)) }}" + switch_refresh_item_file_safe_name: "{{ refresh_item.name | default(refresh_item.old.hostname | default(switch_refresh_old_management_ip)) | regex_replace('[^A-Za-z0-9_.-]', '_') }}" + switch_refresh_old_host_port_cleanup_file: "{{ refresh_item.old_host_port_cleanup_file | default(switch_refresh_work_dir ~ '/' ~ (refresh_item.name | default(refresh_item.old.hostname | default(switch_refresh_old_management_ip)) | regex_replace('[^A-Za-z0-9_.-]', '_')) ~ '_old_host_port_cleanup.yml') }}" + +- name: Build old host-port cleanup generator config + ansible.builtin.set_fact: + switch_refresh_old_host_port_cleanup_generator_config: + component_specific_filters: + port_assignments: + - fabric_site_name_hierarchy: "{{ refresh_item.fabric_site_name_hierarchy }}" + source_device_ip: "{{ switch_refresh_old_management_ip }}" + destination_device_ip: "{{ switch_refresh_old_management_ip }}" + port_channels: + - fabric_site_name_hierarchy: "{{ refresh_item.fabric_site_name_hierarchy }}" + source_device_ip: "{{ switch_refresh_old_management_ip }}" + destination_device_ip: "{{ switch_refresh_old_management_ip }}" + when: switch_refresh_cleanup_host_onboarding_enabled | bool + +- name: Generate old host-port cleanup payload + ansible.builtin.include_role: + name: sda_host_port_migration_config_generator + apply: + tags: + - switch_refresh_cleanup_old + vars: + sda_host_port_migration_config_generator_state: gathered + sda_host_port_migration_config_generator_file_path: "{{ switch_refresh_old_host_port_cleanup_file }}" + sda_host_port_migration_config_generator_file_mode: "{{ switch_refresh_migration_file_mode }}" + sda_host_port_migration_config_generator_config: "{{ switch_refresh_old_host_port_cleanup_generator_config }}" + when: switch_refresh_cleanup_host_onboarding_enabled | bool + +- name: Load old host-port cleanup payload + ansible.builtin.include_vars: + file: "{{ switch_refresh_old_host_port_cleanup_file }}" + name: switch_refresh_old_host_port_cleanup + when: switch_refresh_cleanup_host_onboarding_enabled | bool + +- name: Validate old host-port cleanup payload + ansible.builtin.assert: + that: + - switch_refresh_old_host_port_cleanup.config is defined + - switch_refresh_allow_empty_host_port_config | bool or (switch_refresh_old_host_port_cleanup.config | length > 0) + fail_msg: >- + Old-device host-port cleanup did not generate a delete payload for + {{ switch_refresh_item_name }}. Confirm the old switch still has port + assignments or port channels in the supplied fabric site. + when: switch_refresh_cleanup_host_onboarding_enabled | bool + +- name: Delete host-port onboarding config from old switch + ansible.builtin.include_role: + name: sda_host_port_onboarding + apply: + tags: + - switch_refresh_cleanup_old + vars: + sda_host_port_onboarding_state: deleted + sda_host_port_onboarding_config_verify: "{{ switch_refresh_sda_host_port_onboarding_config_verify }}" + sda_host_port_onboarding_sda_fabric_port_channel_limit: "{{ switch_refresh_sda_fabric_port_channel_limit }}" + sda_host_port_onboarding_config: "{{ switch_refresh_old_host_port_cleanup.config }}" + when: switch_refresh_cleanup_host_onboarding_enabled | bool + +- name: Build old switch fabric removal config + ansible.builtin.set_fact: + switch_refresh_old_fabric_devices_config: + - fabric_devices: + fabric_name: "{{ refresh_item.fabric_site_name_hierarchy }}" + device_config: + - device_ip: "{{ switch_refresh_old_management_ip }}" + when: switch_refresh_cleanup_fabric_enabled | bool + +- name: Remove old switch from fabric + ansible.builtin.include_role: + name: sda_fabric_devices + apply: + tags: + - switch_refresh_cleanup_old + vars: + sda_fabric_devices_state: deleted + sda_fabric_devices_config_verify: "{{ switch_refresh_sda_fabric_devices_config_verify }}" + sda_fabric_devices_config: "{{ switch_refresh_old_fabric_devices_config }}" + when: switch_refresh_cleanup_fabric_enabled | bool + +- name: Validate old switch is absent from fabric + ansible.builtin.include_role: + name: fabric_devices_info + apply: + tags: + - switch_refresh_cleanup_old + vars: + fabric_devices_info_state: gathered + fabric_devices_info_config_verify: false + fabric_devices_info_config: + - fabric_devices: + - fabric_site_hierarchy: "{{ refresh_item.fabric_site_name_hierarchy }}" + fabric_device_role: "{{ refresh_item.fabric_validation_role | default('EDGE_NODE') }}" + device_identifier: + - ip_address: + - "{{ switch_refresh_old_management_ip }}" + timeout: "{{ refresh_item.fabric_validation_timeout | default(switch_refresh_fabric_validation_timeout) }}" + retries: "{{ refresh_item.fabric_validation_retries | default(switch_refresh_fabric_validation_retries) }}" + interval: "{{ refresh_item.fabric_validation_interval | default(switch_refresh_fabric_validation_interval) }}" + requested_info: + - fabric_info + when: + - switch_refresh_cleanup_fabric_enabled | bool + - switch_refresh_fabric_validation_enabled | bool + +- name: Fail if old switch is still visible as fabric edge device + ansible.builtin.assert: + that: + - fabric_devices_info_result.response is defined + - "'The fabric devices filtered' not in (fabric_devices_info_result.response | string)" + - switch_refresh_old_management_ip not in (fabric_devices_info_result.response | string) + fail_msg: >- + Old switch {{ switch_refresh_old_management_ip }} is still visible as an + {{ refresh_item.fabric_validation_role | default('EDGE_NODE') }} in + fabric {{ refresh_item.fabric_site_name_hierarchy }}. + when: + - switch_refresh_cleanup_fabric_enabled | bool + - switch_refresh_fabric_validation_enabled | bool + +- name: Unprovision old switch + ansible.builtin.include_role: + name: provision + apply: + tags: + - switch_refresh_cleanup_old + vars: + provision_state: deleted + provision_config_verify: "{{ switch_refresh_provision_config_verify }}" + provision_config: + - management_ip_address: "{{ switch_refresh_old_management_ip }}" + when: switch_refresh_cleanup_unprovision_enabled | bool + +- name: Record old switch cleanup result + ansible.builtin.set_fact: + switch_refresh_cleanup_results: >- + {{ + (switch_refresh_cleanup_results | default([])) + [ + { + 'name': switch_refresh_item_name, + 'old_management_ip': switch_refresh_old_management_ip, + 'fabric_site_name_hierarchy': refresh_item.fabric_site_name_hierarchy, + 'old_host_port_cleanup_file': switch_refresh_old_host_port_cleanup_file + } + ] + }} diff --git a/roles/switch_refresh/tasks/main.yml b/roles/switch_refresh/tasks/main.yml new file mode 100644 index 000000000..833238201 --- /dev/null +++ b/roles/switch_refresh/tasks/main.yml @@ -0,0 +1,64 @@ +--- +- name: Validate switch refresh control variables + ansible.builtin.assert: + that: + - switch_refresh_phase in ['prepare', 'cleanup_old', 'all'] + - switch_refresh_onboarding_method in ['discovery', 'lan_automation'] + - switch_refresh_devices | length > 0 + fail_msg: >- + switch_refresh_phase must be prepare, cleanup_old, or all, + switch_refresh_onboarding_method must be discovery or lan_automation, and + switch_refresh_devices must contain at least one switch refresh entry. + tags: + - always + +- name: Ensure switch refresh work directory exists + ansible.builtin.file: + path: "{{ switch_refresh_work_dir }}" + state: directory + mode: "0750" + when: switch_refresh_phase in ['prepare', 'cleanup_old', 'all'] + tags: + - switch_refresh_prepare + - switch_refresh_cleanup_old + +- name: Prepare replacement switches + ansible.builtin.include_tasks: + file: prepare_one.yml + apply: + tags: + - switch_refresh_prepare + loop: "{{ switch_refresh_devices }}" + loop_control: + loop_var: refresh_item + label: "{{ refresh_item.name | default(refresh_item.new.hostname | default(refresh_item.new.management_ip | default('switch-refresh'))) }}" + when: switch_refresh_phase in ['prepare', 'all'] + tags: + - switch_refresh_prepare + +- name: Require explicit old-switch cleanup approval + ansible.builtin.fail: + msg: >- + Old-switch cleanup is gated. Set switch_refresh_cleanup_old=true or run + the dedicated switch_refresh_cleanup_old.yml playbook after validation. + when: + - switch_refresh_phase in ['cleanup_old', 'all'] + - not (switch_refresh_cleanup_old | bool) + tags: + - switch_refresh_cleanup_old + +- name: Clean up old switches + ansible.builtin.include_tasks: + file: cleanup_one.yml + apply: + tags: + - switch_refresh_cleanup_old + loop: "{{ switch_refresh_devices }}" + loop_control: + loop_var: refresh_item + label: "{{ refresh_item.name | default(refresh_item.old.hostname | default(refresh_item.old.management_ip | default('switch-refresh'))) }}" + when: + - switch_refresh_phase in ['cleanup_old', 'all'] + - switch_refresh_cleanup_old | bool + tags: + - switch_refresh_cleanup_old diff --git a/roles/switch_refresh/tasks/prepare_one.yml b/roles/switch_refresh/tasks/prepare_one.yml new file mode 100644 index 000000000..4ad3a32f2 --- /dev/null +++ b/roles/switch_refresh/tasks/prepare_one.yml @@ -0,0 +1,397 @@ +--- +- name: Validate prepare input for switch refresh item + ansible.builtin.assert: + that: + - refresh_item.fabric_site_name_hierarchy is defined + - refresh_item.old is defined + - refresh_item.new is defined + - refresh_item.old.management_ip is defined or refresh_item.old.hostname is defined or refresh_item.old.serial_number is defined or refresh_item.old.mac_address is defined + - refresh_item.new.management_ip is defined or refresh_item.new.discovery_ip is defined or refresh_item.new.discovery_config is defined + fail_msg: >- + Each switch_refresh_devices entry needs fabric_site_name_hierarchy, an old + device identifier (management_ip, hostname, serial_number, or mac_address), + and a new management IP, discovery IP, or discovery_config. For + LAN Automation onboarding, new.management_ip should be the expected + management IP after LAN Automation completes. + +- name: Set replacement switch onboarding method + ansible.builtin.set_fact: + switch_refresh_item_onboarding_method: "{{ refresh_item.onboarding_method | default(switch_refresh_onboarding_method) }}" + +- name: Validate replacement switch onboarding method + ansible.builtin.assert: + that: + - switch_refresh_item_onboarding_method in ['discovery', 'lan_automation'] + fail_msg: >- + onboarding_method must be discovery or lan_automation for + {{ refresh_item.name | default('switch refresh item') }}. + +- name: Validate replacement switch LAN Automation config + ansible.builtin.assert: + that: + - refresh_item.new.lan_automation_config is defined + - refresh_item.new.lan_automation_config | length > 0 + fail_msg: >- + new.lan_automation_config is required when onboarding_method is + lan_automation. Provide the full lan_automation role config for + {{ refresh_item.name | default('switch refresh item') }}. + when: + - switch_refresh_item_onboarding_method == 'lan_automation' + - switch_refresh_lan_automation_enabled | bool + +- name: Seed replacement switch management IP + ansible.builtin.set_fact: + switch_refresh_new_management_ip: "{{ refresh_item.new.management_ip | default(refresh_item.new.discovery_ip | default('')) }}" + +- name: Derive replacement switch management IP from discovery config + ansible.builtin.set_fact: + switch_refresh_new_management_ip: "{{ refresh_item.new.discovery_config.single[0].ip_address_list[0] }}" + when: + - switch_refresh_new_management_ip | length == 0 + - refresh_item.new.discovery_config is defined + - refresh_item.new.discovery_config.single is defined + - refresh_item.new.discovery_config.single | length > 0 + - refresh_item.new.discovery_config.single[0].ip_address_list is defined + - refresh_item.new.discovery_config.single[0].ip_address_list | length > 0 + +- name: Validate replacement switch management IP is known + ansible.builtin.assert: + that: + - switch_refresh_new_management_ip | length > 0 + fail_msg: >- + Unable to determine the replacement switch management IP. Set + new.management_ip, new.discovery_ip, or include a SINGLE discovery config + with ip_address_list. + +- name: Build switch refresh prepare context + ansible.builtin.set_fact: + switch_refresh_item_name: "{{ refresh_item.name | default(refresh_item.new.hostname | default(switch_refresh_new_management_ip)) }}" + switch_refresh_item_file_safe_name: "{{ refresh_item.name | default(refresh_item.new.hostname | default(switch_refresh_new_management_ip)) | regex_replace('[^A-Za-z0-9_.-]', '_') }}" + switch_refresh_migration_output_file: "{{ refresh_item.migration_output_file | default(switch_refresh_work_dir ~ '/' ~ (refresh_item.name | default(refresh_item.new.hostname | default(switch_refresh_new_management_ip)) | regex_replace('[^A-Za-z0-9_.-]', '_')) ~ '_host_port_migration.yml') }}" + switch_refresh_port_assignment_interface_mappings: "{{ refresh_item.port_assignment_interface_mappings | default(refresh_item.interface_mappings | default([])) }}" + switch_refresh_port_channel_interface_mappings: "{{ refresh_item.port_channel_interface_mappings | default(refresh_item.interface_mappings | default([])) }}" + +- name: Build replacement switch discovery configuration + ansible.builtin.set_fact: + switch_refresh_new_discovery_config: >- + {{ + refresh_item.new.discovery_config | default({ + 'single': [ + { + 'discovery_name': refresh_item.new.discovery_name | default('switch-refresh-' ~ switch_refresh_item_file_safe_name), + 'discovery_type': 'SINGLE', + 'ip_address_list': [switch_refresh_new_management_ip], + 'protocol_order': refresh_item.new.protocol_order | default('ssh'), + 'retry': refresh_item.new.discovery_retry | default(2), + 'use_global_credentials': refresh_item.new.use_global_credentials | default(true) + } + ] + }) + }} + when: + - switch_refresh_item_onboarding_method == 'discovery' + - switch_refresh_discovery_enabled | bool + +- name: Build old switch inventory identifier + ansible.builtin.set_fact: + switch_refresh_old_device_identifier: >- + {{ + {'ip_address': [refresh_item.old.management_ip]} if (refresh_item.old.management_ip is defined) else + {'hostname': [refresh_item.old.hostname]} if (refresh_item.old.hostname is defined) else + {'serial_number': [refresh_item.old.serial_number]} if (refresh_item.old.serial_number is defined) else + {'mac_address': [refresh_item.old.mac_address]} if (refresh_item.old.mac_address is defined) else + {} + }} + +- name: Resolve old switch inventory details + ansible.builtin.include_role: + name: network_devices_info + apply: + tags: + - switch_refresh_prepare + vars: + network_devices_info_state: gathered + network_devices_info_config_verify: false + network_devices_info_config: + - network_devices: + - device_identifier: + - "{{ switch_refresh_old_device_identifier }}" + timeout: "{{ refresh_item.device_info_lookup_timeout | default(switch_refresh_device_info_lookup_timeout) }}" + retries: "{{ refresh_item.device_info_lookup_retries | default(switch_refresh_device_info_lookup_retries) }}" + interval: "{{ refresh_item.device_info_lookup_interval | default(switch_refresh_device_info_lookup_interval) }}" + requested_info: + - device_info + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Validate old switch inventory lookup result + ansible.builtin.assert: + that: + - network_devices_info_result.response is defined + - network_devices_info_result.response | length > 1 + - network_devices_info_result.response[1] | length > 0 + - network_devices_info_result.response[1][0].device_info is defined + - network_devices_info_result.response[1][0].device_info | length == 1 + - network_devices_info_result.response[1][0].device_info[0].device_details | length == 1 + fail_msg: >- + Unable to resolve exactly one old switch from Catalyst Center inventory + using {{ switch_refresh_old_device_identifier }}. + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Set old switch resolved details + ansible.builtin.set_fact: + switch_refresh_old_device_details: "{{ network_devices_info_result.response[1][0].device_info[0].device_details[0] }}" + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Set old switch management IP + ansible.builtin.set_fact: + switch_refresh_old_management_ip: >- + {{ + refresh_item.old.management_ip if (refresh_item.old.management_ip is defined) + else (switch_refresh_old_device_details.managementIpAddress | default('')) + }} + +- name: Validate old switch management IP is known + ansible.builtin.assert: + that: + - switch_refresh_old_management_ip | length > 0 + fail_msg: >- + Unable to determine the old switch management IP. Provide old.management_ip + or enable inventory lookup with hostname, serial_number, or mac_address. + +- name: Discover replacement switch + ansible.builtin.include_role: + name: discovery + apply: + tags: + - switch_refresh_prepare + vars: + discovery_state: merged + discovery_config_verify: "{{ switch_refresh_discovery_config_verify }}" + discovery_config: "{{ switch_refresh_new_discovery_config }}" + when: + - switch_refresh_item_onboarding_method == 'discovery' + - switch_refresh_discovery_enabled | bool + +- name: Run LAN Automation for replacement switch + ansible.builtin.include_role: + name: lan_automation + apply: + tags: + - switch_refresh_prepare + vars: + lan_automation_state: "{{ refresh_item.new.lan_automation_state | default('merged') }}" + lan_automation_config_verify: "{{ refresh_item.new.lan_automation_config_verify | default(switch_refresh_lan_automation_config_verify) }}" + lan_automation_config: "{{ refresh_item.new.lan_automation_config }}" + when: + - switch_refresh_item_onboarding_method == 'lan_automation' + - switch_refresh_lan_automation_enabled | bool + +- name: Resolve replacement switch inventory details + ansible.builtin.include_role: + name: network_devices_info + apply: + tags: + - switch_refresh_prepare + vars: + network_devices_info_state: gathered + network_devices_info_config_verify: false + network_devices_info_config: + - network_devices: + - device_identifier: + - ip_address: + - "{{ switch_refresh_new_management_ip }}" + timeout: "{{ refresh_item.device_info_lookup_timeout | default(switch_refresh_device_info_lookup_timeout) }}" + retries: "{{ refresh_item.device_info_lookup_retries | default(switch_refresh_device_info_lookup_retries) }}" + interval: "{{ refresh_item.device_info_lookup_interval | default(switch_refresh_device_info_lookup_interval) }}" + requested_info: + - device_info + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Validate replacement switch inventory lookup result + ansible.builtin.assert: + that: + - network_devices_info_result.response is defined + - network_devices_info_result.response | length > 1 + - network_devices_info_result.response[1] | length > 0 + - network_devices_info_result.response[1][0].device_info is defined + - network_devices_info_result.response[1][0].device_info | length == 1 + - network_devices_info_result.response[1][0].device_info[0].device_details | length == 1 + fail_msg: >- + Unable to resolve exactly one replacement switch from Catalyst Center + inventory using {{ switch_refresh_new_management_ip }}. + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Set replacement switch resolved details + ansible.builtin.set_fact: + switch_refresh_new_device_details: "{{ network_devices_info_result.response[1][0].device_info[0].device_details[0] }}" + when: switch_refresh_device_info_lookup_enabled | bool + +- name: Build replacement switch provision configuration + ansible.builtin.set_fact: + switch_refresh_new_provision_config: >- + {{ + refresh_item.new.provision_config | default([ + { + 'site_name_hierarchy': refresh_item.provision_site_name_hierarchy | default(refresh_item.fabric_site_name_hierarchy), + 'management_ip_address': switch_refresh_new_management_ip + } + ]) + }} + when: switch_refresh_provision_enabled | bool + +- name: Provision replacement switch + ansible.builtin.include_role: + name: provision + apply: + tags: + - switch_refresh_prepare + vars: + provision_state: merged + provision_config_verify: "{{ switch_refresh_provision_config_verify }}" + provision_config: "{{ switch_refresh_new_provision_config }}" + when: switch_refresh_provision_enabled | bool + +- name: Build replacement fabric edge configuration + ansible.builtin.set_fact: + switch_refresh_new_fabric_devices_config: >- + {{ + refresh_item.new.fabric_devices_config | default([ + { + 'fabric_devices': { + 'fabric_name': refresh_item.fabric_site_name_hierarchy, + 'device_config': [ + { + 'device_ip': switch_refresh_new_management_ip, + 'device_roles': refresh_item.new.device_roles | default(['EDGE_NODE']) + } + ] + } + } + ]) + }} + when: switch_refresh_fabric_add_enabled | bool + +- name: Add replacement switch to fabric as edge device + ansible.builtin.include_role: + name: sda_fabric_devices + apply: + tags: + - switch_refresh_prepare + vars: + sda_fabric_devices_state: merged + sda_fabric_devices_config_verify: "{{ switch_refresh_sda_fabric_devices_config_verify }}" + sda_fabric_devices_config: "{{ switch_refresh_new_fabric_devices_config }}" + when: switch_refresh_fabric_add_enabled | bool + +- name: Validate replacement switch is present in fabric + ansible.builtin.include_role: + name: fabric_devices_info + apply: + tags: + - switch_refresh_prepare + vars: + fabric_devices_info_state: gathered + fabric_devices_info_config_verify: false + fabric_devices_info_config: + - fabric_devices: + - fabric_site_hierarchy: "{{ refresh_item.fabric_site_name_hierarchy }}" + fabric_device_role: "{{ refresh_item.fabric_validation_role | default('EDGE_NODE') }}" + device_identifier: + - ip_address: + - "{{ switch_refresh_new_management_ip }}" + timeout: "{{ refresh_item.fabric_validation_timeout | default(switch_refresh_fabric_validation_timeout) }}" + retries: "{{ refresh_item.fabric_validation_retries | default(switch_refresh_fabric_validation_retries) }}" + interval: "{{ refresh_item.fabric_validation_interval | default(switch_refresh_fabric_validation_interval) }}" + requested_info: + - fabric_info + when: + - switch_refresh_fabric_add_enabled | bool + - switch_refresh_fabric_validation_enabled | bool + +- name: Fail if replacement switch is not visible as fabric edge device + ansible.builtin.assert: + that: + - fabric_devices_info_result.response is defined + - "'The fabric devices filtered' in (fabric_devices_info_result.response | string)" + - switch_refresh_new_management_ip in (fabric_devices_info_result.response | string) + fail_msg: >- + Replacement switch {{ switch_refresh_new_management_ip }} was not found as + a {{ refresh_item.fabric_validation_role | default('EDGE_NODE') }} in + fabric {{ refresh_item.fabric_site_name_hierarchy }}. + when: + - switch_refresh_fabric_add_enabled | bool + - switch_refresh_fabric_validation_enabled | bool + +- name: Build host-port migration generator config + ansible.builtin.set_fact: + switch_refresh_migration_config: + component_specific_filters: + port_assignments: + - fabric_site_name_hierarchy: "{{ refresh_item.fabric_site_name_hierarchy }}" + source_device_ip: "{{ switch_refresh_old_management_ip }}" + destination_device_ip: "{{ switch_refresh_new_management_ip }}" + interface_mappings: "{{ switch_refresh_port_assignment_interface_mappings }}" + port_channels: + - fabric_site_name_hierarchy: "{{ refresh_item.fabric_site_name_hierarchy }}" + source_device_ip: "{{ switch_refresh_old_management_ip }}" + destination_device_ip: "{{ switch_refresh_new_management_ip }}" + interface_mappings: "{{ switch_refresh_port_channel_interface_mappings }}" + when: switch_refresh_host_onboarding_enabled | bool + +- name: Generate replacement host-port onboarding payload + ansible.builtin.include_role: + name: sda_host_port_migration_config_generator + apply: + tags: + - switch_refresh_prepare + vars: + sda_host_port_migration_config_generator_state: gathered + sda_host_port_migration_config_generator_file_path: "{{ switch_refresh_migration_output_file }}" + sda_host_port_migration_config_generator_file_mode: "{{ switch_refresh_migration_file_mode }}" + sda_host_port_migration_config_generator_config: "{{ switch_refresh_migration_config }}" + when: switch_refresh_host_onboarding_enabled | bool + +- name: Load generated replacement host-port onboarding payload + ansible.builtin.include_vars: + file: "{{ switch_refresh_migration_output_file }}" + name: switch_refresh_generated_host_onboarding + when: switch_refresh_host_onboarding_enabled | bool + +- name: Validate generated replacement host-port onboarding payload + ansible.builtin.assert: + that: + - switch_refresh_generated_host_onboarding.config is defined + - switch_refresh_allow_empty_host_port_config | bool or (switch_refresh_generated_host_onboarding.config | length > 0) + fail_msg: >- + Host-port migration did not generate an onboarding config for + {{ switch_refresh_item_name }}. Confirm the old switch has port + assignments or port channels in the supplied fabric site. + when: switch_refresh_host_onboarding_enabled | bool + +- name: Push replacement host-port onboarding config + ansible.builtin.include_role: + name: sda_host_port_onboarding + apply: + tags: + - switch_refresh_prepare + vars: + sda_host_port_onboarding_state: merged + sda_host_port_onboarding_config_verify: "{{ switch_refresh_sda_host_port_onboarding_config_verify }}" + sda_host_port_onboarding_sda_fabric_port_channel_limit: "{{ switch_refresh_sda_fabric_port_channel_limit }}" + sda_host_port_onboarding_config: "{{ switch_refresh_generated_host_onboarding.config }}" + when: switch_refresh_host_onboarding_enabled | bool + +- name: Record replacement switch prepare result + ansible.builtin.set_fact: + switch_refresh_prepare_results: >- + {{ + (switch_refresh_prepare_results | default([])) + [ + { + 'name': switch_refresh_item_name, + 'old_management_ip': switch_refresh_old_management_ip, + 'new_management_ip': switch_refresh_new_management_ip, + 'fabric_site_name_hierarchy': refresh_item.fabric_site_name_hierarchy, + 'migration_output_file': switch_refresh_migration_output_file + } + ] + }} diff --git a/tests/unit/modules/catalyst/fixtures/sda_host_port_migration_playbook_config_generator.json b/tests/unit/modules/catalyst/fixtures/sda_host_port_migration_playbook_config_generator.json new file mode 100644 index 000000000..af6376dba --- /dev/null +++ b/tests/unit/modules/catalyst/fixtures/sda_host_port_migration_playbook_config_generator.json @@ -0,0 +1,191 @@ +{ + "playbook_config_one_to_one": { + "component_specific_filters": { + "port_assignments": [ + { + "fabric_site_name_hierarchy": "Global/USA/San Jose/Building1", + "source_device_ip": "10.10.10.101", + "destination_device_ip": "10.10.20.201" + } + ] + } + }, + "playbook_config_port_channels_only": { + "component_specific_filters": { + "port_channels": [ + { + "fabric_site_name_hierarchy": "Global/USA/San Jose/Building1", + "source_device_ip": "10.10.10.101", + "destination_device_ip": "10.10.20.201", + "interface_mappings": [ + { + "source_interface_name": "GigabitEthernet1/0/2", + "destination_interface_name": "GigabitEthernet1/0/26" + } + ] + } + ] + } + }, + "playbook_config_partial_remap": { + "component_specific_filters": { + "port_assignments": [ + { + "fabric_site_name_hierarchy": "Global/USA/San Jose/Building1", + "source_device_ip": "10.10.10.101", + "destination_device_ip": "10.10.20.201", + "interface_mappings": [ + { + "source_interface_name": "GigabitEthernet1/0/1", + "destination_interface_name": "GigabitEthernet1/0/25" + }, + { + "source_interface_name": "GigabitEthernet1/0/99", + "destination_interface_name": "GigabitEthernet1/0/26" + } + ] + } + ] + } + }, + "playbook_config_assignments_and_channels": { + "component_specific_filters": { + "port_assignments": [ + { + "fabric_site_name_hierarchy": "Global/USA/San Jose/Building1", + "source_device_ip": "10.10.10.101", + "destination_device_ip": "10.10.20.201", + "interface_mappings": [ + { + "source_interface_name": "GigabitEthernet1/0/1", + "destination_interface_name": "GigabitEthernet1/0/25" + } + ] + } + ], + "port_channels": [ + { + "fabric_site_name_hierarchy": "Global/USA/San Jose/Building1", + "source_device_ip": "10.10.10.101", + "destination_device_ip": "10.10.20.201", + "interface_mappings": [ + { + "source_interface_name": "GigabitEthernet1/0/2", + "destination_interface_name": "GigabitEthernet1/0/26" + } + ] + } + ] + } + }, + "get_port_assignments_response": { + "response": [ + { + "id": "port-assign-001", + "fabricId": "fabric-001", + "networkDeviceId": "device-001", + "interfaceName": "GigabitEthernet1/0/1", + "connectedDeviceType": "USER_DEVICE", + "dataVlanName": "Data_VLAN_100", + "voiceVlanName": "Voice_VLAN_150", + "authenticateTemplateName": "No Authentication", + "securityGroupName": "SGT_Employees", + "interfaceDescription": "User Port 1" + }, + { + "id": "port-assign-002", + "fabricId": "fabric-001", + "networkDeviceId": "device-001", + "interfaceName": "GigabitEthernet1/0/2", + "connectedDeviceType": "USER_DEVICE", + "dataVlanName": "Data_VLAN_200", + "authenticateTemplateName": "Closed Authentication", + "interfaceDescription": "User Port 2" + }, + { + "id": "port-assign-003", + "fabricId": "fabric-001", + "networkDeviceId": "device-002", + "interfaceName": "GigabitEthernet1/0/3", + "connectedDeviceType": "USER_DEVICE", + "dataVlanName": "Data_VLAN_300", + "authenticateTemplateName": "No Authentication", + "interfaceDescription": "Other Device Port" + } + ], + "version": "1.0" + }, + "get_port_channels_response": { + "response": [ + { + "id": "port-channel-001", + "fabricId": "fabric-001", + "networkDeviceId": "device-001", + "interfaceNames": [ + "GigabitEthernet1/0/2", + "GigabitEthernet1/0/4" + ], + "connectedDeviceType": "TRUNK", + "protocol": "LACP", + "description": "Uplink Port Channel" + }, + { + "id": "port-channel-002", + "fabricId": "fabric-001", + "networkDeviceId": "device-002", + "interfaceNames": [ + "GigabitEthernet1/0/5", + "GigabitEthernet1/0/6" + ], + "connectedDeviceType": "TRUNK", + "protocol": "LACP", + "description": "Other Device Port Channel" + } + ], + "version": "1.0" + }, + "get_device_by_id_response_device_001": { + "response": { + "hostname": "TB3-BGL-EDGE1.autoagni1.com", + "managementIpAddress": "10.10.10.101", + "serialNumber": "FJC2413E16S", + "id": "device-001" + }, + "version": "1.0" + }, + "get_device_by_id_response_device_002": { + "response": { + "hostname": "TB3-BGL-EDGE2.autoagni1.com", + "managementIpAddress": "10.10.10.102", + "serialNumber": "FJC2413E17S", + "id": "device-002" + }, + "version": "1.0" + }, + "get_fabric_sites_response": { + "response": [ + { + "id": "fabric-001", + "siteId": "site-001", + "authenticationProfileName": "No Authentication", + "isPubSubEnabled": true + } + ], + "version": "1.0" + }, + "get_sites_response": { + "response": [ + { + "id": "site-001", + "nameHierarchy": "Global/USA/San Jose/Building1", + "name": "Building1", + "type": "area" + } + ], + "version": "1.0" + }, + "empty_response": { + "response": [], + "version": "1.0" + } +} diff --git a/tests/unit/modules/catalyst/test_sda_host_port_migration_playbook_config_generator.py b/tests/unit/modules/catalyst/test_sda_host_port_migration_playbook_config_generator.py new file mode 100644 index 000000000..9f1ee4632 --- /dev/null +++ b/tests/unit/modules/catalyst/test_sda_host_port_migration_playbook_config_generator.py @@ -0,0 +1,167 @@ +# Copyright (c) 2026 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from unittest.mock import patch, mock_open +import yaml +from ansible_collections.cisco.catalystcenter.plugins.modules import ( + sda_host_port_migration_playbook_config_generator, +) +from .catalystcenter_module import TestCatalystModule, set_module_args, loadPlaybookData + + +class TestSdaHostPortMigrationPlaybookConfigGenerator(TestCatalystModule): + module = sda_host_port_migration_playbook_config_generator + test_data = loadPlaybookData("sda_host_port_migration_playbook_config_generator") + + playbook_config_one_to_one = test_data.get("playbook_config_one_to_one") + playbook_config_port_channels_only = test_data.get( + "playbook_config_port_channels_only" + ) + playbook_config_partial_remap = test_data.get("playbook_config_partial_remap") + playbook_config_assignments_and_channels = test_data.get( + "playbook_config_assignments_and_channels" + ) + + def setUp(self): + super(TestSdaHostPortMigrationPlaybookConfigGenerator, self).setUp() + + self.mock_catalystcenter_init = patch( + "ansible_collections.cisco.catalystcenter.plugins.module_utils.catalystcenter.CatalystCenterSDK.__init__" + ) + self.run_catalystcenter_init = self.mock_catalystcenter_init.start() + self.run_catalystcenter_init.side_effect = [None] + + self.mock_catalystcenter_exec = patch( + "ansible_collections.cisco.catalystcenter.plugins.module_utils.catalystcenter.CatalystCenterSDK._exec" + ) + self.run_catalystcenter_exec = self.mock_catalystcenter_exec.start() + + self.load_fixtures() + + def tearDown(self): + super(TestSdaHostPortMigrationPlaybookConfigGenerator, self).tearDown() + self.mock_catalystcenter_exec.stop() + self.mock_catalystcenter_init.stop() + + def load_fixtures(self, response=None, device=""): + def mock_catalystcenter_exec(family, function, op_modifies=False, params=None): + if function == "get_port_assignments": + return self.test_data.get("get_port_assignments_response") + elif function == "get_port_channels": + return self.test_data.get("get_port_channels_response") + elif function == "get_device_by_id": + if params and params.get("id") == "device-002": + return self.test_data.get("get_device_by_id_response_device_002") + return self.test_data.get("get_device_by_id_response_device_001") + elif function == "get_fabric_sites": + return self.test_data.get("get_fabric_sites_response") + elif function == "get_sites": + return self.test_data.get("get_sites_response") + return self.test_data.get("empty_response", {"response": []}) + + self.run_catalystcenter_exec.side_effect = mock_catalystcenter_exec + + def _get_written_yaml(self, mock_file): + handle = mock_file() + writes = [call.args[0] for call in handle.write.call_args_list] + return "".join(writes) + + def _base_config_args(self, config): + return { + "catalystcenter_host": "1.2.3.4", + "catalystcenter_username": "admin", + "catalystcenter_password": "pass", + "catalystcenter_version": "2.3.7.9", + "file_path": "/tmp/sda_host_port_migration.yaml", + "file_mode": "overwrite", + "state": "gathered", + "config": config, + } + + @patch("builtins.open", new_callable=mock_open) + def test_one_to_one_migration_uses_destination_device_ip(self, mock_file): + set_module_args(self._base_config_args(self.playbook_config_one_to_one)) + + result = self.execute_module(changed=True) + + self.assertEqual(result["changed"], True) + mock_file.assert_called() + data = yaml.safe_load(self._get_written_yaml(mock_file)) + self.assertIn("config", data) + self.assertEqual(data["config"][0]["ip_address"], "10.10.20.201") + self.assertEqual( + [item["interface_name"] for item in data["config"][0]["port_assignments"]], + ["GigabitEthernet1/0/1", "GigabitEthernet1/0/2"], + ) + + @patch("builtins.open", new_callable=mock_open) + def test_partial_interface_remap_keeps_unmapped_interfaces_one_to_one(self, mock_file): + set_module_args(self._base_config_args(self.playbook_config_partial_remap)) + + result = self.execute_module(changed=True) + + self.assertEqual(result["changed"], True) + data = yaml.safe_load(self._get_written_yaml(mock_file)) + destination_interfaces = [ + item["interface_name"] for item in data["config"][0]["port_assignments"] + ] + self.assertEqual( + destination_interfaces, + ["GigabitEthernet1/0/25", "GigabitEthernet1/0/2"], + ) + + @patch("builtins.open", new_callable=mock_open) + def test_port_channels_only_migration_uses_destination_device_ip(self, mock_file): + set_module_args( + self._base_config_args(self.playbook_config_port_channels_only) + ) + + result = self.execute_module(changed=True) + + self.assertEqual(result["changed"], True) + data = yaml.safe_load(self._get_written_yaml(mock_file)) + self.assertEqual(len(data["config"]), 1) + self.assertEqual(data["config"][0]["ip_address"], "10.10.20.201") + self.assertNotIn("port_assignments", data["config"][0]) + self.assertEqual( + data["config"][0]["port_channels"][0]["interface_names"], + ["GigabitEthernet1/0/26", "GigabitEthernet1/0/4"], + ) + + @patch("builtins.open", new_callable=mock_open) + def test_config_generates_merged_assignments_and_channels(self, mock_file): + set_module_args( + self._base_config_args(self.playbook_config_assignments_and_channels) + ) + + result = self.execute_module(changed=True) + + self.assertEqual(result["changed"], True) + data = yaml.safe_load(self._get_written_yaml(mock_file)) + self.assertEqual(len(data["config"]), 1) + self.assertEqual(data["config"][0]["ip_address"], "10.10.20.201") + self.assertIn("port_assignments", data["config"][0]) + self.assertIn("port_channels", data["config"][0]) + self.assertEqual( + [item["interface_name"] for item in data["config"][0]["port_assignments"]], + ["GigabitEthernet1/0/25", "GigabitEthernet1/0/2"], + ) + self.assertEqual( + data["config"][0]["port_channels"][0]["interface_names"], + ["GigabitEthernet1/0/26", "GigabitEthernet1/0/4"], + )