diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 6259ed78c6..45143ba82e 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -2045,3 +2045,32 @@ releases: - Changes in wireless_design_playbook_config_generator module - Changes in wireless_design_workflow_manager module - Added 'member_template_deployment_info' attribute in template_workflow_manager module + + 6.51.0: + release_date: "2026-04-23" + changes: + release_summary: Minor changes in workflow manager modules + minor_changes: + - Changes in application_policy_workflow_manager module + - Changes in assurance_icap_settings_workflow_manager module + - Changes in assurance_issue_playbook_config_generator module + - Changes in device_credential_playbook_config_generator module + - Changes in events_and_notifications_playbook_config_generator module + - Changes in events_and_notifications_workflow_manager module + - Changes in inventory_playbook_config_generator module + - Changes in lan_automation_workflow_manager module + - Changes in network_profile_switching_playbook_config_generator module + - Changes in network_profile_wireless_playbook_config_generator module + - Changes in pnp_playbook_config_generator module + - Changes in sda_fabric_devices_playbook_config_generator module + - Changes in sda_fabric_devices_workflow_manager module + - Changes in sda_fabric_sites_zones_playbook_config_generator module + - Changes in sda_fabric_virtual_networks_workflow_manager module + - Changes in tags_playbook_config_generator module + - Changes in tags_workflow_manager module + - Changes in template_workflow_manager module + - Changes in user_role_playbook_config_generator module + - Changes in user_role_workflow_manager module + - Changes in wired_campus_automation_playbook_config_generator module + - Changes in wired_campus_automation_workflow_manager module + - Changes in wireless_design_playbook_config_generator module diff --git a/galaxy.yml b/galaxy.yml index 29049c86fd..f039df3e31 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: cisco name: dnac -version: 6.50.0 +version: 6.51.0 readme: README.md authors: - Rafael Campos diff --git a/playbooks/inventory_playbook_config_generator.yml b/playbooks/inventory_playbook_config_generator.yml index f49022d28a..c8fc407904 100644 --- a/playbooks/inventory_playbook_config_generator.yml +++ b/playbooks/inventory_playbook_config_generator.yml @@ -1,1139 +1,71 @@ --- -# =================================================================================================== -# INVENTORY PLAYBOOK GENERATOR - KEY SCENARIOS -# =================================================================================================== -# -# This playbook demonstrates key scenarios for generating YAML configurations -# for the inventory_workflow_manager module based on existing device inventory in -# Cisco Catalyst Center. -# -# KEY FEATURES: -# 1. Three Independent Components: device_details, provision_device, interface_details -# 2. Global Filters: ip_address_list applies to ALL components -# 3. Component-Specific Filters: Independent filtering for each component -# 4. Three-Document Output: Separate YAML documents for each component -# 5. HTTP Fields Support: http_username, http_password, http_port, http_secure -# 6. Smart File Creation: No file created if no data matches filters -# 7. Preferred Syntax: file_path and file_mode are top-level module arguments -# -# =================================================================================================== - -- name: Cisco Catalyst Center Inventory Playbook Generator - Key Scenarios +- name: Generate Inventory YAML Playbook Configurations from Cisco Catalyst Center hosts: localhost connection: local gather_facts: false vars_files: - "credentials.yml" + module_defaults: + cisco.dnac.inventory_playbook_config_generator: + dnac_host: "{{ dnac_host }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_port: "{{ dnac_port }}" + dnac_version: "{{ dnac_version }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: true + dnac_log_level: DEBUG + state: gathered tasks: - # =================================================================================== - # SCENARIO 1: Complete Discovery - All Components - # =================================================================================== - # Description: Auto-discovers ALL devices and generates all three components - # Use Case: Initial migration, complete infrastructure backup - # Output: Single YAML with 3 documents (device details, provision, interfaces) - # =================================================================================== - - name: "SCENARIO 1: Complete Discovery - All Components" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_all_devices_complete.yml" - file_mode: "overwrite" - tags: [scenario1, complete_discovery] - - # =================================================================================== - # SCENARIO 2: Specific IPs - All Three Components - # =================================================================================== - # Description: Generate all three components for specific IP addresses - # Use Case: Targeted device migration with full configuration - # Output: 3 documents for specified IPs - # =================================================================================== - - name: "SCENARIO 2: Specific IPs - All Three Components" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_specific_ips.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "172.27.248.223" - - "204.1.2.3" - - "205.1.2.67" - component_specific_filters: - components_list: ["device_details", "provision_device", "interface_details"] - tags: [scenario2, specific_ips] - - # =================================================================================== - # SCENARIO 3: Device Details Only - # =================================================================================== - # Description: Generate only device details component (credentials and basic info) - # Use Case: Device inventory only, no provisioning or interface changes - # Output: Single document with device credentials - # =================================================================================== - - name: "SCENARIO 3: Device Details Only" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_device_details_only.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "172.27.248.223" - - "204.1.2.2" - component_specific_filters: - components_list: ["device_details"] - tags: [scenario3, device_details_only] - - # =================================================================================== - # SCENARIO 4: Provision Device Only - Site Filter - # =================================================================================== - # Description: Generate only provision config for devices at a specific site - # Use Case: Site-specific provisioning without modifying device credentials - # Output: Single document with provision config - # =================================================================================== - - name: "SCENARIO 4: Provision Device Only - Site Filter" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_provision_only.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["provision_device"] - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1" - tags: [scenario4, provision_only] - - # =================================================================================== - # SCENARIO 5: Interface Details Only - # =================================================================================== - # Description: Generate only interface details for specific devices - # Use Case: Interface audit, VLAN configuration review - # Output: Single document with interface configurations - # =================================================================================== - - name: "SCENARIO 5: Interface Details Only" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_interface_only.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "205.1.2.67" - - "205.1.2.68" - component_specific_filters: - components_list: ["interface_details"] - tags: [scenario5, interface_only] - - # =================================================================================== - # SCENARIO 6: Independent Component Filters - # =================================================================================== - # Description: Demonstrates INDEPENDENT filtering for different components: - # - device_details: Only ACCESS role devices - # - provision_device: Only specific site (independent of role filter) - # - interface_details: All devices from global filter - # Use Case: Show that each component filters independently - # Output: 3 documents with different device sets based on independent filters - # =================================================================================== - - name: "SCENARIO 6: Independent Component Filters" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_independent_filters.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details", "provision_device", "interface_details"] - device_details: - role: "ACCESS" - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1" - tags: [scenario6, independent_filters] - - # =================================================================================== - # SCENARIO 7: Global Filter + Component-Specific Filters - # =================================================================================== - # Description: Global IP filter applies to ALL components, then provision filter applied - # Use Case: Target specific devices, then filter provision by site - # Output: 3 documents, all using same global IPs, provision filtered by site - # =================================================================================== - - name: "SCENARIO 7: Global + Component-Specific Filters" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_global_component_filters.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "172.27.248.223" - - "172.27.248.224" - - "205.1.2.67" - component_specific_filters: - components_list: ["device_details", "provision_device", "interface_details"] - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1" - tags: [scenario7, global_component] - - # =================================================================================== - # SCENARIO 8: Role Filter - ACCESS Devices - # =================================================================================== - # Description: Generate device details for ACCESS role devices only - # Use Case: Access layer device inventory - # Output: Single document with ACCESS role devices - # =================================================================================== - - name: "SCENARIO 8: ACCESS Role Devices" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_access_role.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details"] - device_details: - role: "ACCESS" - tags: [scenario8, access_role] - - # =================================================================================== - # SCENARIO 9: Multiple Roles - OR Logic - # =================================================================================== - # Description: Generate device details for multiple roles (ACCESS OR BORDER ROUTER) - # Use Case: Combined access and border device inventory - # Output: Single document with devices matching ANY of the specified roles - # =================================================================================== - - name: "SCENARIO 9: Multiple Roles - OR Logic" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_multi_role.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details"] - device_details: - role: ["ACCESS", "BORDER ROUTER", "CORE"] - tags: [scenario9, multi_role] - - # =================================================================================== - # SCENARIO 10: Device Details + Provision - # =================================================================================== - # Description: Generate device details and provision, skip interface details - # Use Case: Device onboarding without interface changes - # Output: 2 documents - device details and provision - # =================================================================================== - - name: "SCENARIO 10: Device Details + Provision" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_device_provision.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "172.27.248.223" - component_specific_filters: - components_list: ["device_details", "provision_device"] - tags: [scenario10, device_provision] - - # =================================================================================== - # SCENARIO 11: Multiple Sites - Independent Files - # =================================================================================== - # Description: Generate multiple files for different sites - # Use Case: Multi-site deployment with site-specific configs - # Output: Multiple YAML files, each with different site provision configs - # =================================================================================== - # Site 1: Bangalore BLD_1 - - name: "SCENARIO 11a: Multiple Sites - Site 1 Bangalore BLD_1" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_site_bangalore_bld1.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["provision_device"] - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore/BLD_1/Floor_1" - tags: [scenario11, multi_site] - - # Site 2: Bangalore BLD_2 - - name: "SCENARIO 11b: Multiple Sites - Site 2 Bangalore BLD_2" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_site_bangalore_bld2.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["provision_device"] - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1" - tags: [scenario11, multi_site] - - # =================================================================================== - # SCENARIO 12: Default File Path - Auto-Generated Name - # =================================================================================== - # Description: No file_path specified - generates with timestamp - # Use Case: Quick discovery without worrying about naming - # Output: File named inventory_workflow_manager_playbook_.yml - # =================================================================================== - - name: "SCENARIO 12: Default File Path (Auto-Generated)" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - config: - global_filters: - ip_address_list: - - "172.27.248.223" - component_specific_filters: - components_list: ["device_details"] - tags: [scenario12, default_path] - - # =================================================================================== - # SCENARIO 13: Interface Details with Single Interface Name Filter - # =================================================================================== - # Description: Filter interface_details to include only specific interface names - # Use Case: Focus on specific VLAN or Loopback interface configuration - # Output: Single document with only specified interface names (e.g., Vlan100) - # =================================================================================== - - name: "SCENARIO 13: Interface Details - Single Interface Filter (Vlan100)" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_interface_vlan100_only.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["interface_details"] - interface_details: - interface_name: ["Vlan100"] - tags: [scenario13, interface_single_filter] - - # =================================================================================== - # SCENARIO 14: Interface Details with Multiple Interface Name Filters - # =================================================================================== - # Description: Filter interface_details to include multiple specific interface names - # Use Case: Audit and configure multiple critical interfaces across all devices - # Output: Single document with only specified interface names (Vlan100, Loopback0, etc.) - # =================================================================================== - - name: "SCENARIO 14: Interface Details - Multiple Interface Filters" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_interface_multi_filter.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["interface_details"] - interface_details: - interface_name: ["Vlan100", "Vlan111", "Loopback0"] - tags: [scenario14, interface_multi_filter] - - # =================================================================================== - # SCENARIO 15: Global IP Filter + Interface Name Filter - # =================================================================================== - # Description: Combine global IP filter with specific interface name filter - # Use Case: Get specific interfaces only from targeted devices - # Output: Single document with only specified IPs and specified interfaces - # Note: If device doesn't have the specified interface, no config generated for it - # =================================================================================== - - name: "SCENARIO 15: Global IP + Interface Name Filter" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_ip_interface_filter.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "100.1.1.61" - - "172.27.248.223" - - "205.1.2.67" - component_specific_filters: - components_list: ["interface_details"] - interface_details: - interface_name: ["Vlan100", "Loopback0", "GigabitEthernet0/0"] - tags: [scenario15, ip_interface_filter] - - # =================================================================================== - # SCENARIO 16: Device Details + Interface Details with Interface Filter - # =================================================================================== - # Description: Generate device credentials and only specific interfaces - # Use Case: Onboard devices and configure specific interfaces simultaneously - # Output: 2 documents - device details and filtered interface configs - # =================================================================================== - - name: "SCENARIO 16: Device Details + Filtered Interfaces" + # Example 1: Auto-discovery mode (no config) + - name: Generate inventory playbook config for all discovered devices using default file path cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_device_filtered_interfaces.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "172.27.248.223" - - "205.1.2.67" - component_specific_filters: - components_list: ["device_details", "interface_details"] - interface_details: - interface_name: ["Loopback0", "Vlan100"] - tags: [scenario16, device_filtered_interface] + # No config provided - generates all inventory configurations - # =================================================================================== - # SCENARIO 17: All Components with Interface Filter - # =================================================================================== - # Description: Generate all three components with interface_details filtered by name - # Use Case: Complete infrastructure migration with controlled interface updates - # Output: 3 documents - device details, provision config, and filtered interfaces - # =================================================================================== - - name: "SCENARIO 17: All Components - Device + Provision + Filtered Interfaces" + # Example 2: Auto-discovery mode with custom output file + - name: Generate inventory playbook config with custom file path and overwrite mode cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_all_filtered_interfaces.yml" + file_path: "tmp/catc_inventory_config.yml" file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "172.27.248.223" - - "205.1.2.67" - component_specific_filters: - components_list: ["device_details", "provision_device", "interface_details"] - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1" - interface_details: - interface_name: ["Loopback0"] - tags: [scenario17, all_components_filtered] - # =================================================================================== - # SCENARIO 18: Interface Filter with No Matching Interfaces - # =================================================================================== - # Description: Filter interfaces that may not exist on all devices - # Use Case: Handle scenarios where some devices don't have specified interfaces - # Output: Only generates configs for devices that have the specified interfaces - # Note: No file created if no devices have matching interfaces - # =================================================================================== - - name: "SCENARIO 18: Interface Filter - No Match Handling" + # Example 3: Filter by device roles + - name: Generate inventory playbook config filtered by role cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_interface_no_match.yml" + file_path: "tmp/catc_filtered_inventory_config.yml" file_mode: "overwrite" config: global_filters: - ip_address_list: - - "172.27.248.223" - - "172.27.248.224" - - "172.27.248.82" - component_specific_filters: - components_list: ["interface_details"] - interface_details: - interface_name: ["TenGigabitEthernet1/1/1"] # Might not exist on all devices - tags: [scenario18, interface_no_match] - - # =================================================================================== - # SCENARIO 19: GigabitEthernet Interface Filter - # =================================================================================== - # Description: Filter for physical GigabitEthernet interfaces only - # Use Case: Configure access ports or uplinks specifically - # Output: Single document with GigabitEthernet interface configs only - # =================================================================================== - - name: "SCENARIO 19: GigabitEthernet Interfaces Only" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_gigabitethernet_only.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["interface_details"] - interface_details: - interface_name: ["GigabitEthernet0/0", "GigabitEthernet1/0/11", "GigabitEthernet1/0/12"] - tags: [scenario19, gigabitethernet_only] - - # =================================================================================== - # SCENARIO 20: Role-Based Devices with Interface Filter - # =================================================================================== - # Description: Get ACCESS role devices and filter their specific interfaces - # Use Case: Configure access layer devices with specific interface updates - # Output: 2 documents - filtered device details (ACCESS only) and their interfaces - # =================================================================================== - - name: "SCENARIO 20: ACCESS Devices with Specific Interface Filter" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_access_devices_interface_filter.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details", "interface_details"] - device_details: - role: "ACCESS" - interface_details: - interface_name: ["GigabitEthernet0/0", "GigabitEthernet0/1"] - tags: [scenario20, access_devices_interface] - # =================================================================================== - # SCENARIO 21: User Defined Fields Only - # =================================================================================== - # Description: Generate only user_defined_fields configuration for all devices - # Use Case: Inventory audit of custom UDF values assigned to devices - # Output: Single document with consolidated UDF entries grouped by identical UDF sets - # =================================================================================== - - name: "SCENARIO 21: User Defined Fields Only" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_udf_only.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["user_defined_fields"] - tags: [scenario21, udf_only] - - # =================================================================================== - # SCENARIO 22: All Components Including User Defined Fields - # =================================================================================== - # Description: Generate all four components: device_details, provision_device, - # interface_details, and user_defined_fields - # Use Case: Complete infrastructure migration with all metadata and custom fields - # Output: 4 YAML documents with all configurations - # =================================================================================== - - name: "SCENARIO 22: All Components Including User Defined Fields" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_all_components_with_udf.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details", "provision_device", "interface_details", "user_defined_fields"] - tags: [scenario22, all_components_udf] - - # =================================================================================== - # SCENARIO 23: Device Details + User Defined Fields - # =================================================================================== - # Description: Generate device credentials and their user-defined field assignments - # Use Case: Device onboarding with custom metadata preservation - # Output: 2 documents - device details and UDF configurations - # =================================================================================== - - name: "SCENARIO 23: Device Details + User Defined Fields" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_device_udf.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details", "user_defined_fields"] - tags: [scenario23, device_udf] - - # =================================================================================== - # SCENARIO 24: Global IP Filter + User Defined Fields - # =================================================================================== - # Description: Generate UDF configurations only for devices at specific IP addresses - # Use Case: Audit custom fields for specific set of devices - # Output: UDF entries for devices matching the IP filter - # Note: Replace IPs with actual device management IPs from your environment - # =================================================================================== - - name: "SCENARIO 24: Global IP Filter + User Defined Fields" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_ip_filter_udf.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "206.1.2.3" - - "206.1.2.4" - - "172.27.248.223" - component_specific_filters: - components_list: ["user_defined_fields"] - tags: [scenario24, ip_filter_udf] - - # =================================================================================== - # SCENARIO 25: Provision Device + User Defined Fields (Site-Specific) - # =================================================================================== - # Description: Generate provisioning config for specific site and UDF for all devices - # Use Case: Site provisioning with device metadata tracking - # Output: 2 documents - provision_device (site-filtered) and user_defined_fields (all) - # =================================================================================== - - name: "SCENARIO 25: Provision Device Site Filter + UDF" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_provision_site_udf.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["provision_device", "user_defined_fields"] - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1" - tags: [scenario25, provision_site_udf] - - # =================================================================================== - # SCENARIO 26: Interface Details + User Defined Fields - # =================================================================================== - # Description: Generate interface configurations and device custom field assignments - # Use Case: Interface updates with device metadata synchronization - # Output: 2 documents - interface_details (filtered or all) and user_defined_fields - # =================================================================================== - - name: "SCENARIO 26: Interface Details + User Defined Fields" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_interface_udf.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["interface_details", "user_defined_fields"] - tags: [scenario26, interface_udf] - - # =================================================================================== - # SCENARIO 27: Interface Filter + UDF (Specific Interfaces Only) - # =================================================================================== - # Description: Generate UDF and only specific interface names - # Use Case: Configure critical interfaces and sync custom device metadata - # Output: 2 documents - filtered interface_details and user_defined_fields - # =================================================================================== - - name: "SCENARIO 27: Specific Interfaces + User Defined Fields" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_interface_name_udf.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["interface_details", "user_defined_fields"] - interface_details: - interface_name: ["Loopback0", "Vlan100"] - tags: [scenario27, interface_name_udf] - - # =================================================================================== - # SCENARIO 28: Role-Based Device Details + UDF - # =================================================================================== - # Description: Generate UDF for devices of specific roles (e.g., ACCESS layer) - # Use Case: Access layer device migration with preserved custom metadata - # Output: 2 documents - role-filtered device_details and corresponding user_defined_fields - # =================================================================================== - - name: "SCENARIO 28: Role-Based Device Details + UDF" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_access_role_udf.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details", "user_defined_fields"] - device_details: - role: "ACCESS" - tags: [scenario28, role_based_udf] + device_roles: ["ACCESS", "CORE"] + device_identifier: "hostname" - # =================================================================================== - # SCENARIO 29: Complex Multi-Filter with UDF - # =================================================================================== - # Description: Multiple global filters with device roles and provision site + UDF - # Use Case: Multi-site, multi-role infrastructure with comprehensive metadata - # Output: 4 documents - filtered device_details, provision_device, interfaces, and UDF - # =================================================================================== - - name: "SCENARIO 29: Multi-Filter Complex Scenario with UDF" + # Example 4: Filter using explicit device identifiers + - name: Generate inventory playbook config for selected devices by IP and serial number cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_complex_multi_filter_udf.yml" - file_mode: "overwrite" + file_path: "tmp/catc_inventory_selected_devices.yml" + file_mode: "append" config: global_filters: - ip_address_list: - - "172.27.248.223" - - "172.27.248.224" - - "205.1.2.67" - component_specific_filters: - components_list: ["device_details", "provision_device", "interface_details", "user_defined_fields"] - device_details: - role: "ACCESS" - provision_device: - site_name: "Global/Site_India/Karnataka/Bangalore" - interface_details: - interface_name: ["Loopback0", "Vlan100"] - tags: [scenario29, complex_multi_filter_udf] + devices: + - "10.10.20.11" + - "FDO1234A1BC" - # =================================================================================== - # SCENARIO 30: UDF Audit - All Devices with Custom Metadata - # =================================================================================== - # Description: Comprehensive UDF audit showing all devices and their custom field values - # Use Case: Complete inventory audit with custom metadata for compliance reporting - # Output: Consolidated UDF configurations grouped by identical field sets - # Note: Uses generate_all_configurations for maximum device coverage - # =================================================================================== - - name: "SCENARIO 30: Complete UDF Audit - All Devices" + # Example 5: Filter by role only with default device identifier + - name: Generate inventory playbook config for distribution and border devices cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_udf_audit_complete.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["user_defined_fields"] - tags: [scenario30, udf_audit] - - # =================================================================================== - # SCENARIO 31: UDF Name Filter - Specific Field Names Only - # =================================================================================== - # Description: Generate UDF configurations but only for specified UDF field names - # Use Case: Focus on specific custom fields (e.g., only "Cisco Switches" and "To_test_udf") - # Output: UDF entries grouped by identical field sets (only specified names) - # =================================================================================== - - name: "SCENARIO 31: UDF Name Filter - Specific Field Names" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_udf_name_filter.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["user_defined_fields"] - user_defined_fields: - name: ["Cisco Switches", "To_test_udf"] - tags: [scenario31, udf_name_filter] - - # =================================================================================== - # SCENARIO 32: UDF Value Filter - Specific Field Values Only - # =================================================================================== - # Description: Generate UDF configurations but only for devices matching specific values - # Use Case: Audit devices with any of the specified values (OR logic) - # Output: UDF entries only where values match any in the list - # Note: Matches devices with values: 2234, value123, or value12345 - # =================================================================================== - - name: "SCENARIO 32: UDF Value Filter - Specific Field Values" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_udf_value_filter.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["user_defined_fields"] - user_defined_fields: - value: ["2234", "value123", "value12345"] - tags: [scenario32, udf_value_filter] - - # =================================================================================== - # SCENARIO 33: Global IP Filter + UDF Name Filter - # =================================================================================== - # Description: Generate UDF for specific IPs but only for specified UDF field names - # Use Case: Audit custom fields for targeted devices - # Output: UDF entries for devices matching IP filter and field names - # =================================================================================== - - name: "SCENARIO 33: Global IP Filter + UDF Name Filter" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_ip_udf_name_filter.yml" + file_path: "tmp/catc_inventory_roles_only.yml" file_mode: "overwrite" config: global_filters: - ip_address_list: - - "206.1.2.4" - - "204.1.2.2" - - "204.1.2.3" - component_specific_filters: - components_list: ["user_defined_fields"] - user_defined_fields: - name: ["Cisco Switches", "Test123"] - tags: [scenario33, ip_udf_name_filter] - - # =================================================================================== - # SCENARIO 34: Device Details + Filtered UDF Names - # =================================================================================== - # Description: Generate device credentials with only specific UDF field names - # Use Case: Device migration preserving only critical custom metadata - # Output: 2 documents - device_details and UDF (name-filtered) - # =================================================================================== - - name: "SCENARIO 34: Device Details + Filtered UDF Names" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_device_udf_name_filter.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["device_details", "user_defined_fields"] - user_defined_fields: - name: ["Cisco Switches", "Test321"] - tags: [scenario34, device_udf_name_filter] + device_roles: ["DISTRIBUTION", "BORDER ROUTER"] - # =================================================================================== - # SCENARIO 35: All Components + UDF Name and Value Filters - # =================================================================================== - # Description: Complete migration with UDF filtered by both name and value - # Use Case: Multi-site deployment with specific metadata filtering - # Output: Multiple documents with filtered UDF entries - # Note: Filters by field names AND by specific values (combination filter) - # =================================================================================== - - name: "SCENARIO 35: All Components + UDF Name & Value Filters" + # Example 6: Filter by role with serial number as identifier list + - name: Generate inventory playbook config for unknown role devices cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_all_udf_filtered.yml" + file_path: "tmp/catc_inventory_roles_serial.yml" file_mode: "overwrite" config: global_filters: - ip_address_list: - - "206.1.2.4" - - "204.1.2.2" - - "204.1.2.3" - component_specific_filters: - components_list: ["device_details", "user_defined_fields"] - user_defined_fields: - name: ["Cisco Switches", "Test123", "Test321"] - value: ["2234", "value321"] - tags: [scenario35, all_components_udf_filtered] - - # =================================================================================== - # SCENARIO 36: UDF Name Filter - Single String - # =================================================================================== - # Description: Generate UDF using single name string (no list) - # Use Case: Validate string input support for user_defined_fields.name - # Output: UDF entries containing only "Cisco Switches" - # =================================================================================== - - name: "SCENARIO 36: UDF Name Filter - Single String" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_udf_name_filter_single.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["user_defined_fields"] - user_defined_fields: - name: "Cisco Switches" - tags: [scenario36, udf_name_filter_str] - - # =================================================================================== - # SCENARIO 37: UDF Value Filter - Single String - # =================================================================================== - # Description: Generate UDF using single value string (no list) - # Use Case: Validate string input support for user_defined_fields.value - # Output: UDF entries where value equals "value12345" - # =================================================================================== - - name: "SCENARIO 37: UDF Value Filter - Single String" - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_port: "{{ dnac_port }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - dnac_log: true - dnac_log_level: INFO - state: gathered - file_path: "inventory_udf_value_filter_single.yml" - file_mode: "overwrite" - config: - component_specific_filters: - components_list: ["user_defined_fields"] - user_defined_fields: - value: "value12345" - tags: [scenario37, udf_value_filter_str] + device_roles: ["UNKNOWN"] + device_identifier: "serial_number" diff --git a/playbooks/tags_playbook_config_generator.yml b/playbooks/tags_playbook_config_generator.yml index 7a4d364415..086b49d8a2 100644 --- a/playbooks/tags_playbook_config_generator.yml +++ b/playbooks/tags_playbook_config_generator.yml @@ -75,7 +75,7 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag"] + components_list: ["tags"] # Example 4: Generate only tag membership configurations - name: Generate tag memberships only @@ -131,7 +131,7 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] + components_list: ["tags", "tag_memberships"] # Example 6: Auto-populate components_list from component filters - name: Generate configuration with auto-populated components_list @@ -159,12 +159,12 @@ file_mode: "overwrite" config: component_specific_filters: - # No components_list specified, but tag filters are provided - # The 'tag' component will be automatically added to components_list - tag: + # No components_list specified, but tags filters are provided + # The 'tags' component will be automatically added to components_list + tags: - tag_name: Production - tag_name: Data-Center - # This will automatically include 'tag' in components_list and retrieve only those tags + # This will automatically include 'tags' in components_list and retrieve only those tags # Example 7: Filter specific tags by name - name: Generate configuration for specific tags by name @@ -192,8 +192,8 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] - tag: + components_list: ["tags", "tag_memberships"] + tags: - tag_name: Production - tag_name: Data-Center @@ -223,7 +223,7 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] + components_list: ["tags", "tag_memberships"] tag_memberships: - tag_name: Campus-Switches - tag_name: Core-Routers @@ -254,8 +254,8 @@ file_mode: "append" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] - tag: + components_list: ["tags", "tag_memberships"] + tags: - tag_name: Branch-Office - tag_name: Access-Points diff --git a/playbooks/tags_workflow_manager.yml b/playbooks/tags_workflow_manager.yml index 758a64c2f9..8d523444a3 100644 --- a/playbooks/tags_workflow_manager.yml +++ b/playbooks/tags_workflow_manager.yml @@ -22,89 +22,88 @@ state: merged config_verify: true config: - - tag: - name: Servers_Connected_Devices_and_Interfaces - description: Tag for devices and interfaces connected to servers - device_rules: - rule_descriptions: - - rule_name: device_name - search_pattern: equals - value: Border - operation: ILIKE - - rule_name: device_family - search_pattern: contains - value: "9300" - operation: ILIKE - - rule_name: device_series - search_pattern: equals - value: "9X" - operation: ILIKE - - rule_name: ip_address - search_pattern: starts_with - value: "10.197" - operation: ILIKE - - rule_name: location - search_pattern: contains - value: "Global/India" - operation: ILIKE - - rule_name: version - search_pattern: ends_with - value: "3.8.0" - operation: ILIKE - - port_rules: - scope_description: - scope_category: SITE - scope_members: - - GLOBAL - rule_descriptions: - - rule_name: speed - search_pattern: contains - value: "100000" - operation: ILIKE - - rule_name: admin_status - search_pattern: Equals - value: Active - operation: ILIKE - - rule_name: port_name - search_pattern: equals - value: TenGigaBitEthernet1/0/1 - operation: ILIKE - - rule_name: operational_status - search_pattern: contains - value: Active - operation: ILIKE - - rule_name: description - search_pattern: contains - value: Border To Fusion Link - operation: ILIKE + - tags: + - name: Servers_Connected_Devices_and_Interfaces + description: Tag for devices and interfaces connected to servers + device_rules: + rule_descriptions: + - rule_name: device_name + search_pattern: equals + value: Border + operation: ILIKE + - rule_name: device_family + search_pattern: contains + value: "9300" + operation: ILIKE + - rule_name: device_series + search_pattern: equals + value: "9X" + operation: ILIKE + - rule_name: ip_address + search_pattern: starts_with + value: "10.197" + operation: ILIKE + - rule_name: location + search_pattern: contains + value: "Global/India" + operation: ILIKE + - rule_name: version + search_pattern: ends_with + value: "3.8.0" + operation: ILIKE + port_rules: + scope_description: + scope_category: SITE + scope_members: + - GLOBAL + rule_descriptions: + - rule_name: speed + search_pattern: contains + value: "100000" + operation: ILIKE + - rule_name: admin_status + search_pattern: Equals + value: Active + operation: ILIKE + - rule_name: port_name + search_pattern: equals + value: TenGigaBitEthernet1/0/1 + operation: ILIKE + - rule_name: operational_status + search_pattern: contains + value: Active + operation: ILIKE + - rule_name: description + search_pattern: contains + value: Border To Fusion Link + operation: ILIKE - tag_memberships: - tags: - - Servers_Connected_Devices_and_Interfaces - device_details: - - ip_addresses: - - 10.197.156.97 - - 10.197.156.98 - - 10.197.156.99 - hostnames: - - SJC_Border1 - - SJC_Border2 - - NY_Border1 - mac_addresses: - - e4:38:7e:42:bc:00 - - 6c:d6:e3:75:5a:e0 - - 34:5d:a8:3b:d8:e0 - serial_numbers: - - SAD055006NE - - SAD04350EEU - - SAD055108C2 - port_names: - - FortyGigabitEthernet1/1/1 - - FortyGigabitEthernet1/1/2 - site_details: - - site_names: - - Global/prime_site_global/prime_site/Bengaluru - port_names: - - FortyGigabitEthernet1/1/1 - - FortyGigabitEthernet1/1/2 + - tags: + - Servers_Connected_Devices_and_Interfaces + device_details: + - ip_addresses: + - 10.197.156.97 + - 10.197.156.98 + - 10.197.156.99 + hostnames: + - SJC_Border1 + - SJC_Border2 + - NY_Border1 + mac_addresses: + - e4:38:7e:42:bc:00 + - 6c:d6:e3:75:5a:e0 + - 34:5d:a8:3b:d8:e0 + serial_numbers: + - SAD055006NE + - SAD04350EEU + - SAD055108C2 + port_names: + - FortyGigabitEthernet1/1/1 + - FortyGigabitEthernet1/1/2 + site_details: + - site_names: + - Global/prime_site_global/prime_site/Bengaluru + port_names: + - FortyGigabitEthernet1/1/1 + - FortyGigabitEthernet1/1/2 diff --git a/playbooks/wired_campus_automation_playbook_config_generator.yml b/playbooks/wired_campus_automation_playbook_config_generator.yml index 17bd3da6e0..b4e91e7452 100644 --- a/playbooks/wired_campus_automation_playbook_config_generator.yml +++ b/playbooks/wired_campus_automation_playbook_config_generator.yml @@ -9,7 +9,7 @@ # # =================================================================================================== -- name: Cisco DNA Center Brownfield Wired Campus Automation Playbook Generator Examples +- name: Cisco Catalyst Center Brownfield Wired Campus Automation Playbook Generator Examples hosts: localhost gather_facts: false vars_files: @@ -38,8 +38,7 @@ # cisco.dnac.wired_campus_automation_playbook_config_generator: # <<: *dnac_login # state: gathered - # config: - # - generate_all_configurations: true + # file_mode: "append" # register: result_generate_all # tags: [generate_all, complete] @@ -47,8 +46,8 @@ # cisco.dnac.wired_campus_automation_playbook_config_generator: # <<: *dnac_login # state: gathered - # config: - # - file_path: "/tmp/complete_infrastructure_config.yml" + # file_mode: "append" + # file_path: "/tmp/complete_infrastructure_config.yml" # register: result_custom_path # tags: [generate_all, custom_path] @@ -60,10 +59,11 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/wired_campus_automation_config.yml" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11", "192.168.1.12"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11", "192.168.1.12"] register: result_ip_filter tags: [device_filtering, ip_address] @@ -71,10 +71,11 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/single_device_config.yml" config: - - file_path: "/tmp/single_device_config.yml" - global_filters: - ip_address_list: ["204.1.2.3"] + global_filters: + ip_address_list: ["204.1.2.3"] register: result_single_ip tags: [device_filtering, single_device] @@ -86,10 +87,11 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/hostname_based_config.yml" config: - - file_path: "/tmp/hostname_based_config.yml" - global_filters: - hostname_list: ["switch01.lab.com", "switch02.lab.com", "core-switch-01"] + global_filters: + hostname_list: ["switch01.lab.com", "switch02.lab.com", "core-switch-01"] register: result_hostname_filter tags: [device_filtering, hostname] @@ -97,10 +99,11 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/core_switches_config.yml" config: - - file_path: "/tmp/core_switches_config.yml" - global_filters: - hostname_list: ["core-switch-01.lab.com", "core-switch-02.lab.com"] + global_filters: + hostname_list: ["core-switch-01.lab.com", "core-switch-02.lab.com"] register: result_core_switches tags: [device_filtering, core_switches] @@ -112,10 +115,11 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/serial_based_config.yml" config: - - file_path: "/tmp/serial_based_config.yml" - global_filters: - serial_number_list: ["FCW2140L05Y", "FCW2140L06Z", "9080V0I41J3"] + global_filters: + serial_number_list: ["FCW2140L05Y", "FCW2140L06Z", "9080V0I41J3"] register: result_serial_filter tags: [device_filtering, serial_number] @@ -127,12 +131,13 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/mixed_device_filter_config.yml" config: - - file_path: "/tmp/mixed_device_filter_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - hostname_list: ["switch03.lab.com"] - serial_number_list: ["FCW2140L05Y"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + hostname_list: ["switch03.lab.com"] + serial_number_list: ["FCW2140L05Y"] register: result_mixed_filter tags: [device_filtering, mixed_identification] @@ -144,12 +149,13 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/layer2_only_config.yml" config: - - file_path: "/tmp/layer2_only_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - components_list: ["layer2_configurations"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + components_list: ["layer2_configurations"] register: result_components_list tags: [component_filtering, layer2_only] @@ -157,14 +163,15 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/layer2_specific_features_config.yml" config: - - file_path: "/tmp/layer2_specific_features_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: ["vlans", "stp", "cdp"] + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + layer2_features: ["vlans", "stp", "cdp"] register: result_components_with_features tags: [component_filtering, specific_features] @@ -172,12 +179,13 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/all_layer2_features_config.yml" config: - - file_path: "/tmp/all_layer2_features_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - components_list: ["layer2_configurations"] + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + components_list: ["layer2_configurations"] register: result_all_layer2_features tags: [component_filtering, all_features] @@ -189,11 +197,12 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/security_only_config.yml" config: - - file_path: "/tmp/security_only_config.yml" - component_specific_filters: - layer2_configurations: - layer2_features: ["dhcp_snooping", "igmp_snooping", "authentication"] + component_specific_filters: + layer2_configurations: + layer2_features: ["dhcp_snooping", "igmp_snooping", "authentication"] register: result_security_features tags: [feature_filtering, security] @@ -201,13 +210,14 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/protocol_features_config.yml" config: - - file_path: "/tmp/protocol_features_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - layer2_configurations: - layer2_features: ["vlans", "stp", "cdp"] + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + layer2_configurations: + layer2_features: ["vlans", "stp", "cdp"] register: result_protocol_features tags: [feature_filtering, protocols] @@ -219,13 +229,14 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/vlans_only_config.yml" config: - - file_path: "/tmp/vlans_only_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - layer2_configurations: - layer2_features: ["vlans"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + layer2_configurations: + layer2_features: ["vlans"] register: result_vlans_only tags: [vlan_filtering, vlans_only] @@ -233,15 +244,16 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/specific_vlans_config.yml" config: - - file_path: "/tmp/specific_vlans_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - layer2_configurations: - layer2_features: ["vlans"] - vlans: - vlan_ids_list: ["10", "20", "100", "200"] + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + layer2_configurations: + layer2_features: ["vlans"] + vlans: + vlan_ids_list: ["10", "20", "100", "200"] register: result_specific_vlans tags: [vlan_filtering, specific_vlans] @@ -249,15 +261,16 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/production_vlans_config.yml" config: - - file_path: "/tmp/production_vlans_config.yml" - global_filters: - hostname_list: ["prod-switch-01", "prod-switch-02"] - component_specific_filters: - layer2_configurations: - layer2_features: ["vlans"] - vlans: - vlan_ids_list: ["100", "200", "300"] + global_filters: + hostname_list: ["prod-switch-01", "prod-switch-02"] + component_specific_filters: + layer2_configurations: + layer2_features: ["vlans"] + vlans: + vlan_ids_list: ["100", "200", "300"] register: result_production_vlans tags: [vlan_filtering, production] @@ -269,18 +282,19 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/specific_interfaces_config.yml" config: - - file_path: "/tmp/specific_interfaces_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - layer2_configurations: - layer2_features: ["port_configuration"] - port_configuration: - interface_names_list: - - "GigabitEthernet1/0/1" - - "GigabitEthernet1/0/2" - - "TenGigabitEthernet1/0/1" + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + layer2_configurations: + layer2_features: ["port_configuration"] + port_configuration: + interface_names_list: + - "GigabitEthernet1/0/1" + - "GigabitEthernet1/0/2" + - "TenGigabitEthernet1/0/1" register: result_specific_interfaces tags: [interface_filtering, specific_ports] @@ -288,19 +302,20 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/uplink_interfaces_config.yml" config: - - file_path: "/tmp/uplink_interfaces_config.yml" - global_filters: - hostname_list: ["access-switch-01", "access-switch-02"] - component_specific_filters: - layer2_configurations: - layer2_features: ["port_configuration"] - port_configuration: - interface_names_list: - - "GigabitEthernet1/0/23" - - "GigabitEthernet1/0/24" - - "TenGigabitEthernet1/0/1" - - "TenGigabitEthernet1/0/2" + global_filters: + hostname_list: ["access-switch-01", "access-switch-02"] + component_specific_filters: + layer2_configurations: + layer2_features: ["port_configuration"] + port_configuration: + interface_names_list: + - "GigabitEthernet1/0/23" + - "GigabitEthernet1/0/24" + - "TenGigabitEthernet1/0/1" + - "TenGigabitEthernet1/0/2" register: result_uplink_interfaces tags: [interface_filtering, uplinks] @@ -312,13 +327,14 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/protocol_features_config.yml" config: - - file_path: "/tmp/protocol_features_config.yml" - global_filters: - hostname_list: ["switch01.lab.com", "switch02.lab.com"] - component_specific_filters: - layer2_configurations: - layer2_features: ["cdp", "lldp", "stp", "vtp"] + global_filters: + hostname_list: ["switch01.lab.com", "switch02.lab.com"] + component_specific_filters: + layer2_configurations: + layer2_features: ["cdp", "lldp", "stp", "vtp"] register: result_protocol_config tags: [protocol_filtering, discovery_protocols] @@ -326,13 +342,14 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/stp_only_config.yml" config: - - file_path: "/tmp/stp_only_config.yml" - global_filters: - serial_number_list: ["FCW2140L05Y", "FCW2140L06Z"] - component_specific_filters: - layer2_configurations: - layer2_features: ["stp"] + global_filters: + serial_number_list: ["FCW2140L05Y", "FCW2140L06Z"] + component_specific_filters: + layer2_configurations: + layer2_features: ["stp"] register: result_stp_only tags: [protocol_filtering, stp] @@ -344,13 +361,14 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/security_features_config.yml" config: - - file_path: "/tmp/security_features_config.yml" - global_filters: - serial_number_list: ["FCW2140L05Y", "FCW2140L06Z"] - component_specific_filters: - layer2_configurations: - layer2_features: ["dhcp_snooping", "igmp_snooping", "authentication"] + global_filters: + serial_number_list: ["FCW2140L05Y", "FCW2140L06Z"] + component_specific_filters: + layer2_configurations: + layer2_features: ["dhcp_snooping", "igmp_snooping", "authentication"] register: result_security_config tags: [security_filtering, security_features] @@ -358,13 +376,14 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/authentication_config.yml" config: - - file_path: "/tmp/authentication_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - layer2_configurations: - layer2_features: ["authentication"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + layer2_configurations: + layer2_features: ["authentication"] register: result_authentication_only tags: [security_filtering, authentication] @@ -376,20 +395,21 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/comprehensive_filtered_config.yml" config: - - file_path: "/tmp/comprehensive_filtered_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: ["vlans", "stp", "port_configuration"] - vlans: - vlan_ids_list: ["10", "20", "100"] - port_configuration: - interface_names_list: - - "GigabitEthernet1/0/1" - - "GigabitEthernet1/0/24" + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + layer2_features: ["vlans", "stp", "port_configuration"] + vlans: + vlan_ids_list: ["10", "20", "100"] + port_configuration: + interface_names_list: + - "GigabitEthernet1/0/1" + - "GigabitEthernet1/0/24" register: result_comprehensive_filtering tags: [comprehensive, multi_filter] @@ -397,25 +417,26 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/all_components_config.yml" config: - - file_path: "/tmp/all_components_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: - - "vlans" - - "cdp" - - "lldp" - - "stp" - - "vtp" - - "dhcp_snooping" - - "igmp_snooping" - - "mld_snooping" - - "authentication" - - "logical_ports" - - "port_configuration" + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + layer2_features: + - "vlans" + - "cdp" + - "lldp" + - "stp" + - "vtp" + - "dhcp_snooping" + - "igmp_snooping" + - "mld_snooping" + - "authentication" + - "logical_ports" + - "port_configuration" register: result_all_components tags: [comprehensive, all_components] @@ -427,9 +448,10 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" config: - - global_filters: - ip_address_list: ["192.168.1.10"] + global_filters: + ip_address_list: ["192.168.1.10"] register: result_default_path tags: [default_settings, default_path] @@ -437,9 +459,10 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" config: - - global_filters: - hostname_list: ["lab-switch-01.test.com", "lab-switch-02.test.com"] + global_filters: + hostname_list: ["lab-switch-01.test.com", "lab-switch-02.test.com"] register: result_lab_default tags: [lab_environment, defaults] @@ -451,15 +474,16 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/distribution_layer_config.yml" config: - - file_path: "/tmp/distribution_layer_config.yml" - global_filters: - hostname_list: ["dist-switch-01", "dist-switch-02"] - component_specific_filters: - layer2_configurations: - layer2_features: ["vlans", "stp", "vtp", "logical_ports"] - vlans: - vlan_ids_list: ["10", "20", "30", "40", "100", "200"] + global_filters: + hostname_list: ["dist-switch-01", "dist-switch-02"] + component_specific_filters: + layer2_configurations: + layer2_features: ["vlans", "stp", "vtp", "logical_ports"] + vlans: + vlan_ids_list: ["10", "20", "30", "40", "100", "200"] register: result_distribution_layer tags: [campus_network, distribution] @@ -467,21 +491,22 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/access_layer_config.yml" config: - - file_path: "/tmp/access_layer_config.yml" - global_filters: - hostname_list: ["access-sw-01", "access-sw-02", "access-sw-03"] - component_specific_filters: - layer2_configurations: - layer2_features: ["vlans", "authentication", "dhcp_snooping", "port_configuration"] - vlans: - vlan_ids_list: ["10", "20", "100", "200"] - port_configuration: - interface_names_list: - - "GigabitEthernet1/0/1" - - "GigabitEthernet1/0/2" - - "GigabitEthernet1/0/3" - - "GigabitEthernet1/0/4" + global_filters: + hostname_list: ["access-sw-01", "access-sw-02", "access-sw-03"] + component_specific_filters: + layer2_configurations: + layer2_features: ["vlans", "authentication", "dhcp_snooping", "port_configuration"] + vlans: + vlan_ids_list: ["10", "20", "100", "200"] + port_configuration: + interface_names_list: + - "GigabitEthernet1/0/1" + - "GigabitEthernet1/0/2" + - "GigabitEthernet1/0/3" + - "GigabitEthernet1/0/4" register: result_access_layer tags: [campus_network, access_layer] @@ -489,18 +514,19 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/building_a_config.yml" config: - - file_path: "/tmp/building_a_config.yml" - global_filters: - hostname_list: - - "bldg-a-dist-01" - - "bldg-a-acc-01" - - "bldg-a-acc-02" - - "bldg-a-acc-03" - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: ["vlans", "cdp", "lldp", "stp", "authentication", "port_configuration"] + global_filters: + hostname_list: + - "bldg-a-dist-01" + - "bldg-a-acc-01" + - "bldg-a-acc-02" + - "bldg-a-acc-03" + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + layer2_features: ["vlans", "cdp", "lldp", "stp", "authentication", "port_configuration"] register: result_building_config tags: [building_specific, infrastructure] @@ -512,17 +538,18 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/compliance_audit_config.yml" config: - - file_path: "/tmp/compliance_audit_config.yml" - global_filters: - ip_address_list: - - "192.168.1.10" - - "192.168.1.11" - - "192.168.1.12" - - "192.168.1.13" - component_specific_filters: - layer2_configurations: - layer2_features: ["authentication", "dhcp_snooping", "igmp_snooping"] + global_filters: + ip_address_list: + - "192.168.1.10" + - "192.168.1.11" + - "192.168.1.12" + - "192.168.1.13" + component_specific_filters: + layer2_configurations: + layer2_features: ["authentication", "dhcp_snooping", "igmp_snooping"] register: result_compliance_audit tags: [maintenance, audit, compliance] @@ -530,15 +557,16 @@ cisco.dnac.wired_campus_automation_playbook_config_generator: <<: *dnac_login state: gathered + file_mode: "append" + file_path: "/tmp/dr_backup_config.yml" config: - - file_path: "/tmp/dr_backup_config.yml" - global_filters: - serial_number_list: - - "FCW2140L05Y" - - "FCW2140L06Z" - - "9080V0I41J3" - - "FCW2140L07A" - component_specific_filters: - components_list: ["layer2_configurations"] + global_filters: + serial_number_list: + - "FCW2140L05Y" + - "FCW2140L06Z" + - "9080V0I41J3" + - "FCW2140L07A" + component_specific_filters: + components_list: ["layer2_configurations"] register: result_dr_backup tags: [maintenance, disaster_recovery, backup] diff --git a/plugins/module_utils/brownfield_helper.py b/plugins/module_utils/brownfield_helper.py index 66726a5181..5e0ded28dc 100644 --- a/plugins/module_utils/brownfield_helper.py +++ b/plugins/module_utils/brownfield_helper.py @@ -184,6 +184,33 @@ def validate_global_filters(self, global_filters): ) continue + # Validate choices for strings + if expected_type == "str" and "choices" in filter_spec: + valid_choices = filter_spec["choices"] + if filter_value not in valid_choices: + invalid_filters.append( + "Filter '{0}' has invalid value: '{1}'. Valid choices: {2}".format( + filter_name, + filter_value, + valid_choices, + ) + ) + + # Validate choices for lists + if expected_type == "list" and "choices" in filter_spec: + valid_choices = filter_spec["choices"] + invalid_choices = [ + item for item in filter_value if item not in valid_choices + ] + if invalid_choices: + invalid_filters.append( + "Filter '{0}' contains invalid choices: {1}. Valid choices: {2}".format( + filter_name, + invalid_choices, + valid_choices, + ) + ) + # Validate list elements if expected_type == "list" and filter_value: element_type = filter_spec.get("elements", "str") @@ -1007,17 +1034,88 @@ def validate_invalid_params(self, config_dict, valid_params): "DEBUG", ) + def validate_config_filters_against_temp_spec(self, config_dict, temp_spec): + """ + Validates that only filter keys defined in temp_spec are present in config. + + This function is focused on filter-level key validation for config generator + playbook inputs. Supported filter keys are defined by temp_spec and may + include one or both of: + - global_filters + - component_specific_filters + + Args: + config_dict (dict): User-provided configuration dictionary. + temp_spec (dict): Schema dictionary that defines allowed filter keys. + + Returns: + None + + Raises: + SystemExit: If validation fails and fail_and_exit is called. + """ + + self.log( + "Starting validation of filter keys against temp_spec. " + "config_keys={0}, temp_spec_keys={1}".format( + sorted(config_dict.keys()), + sorted(temp_spec.keys()), + ), + "DEBUG", + ) + + configured_filter_keys = set(config_dict.keys()) + allowed_filter_keys = set(temp_spec.keys()) + sorted_allowed = sorted(allowed_filter_keys) + + self.log( + "Filter key validation context - configured_filter_keys={0}, " + "allowed_filter_keys={1}".format( + sorted(configured_filter_keys), sorted_allowed + ), + "DEBUG", + ) + + invalid_filter_keys = configured_filter_keys - allowed_filter_keys + sorted_invalid = sorted(invalid_filter_keys) + if invalid_filter_keys: + self.msg = ( + "Invalid filters found in playbook config: {0}. " + "Allowed filters are: {1}." + ).format( + sorted_invalid, + sorted_allowed, + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + + self.log( + "Filter key validation completed successfully. " + "Provided filter keys are valid against temp_spec.", + "DEBUG", + ) + def validate_config_dict(self, config_dict, temp_spec): """ Validates config dictionary using the same behavior as validate_list_of_dicts by wrapping the dict into a one-item list. + This method performs multi-stage validation: + 1. Type validation to ensure config_dict is a dictionary. + 2. Schema validation against temp_spec using validate_list_of_dicts. + 3. Filter-key validation to ensure only keys defined in temp_spec are used. + 4. Guard checks for empty filter dictionaries when filter keys are provided + (for example, empty global_filters or component_specific_filters). + Args: config_dict (dict): Single configuration dictionary from playbook input. temp_spec (dict): Validation schema for config keys. Returns: - dict: Single config dictionary entry. + dict: Single validated configuration dictionary entry. + + Raises: + SystemExit: If validation fails and fail_and_exit is called. """ self.log( @@ -1043,8 +1141,17 @@ def validate_config_dict(self, config_dict, temp_spec): self.log(self.msg, "ERROR") self.fail_and_exit(self.msg) + self.validate_config_filters_against_temp_spec(config_dict, temp_spec) + component_specific_filters = config_dict.get("component_specific_filters") - if component_specific_filters is None: + if "component_specific_filters" in config_dict and component_specific_filters is None: + self.msg = ( + "Invalid playbook config: 'component_specific_filters' cannot be null when provided. " + "Provide at least one filter or omit 'component_specific_filters'." + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + elif component_specific_filters is None: self.log( "No 'component_specific_filters' provided in config; skipping validation.", "DEBUG", @@ -1058,6 +1165,27 @@ def validate_config_dict(self, config_dict, temp_spec): self.log(self.msg, "ERROR") self.fail_and_exit(self.msg) + global_filters = config_dict.get("global_filters") + if "global_filters" in config_dict and global_filters is None: + self.msg = ( + "Invalid playbook config: 'global_filters' cannot be null when provided. " + "Provide at least one filter or omit 'global_filters'." + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + elif global_filters is None: + self.log( + "No 'global_filters' provided in config; skipping validation.", + "DEBUG", + ) + elif not global_filters: + self.msg = ( + "Invalid playbook config: 'global_filters' is empty. " + "Provide at least one filter or omit 'global_filters'." + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + validated_config = validated_list[0] if validated_list else {} self.log( "Completed config dictionary validation. Validated config: {0}".format( @@ -2956,9 +3084,12 @@ def get_site_id_name_mapping(self, site_id_list=None): ) return site_id_name_mapping - def get_fabric_site_name_to_id_mapping(self): + def get_fabric_site_name_to_id_mapping(self, site_id_name_mapping=None): """ Retrieves the bidirectional mapping of fabric site names to fabric site IDs for all fabric sites. + Args: + site_id_name_mapping (dict, optional): Pre-fetched mapping of site IDs to site names. + If None, the mapping is retrieved from the API. Defaults to None. Returns: tuple: A tuple containing two dictionaries: - fabric_site_name_to_id (dict): Mapping of fabric site names (hierarchical) to fabric site IDs @@ -2988,7 +3119,12 @@ def get_fabric_site_name_to_id_mapping(self): ] # Get mapping of siteId to nameHierarchy - site_id_name_mapping = self.get_site_id_name_mapping(site_ids_of_fabric_sites) + if site_id_name_mapping is None: + self.log( + "site_id_name_mapping not passed as parameter, creating mapping from API", + "INFO", + ) + site_id_name_mapping = self.get_site_id_name_mapping(site_ids_of_fabric_sites) for fabric_site in fabric_sites: fabric_id = fabric_site.get("id") diff --git a/plugins/module_utils/dnac.py b/plugins/module_utils/dnac.py index 20ebc0852f..b2270b9f5d 100644 --- a/plugins/module_utils/dnac.py +++ b/plugins/module_utils/dnac.py @@ -3097,7 +3097,7 @@ def _exec(self, family, function, params=None, op_modifies=False, **kwargs): except exceptions.ApiError as e: self.fail_json( msg=( - "An error occured when executing operation for the family '{family}' " + "An error occurred when executing operation for the family '{family}' " "having the function '{function}'." " The error was: status_code: {error_status}, {error}" ).format(error_status=to_native(e.response.status_code), error=to_native(e.response.text), @@ -3107,13 +3107,128 @@ def _exec(self, family, function, params=None, op_modifies=False, **kwargs): except exceptions.dnacentersdkException as e: self.fail_json( msg=( - "An error occured when executing operation for the family '{family}' " + "An error occurred when executing operation for the family '{family}' " "having the function '{function}'." " The error was: {error}" ).format(error=to_native(e), family=family_name, function=function_name) ) return response + def execute_rest_api_call(self, method, endpoint, params=None, op_modifies=False): + """ + Execute a REST API call using DNAC SDK custom_caller.call_api. + + Args: + method (str): HTTP method to execute (for example, "GET", "POST", "PUT", "DELETE"). + endpoint (str): API resource path/endpoint to invoke. + params (dict, optional): Request arguments to pass to the SDK call. + Supported keys include "params", "payload", "data", "headers", + "files", and "active_validation". + op_modifies (bool, optional): Indicates whether the operation modifies + Catalyst Center state. When True and response schema validation is + disabled, active_validation is forced to False. + + Returns: + Any: SDK response object returned by custom_caller.call_api. + + Raises: + Exception: Raised through fail_json when custom_caller.call_api is not + available or when the API invocation cannot be + executed or when the DNAC SDK raises ApiError/dnacentersdkException. + """ + method_upper = str(method).upper() + custom_caller = getattr(self.api, "custom_caller", None) + call_api = getattr(custom_caller, "call_api", None) + logger = logging.getLogger("logger") + + logger.debug( + "Preparing REST API call: method=%s endpoint=%s op_modifies=%s params=%s", + method_upper, + endpoint, + op_modifies, + params, + ) + + if call_api is None: + logger.error( + "REST API call failed before execution: method=%s endpoint=%s (custom_caller.call_api unavailable)", + method_upper, + endpoint, + ) + self.fail_json( + msg=( + "Unable to execute REST API call. custom_caller.call_api is not available " + "for method '{0}' and endpoint '{1}'." + ).format(method, endpoint) + ) + + try: + request_params = params.copy() if isinstance(params, dict) else params + + if isinstance(request_params, dict) and not self.validate_response_schema and op_modifies: + request_params["active_validation"] = False + logger.debug( + "Disabled active_validation for mutating REST API call: method=%s endpoint=%s", + method_upper, + endpoint, + ) + + logger.debug( + "Executing REST API call via custom_caller.call_api for method=%s endpoint=%s", + method_upper, + endpoint, + ) + call_api_kwargs = { + "method": method_upper, + "resource_path": endpoint, + } + if isinstance(request_params, dict) and request_params: + known_request_keys = { + "params", "payload", "data", "headers", "active_validation", "files" + } + if known_request_keys.intersection(set(request_params.keys())): + call_api_kwargs.update(request_params) + else: + call_api_kwargs["params"] = request_params + + response = call_api(**call_api_kwargs) + + if isinstance(response, dict): + logger.debug( + "REST API call completed: method=%s endpoint=%s response_keys=%s", + method_upper, + endpoint, + sorted(list(response.keys())), + ) + else: + logger.debug( + "REST API call completed: method=%s endpoint=%s response_type=%s", + method_upper, + endpoint, + type(response).__name__, + ) + + except exceptions.ApiError as e: + self.fail_json( + msg=( + "An error occurred when executing REST API call with method '{method}' " + "and endpoint '{endpoint}'." + " The error was: status_code: {error_status}, {error}" + ).format(error_status=to_native(e.response.status_code), error=to_native(e.response.text), + method=method, endpoint=endpoint) + ) + + except exceptions.dnacentersdkException as e: + self.fail_json( + msg=( + "An error occurred when executing REST API call with method '{method}' " + "and endpoint '{endpoint}'." + " The error was: {error}" + ).format(error=to_native(e), method=method, endpoint=endpoint) + ) + + return response + def fail_json(self, msg, **kwargs): self.result.update(**kwargs) raise Exception(msg) diff --git a/plugins/modules/application_policy_workflow_manager.py b/plugins/modules/application_policy_workflow_manager.py index 15f8530c5f..5ec50a9279 100644 --- a/plugins/modules/application_policy_workflow_manager.py +++ b/plugins/modules/application_policy_workflow_manager.py @@ -1314,6 +1314,27 @@ def __init__(self, module): ) = ([], [], []) self.deleted_queuing_profile, self.no_deleted_queuing_profile = [], [] + def _extract_app_set_name(self, application_set_entry): + """ + Extract the application set name from a policy entry by stripping the + '{policyScope}_' prefix from the entry's 'name' field. + + Args: + application_set_entry (dict): A single entry from the current + application policy response. + + Returns: + str: The bare application set name without the policy-scope prefix. + """ + full_name = application_set_entry.get("name", "") + policy_scope = application_set_entry.get("policyScope", "") + policy_prefix = "{0}_".format(policy_scope) if policy_scope else "" + + if policy_prefix and full_name.startswith(policy_prefix): + return full_name[len(policy_prefix):] + + return full_name + def validate_input(self): """ Validate the fields provided in the playbook. @@ -2428,9 +2449,7 @@ def is_update_required_for_application_policy(self): if clause and clause[0].get("relevanceLevel"): current_relevance_type = clause[0].get("relevanceLevel") - app_set_name = application_sets.get("name").replace( - application_sets.get("policyScope") + "_", "" - ) + app_set_name = self._extract_app_set_name(application_sets) if current_relevance_type == "BUSINESS_RELEVANT": have_business_relevant_set_name.append(app_set_name) @@ -2909,14 +2928,27 @@ def get_diff_application_policy(self): } # Process current application sets - for application_sets in current_application_policy: + for idx, application_sets in enumerate(current_application_policy): + self.log( + "Processing application set entry {0}/{1} with name '{2}'.".format( + idx + 1, + len(current_application_policy), + application_sets.get("name", "unknown"), + ), + "DEBUG", + ) clause = application_sets.get("exclusiveContract", {}).get("clause") if clause and clause[0].get("relevanceLevel") is not None: current_relevance_type = clause[0].get("relevanceLevel") - full_name = application_sets.get("name") - policy_name = application_sets.get("policyScope") + "_" - app_set_name = full_name.replace(policy_name, "") + app_set_name = self._extract_app_set_name(application_sets) + + self.log( + "Extracted app set name '{0}' from policy entry '{1}'.".format( + app_set_name, application_sets.get("name", "") + ), + "DEBUG", + ) # Handle known relevance types if current_relevance_type in relevant_set_names: @@ -2952,7 +2984,7 @@ def get_diff_application_policy(self): ) else: for set_name in expected_set_names: - if set_name in full_name: + if set_name == app_set_name: update_not_required = True self.log( "No update required for application set: {0}".format(app_set_name), @@ -3125,7 +3157,11 @@ def get_diff_application_policy(self): elif app_set in final_default_set_name: relevance_level = "DEFAULT" - if relevance_level and app_set in application_sets.get("name"): + current_app_set_name = self._extract_app_set_name( + application_sets + ) + + if relevance_level and app_set == current_app_set_name: app_set_payload = { "id": application_sets.get("id"), "name": "{}_{}".format( @@ -3206,7 +3242,11 @@ def get_diff_application_policy(self): elif app_set in final_want_default: relevance_level = "DEFAULT" - if relevance_level and app_set in application_sets.get("name"): + current_app_set_name = self._extract_app_set_name( + application_sets + ) + + if relevance_level and app_set == current_app_set_name: app_set_payload = { "id": application_sets.get("id"), "name": "{}_{}".format( diff --git a/plugins/modules/assurance_icap_settings_workflow_manager.py b/plugins/modules/assurance_icap_settings_workflow_manager.py index cc2c213f57..b0d477f393 100644 --- a/plugins/modules/assurance_icap_settings_workflow_manager.py +++ b/plugins/modules/assurance_icap_settings_workflow_manager.py @@ -81,8 +81,9 @@ - RFSTATS # Captures RF statistics to analyze signal and interference levels. - ANOMALY # Captures specific anomalies detected in the network. duration_in_mins: - description: The duration of the Intelligent - Capture session in minutes. + description: + - The duration of the Intelligent Capture session in minutes. + - Accepted range is 30 to 480 minutes (inclusive). type: int preview_description: description: A short summary or metadata diff --git a/plugins/modules/assurance_issue_playbook_config_generator.py b/plugins/modules/assurance_issue_playbook_config_generator.py index a306f07536..79d74fe946 100644 --- a/plugins/modules/assurance_issue_playbook_config_generator.py +++ b/plugins/modules/assurance_issue_playbook_config_generator.py @@ -364,6 +364,12 @@ def validate_input(self): if config_provided: component_filters = valid_temp.get("component_specific_filters") or {} + + # Validate that only known keys are present in component_specific_filters. + # Valid keys are the component block names from the schema plus 'components_list'. + valid_csf_keys = set(self.module_schema.get("issue_elements", {}).keys()) | {"components_list"} + self.validate_invalid_params(component_filters, valid_csf_keys) + components_list = component_filters.get("components_list") has_components_list = isinstance(components_list, list) and len(components_list) > 0 has_component_blocks = any( @@ -426,6 +432,14 @@ def validate_input(self): ), "DEBUG" ) + # Derive valid keys for each filter entry from the schema + valid_filter_keys = set( + self.module_schema + .get("issue_elements", {}) + .get("assurance_user_defined_issue_settings", {}) + .get("filters", {}) + .keys() + ) deduplicated_issue_filters = [] seen_filter_keys = set() for filter_index, item in enumerate(issue_filters, start=1): @@ -437,6 +451,9 @@ def validate_input(self): deduplicated_issue_filters.append(item) continue + # Reject any unrecognized keys in the filter entry + self.validate_invalid_params(item, valid_filter_keys) + filter_key = ( item.get("name"), item.get("is_enabled") diff --git a/plugins/modules/device_credential_playbook_config_generator.py b/plugins/modules/device_credential_playbook_config_generator.py index 9b8c403353..3bbe2b5eca 100644 --- a/plugins/modules/device_credential_playbook_config_generator.py +++ b/plugins/modules/device_credential_playbook_config_generator.py @@ -2564,7 +2564,7 @@ def main(): ): ccc_device_credential_playbook_config_generator.msg = ( "The specified version '{0}' does not support the YAML Playbook generation " - "for Module. Supported versions start from '2.3.7.9' onwards. ".format( + "for Device Credential Module. Supported versions start from '2.3.7.9' onwards. ".format( ccc_device_credential_playbook_config_generator.get_ccc_version() ) ) @@ -2590,7 +2590,6 @@ def main(): "Validated configuration parameters: {0}".format(str(config)), "DEBUG" ) - config = ccc_device_credential_playbook_config_generator.validated_config ccc_device_credential_playbook_config_generator.get_want( config, state ).check_return_status() diff --git a/plugins/modules/events_and_notifications_playbook_config_generator.py b/plugins/modules/events_and_notifications_playbook_config_generator.py index 3751056403..8262e57fc5 100644 --- a/plugins/modules/events_and_notifications_playbook_config_generator.py +++ b/plugins/modules/events_and_notifications_playbook_config_generator.py @@ -2823,6 +2823,48 @@ def create_instance_description(self, notification): return None + def _resolve_name_filter(self, type_filters, names_filter, expected_type): + """ + Returns effective names filter list for a destination/notification type. + + Centralises the repeated pattern of checking whether the current component + type is included in the user-supplied type filter list before applying the + name-based filter. If ``type_filters`` is empty or ``None`` the names + filter is returned as-is (all types selected). If ``type_filters`` is + non-empty but does not contain ``expected_type``, an empty list is returned + so that name filtering is skipped for this component. + + Args: + type_filters (list): destination_types or notification_types from the + playbook filter block. May be ``None`` or empty. + names_filter (list): destination_names or subscription_names from the + playbook filter block. May be ``None`` or empty. + expected_type (str): The component type to check for, e.g. ``"webhook"``, + ``"email"``, ``"syslog"``, ``"snmp"``. + + Returns: + tuple: A two-element tuple ``(effective_names_filter, type_selected)``. + - effective_names_filter (list): The names filter to apply for this + component type. Returns an empty list when the component type is + excluded by ``type_filters``. + - type_selected (bool): ``True`` when the component type is included + (or no type filter is set); ``False`` when it is excluded. + """ + type_filters = type_filters or [] + names_filter = names_filter or [] + + if type_filters and expected_type not in type_filters: + self.log( + "Component type '{0}' not in type_filters {1}. " + "Clearing name filter for this component.".format( + expected_type, type_filters + ), + "DEBUG" + ) + return [], False + + return names_filter, True + def get_webhook_destinations(self, network_element, filters): """ Retrieves webhook destination configurations from Cisco Catalyst Center. @@ -2850,7 +2892,19 @@ def get_webhook_destinations(self, network_element, filters): component_specific_filters = filters.get("component_specific_filters", {}) destination_filters = component_specific_filters.get("destination_filters", {}) - destination_names = destination_filters.get("destination_names", []) + destination_names, type_selected = self._resolve_name_filter( + destination_filters.get("destination_types"), + destination_filters.get("destination_names"), + "webhook" + ) + if not type_selected: + self.log( + "Webhook destination type not selected in destination_types filter. " + "Skipping retrieval and returning empty list.", + "DEBUG" + ) + return {"webhook_destinations": []} + self.log( "Destination name filters extracted: {0} name(s) specified. Filter mode: {1}".format( len(destination_names), @@ -2868,7 +2922,10 @@ def get_webhook_destinations(self, network_element, filters): ) try: - webhook_configs = self.get_all_webhook_destinations(api_family, api_function) + webhook_configs = self.get_all_webhook_destinations( + api_family, api_function, + target_destination_names=destination_names if destination_names else None + ) self.log( "Retrieved {0} webhook destination(s) from Catalyst Center. Processing " "filtering logic based on destination_names.".format(len(webhook_configs)), @@ -2891,9 +2948,13 @@ def get_webhook_destinations(self, network_element, filters): ), "INFO" ) - final_webhook_configs = matching_configs else: - self.log("No matching webhook destinations found for filter - including all", "DEBUG") + final_webhook_configs = [] + self.log( + "No matching webhook destinations found for destination_names " + "filter: {0}. Returning empty list.".format(destination_names), + "INFO" + ) else: final_webhook_configs = webhook_configs self.log( @@ -2965,7 +3026,18 @@ def get_email_destinations(self, network_element, filters): component_specific_filters = filters.get("component_specific_filters", {}) destination_filters = component_specific_filters.get("destination_filters", {}) - destination_names = destination_filters.get("destination_names", []) + destination_names, type_selected = self._resolve_name_filter( + destination_filters.get("destination_types"), + destination_filters.get("destination_names"), + "email" + ) + if not type_selected: + self.log( + "Email destination type not selected in destination_types filter. " + "Skipping retrieval and returning empty list.", + "DEBUG" + ) + return {"email_destinations": []} self.log( "Destination name filters extracted: {0} name(s) specified. Filter mode: {1}".format( @@ -3014,13 +3086,11 @@ def get_email_destinations(self, network_element, filters): "INFO" ) else: - final_email_configs = email_configs + final_email_configs = [] self.log( - "No matching email destinations found for filter: {0}. Including " - "all {1} email(s) for comprehensive configuration coverage.".format( - destination_names, len(email_configs) - ), - "WARNING" + "No matching email destinations found for destination_names " + "filter: {0}. Returning empty list.".format(destination_names), + "INFO" ) else: self.log( @@ -3094,7 +3164,18 @@ def get_syslog_destinations(self, network_element, filters): component_specific_filters = filters.get("component_specific_filters", {}) destination_filters = component_specific_filters.get("destination_filters", {}) - destination_names = destination_filters.get("destination_names", []) + destination_names, type_selected = self._resolve_name_filter( + destination_filters.get("destination_types"), + destination_filters.get("destination_names"), + "syslog" + ) + if not type_selected: + self.log( + "Syslog destination type not selected in destination_types filter. " + "Skipping retrieval and returning empty list.", + "DEBUG" + ) + return {"syslog_destinations": []} self.log( "Destination name filters extracted: {0} name(s) specified. Filter mode: {1}".format( @@ -3113,7 +3194,10 @@ def get_syslog_destinations(self, network_element, filters): ) try: - syslog_configs = self.get_all_syslog_destinations(api_family, api_function) + syslog_configs = self.get_all_syslog_destinations( + api_family, api_function, + target_destination_names=destination_names if destination_names else None + ) self.log( "Retrieved {0} syslog destination(s) from Catalyst Center. Processing " "filtering logic based on destination_names.".format(len(syslog_configs)), @@ -3137,13 +3221,11 @@ def get_syslog_destinations(self, network_element, filters): "INFO" ) else: - final_syslog_configs = syslog_configs + final_syslog_configs = [] self.log( - "No matching syslog destinations found for filter: {0}. Including " - "all {1} syslog(s) for comprehensive configuration coverage.".format( - destination_names, len(syslog_configs) - ), - "WARNING" + "No matching syslog destinations found for destination_names " + "filter: {0}. Returning empty list.".format(destination_names), + "INFO" ) else: self.log( @@ -3216,7 +3298,18 @@ def get_snmp_destinations(self, network_element, filters): component_specific_filters = filters.get("component_specific_filters", {}) destination_filters = component_specific_filters.get("destination_filters", {}) - destination_names = destination_filters.get("destination_names", []) + destination_names, type_selected = self._resolve_name_filter( + destination_filters.get("destination_types"), + destination_filters.get("destination_names"), + "snmp" + ) + if not type_selected: + self.log( + "SNMP destination type not selected in destination_types filter. " + "Skipping retrieval and returning empty list.", + "DEBUG" + ) + return {"snmp_destinations": []} self.log( "Destination name filters extracted: {0} name(s) specified. Filter mode: {1}".format( @@ -3236,7 +3329,10 @@ def get_snmp_destinations(self, network_element, filters): ) try: - snmp_configs = self.get_all_snmp_destinations(api_family, api_function) + snmp_configs = self.get_all_snmp_destinations( + api_family, api_function, + target_destination_names=destination_names if destination_names else None + ) self.log( "Retrieved {0} SNMP destination(s) from Catalyst Center. Processing " "filtering logic based on destination_names.".format(len(snmp_configs)), @@ -3260,12 +3356,11 @@ def get_snmp_destinations(self, network_element, filters): "INFO" ) else: - final_snmp_configs = snmp_configs + final_snmp_configs = [] self.log( - "No matching SNMP destinations found for filter: {0}. Including " - "all {1} SNMP destination(s) for comprehensive configuration " - "coverage.".format(destination_names, len(snmp_configs)), - "WARNING" + "No matching SNMP destinations found for destination_names " + "filter: {0}. Returning empty list.".format(destination_names), + "INFO" ) else: self.log( @@ -3312,7 +3407,7 @@ def get_snmp_destinations(self, network_element, filters): ) return result - def get_all_webhook_destinations(self, api_family, api_function): + def get_all_webhook_destinations(self, api_family, api_function, target_destination_names=None): """ Retrieves all webhook destinations using pagination from the API. @@ -3324,6 +3419,8 @@ def get_all_webhook_destinations(self, api_family, api_function): Args: api_family (str): The API family identifier for webhook destinations. api_function (str): The specific API function name for retrieving webhook destinations. + target_destination_names (list, optional): Destination names used to + short-circuit pagination when all targets are found. Defaults to None. Returns: list: A list of webhook destination dictionaries containing all available @@ -3338,7 +3435,7 @@ def get_all_webhook_destinations(self, api_family, api_function): ) try: offset = 0 - limit = 10 + limit = 100 all_webhooks = [] page_count = 0 @@ -3386,6 +3483,19 @@ def get_all_webhook_destinations(self, api_family, api_function): "DEBUG" ) + # Early-stop: if all target names found, skip remaining pages + if target_destination_names: + found_names = {w.get("name", "") for w in all_webhooks} + if all(n in found_names for n in target_destination_names): + self.log( + "All {0} target webhook destination(s) found after {1} page(s). " + "Stopping pagination early.".format( + len(target_destination_names), page_count + ), + "INFO" + ) + break + if len(webhooks) < limit: self.log( "Received {0} webhook(s) in page {1}, which is less than limit " @@ -3483,7 +3593,7 @@ def get_all_email_destinations(self, api_family, api_function): ) return [] - def get_all_syslog_destinations(self, api_family, api_function): + def get_all_syslog_destinations(self, api_family, api_function, target_destination_names=None): """ Retrieves all syslog destinations using pagination from the API. @@ -3495,6 +3605,8 @@ def get_all_syslog_destinations(self, api_family, api_function): Args: api_family (str): The API family identifier for syslog destinations. api_function (str): The specific API function name for retrieving syslog destinations. + target_destination_names (list, optional): Destination names used to + short-circuit pagination when all targets are found. Defaults to None. Returns: list: A list of syslog destination dictionaries containing all available @@ -3509,7 +3621,7 @@ def get_all_syslog_destinations(self, api_family, api_function): ) try: offset = 0 - limit = 10 + limit = 100 all_syslogs = [] page_count = 0 @@ -3568,6 +3680,19 @@ def get_all_syslog_destinations(self, api_family, api_function): "DEBUG" ) + # Early-stop: if all target names found, skip remaining pages + if target_destination_names: + found_names = {s.get("name", "") for s in all_syslogs} + if all(n in found_names for n in target_destination_names): + self.log( + "All {0} target syslog destination(s) found after {1} page(s). " + "Stopping pagination early.".format( + len(target_destination_names), page_count + ), + "INFO" + ) + break + if len(syslog_configs) < limit: self.log( "Received {0} syslog destination(s) in page {1}, which is less " @@ -3599,7 +3724,7 @@ def get_all_syslog_destinations(self, api_family, api_function): ) return [] - def get_all_snmp_destinations(self, api_family, api_function): + def get_all_snmp_destinations(self, api_family, api_function, target_destination_names=None): """ Retrieves all SNMP destinations using pagination from the API. @@ -3611,6 +3736,8 @@ def get_all_snmp_destinations(self, api_family, api_function): Args: api_family (str): The API family identifier for SNMP destinations. api_function (str): The specific API function name for retrieving SNMP destinations. + target_destination_names (list, optional): Destination names used to + short-circuit pagination when all targets are found. Defaults to None. Returns: list: A list of SNMP destination dictionaries containing IP addresses, @@ -3625,7 +3752,7 @@ def get_all_snmp_destinations(self, api_family, api_function): ) try: offset = 0 - limit = 10 + limit = 100 all_snmp = [] page_count = 0 @@ -3674,6 +3801,19 @@ def get_all_snmp_destinations(self, api_family, api_function): "DEBUG" ) + # Early-stop: if all target names found, skip remaining pages + if target_destination_names: + found_names = {s.get("name", "") for s in all_snmp} + if all(n in found_names for n in target_destination_names): + self.log( + "All {0} target SNMP destination(s) found after {1} page(s). " + "Stopping pagination early.".format( + len(target_destination_names), page_count + ), + "INFO" + ) + break + if len(snmp_configs) < limit: self.log( "Received {0} SNMP destination(s) in page {1}, which is less " @@ -3759,7 +3899,10 @@ def get_itsm_settings(self, network_element, filters): ) try: - itsm_configs = self.get_all_itsm_settings(api_family, api_function) + itsm_configs = self.get_all_itsm_settings( + api_family, api_function, + target_instance_names=instance_names if instance_names else None + ) self.log( "Retrieved {0} ITSM setting(s) from Catalyst Center. Processing filtering " "logic based on instance_names.".format(len(itsm_configs)), @@ -3824,7 +3967,7 @@ def get_itsm_settings(self, network_element, filters): return result - def get_all_itsm_settings(self, api_family, api_function): + def get_all_itsm_settings(self, api_family, api_function, target_instance_names=None): """ Retrieves all ITSM integration settings from the API with full connection details. @@ -3840,6 +3983,8 @@ def get_all_itsm_settings(self, api_family, api_function): Args: api_family (str): The API family identifier for ITSM settings. api_function (str): The specific API function name for retrieving ITSM settings. + target_instance_names (list, optional): Instance names used to + short-circuit pagination when all targets are found. Defaults to None. Returns: list: A list of ITSM setting dictionaries containing instance names, @@ -3910,6 +4055,24 @@ def get_all_itsm_settings(self, api_family, api_function): "DEBUG" ) + # Early-stop: if all target names already found in listing, skip remaining pages + if target_instance_names: + found_names = { + item.get("name", "").lower() + for item in all_itsm_settings + if isinstance(item, dict) + } + target_lower = {n.lower() for n in target_instance_names} + if target_lower.issubset(found_names): + self.log( + "All {0} target ITSM instance(s) found after {1} page(s). " + "Stopping pagination early.".format( + len(target_instance_names), page_count + ), + "INFO" + ) + break + if len(itsm_settings) < page_size: self.log( "Received {0} ITSM instance(s) in page {1}, less than page_size {2}. " @@ -3930,6 +4093,32 @@ def get_all_itsm_settings(self, api_family, api_function): "INFO" ) + # When target names provided, filter listing before expensive get-by-id calls + if target_instance_names: + target_names_lower = {n.lower() for n in target_instance_names} + filtered_itsm = [ + item for item in all_itsm_settings + if isinstance(item, dict) and item.get("name", "").lower() in target_names_lower + ] + self.log( + "Target instance filter active: {0} target(s). Matched {1} of {2} " + "listing entries. Only matched entries will have details fetched.".format( + len(target_instance_names), len(filtered_itsm), len(all_itsm_settings) + ), + "INFO" + ) + if not filtered_itsm: + available_names = [ + item.get("name", "unknown") for item in all_itsm_settings + if isinstance(item, dict) + ] + self.log( + "No ITSM instances matched target names {0}. Available ITSM " + "instance names: {1}".format(target_instance_names, available_names), + "WARNING" + ) + all_itsm_settings = filtered_itsm + detailed_settings = [] for idx, item in enumerate(all_itsm_settings, start=1): if not isinstance(item, dict): @@ -4111,7 +4300,19 @@ def get_webhook_event_notifications(self, network_element, filters): component_specific_filters = filters.get("component_specific_filters", {}) notification_filters = component_specific_filters.get("notification_filters", {}) - subscription_names = notification_filters.get("subscription_names", []) + subscription_names, type_selected = self._resolve_name_filter( + notification_filters.get("notification_types"), + notification_filters.get("subscription_names"), + "webhook" + ) + if not type_selected: + self.log( + "Webhook notification type not selected in notification_types filter. " + "Skipping retrieval and returning empty list.", + "DEBUG" + ) + return {"webhook_event_notifications": []} + self.log( "Subscription name filters extracted: {0} name(s) specified. Filter mode: {1}".format( len(subscription_names), @@ -4129,7 +4330,10 @@ def get_webhook_event_notifications(self, network_element, filters): ) try: - notification_configs = self.get_all_webhook_event_notifications(api_family, api_function) + notification_configs = self.get_all_webhook_event_notifications( + api_family, api_function, + target_subscription_names=subscription_names if subscription_names else None + ) self.log( "Retrieved {0} webhook event notification(s) from Catalyst Center. " "Processing filtering logic based on subscription_names.".format( @@ -4162,12 +4366,13 @@ def get_webhook_event_notifications(self, network_element, filters): "INFO" ) else: - final_notification_configs = notification_configs + final_notification_configs = [] self.log( - "No matching webhook event notifications found for filter: {0}. " - "Including all {1} notification(s) for comprehensive configuration " - "coverage.".format(subscription_names, len(notification_configs)), - "WARNING" + "No matching webhook event notifications found for " + "subscription_names filter: {0}. Returning empty list.".format( + subscription_names + ), + "INFO" ) else: final_notification_configs = notification_configs @@ -4216,7 +4421,7 @@ def get_webhook_event_notifications(self, network_element, filters): ) return result - def get_all_webhook_event_notifications(self, api_family, api_function): + def get_all_webhook_event_notifications(self, api_family, api_function, target_subscription_names=None): """ Retrieves all webhook event notifications using pagination from the API. @@ -4228,6 +4433,8 @@ def get_all_webhook_event_notifications(self, api_family, api_function): Args: api_family (str): The API family identifier for webhook event notifications. api_function (str): The specific API function name for retrieving webhook notifications. + target_subscription_names (list, optional): Subscription names used to + short-circuit pagination when all targets are found. Defaults to None. Returns: list: A list of webhook event notification dictionaries containing subscription @@ -4242,7 +4449,7 @@ def get_all_webhook_event_notifications(self, api_family, api_function): ) try: offset = 0 - limit = 10 + limit = 100 all_notifications = [] page_count = 0 @@ -4312,6 +4519,19 @@ def get_all_webhook_event_notifications(self, api_family, api_function): "DEBUG" ) + # Early-stop: if all target subscription names found, skip remaining pages + if target_subscription_names: + found_names = {n.get("name", "") for n in all_notifications} + if all(sn in found_names for sn in target_subscription_names): + self.log( + "All {0} target webhook notification(s) found after {1} page(s). " + "Stopping pagination early.".format( + len(target_subscription_names), page_count + ), + "INFO" + ) + break + if len(notifications) < limit: self.log( "Received {0} webhook event notification(s) in page {1}, which is " @@ -4379,7 +4599,18 @@ def get_email_event_notifications(self, network_element, filters): ) component_specific_filters = filters.get("component_specific_filters", {}) notification_filters = component_specific_filters.get("notification_filters", {}) - subscription_names = notification_filters.get("subscription_names", []) + subscription_names, type_selected = self._resolve_name_filter( + notification_filters.get("notification_types"), + notification_filters.get("subscription_names"), + "email" + ) + if not type_selected: + self.log( + "Email notification type not selected in notification_types filter. " + "Skipping retrieval and returning empty list.", + "DEBUG" + ) + return {"email_event_notifications": []} self.log( "Subscription name filters extracted: {0} name(s) specified. Filter mode: {1}".format( @@ -4393,15 +4624,16 @@ def get_email_event_notifications(self, network_element, filters): api_function = network_element.get("api_function") self.log( - "Subscription name filters extracted: {0} name(s) specified. Filter mode: {1}".format( - len(subscription_names), - "name-based filtering" if subscription_names else "retrieve all" - ), + "API details extracted - Family: {0}, Function: {1}. Calling API to retrieve " + "email event notifications.".format(api_family, api_function), "DEBUG" ) try: - notification_configs = self.get_all_email_event_notifications(api_family, api_function) + notification_configs = self.get_all_email_event_notifications( + api_family, api_function, + target_subscription_names=subscription_names if subscription_names else None + ) self.log( "Retrieved {0} email event notification(s) from Catalyst Center. " "Processing filtering logic based on subscription_names.".format( @@ -4434,12 +4666,13 @@ def get_email_event_notifications(self, network_element, filters): "INFO" ) else: - final_notification_configs = notification_configs + final_notification_configs = [] self.log( - "No matching email event notifications found for filter: {0}. " - "Including all {1} notification(s) for comprehensive configuration " - "coverage.".format(subscription_names, len(notification_configs)), - "WARNING" + "No matching email event notifications found for " + "subscription_names filter: {0}. Returning empty list.".format( + subscription_names + ), + "INFO" ) else: final_notification_configs = notification_configs @@ -4487,7 +4720,7 @@ def get_email_event_notifications(self, network_element, filters): ) return result - def get_all_email_event_notifications(self, api_family, api_function): + def get_all_email_event_notifications(self, api_family, api_function, target_subscription_names=None): """ Retrieves all email event notifications using pagination from the API. @@ -4500,6 +4733,8 @@ def get_all_email_event_notifications(self, api_family, api_function): Args: api_family (str): The API family identifier for email event notifications. api_function (str): The specific API function name for retrieving email notifications. + target_subscription_names (list, optional): Subscription names used to + short-circuit pagination when all targets are found. Defaults to None. Returns: list: A list of email event notification dictionaries containing subscription @@ -4514,7 +4749,7 @@ def get_all_email_event_notifications(self, api_family, api_function): ) try: offset = 0 - limit = 10 + limit = 100 all_notifications = [] page_count = 0 @@ -4571,6 +4806,19 @@ def get_all_email_event_notifications(self, api_family, api_function): "DEBUG" ) + # Early-stop: if all target subscription names found, skip remaining pages + if target_subscription_names: + found_names = {n.get("name", "") for n in all_notifications} + if all(sn in found_names for sn in target_subscription_names): + self.log( + "All {0} target email notification(s) found after {1} page(s). " + "Stopping pagination early.".format( + len(target_subscription_names), page_count + ), + "INFO" + ) + break + if len(notifications) < limit: self.log( "Received {0} email event notification(s) in page {1}, which is less " @@ -4628,7 +4876,18 @@ def get_syslog_event_notifications(self, network_element, filters): component_specific_filters = filters.get("component_specific_filters", {}) notification_filters = component_specific_filters.get("notification_filters", {}) - subscription_names = notification_filters.get("subscription_names", []) + subscription_names, type_selected = self._resolve_name_filter( + notification_filters.get("notification_types"), + notification_filters.get("subscription_names"), + "syslog" + ) + if not type_selected: + self.log( + "Syslog notification type not selected in notification_types filter. " + "Skipping retrieval and returning empty list.", + "DEBUG" + ) + return {"syslog_event_notifications": []} self.log( "Subscription name filters extracted: {0} name(s) specified. Filter mode: {1}".format( @@ -4648,7 +4907,10 @@ def get_syslog_event_notifications(self, network_element, filters): ) try: - notification_configs = self.get_all_syslog_event_notifications(api_family, api_function) + notification_configs = self.get_all_syslog_event_notifications( + api_family, api_function, + target_subscription_names=subscription_names if subscription_names else None + ) self.log( "Retrieved {0} syslog event notification(s) from Catalyst Center. " "Processing filtering logic based on subscription_names.".format( @@ -4681,12 +4943,13 @@ def get_syslog_event_notifications(self, network_element, filters): "INFO" ) else: - final_notification_configs = matching_configs + final_notification_configs = [] self.log( - "No matching syslog event notifications found for filter: {0}. " - "Including all {1} notification(s) for comprehensive configuration " - "coverage.".format(subscription_names, len(notification_configs)), - "WARNING" + "No matching syslog event notifications found for " + "subscription_names filter: {0}. Returning empty list.".format( + subscription_names + ), + "INFO" ) else: final_notification_configs = notification_configs @@ -4736,7 +4999,7 @@ def get_syslog_event_notifications(self, network_element, filters): return result - def get_all_syslog_event_notifications(self, api_family, api_function): + def get_all_syslog_event_notifications(self, api_family, api_function, target_subscription_names=None): """ Retrieves all syslog event notifications using pagination from the API. @@ -4748,6 +5011,8 @@ def get_all_syslog_event_notifications(self, api_family, api_function): Args: api_family (str): The API family identifier for syslog event notifications. api_function (str): The specific API function name for retrieving syslog notifications. + target_subscription_names (list, optional): Subscription names used to + short-circuit pagination when all targets are found. Defaults to None. Returns: list: A list of syslog event notification dictionaries containing subscription @@ -4762,7 +5027,7 @@ def get_all_syslog_event_notifications(self, api_family, api_function): ) try: offset = 0 - limit = 10 + limit = 100 all_notifications = [] page_count = 0 @@ -4829,6 +5094,19 @@ def get_all_syslog_event_notifications(self, api_family, api_function): "DEBUG" ) + # Early-stop: if all target subscription names found, skip remaining pages + if target_subscription_names: + found_names = {n.get("name", "") for n in all_notifications} + if all(sn in found_names for sn in target_subscription_names): + self.log( + "All {0} target syslog notification(s) found after {1} page(s). " + "Stopping pagination early.".format( + len(target_subscription_names), page_count + ), + "INFO" + ) + break + if len(notifications) < limit: self.log( "Received {0} syslog event notification(s) in page {1}, which is " @@ -5322,7 +5600,11 @@ def yaml_config_generator(self, yaml_config_generator): "File mode: {0}".format(file_mode), ] - if self.write_dict_to_yaml(playbook_data, file_path, file_mode, notes=header_notes): + write_result = self.write_dict_to_yaml( + playbook_data, file_path, file_mode, notes=header_notes + ) + + if write_result: success_message = "YAML configuration file generated successfully for module '{0}'".format(self.module_name) response_data = { @@ -5344,21 +5626,23 @@ def yaml_config_generator(self, yaml_config_generator): ) else: - error_message = "Failed to write YAML configuration to file: {0}".format(file_path) - + success_message = ( + "YAML configuration file already up-to-date for module '{0}'. " + "No changes written.".format(self.module_name) + ) response_data = { - "message": error_message, - "status": "failed" + "components_processed": components_processed, + "components_skipped": components_skipped, + "configurations_count": total_configurations, + "file_path": file_path, + "message": success_message, + "status": "success" } - self.set_operation_result("failed", False, error_message, "ERROR") + self.set_operation_result("success", False, success_message, "INFO") self.msg = response_data self.result["response"] = response_data - self.log( - "YAML file write operation failed for file path: {0}. Check file " - "permissions and disk space.".format(file_path), - "ERROR" - ) + self.log(success_message, "INFO") else: no_config_message = "No configurations found to generate. Verify that the components exist and have data." diff --git a/plugins/modules/events_and_notifications_workflow_manager.py b/plugins/modules/events_and_notifications_workflow_manager.py index 4ee34059de..1a8ff765d5 100644 --- a/plugins/modules/events_and_notifications_workflow_manager.py +++ b/plugins/modules/events_and_notifications_workflow_manager.py @@ -5,7 +5,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -__author__ = "Abhishek Maheshwari, Madhan Sankaranarayanan" +__author__ = "Abhishek Maheshwari, Priyadharshini B, Madhan Sankaranarayanan" DOCUMENTATION = r""" --- module: events_and_notifications_workflow_manager @@ -32,7 +32,7 @@ version_added: '6.14.0' extends_documentation_fragment: - cisco.dnac.workflow_manager_params -author: Abhishek Maheshwari (@abmahesh) Madhan Sankaranarayanan +author: Abhishek Maheshwari (@abmahesh) Priyadharshini B(@pbalaku2) Madhan Sankaranarayanan (@madhansansel) options: config_verify: @@ -1192,6 +1192,7 @@ DnacBase, validate_list_of_dicts, ) +import ipaddress import re import time @@ -1877,6 +1878,57 @@ def get_snmp_destination_in_ccc(self, name): self.log(self.msg, "ERROR") self.check_return_status() + def validate_ip_literal_or_fail(self, server_address, destination_name): + """ + Validate whether the given server address is a valid IPv4 or IPv6 + literal. If the address looks like an IP literal (rather than an + FQDN) but is malformed, the method sets the module status to + *failed* and calls ``check_return_status`` so execution stops + immediately. + + If ``server_address`` is ``None``/empty or is not an IP literal + (e.g. it is an FQDN), the method returns without taking any + action. + + Args: + server_address (str): The server address string to validate. + destination_name (str): A human-readable destination label + (e.g. ``"SNMP"`` or ``"Syslog"``) used in error messages. + + Returns: + None + """ + if not server_address: + return + + is_ipv4_literal = bool(re.match(r'^\d+\.\d+\.\d+\.\d+$', server_address)) + is_ipv6_literal = ( + ":" in server_address + and bool(re.match(r'^[0-9A-Fa-f:.]+$', server_address)) + ) + + if not (is_ipv4_literal or is_ipv6_literal): + return + + try: + ipaddress.ip_address(server_address) + except ValueError: + if is_ipv6_literal: + self.msg = ( + "Invalid IPv6 address '{0}' given in the playbook for configuring " + "{1} destination. Please provide a valid IPv6 address " + "(e.g., 2001:db8::1)." + ).format(server_address, destination_name) + self.set_operation_result("failed", False, self.msg, "ERROR").check_return_status() + + if is_ipv4_literal: + self.msg = ( + "Invalid IPv4 address '{0}' given in the playbook for configuring " + "{1} destination. Please provide a valid IPv4 address " + "(e.g., 10.0.0.1). Each octet must be between 0 and 255." + ).format(server_address, destination_name) + self.set_operation_result("failed", False, self.msg, "ERROR").check_return_status() + def collect_snmp_playbook_params(self, snmp_details): """ Collect the SNMP playbook parameters based on the provided SNMP details. @@ -1910,14 +1962,18 @@ def collect_snmp_playbook_params(self, snmp_details): self.result["response"] = self.msg self.check_return_status() - if server_address and not self.is_valid_server_address(server_address): - self.status = "failed" - self.msg = "Invalid server address '{0}' given in the playbook for configuring SNMP destination".format( - server_address - ) - self.log(self.msg, "ERROR") - self.result["response"] = self.msg - self.check_return_status() + if server_address: + if not self.is_valid_server_address(server_address): + self.msg = ( + "Invalid server address '{0}' given in the playbook for configuring " + "SNMP destination. Please provide a valid FQDN, IPv4, or IPv6 address." + ).format(server_address) + self.set_operation_result("failed", False, self.msg, "ERROR").check_return_status() + + try: + self.validate_ip_literal_or_fail(server_address, "SNMP") + except Exception: + return self if snmp_version == "V2C": playbook_params["community"] = snmp_details.get("community") @@ -6061,14 +6117,19 @@ def get_diff_merged(self, config): self.result["response"] = self.msg return self - if server_address and not self.is_valid_server_address(server_address): - self.status = "failed" - self.msg = "Invalid server address '{0}' given in the playbook for configuring Syslog destination".format( - server_address - ) - self.log(self.msg, "ERROR") - self.result["response"] = self.msg - return self + if server_address: + if not self.is_valid_server_address(server_address): + self.msg = ( + "Invalid server address '{0}' given in the playbook for configuring " + "Syslog destination. Please provide a valid FQDN, IPv4, or IPv6 address." + ).format(server_address) + self.set_operation_result("failed", False, self.msg, "ERROR") + return self + + try: + self.validate_ip_literal_or_fail(server_address, "Syslog") + except Exception: + return self syslog_details_in_ccc = self.have.get("syslog_destinations") diff --git a/plugins/modules/inventory_playbook_config_generator.py b/plugins/modules/inventory_playbook_config_generator.py index cdb6c36b10..3e79903bf4 100644 --- a/plugins/modules/inventory_playbook_config_generator.py +++ b/plugins/modules/inventory_playbook_config_generator.py @@ -3,455 +3,257 @@ # Copyright (c) 2026, Cisco Systems # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type +"""Ansible module to generate YAML configurations for Inventory Module.""" +from __future__ import absolute_import, division, print_function -"""Ansible module to generate YAML configurations for Wired Campus Automation Module.""" +__metaclass__ = type +__author__ = "Mridul Saurabh, Sunil Shatagopa, Madhan Sankaranarayanan" DOCUMENTATION = r""" --- module: inventory_playbook_config_generator -short_description: Generate YAML playbook input for 'inventory_workflow_manager' module. +short_description: Generate YAML playbook for C(inventory_workflow_manager) module. description: - - Generates YAML input files for C(cisco.dnac.inventory_workflow_manager). - - Supports independent component generation for device details, device - provisioning, interface details, and user-defined fields. - - Supports global device filters by IP address, hostname, serial number, and - MAC address. - - If C(config) is omitted or empty, the module generates all supported - components. +- Generates YAML configurations compatible with the C(inventory_workflow_manager) + module, reducing the effort required to manually create Ansible playbooks and + enabling programmatic modifications. +- The YAML configurations generated represent the inventory configurations + configured on the Cisco Catalyst Center. version_added: 6.44.0 extends_documentation_fragment: - - cisco.dnac.workflow_manager_params +- cisco.dnac.workflow_manager_params author: - - Mridul Saurabh (@msaurabh) - - Madhan Sankaranarayanan (@madsanka) +- Mridul Saurabh (@msaurabh12) +- Sunil Shatagopa (@shatagopasunil) +- Madhan Sankaranarayanan (@madhansansel) options: state: description: The desired state of Cisco Catalyst Center after module execution. type: str - choices: - - gathered - default: "gathered" - required: false + choices: [gathered] + default: gathered file_path: description: - - Path where the generated YAML configuration file will be saved. - - If not provided, the module generates a default file name using the - brownfield helper. - - The generated file name format is - C(inventory_playbook_config_.yml). + - Path where the YAML configuration file will be saved. + - If not provided, the file will be saved in the current working directory with + a default file name C(inventory_playbook_config_.yml). + - For example, C(inventory_playbook_config_2026-02-20_13-34-58.yml). type: str - required: false file_mode: description: - - File write mode for the generated YAML configuration file. - - Relevant only when C(file_path) is provided. - - C(overwrite) replaces the existing file content. - - C(append) appends new YAML documents to the existing file. - - When omitted, defaults to C(overwrite). + - 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. + - This parameter is only relevant when C(file_path) is specified. Defaults to C(overwrite). type: str - choices: - - overwrite - - append - default: overwrite - required: false + choices: ["overwrite", "append"] + default: "overwrite" config: description: - - Dictionary of filters controlling which inventory components are - generated for C(inventory_workflow_manager). - - If omitted or empty, the module generates all supported components. - - If provided, it may contain only C(global_filters) and/or - C(component_specific_filters). - - At least one of C(global_filters) or C(component_specific_filters) must - be present when C(config) is provided. + - A dictionary of filters for generating YAML playbook compatible with the C(inventory_workflow_manager) module. + - If config is not provided (omitted entirely), all configurations for all inventory devices will be generated. + - This is useful for complete brownfield infrastructure discovery and documentation. + - Important - An empty dictionary {} is not valid. Either omit 'config' entirely to generate + all configurations, or provide specific filters within 'config'. type: dict required: false suboptions: global_filters: description: - - Global device filters applied across all components during playbook - generation. - - Supports filtering by IP address, hostname, serial number, or MAC - address. - - When multiple filter types are specified, they are combined using - OR logic and a device matching any specified filter is included. - - Omitting a filter type means no restriction is applied on that - attribute. - - Evaluation order is C(ip_address_list), C(hostname_list), - C(serial_number_list), then C(mac_address_list). + - Global filters used to filter inventory data. + - Multiple filters under C(global_filters) are combined using logical AND. + - At least one supported global filter must be provided when using C(global_filters). + - Unknown keys fail validation with an error. type: dict suboptions: - ip_address_list: - description: - - List of device management IP addresses to include in the - generated YAML configuration. - - Each value is matched against the C(managementIpAddress) field - returned by Cisco Catalyst Center. - - When multiple global filter types are specified, they are - combined using OR logic and a device matching any filter is - included. - - When omitted, no IP-based filtering is applied. - - For example, C(['10.1.1.1', '10.1.1.2']). - type: list - elements: str - hostname_list: + devices: description: - - List of device hostnames to include in the generated YAML - configuration. + - List of device identifier values to filter inventory records. + - You can provide one or more values from C(ip_address), C(hostname), C(serial_number), + or C(mac_address), and the module matches each value against these identifier fields. type: list elements: str - serial_number_list: - description: - - List of device serial numbers to include in the generated YAML - configuration. - type: list - elements: str - mac_address_list: - description: - - List of device MAC addresses to include in the generated YAML - configuration. - type: list - elements: str - component_specific_filters: - description: - - Component-level filters controlling which sections are included in - the generated YAML configuration. - - If no component filter blocks are provided, C(components_list) is - required and must be non-empty. - - If a component filter block is provided, the corresponding component - is auto-added to C(components_list) if missing. - type: dict - suboptions: - components_list: + device_roles: description: - - List of components to include in the generated YAML - configuration file. - - Optional when one or more component filter blocks are provided. - - Required when no component filter blocks are provided. + - List of inventory device roles to include. + - Supported values are C(ACCESS), C(DISTRIBUTION), C(CORE), C(BORDER ROUTER), and C(UNKNOWN). type: list elements: str choices: - - device_details - - provision_device - - interface_details - - user_defined_fields - device_details: - description: - - Filters for device detail generation. - - Accepts either a single dictionary or a list of dictionaries. - - Multiple dictionaries use OR logic and keys inside each - dictionary use AND logic. - - "Supported filter keys: C(type), C(role), C(snmp_version), - C(cli_transport)." - - "Allowed C(type) values: C(NETWORK_DEVICE), - C(COMPUTE_DEVICE), C(MERAKI_DASHBOARD), - C(THIRD_PARTY_DEVICE), - C(FIREPOWER_MANAGEMENT_SYSTEM)." - - "Allowed C(role) values: C(ACCESS), C(CORE), - C(DISTRIBUTION), C(BORDER ROUTER), C(UNKNOWN). - Accepts a string or list of strings." - - "Allowed C(snmp_version) values: C(v2), C(v2c), C(v3)." - - "Allowed C(cli_transport) values: C(ssh), C(telnet)." - - When omitted, no device-attribute filtering is applied. - type: raw - provision_device: - description: - - Filters the provision device output by site name. - type: dict - suboptions: - site_name: - description: - - Hierarchical site name used to filter provisioned devices. - type: str - interface_details: - description: - - Filters interface details by interface name. - type: dict - suboptions: - interface_name: - description: - - Single interface name or list of interface names to include. - type: raw - user_defined_fields: + - "ACCESS" + - "DISTRIBUTION" + - "CORE" + - "BORDER ROUTER" + - "UNKNOWN" + device_identifier: description: - - Filters user-defined field output by field name and/or value. - type: dict - suboptions: - name: - description: - - Single field name or list of field names to include. - type: raw - value: - description: - - Single field value or list of field values to include. - type: raw - + - Identifier used to build the generated list key in output config. + - The output key is written as C(_list). + type: str + choices: + - "ip_address" + - "hostname" + - "serial_number" + - "mac_address" + default: "ip_address" requirements: -- dnacentersdk >= 2.10.10 +- dnacentersdk >= 2.7.2 - python >= 3.9 notes: -- SDK Methods used are - - devices.Devices.get_device_list - - devices.Devices.get_network_device_by_ip - - devices.Devices.get_device_by_ip - - licenses.Licenses.device_license_summary -- API Endpoints used are GET /dna/intent/api/v2/devices (list all devices), GET /dna/intent/api/v2/network-device - (get network device info), GET /dna/intent/api/v1/interface/ip-address/{ipAddress} (get interfaces for device IP), - and GET /dna/intent/api/v1/licenses/device/summary (get device license and site info). -- 'Device Consolidation: Devices are grouped and consolidated by their configuration hash. All interfaces from devices - with identical configurations are grouped under a single device entry. This reduces redundancy when multiple physical - devices share the same configuration.' -- 'Component Independence: Each component (device_details, provision_device, interface_details) is filtered - independently. Global filters apply to all components unless overridden by component-specific filters. Interface - details are automatically fetched based on matched device IPs.' -- 'Interface Discovery: Interfaces are discovered using the IP-to-interface API endpoint. Interface names can be - optionally filtered using the interface_name parameter. When no interfaces match the filter criteria, no - interface_details output is generated.' +- Cisco Catalyst Center >= 2.3.7.9 +- |- + SDK Methods used are + devices.Devices.get_device_list +- |- + SDK/REST Paths used are + GET /dna/intent/api/v1/network-device +- | + Auto-discovery mode: + When C(config) is omitted entirely, the module runs in auto-discovery mode and + generates inventory configuration for all discovered devices without applying filters. +- | + Filter behavior: + When C(config.global_filters) is provided, C(devices) and C(device_roles) are + applied with AND semantics. Unknown global filter keys fail validation with an error. +- |- + Module result behavior (changed/ok/failed): + The module result reflects local file state only, not Catalyst Center state. + In overwrite mode, generated YAML is compared against existing file content after + excluding generated comment lines. In append mode, only the last YAML + document is considered for idempotency check. + - changed=true (status: success): Generated configuration differs (or file does not exist), and file is written. + - changed=false (status: ok): Generated configuration matches existing content, so write is skipped. + - failed=true (status: failed): Validation/API/file-write failure occurred. seealso: - module: cisco.dnac.inventory_workflow_manager - description: Module for managing inventory configurations in Cisco Catalyst Center. + description: Module for managing inventory settings and workflows. """ EXAMPLES = r""" -- name: Generate inventory playbook with no config (generate all) - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_port: "{{ dnac_port }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - state: gathered - # file_path omitted - module auto-generates a timestamped filename - # config omitted - generates all supported components - -- name: Generate inventory playbook for specific devices by IP address +- name: Auto-generate YAML Configuration for all inventory devices cisco.dnac.inventory_playbook_config_generator: dnac_host: "{{ dnac_host }}" - dnac_port: "{{ dnac_port }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - state: gathered - file_path: "./inventory_devices_by_ip.yml" - file_mode: "overwrite" - config: - global_filters: - ip_address_list: - - "10.195.225.40" - - "10.195.225.42" - component_specific_filters: - components_list: - - device_details - - provision_device - - interface_details - -- name: Generate inventory playbook filtered by hostname - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" - dnac_port: "{{ dnac_port }}" dnac_username: "{{ dnac_username }}" dnac_password: "{{ dnac_password }}" dnac_verify: "{{ dnac_verify }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - state: gathered - file_path: "./inventory_hostname_filter.yml" - config: - global_filters: - hostname_list: - - "switch-floor1.example.com" - - "switch-floor2.example.com" - component_specific_filters: - components_list: - - device_details - -- name: Generate inventory playbook filtered by serial number - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" dnac_port: "{{ dnac_port }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" dnac_version: "{{ dnac_version }}" dnac_debug: "{{ dnac_debug }}" + dnac_log_level: "{{ dnac_log_level }}" + dnac_log: true state: gathered - file_path: "./inventory_serial_filter.yml" - config: - global_filters: - serial_number_list: - - "FJC2327U0S2" - component_specific_filters: - components_list: - - device_details - - provision_device + # No config provided - generates all inventory configurations -- name: Generate inventory with combined global filters (OR logic) +- name: Generate YAML Configuration with file path and overwrite mode cisco.dnac.inventory_playbook_config_generator: dnac_host: "{{ dnac_host }}" - dnac_port: "{{ dnac_port }}" dnac_username: "{{ dnac_username }}" dnac_password: "{{ dnac_password }}" dnac_verify: "{{ dnac_verify }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - state: gathered - file_path: "./inventory_combined_filters.yml" - config: - global_filters: - # OR logic - device matching ANY filter is included - ip_address_list: - - "10.195.225.40" - hostname_list: - - "switch-floor2.example.com" - component_specific_filters: - components_list: - - device_details - - interface_details - -- name: Generate inventory with AND device_details filters - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" dnac_port: "{{ dnac_port }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" dnac_version: "{{ dnac_version }}" dnac_debug: "{{ dnac_debug }}" + dnac_log_level: "{{ dnac_log_level }}" + dnac_log: true state: gathered - file_path: "./inventory_and_filter.yml" + file_path: "tmp/catc_inventory_config.yml" file_mode: "overwrite" - config: - component_specific_filters: - device_details: - # AND filter - device must match BOTH role AND type - role: "ACCESS" - type: "NETWORK_DEVICE" -- name: Generate inventory playbook for ACCESS role devices +- name: Generate YAML Configuration filtered by device roles cisco.dnac.inventory_playbook_config_generator: dnac_host: "{{ dnac_host }}" - dnac_port: "{{ dnac_port }}" dnac_username: "{{ dnac_username }}" dnac_password: "{{ dnac_password }}" dnac_verify: "{{ dnac_verify }}" - dnac_version: "{{ dnac_version }}" - dnac_debug: "{{ dnac_debug }}" - state: gathered - file_path: "./inventory_access_role_devices.yml" - file_mode: "overwrite" - config: - component_specific_filters: - device_details: - role: "ACCESS" - -- name: Generate inventory playbook with interface filtering - cisco.dnac.inventory_playbook_config_generator: - dnac_host: "{{ dnac_host }}" dnac_port: "{{ dnac_port }}" - dnac_username: "{{ dnac_username }}" - dnac_password: "{{ dnac_password }}" - dnac_verify: "{{ dnac_verify }}" dnac_version: "{{ dnac_version }}" dnac_debug: "{{ dnac_debug }}" + dnac_log_level: "{{ dnac_log_level }}" + dnac_log: true state: gathered - file_path: "./inventory_interface_filtered.yml" + file_path: "tmp/catc_filtered_inventory_config.yml" file_mode: "overwrite" config: global_filters: - ip_address_list: - - "10.195.225.40" - - "10.195.225.42" - component_specific_filters: - interface_details: - interface_name: - - "Vlan100" - - "GigabitEthernet1/0/1" + device_roles: ["ACCESS", "CORE"] + device_identifier: "hostname" -- name: Generate inventory playbook with filtered user-defined fields +- name: Generate YAML Configuration for selected devices using serial number and IP cisco.dnac.inventory_playbook_config_generator: dnac_host: "{{ dnac_host }}" - dnac_port: "{{ dnac_port }}" dnac_username: "{{ dnac_username }}" dnac_password: "{{ dnac_password }}" dnac_verify: "{{ dnac_verify }}" + dnac_port: "{{ dnac_port }}" dnac_version: "{{ dnac_version }}" dnac_debug: "{{ dnac_debug }}" + dnac_log_level: "{{ dnac_log_level }}" + dnac_log: true state: gathered + file_path: "tmp/catc_inventory_config.yml" + file_mode: "append" config: - component_specific_filters: - user_defined_fields: - name: "Cisco Switches" - value: ["2234", "value12345"] + global_filters: + devices: + - "10.10.20.11" + - "FDO1234A1BC" """ + RETURN = r""" # Case_1: Success Scenario response_1: description: A dictionary with the response returned by the Cisco Catalyst Center Python SDK - returned: success + returned: always type: dict sample: > { "msg": { - "YAML config generation Task succeeded for module 'inventory_workflow_manager'.": { - "file_path": "inventory_specific_ips.yml" - } + "configurations_count": 31, + "file_mode": "overwrite", + "file_path": "tmp/inventory_testing.yml", + "message": "YAML configuration file generated successfully for module 'inventory_workflow_manager'", + "status": "success" }, "response": { - "YAML config generation Task succeeded for module 'inventory_workflow_manager'.": { - "file_path": "inventory_specific_ips.yml" - } + "configurations_count": 31, + "file_mode": "overwrite", + "file_path": "tmp/inventory_testing.yml", + "message": "YAML configuration file generated successfully for module 'inventory_workflow_manager'", + "status": "success" }, "status": "success" } - # Case_2: Error Scenario response_2: - description: A string with the error message returned by the Cisco Catalyst Center Python SDK - returned: on failure + description: A string with the response returned by the Cisco Catalyst Center Python SDK + returned: always type: dict sample: > { - "msg": "Invalid 'global_filters' found for module 'inventory_workflow_manager': [\"Filter 'ip_address_list' must be a list, got NoneType\"]", - "response": "Invalid 'global_filters' found for module 'inventory_workflow_manager': [\"Filter 'ip_address_list' must be a list, got NoneType\"]" + "msg": + "Configuration cannot be an empty dictionary. Either omit 'config' entirely to generate all configurations, + or provide specific filters within 'config'.", + "response": + "Configuration cannot be an empty dictionary. Either omit 'config' entirely to generate all configurations, + or provide specific filters within 'config'." } """ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.dnac.plugins.module_utils.brownfield_helper import ( - BrownFieldHelper, + BrownFieldHelper ) from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( - DnacBase, + DnacBase ) -import time -try: - import yaml - HAS_YAML = True -except ImportError: - HAS_YAML = False - yaml = None from collections import OrderedDict - - -if HAS_YAML: - class OrderedDumper(yaml.Dumper): - def represent_dict(self, data): - return self.represent_mapping("tag:yaml.org,2002:map", data.items()) - - OrderedDumper.add_representer(OrderedDict, OrderedDumper.represent_dict) -else: - OrderedDumper = None +import time class InventoryPlaybookConfigGenerator(DnacBase, BrownFieldHelper): """ - A class for generator playbook files for infrastructure deployed within the Cisco Catalyst Center using the GET APIs. + A class for generating playbook files for inventory config deployed within the Cisco Catalyst Center using the GET APIs. """ values_to_nullify = ["NOT CONFIGURED"] @@ -466,22 +268,12 @@ def __init__(self, module): """ self.supported_states = ["gathered"] super().__init__(module) - self.module_schema = self.get_workflow_filters_schema() + self.module_schema = self.get_workflow_elements_schema() self.module_name = "inventory_workflow_manager" - self.generate_all_configurations = False # Initialize the attribute def validate_input(self): """ - Validate and normalize module input. - - Behavior: - - If C(config) is omitted or empty, generates all supported - components - - Uses top-level C(file_path) and C(file_mode) - - If C(config) is provided, it may contain only C(global_filters) - and/or C(component_specific_filters) - - Auto-adds components to C(components_list) when component filter - blocks are provided + Validates the input configuration parameters for the playbook. Returns: object: An instance of the class with updated attributes: @@ -490,3258 +282,545 @@ def validate_input(self): self.validated_config: If successful, a validated version of the "config" parameter. """ self.log("Starting validation of input configuration parameters.", "DEBUG") - normalized_config = self.config - file_path = self.params.get("file_path") - file_mode = self.params.get("file_mode", "overwrite") - if file_mode not in ("overwrite", "append"): + # Check if config is provided but empty - Error scenario + if isinstance(self.config, dict) and len(self.config) == 0: self.msg = ( - "Invalid value for 'file_mode': '{0}'. " - "Allowed values are: ['overwrite', 'append'].".format(file_mode) + "Configuration cannot be an empty dictionary. " + "Either omit 'config' entirely to generate all configurations, " + "or provide specific filters within 'config'." ) + self.log(self.msg, "ERROR") self.set_operation_result("failed", False, self.msg, "ERROR") return self - if not file_path and file_mode != "overwrite": - self.log( - "file_mode='{0}' is ignored because file_path is not provided.".format( - file_mode - ), - "WARNING", - ) - - if not normalized_config: + # Check if configuration is not provided (None) - treat as generate_all + if self.config is None: self.validated_config = {"generate_all_configurations": True} - self.msg = "No config provided. Generating all supported components." - self.set_operation_result("success", False, self.msg, "INFO") + self.msg = "Configuration is not provided - treating as generate all config mode" + self.log(self.msg, "INFO") return self - allowed_keys = { - "global_filters", - "component_specific_filters", - } - self.validate_invalid_params(normalized_config, allowed_keys) - + # Expected schema for configuration parameters temp_spec = { - "component_specific_filters": { - "type": "dict", - "required": False, - }, "global_filters": { "type": "dict", - "required": False, - }, - } - - validated_config = self.validate_config_dict(normalized_config, temp_spec) - if not isinstance(validated_config, dict): - validated_config = dict(normalized_config) - else: - validated_config = { - key: value for key, value in validated_config.items() if value is not None + "required": False } + } - global_filters = validated_config.get("global_filters") - component_filters = validated_config.get("component_specific_filters") - - if global_filters is None and component_filters is None: - self.msg = ( - "Validation Error: when 'config' is provided, at least one of " - "'global_filters' or 'component_specific_filters' must be present." - ) - self.set_operation_result("failed", False, self.msg, "ERROR") - return self + # Validate params + self.log("Validating configuration against schema", "DEBUG") + valid_temp = self.validate_config_dict(self.config, temp_spec) - if component_filters is not None: - validation_error = self._validate_inventory_component_specific_filters( - component_filters - ) - if validation_error: - self.msg = validation_error - self.set_operation_result("failed", False, self.msg, "ERROR") - return self + self.log("Validating invalid parameters against provided config", "DEBUG") + self.validate_invalid_params(self.config, temp_spec.keys()) - self.validated_config = validated_config - self.msg = ( - "Successfully validated playbook configuration parameters using " - "'validated_input': {0}".format(str(validated_config)) + # Set the validated configuration and update the result with success status + self.validated_config = valid_temp + self.msg = "Successfully validated playbook configuration parameters using 'validated_input': {0}".format( + str(valid_temp) ) self.set_operation_result("success", False, self.msg, "INFO") return self - def _validate_inventory_string_or_list(self, value, field_name): + def get_workflow_elements_schema(self): """ - Validate and auto-populate component-specific filters. - - Checks key validity, validates each component block via - _validate_inventory_component_block, and auto-adds - components to components_list when filter blocks are - provided. + Constructs and returns a structured mapping for inventory global filters + used by the playbook configuration generator workflow. This mapping defines + supported filter keys used during input validation and downstream filtering logic. Args: - component_filters (dict): The - 'component_specific_filters' dictionary from - the user's config input. + self: Refers to the instance of the class containing helper methods. - Returns: - str | None: Error message string if validation - fails, None on success. + Return: + dict: A dictionary containing "global_filters". """ - if isinstance(value, str): - return None - if isinstance(value, list) and all(isinstance(item, str) for item in value): - return None + self.log("Building workflow filters schema for inventory module.", "DEBUG") - return ( - "'{0}' must be a string or a list of strings, got: {1}.".format( - field_name, type(value).__name__ - ) - ) + schema = { + "global_filters": { + "devices": { + "type": "list", + "elements": "str", + "required": False, + }, + "device_roles": { + "type": "list", + "elements": "str", + "required": False, + "choices": [ + "ACCESS", + "DISTRIBUTION", + "CORE", + "BORDER ROUTER", + "UNKNOWN" + ] + }, + "device_identifier": { + "type": "str", + "choices": ["ip_address", "hostname", "serial_number", "mac_address"], + "default": "ip_address", + "required": False + } + } + } - def _validate_inventory_component_block(self, component_name, component_value): - """ - Validate a single component filter block for inventory generation. + global_filters = list(schema["global_filters"].keys()) + self.log( + f"Workflow filters schema generated successfully with {len(global_filters)} global filter(s): {global_filters}", + "INFO", + ) - For 'device_details', accepts a dictionary or list of dictionaries. - Keys inside each dictionary use AND logic and multiple dictionaries use - OR logic. For other components, accepts a dictionary only. + return schema - Args: - component_name (str): One of 'device_details', - 'provision_device', 'interface_details', or - 'user_defined_fields'. - component_value (dict | list): The filter block to validate. + def device_identifiers_temp_spec(self): + """ + Constructs a temporary specification for device identifiers for inventory devices. + This specification includes details such as IP address, hostname, serial number, MAC address, and role. + This is used to transform the device identifiers in the inventory configuration. Returns: - str | None: Error message if validation fails, otherwise None. + OrderedDict: An ordered dictionary defining the structure of device identifier attributes. """ - if component_name == "device_details": - if isinstance(component_value, dict): - filter_items = [component_value] - - elif isinstance(component_value, list): - filter_items = component_value - else: - return ( - "'device_details' must be a dictionary or list of dictionaries, " - "got: {0}.".format(type(component_value).__name__) - ) + self.log("Generating temporary specification for device identifiers.", "DEBUG") - allowed_filter_keys = {"type", "role", "snmp_version", "cli_transport"} - allowed_types = { - "NETWORK_DEVICE", - "COMPUTE_DEVICE", - "MERAKI_DASHBOARD", - "THIRD_PARTY_DEVICE", - "FIREPOWER_MANAGEMENT_SYSTEM", - } - allowed_roles = { - "ACCESS", - "CORE", - "DISTRIBUTION", - "BORDER ROUTER", - "UNKNOWN", + device_identifier_details = OrderedDict( + { + "ip_address": {"type": "str", "source_key": "managementIpAddress"}, + "hostname": {"type": "str", "source_key": "hostname"}, + "serial_number": {"type": "str", "source_key": "serialNumber"}, + "mac_address": {"type": "str", "source_key": "macAddress"}, + "role": {"type": "str", "source_key": "role"}, + "family": {"type": "str", "source_key": "family"} } - allowed_snmp_versions = {"v2", "v2c", "v3"} - allowed_cli_transports = {"ssh", "telnet", "SSH", "TELNET"} + ) - for index, filter_item in enumerate(filter_items, start=1): - if not isinstance(filter_item, dict): - return ( - "Each entry in 'device_details' must be a dictionary, but entry " - "{0} is of type: {1}.".format( - index, type(filter_item).__name__ - ) - ) + return device_identifier_details - invalid_keys = set(filter_item.keys()) - allowed_filter_keys - if invalid_keys: - return ( - "Invalid parameters found in 'device_details' filter entry {0}: " - "{1}. Allowed parameters are: {2}.".format( - index, - sorted(list(invalid_keys)), - sorted(list(allowed_filter_keys)), - ) - ) + def apply_global_filters(self, inventory_config_data, global_filters): + """ + Apply supported global filters to inventory configuration data. - device_type = filter_item.get("type") - if device_type is not None: - if not isinstance(device_type, str) or device_type not in allowed_types: - return ( - "Invalid 'type' value '{0}' in 'device_details'. " - "Allowed values are: {1}.".format( - device_type, sorted(list(allowed_types)) - ) - ) + Supported global_filters keys: + - devices (list): Match when any one of these record fields equals + any provided value: ip_address, hostname, serial_number, mac_address. + - device_roles (list): Match against record key 'role'. - role = filter_item.get("role") - if role is not None: - if isinstance(role, str): - role_values = [role] - filter_item["role"] = role_values + Filter groups are combined using AND semantics. + + Args: + inventory_config_data (list): List of device dictionaries to be filtered. + global_filters (dict): Dictionary containing supported global filters. - elif isinstance(role, list): - role_values = role + Returns: + list: Filtered list of device dictionaries that match the global filter criteria. + """ - else: - return ( - "'device_details.role' must be a string or list of strings, " - "got: {0}.".format(type(role).__name__) - ) + if not global_filters: + self.log("No global filters provided, skipping filtering step.", "DEBUG") + return inventory_config_data - invalid_roles = [ - role_item - for role_item in role_values - if not isinstance(role_item, str) or role_item not in allowed_roles - ] - if invalid_roles: - return ( - "Invalid role value(s) in 'device_details.role': {0}. " - "Allowed values are: {1}.".format( - invalid_roles, sorted(list(allowed_roles)) - ) - ) + self.log( + "Applying global filters to inventory configuration data. Filters: {0}".format( + self.pprint(global_filters) + ), + "DEBUG", + ) - snmp_version = filter_item.get("snmp_version") - if snmp_version is not None: - if ( - not isinstance(snmp_version, str) - or snmp_version not in allowed_snmp_versions - ): - return ( - "Invalid 'snmp_version' value '{0}' in 'device_details'. " - "Allowed values are: {1}.".format( - snmp_version, sorted(list(allowed_snmp_versions)) - ) - ) + supported_filter_keys = {"devices", "device_roles", "device_identifier"} + unknown_filter_keys = sorted(set(global_filters.keys()) - supported_filter_keys) + if unknown_filter_keys: + self.log( + "Ignoring unsupported global filter key(s): {0}".format( + unknown_filter_keys + ), + "WARNING", + ) - cli_transport = filter_item.get("cli_transport") - if cli_transport is not None: - if ( - not isinstance(cli_transport, str) - or cli_transport not in allowed_cli_transports - ): - return ( - "Invalid 'cli_transport' value '{0}' in 'device_details'. " - "Allowed values are: {1}.".format( - cli_transport, sorted(list(allowed_cli_transports)) - ) - ) - return None + devices_filter = global_filters.get("devices") or [] + roles_filter = global_filters.get("device_roles") or [] - if not isinstance(component_value, dict): - return ( - "'{0}' must be a dictionary, got: {1}.".format( - component_name, type(component_value).__name__ - ) + if not devices_filter and not roles_filter: + self.log( + "No valid global filters provided within 'global_filters' for filtering. Skipping filtering step.", + "DEBUG", ) + return inventory_config_data - if component_name == "provision_device": - invalid_keys = set(component_value.keys()) - {"site_name"} - if invalid_keys: - return ( - "Invalid parameters found in 'provision_device': {0}. " - "Allowed parameters are: ['site_name'].".format( - sorted(list(invalid_keys)) - ) - ) + devices_values = { + value for value in devices_filter if value is not None and value.strip() + } + roles_values = { + value for value in roles_filter if value is not None and value.strip() + } - site_name = component_value.get("site_name") - if site_name is not None and not isinstance(site_name, str): - self.log( - "Validation failed: 'provision_device.site_name' must be " - "a string, got '{0}'.".format(type(site_name).__name__), - "WARNING", - ) - return "'provision_device.site_name' must be a string." + self.log( + "Resolved global filters - devices({0}): {1}, roles({2}): {3}".format( + len(devices_values), devices_values, + len(roles_values), roles_values + ), + "DEBUG", + ) - return None + filtered_data = [ + record for record in inventory_config_data if isinstance(record, dict) + ] - if component_name == "interface_details": - invalid_keys = set(component_value.keys()) - {"interface_name"} - if invalid_keys: - return ( - "Invalid parameters found in 'interface_details': {0}. " - "Allowed parameters are: ['interface_name'].".format( - sorted(list(invalid_keys)) - ) - ) - interface_name = component_value.get("interface_name") - if interface_name is not None: - validation_error = self._validate_inventory_string_or_list( - interface_name, "interface_details.interface_name" - ) - if validation_error: - return validation_error - if isinstance(interface_name, str): - component_value["interface_name"] = [interface_name] - return None + if len(filtered_data) != len(inventory_config_data): + self.log( + "Skipped {0} non-dictionary record(s) before applying filters.".format( + len(inventory_config_data) - len(filtered_data) + ), + "DEBUG", + ) - if component_name == "user_defined_fields": - invalid_keys = set(component_value.keys()) - {"name", "value"} - if invalid_keys: - return ( - "Invalid parameters found in 'user_defined_fields': {0}. " - "Allowed parameters are: ['name', 'value'].".format( - sorted(list(invalid_keys)) - ) - ) + if devices_values: + before_count = len(filtered_data) + device_identifier_fields = [ + "ip_address", + "hostname", + "serial_number", + "mac_address", + ] + filtered_data = [ + record + for record in filtered_data + if any(record.get(field) in devices_values for field in device_identifier_fields) + ] + self.log( + "Applied 'devices' global filter. Records before: {0}, after: {1}.".format( + before_count, len(filtered_data) + ), + "DEBUG", + ) - for field_name in ("name", "value"): - field_value = component_value.get(field_name) - if field_value is not None: - validation_error = self._validate_inventory_string_or_list( - field_value, "user_defined_fields.{0}".format(field_name) - ) - if validation_error: - return validation_error - if isinstance(field_value, str): - component_value[field_name] = [field_value] + if roles_values: + before_count = len(filtered_data) + filtered_data = [ + record for record in filtered_data if record.get("role") in roles_values + ] + self.log( + "Applied 'roles' global filter. Records before: {0}, after: {1}.".format( + before_count, len(filtered_data) + ), + "DEBUG", + ) - return None + self.log( + "Completed filtering using global filters. Matched {0} of {1} input record(s). Filtered data: {2}" + .format( + len(filtered_data), + len(inventory_config_data), + filtered_data, + ), + "DEBUG", + ) - return None + return filtered_data - def _validate_inventory_component_specific_filters(self, component_filters): + def transform_config_using_device_identifier(self, inventory_config_data, device_identifier): """ - Validate and auto-populate component-specific filters. - - Checks key validity, validates each component block via - _validate_inventory_component_block(), and auto-adds components to - 'components_list' when filter blocks are provided. + Transform inventory records to use selected device identifier list key. Args: - component_filters (dict): The 'component_specific_filters' - dictionary from the user configuration. + inventory_config_data (list): List of device dictionaries. + device_identifier (str): Selected identifier key from supported device + identifiers. Returns: - str | None: Error message if validation fails, otherwise None. + list: Updated list where only selected identifier is retained as + '_list' with a single-item list value. """ - if not isinstance(component_filters, dict): - return ( - "'component_specific_filters' must be a dictionary, got: {0}.".format( - type(component_filters).__name__ - ) - ) - if "component_list" in component_filters: - return "Invalid key 'component_list' under component_specific_filters. Use 'components_list'." - - allowed_component_filter_keys = { - "components_list", - "device_details", - "provision_device", - "interface_details", - "user_defined_fields", - } - invalid_filter_keys = set(component_filters.keys()) - allowed_component_filter_keys - if invalid_filter_keys: - return ( - "Invalid keys found in 'component_specific_filters': {0}. " - "Allowed keys are: {1}.".format( - sorted(list(invalid_filter_keys)), - sorted(list(allowed_component_filter_keys)), - ) + if not isinstance(inventory_config_data, list): + self.log( + "Expected list for inventory_config_data, got '{0}'. Skipping IP transformation.".format( + type(inventory_config_data).__name__ + ), + "WARNING", ) + return inventory_config_data - components_list = component_filters.get("components_list") - normalized_components_list = [] - allowed_components = { - "device_details", - "provision_device", - "interface_details", - "user_defined_fields", - } + supported_device_identifiers = [ + "ip_address", + "hostname", + "serial_number", + "mac_address", + ] + # We don't need family in the output config, so we can remove it from the output if present. + removable_output_keys = set(supported_device_identifiers + ["family"]) + if device_identifier not in supported_device_identifiers: + self.log( + "Unsupported device_identifier '{0}'. Supported values are: {1}. Returning unmodified config.".format( + device_identifier, supported_device_identifiers + ), + "WARNING", + ) + return inventory_config_data - if components_list is not None: - if not isinstance(components_list, list): - return ( - "'components_list' must be a list, got: {0}.".format( - type(components_list).__name__ - ) - ) + device_identifier_list_key = "{0}_list".format(device_identifier) - invalid_components = [ - component - for component in components_list - if component not in allowed_components - ] - if invalid_components: - return ( - "Invalid component names found in 'components_list': {0}. " - "Allowed values are: {1}.".format( - sorted(list(set(invalid_components))), - sorted(list(allowed_components)), - ) - ) + self.log( + "Transforming device identifier '{0}' to '{1}' for {2} record(s).".format( + device_identifier, device_identifier_list_key, len(inventory_config_data) + ), + "DEBUG", + ) - seen_components = set() - duplicate_components = [] - for component_name in components_list: - if component_name in seen_components and component_name not in duplicate_components: - duplicate_components.append(component_name) - seen_components.add(component_name) - normalized_components_list.append(component_name) + transformed_inventory_config = [] - if duplicate_components: - return ( - "Duplicate component names found in 'components_list': {0}. " - "Each component may be specified only once.".format( - duplicate_components - ) + for device_info in inventory_config_data: + if not isinstance(device_info, dict): + self.log( + "Skipping non-dictionary record during IP transformation: {0}".format( + device_info + ), + "DEBUG", ) + continue - component_blocks = [] - component_names_to_check = ( - "device_details", - "provision_device", - "interface_details", - "user_defined_fields", - ) - total_component_names = len(component_names_to_check) - for idx, component_name in enumerate(component_names_to_check, start=1): - if component_name not in component_filters: + identifier_value = device_info.get(device_identifier) + if not identifier_value: self.log( - "Processing component {0}/{1}: '{2}' is not present in " - "component_specific_filters. Continuing.".format( - idx, total_component_names, component_name + "Record does not contain valid '{0}'; skipping: {1}".format( + device_identifier, device_info ), "DEBUG", ) continue - self.log( - "Processing component {0}/{1}: validating filter block for " - "'{2}'.".format(idx, total_component_names, component_name), - "DEBUG", - ) - validation_error = self._validate_inventory_component_block( - component_name, component_filters.get(component_name) - ) - if validation_error: - return validation_error - - component_blocks.append(component_name) - - if component_blocks: - for component_name in component_blocks: - if component_name not in normalized_components_list: - normalized_components_list.append(component_name) - component_filters["components_list"] = normalized_components_list - elif not normalized_components_list: - return ( - "Validation Error: 'components_list' is mandatory and must be " - "non-empty when no component filter blocks are provided under " - "'component_specific_filters'." - ) - else: - component_filters["components_list"] = normalized_components_list + # Keep selected _list as first key and remove all identifier keys. + transformed_device_info = { + device_identifier_list_key: [identifier_value], + **{ + k: v + for k, v in device_info.items() + if k not in removable_output_keys + }, + } + transformed_inventory_config.append(transformed_device_info) - return None + return transformed_inventory_config - def get_workflow_filters_schema(self): + def fetch_device_identifier_details(self): """ - Description: Returns the schema for workflow filters supported by the module. + Fetch and transform device identifier details from Catalyst Center inventory. + Returns: - dict: A dictionary representing the schema for workflow filters. + list: Transformed list of device records containing identifier fields + such as ip_address, hostname, serial_number, mac_address, and role. """ - self.log("Building workflow filter schema for inventory generation.", "DEBUG") - - schema = { - "network_elements": { - "device_details": { - "filters": { - "type": { - "type": "str", - "required": False, - "choices": [ - "NETWORK_DEVICE", - "COMPUTE_DEVICE", - "MERAKI_DASHBOARD", - "THIRD_PARTY_DEVICE", - "FIREPOWER_MANAGEMENT_SYSTEM", - ], - }, - "role": { - "type": "list", - "required": False, - "elements": "str", - "choices": [ - "ACCESS", - "CORE", - "DISTRIBUTION", - "BORDER ROUTER", - "UNKNOWN", - ], - }, - "snmp_version": { - "type": "str", - "required": False, - "choices": ["v2", "v2c", "v3"], - }, - "cli_transport": { - "type": "str", - "required": False, - "choices": ["ssh", "telnet", "SSH", "TELNET"], - }, - }, - "api_function": "get_device_list", - "api_family": "devices", - "reverse_mapping_function": self.inventory_get_device_reverse_mapping, - "get_function_name": self.get_device_details_details, - }, - "provision_device": { - "filters": { - "site_name": { - "type": "str", - "required": False, - }, - }, - "is_filter_only": True, - }, - "interface_details": { - "filters": { - "interface_name": { - "type": "list", - "required": False, - "elements": "str", - }, - }, - "is_filter_only": True, - }, - "user_defined_fields": { - "filters": { - "name": { - "type": "list", - "required": False, - "elements": "str", - }, - "value": { - "type": "list", - "required": False, - "elements": "str", - }, - }, - "api_function": "get_device_list", - "api_family": "devices", - "get_function_name": self.get_user_defined_fields_details, - }, - }, - "global_filters": { - "ip_address_list": { - "type": "list", - "required": False, - "elements": "str", - "validate_ip": True, - }, - "hostname_list": {"type": "list", "required": False, "elements": "str"}, - "serial_number_list": { - "type": "list", - "required": False, - "elements": "str", - }, - "mac_address_list": { - "type": "list", - "required": False, - "elements": "str", - }, - }, - "component_specific_filters": { - "components_list": { - "type": "list", - "required": False, - "elements": "str", - "choices": [ - "device_details", - "provision_device", - "interface_details", - "user_defined_fields", - ], - }, - "device_details": { - "type": { - "type": "str", - "required": False, - "choices": [ - "NETWORK_DEVICE", - "COMPUTE_DEVICE", - "MERAKI_DASHBOARD", - "THIRD_PARTY_DEVICE", - "FIREPOWER_MANAGEMENT_SYSTEM", - ], - }, - "role": { - "type": "str", - "required": False, - "choices": [ - "ACCESS", - "CORE", - "DISTRIBUTION", - "BORDER ROUTER", - "UNKNOWN", - ], - }, - "snmp_version": { - "type": "str", - "required": False, - "choices": ["v2", "v2c", "v3"], - }, - "cli_transport": { - "type": "str", - "required": False, - "choices": ["ssh", "telnet", "SSH", "TELNET"], - }, - }, - "interface_details": { - "interface_name": { - "type": "list", - "required": False, - "elements": "str", - }, - }, - }, - } - - self.log( - "Workflow filter schema built with components: {0}".format( - list(schema.get("network_elements", {}).keys()) - ), - "DEBUG", - ) - - return schema - - def fetch_all_devices(self, reason=""): - """ - Fetch all devices from Cisco Catalyst Center API with pagination support. - Handles large device inventories (500+ devices) by paginating through results. - Deduplicates devices by UUID to prevent duplicate entries. - - Args: - reason (str): Optional reason for fetching all devices (for logging) - - Returns: - list: List of all device dictionaries from API - """ - self.log( - "Starting device inventory retrieval for playbook generation. " - "Reason: {0}".format(reason if reason else "not provided"), - "INFO", - ) - - all_devices = [] - seen_device_ids = set() - offset = 1 - limit = 500 - page_number = 1 - - try: - while True: - request_params = {"offset": offset, "limit": limit} - self.log( - "Requesting device inventory page {0} with offset={1}, limit={2}".format( - page_number, offset, limit - ), - "DEBUG", - ) - - response = self.dnac._exec( - family="devices", - function="get_device_list", - op_modifies=False, - params=request_params, - ) - - if not isinstance(response, dict): - self.msg = ( - "Invalid device inventory response type: expected dict, got {0}".format( - type(response).__name__ - ) - ) - self.status = "failed" - self.log(self.msg, "ERROR") - return [] - - page_devices = response.get("response", []) - if page_devices is None: - page_devices = [] - - if isinstance(page_devices, dict): - page_devices = [page_devices] - elif not isinstance(page_devices, list): - self.msg = ( - "Invalid device inventory payload type under 'response': " - "expected list or dict, got {0}".format(type(page_devices).__name__) - ) - self.status = "failed" - self.log(self.msg, "ERROR") - return [] - - if not page_devices: - self.log( - "No additional devices returned from API. Pagination complete at page {0}.".format( - page_number - ), - "DEBUG", - ) - break - - added_count = 0 - for device in page_devices: - if not isinstance(device, dict): - continue - - device_id = device.get("id") or device.get("instanceUuid") - if device_id and device_id in seen_device_ids: - continue - - if device_id: - seen_device_ids.add(device_id) - - all_devices.append(device) - added_count += 1 - - self.log( - "Processed page {0}: received={1}, added={2}, cumulative_total={3}".format( - page_number, len(page_devices), added_count, len(all_devices) - ), - "INFO", - ) - - if len(page_devices) < limit: - self.log( - "Last page detected because returned records are fewer than the page limit.", - "DEBUG", - ) - break - - offset += limit - page_number += 1 - - if all_devices: - sample_fields = sorted(all_devices[0].keys()) - self.log( - "Completed device inventory retrieval. Total devices collected: {0}".format( - len(all_devices) - ), - "INFO", - ) - self.log( - "Sample fields available in retrieved device payload: {0}".format( - sample_fields - ), - "DEBUG", - ) - else: - self.log( - "Completed device inventory retrieval with no devices returned.", - "WARNING", - ) - - return all_devices - - except Exception as e: - self.msg = "Failed to retrieve device inventory from Catalyst Center: {0}".format( - str(e) - ) - self.status = "failed" - self.log(self.msg, "ERROR") - return [] - - def fetch_user_defined_field_descriptions(self, udf_names): - """ - Fetch UDF descriptions using /dna/intent/api/v1/network-device/user-defined-field. - - Args: - udf_names (iterable): UDF names to look up. - - Returns: - dict: Mapping of UDF name to description. - """ - if not udf_names: - return {} - - name_list = [name for name in udf_names if isinstance(name, str) and name.strip()] - if not name_list: - return {} - - unique_names = sorted(set(name_list)) - params = {"name": ",".join(unique_names)} - self.log( - "Fetching UDF descriptions for {0} names.".format(len(unique_names)), - "DEBUG", - ) - - try: - response = self.dnac._exec( - family="devices", - function="get_all_user_defined_fields", - op_modifies=False, - params=params, - ) - except Exception as error: - self.log( - "Failed to fetch UDF descriptions: {0}".format(str(error)), - "ERROR", - ) - return {} - - if not isinstance(response, dict): - self.log( - "Invalid UDF response type: expected dict, got {0}".format( - type(response).__name__ - ), - "WARNING", - ) - return {} - - records = response.get("response", []) - self.log("Received UDF descriptions response with {0} records.".format(len(records)), "INFO") - if records is None: - records = [] - if isinstance(records, dict): - records = [records] - elif not isinstance(records, list): - self.log( - "Invalid UDF response payload type: {0}".format(type(records).__name__), - "WARNING", - ) - return {} - - mapping = {} - for record in records: - if not isinstance(record, dict): - continue - name = record.get("name") - description = record.get("description") - if isinstance(name, str) and name.strip(): - mapping[name] = description if description is not None else "" - - return mapping - - def fetch_device_user_defined_fields(self, device_id): - """ - Fetch user-defined fields for a specific device. - Attempts multiple SDK functions to retrieve UDF data. - - Args: - device_id (str): The device UUID/ID - - Returns: - dict: User-defined fields as {name: value} mapping - """ - if not device_id or not isinstance(device_id, str): - return {} - - # Try the direct function first - sdk_functions = [ - ("get_device_user_defined_fields", {"device_id": device_id}), - ("get_network_device_user_defined_fields", {"deviceId": device_id}), - ("get_network_device_user_defined_fields", {"device_id": device_id}), - ] - - for func_name, params in sdk_functions: - try: - response = self.dnac._exec( - family="devices", - function=func_name, - op_modifies=False, - params=params, - ) - except Exception as error: - continue - - if not isinstance(response, dict): - continue - - records = response.get("response", []) - if records is None: - records = [] - if isinstance(records, dict): - records = [records] - elif not isinstance(records, list): - continue - - udf_dict = {} - for record in records: - if not isinstance(record, dict): - continue - name = record.get("name") - value = record.get("value") - if isinstance(name, str) and name.strip(): - udf_dict[name] = value - - if udf_dict: - return udf_dict - - return {} - - def get_user_defined_fields_details(self, network_element, filters): - """ - Build user-defined fields configuration data for YAML output. - Fetches devices from /networkDevices endpoint with USER_DEFINED_FIELDS view. - - Args: - network_element (dict): network element schema definition - filters (dict): contains global_filters and generate_all_configurations flags - - Returns: - dict: Configuration with user_defined_fields section, or empty dict - """ - global_filters = (filters or {}).get("global_filters") or {} - generate_all = bool((filters or {}).get("generate_all_configurations", False)) - - allowed_ips = set() - if not generate_all and global_filters and any(global_filters.values()): - filter_result = self.process_global_filters(global_filters) - device_mapping = filter_result.get("device_ip_to_id_mapping", {}) - if isinstance(device_mapping, dict): - allowed_ips = set(device_mapping.keys()) - - # Fetch all devices with USER_DEFINED_FIELDS view - all_devices = [] - seen_device_ids = set() - offset = 1 - limit = 500 - page_number = 1 - - self.log("Fetching devices with USER_DEFINED_FIELDS view for UDF processing", "INFO") - - try: - while True: - request_params = { - "offset": str(offset), - "limit": str(limit), - "views": "USER_DEFINED_FIELDS", - } - - self.log( - "UDF device list request params (page {0}): {1}".format( - page_number, request_params - ), - "INFO", - ) - - response = self.dnac._exec( - family="devices", - function="retrieve_network_devices", - op_modifies=False, - params=request_params, - ) - - if not isinstance(response, dict): - break - - page_devices = response.get("response", []) - if not page_devices: - break - - if isinstance(page_devices, dict): - page_devices = [page_devices] - elif not isinstance(page_devices, list): - break - - added_count = 0 - for device in page_devices: - if not isinstance(device, dict): - continue - - device_id = device.get("id") or device.get("instanceUuid") - if device_id and device_id in seen_device_ids: - continue - - if device_id: - seen_device_ids.add(device_id) - - all_devices.append(device) - added_count += 1 - - if len(page_devices) < limit: - break - - offset += limit - page_number += 1 - - except Exception as e: - self.log("Error fetching devices with USER_DEFINED_FIELDS view: {0}".format(str(e)), "ERROR") - - if not all_devices: - self.log("No devices returned for user_defined_fields generation.", "WARNING") - return [] - - self.log("Fetched {0} devices with USER_DEFINED_FIELDS view".format(len(all_devices)), "INFO") - - # Extract UDF-specific filters from component_specific_filters - # Note: component_specific_filters contains the filters FOR this component directly - component_filters = (filters or {}).get("component_specific_filters") or {} - self.log("Component filters received: {0}".format(component_filters), "DEBUG") - - udf_name_filter = component_filters.get("name") - udf_value_filter = component_filters.get("value") - - self.log("Extracted UDF filters - name: {0}, value: {1}".format(udf_name_filter, udf_value_filter), "DEBUG") - - # Normalize UDF name filter to set - udf_name_filter_set = None - if udf_name_filter: - if isinstance(udf_name_filter, str): - udf_name_filter_set = {udf_name_filter.strip()} - elif isinstance(udf_name_filter, list): - udf_name_filter_set = { - name.strip() for name in udf_name_filter - if isinstance(name, str) and name.strip() - } - - if udf_name_filter_set: - self.log("UDF name filter applied: {0}".format(sorted(list(udf_name_filter_set))), "INFO") - - # Normalize UDF value filter to list for consistent processing - udf_value_filter_list = None - if udf_value_filter: - if isinstance(udf_value_filter, str): - udf_value_filter_list = [udf_value_filter] - self.log("UDF value filter applied: {0}".format(udf_value_filter_list), "INFO") - elif isinstance(udf_value_filter, list): - udf_value_filter_list = udf_value_filter - self.log("UDF value filter applied: {0}".format(udf_value_filter_list), "INFO") - - # Collect all UDF names and device data - udf_names = set() - device_udf_data = {} # managementAddress -> {udf_dict} - devices_with_udf = 0 - - for device in all_devices: - if not isinstance(device, dict): - continue - - ip_address = ( - device.get("managementAddress") - or device.get("dnsResolvedManagementIpAddress") - or device.get("managementIpAddress") - or device.get("ipAddress") - ) - - if not ip_address: - continue - - if allowed_ips and ip_address not in allowed_ips: - continue - - # Extract UDF data from device response - user_defined_fields = device.get("userDefinedFields", {}) - - if isinstance(user_defined_fields, dict) and user_defined_fields: - devices_with_udf += 1 - - # Filter UDFs based on name and value filters - filtered_udf_data = {} - for udf_name in user_defined_fields.keys(): - if not isinstance(udf_name, str) or not udf_name.strip(): - continue - - # Apply UDF name filter check - if udf_name_filter_set is not None and udf_name not in udf_name_filter_set: - self.log("Filtering out UDF '{0}' for {1}: not in name filter".format( - udf_name, ip_address), "DEBUG") - continue - - # Apply UDF value filter check - udf_value = user_defined_fields.get(udf_name) - if udf_value_filter: - # Supports str/list with OR logic - if udf_value_filter_list is not None: - if str(udf_value) not in [str(v) for v in udf_value_filter_list]: - self.log("Filtering out UDF '{0}' for {1}: value '{2}' not in filter list {3}".format( - udf_name, ip_address, udf_value, udf_value_filter_list), "DEBUG") - continue - - # This UDF passes the filters, include it - filtered_udf_data[udf_name] = udf_value - udf_names.add(udf_name) - self.log("Including UDF '{0}' for {1}: value '{2}'".format( - udf_name, ip_address, udf_value), "DEBUG") - - # Only store device if it has UDFs after filtering - if filtered_udf_data: - device_udf_data[ip_address] = filtered_udf_data - - self.log( - "Device {0}: {1} total UDFs, {2} after filtering".format( - ip_address, len(user_defined_fields), len(filtered_udf_data) - ), - "DEBUG", - ) - - self.log( - "UDF scan summary: total_devices={0}, devices_with_udf_data={1}".format( - len(all_devices), devices_with_udf - ), - "INFO", - ) - - if not device_udf_data: - self.log("No devices with user-defined fields found.", "WARNING") - return [] - - # Fetch descriptions for all UDF names - udf_descriptions = self.fetch_user_defined_field_descriptions(udf_names) - - # Group devices by identical UDF sets - grouped_entries = {} - for ip_address, udf_dict in device_udf_data.items(): - udf_list = [] - for udf_name, udf_value in sorted(udf_dict.items()): - # All UDFs here are already filtered, so just build the list - udf_list.append( - { - "name": udf_name, - "description": udf_descriptions.get(udf_name, ""), - "value": udf_value, - } - ) - - if not udf_list: - continue - - # Create grouping key based on UDF configuration - grouping_key = tuple( - (item.get("name"), item.get("description"), str(item.get("value"))) - for item in udf_list - ) - - entry = grouped_entries.get(grouping_key) - if entry is None: - grouped_entries[grouping_key] = { - "ip_address_list": [ip_address], - "add_user_defined_field": udf_list, - } - else: - entry_ips = entry.get("ip_address_list", []) - if ip_address not in entry_ips: - entry_ips.append(ip_address) - entry["ip_address_list"] = entry_ips - - if not grouped_entries: - self.log("No UDF entries found matching the specified filters.", "WARNING") - return [] - - self.log( - "Generated {0} consolidated UDF configurations".format(len(grouped_entries)), - "INFO", - ) - - if udf_value_filter: - self.log("UDF value filter applied: {0}".format(udf_value_filter), "INFO") - - return {"user_defined_fields": list(grouped_entries.values())} - - def process_global_filters(self, global_filters): - """ - Retrieve device details for the provided global filters. - - Args: - global_filters (dict): Filter dictionary with optional keys: - ip_address_list, hostname_list, serial_number_list, mac_address_list. - - Returns: - dict: {"device_ip_to_id_mapping": {: }} - """ - self.log( - "Collecting device inventory using global filter input: {0}".format(global_filters), - "DEBUG", - ) - - device_ip_to_id_mapping = {} - lookup_errors = 0 - - def normalize_filter_values(filter_name): - """Normalize filter values to a unique, non-empty string list.""" - raw_value = (global_filters or {}).get(filter_name) - - if raw_value is None: - return [] - - if isinstance(raw_value, str): - raw_value = [raw_value] - - if not isinstance(raw_value, list): - self.log( - "Skipping filter '{0}' because the value type is invalid: {1}".format( - filter_name, type(raw_value).__name__ - ), - "WARNING", - ) - return [] - - normalized = [] - for item in raw_value: - if not isinstance(item, str): - self.log( - "Ignoring non-string value in filter '{0}': {1}".format( - filter_name, item - ), - "WARNING", - ) - continue - item = item.strip() - if item: - normalized.append(item) - - return list(dict.fromkeys(normalized)) - - def add_device_to_mapping(device_info, filter_name, filter_value): - """Add or refresh a device entry in the IP-to-device mapping.""" - if not isinstance(device_info, dict): - return - - device_ip = device_info.get("managementIpAddress") or device_info.get("ipAddress") - if not device_ip: - self.log( - "Skipping device from {0}='{1}' because management IP is missing".format( - filter_name, filter_value - ), - "WARNING", - ) - return - - existing_device = device_ip_to_id_mapping.get(device_ip) - if existing_device is None: - device_ip_to_id_mapping[device_ip] = device_info - self.log( - "Added device '{0}' from {1}='{2}'".format( - device_ip, filter_name, filter_value - ), - "DEBUG", - ) - return - - existing_keys = len(existing_device.keys()) if isinstance(existing_device, dict) else 0 - current_keys = len(device_info.keys()) - if current_keys > existing_keys: - device_ip_to_id_mapping[device_ip] = device_info - self.log( - "Refreshed device '{0}' with richer payload from {1}='{2}'".format( - device_ip, filter_name, filter_value - ), - "DEBUG", - ) - - def fetch_devices_by_query(param_key, param_value, filter_name): - """ - Fetch devices from get_device_list with pagination for one filter value. - """ - nonlocal lookup_errors - offset = 1 - limit = 500 - page_number = 1 - - while True: - request_params = {param_key: param_value, "offset": offset, "limit": limit} - self.log( - "Querying devices for {0}='{1}', page={2}, offset={3}, limit={4}".format( - filter_name, param_value, page_number, offset, limit - ), - "DEBUG", - ) - - try: - response = self.dnac._exec( - family="devices", - function="get_device_list", - op_modifies=False, - params=request_params, - ) - except Exception as error: - lookup_errors += 1 - self.log( - "Device lookup failed for {0}='{1}': {2}".format( - filter_name, param_value, str(error) - ), - "ERROR", - ) - return - - if not isinstance(response, dict): - lookup_errors += 1 - self.log( - "Skipping {0}='{1}' because API response type is invalid: {2}".format( - filter_name, param_value, type(response).__name__ - ), - "WARNING", - ) - return - - records = response.get("response", []) - if records is None: - records = [] - elif isinstance(records, dict): - records = [records] - elif not isinstance(records, list): - lookup_errors += 1 - self.log( - "Skipping {0}='{1}' because response payload type is invalid: {2}".format( - filter_name, param_value, type(records).__name__ - ), - "WARNING", - ) - return - - if not records: - self.log( - "No additional devices found for {0}='{1}'".format( - filter_name, param_value - ), - "DEBUG", - ) - return - - for device_info in records: - add_device_to_mapping(device_info, filter_name, param_value) - - if len(records) < limit: - return - - offset += limit - page_number += 1 - - try: - ip_address_list = normalize_filter_values("ip_address_list") - hostname_list = normalize_filter_values("hostname_list") - serial_number_list = normalize_filter_values("serial_number_list") - mac_address_list = normalize_filter_values("mac_address_list") - - self.log( - "Prepared filter counts for inventory lookup: ips={0}, hostnames={1}, " - "serials={2}, macs={3}".format( - len(ip_address_list), - len(hostname_list), - len(serial_number_list), - len(mac_address_list), - ), - "INFO", - ) - - if ( - not ip_address_list - and not hostname_list - and not serial_number_list - and not mac_address_list - ): - self.log( - "No valid global filters provided. Returning empty device mapping.", - "DEBUG", - ) - return {"device_ip_to_id_mapping": {}} - - for ip_address in ip_address_list: - self.log( - "Looking up device details for management IP '{0}'".format(ip_address), - "DEBUG", - ) - try: - response = self.dnac._exec( - family="devices", - function="get_network_device_by_ip", - op_modifies=False, - params={"ip_address": ip_address}, - ) - except Exception as error: - lookup_errors += 1 - self.log( - "Management IP lookup failed for '{0}': {1}".format( - ip_address, str(error) - ), - "ERROR", - ) - continue - - if not isinstance(response, dict): - lookup_errors += 1 - self.log( - "Skipping IP '{0}' because API response type is invalid: {1}".format( - ip_address, type(response).__name__ - ), - "WARNING", - ) - continue - - device_payload = response.get("response") - if isinstance(device_payload, list): - for device_info in device_payload: - add_device_to_mapping(device_info, "ip_address_list", ip_address) - elif isinstance(device_payload, dict): - add_device_to_mapping(device_payload, "ip_address_list", ip_address) - else: - self.log( - "No device found for management IP '{0}'".format(ip_address), - "WARNING", - ) - - for hostname in hostname_list: - fetch_devices_by_query("hostname", hostname, "hostname_list") - - for serial_number in serial_number_list: - fetch_devices_by_query("serialNumber", serial_number, "serial_number_list") - - for mac_address in mac_address_list: - fetch_devices_by_query("macAddress", mac_address, "mac_address_list") - - self.log( - "Completed inventory lookup using global filters. Matched devices={0}, " - "lookup_errors={1}".format(len(device_ip_to_id_mapping), lookup_errors), - "INFO", - ) - - return {"device_ip_to_id_mapping": device_ip_to_id_mapping} - - except Exception as error: - self.log( - "Global filter processing failed unexpectedly: {0}".format(str(error)), - "ERROR", - ) - return {"device_ip_to_id_mapping": {}} - - def _get_device_mapping_spec(self): - """ - Build and return the device field mapping specification. - Defines transformation rules for mapping Catalyst Center API device fields - to inventory_workflow_manager playbook format. - - Returns: - OrderedDict: Mapping specification with field definitions and transform functions - """ - # Valid enumeration values - valid_device_types = { - "NETWORK_DEVICE", - "COMPUTE_DEVICE", - "MERAKI_DASHBOARD", - "THIRD_PARTY_DEVICE", - "FIREPOWER_MANAGEMENT_SYSTEM", - } - valid_snmp_modes = {"NOAUTHNOPRIV", "AUTHNOPRIV", "AUTHPRIV"} - - # Helper transformation functions - def parse_int(value, default): - try: - return int(value) - except (TypeError, ValueError): - return default - - def normalize_device_type(value): - if isinstance(value, str): - normalized = value.strip().upper() - if normalized in valid_device_types: - return normalized - return "NETWORK_DEVICE" - - def normalize_cli_transport(value): - if not value: - return "ssh" - normalized = str(value).strip().lower() - return normalized if normalized in {"ssh", "telnet"} else "ssh" - - def normalize_snmp_version(value): - if not value: - return "v2" - normalized = str(value).strip().lower() - if normalized in {"v2", "v2c"}: - return "v2" - if normalized == "v3": - return "v3" - return "v2" - - def normalize_snmp_mode(value): - if isinstance(value, str): - normalized = value.strip().upper() - if normalized in valid_snmp_modes: - return normalized - return "{{ item.snmp_mode }}" - - def value_or_template(value, template): - return value if value not in (None, "") else template - - def normalize_bool(value, default=False): - if isinstance(value, bool): - return value - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in {"true", "yes", "1"}: - return True - if lowered in {"false", "no", "0"}: - return False - return default - - # Device field mapping specification - mapping_spec = OrderedDict( - { - "ip_address_list": { - "type": "list", - "source_key": "managementIpAddress", - "transform": lambda x: [x] if x else [], - }, - "type": { - "type": "str", - "source_key": "type", - "transform": normalize_device_type, - }, - "role": { - "type": "str", - "source_key": "role", - "transform": lambda x: x if x else None, - }, - "cli_transport": { - "type": "str", - "source_key": "cliTransport", - "transform": normalize_cli_transport, - }, - "netconf_port": { - "type": "str", - "source_key": "netconfPort", - "transform": lambda x: str(x) if x not in (None, "") else "830", - }, - "snmp_mode": { - "type": "str", - "source_key": "snmpMode", - "transform": normalize_snmp_mode, - }, - "snmp_ro_community": { - "type": "str", - "source_key": "snmpRoCommunity", - "transform": lambda x: value_or_template(x, "{{ item.snmp_ro_community }}"), - }, - "snmp_rw_community": { - "type": "str", - "source_key": "snmpRwCommunity", - "transform": lambda x: value_or_template(x, "{{ item.snmp_rw_community }}"), - }, - "snmp_username": { - "type": "str", - "source_key": "snmpUsername", - "transform": lambda x: value_or_template(x, "{{ item.snmp_username }}"), - }, - "snmp_auth_protocol": { - "type": "str", - "source_key": "snmpAuthProtocol", - "transform": lambda x: value_or_template(x, "{{ item.snmp_auth_protocol }}"), - }, - "snmp_priv_protocol": { - "type": "str", - "source_key": "snmpPrivProtocol", - "transform": lambda x: value_or_template(x, "{{ item.snmp_priv_protocol }}"), - }, - "snmp_retry": { - "type": "int", - "source_key": "snmpRetry", - "transform": lambda x: parse_int(x, 3), - }, - "snmp_timeout": { - "type": "int", - "source_key": "snmpTimeout", - "transform": lambda x: parse_int(x, 5), - }, - "snmp_version": { - "type": "str", - "source_key": "snmpVersion", - "transform": normalize_snmp_version, - }, - "http_username": { - "type": "str", - "source_key": "httpUserName", - "transform": lambda x: value_or_template(x, "{{ item.http_username }}"), - }, - "http_password": { - "type": "str", - "source_key": "httpPassword", - "transform": lambda x: value_or_template(x, "{{ item.http_password }}"), - }, - "http_port": { - "type": "str", - "source_key": "httpPort", - "transform": lambda x: str(x) if x not in (None, "") else "{{ item.http_port }}", - }, - "http_secure": { - "type": "bool", - "source_key": "httpSecure", - "transform": lambda x: normalize_bool(x, default=False), - }, - "username": { - "type": "str", - "source_key": None, - "transform": lambda x: "{{ item.username }}", - }, - "password": { - "type": "str", - "source_key": None, - "transform": lambda x: "{{ item.password }}", - }, - "enable_password": { - "type": "str", - "source_key": None, - "transform": lambda x: "{{ item.enable_password }}", - }, - "snmp_auth_passphrase": { - "type": "str", - "source_key": None, - "transform": lambda x: "{{ item.snmp_auth_passphrase }}", - }, - "snmp_priv_passphrase": { - "type": "str", - "source_key": None, - "transform": lambda x: "{{ item.snmp_priv_passphrase }}", - }, - "credential_update": { - "type": "bool", - "source_key": None, - "transform": lambda x: False, - }, - "clean_config": { - "type": "bool", - "source_key": None, - "transform": lambda x: False, - }, - "device_resync": { - "type": "bool", - "source_key": None, - "transform": lambda x: False, - }, - "reboot_device": { - "type": "bool", - "source_key": None, - "transform": lambda x: False, - }, - "provision_wired_device": { - "type": "list", - "elements": "dict", - "device_ip": {"type": "str"}, - "site_name": {"type": "str"}, - "resync_retry_count": {"default": 200, "type": "int"}, - "resync_retry_interval": {"default": 2, "type": "int"}, - }, - "update_interface_details": { - "type": "dict", - "description": {"type": "str"}, - "vlan_id": {"type": "int"}, - "voice_vlan_id": {"type": "int"}, - "interface_name": {"type": "list", "elements": "str"}, - "deployment_mode": {"default": "Deploy", "type": "str"}, - "clear_mac_address_table": {"default": False, "type": "bool"}, - "admin_status": {"type": "str"}, - }, - } - ) - - return mapping_spec - - def inventory_get_device_reverse_mapping(self): - """ - Returns reverse mapping specification for inventory devices. - Transforms API response from Catalyst Center to inventory_workflow_manager format. - Maps device attributes from API response to playbook configuration structure. - Includes only fields needed for inventory_workflow_manager module. - """ - self.log( - "Preparing reverse mapping rules for device inventory transformation.", - "DEBUG", - ) - - mapping_spec = self._get_device_mapping_spec() - - self.log( - "Prepared reverse mapping rules for {0} device fields.".format( - len(mapping_spec) - ), - "DEBUG", - ) - - return mapping_spec - - def fetch_device_site_mapping(self, device_id): - """ - Fetch site assignment for a specific device. - - Args: - device_id (str): Device UUID - - Returns: - str: Site name path (e.g., "Global/USA/San Francisco/BGL_18") or empty string if not assigned - """ - self.log( - "Starting site assignment lookup for device_id='{0}'".format(device_id), - "DEBUG", - ) - - if not device_id or not isinstance(device_id, str): - self.log( - "Skipping site assignment lookup because device_id is invalid: {0}".format( - device_id - ), - "WARNING", - ) - return "" - - try: - response = self.dnac._exec( - family="devices", - function="get_assigned_site_for_device", - params={"device_id": device_id}, - op_modifies=False, - ) - - self.log("Site assignment response for device {0}: {1}".format(device_id, response), "INFO") - - if not isinstance(response, dict): - self.log( - "Site assignment lookup returned invalid response type for device_id='{0}': {1}".format( - device_id, type(response).__name__ - ), - "WARNING", - ) - return "" - - site_info = response.get("response") - if not site_info: - self.log( - "No site assignment data found for device_id='{0}'".format(device_id), - "DEBUG", - ) - return "" - - if isinstance(site_info, list): - site_info = site_info[0] if site_info else {} - - if not isinstance(site_info, dict): - self.log( - "Site assignment payload is not a dictionary for device_id='{0}'".format( - device_id - ), - "WARNING", - ) - return "" - - site_name_path = ( - site_info.get("groupNameHierarchy") - or site_info.get("siteNameHierarchy") - or site_info.get("nameHierarchy") - or site_info.get("site") - ) - - if site_name_path: - self.log( - "Resolved site assignment for device_id='{0}' to '{1}'".format( - device_id, site_name_path - ), - "DEBUG", - ) - return site_name_path - - self.log( - "Site assignment response did not contain hierarchy fields for device_id='{0}'".format( - device_id - ), - "DEBUG", - ) - return "" - - except Exception as e: - self.log("Error fetching site for device {0}: {1}".format(device_id, str(e)), "WARNING") - return "" - - def build_provision_wired_device_config(self, device_list): - """ - Build provision_wired_device configuration from device list. - - Args: - device_list (list): List of device dictionaries from API - - Returns: - list: List of provision_wired_device configuration dictionaries - """ - if not isinstance(device_list, list): - self.log( - "Skipping provisioning entry creation because device input type is invalid: {0}".format( - type(device_list).__name__ - ), - "ERROR", - ) - return [] - - self.log( - "Preparing provisioning entries from {0} discovered device records.".format( - len(device_list) - ), - "INFO", - ) - - provision_devices = [] - seen_device_ips = set() - - skipped_invalid_records = 0 - skipped_missing_ip = 0 - skipped_duplicates = 0 - placeholder_site_count = 0 - site_lookup_attempts = 0 - - for index, device in enumerate(device_list, start=1): - try: - if not isinstance(device, dict): - skipped_invalid_records += 1 - self.log( - "Skipping record {0} because device data is not a dictionary.".format(index), - "WARNING", - ) - continue - - device_ip = device.get("managementIpAddress") or device.get("ipAddress") - device_id = device.get("id") or device.get("instanceUuid") - device_hostname = device.get("hostname", "Unknown") - - if isinstance(device_ip, str): - device_ip = device_ip.strip() - - if not device_ip: - skipped_missing_ip += 1 - self.log( - "Skipping device '{0}' in record {1} because management IP is missing.".format( - device_hostname, index - ), - "DEBUG", - ) - continue - - if device_ip in seen_device_ips: - skipped_duplicates += 1 - self.log( - "Skipping duplicate provisioning entry for device IP '{0}'.".format(device_ip), - "DEBUG", - ) - continue - - seen_device_ips.add(device_ip) - site_name = ( - device.get("siteNameHierarchy") - or device.get("groupNameHierarchy") - or device.get("nameHierarchy") - or device.get("site") - ) - - if isinstance(site_name, str): - site_name = site_name.strip() - else: - site_name = "" - - if not site_name: - if device_id: - site_lookup_attempts += 1 - site_name = self.fetch_device_site_mapping(device_id) - if isinstance(site_name, str): - site_name = site_name.strip() - else: - site_name = "" - else: - self.log( - "Using fallback site placeholder for device IP '{0}' because device ID is missing.".format( - device_ip - ), - "DEBUG", - ) - - if not site_name: - site_name = "Global/{{ site_name }}" - placeholder_site_count += 1 - self.log( - "Using fallback site placeholder for device IP '{0}'.".format(device_ip), - "DEBUG", - ) - - provision_entry = { - "device_ip": device_ip, - "site_name": site_name, - "resync_retry_count": 200, - "resync_retry_interval": 2, - } - - provision_devices.append(provision_entry) - self.log( - "Prepared provisioning entry for device IP '{0}' with site '{1}'.".format( - device_ip, site_name - ), - "DEBUG", - ) - - except Exception as e: - skipped_invalid_records += 1 - self.log( - "Skipping record {0} while preparing provisioning entries due to processing error: {1}".format( - index, str(e) - ), - "ERROR", - ) - - self.log( - "Completed provisioning entry preparation. created={0}, lookups={1}, placeholders={2}, " - "skipped_invalid={3}, skipped_missing_ip={4}, skipped_duplicates={5}".format( - len(provision_devices), - site_lookup_attempts, - placeholder_site_count, - skipped_invalid_records, - skipped_missing_ip, - skipped_duplicates, - ), - "INFO", - ) - return provision_devices - - def fetch_sda_provision_device(self, device_ip): - """ - Fetch SDA provision device information for a specific device IP. - Uses the business SDA provision-device endpoint to check if device is provisioned. - - Args: - device_ip (str): Device management IP address - - Returns: - dict: Response containing device provisioning status and site, or None if error/not provisioned - """ - self.log( - "Checking SDA provisioning state for management IP '{0}'.".format(device_ip), - "DEBUG", - ) - - if not isinstance(device_ip, str) or not device_ip.strip(): - self.log( - "Skipping SDA provisioning lookup because management IP is invalid: {0}".format( - device_ip - ), - "WARNING", - ) - return None - - device_ip = device_ip.strip() - try: - response = self.dnac._exec( - family="sda", - function="get_provisioned_wired_device", - op_modifies=False, - params={"device_management_ip_address": device_ip}, - ) - except Exception as e: - self.log( - "SDA provisioning lookup failed for management IP '{0}': {1}".format( - device_ip, str(e) - ), - "ERROR", - ) - return None - - if not isinstance(response, dict): - self.log( - "Ignoring SDA provisioning response for management IP '{0}' because response " - "type is invalid: {1}".format(device_ip, type(response).__name__), - "WARNING", - ) - return None - - self.log( - "Received SDA provisioning response keys for management IP '{0}': {1}".format( - device_ip, list(response.keys()) - ), - "DEBUG", - ) - - response_payload = response.get("response") - if isinstance(response_payload, dict): - payload = response_payload - elif isinstance(response_payload, list): - payload = response_payload[0] if response_payload else {} - if payload and not isinstance(payload, dict): - payload = {} - else: - payload = response - - status_raw = payload.get("status", "") - status = str(status_raw).strip().lower() - description = payload.get("description", "") - provisioned_ip = payload.get("deviceManagementIpAddress") or device_ip - site_name_hierarchy = payload.get("siteNameHierarchy") - - if status != "success": - self.log( - "Device '{0}' is not provisioned in SDA. Status='{1}', description='{2}'.".format( - device_ip, status_raw, description - ), - "INFO", - ) - return None - - if not site_name_hierarchy: - self.log( - "Device '{0}' returned success status but site hierarchy is missing. " - "Skipping provisioning entry.".format(device_ip), - "WARNING", - ) - return None - - normalized_response = { - "status": "success", - "description": description, - "deviceManagementIpAddress": provisioned_ip, - "siteNameHierarchy": site_name_hierarchy, - } - - self.log( - "Device '{0}' is provisioned in SDA at site '{1}'.".format( - provisioned_ip, site_name_hierarchy - ), - "INFO", - ) - return normalized_response - - def build_provision_wired_device_from_sda_endpoint(self, device_configs): - """ - Build provision_wired_device configuration from SDA provision-device endpoint. - Queries each device IP individually to check provisioning status and site assignment. - Only includes devices that are successfully provisioned to a site. - - Args: - device_configs (list): List of filtered device configurations with ip_address_list - - Returns: - dict: Configuration dictionary with provision_wired_device only for provisioned devices - """ - self.log( - "Building provision_wired_device configuration from SDA provision-device data.", - "INFO", - ) - - if not isinstance(device_configs, list): - self.log( - "Skipping provisioning build because device_configs type is invalid: {0}".format( - type(device_configs).__name__ - ), - "ERROR", - ) - return {} - - filtered_device_ips = [] - invalid_config_entries = 0 - invalid_ip_values = 0 - - for index, config in enumerate(device_configs, start=1): - if not isinstance(config, dict): - invalid_config_entries += 1 - self.log( - "Skipping config entry {0} because it is not a dictionary.".format(index), - "WARNING", - ) - continue - - ip_list = config.get("ip_address_list", []) - if isinstance(ip_list, str): - ip_list = [ip_list] - - if not isinstance(ip_list, list): - invalid_config_entries += 1 - self.log( - "Skipping config entry {0} because ip_address_list type is invalid: {1}".format( - index, type(ip_list).__name__ - ), - "WARNING", - ) - continue - - for ip_value in ip_list: - if not isinstance(ip_value, str): - invalid_ip_values += 1 - self.log( - "Ignoring non-string device IP value in config entry {0}: {1}".format( - index, ip_value - ), - "WARNING", - ) - continue - - normalized_ip = ip_value.strip() - if not normalized_ip: - invalid_ip_values += 1 - self.log( - "Ignoring empty device IP value in config entry {0}.".format(index), - "WARNING", - ) - continue - - filtered_device_ips.append(normalized_ip) - - if not filtered_device_ips: - self.log( - "No valid device IPs found for SDA provisioning lookup.", - "WARNING", - ) - return {} - - unique_device_ips = [] - seen_input_ips = set() - duplicate_input_ips = 0 - - for device_ip in filtered_device_ips: - if device_ip in seen_input_ips: - duplicate_input_ips += 1 - continue - seen_input_ips.add(device_ip) - unique_device_ips.append(device_ip) - - self.log( - "Starting SDA provisioning checks for {0} unique device IPs " - "(duplicates_removed={1}, invalid_configs={2}, invalid_ip_values={3}).".format( - len(unique_device_ips), - duplicate_input_ips, - invalid_config_entries, - invalid_ip_values, - ), - "INFO", - ) - - provision_devices = [] - seen_output_ips = set() - not_provisioned_count = 0 - invalid_response_count = 0 - duplicate_output_count = 0 - - for device_ip in unique_device_ips: - try: - provision_response = self.fetch_sda_provision_device(device_ip) - - if not provision_response: - not_provisioned_count += 1 - self.log( - "Device '{0}' is not provisioned in SDA or lookup returned no data.".format( - device_ip - ), - "INFO", - ) - continue - - if not isinstance(provision_response, dict): - invalid_response_count += 1 - self.log( - "Skipping device '{0}' due to invalid provisioning response type: {1}".format( - device_ip, type(provision_response).__name__ - ), - "WARNING", - ) - continue - - device_mgmt_ip = provision_response.get("deviceManagementIpAddress") or device_ip - site_name_hierarchy = provision_response.get("siteNameHierarchy") - status = provision_response.get("status") - - if not site_name_hierarchy: - invalid_response_count += 1 - self.log( - "Skipping device '{0}' because siteNameHierarchy is missing in SDA response.".format( - device_ip - ), - "WARNING", - ) - continue - - if device_mgmt_ip in seen_output_ips: - duplicate_output_count += 1 - self.log( - "Skipping duplicate provision output entry for device '{0}'.".format( - device_mgmt_ip - ), - "DEBUG", - ) - continue - - seen_output_ips.add(device_mgmt_ip) - - provision_entry = { - "device_ip": device_mgmt_ip, - "site_name": site_name_hierarchy, - "resync_retry_count": 200, - "resync_retry_interval": 2, - } - - provision_devices.append(provision_entry) - self.log( - "Prepared provision entry from SDA response: IP='{0}', site='{1}', status='{2}'.".format( - device_mgmt_ip, site_name_hierarchy, status - ), - "DEBUG", - ) - - except Exception as e: - invalid_response_count += 1 - self.log( - "Error while processing SDA provisioning for device '{0}': {1}".format( - device_ip, str(e) - ), - "ERROR", - ) - continue - - self.log( - "Completed SDA provisioning build. provisioned={0}, not_provisioned={1}, " - "invalid_response={2}, duplicate_output={3}.".format( - len(provision_devices), - not_provisioned_count, - invalid_response_count, - duplicate_output_count, - ), - "INFO", - ) - - if not provision_devices: - self.log("No provisioned devices found via SDA endpoint.", "WARNING") - return {} - - return {"provision_wired_device": provision_devices} - - def build_update_interface_details_from_all_devices(self, device_configs, interface_name_filter=None): - """ - Fetch interface details from all devices in device_configs and consolidate - into separate update_interface_details configs grouped by interface configuration. - Uses get_interface_by_ip endpoint to fetch actual interface information. - - Args: - device_configs (list): List of device configuration dicts with ip_address_list - interface_name_filter (list): Optional list of interface names to include. If specified, only these interfaces are included. - - Returns: - list: List of update_interface_details configs with consolidated IP addresses - """ - self.log( - "Preparing interface update configurations from discovered devices.", - "INFO", - ) - - if not isinstance(device_configs, list): - self.log( - "Skipping interface details generation because device_configs type is invalid: {0}".format( - type(device_configs).__name__ - ), - "ERROR", - ) - return [] - - interface_name_filter_set = None - if interface_name_filter: - if isinstance(interface_name_filter, str): - normalized_filter = interface_name_filter.strip() - interface_name_filter_set = {normalized_filter} if normalized_filter else set() - elif isinstance(interface_name_filter, list): - interface_name_filter_set = { - item.strip() - for item in interface_name_filter - if isinstance(item, str) and item.strip() - } - else: - self.log( - "Ignoring interface_name filter because type is invalid: {0}".format( - type(interface_name_filter).__name__ - ), - "WARNING", - ) - interface_name_filter_set = None - - try: - if not device_configs: - self.log("No device configs provided", "WARNING") - return [] - - collected_ips = [] - for index, config in enumerate(device_configs, start=1): - if not isinstance(config, dict): - self.log( - "Skipping config entry {0} because it is not a dictionary.".format(index), - "WARNING", - ) - self.log( - "Continuing to next config entry after invalid type at index {0}.".format(index), - "DEBUG", - ) - continue - - ip_list = config.get("ip_address_list", []) - if isinstance(ip_list, str): - ip_list = [ip_list] - - if not isinstance(ip_list, list): - self.log( - "Skipping config entry {0} because ip_address_list type is invalid: {1}".format( - index, type(ip_list).__name__ - ), - "WARNING", - ) - self.log( - "Continuing to next config entry after invalid ip_address_list at index {0}.".format( - index - ), - "DEBUG", - ) - continue - - for ip in ip_list: - if isinstance(ip, str) and ip.strip(): - collected_ips.append(ip.strip()) - - if not collected_ips: - self.log("No valid device IPs found for interface detail retrieval.", "WARNING") - return [] - - unique_device_ips = [] - seen_ips = set() - for ip in collected_ips: - if ip in seen_ips: - continue - seen_ips.add(ip) - unique_device_ips.append(ip) - - self.log( - "Fetching interface details for {0} unique device IPs.".format(len(unique_device_ips)), - "INFO", - ) - - interface_configs_by_hash = {} - total_interfaces_seen = 0 - total_interfaces_included = 0 - device_errors = 0 - - for device_ip in unique_device_ips: - try: - interface_response = self.dnac._exec( - family="devices", - function="get_interface_by_ip", - op_modifies=False, - params={"ip_address": device_ip}, - ) - except Exception as e: - device_errors += 1 - self.log( - "Interface retrieval failed for device '{0}': {1}".format( - device_ip, str(e) - ), - "ERROR", - ) - self.log( - "Continuing to next device after interface retrieval failure for '{0}'.".format( - device_ip - ), - "DEBUG", - ) - continue - - if not isinstance(interface_response, dict): - device_errors += 1 - self.log( - "Skipping device '{0}' because interface response type is invalid: {1}".format( - device_ip, type(interface_response).__name__ - ), - "WARNING", - ) - self.log( - "Continuing to next device after invalid interface response for '{0}'.".format( - device_ip - ), - "DEBUG", - ) - continue - - interfaces = interface_response.get("response", []) - if interfaces is None: - interfaces = [] - elif isinstance(interfaces, dict): - interfaces = [interfaces] - elif not isinstance(interfaces, list): - device_errors += 1 - self.log( - "Skipping device '{0}' because interface payload type is invalid: {1}".format( - device_ip, type(interfaces).__name__ - ), - "WARNING", - ) - self.log( - "Continuing to next device after invalid interface payload for '{0}'.".format( - device_ip - ), - "DEBUG", - ) - continue - - for interface in interfaces: - if not isinstance(interface, dict): - self.log( - "Skipping interface entry because payload is not a dictionary.", - "DEBUG", - ) - continue - - total_interfaces_seen += 1 - - interface_name = interface.get("name") or interface.get("portName") or "" - interface_name = interface_name.strip() if isinstance(interface_name, str) else "" - if not interface_name: - self.log( - "Skipping interface entry on device '{0}' because name is missing.".format( - device_ip - ), - "DEBUG", - ) - continue - - if interface_name_filter_set is not None and interface_name not in interface_name_filter_set: - self.log( - "Skipping interface '{0}' on device '{1}' because it is not in the filter list.".format( - interface_name, device_ip - ), - "DEBUG", - ) - continue - - interface_config = { - "description": interface.get("description") or "", - "admin_status": interface.get("adminStatus") or "", - "vlan_id": interface.get("vlanId") or interface.get("nativeVlanId"), - "voice_vlan_id": interface.get("voiceVlan"), - "interface_name": [interface_name], - "deployment_mode": "Deploy", - "clear_mac_address_table": False, - } - - config_hash = ( - interface_config["description"], - interface_config["admin_status"], - interface_config["vlan_id"], - interface_config["voice_vlan_id"], - interface_name, - interface_config["deployment_mode"], - interface_config["clear_mac_address_table"], - ) - - if config_hash not in interface_configs_by_hash: - interface_configs_by_hash[config_hash] = { - "ip_address_list": [], - "update_interface_details": interface_config, - } - - if device_ip not in interface_configs_by_hash[config_hash]["ip_address_list"]: - interface_configs_by_hash[config_hash]["ip_address_list"].append(device_ip) - - total_interfaces_included += 1 - - update_interface_configs = list(interface_configs_by_hash.values()) - - self.log( - "Interface detail generation completed. groups={0}, interfaces_seen={1}, " - "interfaces_included={2}, device_errors={3}".format( - len(update_interface_configs), - total_interfaces_seen, - total_interfaces_included, - device_errors, - ), - "INFO", - ) - return update_interface_configs - - except Exception as e: - self.log("Error building update_interface_details from all devices: {0}".format(str(e)), "ERROR") - return [] - - def transform_ip_address_list(self, api_value): - - """ - Transform API ipAddress to ip_address_list format. - Ensures it's always returned as a list. - """ - if not api_value: - return [] - if isinstance(api_value, list): - return [ip.strip() for ip in api_value if isinstance(ip, str) and ip.strip()] - - if isinstance(api_value, str): - api_value = api_value.strip() - return [api_value] if api_value else [] - - return [] - - def get_device_details_details(self, network_element, filters): - """ - Retrieves inventory device credentials from Cisco Catalyst Center API. - Processes the response and transforms it using the reverse mapping specification. - Captures FULL device response with all available fields. - """ - self.log("Retrieving device details for inventory playbook generation.", "INFO") - - if not isinstance(filters, dict): - self.log( - "Skipping device details retrieval because filters type is invalid: {0}".format( - type(filters).__name__ - ), - "ERROR", - ) - return [] - - try: - reverse_mapping_spec = self.inventory_get_device_reverse_mapping() - global_filters = filters.get("global_filters") or {} - component_specific_filters = filters.get("component_specific_filters") or {} - generate_all = bool(filters.get("generate_all_configurations", False)) - - if not isinstance(global_filters, dict): - self.log( - "Ignoring invalid global_filters type: {0}".format(type(global_filters).__name__), - "WARNING", - ) - global_filters = {} - - if not isinstance(component_specific_filters, (dict, list)): - self.log( - "Ignoring invalid component-specific filter type: {0}".format( - type(component_specific_filters).__name__ - ), - "WARNING", - ) - component_specific_filters = {} - - has_global_filters = bool(global_filters and any(global_filters.values())) - - self.log("Filters received - Global: {0}, Component: {1}, Generate All: {2}".format( - global_filters, component_specific_filters, generate_all - ), "DEBUG") - - device_response = [] - - try: - if generate_all: - device_response = self.fetch_all_devices( - reason="generate_all_configurations enabled" - ) - elif has_global_filters: - filter_result = self.process_global_filters(global_filters) - mapping = {} - if isinstance(filter_result, dict): - mapping = filter_result.get("device_ip_to_id_mapping", {}) - if isinstance(mapping, dict): - device_response = list(mapping.values()) - self.log( - "Device lookup with global filters returned {0} record(s).".format( - len(device_response) - ), - "INFO", - ) - else: - device_response = self.fetch_all_devices( - reason="no global filters provided" - ) - except Exception as e: - self.log( - "Device retrieval failed: {0}".format(str(e)), - "ERROR", - ) - return [] - - if device_response and has_global_filters: - first_device = device_response[0] - if isinstance(first_device, dict): - self.log( - "Sample filtered device fields: {0}".format(list(first_device.keys())), - "INFO", - ) - self.log( - "Sample filtered device data: {0}".format(first_device), - "DEBUG", - ) - - self.log("Retrieved {0} devices before component filtering".format(len(device_response)), "INFO") - - if not isinstance(device_response, list): - self.log( - "Invalid device response type received: {0}".format(type(device_response).__name__), - "ERROR", - ) - return [] - - device_response = [d for d in device_response if isinstance(d, dict)] - if not device_response: - self.log("No device data available after retrieval and normalization.", "WARNING") - return [] - - # ✅ Log what fields are actually available in the device_response - sample_device = device_response[0] - available_fields = list(sample_device.keys()) - self.log("Available fields in device response: {0}".format(available_fields), "INFO") - self.log("Total fields available: {0}".format(len(available_fields)), "INFO") - - # Check which fields from reverse_mapping_spec are missing - missing_fields = [] - for playbook_key, mapping_spec in reverse_mapping_spec.items(): - source_key = mapping_spec.get("source_key") - if source_key and source_key not in sample_device: - missing_fields.append(source_key) - - if missing_fields: - self.log("WARNING: {0} fields from reverse_mapping_spec are NOT in API response: {1}".format( - len(missing_fields), missing_fields - ), "WARNING") - else: - self.log("All fields from reverse_mapping_spec are present in API response", "INFO") - - if component_specific_filters: - filtered_devices = self.apply_component_specific_filters( - device_response, component_specific_filters - ) - if filtered_devices is None: - self.log("Component-specific filter validation failed.", "ERROR") - return [] - device_response = filtered_devices - - if not device_response: - self.log("No devices matched requested component-specific filters.", "WARNING") - return [] - - transformed_devices = self.transform_device_to_playbook_format( - reverse_mapping_spec, device_response - ) - - if not transformed_devices: - self.log("No transformed device configurations were generated.", "WARNING") - return [] - - provision_source_devices = [] - try: - if generate_all or not has_global_filters: - provision_source_devices = self.fetch_all_devices( - reason="building provision_wired_device section" - ) - else: - provision_filter_result = self.process_global_filters(global_filters) - provision_mapping = {} - if isinstance(provision_filter_result, dict): - provision_mapping = provision_filter_result.get("device_ip_to_id_mapping", {}) - if isinstance(provision_mapping, dict): - provision_source_devices = list(provision_mapping.values()) - except Exception as e: - self.log( - "Provision source retrieval failed: {0}".format(str(e)), - "ERROR", - ) - provision_source_devices = [] - - if provision_source_devices: - transformed_for_provision = self.transform_device_to_playbook_format( - reverse_mapping_spec, provision_source_devices - ) - provision_config = self.build_provision_wired_device_from_sda_endpoint( - transformed_for_provision - ) - if ( - isinstance(provision_config, dict) - and provision_config.get("provision_wired_device") - ): - transformed_devices.append(provision_config) - self.log( - "Appended provision_wired_device section with {0} entries.".format( - len(provision_config.get("provision_wired_device", [])) - ), - "INFO", - ) - - return transformed_devices - - except Exception as e: - self.log("Error in get_device_details_details: {0}".format(str(e)), "ERROR") - import traceback - self.log("Traceback: {0}".format(traceback.format_exc()), "ERROR") - return [] - - def yaml_config_generator(self, yaml_config_generator): - """ - Generates a YAML configuration file based on the provided parameters. - This function retrieves network element details using global and component-specific filters, processes the data, - and writes the YAML content to a specified file. It dynamically handles multiple network elements and their respective filters. - - Args: - yaml_config_generator (dict): Contains generate_all_configurations, - global_filters, and component_specific_filters. - - Returns: - self: The current instance with the operation result and message updated. - """ - - self.log( - "Starting YAML config generation with parameters: {0}".format( - yaml_config_generator - ), - "DEBUG", - ) - - if not isinstance(yaml_config_generator, dict): - self.msg = { - "YAML config generation Task failed for module '{0}'.".format(self.module_name): { - "reason": "Invalid input for configuration generation. Expected a dictionary.", - "status": "INVALID_INPUT", - } - } - self.set_operation_result("failed", False, self.msg, "ERROR") - return self - - self.generate_all_configurations = bool( - yaml_config_generator.get("generate_all_configurations", False) - ) - - file_path = self.params.get("file_path") or self.generate_filename() - self.log("YAML output file path resolved: {0}".format(file_path), "DEBUG") - - module_supported_network_elements = self.module_schema.get("network_elements", {}) - - if self.generate_all_configurations: - global_filters = {} - component_specific_filters = {} - else: - global_filters = yaml_config_generator.get("global_filters") or {} - component_specific_filters = yaml_config_generator.get("component_specific_filters") or {} - - if not isinstance(global_filters, dict): - self.msg = { - "YAML config generation Task failed for module '{0}'.".format(self.module_name): { - "reason": "global_filters must be a dictionary.", - "status": "INVALID_GLOBAL_FILTERS", - } - } - self.set_operation_result("failed", False, self.msg, "ERROR") - return self - - if not isinstance(component_specific_filters, dict): - self.msg = { - "YAML config generation Task failed for module '{0}'.".format(self.module_name): { - "reason": "component_specific_filters must be a dictionary.", - "status": "INVALID_COMPONENT_FILTERS", - } - } - self.set_operation_result("failed", False, self.msg, "ERROR") - return self - - # Extract and validate components_list from component_specific_filters - # Supports: None (defaults to all), string (single component), or list/tuple/set (multiple components) - raw_components_list = component_specific_filters.get("components_list") - if raw_components_list is None: - # Default: process all supported network elements - components_list = list(module_supported_network_elements.keys()) - elif isinstance(raw_components_list, str): - # Single component provided as string - components_list = [raw_components_list] - elif isinstance(raw_components_list, (list, tuple, set)): - # Multiple components provided as iterable - components_list = list(raw_components_list) - else: - # Invalid type - fail fast - self.msg = { - "YAML config generation Task failed for module '{0}'.".format(self.module_name): { - "reason": "components_list must be a string, list, tuple, or set.", - "status": "INVALID_COMPONENTS_LIST", - } - } - self.set_operation_result("failed", False, self.msg, "ERROR") - return self - - # Normalize components: strip whitespace, remove duplicates, validate types - normalized_components = [] - for component in components_list: - if not isinstance(component, str) or not component.strip(): - self.log("Ignoring invalid component value: {0}".format(component), "WARNING") - continue - component_name = component.strip() - # Preserve order, avoid duplicates - if component_name not in normalized_components: - normalized_components.append(component_name) - - # Identify unsupported components and log warnings (non-fatal) - unsupported_components = [ - component - for component in normalized_components - if component not in module_supported_network_elements - ] - if unsupported_components: - self.log("Ignoring unsupported component(s): {0}".format(unsupported_components), "WARNING") - - # Filter to only include supported components - components_list = [ - component - for component in normalized_components - if component in module_supported_network_elements - ] - - self.log("Retrieving module-supported network elements", "DEBUG") - - self.log( - "Retrieved {0} supported network elements: {1}".format( - len(module_supported_network_elements), - list(module_supported_network_elements.keys()), - ), - "DEBUG", - ) - - self.log( - "Components list determined (independent): {0}".format(components_list), "DEBUG" - ) - - # For filter-only components (provision_device, interface_details), we need device_details data - # So we fetch device_details internally if any filter-only component is requested - components_to_fetch = list(components_list) - has_filter_only_component = any( - module_supported_network_elements.get(component, {}).get("is_filter_only", False) - for component in components_list - ) - if has_filter_only_component and "device_details" not in components_to_fetch: - components_to_fetch.insert(0, "device_details") - - self.log( - "Components to fetch internally: {0}".format(components_to_fetch), "DEBUG" - ) - - final_list = [] - for component in components_to_fetch: - network_element = module_supported_network_elements.get(component) - if not network_element: - self.log( - "Skipping unsupported network element: {0}".format(component), - "WARNING", - ) - continue - - # Skip provision_device in this loop as it's a filter-only component - # It will be handled after provision_wired_device is built - if network_element.get("is_filter_only"): - self.log("Skipping filter-only component: {0}".format(component), "DEBUG") - continue - - operation_func = network_element.get("get_function_name") - if not callable(operation_func): - self.log( - "Skipping component '{0}' due to unavailable operation.".format(component), - "WARNING", - ) - continue - - filters = { - "global_filters": global_filters, - "component_specific_filters": component_specific_filters.get(component, {}), - "generate_all_configurations": self.generate_all_configurations, - } - - self.log("Collecting data for component '{0}'.".format(component), "INFO") - details = operation_func(network_element, filters) - - if self.status == "failed": - self.set_operation_result("failed", False, self.msg, "ERROR") - return self - - if isinstance(details, list): - final_list.extend([item for item in details if item]) - elif isinstance(details, dict): - final_list.append(details) - elif details is not None: - self.log( - "Skipping unexpected response type for component '{0}': {1}".format( - component, type(details).__name__ - ), - "WARNING", - ) - - self.log( - "Completed processing all components. Total configurations: {0}".format( - len(final_list) - ), - "INFO", - ) - - # Separate provision_wired_device and user_defined_fields configs from device configs - device_configs = [] - provision_config = None - user_defined_fields_config = None - - self.log("Separating configs from final_list with {0} total items".format(len(final_list)), "DEBUG") - - for idx, config in enumerate(final_list): - self.log("Config {0}: keys = {1}".format(idx, list(config.keys()) if isinstance(config, dict) else config.__class__.__name__), "DEBUG") - # Check if this is the main provision_wired_device config (not the null field in device configs) - if isinstance(config, dict) and "provision_wired_device" in config and isinstance(config.get("provision_wired_device"), list): - provision_config = config - self.log("Found provision_wired_device config at index {0}".format(idx), "DEBUG") - elif isinstance(config, dict) and "user_defined_fields" in config and isinstance(config.get("user_defined_fields"), list): - user_defined_fields_config = config - self.log("Found user_defined_fields config at index {0}".format(idx), "DEBUG") - else: - device_configs.append(config) - self.log("Added device config at index {0}".format(idx), "DEBUG") - - self.log("Separated configs - Device configs: {0}, Provision config: {1}".format( - len(device_configs), "yes" if provision_config else "no"), "DEBUG") - - # Filter provision_wired_device by site_name if provision_device component is specified - # Each component filter is INDEPENDENT - provision_device filter only affects provision output - if provision_config and "provision_device" in components_list: - provision_device_filters = component_specific_filters.get("provision_device", {}) - site_name_filter = provision_device_filters.get("site_name") - - if site_name_filter: - self.log("Applying provision_device site_name filter (independent of device_details filter)", "INFO") - self.log("Filtering provision config by site_name: {0}".format(site_name_filter), "INFO") - - # Filter provision_wired_device - this does NOT affect device_configs - provision_wired_devices = provision_config.get("provision_wired_device", []) - filtered_provision_devices = [ - device for device in provision_wired_devices - if device.get("site_name") == site_name_filter - ] - self.log("Provision devices before site_name filter: {0}, after filter: {1}".format( - len(provision_wired_devices), len(filtered_provision_devices)), "INFO") - provision_config["provision_wired_device"] = filtered_provision_devices - - # device_configs remains unchanged - it's filtered independently by device_details criteria only - self.log("Device configs (filtered by device_details only): {0}".format(len(device_configs)), "INFO") - - # Create the list of dictionaries to output (may be one, two, or three configs) - dicts_to_write = [] - - # Determine which components to include based on generate_all_configurations or components_list - # Each component is independent - only include what user explicitly requested - include_device_details = self.generate_all_configurations or "device_details" in components_list - include_provision_device = self.generate_all_configurations or "provision_device" in components_list - include_interface_details = self.generate_all_configurations or "interface_details" in components_list - include_user_defined_fields = self.generate_all_configurations or "user_defined_fields" in components_list - - self.log("Component inclusion (independent) - device_details: {0}, provision_device: {1}, interface_details: {2}, user_defined_fields: {3}".format( - include_device_details, include_provision_device, include_interface_details, include_user_defined_fields), "INFO") - - # First document: device details - if include_device_details and device_configs: - dicts_to_write.append({ - "_comment": "config for adding network devices:", - "data": device_configs - }) - self.log("Added device configs section with {0} configs".format(len(device_configs)), "DEBUG") - - # When device configs are available and interface_details is requested, auto-fetch interface details - # For independent filtering, fetch from ALL devices respecting global filters - auto_interface_configs = [] - if include_interface_details: - self.log("Auto-generating interface details from devices (applying global filters)", "INFO") - - # Fetch devices respecting global filters for interface details - if global_filters and any(global_filters.values()): - # Apply same global filters as device_details - self.log("Applying global filters to interface details fetch", "INFO") - result = self.process_global_filters(global_filters) - device_ip_to_id_mapping = result.get("device_ip_to_id_mapping", {}) - - if device_ip_to_id_mapping: - all_devices_for_interfaces = list(device_ip_to_id_mapping.values()) - else: - all_devices_for_interfaces = self.fetch_all_devices(reason="fallback for interface filtering") - else: - # No global filters - fetch all devices - all_devices_for_interfaces = self.fetch_all_devices(reason="no global filters for interface") - - if all_devices_for_interfaces: - # Transform all devices to get IP addresses - reverse_mapping_spec = self.inventory_get_device_reverse_mapping() - all_transformed_for_interfaces = self.transform_device_to_playbook_format( - reverse_mapping_spec, all_devices_for_interfaces - ) - # Extract interface_name filter if specified in component_specific_filters - interface_name_filter = None - if component_specific_filters and "interface_details" in component_specific_filters: - interface_details_filter = component_specific_filters.get("interface_details", {}) - if isinstance(interface_details_filter, dict): - interface_name_filter = interface_details_filter.get("interface_name") - if interface_name_filter and not isinstance(interface_name_filter, list): - interface_name_filter = [interface_name_filter] - - auto_interface_configs = self.build_update_interface_details_from_all_devices( - all_transformed_for_interfaces, - interface_name_filter=interface_name_filter - ) - if auto_interface_configs: - self.log("Generated {0} interface detail configs (with global filters)".format( - len(auto_interface_configs) - ), "INFO") - else: - self.log("No devices found for interface details generation", "WARNING") - - # Second document with provision_wired_device configuration - second_doc_config = [] - - if include_provision_device and provision_config: - # Only add if there are actual devices in the provision config - provision_devices = provision_config.get("provision_wired_device", []) - if provision_devices: - second_doc_config.append(provision_config) - self.log("Added provision_wired_device config section with {0} devices".format(len(provision_devices)), "DEBUG") - else: - self.log("Skipping empty provision_wired_device config (no devices after filtering)", "DEBUG") - - if second_doc_config: - dicts_to_write.append({ - "_comment": "config for provisioning wired device:", - "data": second_doc_config - }) - self.log("Added second document with {0} config sections".format(len(second_doc_config)), "DEBUG") - - # Third document with auto-generated interface details - if include_interface_details and auto_interface_configs: - dicts_to_write.append({ - "_comment": "config for updating interface details:", - "data": auto_interface_configs - }) - self.log("Added third document with {0} auto-generated interface configs".format(len(auto_interface_configs)), "DEBUG") + self.log( + "Fetching device identifier details using 'devices.get_device_list'.", + "DEBUG", + ) - # Fourth document with user-defined fields configuration - if include_user_defined_fields and user_defined_fields_config: - user_defined_fields_entries = user_defined_fields_config.get("user_defined_fields", []) - if user_defined_fields_entries: - dicts_to_write.append({ - "_comment": "config for updating user defined fields:", - "data": user_defined_fields_entries - }) - self.log("Added fourth document with {0} user-defined fields configs".format( - len(user_defined_fields_entries) - ), "DEBUG") + final_device_identifier_details = [] + api_function, api_family = "get_device_list", "devices" + device_identifier_details = self.execute_get_with_pagination( + api_family, api_function + ) - self.log("Final dictionaries created: {0} config sections".format(len(dicts_to_write)), "DEBUG") + if not device_identifier_details: + self.log( + "No inventory devices found from 'get_device_list' API.", + "DEBUG", + ) + return final_device_identifier_details - # Check if there's any data to write - if not dicts_to_write: - self.log("No data found to generate YAML configuration", "WARNING") - self.msg = { - "YAML config generation Task completed for module '{0}' - No data found.".format( - self.module_name - ): { - "reason": "No devices matching the provided filters were found in Cisco Catalyst Center", - "file_path": file_path, - "status": "NO_DATA_TO_GENERATE" - } - } - self.set_operation_result("success", False, self.msg, "WARNING") - return self + self.log( + "Retrieved {0} raw device record(s) from inventory API. Data: {1}".format( + len(device_identifier_details), + self.pprint(device_identifier_details), + ), + "DEBUG", + ) - file_mode = self.params.get("file_mode", "overwrite") - if not self.params.get("file_path"): - file_mode = "overwrite" + final_device_identifier_details = self.modify_parameters( + self.device_identifiers_temp_spec(), device_identifier_details + ) self.log( - "YAML configuration file path determined: {0}, file_mode: {1}".format(file_path, file_mode), - "DEBUG" + "Transformed inventory data into {0} device identifier record(s). Data: {1}".format( + len(final_device_identifier_details), + self.pprint(final_device_identifier_details), + ), + "INFO", ) - self.log("Writing final dictionaries to file: {0}".format(file_path), "INFO") - write_result = self.write_dicts_to_yaml(dicts_to_write, file_path, file_mode, dumper=OrderedDumper) - if write_result: - self.msg = { - "YAML config generation Task succeeded for module '{0}'.".format( - self.module_name - ): {"file_path": file_path} - } - self.set_operation_result("success", True, self.msg, "INFO") - else: - self.msg = { - "YAML config generation Task failed for module '{0}'.".format( - self.module_name - ): {"file_path": file_path} - } - self.set_operation_result("failed", True, self.msg, "ERROR") - return self + return final_device_identifier_details - def write_dicts_to_yaml(self, dicts_list, file_path, file_mode, dumper=None): + def fetch_all_inventory_config(self): """ - Writes multiple dictionaries as separate YAML documents to a file. - Each dictionary becomes a separate YAML document separated by ---. - Adds blank lines before top-level config items for better readability. - Supports _comment key for adding comments before YAML sections. + Build inventory configuration from device identifier data. - Args: - dicts_list (list): List of dictionaries to write as separate YAML documents. - file_path (str): The path where the YAML file will be written. - dumper: The YAML dumper class to use for serialization (default is OrderedDumper). Returns: - bool: True if the YAML file was successfully written, False otherwise. + list: Inventory configuration records ready for downstream transformation + and YAML generation. """ - if dumper is None: - dumper = OrderedDumper - if not isinstance(dicts_list, list): - self.log("YAML write skipped: expected list input for document payload.", "ERROR") - return False + self.log("Starting inventory configuration data aggregation.", "DEBUG") - if not dicts_list: - self.log("YAML write skipped: no document payload provided.", "WARNING") - return False - - if not isinstance(file_path, str) or not file_path.strip(): - self.log("YAML write skipped: invalid file path provided.", "ERROR") - return False + device_identifier_details = self.fetch_device_identifier_details() + if not device_identifier_details: + self.log( + "No device identifier details available to generate inventory configuration.", + "DEBUG", + ) + return [] self.log( - "Preparing to write {0} YAML document(s) to {1}.".format(len(dicts_list), file_path), - "INFO", + "Retrieved {0} device identifier record(s). Data: {1}".format( + len(device_identifier_details), + self.pprint(device_identifier_details), + ), + "DEBUG", ) - try: - serialized_documents = [] + return device_identifier_details - for index, section in enumerate(dicts_list, start=1): - if not isinstance(section, dict): - self.log( - "Skipping non-dictionary YAML section at index {0}.".format(index), - "WARNING", - ) - continue - - comment = section.get("_comment") - if "data" in section: - payload = section.get("data") - else: - payload = {k: v for k, v in section.items() if k != "_comment"} + def get_final_inventory_config(self, yaml_config_generator): + """ + Retrieves the final inventory configuration data to be included in the YAML configuration file. - if payload is None: - payload = {} + Args: + yaml_config_generator (dict): Contains global_filters. - yaml_content = yaml.dump( - payload, - Dumper=dumper, - default_flow_style=False, - indent=2, - allow_unicode=True, - sort_keys=False, - ).rstrip() + Returns: + list: A list of inventory configuration dictionaries that will be included in the YAML file. + """ - if not yaml_content: - yaml_content = "{}" + final_inventory_data = self.fetch_all_inventory_config() - lines = yaml_content.split("\n") - formatted_lines = [] - for line_index, line in enumerate(lines): - if ( - line.startswith("- ") - and line_index > 0 - and formatted_lines - and formatted_lines[-1].strip() != "" - ): - formatted_lines.append("") - formatted_lines.append(line) + generate_all = yaml_config_generator.get("generate_all_configurations", False) - yaml_content = "\n".join(formatted_lines) + if generate_all: + self.log( + "Auto-discovery mode enabled - will process all the config.", + "INFO", + ) + config_device_identifier = "ip_address" + else: + self.log( + "Auto-discovery mode not enabled - applying global filters to filter inventory data.", + "INFO", + ) + global_filters = yaml_config_generator.get("global_filters", {}) - if comment: - comment_line = str(comment).splitlines()[0].strip() - serialized_documents.append("# {0}\n{1}".format(comment_line, yaml_content)) - else: - serialized_documents.append(yaml_content) + final_inventory_data = self.apply_global_filters(final_inventory_data, global_filters) + self.log( + "Inventory data count after applying global filters: {0}".format( + len(final_inventory_data) + ), + "DEBUG", + ) + config_device_identifier = global_filters.get("device_identifier", "ip_address") - if not serialized_documents: - self.log("YAML write skipped: no valid documents after normalization.", "WARNING") - return False + self.log( + "Device identifier selected for YAML configuration: '{0}'".format(config_device_identifier), + "DEBUG" + ) + transformed_inventory_config = self.transform_config_using_device_identifier(final_inventory_data, config_device_identifier) - if file_mode not in ("overwrite", "append"): - self.msg = ( - "Invalid file_mode '{0}'. Supported values are 'overwrite' and 'append'." - .format(file_mode) - ) - self.fail_and_exit(self.msg) + return transformed_inventory_config - if file_mode == "overwrite": - open_mode = "w" - else: - open_mode = "a" + def yaml_config_generator(self, yaml_config_generator): + """ + Generates a YAML configuration file for the inventory workflow based on the provided configuration parameters. - header_comments = self.add_header_comments() - final_yaml = header_comments + "\n---\n" + "\n---\n".join(serialized_documents) + "\n" + Args: + yaml_config_generator (dict): Contains global_filters. - self.ensure_directory_exists(file_path) - with open(file_path, open_mode, encoding="utf-8") as yaml_file: - yaml_file.write(final_yaml) + Returns: + self: The current instance with the operation result and message updated. + """ - self.log("YAML documents written successfully to {0}.".format(file_path), "INFO") - return True + final_inventory_config = self.get_final_inventory_config(yaml_config_generator) - except Exception as e: - self.msg = "An error occurred while writing to {0}: {1}".format( - file_path, str(e) + if not final_inventory_config: + self.log( + "No configurations retrieved for inventory workflow YAML generation after applying filters.", + "WARNING", ) - self.fail_and_exit(self.msg) + self.msg = { + "status": "ok", + "message": + "No configurations found for module '{0}'. Verify filters applied and inventory data in Catalyst Center." + .format(self.module_name) + } + self.set_operation_result("ok", False, self.msg, "INFO") + return self - def write_dict_to_yaml(self, data_dict, file_path, dumper=None): - """ - Override: Converts a dictionary to YAML format and writes it to a specified file path. - Adds blank lines before top-level config items (no indentation) for better readability. + final_config = {"config": final_inventory_config} - Args: - data_dict (dict): The dictionary to convert to YAML format. - file_path (str): The path where the YAML file will be written. - dumper: The YAML dumper class to use for serialization (default is OrderedDumper). - Returns: - bool: True if the YAML file was successfully written, False otherwise. - """ - if dumper is None: - dumper = OrderedDumper + # Get file_path and file_mode from self.params (top-level parameters) + file_path = self.params.get("file_path") + if not file_path: + self.log( + "No file_path provided by user, generating default filename", "DEBUG" + ) + file_path = self.generate_filename() + else: + self.log("Using user-provided file_path: {0}".format(file_path), "DEBUG") + file_mode = self.params.get("file_mode", "overwrite") self.log( - "Starting to write dictionary to YAML file at: {0}".format(file_path), + "YAML configuration file path determined: {0}, file_mode: {1}".format( + file_path, file_mode + ), "DEBUG", ) - try: - self.log("Starting conversion of dictionary to YAML format.", "INFO") - yaml_content = yaml.dump( - data_dict, - Dumper=dumper, - default_flow_style=False, - indent=2, - allow_unicode=True, - sort_keys=False, - ) - yaml_content = "---\n" + yaml_content - - # Post-process to add blank lines only before top-level list items (config items) - # Top-level items have no indentation (start with - at column 0) - lines = yaml_content.split('\n') - result_lines = [] - for i, line in enumerate(lines): - # Check if this line starts a top-level list item (no leading whitespace before -) - if line.startswith('- ') and i > 0: - # Check if previous line is not blank and not the opening --- - if result_lines and result_lines[-1].strip() != '' and result_lines[-1] != '---': - # Add a blank line before this top-level list item - result_lines.append('') - result_lines.append(line) - - yaml_content = '\n'.join(result_lines) - self.log("Dictionary successfully converted to YAML format with blank lines before config items.", "DEBUG") + file_written = self.write_dict_to_yaml( + final_config, + file_path, + file_mode + ) - # Ensure the directory exists - self.ensure_directory_exists(file_path) + if file_written: + self.msg = { + "status": "success", + "message": "YAML configuration file generated successfully for module '{0}'".format( + self.module_name + ), + "file_path": file_path, + "file_mode": file_mode, + "configurations_count": len(final_inventory_config) + } + self.set_operation_result("success", True, self.msg, "INFO") self.log( - "Preparing to write YAML content to file: {0}".format(file_path), "INFO" + "YAML configuration generation completed. File: {0}, Configs: {1}".format( + file_path, + len(final_inventory_config), + ), + "INFO", ) - with open(file_path, "w") as yaml_file: - yaml_file.write(yaml_content) + else: + self.msg = { + "status": "ok", + "message": "YAML configuration file already up-to-date for module '{0}'. No changes written.".format( + self.module_name + ), + "file_path": file_path, + "file_mode": file_mode, + "configurations_count": len(final_inventory_config) + } + self.set_operation_result("ok", False, self.msg, "INFO") self.log( - "Successfully written YAML content to {0}.".format(file_path), "INFO" + "YAML configuration unchanged. File: {0}, Configs: {1}".format( + file_path, + len(final_inventory_config), + ), + "INFO", ) - return True - except Exception as e: - self.msg = "An error occurred while writing to {0}: {1}".format( - file_path, str(e) - ) - self.fail_and_exit(self.msg) + return self def get_diff_gathered(self): """ @@ -3751,10 +830,15 @@ def get_diff_gathered(self): YAML configuration file containing network element details from Catalyst Center. This method orchestrates the yaml_config_generator operation and tracks execution time for performance monitoring. + + Returns: + InventoryPlaybookConfigGenerator: Current instance after completing + gathered-state processing. """ start_time = time.time() self.log("Starting 'get_diff_gathered' operation.", "DEBUG") + # Define workflow operations workflow_operations = [ ( "yaml_config_generator", @@ -3765,8 +849,6 @@ def get_diff_gathered(self): operations_executed = 0 operations_skipped = 0 - want_payload = self.want if isinstance(self.want, dict) else {} - # Iterate over operations and process them self.log("Beginning iteration over defined workflow operations for processing.", "DEBUG") for index, (param_key, operation_name, operation_func) in enumerate( @@ -3778,425 +860,51 @@ def get_diff_gathered(self): ), "DEBUG", ) - if param_key not in want_payload: - operations_skipped += 1 - self.log( - "Skipping {0}: input section '{1}' not provided.".format( - operation_name, param_key - ), - "DEBUG", - ) - continue - - params = want_payload.get(param_key) - if params is None: - operations_skipped += 1 + params = self.want.get(param_key) + if params: self.log( - "Skipping {0}: input section '{1}' is null.".format( - operation_name, param_key + "Iteration {0}: Parameters found for {1}. Starting processing.".format( + index, operation_name ), - "WARNING", - ) - continue - - try: - self.log("Running {0} operation.".format(operation_name), "INFO") - operation_result = operation_func(params) - if hasattr(operation_result, "check_return_status"): - operation_result.check_return_status() - operations_executed += 1 - self.log("{0} operation completed successfully.".format(operation_name), "INFO") - except Exception as e: - self.log( - "{0} operation failed: {1}".format(operation_name, str(e)), - "ERROR", - ) - self.set_operation_result( - "failed", - False, - "{0} operation failed: {1}".format(operation_name, str(e)), - "ERROR", - ).check_return_status() - - elapsed_time = time.time() - start_time - self.log( - "Gathered-state workflow completed in {0:.2f} seconds (executed={1}, skipped={2}).".format( - elapsed_time, operations_executed, operations_skipped - ), - "INFO", - ) - - if operations_executed == 0 and operations_skipped > 0 and self.status != "failed": - self.set_operation_result( - "success", - False, - "No gathered-state operations were executed for the provided configuration.", - "WARNING", - ) - - return self - - def transform_device_to_playbook_format(self, reverse_mapping_spec, device_response): - """ - Transform raw device payload into playbook format and consolidate records - with identical non-IP attributes. - - Args: - reverse_mapping_spec (OrderedDict): Mapping specification for transformation - device_response (list): List of raw device dictionaries from API - - Returns: - list: List of consolidated device configurations with merged IP addresses - """ - if not isinstance(reverse_mapping_spec, dict): - self.log("Invalid reverse mapping specification. Expected dictionary input.", "ERROR") - return [] - - if device_response is None: - self.log("No device data available for transformation.", "WARNING") - return [] - - if not isinstance(device_response, list): - self.log("Invalid device response format. Expected list input.", "ERROR") - return [] - - if not device_response: - self.log("Empty device list received for transformation.", "INFO") - return [] - - self.log( - "Transforming {0} devices into consolidated playbook configurations.".format( - len(device_response) - ), - "INFO", - ) - - def normalize_for_grouping(value): - if isinstance(value, dict): - return tuple( - (str(key), normalize_for_grouping(val)) - for key, val in sorted(value.items(), key=lambda item: str(item[0])) - ) - - if isinstance(value, list): - return tuple(normalize_for_grouping(item) for item in value) - - if isinstance(value, tuple): - return tuple(normalize_for_grouping(item) for item in value) - - if isinstance(value, set): - normalized_values = [normalize_for_grouping(item) for item in value] - return tuple(sorted(normalized_values, key=str)) - - return value - - optional_nested_keys = { - "provision_wired_device", - "update_interface_details", - } - - transformed_devices = [] - total_devices = len(device_response) - - for index, device in enumerate(device_response, start=1): - if not isinstance(device, dict): - self.log( - "Skipping device record {0} because it is not a dictionary.".format(index), - "WARNING", + "INFO", ) - continue - - device_name = device.get("hostname") or device.get("managementIpAddress") or "Unknown" - self.log( - "Preparing playbook fields for device {0}/{1}: {2}".format( - index, total_devices, device_name - ), - "DEBUG", - ) - - device_config = {} - for playbook_key, mapping_spec in reverse_mapping_spec.items(): - if not isinstance(mapping_spec, dict): - continue - - source_key = mapping_spec.get("source_key") - transform_func = mapping_spec.get("transform") - - api_value = device.get(source_key) if source_key else None try: - transformed_value = ( - transform_func(api_value) if callable(transform_func) else api_value + 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( - "Transformation failed for key '{0}' on device index {1}: {2}".format( - playbook_key, index, str(e) - ), - "ERROR", + "{0} operation failed with error: {1}".format(operation_name, str(e)), + "ERROR" ) - transformed_value = None - - if playbook_key in ( - "provision_wired_device", - "update_interface_details", - ): - if transformed_value in (None, [], {}): - continue - - device_config[playbook_key] = transformed_value - - transformed_devices.append(device_config) - - if not transformed_devices: - self.log("No device records were transformed.", "WARNING") - return [] - - def to_immutable(value): - if isinstance(value, dict): - return tuple((k, to_immutable(v)) for k, v in sorted(value.items())) - if isinstance(value, list): - return tuple(to_immutable(v) for v in value) - return value - - consolidated = {} - for device_config in transformed_devices: - non_ip_payload = { - k: v for k, v in device_config.items() if k != "ip_address_list" - } - signature = to_immutable(non_ip_payload) - - if signature not in consolidated: - consolidated[signature] = device_config.copy() - if "ip_address_list" not in consolidated[signature]: - consolidated[signature]["ip_address_list"] = [] - - current_ips = consolidated[signature].get("ip_address_list", []) - incoming_ips = device_config.get("ip_address_list", []) - - if isinstance(incoming_ips, str): - incoming_ips = [incoming_ips] - elif not isinstance(incoming_ips, list): - incoming_ips = [] + self.set_operation_result( + "failed", True, + "{0} operation failed: {1}".format(operation_name, str(e)), + "ERROR" + ).check_return_status() - for ip in incoming_ips: - if isinstance(ip, str): - ip = ip.strip() - if ip and ip not in current_ips: - current_ips.append(ip) - - consolidated[signature]["ip_address_list"] = current_ips - - consolidated_list = list(consolidated.values()) - - self.log( - "Transformation completed. raw={0}, transformed={1}, consolidated={2}".format( - len(device_response), - len(transformed_devices), - len(consolidated_list), - ), - "INFO", - ) - return consolidated_list - - def apply_component_specific_filters(self, devices, component_filters): - """ - Apply component-specific filters to device list after API retrieval. - Handles filters that can be: - - Single dict: {role: "ACCESS"} - - List of single dict: [{role: "ACCESS"}] - - List of multiple dicts: [{role: "ACCESS"}, {role: "CORE"}] - - Multiple filter dicts use OR logic (device matches ANY filter set). - - Args: - devices (list): List of device dictionaries from API - component_filters (dict or list): Filters like type, role, snmp_version, cli_transport - Can be nested dict or list of filter dicts - - Returns: - list: Filtered device list - """ - if not isinstance(devices, list): - self.log( - "Cannot apply component filters because devices type is invalid: {0}".format( - type(devices).__name__ - ), - "ERROR", - ) - self.status = "failed" - self.msg = "Device filter input is invalid." - return None - - if not component_filters: - return [d for d in devices if isinstance(d, dict)] - - if isinstance(component_filters, dict): - filter_sets = [component_filters] - elif isinstance(component_filters, list): - filter_sets = [f for f in component_filters if isinstance(f, dict)] - else: - self.log( - "Ignoring component filters because type is invalid: {0}".format( - type(component_filters).__name__ - ), - "WARNING", - ) - return [d for d in devices if isinstance(d, dict)] - - if not filter_sets: - return [d for d in devices if isinstance(d, dict)] - valid_types = { - "NETWORK_DEVICE", - "COMPUTE_DEVICE", - "MERAKI_DASHBOARD", - "THIRD_PARTY_DEVICE", - "FIREPOWER_MANAGEMENT_SYSTEM", - } - valid_roles = {"ACCESS", "CORE", "DISTRIBUTION", "BORDER ROUTER", "UNKNOWN"} - valid_snmp_versions = {"v2", "v2c", "v3"} - valid_cli_transports = {"ssh", "telnet"} - - normalized_filters = [] - - for index, raw_filter in enumerate(filter_sets, start=1): - unknown_keys = set(raw_filter.keys()) - { - "type", - "role", - "snmp_version", - "cli_transport", - } - if unknown_keys: + else: + operations_skipped += 1 self.log( - "Ignoring unsupported keys in filter set {0}: {1}".format( - index, sorted(list(unknown_keys)) + "Iteration {0}: No parameters found for {1}. Skipping operation.".format( + index, operation_name ), "WARNING", ) - device_type = raw_filter.get("type") - device_role = raw_filter.get("role") - snmp_version = raw_filter.get("snmp_version") - cli_transport = raw_filter.get("cli_transport") - - if device_type: - if not isinstance(device_type, str): - self.status = "failed" - self.msg = "Filter 'type' must be a string." - return None - device_type = device_type.strip().upper() - if device_type not in valid_types: - self.status = "failed" - self.msg = "Invalid type '{0}' in component_specific_filters.".format( - raw_filter.get("type") - ) - return None - - role_values = None - if device_role is not None: - if isinstance(device_role, str): - role_values = [device_role] - elif isinstance(device_role, list): - role_values = device_role - else: - self.status = "failed" - self.msg = "Filter 'role' must be a string or list." - return None - - normalized_roles = [] - for role in role_values: - if not isinstance(role, str): - self.status = "failed" - self.msg = "Role filter values must be strings." - return None - role_norm = role.strip().upper() - if role_norm not in valid_roles: - self.status = "failed" - self.msg = "Invalid role '{0}' in component_specific_filters.".format(role) - return None - normalized_roles.append(role_norm) - role_values = normalized_roles - - if snmp_version: - if not isinstance(snmp_version, str): - self.status = "failed" - self.msg = "Filter 'snmp_version' must be a string." - return None - snmp_version = snmp_version.strip().lower() - if snmp_version not in valid_snmp_versions: - self.status = "failed" - self.msg = "Invalid snmp_version '{0}' in component_specific_filters.".format( - raw_filter.get("snmp_version") - ) - return None - - if cli_transport: - if not isinstance(cli_transport, str): - self.status = "failed" - self.msg = "Filter 'cli_transport' must be a string." - return None - cli_transport = cli_transport.strip().lower() - if cli_transport not in valid_cli_transports: - self.status = "failed" - self.msg = "Invalid cli_transport '{0}' in component_specific_filters.".format( - raw_filter.get("cli_transport") - ) - return None - - if any([device_type, role_values, snmp_version, cli_transport]): - normalized_filters.append( - { - "type": device_type, - "role": role_values, - "snmp_version": snmp_version, - "cli_transport": cli_transport, - } - ) - - if not normalized_filters: - return [d for d in devices if isinstance(d, dict)] - - matched_indexes = set() - - for device_index, device in enumerate(devices): - if not isinstance(device, dict): - continue - - device_type_value = (device.get("type") or "").strip().upper() - device_role_value = (device.get("role") or "").strip().upper() - device_snmp_value = (device.get("snmpVersion") or "").strip().lower() - device_cli_value = (device.get("cliTransport") or "").strip().lower() - - normalized_device_snmp = device_snmp_value.replace("v2c", "v2") - normalized_device_role = device_role_value if device_role_value else "UNKNOWN" - - for criteria in normalized_filters: - if criteria["type"] and device_type_value != criteria["type"]: - continue - - if criteria["role"] and normalized_device_role not in criteria["role"]: - continue - - if criteria["snmp_version"]: - expected_snmp = criteria["snmp_version"].replace("v2c", "v2") - if normalized_device_snmp != expected_snmp: - continue - - if criteria["cli_transport"] and device_cli_value != criteria["cli_transport"]: - continue - - matched_indexes.add(device_index) - break - - filtered_devices = [devices[i] for i in sorted(matched_indexes)] - + end_time = time.time() self.log( - "Component filtering completed: matched={0}, total={1}, filter_sets={2}".format( - len(filtered_devices), len(devices), len(normalized_filters) + "Completed 'get_diff_gathered' operation in {0:.2f} seconds.".format( + end_time - start_time ), - "INFO", + "DEBUG", ) - return filtered_devices + + return self def main(): @@ -4217,60 +925,60 @@ def main(): "validate_response_schema": {"type": "bool", "default": True}, "dnac_api_task_timeout": {"type": "int", "default": 1200}, "dnac_task_poll_interval": {"type": "int", "default": 2}, - "file_path": {"type": "str", "required": False}, + "state": {"default": "gathered", "choices": ["gathered"]}, + "file_path": {"required": False, "type": "str"}, "file_mode": { - "type": "str", "required": False, + "type": "str", "default": "overwrite", "choices": ["overwrite", "append"], }, "config": {"required": False, "type": "dict"}, - "state": {"default": "gathered", "choices": ["gathered"]}, } # Initialize the Ansible module with the provided argument specifications module = AnsibleModule(argument_spec=element_spec, supports_check_mode=True) - # Initialize the NetworkCompliance object with the module - ccc_inventory_playbook_generator = InventoryPlaybookConfigGenerator(module) + + config_generator = InventoryPlaybookConfigGenerator(module) if ( - ccc_inventory_playbook_generator.compare_dnac_versions( - ccc_inventory_playbook_generator.get_ccc_version(), "2.3.7.9" + config_generator.compare_dnac_versions( + config_generator.get_ccc_version(), "2.3.7.9" ) < 0 ): - ccc_inventory_playbook_generator.msg = ( + config_generator.msg = ( "The specified version '{0}' does not support the YAML Playbook generation " "for INVENTORY Module. Supported versions start from '2.3.7.9' onwards. ".format( - ccc_inventory_playbook_generator.get_ccc_version() + config_generator.get_ccc_version() ) ) - ccc_inventory_playbook_generator.set_operation_result( - "failed", False, ccc_inventory_playbook_generator.msg, "ERROR" + config_generator.set_operation_result( + "failed", False, config_generator.msg, "ERROR" ).check_return_status() # Get the state parameter from the provided parameters - state = ccc_inventory_playbook_generator.params.get("state") + state = config_generator.params.get("state") # Check if the state is valid - if state not in ccc_inventory_playbook_generator.supported_states: - ccc_inventory_playbook_generator.status = "invalid" - ccc_inventory_playbook_generator.msg = "State {0} is invalid".format( + if state not in config_generator.supported_states: + config_generator.status = "invalid" + config_generator.msg = "State {0} is invalid".format( state ) - ccc_inventory_playbook_generator.check_return_status() + config_generator.check_return_status() - # Validate the input parameters and check the return statusk - ccc_inventory_playbook_generator.validate_input().check_return_status() + # Validate the input parameters and check the return status + config_generator.validate_input().check_return_status() - config = ccc_inventory_playbook_generator.validated_config - ccc_inventory_playbook_generator.get_want( + config = config_generator.validated_config + config_generator.get_want( config, state ).check_return_status() - ccc_inventory_playbook_generator.get_diff_state_apply[ + config_generator.get_diff_state_apply[ state ]().check_return_status() - module.exit_json(**ccc_inventory_playbook_generator.result) + module.exit_json(**config_generator.result) if __name__ == "__main__": diff --git a/plugins/modules/lan_automation_workflow_manager.py b/plugins/modules/lan_automation_workflow_manager.py index 27951765eb..4d2769cf3f 100644 --- a/plugins/modules/lan_automation_workflow_manager.py +++ b/plugins/modules/lan_automation_workflow_manager.py @@ -483,7 +483,14 @@ - Links from different Port Channels cannot be mixed during update operations. Each physical link can belong to only one Port Channel at any given time. - + - To run multiple LAN Automation sessions in parallel, use Ansible asynchronous + tasks within a single playbook. This allows multiple LAN session start tasks + to execute concurrently. Cisco Catalyst Center supports up to 5 concurrent + LAN Automation sessions. + - For parallel execution, add async, poll, and register on each LAN + Automation task, then add a final async_status task that loops over the + registered jobs and waits for completion. Check the examples section for a + complete parallel execution playbook. - SDK Method used are lan_automation.LanAutomation.lan_automation_start_v2 lan_automation.LanAutomation.lan_automation_stop @@ -601,6 +608,70 @@ - "FJC27172JDW" - "FJC2721261A" +- name: Start multiple LAN Automation sessions in parallel + hosts: dnac_servers + gather_facts: false + connection: local + tasks: + - name: Start LAN Automation session 1 asynchronously + cisco.dnac.lan_automation_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + state: merged + config: + - lan_automation: + primary_device_management_ip_address: "204.1.1.1" + discovered_device_site_name_hierarchy: "Global/USA/SAN JOSE" + primary_device_interface_names: + - "HundredGigE1/0/2" + ip_pools: + - ip_pool_name: "underlay_sub_sj" + ip_pool_role: "MAIN_POOL" + launch_and_wait: false + async: 3600 + poll: 0 + register: lan_job_1 + + - name: Start LAN Automation session 2 asynchronously + cisco.dnac.lan_automation_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + state: merged + config: + - lan_automation: + primary_device_management_ip_address: "204.1.1.2" + discovered_device_site_name_hierarchy: "Global/USA/SAN FRANCISCO" + primary_device_interface_names: + - "HundredGigE1/0/29" + ip_pools: + - ip_pool_name: "underlay_sub_sf" + ip_pool_role: "MAIN_POOL" + launch_and_wait: false + async: 3600 + poll: 0 + register: lan_job_2 + + - name: Wait for all asynchronous LAN Automation jobs + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: lan_job_status + until: lan_job_status.finished + retries: 300 + delay: 10 + loop: + - "{{ lan_job_1 }}" + - "{{ lan_job_2 }}" + - name: Stop a LAN Automation session cisco.dnac.lan_automation_workflow_manager: dnac_host: "{{dnac_host}}" diff --git a/plugins/modules/network_profile_switching_playbook_config_generator.py b/plugins/modules/network_profile_switching_playbook_config_generator.py index 7fb86bc5ca..6e4a8e8b45 100644 --- a/plugins/modules/network_profile_switching_playbook_config_generator.py +++ b/plugins/modules/network_profile_switching_playbook_config_generator.py @@ -1860,25 +1860,17 @@ def yaml_config_generator(self, yaml_config_generator): self.set_operation_result("success", True, self.msg, "INFO") else: self.log( - "YAML configuration file write FAILED for path: {0}. write_dict_to_yaml() returned " - "False indicating file creation or serialization error. Verify file path permissions, " - "directory existence, and disk space availability.".format(file_path), - "ERROR" + f"YAML configuration file is already up-to-date at: {file_path}. " + f"No write operation performed.", + "INFO" ) self.msg = { - "YAML config generation Task failed for module '{0}'.".format( - self.module_name - ): {"file_path": file_path} + f"YAML configuration file already up-to-date for module '{self.module_name}'. " + f"No changes required.": { + "file_path": file_path + } } - self.set_operation_result("failed", True, self.msg, "ERROR") - - self.log( - "Completed yaml_config_generator operation. Operation status: {0}, Changed: {1}, " - "File path: {2}. Returning control to calling function.".format( - self.status, self.result.get("changed"), file_path - ), - "INFO" - ) + self.set_operation_result("ok", False, self.msg, "INFO") return self diff --git a/plugins/modules/network_profile_wireless_playbook_config_generator.py b/plugins/modules/network_profile_wireless_playbook_config_generator.py index c4f78717aa..f8129e3519 100644 --- a/plugins/modules/network_profile_wireless_playbook_config_generator.py +++ b/plugins/modules/network_profile_wireless_playbook_config_generator.py @@ -2464,24 +2464,18 @@ def yaml_config_generator(self, yaml_config_generator): ) else: self.log( - f"YAML file write operation failed for file path: {file_path}. Check file " - f"permissions, disk space, and path validity. Configurations ready but " - f"not written: {len(final_list)}", - "ERROR" + f"YAML configuration file is already up-to-date at: {file_path}. " + f"No write operation performed.", + "INFO" ) self.msg = { - "YAML config generation Task failed for module '{0}'.".format( - self.module_name - ): {"file_path": file_path} + f"YAML configuration file already up-to-date for module '{self.module_name}'. " + f"No changes required.": { + "file_path": file_path + } } - self.set_operation_result("failed", True, self.msg, "ERROR") - - self.log( - "YAML configuration generation failed due to file write error. " - "Operation result set to 'failed'. Returning instance with error state.", - "ERROR" - ) + self.set_operation_result("ok", False, self.msg, "ERROR") return self diff --git a/plugins/modules/pnp_playbook_config_generator.py b/plugins/modules/pnp_playbook_config_generator.py index 53b0286e0b..112b34bfc2 100644 --- a/plugins/modules/pnp_playbook_config_generator.py +++ b/plugins/modules/pnp_playbook_config_generator.py @@ -303,26 +303,9 @@ ) import time -try: - import yaml - HAS_YAML = True -except ImportError: - HAS_YAML = False - yaml = None - from collections import OrderedDict -if HAS_YAML: - class OrderedDumper(yaml.Dumper): - def represent_dict(self, data): - return self.represent_mapping("tag:yaml.org,2002:map", data.items()) - - OrderedDumper.add_representer(OrderedDict, OrderedDumper.represent_dict) -else: - OrderedDumper = None - - class PnPPlaybookGenerator(DnacBase, BrownFieldHelper): """ Class for generating YAML playbooks from PnP device configurations. @@ -1350,49 +1333,53 @@ def yaml_config_generator(self, yaml_config_generator): "DEBUG" ) - # Write to YAML file - success = self.write_dict_to_yaml([output_structure], file_path, file_mode=file_mode) + file_written = self.write_dict_to_yaml( + [output_structure], + file_path, + file_mode=file_mode, + ) - if success: - # Component successfully processed - components_processed = components_requested - components_skipped = 0 + components_processed = components_requested + components_skipped = 0 - self.msg = "YAML config generation succeeded for module '{0}'.".format(self.module_name) - self.result["msg"] = self.msg - self.result["response"] = { - "status": "success", - "message": self.msg, - "file_path": file_path, - "configurations_count": sum(len(g["device_info"]) for g in grouped_configs), - "components_processed": components_processed, - "components_skipped": components_skipped - } - self.log( - "{0} File path: {1}, Configurations count: {2}, " - "Components processed: {3}, Components skipped: {4}".format( - self.msg, - file_path, - sum(len(g["device_info"]) for g in grouped_configs), - components_processed, - components_skipped - ), - "INFO" + configurations_count = sum(len(g["device_info"]) for g in grouped_configs) + + if file_written: + self.msg = "YAML config generation succeeded for module '{0}'.".format( + self.module_name ) - self.result["changed"] = True - self.status = "success" + response_status = "success" else: - failure_message = "Failed to write YAML configuration file to path: {0}".format( - file_path - ) - self.log( - "YAML file write operation failed. Unable to write configuration to file: {0}. " - "Check file permissions, disk space, and parent directory existence.".format( - file_path - ), - "ERROR" + self.msg = ( + "YAML configuration file already up-to-date for module '{0}'. " + "No changes written.".format(self.module_name) ) - self.fail_and_exit(failure_message) + response_status = "ok" + + self.result["msg"] = self.msg + self.result["response"] = { + "status": response_status, + "message": self.msg, + "file_path": file_path, + "file_mode": file_mode, + "configurations_count": configurations_count, + "components_processed": components_processed, + "components_skipped": components_skipped, + } + self.log( + "{0} File path: {1}, Configurations count: {2}, " + "Components processed: {3}, Components skipped: {4}, Local file changed: {5}".format( + self.msg, + file_path, + configurations_count, + components_processed, + components_skipped, + file_written + ), + "INFO" + ) + self.result["changed"] = bool(file_written) + self.status = "success" return self diff --git a/plugins/modules/sda_fabric_devices_playbook_config_generator.py b/plugins/modules/sda_fabric_devices_playbook_config_generator.py index ccccd5e3ac..f877a51e2c 100644 --- a/plugins/modules/sda_fabric_devices_playbook_config_generator.py +++ b/plugins/modules/sda_fabric_devices_playbook_config_generator.py @@ -501,7 +501,7 @@ def __init__(self, module): self.log("Retrieving fabric site name to ID mapping", "DEBUG") self.fabric_site_name_to_id_dict, self.fabric_site_id_to_name_dict = ( - self.get_fabric_site_name_to_id_mapping() + self.get_fabric_site_name_to_id_mapping(self.site_id_name_dict) ) self.log( f"Retrieved {len(self.fabric_site_name_to_id_dict)} fabric site(s) in mapping", @@ -1172,8 +1172,8 @@ def get_fabric_devices_configuration(self, network_element, filters=None): If omitted or None, all fabric sites and their devices are retrieved. Returns: - dict: Dictionary with key 'fabric_devices' mapping to a list of transformed fabric - site entries, each containing fabric_name and device_config list. + list: List of dicts, each with a single key 'fabric_devices' mapping to a dict + containing fabric_name (str) and device_config (list). One entry per fabric site. None: If no valid query parameters could be built from the provided filters, or if no fabric devices are found matching the filters. @@ -1195,7 +1195,7 @@ def get_fabric_devices_configuration(self, network_element, filters=None): if not self.fabric_site_name_to_id_dict: self.log("No fabric sites found in Cisco Catalyst Center", "WARNING") - return {"fabric_devices": []} + return [] fabric_devices_params_list_to_query = [] @@ -1415,12 +1415,13 @@ def get_fabric_devices_configuration(self, network_element, filters=None): return None self.log( - f"Transformation complete. Generated {len(transformed_fabric_devices_list)} fabric site(s) with devices", - "INFO", + "Fabric device configuration retrieval complete. " + f"Returning {len(transformed_fabric_devices_list)} fabric site entries.", + "DEBUG", ) self.log("Exiting get_fabric_devices_configuration method", "DEBUG") - return {"fabric_devices": transformed_fabric_devices_list} + return [{"fabric_devices": entry} for entry in transformed_fabric_devices_list] def transform_fabric_name(self, details): """ diff --git a/plugins/modules/sda_fabric_devices_workflow_manager.py b/plugins/modules/sda_fabric_devices_workflow_manager.py index 4236027867..2498de65a5 100644 --- a/plugins/modules/sda_fabric_devices_workflow_manager.py +++ b/plugins/modules/sda_fabric_devices_workflow_manager.py @@ -4790,6 +4790,13 @@ def process_scope_list(self, scope_list): current_site = self.get_site(site_name).get("response", []) child_sites_response = self.get_site(site_name + "/.*") + if not child_sites_response: + self.log( + f"No child sites found for site '{site_name}'.", + "DEBUG", + ) + continue + child_sites = child_sites_response.get("response", []) self.log( f"Found {len(child_sites)} child site(s) for site '{site_name}'.", diff --git a/plugins/modules/sda_fabric_sites_zones_workflow_manager.py b/plugins/modules/sda_fabric_sites_zones_workflow_manager.py index 9734c83255..aa8da23ef9 100644 --- a/plugins/modules/sda_fabric_sites_zones_workflow_manager.py +++ b/plugins/modules/sda_fabric_sites_zones_workflow_manager.py @@ -175,7 +175,7 @@ the network access and maintaining security. type: str - enable_bpu_guard: + enable_bpdu_guard: description: A boolean setting that enables or disables BPDU Guard. BPDU Guard provides a security mechanism @@ -461,6 +461,32 @@ dot1x_fallback_timeout: 28 wake_on_lan: false number_of_hosts: "Single" + +- name: Update BPDU Guard in the Closed Authentication + profile template for a fabric zone. + cisco.dnac.sda_fabric_sites_zones_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: false + state: merged + config: + - fabric_sites: + - site_name_hierarchy: "Global/Test_SDA/Bld1/Floor1" + fabric_type: "fabric_zone" + authentication_profile: "Closed Authentication" + update_authentication_profile: + authentication_order: "dot1x" + dot1x_fallback_timeout: 28 + wake_on_lan: false + number_of_hosts: "Single" + enable_bpdu_guard: false + - name: Deleting/removing fabric site from sda from Cisco Catalyst Center cisco.dnac.sda_fabric_sites_zones_workflow_manager: @@ -573,7 +599,7 @@ def validate_input(self): "dot1x_fallback_timeout": {"type": "int"}, "wake_on_lan": {"type": "bool"}, "number_of_hosts": {"type": "str"}, - "enable_bpu_guard": {"type": "bool"}, + "enable_bpdu_guard": {"type": "bool"}, "pre_auth_acl": { "type": "dict", "enabled": {"type": "bool"}, @@ -1388,7 +1414,7 @@ def auth_profile_needs_update(self, auth_profile_dict, auth_profile_in_ccc): is needed. Returns `False` if the settings match and no update is required. Description: This method compares the provided authentication profile settings (`auth_profile_dict`) with the current settings retrieved from - the Cisco Catalyst Center (`auth_profile_in_ccc`). It considers the possibility of an additional setting "enable_bpu_guard" if + the Cisco Catalyst Center (`auth_profile_in_ccc`). It considers the possibility of an additional setting "enable_bpdu_guard" if the current profile is "Closed Authentication". It iterates through a mapping of profile settings and checks if any of the settings require an update. If any discrepancies are found, the method returns `True`. If all settings match, it returns `False`. @@ -1402,7 +1428,7 @@ def auth_profile_needs_update(self, auth_profile_dict, auth_profile_in_ccc): } profile_name = auth_profile_in_ccc.get("authenticationProfileName") if profile_name == "Closed Authentication": - profile_key_mapping["enable_bpu_guard"] = "isBpduGuardEnabled" + profile_key_mapping["enable_bpdu_guard"] = "isBpduGuardEnabled" for key, ccc_key in profile_key_mapping.items(): desired_value = auth_profile_dict.get(key) @@ -1523,13 +1549,13 @@ def collect_authentication_params(self, auth_profile_dict, auth_profile_in_ccc): ) if profile_name == "Closed Authentication": - if auth_profile_dict.get("enable_bpu_guard") is None: + if auth_profile_dict.get("enable_bpdu_guard") is None: authentications_params_dict["isBpduGuardEnabled"] = ( auth_profile_in_ccc.get("isBpduGuardEnabled", True) ) else: authentications_params_dict["isBpduGuardEnabled"] = ( - auth_profile_dict.get("enable_bpu_guard") + auth_profile_dict.get("enable_bpdu_guard") ) if ( @@ -1656,7 +1682,7 @@ def update_authentication_profile_template(self, profile_update_params, site_nam ) if auth_profile_name == "Low Impact": self.log( - "Site '{0}' uses 'Low Impact' authentication profile (with with pre-authentication access control list configuration).".format( + "Site '{0}' uses 'Low Impact' authentication profile (with pre-authentication access control list configuration).".format( site_name ), "DEBUG", diff --git a/plugins/modules/sda_fabric_virtual_networks_workflow_manager.py b/plugins/modules/sda_fabric_virtual_networks_workflow_manager.py index 0bd62b33ec..78d161d378 100644 --- a/plugins/modules/sda_fabric_virtual_networks_workflow_manager.py +++ b/plugins/modules/sda_fabric_virtual_networks_workflow_manager.py @@ -221,6 +221,16 @@ payload elements or none. And update of this field is not allowed. type: str + multiple_ip_to_mac_addresses: + description: Indicates whether multiple IPs + can be associated with a single MAC address + for the layer2 fabric VLAN. By default, + it is set to false when associated with a + layer 3 virtual network and cannot be used + when not associated with a layer 3 virtual + network. + type: bool + default: false virtual_networks: description: A list of virtual networks (VNs) configured within the SDA fabric. Each virtual @@ -965,6 +975,33 @@ site_name_hierarchy: "Global/India" fabric_type: "fabric_site" ip_pool_name: "IP_Pool_1" + +- name: Create fabric VLAN with multiple IP to MAC enabled + cisco.dnac.sda_fabric_virtual_networks_workflow_manager: + state: merged + config: + - fabric_vlan: + - vlan_name: "vlan_multi_ip" + vlan_id: 1944 + traffic_type: "DATA" + associated_layer3_virtual_network: "L3_VN_1" + multiple_ip_to_mac_addresses: true + fabric_site_locations: + - site_name_hierarchy: "Global/India/Fabric_Test" + fabric_type: "fabric_site" + +- name: Create fabric VLAN without multiple_ip_to_mac_addresses (omitted) + cisco.dnac.sda_fabric_virtual_networks_workflow_manager: + state: merged + config: + - fabric_vlan: + - vlan_name: "vlan_default_multi_ip" + vlan_id: 1945 + traffic_type: "DATA" + associated_layer3_virtual_network: "L3_VN_1" + fabric_site_locations: + - site_name_hierarchy: "Global/India/Fabric_Test" + fabric_type: "fabric_site" """ RETURN = r""" dnac_response: @@ -1106,6 +1143,7 @@ def validate_input(self): "resource_guard_enable": {"type": "bool"}, "flooding_address_assignment": {"type": "str"}, "flooding_address": {"type": "str"}, + "multiple_ip_to_mac_addresses": {"type": "bool"}, }, "virtual_networks": { "type": "list", @@ -1752,6 +1790,8 @@ def create_payload_for_fabric_vlan(self, vlan, fabric_id_list): - flooding_address_assignment (str, optional): How the flooding address is assigned ("SHARED" or "CUSTOM"). - flooding_address (str, optional): The custom flooding address, if assignment is "CUSTOM". + - multiple_ip_to_mac_addresses (bool, optional): Whether multiple IPs can be associated + with a single MAC address. Defaults to False. fabric_id_list (list): A list of fabric IDs where the VLAN configuration will be applied. Returns: list: A list of dictionaries, each containing the payload required to create or configure the VLAN @@ -1794,6 +1834,12 @@ def create_payload_for_fabric_vlan(self, vlan, fabric_id_list): vlan_payload["isResourceGuardEnabled"] = vlan.get( "resource_guard_enable", False ) + + if "multiple_ip_to_mac_addresses" in vlan: + vlan_payload["isMultipleIpToMacAddresses"] = vlan.get( + "multiple_ip_to_mac_addresses" + ) + vlan_payload["layer2FloodingAddressAssignment"] = vlan.get( "flooding_address_assignment", "SHARED" ) @@ -1901,6 +1947,8 @@ def fabric_vlan_needs_update(self, desired_vlan_config, current_vlan_config): - flooding_address_assignment (str): The assignment method for the flooding address ('SHARED' or 'CUSTOM'). - flooding_address (str): The custom flooding IP address. + - multiple_ip_to_mac_addresses (bool): Indicates if multiple IPs can be associated + with a single MAC address. current_vlan_config (dict): A dictionary representing the current VLAN configuration from Catalyst Center. Returns: bool: Returns `True` if the VLAN needs to be updated and `False` otherwise. @@ -2024,6 +2072,21 @@ def fabric_vlan_needs_update(self, desired_vlan_config, current_vlan_config): ) return True + multiple_ip_to_mac = desired_vlan_config.get("multiple_ip_to_mac_addresses") + if multiple_ip_to_mac is not None: + current_multiple_ip_to_mac = current_vlan_config.get( + "isMultipleIpToMacAddresses", False + ) + if multiple_ip_to_mac != current_multiple_ip_to_mac: + self.log( + "Multiple IP to MAC addresses setting needs update: desired='{0}', current='{1}'".format( + multiple_ip_to_mac, + current_multiple_ip_to_mac, + ), + "DEBUG", + ) + return True + self.log("No updates required for the fabric VLAN configuration.", "DEBUG") return False @@ -2043,8 +2106,9 @@ def update_payload_fabric_vlan( relevant identifiers and configuration details. Description: This function constructs a payload for updating a fabric VLAN in Cisco Catalyst Center. The resulting payload - is structured to include the VLAN ID, fabric ID, traffic type, wireless enablement status, and associated - Layer3 virtual network name and used to submit an update request to the Cisco Catalyst Center API. + is structured to include the VLAN ID, fabric ID, traffic type, wireless enablement status, multiple IP + to MAC addresses setting, and associated Layer3 virtual network name and used to submit an update request + to the Cisco Catalyst Center API. """ self.log( "Constructing update payload for VLAN '{vlan_name}' on fabric '{fabric_id}'.".format( @@ -2124,7 +2188,16 @@ def update_payload_fabric_vlan( "layer2FloodingAddressAssignment" ) + multiple_ip_to_mac_addresses = new_vlan_config.get("multiple_ip_to_mac_addresses") + if multiple_ip_to_mac_addresses is None: + self.log( + "Parameter 'multiple_ip_to_mac_addresses' not provided; using current value from Catalyst Center.", + "DEBUG", + ) + multiple_ip_to_mac_addresses = current_vlan_config.get("isMultipleIpToMacAddresses") + vlan_update_payload["isResourceGuardEnabled"] = resource_guard_enable + vlan_update_payload["isMultipleIpToMacAddresses"] = multiple_ip_to_mac_addresses vlan_update_payload["layer2FloodingAddressAssignment"] = flooding_address_assignment if flooding_address_assignment == "CUSTOM": @@ -4230,6 +4303,17 @@ def get_want_fabric_vlan_details(self, fabric_vlan_details): # Validate the correct fabric_type given in the playbook self.validate_fabric_type(fabric_type).check_return_status() self.log("Fabric type '{0}' is valid.".format(fabric_type), "INFO") + + # Validate that multiple_ip_to_mac_addresses requires associated_layer3_virtual_network + if vlan.get("multiple_ip_to_mac_addresses") and not vlan.get("associated_layer3_virtual_network"): + self.msg = ( + "The parameter 'multiple_ip_to_mac_addresses' for VLAN '{0}' requires " + "'associated_layer3_virtual_network' to be specified." + ).format(vlan_name) + self.set_operation_result( + "failed", False, self.msg, "ERROR" + ).check_return_status() + fabric_vlan_info.append(vlan) return fabric_vlan_info @@ -4928,6 +5012,11 @@ def process_fabric_vlans(self, fabric_vlan_details): self.create_payload_for_fabric_vlan(vlan, fabric_id_list) ) + self.log( + "Collected fabric VLAN payload(s) for creation: {0}".format(collected_add_vlan_payload), + "DEBUG", + ) + if collected_add_vlan_payload: self.create_fabric_vlan(collected_add_vlan_payload).check_return_status() self.log("Successfully created fabric VLANs.", "INFO") diff --git a/plugins/modules/tags_playbook_config_generator.py b/plugins/modules/tags_playbook_config_generator.py index 822bcd309a..513fb97ba1 100644 --- a/plugins/modules/tags_playbook_config_generator.py +++ b/plugins/modules/tags_playbook_config_generator.py @@ -68,7 +68,7 @@ file. - If "components_list" is specified, only those components are included, regardless of other filters. - - If filters for specific components (e.g., tag or tag_memberships) are provided + - If filters for specific components (e.g., tags or tag_memberships) are provided without explicitly including them in components_list, those components will be automatically added to components_list. - At least one of components_list or component filters must be provided. @@ -77,15 +77,15 @@ components_list: description: - List of components to include in the YAML configuration file. - - Valid values are tag and tag_memberships. + - Valid values are tags and tag_memberships. - If specified, only the listed components will be included in the generated YAML file. - - If not specified but component filters (tag or tag_memberships) are provided, + - If not specified but component filters (tags or tag_memberships) are provided, those components are automatically added to this list. - If neither components_list nor any component filters are provided, an error will be raised. type: list elements: str - choices: ["tag" , "tag_memberships"] - tag: + choices: ["tags", "tag_memberships"] + tags: description: - Filters specific to tag configuration retrieval. - Used to narrow down which tags should be included in the generated YAML file. @@ -156,20 +156,20 @@ /dna/intent/api/v1/tag/${id}/member - | Auto-population of components_list: - If component-specific filters (such as 'tag' or 'tag_memberships') are provided + If component-specific filters (such as 'tags' or 'tag_memberships') are provided without explicitly including them in 'components_list', those components will be automatically added to 'components_list'. This simplifies configuration by eliminating the need to redundantly specify components in both places. - | Example of auto-population behavior: - If you provide filters for 'tag' without including 'tag' in 'components_list', - the module will automatically add 'tag' to 'components_list' before processing. + If you provide filters for 'tags' without including 'tags' in 'components_list', + the module will automatically add 'tags' to 'components_list' before processing. This allows you to write more concise playbooks. - | Validation requirements: If 'component_specific_filters' is provided, at least one of the following must be true: (1) 'components_list' contains at least one component, OR - (2) Component-specific filters (e.g., 'tag', 'tag_memberships') are provided. + (2) Component-specific filters (e.g., 'tags', 'tag_memberships') are provided. If neither condition is met, the module will fail with a validation error. - | System tags filtering: @@ -259,7 +259,7 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag"] + components_list: ["tags"] # Example 4: Generate only tag membership configurations - name: Generate tag memberships only @@ -315,7 +315,7 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] + components_list: ["tags", "tag_memberships"] # Example 6: Auto-populate components_list from component filters - name: Generate configuration with auto-populated components_list @@ -343,12 +343,12 @@ file_mode: "overwrite" config: component_specific_filters: - # No components_list specified, but tag filters are provided - # The 'tag' component will be automatically added to components_list - tag: + # No components_list specified, but tags filters are provided + # The 'tags' component will be automatically added to components_list + tags: - tag_name: Production - tag_name: Data-Center - # This will automatically include 'tag' in components_list and retrieve only those tags + # This will automatically include 'tags' in components_list and retrieve only those tags # Example 7: Filter specific tags by name - name: Generate configuration for specific tags by name @@ -376,8 +376,8 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] - tag: + components_list: ["tags", "tag_memberships"] + tags: - tag_name: Production - tag_name: Data-Center @@ -407,7 +407,7 @@ file_mode: "overwrite" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] + components_list: ["tags", "tag_memberships"] tag_memberships: - tag_name: Campus-Switches - tag_name: Core-Routers @@ -438,8 +438,8 @@ file_mode: "append" config: component_specific_filters: - components_list: ["tag", "tag_memberships"] - tag: + components_list: ["tags", "tag_memberships"] + tags: - tag_name: Branch-Office - tag_name: Access-Points @@ -832,7 +832,7 @@ def get_workflow_filters_schema(self): Returns: dict: A dictionary containing the workflow filters schema with the following structure: - network_elements (dict): Contains configuration for different network element types - - tag (dict): Configuration for tag-related operations + - tags (dict): Configuration for tag-related operations - filters (list): List of filter parameters (tag_name, tag_id) - reverse_mapping_function (method): Function to map tag specifications - api_function (str): API function name for retrieving tags @@ -854,12 +854,12 @@ def get_workflow_filters_schema(self): schema = { "network_elements": { - "tag": { + "tags": { "filters": ["tag_name", "tag_id"], "reverse_mapping_function": self.tag_temp_spec, "api_function": "get_tag", "api_family": "tag", - "get_function_name": self.get_tag_configuration, + "get_function_name": self.get_tags_configuration, }, "tag_memberships": { "filters": ["tag_name", "tag_id", "device_identifier"], @@ -1111,9 +1111,7 @@ def fetch_tag_memberships_for_single_tag( ) return tag_membership_details - def get_tag_membership_configuration( - self, network_element, component_specific_filters=None - ): + def get_tag_membership_configuration(self, network_element, combined_filters=None): """ Retrieves tag membership configuration from Cisco Catalyst Center. @@ -1126,11 +1124,19 @@ def get_tag_membership_configuration( Expected keys: - "api_family" (str): The API family name (e.g., "tag"). - "api_function" (str): The API function name (e.g., "get_tag_members_by_id"). - component_specific_filters (list, optional): List of filter dictionaries to specify - which tags to query. Each dictionary can contain: - - "tag_name" (str): The name of the tag to filter by. - - "tag_id" (str): The ID of the tag to filter by. - If None, all tags from the cached mapping will be processed. + + combined_filters (dict, optional): A dictionary containing component-specific filters and global filters. + We do not support global filters for tag memberships, so this should primarily contain: + { + "component_specific_filters": [ + { + "tag_name": , # Optional, can be used instead of tag_id + "tag_id": , # Optional, can be used instead of tag_name + "device_identifier": # Optional, defaults to "serial_number" + }, + ... + ] + } Returns: dict: A dictionary containing modified tag membership details with the following structure: @@ -1166,11 +1172,21 @@ def get_tag_membership_configuration( tag_id_to_tag_name_mapping) to avoid redundant API calls. """ + if isinstance(combined_filters, dict): + component_specific_filters = combined_filters.get( + "component_specific_filters", [] + ) + if not isinstance(component_specific_filters, list): + component_specific_filters = [] + else: + component_specific_filters = [] + self.log( f"Starting to retrieve tag membership configuration with network element: {network_element} and " f"component-specific filters: {component_specific_filters}", "DEBUG", ) + # Extract API family and function from network_element tag_memberships_config = [] api_family = network_element.get("api_family") @@ -1329,7 +1345,7 @@ def get_tag_membership_configuration( "INFO", ) - return modified_tag_memberships_details + return [modified_tag_memberships_details] def check_if_tag_is_system_tag(self, tag_details): """ @@ -1369,9 +1385,9 @@ def check_if_tag_is_system_tag(self, tag_details): return is_system_tag - def get_tag_configuration(self, network_element, component_specific_filters=None): + def get_tags_configuration(self, network_element, combined_filters=None): """ - Retrieve and process tag configuration from Cisco Catalyst Center. + Retrieve and process tags configuration from Cisco Catalyst Center. This method fetches tag details either by applying component-specific filters or by retrieving all available tags. It uses cached tag mappings to avoid unnecessary API calls and processes @@ -1381,17 +1397,22 @@ def get_tag_configuration(self, network_element, component_specific_filters=None Args: network_element: The network element identifier for which tags are being retrieved. Used primarily for logging purposes. - component_specific_filters (list of dict, optional): A list of filter dictionaries to - narrow down tag selection. Each dictionary - can contain: - - 'tag_name': Filter by tag name - - 'tag_id': Filter by tag ID - If None, all cached tags are retrieved. + combined_filters (dict, optional): A dictionary containing component-specific filters and global filters. + We do not support global filters for tags, so this should primarily contain: + { + "component_specific_filters": [ + { + "tag_name": , # Optional, can be used instead of tag_id + "tag_id": , # Optional, can be used instead of tag_name + }, + ... + ] + } Returns: dict: A dictionary containing modified tag details with the following structure: { - "tag": [list of processed tag detail dictionaries] + "tags": [list of processed tag detail dictionaries] } Each tag detail is modified according to the tag_temp_spec template. System tags are excluded from the returned configuration. @@ -1407,6 +1428,15 @@ def get_tag_configuration(self, network_element, component_specific_filters=None and cannot be modified by users. """ + if isinstance(combined_filters, dict): + component_specific_filters = combined_filters.get( + "component_specific_filters", [] + ) + if not isinstance(component_specific_filters, list): + component_specific_filters = [] + else: + component_specific_filters = [] + self.log( "Starting to retrieve tag configuration with network element: {0} and component-specific filters: {1}".format( network_element, component_specific_filters @@ -1582,7 +1612,7 @@ def get_tag_configuration(self, network_element, component_specific_filters=None "INFO", ) - modified_tags_details = {"tag": tags_details} + modified_tags_details = {"tags": tags_details} self.log( f"Modified Tag details (count: {len(tags_details)}): {self.pprint(modified_tags_details)}", @@ -1594,7 +1624,7 @@ def get_tag_configuration(self, network_element, component_specific_filters=None "DEBUG", ) - return modified_tags_details + return [modified_tags_details] def tag_temp_spec(self): """ @@ -2843,187 +2873,6 @@ def transform_port_rules(self, tags_details): return result - def yaml_config_generator(self, yaml_config_generator): - """ - Generates a YAML configuration file based on the provided parameters. - This function retrieves network element details using component-specific filters, processes the data, - and writes the YAML content to a specified file. It dynamically handles multiple network elements and their respective filters. - - Args: - yaml_config_generator (dict): Contains component_specific_filters and optionally generate_all_configurations flag. - file_path and file_mode are now taken from self.params. - - Returns: - self: The current instance with the operation result and message updated. - """ - - self.log( - "Starting YAML config generation with parameters: {0}".format( - yaml_config_generator - ), - "DEBUG", - ) - - # Check if generate_all_configurations mode is enabled - generate_all = yaml_config_generator.get("generate_all_configurations", False) - - self.log("Determining output file path for YAML configuration", "DEBUG") - - # Get file_path and file_mode from self.params (top-level parameters) - file_path = self.params.get("file_path") - if not file_path: - self.log( - "No file_path provided by user, generating default filename", "DEBUG" - ) - file_path = self.generate_filename() - else: - self.log("Using user-provided file_path: {0}".format(file_path), "DEBUG") - - file_mode = self.params.get("file_mode", "overwrite") - - self.log( - "YAML configuration file path determined: {0}, file_mode: {1}".format( - file_path, file_mode - ), - "DEBUG", - ) - - self.log("Initializing filter dictionaries", "DEBUG") - if generate_all: - # In generate_all_configurations mode, override any provided filters to ensure we get ALL configurations - self.log( - "Auto-discovery mode: Overriding any provided filters to retrieve all devices and all features", - "INFO", - ) - # Set empty filters to retrieve everything - component_specific_filters = {} - else: - # Use provided filters or default to empty - self.log( - "Normal mode: Using provided component_specific_filters from input", - "DEBUG", - ) - component_specific_filters = ( - yaml_config_generator.get("component_specific_filters") or {} - ) - self.log( - f"Component specific filters initialized: {self.pprint(component_specific_filters)}", - "DEBUG", - ) - - # Retrieve the supported network elements for the module - self.log("Retrieving supported network elements schema for the module", "DEBUG") - module_supported_network_elements = self.module_schema.get( - "network_elements", {} - ) - components_list = component_specific_filters.get( - "components_list", module_supported_network_elements.keys() - ) - self.log("Components to process: {0}".format(components_list), "DEBUG") - - self.log( - "Initializing final configuration list and operation summary tracking", - "DEBUG", - ) - final_config_list = [] - processed_count = 0 - skipped_count = 0 - for component in components_list: - self.log("Processing component: {0}".format(component), "DEBUG") - network_element = module_supported_network_elements.get(component) - if not network_element: - self.log( - f"Component {component} not supported by module, skipping processing", - "WARNING", - ) - skipped_count += 1 - continue - - filters = component_specific_filters.get(component, []) - operation_func = network_element.get("get_function_name") - - if not callable(operation_func): - self.log( - f"No retrieval function defined for component: {component}", "ERROR" - ) - skipped_count += 1 - continue - - component_data = operation_func(network_element, filters) - # Validate retrieval success - if not component_data: - self.log( - "No data retrieved for component: {0}".format(component), "DEBUG" - ) - continue - - modified_details = [ - {f"{component}": detail} for detail in component_data.get(component, []) - ] - self.log( - "Details retrieved for {0}: {1}".format(component, component_data), - "DEBUG", - ) - processed_count += 1 - final_config_list.extend(modified_details) - - if not final_config_list: - self.log( - "No configurations retrieved. Processed: {0}, Skipped: {1}, Components: {2}".format( - processed_count, skipped_count, components_list - ), - "WARNING", - ) - self.msg = { - "status": "ok", - "message": ( - "No configurations found for module '{0}'. Verify filters and component availability. " - "Components attempted: {1}".format( - self.module_name, components_list - ) - ), - "components_attempted": len(components_list), - "components_processed": processed_count, - "components_skipped": skipped_count, - } - self.set_operation_result("ok", False, self.msg, "INFO") - return self - - yaml_config_dict = {"config": final_config_list} - self.log( - "Final config dictionary created: {0}".format( - self.pprint(yaml_config_dict) - ), - "DEBUG", - ) - - if self.write_dict_to_yaml( - yaml_config_dict, file_path, file_mode, OrderedDumper - ): - self.msg = ( - f"YAML configuration file generated successfully for module '{self.module_name}'. " - f"File: {file_path}, " - f"Components processed: {processed_count}, " - f"Components skipped: {skipped_count}, " - f"Configurations count: {len(final_config_list)}" - ) - self.set_operation_result("success", True, self.msg, "INFO") - self.log( - f"YAML configuration generation completed. File: {file_path}, " - f"Components: {processed_count}/{len(components_list)}, " - f"Configs: {len(final_config_list)}", - "INFO", - ) - else: - self.msg = { - "YAML config generation Task failed for module '{0}'.".format( - self.module_name - ): {"file_path": file_path} - } - self.set_operation_result("failed", True, self.msg, "ERROR") - - return self - def get_want(self, config, state): """ Creates parameters for API calls based on the specified state. diff --git a/plugins/modules/tags_workflow_manager.py b/plugins/modules/tags_workflow_manager.py index 32a86192f0..c6971e9374 100644 --- a/plugins/modules/tags_workflow_manager.py +++ b/plugins/modules/tags_workflow_manager.py @@ -63,11 +63,12 @@ elements: dict required: true suboptions: - tag: - description: A dictionary containing detailed - configurations for creating, updating, or - deleting tags. - type: dict + tags: + description: A list of dictionaries, each containing + detailed configurations for creating, updating, + or deleting one or more tags. + type: list + elements: dict suboptions: name: description: > @@ -266,10 +267,11 @@ type: str required: false tag_memberships: - description: A dictionary containing detailed - configuration for managing tag memberships + description: A list of dictionaries, each containing + detailed configuration for managing tag memberships for devices and interfaces. - type: dict + type: list + elements: dict suboptions: tags: description: > @@ -406,10 +408,10 @@ state: merged config_verify: false config: - - tag: - name: Server_Connected_Devices_and_Ports - description: "Tag for devices and interfaces - connected to servers" + - tags: + - name: Server_Connected_Devices_and_Ports + description: "Tag for devices and interfaces + connected to servers" # For creating/updating a tag with device rules. - name: Create a tag for border devices in the 9300 series. @@ -436,20 +438,20 @@ state: merged config_verify: false config: - - tag: - name: Border_9300_Tag - description: Tag for border devices belonging - to the Cisco Catalyst 9300 family. - device_rules: - rule_descriptions: - - rule_name: device_name - search_pattern: contains - value: Border - operation: ILIKE - - rule_name: device_series - search_pattern: ends_with - value: 9300 - operation: ILIKE + - tags: + - name: Border_9300_Tag + description: Tag for border devices belonging + to the Cisco Catalyst 9300 family. + device_rules: + rule_descriptions: + - rule_name: device_name + search_pattern: contains + value: Border + operation: ILIKE + - rule_name: device_series + search_pattern: ends_with + value: 9300 + operation: ILIKE # For creating/updating a tag with port rules. - name: Create a tag for high-speed server-connected interfaces. @@ -476,25 +478,50 @@ state: merged config_verify: false config: - - tag: - name: HighSpeed_Server_Interfaces - description: Tag for 10G interfaces connected - to servers. - port_rules: - scope_description: - scope_category: TAG - scope_members: - - NY_SERVER_TAG - - SJC_SERVER_TAG - rule_descriptions: - - rule_name: speed - search_pattern: equals - value: "10000" - operation: ILIKE - - rule_name: port_name - search_pattern: contains - value: TenGigabitEthernet1/0/1 - operation: ILIKE + - tags: + - name: HighSpeed_Server_Interfaces + description: Tag for 10G interfaces connected + to servers. + port_rules: + scope_description: + scope_category: TAG + scope_members: + - NY_SERVER_TAG + - SJC_SERVER_TAG + rule_descriptions: + - rule_name: speed + search_pattern: equals + value: "10000" + operation: ILIKE + - rule_name: port_name + search_pattern: contains + value: TenGigabitEthernet1/0/1 + operation: ILIKE +# For creating multiple tags in a single config entry. +- name: Create multiple tags in one config entry. + hosts: dnac_servers + vars_files: + - credentials.yml + gather_facts: false + tasks: + - name: Create two tags at once + cisco.dnac.tags_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_version: "{{ dnac_version }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: true + dnac_log_level: DEBUG + state: merged + config_verify: false + config: + - tags: + - name: DC_Core_Devices + description: "Core devices in data center" + - name: DC_Access_Devices # For updating the scope description of a tag with port rules: - name: Update scope description for tagged server-connected interfaces. @@ -521,17 +548,17 @@ state: merged config_verify: false config: - - tag: - name: Server_Connected_Interfaces - description: Tag for interfaces on devices - connected to servers, scoped to specific - sites. - port_rules: - scope_description: - scope_category: SITE - scope_members: - - Global/USA - - Global/INDIA + - tags: + - name: Server_Connected_Interfaces + description: Tag for interfaces on devices + connected to servers, scoped to specific + sites. + port_rules: + scope_description: + scope_category: SITE + scope_members: + - Global/USA + - Global/INDIA # For updating rule descriptions of a tag with port rules: - name: Update port rule descriptions for server-connected interfaces. @@ -558,20 +585,20 @@ state: merged config_verify: false config: - - tag: - name: Server_Connected_Interfaces - description: Tag for interfaces on devices - connected to servers. - port_rules: - rule_descriptions: - - rule_name: speed - search_pattern: contains - value: "100000" - operation: ILIKE - - rule_name: port_name - search_pattern: equals - value: TenGigabitEthernet1/0/1 - operation: ILIKE + - tags: + - name: Server_Connected_Interfaces + description: Tag for interfaces on devices + connected to servers. + port_rules: + rule_descriptions: + - rule_name: speed + search_pattern: contains + value: "100000" + operation: ILIKE + - rule_name: port_name + search_pattern: equals + value: TenGigabitEthernet1/0/1 + operation: ILIKE # To assign tags to devices/ports (Remove port_names list to assign tags to devices.) - name: Assign tags to devices or interfaces. hosts: dnac_servers @@ -597,48 +624,47 @@ config_verify: false config: - tag_memberships: - tags: - - High_Speed_Interfaces - device_details: - - ip_addresses: - - 10.197.156.97 - - 10.197.156.98 - - 10.197.156.99 - hostnames: - - SJC_Border1 - - SJC_Border2 - - NY_Border1 - mac_addresses: - - e4:38:7e:42:bc:00 - - 6c:d6:e3:75:5a:e0 - - 34:5d:a8:3b:d8:e0 - serial_numbers: - - SAD055006NE - - SAD04350EEU - - SAD055108C2 - port_names: - - FortyGigabitEthernet1/1/1 - - FortyGigabitEthernet1/1/2 - - tag_memberships: - tags: - - Server_Connected_Devices - device_details: - - ip_addresses: - - 10.197.156.97 - - 10.197.156.98 - - 10.197.156.99 - hostnames: - - SJC_Border1 - - SJC_Border2 - - NY_Border1 - mac_addresses: - - e4:38:7e:42:bc:00 - - 6c:d6:e3:75:5a:e0 - - 34:5d:a8:3b:d8:e0 - serial_numbers: - - SAD055006NE - - SAD04350EEU - - SAD055108C2 + - tags: + - High_Speed_Interfaces + device_details: + - ip_addresses: + - 10.197.156.97 + - 10.197.156.98 + - 10.197.156.99 + hostnames: + - SJC_Border1 + - SJC_Border2 + - NY_Border1 + mac_addresses: + - e4:38:7e:42:bc:00 + - 6c:d6:e3:75:5a:e0 + - 34:5d:a8:3b:d8:e0 + serial_numbers: + - SAD055006NE + - SAD04350EEU + - SAD055108C2 + port_names: + - FortyGigabitEthernet1/1/1 + - FortyGigabitEthernet1/1/2 + - tags: + - Server_Connected_Devices + device_details: + - ip_addresses: + - 10.197.156.97 + - 10.197.156.98 + - 10.197.156.99 + hostnames: + - SJC_Border1 + - SJC_Border2 + - NY_Border1 + mac_addresses: + - e4:38:7e:42:bc:00 + - 6c:d6:e3:75:5a:e0 + - 34:5d:a8:3b:d8:e0 + serial_numbers: + - SAD055006NE + - SAD04350EEU + - SAD055108C2 # To assign tags to devices or ports under specific sites (Remove port_namesto assign tags to devices only.) - name: Assign tags to devices or interfaces within a specific site. @@ -666,20 +692,19 @@ config_verify: false config: - tag_memberships: - tags: - - High_Speed_Interfaces - site_details: - - site_names: - - Global/INDIA - port_names: - - FortyGigabitEthernet1/1/1 - - FortyGigabitEthernet1/1/2 - - tag_memberships: - tags: - - Server_Connected_Devices - site_details: - - site_names: - - Global/INDIA + - tags: + - High_Speed_Interfaces + site_details: + - site_names: + - Global/INDIA + port_names: + - FortyGigabitEthernet1/1/1 + - FortyGigabitEthernet1/1/2 + - tags: + - Server_Connected_Devices + site_details: + - site_names: + - Global/INDIA # Deleting a tag. - name: Delete a Tag. hosts: dnac_servers @@ -704,8 +729,8 @@ state: deleted config_verify: false config: - - tag: - name: Server_Connected_Devices + - tags: + - name: Server_Connected_Devices # Force Deleting a tag. # It will remove all the dynamic and static members from the tag and delete the tag. - name: Force delete a Tag. @@ -731,9 +756,9 @@ state: deleted config_verify: false config: - - tag: - name: Server_Connected_Devices - force_delete: true + - tags: + - name: Server_Connected_Devices + force_delete: true # For deleting rule descriptions of a tag with device rules. - name: Delete rule description of a tag with device rules @@ -760,14 +785,14 @@ state: deleted config_verify: false config: - - tag: - name: Catalyst_Access_Tag - device_rules: - rule_descriptions: - - rule_name: device_family - search_pattern: ends_with - value: 9300 - operation: ILIKE + - tags: + - name: Catalyst_Access_Tag + device_rules: + rule_descriptions: + - rule_name: device_family + search_pattern: ends_with + value: 9300 + operation: ILIKE # For deleting scope members of a tag with port rules. - name: Delete scope members of a tag with port rules hosts: dnac_servers @@ -793,15 +818,15 @@ state: deleted config_verify: false config: - - tag: - name: Catalyst_Site_Tag - description: Tag for managing site-based - configurations - port_rules: - scope_description: - scope_category: SITE - scope_members: - - Global/INDIA + - tags: + - name: Catalyst_Site_Tag + description: Tag for managing site-based + configurations + port_rules: + scope_description: + scope_category: SITE + scope_members: + - Global/INDIA # For deleting rule descriptions of a tag with port rules. - name: Delete rule descriptions of a tag with port rules @@ -828,20 +853,20 @@ state: deleted config_verify: false config: - - tag: - name: Catalyst_Port_Tag - description: Tag for high-speed ports - and interface rules - port_rules: - rule_descriptions: - - rule_name: speed - search_pattern: equals - value: "10000" - operation: ILIKE - - rule_name: port_name - search_pattern: contains - value: tengig/1/0/1 - operation: ILIKE + - tags: + - name: Catalyst_Port_Tag + description: Tag for high-speed ports + and interface rules + port_rules: + rule_descriptions: + - rule_name: speed + search_pattern: equals + value: "10000" + operation: ILIKE + - rule_name: port_name + search_pattern: contains + value: tengig/1/0/1 + operation: ILIKE # For Deleting tags from devices/ports (Remove port_names to delete tags from devices) - name: Delete tags from members. hosts: dnac_servers @@ -867,40 +892,39 @@ config_verify: false config: - tag_memberships: - tags: - - Catalyst_Port_Tag - device_details: - - ip_addresses: - - 10.197.156.97 - - 10.197.156.98 - hostnames: - - SJC_Border1 - - NY_Border1 - mac_addresses: - - e4:38:7e:42:bc:00 - - 6c:d6:e3:75:5a:e0 - serial_numbers: - - SAD055006NE - - SAD04350EEU - port_names: - - TenGigabitEthernet1/0/1 - - TenGigabitEthernet1/0/2 - - tag_memberships: - tags: - - Catalyst_Device_Tag - device_details: - - ip_addresses: - - 10.197.156.97 - - 10.197.156.98 - hostnames: - - SJC_Border1 - - NY_Border1 - mac_addresses: - - e4:38:7e:42:bc:00 - - 6c:d6:e3:75:5a:e0 - serial_numbers: - - SAD055006NE - - SAD04350EEU + - tags: + - Catalyst_Port_Tag + device_details: + - ip_addresses: + - 10.197.156.97 + - 10.197.156.98 + hostnames: + - SJC_Border1 + - NY_Border1 + mac_addresses: + - e4:38:7e:42:bc:00 + - 6c:d6:e3:75:5a:e0 + serial_numbers: + - SAD055006NE + - SAD04350EEU + port_names: + - TenGigabitEthernet1/0/1 + - TenGigabitEthernet1/0/2 + - tags: + - Catalyst_Device_Tag + device_details: + - ip_addresses: + - 10.197.156.97 + - 10.197.156.98 + hostnames: + - SJC_Border1 + - NY_Border1 + mac_addresses: + - e4:38:7e:42:bc:00 + - 6c:d6:e3:75:5a:e0 + serial_numbers: + - SAD055006NE + - SAD04350EEU # For deleting tags from devices/ports under specific sites (Remove port_names to delete tags from devices) - name: Delete tags from members within a specific sites. hosts: dnac_servers @@ -927,20 +951,19 @@ config_verify: true config: - tag_memberships: - tags: - - Catalyst_Device_Tag - site_details: - - site_names: - - Global/INDIA - - tag_memberships: - tags: - - Catalyst_Port_Tag - site_details: - - site_names: - - Global/INDIA - port_names: - - TenGigabitEthernet1/0/1 - - TenGigabitEthernet1/0/2 + - tags: + - Catalyst_Device_Tag + site_details: + - site_names: + - Global/INDIA + - tags: + - Catalyst_Port_Tag + site_details: + - site_names: + - Global/INDIA + port_names: + - TenGigabitEthernet1/0/1 + - TenGigabitEthernet1/0/2 """ RETURN = r""" @@ -1037,8 +1060,8 @@ def validate_input(self): """ validation_schema = { - "tag": { - "type": "dict", + "tags": { + "type": "list", "elements": "dict", "name": {"type": "str", "required": True}, "description": {"type": "str"}, @@ -1106,7 +1129,8 @@ def validate_input(self): }, }, "tag_memberships": { - "type": "dict", + "type": "list", + "elements": "dict", "tags": {"type": "list", "elements": "str", "required": True}, "device_details": { "type": "list", @@ -1952,8 +1976,8 @@ def get_want(self, config): parameters. The processed results are stored in the `want` dictionary, which includes: - - 'tag': The validated tag configuration (if provided). - - 'tag_memberships': The validated tag membership details (if provided). + - 'tags': A list of validated tag configurations (if provided). + - 'tag_memberships': A list of validated tag membership details (if provided). The `want` dictionary is assigned to the instance for further processing. """ @@ -1967,24 +1991,26 @@ def get_want(self, config): want = {} - tag = config.get("tag") - tag_memberships = config.get("tag_memberships") + tags = config.get("tags") + tag_memberships_list = config.get("tag_memberships") - if not tag and not tag_memberships: + if not tags and not tag_memberships_list: self.msg = "No input provided for tag operations or updating tag memberships in Cisco Catalyst Center." self.set_operation_result( "failed", False, self.msg, "ERROR" ).check_return_status() # Process tags - if tag: - want["tag"] = self.process_tag(tag) + if tags: + want["tags"] = [self.process_tag(tag) for tag in tags] else: self.log("Tag config not provided.", "DEBUG") # Process tag memberships - if tag_memberships: - want["tag_memberships"] = self.process_tag_memberships(tag_memberships) + if tag_memberships_list: + want["tag_memberships"] = [ + self.process_tag_memberships(tm) for tm in tag_memberships_list + ] else: self.log("Tag memberships config not provided.", "DEBUG") @@ -1996,17 +2022,19 @@ def get_want(self, config): def get_have(self, config): """ - Retrieves the tag ID based on the provided config, and stores it in the 'have' dictionary. + Retrieves the tag IDs based on the provided config, and stores them in the 'have' dictionary. Args: - config (dict): Configuration dictionary containing the 'tag' key with 'name' as a subkey. + config (dict): Configuration dictionary containing the 'tags' key with a list of tag dicts, + each having 'name' as a subkey. Returns: self: Returns the instance of the class for method chaining. Description: - This method extracts the tag name from the config, retrieves the tag ID, - and stores it in the 'have' dictionary. If the tag ID is not found, it logs an debug message. + This method extracts the tag names from the config, retrieves the tag IDs, + and stores them in the 'have' dictionary keyed by tag name. If the tag ID is + not found, it logs a debug message. """ self.log( @@ -2016,17 +2044,19 @@ def get_have(self, config): "DEBUG", ) have = {} - tag = config.get("tag") - if tag: - tag_name = tag.get("name") - tag_info = self.get_tag_info(tag_name) - if not tag_info: - self.msg = "Tag Details for {0} are not available in Cisco Catalyst Center".format( - tag_name - ) - self.log(self.msg, "DEBUG") - else: - have["tag_info"] = tag_info + tags = config.get("tags") + if tags: + have["tag_info"] = {} + for tag in tags: + tag_name = tag.get("name") + tag_info = self.get_tag_info(tag_name) + if not tag_info: + self.msg = "Tag Details for {0} are not available in Cisco Catalyst Center".format( + tag_name + ) + self.log(self.msg, "DEBUG") + else: + have["tag_info"][tag_name] = tag_info self.have = have self.msg = "Successfully retrieved tag details from Cisco Catalyst Center." @@ -4967,7 +4997,7 @@ def process_tag_merged(self, tag): ) self.initialize_batch_size_values(tag) - tag_in_ccc = self.have.get("tag_info") + tag_in_ccc = self.have.get("tag_info", {}).get(tag_name) if not tag_in_ccc: self.log( "Creating Tag: {0} with config: {1}".format(tag_name, self.pprint(tag)), @@ -5062,13 +5092,15 @@ def get_diff_merged(self, config): "INFO", ) - tag = self.want.get("tag") + tag = self.want.get("tags") tag_memberships = self.want.get("tag_memberships") if tag: - self.process_tag_merged(tag) + for t in tag: + self.process_tag_merged(t) if tag_memberships: - self.process_tag_memberships_merged(tag_memberships) + for tm in tag_memberships: + self.process_tag_memberships_merged(tm) self.msg = "Get Diff Merged Completed Successfully" return self @@ -5182,7 +5214,7 @@ def process_tag_deleted(self, tag): self.log("Starting Tag Deletion for the Tag '{0}'".format(tag_name), "DEBUG") self.initialize_batch_size_values(tag) - tag_in_ccc = self.have.get("tag_info") + tag_in_ccc = self.have.get("tag_info", {}).get(tag_name) if not tag_in_ccc: self.log( "Not able to perform delete operations. Tag '{0}' as it is not present in Cisco Catalyst Center.".format( @@ -5265,14 +5297,16 @@ def get_diff_deleted(self, config): "INFO", ) - tag = self.want.get("tag") + tag = self.want.get("tags") tag_memberships = self.want.get("tag_memberships") if tag: - self.process_tag_deleted(tag) + for t in tag: + self.process_tag_deleted(t) if tag_memberships: - self.process_tag_membership_deleted(tag_memberships) + for tm in tag_memberships: + self.process_tag_membership_deleted(tm) self.msg = "Get Diff Deleted Completed Successfully" @@ -5463,10 +5497,10 @@ def verify_tag_diff_merged(self, tag): self.log("Verifying the tag details for the playbook operation", "INFO") - tag_name = tag.get("name") + tag_name = tag.get("new_name") or tag.get("name") verify_diff = True - tag_in_ccc = self.have.get("tag_info") + tag_in_ccc = self.have.get("tag_info", {}).get(tag_name) if not tag_in_ccc: verify_diff = False self.log( @@ -5525,39 +5559,43 @@ def verify_diff_merged(self, config): "DEBUG", ) - tag_config_data = config.get("tag", {}) - if tag_config_data: - new_tag_name = tag_config_data.get("new_name") + tags_config_data = config.get("tags") - if new_tag_name: - current_name = self.want.get("tag", {}).get("name", "Unknown") - self.log( - f"Updating tag name: current name='{current_name}', new name='{new_tag_name}'.", - "DEBUG", - ) - # Update the tag name in the config - config["tag"]["name"] = config["tag"].get("new_name") + if tags_config_data: + self.log("Updating the tag names in the config data if 'new_name' is provided for verification.", "DEBUG") + for tag_config_data in tags_config_data: + new_tag_name = tag_config_data.get("new_name") + if new_tag_name: + current_name = tag_config_data.get("name", "Unknown") + self.log( + f"Updating tag name: current name='{current_name}', new name='{new_tag_name}'.", + "DEBUG", + ) + # Update the tag name in the config entry + tag_config_data["name"] = new_tag_name self.get_have(config).check_return_status() - tag = self.want.get("tag") + tag = self.want.get("tags") tag_memberships = self.want.get("tag_memberships") verify_diff = True if tag: - verify_diff &= self.verify_tag_diff_merged(tag) + for t in tag: + verify_diff &= self.verify_tag_diff_merged(t) if tag_memberships: - membership_verify_diff = self.verify_tag_membership_diff(tag_memberships) - if membership_verify_diff: - self.log( - "tag memberships Details present in playbook and Cisco Catalyst Center are same.", - "DEBUG", - ) - else: - verify_diff = False - self.log( - "tag memberships Details present in playbook and Cisco Catalyst Center does not match. Playbook operation might be unsuccessful", - "WARNING", - ) + for tm in tag_memberships: + membership_verify_diff = self.verify_tag_membership_diff(tm) + if membership_verify_diff: + self.log( + "tag memberships Details present in playbook and Cisco Catalyst Center are same.", + "DEBUG", + ) + else: + verify_diff = False + self.log( + "tag memberships Details present in playbook and Cisco Catalyst Center does not match. Playbook operation might be unsuccessful", + "WARNING", + ) if verify_diff: self.msg = "Playbook operation is successful. Verification Completed" @@ -5590,7 +5628,7 @@ def verify_tag_diff_deleted(self, tag): "DEBUG", ) - tag_in_ccc = self.have.get("tag_info") + tag_in_ccc = self.have.get("tag_info", {}).get(tag_name) force_delete = tag.get("force_delete") verify_diff = True if force_delete: @@ -5692,26 +5730,28 @@ def verify_diff_deleted(self, config): ) self.get_have(config).check_return_status() - tag = self.want.get("tag") + tag = self.want.get("tags") tag_memberships = self.want.get("tag_memberships") verify_diff = True if tag: - verify_diff &= self.verify_tag_diff_deleted(tag) + for t in tag: + verify_diff &= self.verify_tag_diff_deleted(t) if tag_memberships: - membership_verify_diff = self.verify_tag_membership_diff(tag_memberships) - if membership_verify_diff: - self.log( - "tag memberships Details present in playbook and Cisco Catalyst Center are same.", - "DEBUG", - ) - else: - verify_diff = False - self.log( - "tag memberships Details present in playbook and Cisco Catalyst Center does not match. Playbook operation might be unsuccessful", - "WARNING", - ) + for tm in tag_memberships: + membership_verify_diff = self.verify_tag_membership_diff(tm) + if membership_verify_diff: + self.log( + "tag memberships Details present in playbook and Cisco Catalyst Center are same.", + "DEBUG", + ) + else: + verify_diff = False + self.log( + "tag memberships Details present in playbook and Cisco Catalyst Center does not match. Playbook operation might be unsuccessful", + "WARNING", + ) if verify_diff: self.msg = "Playbook operation is successful. Verification Completed" diff --git a/plugins/modules/template_workflow_manager.py b/plugins/modules/template_workflow_manager.py index 0478b2c65f..e2ee203301 100644 --- a/plugins/modules/template_workflow_manager.py +++ b/plugins/modules/template_workflow_manager.py @@ -1525,6 +1525,10 @@ - While deploying the template to devices, the value for the following resource types can be filled in the resource parameters at RUNTIME- MANAGED_DEVICE_UUID, MANAGED_DEVICE_IP, MANAGED_DEVICE_HOSTNAME, and SITE_UUID. For all other resource types, the value must be provided at DESIGN time in the playbook. + - Sensitive data such as template parameter values, credentials, or other confidential information can be protected + using Ansible Vault. Store sensitive values in a vault-encrypted file and reference them as variables in the playbook. + Use C(ansible-vault encrypt ) to encrypt the file and pass C(--ask-vault-pass) or C(--vault-password-file) + when running the playbook. """ EXAMPLES = r""" @@ -2203,6 +2207,42 @@ device_details: device_ips: - 10.1.1.1 + +- name: Deploy template with sensitive parameter values protected using Ansible Vault + # Step 1 - Create a vault file (e.g., vault_vars.yml) with sensitive values: + # password: "my_password" + # Step 2 - Encrypt it: ansible-vault encrypt vault_vars.yml --ask-vault-pass + # Step 3 - Run the playbook: ansible-playbook playbook.yml --ask-vault-pass + hosts: localhost + vars_files: + - credentials.yml + - vault_vars.yml + gather_facts: false + tasks: + - name: Deploy template with vaulted parameter values + cisco.dnac.template_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_version: "{{ dnac_version }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: true + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: true + config: + - deploy_template: + project_name: "Sample_Project" + template_name: "Sample Template" + force_push: true + template_parameters: + - param_name: "password" + param_value: "{{ password }}" + device_details: + device_ips: + - 10.1.2.1 """ RETURN = r""" diff --git a/plugins/modules/user_role_playbook_config_generator.py b/plugins/modules/user_role_playbook_config_generator.py index b0554479c4..e1ee959a16 100644 --- a/plugins/modules/user_role_playbook_config_generator.py +++ b/plugins/modules/user_role_playbook_config_generator.py @@ -404,17 +404,19 @@ response_2: description: A dictionary with the response returned by the Cisco Catalyst Center returned: always - type: list + type: dict sample: > - "msg": { - "YAML config generation Task failed for module 'user_role_workflow_manager'.": { - "file_path": "/Users/priyadharshini/Downloads/specific_userrole_details_info" - } - }, - "response": { - "YAML config generation Task failed for module 'user_role_workflow_manager'.": { - "file_path": "/Users/priyadharshini/Downloads/specific_userrole_details_info" - } + { + "msg": "YAML configuration file already up-to-date for module 'user_role_workflow_manager'. No changes written.", + "response": { + "components_processed": 2, + "components_skipped": 0, + "configurations_count": 13, + "file_path": "/tmp/specific_userrole_details_info", + "message": "YAML configuration file already up-to-date for module 'user_role_workflow_manager'. No changes written.", + "status": "success" + }, + "status": "success" } """ @@ -930,12 +932,11 @@ def transform_user_role_list(self, user_details): # Check if lookup returned role_id as fallback (not found in cache) if role_name == role_id: self.log( - "Role name lookup returned role_id as fallback for role_id '{0}' " - "(role {1}/{2} for user '{3}') - role not found in cache. " - "This may indicate a deleted or invalid role.".format( - role_id, role_index, len(role_ids), username + "Role value '{0}' for user '{1}' (role {2}/{3}) is already a role " + "name (API returned name directly instead of ID).".format( + role_id, username, role_index, len(role_ids) ), - "WARNING" + "DEBUG" ) # Still count as successful since we have a value successful_lookups += 1 @@ -1031,14 +1032,12 @@ def get_role_name_by_id(self, role_id): ) try: - cache_exists = hasattr(self, '_role_cache') - if not cache_exists: + if not hasattr(self, '_role_cache'): self.log( "Role cache not initialized - triggering cache population via API " "call to retrieve all roles from Catalyst Center", "INFO" ) - if not hasattr(self, '_role_cache'): self._role_cache = {} roles_response = self.dnac._exec( family="user_and_roles", @@ -1098,12 +1097,39 @@ def get_role_name_by_id(self, role_id): ), "DEBUG" ) + self.log( + "Role name lookup using cache for role_id '{0}'".format(role_id), + "DEBUG" + ) + + # First, check if role_id is an actual role ID in the cache + cached_name = self._role_cache.get(role_id) + if cached_name: + self.log( + "Found role name '{0}' for role_id '{1}' in cache".format( + cached_name, role_id + ), + "DEBUG" + ) + return cached_name + + # The API roleList field may return role names directly (e.g. + # "SUPER-ADMIN-ROLE") instead of role IDs. Check whether the + # supplied value already matches a known role name. + if role_id in self._role_cache.values(): + self.log( + "Value '{0}' is already a valid role name (found in cache " + "values) - returning as-is".format(role_id), + "DEBUG" + ) + return role_id + self.log( "Role name lookup completed with cache miss for role_id '{0}' - " - "returning original role_id as fallback".format(role_id), + "returning original value as fallback".format(role_id), "DEBUG" ) - return self._role_cache.get(role_id) + return role_id except Exception as e: self.log("Error getting role name for ID {0}: {1}".format(role_id, str(e)), "ERROR") @@ -2750,7 +2776,6 @@ def yaml_config_generator(self, yaml_config_generator): self.set_operation_result("success", False, no_config_message, "INFO") return self - final_dict = {"config": config_dict} # Create final dictionary structure for YAML self.log( "Creating final dictionary structure for YAML generation with 'config' " @@ -2776,39 +2801,42 @@ def yaml_config_generator(self, yaml_config_generator): ) write_success = self.write_dict_to_yaml(final_dict, file_path, file_mode) - if not write_success: + if write_success is False: self.log( - "YAML file write operation failed - write_dict_to_yaml returned False " - "for file path: {0}".format(file_path), - "ERROR" - ) - - error_message = ( - "Failed to write YAML configuration to file: {0}. Check file " - "permissions, disk space, and path validity.".format(file_path) + "YAML file is already up-to-date - write_dict_to_yaml returned False " + "for file path: {0}. Treating as idempotent success.".format(file_path), + "INFO" ) response_data = { - "message": error_message, - "status": "failed" + "components_processed": components_processed, + "components_skipped": components_skipped, + "configurations_count": total_configurations, + "file_path": file_path, + "message": ( + "YAML configuration file already up-to-date for module '{0}'. " + "No changes written.".format(self.module_name) + ), + "status": "success", } self.log( - "Error response data prepared: {0}".format(response_data), + "Idempotent response data prepared: {0}".format(response_data), "DEBUG" ) self.log( - "Setting operation result to failed with changed=False - no file created", + "Setting operation result to success with changed=False for idempotent run", "DEBUG" ) - self.set_operation_result("failed", False, error_message, "ERROR") + self.set_operation_result("success", False, response_data["message"], "INFO") self.msg = response_data self.result["response"] = response_data self.log( - "YAML configuration generation workflow failed during file write operation", - "ERROR" + "YAML configuration generation workflow completed with no changes " + "(idempotent success).", + "INFO" ) return self @@ -2885,7 +2913,6 @@ def get_want(self, config, state): "DEBUG" ) - self.validate_params(config) self.log( "Calling validate_params to validate configuration structure and values", "DEBUG" diff --git a/plugins/modules/user_role_workflow_manager.py b/plugins/modules/user_role_workflow_manager.py index 597c47f9bf..83b0e29fc5 100644 --- a/plugins/modules/user_role_workflow_manager.py +++ b/plugins/modules/user_role_workflow_manager.py @@ -75,9 +75,10 @@ - The password for the user account, which must adhere to specified complexity requirements. - - Must contain at least one special character, - one capital letter, one lowercase letter, - and a minimum length of 8 characters. + - Must be at least 9 characters long and include + at least three of the following character + types - lowercase letters, uppercase letters, + digits, and special characters. - Required for creating a new user account. type: str password_update: @@ -93,7 +94,7 @@ or set it to `false`. - Ensure this parameter is correctly set to avoid unnecessary updates or errors. - type: str + type: bool role_list: description: - A list of role names to be assigned @@ -1178,6 +1179,23 @@ def validate_input_yml(self, user_role_details): user["password"] = encrypt_password_response.get("encrypt_password") + # Validate password_update type before validate_list_of_dicts + # coerces string values to bool silently + for user in user_role_details: + pw_update = user.get("password_update") + if pw_update is not None and not isinstance(pw_update, bool): + self.msg = ( + "Invalid type for 'password_update': expected bool " + "(true/false), got '{0}' of type '{1}'. " + "Please use an unquoted boolean in the playbook " + "(e.g., password_update: true).".format( + pw_update, type(pw_update).__name__ + ) + ) + self.log(self.msg, "ERROR") + self.status = "failed" + return self + if ( user_role_details[0].get("username") is not None or user_role_details[0].get("email") is not None @@ -1254,24 +1272,24 @@ def validate_password(self, password, error_messages): Returns: None: This function does not return a value, but it may append an error message to `error_messages` if the password is invalid. Criteria: - - The password must be 9 to 20 characters long. + - The password must be at least 9 characters long. - The password must include characters from at least three of the following classes: lowercase letters, uppercase letters, digits, and special characters. """ meets_character_requirements = False password_criteria_message = ( - "The password must be 9 to 20 characters long and include at least three of the following " + "The password must be at least 9 characters long and include at least three of the following " "character types: lowercase letters, uppercase letters, digits, and special characters. " "Additionally, the password must not contain repetitive or sequential characters." ) self.log(password_criteria_message, "DEBUG") password_regexs = [ - re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?!.*[\W_]).{9,20}$"), - re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_])(?!.*\d).{9,20}$"), - re.compile(r"^(?=.*[a-z])(?=.*\d)(?=.*[\W_])(?!.*[A-Z]).{9,20}$"), - re.compile(r"^(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*[a-z]).{9,20}$"), - re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{9,20}$"), + re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?!.*[\W_]).{9,}$"), + re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_])(?!.*\d).{9,}$"), + re.compile(r"^(?=.*[a-z])(?=.*\d)(?=.*[\W_])(?!.*[A-Z]).{9,}$"), + re.compile(r"^(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*[a-z]).{9,}$"), + re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{9,}$"), ] self.log("Password meets character type and length requirements.", "INFO") @@ -1554,12 +1572,15 @@ def valid_role_config_parameters(self, role_config): self.status = "success" return self - def valid_user_config_parameters(self, user_config): + def valid_user_config_parameters(self, user_config, is_create=False): """ Additional validation for the create user configuration payload. Parameters: - self (object): An instance of a class used for interacting with Cisco Catalyst Center. - user_config (dict): A dictionary containing the input configuration details. + - is_create (bool): When True, enforces that the password field is + present and meets minimum-length requirements (API mandates + password for new user creation). Returns: The method returns an instance of the class with updated attributes: - self.msg: A message describing the validation result. @@ -1594,6 +1615,20 @@ def valid_user_config_parameters(self, user_config): password = user_config.get("password") + self.log( + "Checking whether password is required for user creation.", "DEBUG" + ) + + if is_create and not password: + error_messages.append( + "password: A password is required when creating a new user " + "account. Please provide a password that is at least 9 " + "characters long and includes at least three of the following " + "character types: lowercase letters, uppercase letters, digits, " + "and special characters. Additionally, the password must not " + "contain repetitive or sequential characters." + ) + if password: decrypt_password_response = self.decrypt_password( password, self.key.get("generate_key") @@ -1631,15 +1666,38 @@ def valid_user_config_parameters(self, user_config): "Password decrypted, validated, and re-encrypted successfully.", "DEBUG" ) - username_regex = re.compile(r"^[A-Za-z0-9@._-]{3,50}$") - username_regex_msg = "The username must not contain any special characters and must be 3 to 50 characters long." username = user_config.get("username") - self.validate_string_field( - username, - username_regex, - "username: '{0}' {1}".format(username, username_regex_msg), - error_messages, - ) + self.log("Validating username field presence and format.", "DEBUG") + if not username: + error_messages.append( + "username: The 'username' field is required for user " + "create, update, and delete operations. Please provide " + "a valid username in the playbook." + ) + else: + username_regex = re.compile(r"^[A-Za-z0-9@._-]{3,50}$") + username_regex_msg = ( + "The username must not contain any special characters " + "and must be 3 to 50 characters long." + ) + self.validate_string_field( + username, + username_regex, + "username: '{0}' {1}".format(username, username_regex_msg), + error_messages, + ) + + password_update = user_config.get("password_update") + self.log("Validating password_update field type.", "DEBUG") + + if password_update is not None and not isinstance(password_update, bool): + error_messages.append( + "password_update: Expected a boolean value (true/false), " + "but got '{0}' of type '{1}'. Please use a boolean value " + "without quotes in the playbook (e.g., password_update: true).".format( + password_update, type(password_update).__name__ + ) + ) if user_config.get("role_list"): param_spec = dict(type="list", elements="str") @@ -1877,7 +1935,7 @@ def get_diff_merged(self, config): } else: # Create the user - self.valid_user_config_parameters(config).check_return_status() + self.valid_user_config_parameters(config, is_create=True).check_return_status() self.log("Creating user with config {0}".format(str(config)), "DEBUG") user_params = self.want @@ -1916,7 +1974,7 @@ def get_diff_merged(self, config): } if task_response and "error_message" not in task_response: - self.log("Task respoonse {0}".format(str(task_response)), "INFO") + self.log("Task response {0}".format(str(task_response)), "INFO") responses["operation"] = {"response": task_response} self.msg = responses self.result["response"] = self.msg diff --git a/plugins/modules/wired_campus_automation_playbook_config_generator.py b/plugins/modules/wired_campus_automation_playbook_config_generator.py index 3ece78cef5..61424126e6 100644 --- a/plugins/modules/wired_campus_automation_playbook_config_generator.py +++ b/plugins/modules/wired_campus_automation_playbook_config_generator.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -__author__ = "Rugvedi Kapse, Madhan Sankaranarayanan" +__author__ = "Rugvedi Kapse, Vivek Raj, Madhan Sankaranarayanan" DOCUMENTATION = r""" --- @@ -26,61 +26,61 @@ - cisco.dnac.workflow_manager_params author: - Rugvedi Kapse (@rukapse) +- Vivek Raj (@vivekraj2000) - Madhan Sankaranarayanan (@madhansansel) options: - config_verify: - description: Set to True to verify the Cisco Catalyst - Center after applying the playbook config. - type: bool - default: false state: description: The desired state of Cisco Catalyst Center after module execution. type: str choices: [gathered] default: gathered + file_path: + description: + - Absolute or relative path for YAML configuration file output. + - If not provided, a default filename is generated in the + current working directory using the pattern + C(wired_campus_automation_playbook_config_.yml). + - Example default filename + 'wired_campus_automation_playbook_config_2026-02-27_14-31-46.yml' + - Directory created automatically if path does not exist. + - Supports YAML file extension (.yml or .yaml). + type: str + file_mode: + description: + - Controls how the generated configuration is written to + the output YAML file. + - C(overwrite) replaces the existing file content entirely. + - C(append) appends the generated YAML content to the + existing file. If the file does not exist, it is created. + type: str + choices: ["overwrite", "append"] + default: "overwrite" config: description: - - A list of filters for generating YAML playbook compatible with the 'wired_campus_automation_workflow_manager' - module. - - Filters specify which components and devices to include in the YAML configuration file. - - Global filters identify target devices by IP address, hostname, or serial number. - - Component-specific filters allow selection of specific layer2 features and detailed filtering. - type: list - elements: dict - required: true + - A dictionary of filters for generating a YAML playbook + compatible with the + 'wired_campus_automation_workflow_manager' module. + - If omitted, all managed devices and all supported + layer2 features are extracted automatically. + - Global filters identify target devices by IP address, + hostname, or serial number. + - Component-specific filters select which layer2 features + to extract and allow VLAN ID or interface name filtering. + - Global filters and component-specific filters are + combined with AND logic. Omitting a filter means no + restriction on that attribute. + type: dict + required: false suboptions: - generate_all_configurations: - description: - - When set to True, automatically generates YAML configurations for all devices and all supported layer2 features. - - This mode discovers all managed devices in Cisco Catalyst Center and extracts all supported configurations. - - When enabled, the config parameter becomes optional and will use default values if not provided. - - A default filename will be generated automatically if file_path is not specified. - - This is useful for complete brownfield infrastructure discovery and documentation. - - IMPORTANT NOTE - Currently, this module only supports layer2 configurations. - When generate_all_configurations is enabled, it will attempt to retrieve layer2 configurations - from ALL managed devices without filtering for layer2 capability. - This may result in API errors for devices that do not support layer2 configuration features - (such as older switch models, routers, wireless controllers, etc.). - It is recommended to use specific device filters (ip_address_list, hostname_list, - or serial_number_list) to target only layer2-capable devices when not using generate_all_configurations mode. - - Supported layer2 devices include Catalyst 9000 series switches (9200/9300/9350/9400/9500/9600) - and IE series switches (IE3400/IE3400H/IE3500/IE9300) running IOS-XE 17.3 or higher. - type: bool - required: false - default: false - file_path: - description: - - Path where the YAML configuration file will be saved. - - If not provided, the file will be saved in the current working directory with - a default file name C(wired_campus_automation_playbook_config_.yml). - - For example, C(wired_campus_automation_playbook_config_2025-04-22_21-43-26.yml). - type: str - required: false global_filters: description: - - Global filters to apply when generating the YAML configuration file. - - These filters identify which network devices to extract configurations from. - - At least one filter type must be specified to identify target devices. + - Filters that identify which network devices to extract + configurations from. + - If no global filters are specified, all managed devices + in Cisco Catalyst Center are targeted. + - When multiple filter types are provided, only the + highest-priority type is used (IP > serial > hostname). + Lower-priority filters are ignored. type: dict required: false suboptions: @@ -131,54 +131,66 @@ - Future versions may support additional component types. type: list elements: str + choices: ["layer2_configurations"] required: false - layer2_features: + layer2_configurations: description: - - List of specific layer2 features to extract from devices. - - Valid values are ["vlans", "cdp", "lldp", "stp", "vtp", "dhcp_snooping", - "igmp_snooping", "mld_snooping", "authentication", "logical_ports", "port_configuration"] - - If not specified, all supported layer2 features will be extracted. - - Example ["vlans", "stp", "cdp"] to extract only VLAN, STP, and CDP configurations. + - Filters specific to layer2 configurations. + - Allows selection of specific layer2 features and detailed filtering options. + - If not specified, all supported layer2 features and their configurations will be extracted. type: list - elements: str - required: false - choices: ["vlans", "cdp", "lldp", "stp", "vtp", "dhcp_snooping", - "igmp_snooping", "mld_snooping", "authentication", - "logical_ports", "port_configuration"] - vlans: - description: - - Specific VLAN filtering options. - - Allows extraction of only specific VLANs based on VLAN IDs. - - If not specified, all VLANs configured on the device will be extracted. - type: dict + elements: dict required: false suboptions: - vlan_ids_list: + layer2_features: description: - - List of specific VLAN IDs to extract from devices. - - Each VLAN ID must be between 1 and 4094. - - Only VLANs in this list will be included in the generated configuration. - - Example ["10", "20", "100", "200"] to extract only these specific VLANs. + - List of specific layer2 features to extract from devices. + - Valid values are ["vlans", "cdp", "lldp", "stp", "vtp", "dhcp_snooping", + "igmp_snooping", "mld_snooping", "authentication", "logical_ports", "port_configuration"] + - If not specified, all supported layer2 features will be extracted. + - Example C(["vlans", "stp", "cdp"]) to extract + only VLAN, STP, and CDP configurations. type: list elements: str required: false - port_configuration: - description: - - Specific port configuration filtering options. - - Allows extraction of configurations for only specific interfaces. - - If not specified, all configured interfaces will be extracted. - type: dict - required: false - suboptions: - interface_names_list: + choices: ["vlans", "cdp", "lldp", "stp", "vtp", "dhcp_snooping", + "igmp_snooping", "mld_snooping", "authentication", + "logical_ports", "port_configuration"] + vlans: description: - - List of specific interface names to extract configurations from. - - Interface names must match the format used by the device. - - Example ["GigabitEthernet1/0/1", "GigabitEthernet1/0/2", "TenGigabitEthernet1/0/1"] - - Only interfaces in this list will have their configurations extracted. - type: list - elements: str + - Specific VLAN filtering options. + - Allows extraction of only specific VLANs based on VLAN IDs. + - If not specified, all VLANs configured on the device will be extracted. + type: dict + required: false + suboptions: + vlan_ids_list: + description: + - List of specific VLAN IDs to extract from devices. + - Each VLAN ID must be between 1 and 4094. + - Only VLANs in this list will be included in the generated configuration. + - Example C(["10", "20", "100", "200"]) to + extract only these specific VLANs. + type: list + elements: str + required: false + port_configuration: + description: + - Specific port configuration filtering options. + - Allows extraction of configurations for only specific interfaces. + - If not specified, all configured interfaces will be extracted. + type: dict required: false + suboptions: + interface_names_list: + description: + - List of specific interface names to extract configurations from. + - Interface names must match the format used by the device. + - Example C(["GigabitEthernet1/0/1", "TenGigabitEthernet1/0/1"]). + - Only interfaces in this list will have their configurations extracted. + type: list + elements: str + required: false requirements: - dnacentersdk >= 2.10.10 - python >= 3.9 @@ -189,6 +201,40 @@ - Paths used are - GET /dna/intent/api/v1/network-device - GET /dna/intent/api/v1/networkDevices/${id}/configFeatures/deployed/layer2/${feature} +- Auto-population of C(components_list) - + If component-specific filters (such as 'projects' or 'configuration_templates') are provided + without explicitly including them in 'components_list', those components will be + automatically added to 'components_list'. This simplifies configuration by eliminating + the need to redundantly specify components in both places. +- Example of auto-population behavior - + If you provide filters for 'projects' without including 'projects' in 'components_list', + the module will automatically add 'projects' to 'components_list' before processing. + This allows you to write more concise playbooks. +- Module result behavior (changed/ok/failed) - + The module result reflects local file state only, not Catalyst Center state. + In overwrite mode, the full generated YAML content is compared against the + existing file after excluding generated header comment lines. In append mode, + only the last YAML document in the file is compared against the newly generated + configuration. If a file contains multiple config entries from previous appends, + only the most recent entry is used for the idempotency check. + changed=true (status success) means the generated YAML configuration differs + from the existing output file (or the file does not exist). The file was + written and the configuration was updated. + changed=false (status ok) means the generated YAML configuration matches the + existing output file content. The write was skipped as the file is + already up-to-date. + failed=true (status failed) means the module encountered a validation error, + API failure, or file write error. No file was written or modified. + Re-running with identical inputs and unchanged Catalyst Center state + will produce changed=false, ensuring idempotent playbook behavior. + If append mode creates multiple config entries in the + generated file, replaying the file as config in the workflow + manager module applies only the last config entry because + yaml.safe_load uses last-key-wins semantics for duplicate + keys in a single YAML document. +seealso: +- module: cisco.dnac.wired_campus_automation_workflow_manager + description: Module for managing wired campus automation workflows in Cisco Catalyst Center. """ EXAMPLES = r""" @@ -206,8 +252,7 @@ # dnac_log: true # dnac_log_level: "{{dnac_log_level}}" # state: gathered -# config: -# - generate_all_configurations: true +# file_mode: "append" # NOT Recommended for actual use cases due to potential API errors on non-layer2 devices. # - name: Auto-generate YAML Configuration with custom file path @@ -222,8 +267,8 @@ # dnac_log: true # dnac_log_level: "{{dnac_log_level}}" # state: gathered -# config: -# - file_path: "/tmp/complete_infrastructure_config.yml" +# file_path: "/tmp/complete_infrastructure_config.yml" +# file_mode: "append" - name: Generate YAML Configuration with default file path cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -237,9 +282,10 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_mode: "append" config: - - global_filters: - ip_address_list: ["192.168.1.10"] + global_filters: + ip_address_list: ["192.168.1.10"] - name: Generate YAML Configuration with specific devices by IP address cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -253,10 +299,11 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11", "192.168.1.12"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11", "192.168.1.12"] - name: Generate YAML Configuration with specific devices by hostname cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -270,10 +317,11 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - hostname_list: ["switch01.lab.com", "switch02.lab.com", "core-switch-01"] + global_filters: + hostname_list: ["switch01.lab.com", "switch02.lab.com", "core-switch-01"] - name: Generate YAML Configuration with specific devices by serial number cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -287,10 +335,11 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - serial_number_list: ["FCW2140L05Y", "FCW2140L06Z", "9080V0I41J3"] + global_filters: + serial_number_list: ["FCW2140L05Y", "FCW2140L06Z", "9080V0I41J3"] - name: Generate YAML Configuration with specific devices by hostname cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -304,10 +353,11 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - hostname_list: ["switch01.lab.com", "switch02.lab.com", "core-switch-01"] + global_filters: + hostname_list: ["switch01.lab.com", "switch02.lab.com", "core-switch-01"] - name: Generate YAML Configuration with specific devices by serial number cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -321,10 +371,11 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - serial_number_list: ["FCW2140L05Y", "FCW2140L06Z", "9080V0I41J3"] + global_filters: + serial_number_list: ["FCW2140L05Y", "FCW2140L06Z", "9080V0I41J3"] - name: Generate YAML Configuration using explicit components list cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -338,12 +389,13 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - components_list: ["layer2_configurations"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + components_list: ["layer2_configurations"] - name: Generate YAML Configuration with components list and specific features cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -357,14 +409,15 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: ["vlans", "stp", "cdp"] + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["vlans", "stp", "cdp"] - name: Generate YAML Configuration for specific VLANs cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -378,14 +431,15 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: ["vlans"] + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["vlans"] vlans: vlan_ids_list: ["10", "20", "100", "200"] @@ -401,14 +455,15 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: ["port_configuration"] + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["port_configuration"] port_configuration: interface_names_list: - "GigabitEthernet1/0/1" @@ -427,14 +482,15 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - components_list: ["layer2_configurations"] - layer2_configurations: - layer2_features: ["vlans", "stp", "port_configuration"] + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["vlans", "stp", "port_configuration"] vlans: vlan_ids_list: ["10", "20", "100"] port_configuration: @@ -454,17 +510,20 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] - component_specific_filters: - layer2_features: ["port_configuration"] - port_configuration: - interface_names_list: - - "GigabitEthernet1/0/1" - - "GigabitEthernet1/0/2" - - "TenGigabitEthernet1/0/1" + global_filters: + ip_address_list: ["192.168.1.10"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["port_configuration"] + port_configuration: + interface_names_list: + - "GigabitEthernet1/0/1" + - "GigabitEthernet1/0/2" + - "TenGigabitEthernet1/0/1" - name: Generate YAML Configuration with comprehensive filtering cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -478,18 +537,21 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10", "192.168.1.11"] - component_specific_filters: - layer2_features: ["vlans", "stp", "port_configuration"] - vlans: - vlan_ids_list: ["10", "20", "100"] - port_configuration: - interface_names_list: - - "GigabitEthernet1/0/1" - - "GigabitEthernet1/0/24" + global_filters: + ip_address_list: ["192.168.1.10", "192.168.1.11"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["vlans", "stp", "port_configuration"] + vlans: + vlan_ids_list: ["10", "20", "100"] + port_configuration: + interface_names_list: + - "GigabitEthernet1/0/1" + - "GigabitEthernet1/0/24" - name: Generate YAML Configuration for all features (no component filters) cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -503,10 +565,11 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/wired_campus_automation_config.yml" - global_filters: - ip_address_list: ["192.168.1.10"] + global_filters: + ip_address_list: ["192.168.1.10"] - name: Generate YAML Configuration with default file path cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -520,9 +583,11 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - global_filters: - ip_address_list: ["192.168.1.10"] + global_filters: + ip_address_list: ["192.168.1.10"] - name: Generate YAML Configuration for protocol features cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -536,12 +601,15 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/protocol_features_config.yml" - global_filters: - hostname_list: ["switch01.lab.com", "switch02.lab.com"] - component_specific_filters: - layer2_features: ["cdp", "lldp", "stp", "vtp"] + global_filters: + hostname_list: ["switch01.lab.com", "switch02.lab.com"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["cdp", "lldp", "stp", "vtp"] - name: Generate YAML Configuration for security features cisco.dnac.wired_campus_automation_playbook_config_generator: @@ -555,12 +623,15 @@ dnac_log: true dnac_log_level: "{{dnac_log_level}}" state: gathered + file_path: "/tmp/wired_campus_automation_config.yml" + file_mode: "append" config: - - file_path: "/tmp/security_features_config.yml" - global_filters: - serial_number_list: ["FCW2140L05Y", "FCW2140L06Z"] - component_specific_filters: - layer2_features: ["dhcp_snooping", "igmp_snooping", "authentication"] + global_filters: + serial_number_list: ["FCW2140L05Y", "FCW2140L06Z"] + component_specific_filters: + components_list: ["layer2_configurations"] + layer2_configurations: + - layer2_features: ["dhcp_snooping", "igmp_snooping", "authentication"] """ RETURN = r""" @@ -571,44 +642,68 @@ type: dict sample: > { - "response": - { - "message": "YAML config generation succeeded for module 'wired_campus_automation_workflow_manager'.", - "file_path": "/tmp/wired_campus_automation_config.yml", - "configurations_generated": 25, - "operation_summary": { - "total_devices_processed": 3, - "total_features_processed": 33, - "total_successful_operations": 30, - "total_failed_operations": 3, - "devices_with_complete_success": ["192.168.1.10", "192.168.1.11"], - "devices_with_partial_success": ["192.168.1.12"], - "devices_with_complete_failure": [], - "success_details": [ - { - "device_ip": "192.168.1.10", - "device_id": "12345678-1234-1234-1234-123456789abc", - "feature": "vlans", - "status": "success", - "api_features_processed": ["vlanConfig"] - } - ], - "failure_details": [ - { - "device_ip": "192.168.1.12", - "device_id": "12345678-1234-1234-1234-123456789def", - "feature": "stp", - "status": "failed", - "error_info": { - "error_type": "api_error", - "error_message": "Feature not supported on this device", - "error_code": "FEATURE_NOT_SUPPORTED" - } - } - ] - } + "changed": true, + "diff": [], + "invocation": { + "module_args": { + "config": { + "component_specific_filters": { + "components_list": [ + "layer2_configurations" + ], + "layer2_configurations": [ + { + "layer2_features": [ + "vlans", + "cdp" + ] + } + ] + }, + "global_filters": { + "ip_address_list": [ + "1.1.1.1" + ] + } + }, + "dnac_api_task_timeout": 1200, + "dnac_debug": false, + "dnac_host": "10.22.40.214", + "dnac_log": true, + "dnac_log_append": true, + "dnac_log_file_path": "dnac.log", + "dnac_log_level": "DEBUG", + "dnac_password": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER", + "dnac_port": "443", + "dnac_task_poll_interval": 2, + "dnac_username": "admin", + "dnac_verify": false, + "dnac_version": "3.1.3.0", + "file_mode": "overwrite", + "file_path": "wired_campus_automation_config.yml", + "state": "gathered", + "validate_response_schema": true + } + }, + "msg": { + "components_processed": 1, + "components_skipped": 0, + "configurations_count": 1, + "file_mode": "overwrite", + "file_path": "wired_campus_automation_config.yml", + "message": "YAML configuration file generated successfully for module 'wired_campus_automation_workflow_manager'", + "status": "success" }, - "msg": "YAML config generation succeeded for module 'wired_campus_automation_workflow_manager'." + "response": { + "components_processed": 1, + "components_skipped": 0, + "configurations_count": 1, + "file_mode": "overwrite", + "file_path": "wired_campus_automation_config.yml", + "message": "YAML configuration file generated successfully for module 'wired_campus_automation_workflow_manager'", + "status": "success" + }, + "status": "success" } # Case_2: No Configurations Found Scenario @@ -746,43 +841,54 @@ def validate_input(self): """ self.log("Starting validation of input configuration parameters.", "DEBUG") - # Check if configuration is available - if not self.config: - self.status = "success" - self.msg = "Configuration is not available in the playbook for validation" + # Check if config is provided but empty - this is an error + if isinstance(self.config, dict) and len(self.config) == 0: + self.msg = ( + "Configuration cannot be an empty dictionary. " + "Either omit 'config' entirely to generate all configurations, " + "or provide specific filters within 'config'." + ) self.log(self.msg, "ERROR") + self.set_operation_result("failed", False, self.msg, "ERROR") + return self + + # Check if configuration is not provided (None) - treat as generate_all + if self.config is None: + self.validated_config = {"generate_all_configurations": True} + self.msg = "Configuration is not provided - treating as generate_all_configurations mode" + self.log(self.msg, "INFO") + self.set_operation_result("success", False, self.msg, "INFO") + return self + + if not isinstance(self.config, dict): + self.msg = ( + f"Configuration must be a dictionary, got: {type(self.config).__name__}. Please provide " + "configuration as a dictionary." + ) + self.set_operation_result("failed", False, self.msg, "ERROR") return self # Expected schema for configuration parameters temp_spec = { - "generate_all_configurations": { - "type": "bool", - "required": False, - "default": False, - }, - "file_path": {"type": "str", "required": False}, "component_specific_filters": {"type": "dict", "required": False}, "global_filters": {"type": "dict", "required": False}, } - # Import validate_list_of_dicts function here to avoid circular imports - from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( - validate_list_of_dicts, - ) - # Validate params - valid_temp, invalid_params = validate_list_of_dicts(self.config, temp_spec) + self.log("Validating configuration against schema", "DEBUG") + valid_temp = self.validate_config_dict(self.config, temp_spec) - if invalid_params: - self.msg = "Invalid parameters in playbook: {0}".format(invalid_params) - self.set_operation_result("failed", False, self.msg, "ERROR") - return self + self.log("Validating invalid parameters against provided config", "DEBUG") + self.validate_invalid_params(self.config, temp_spec.keys()) + + # Auto-populate components_list from component filters and validate + component_specific_filters = valid_temp.get("component_specific_filters") + if component_specific_filters: + self.auto_populate_and_validate_components_list(component_specific_filters) # Set the validated configuration and update the result with success status self.validated_config = valid_temp - self.msg = "Successfully validated playbook configuration parameters using 'validated_input': {0}".format( - str(valid_temp) - ) + self.msg = f"Successfully validated playbook configuration parameters using 'validated_input': {valid_temp}" self.set_operation_result("success", False, self.msg, "INFO") return self @@ -2416,19 +2522,48 @@ def get_layer2_configurations(self, network_element, filters): self.total_devices_processed = len(device_ip_to_id_mapping) self.log("Extracting component-specific filters for layer2 features", "DEBUG") - component_specific_filters = filters.get("component_specific_filters", {}) + component_specific_filters_list = filters.get("component_specific_filters", []) + + # Merge all filter items into a unified set of features and sub-filters + layer2_features = [] + merged_component_filters = {} + + for filter_item in component_specific_filters_list: + if not isinstance(filter_item, dict): + continue + + # Collect unique layer2_features from each item + for feature in filter_item.get("layer2_features", []): + if feature not in layer2_features: + layer2_features.append(feature) + + # Merge sub-filters (vlans, port_configuration, etc.) + for key, value in filter_item.items(): + if key == "layer2_features": + continue + if key not in merged_component_filters: + merged_component_filters[key] = value + elif isinstance(value, dict) and isinstance(merged_component_filters[key], dict): + for sub_key, sub_value in value.items(): + if sub_key in merged_component_filters[key]: + existing = merged_component_filters[key][sub_key] + if isinstance(existing, list) and isinstance(sub_value, list): + for item in sub_value: + if item not in existing: + existing.append(item) + else: + merged_component_filters[key][sub_key] = sub_value + else: + merged_component_filters[key][sub_key] = sub_value + + component_specific_filters = merged_component_filters self.log( - "Component specific filters: {0}".format(component_specific_filters), + "Merged component specific filters from {0} items: {1}".format( + len(component_specific_filters_list), component_specific_filters + ), "DEBUG", ) - layer2_config_filters = component_specific_filters.get( - "layer2_configurations", {} - ) - self.log( - "Layer2 configuration filters: {0}".format(layer2_config_filters), "DEBUG" - ) - layer2_features = layer2_config_filters.get("layer2_features", []) - self.log("Requested layer2 features: {0}".format(layer2_features), "DEBUG") + self.log("Merged layer2 features: {0}".format(layer2_features), "DEBUG") self.log("Checking if specific layer2 features were requested", "DEBUG") if not layer2_features: @@ -4451,15 +4586,11 @@ def apply_port_configuration_filters( "DEBUG", ) - # Fix: Look for port_configuration filters in the correct nested structure - layer2_config_filters = component_specific_filters.get( - "layer2_configurations", {} - ) - port_config_filters = layer2_config_filters.get("port_configuration", {}) + port_config_filters = component_specific_filters.get("port_configuration", {}) if not port_config_filters: self.log( - "No port configuration filters found in layer2_configurations - returning all configurations", + "No port configuration filters found - returning all configurations", "DEBUG", ) return merged_interface_configs @@ -4533,315 +4664,6 @@ def apply_port_configuration_filters( return filtered_configs - def yaml_config_generator(self, yaml_config_generator): - """ - Generates a YAML configuration file based on the provided parameters. - Args: - yaml_config_generator (dict): Contains file_path, global_filters, and component_specific_filters. - Returns: - self: The current instance with the operation result and message updated. - """ - self.log( - "Initializing YAML configuration generation process with parameters: {0}".format( - yaml_config_generator - ), - "DEBUG", - ) - - # Check if generate_all_configurations mode is enabled - generate_all = yaml_config_generator.get("generate_all_configurations", False) - if generate_all: - self.log( - "Auto-discovery mode enabled - will process all devices and all features", - "INFO", - ) - - self.log("Determining output file path for YAML configuration", "DEBUG") - file_path = yaml_config_generator.get("file_path") - if not file_path: - self.log( - "No file_path provided by user, generating default filename", "DEBUG" - ) - file_path = self.generate_filename() - else: - self.log("Using user-provided file_path: {0}".format(file_path), "DEBUG") - - self.log( - "YAML configuration file path determined: {0}".format(file_path), "DEBUG" - ) - - self.log("Initializing filter dictionaries", "DEBUG") - if generate_all: - # In generate_all_configurations mode, override any provided filters to ensure we get ALL configurations - self.log( - "Auto-discovery mode: Overriding any provided filters to retrieve all devices and all features", - "INFO", - ) - if yaml_config_generator.get("global_filters"): - self.log( - "Warning: global_filters provided but will be ignored due to generate_all_configurations=True", - "WARNING", - ) - if yaml_config_generator.get("component_specific_filters"): - self.log( - "Warning: component_specific_filters provided but will be ignored due to generate_all_configurations=True", - "WARNING", - ) - - # Set empty filters to retrieve everything - global_filters = {} - component_specific_filters = {} - else: - # Use provided filters or default to empty - global_filters = yaml_config_generator.get("global_filters") or {} - component_specific_filters = ( - yaml_config_generator.get("component_specific_filters") or {} - ) - - self.log("Retrieving supported network elements schema for the module", "DEBUG") - module_supported_network_elements = self.module_schema.get( - "network_elements", {} - ) - - self.log("Determining components list for processing", "DEBUG") - components_list = component_specific_filters.get( - "components_list", list(module_supported_network_elements.keys()) - ) - self.log("Components to process: {0}".format(components_list), "DEBUG") - - self.log( - "Initializing final configuration list and operation summary tracking", - "DEBUG", - ) - final_list = [] - consolidated_operation_summary = { - "total_devices_processed": 0, - "total_features_processed": 0, - "total_successful_operations": 0, - "total_failed_operations": 0, - "devices_with_complete_success": [], - "devices_with_partial_success": [], - "devices_with_complete_failure": [], - "success_details": [], - "failure_details": [], - } - - for component in components_list: - self.log("Processing component: {0}".format(component), "DEBUG") - network_element = module_supported_network_elements.get(component) - if not network_element: - self.log( - "Component {0} not supported by module, skipping processing".format( - component - ), - "WARNING", - ) - continue - - self.log("Preparing component-specific filter configuration", "DEBUG") - component_filters = { - "global_filters": global_filters, - "component_specific_filters": component_specific_filters, - } - - self.log( - "Executing component operation function to retrieve details", "DEBUG" - ) - operation_func = network_element.get("get_function_name") - details = operation_func(network_element, component_filters) - self.log( - "Details retrieved for component {0}: configurations count = {1}".format( - component, len(details.get("layer2_configurations", [])) - ), - "DEBUG", - ) - - if details and details.get("layer2_configurations"): - self.log( - "Adding {0} configurations from component {1} to final list".format( - len(details["layer2_configurations"]), component - ), - "DEBUG", - ) - final_list.extend(details["layer2_configurations"]) - - self.log("Consolidating operation summary from component results", "DEBUG") - if details and details.get("operation_summary"): - summary = details["operation_summary"] - self.log("Processing operation summary consolidation", "DEBUG") - - consolidated_operation_summary["total_devices_processed"] = max( - consolidated_operation_summary["total_devices_processed"], - summary.get("total_devices_processed", 0), - ) - consolidated_operation_summary[ - "total_features_processed" - ] += summary.get("total_features_processed", 0) - consolidated_operation_summary[ - "total_successful_operations" - ] += summary.get("total_successful_operations", 0) - consolidated_operation_summary[ - "total_failed_operations" - ] += summary.get("total_failed_operations", 0) - - self.log("Merging device lists while avoiding duplicates", "DEBUG") - for key in [ - "devices_with_complete_success", - "devices_with_partial_success", - "devices_with_complete_failure", - ]: - devices = summary.get(key, []) - for device in devices: - if device not in consolidated_operation_summary[key]: - consolidated_operation_summary[key].append(device) - - self.log("Extending operation detail lists", "DEBUG") - consolidated_operation_summary["success_details"].extend( - summary.get("success_details", []) - ) - consolidated_operation_summary["failure_details"].extend( - summary.get("failure_details", []) - ) - - self.log("Creating final dictionary structure with operation summary", "DEBUG") - final_dict = OrderedDict() - final_dict["config"] = final_list - - if not final_list: - self.log( - "No configurations found to process, setting appropriate result", - "WARNING", - ) - self.msg = { - "message": "No configurations or components to process for module '{0}'. Verify input filters or configuration.".format( - self.module_name - ), - "operation_summary": consolidated_operation_summary, - } - self.set_operation_result("ok", False, self.msg, "INFO") - return self - - self.log( - "Final dictionary created successfully with {0} configurations".format( - len(final_list) - ), - "DEBUG", - ) - self.log( - "Consolidated operation summary: {0} total successful operations, {1} total failed operations".format( - consolidated_operation_summary["total_successful_operations"], - consolidated_operation_summary["total_failed_operations"], - ), - "INFO", - ) - - # Determine if operation should be considered failed based on partial or complete failures - has_partial_failures = ( - len(consolidated_operation_summary["devices_with_partial_success"]) > 0 - ) - has_complete_failures = ( - len(consolidated_operation_summary["devices_with_complete_failure"]) > 0 - ) - has_any_failures = consolidated_operation_summary["total_failed_operations"] > 0 - - self.log( - "Evaluating operation status - Partial failures: {0}, Complete failures: {1}, Total failed operations: {2}".format( - has_partial_failures, - has_complete_failures, - consolidated_operation_summary["total_failed_operations"], - ), - "DEBUG", - ) - - self.log("Attempting to write final dictionary to YAML file", "DEBUG") - if self.write_dict_to_yaml(final_dict, file_path): - self.log("YAML file write operation completed successfully", "INFO") - - # Determine final operation status - if has_partial_failures or has_complete_failures or has_any_failures: - self.log( - "Operation contains failures - setting final status to failed", - "WARNING", - ) - self.msg = { - "message": "YAML config generation completed with failures for module '{0}'. Check operation_summary for details.".format( - self.module_name - ), - "file_path": file_path, - "configurations_generated": len(final_list), - "operation_summary": consolidated_operation_summary, - } - self.set_operation_result("failed", True, self.msg, "ERROR") - else: - self.log("Operation completed successfully without failures", "INFO") - self.msg = { - "message": "YAML config generation succeeded for module '{0}'.".format( - self.module_name - ), - "file_path": file_path, - "configurations_generated": len(final_list), - "operation_summary": consolidated_operation_summary, - } - self.set_operation_result("success", True, self.msg, "INFO") - else: - self.log("YAML file write operation failed", "ERROR") - self.msg = { - "message": "YAML config generation failed for module '{0}' - unable to write to file.".format( - self.module_name - ), - "file_path": file_path, - "operation_summary": consolidated_operation_summary, - } - self.set_operation_result("failed", True, self.msg, "ERROR") - - self.log("YAML configuration generation process completed", "DEBUG") - return self - - def get_want(self, config, state): - """ - Creates parameters for API calls based on the specified state. - This method prepares the parameters required for adding, updating, or deleting - network configurations such as SSIDs and interfaces in the Cisco Catalyst Center - based on the desired state. It logs detailed information for each operation. - Args: - config (dict): The configuration data for the network elements. - state (str): The desired state of the network elements ('gathered'). - """ - - self.log( - "Creating Parameters for API Calls with state: {0}".format(state), "INFO" - ) - - self.validate_params(config) - - # Set generate_all_configurations after validation - self.generate_all_configurations = config.get( - "generate_all_configurations", False - ) - self.log( - "Set generate_all_configurations mode: {0}".format( - self.generate_all_configurations - ), - "DEBUG", - ) - - want = {} - - # Add yaml_config_generator to want - want["yaml_config_generator"] = config - self.log( - "yaml_config_generator added to want: {0}".format( - want["yaml_config_generator"] - ), - "INFO", - ) - - self.want = want - self.log("Desired State (want): {0}".format(str(self.want)), "INFO") - self.msg = "Successfully collected all parameters from the playbook for Wireless Design operations." - self.status = "success" - return self - def get_diff_gathered(self): """ Executes the merge operations for various network configurations in the Cisco Catalyst Center. @@ -4915,10 +4737,11 @@ def main(): "dnac_log_append": {"type": "bool", "default": True}, "dnac_log": {"type": "bool", "default": False}, "validate_response_schema": {"type": "bool", "default": True}, - "config_verify": {"type": "bool", "default": False}, "dnac_api_task_timeout": {"type": "int", "default": 1200}, "dnac_task_poll_interval": {"type": "int", "default": 2}, - "config": {"required": True, "type": "list", "elements": "dict"}, + "config": {"type": "dict", "required": False}, + "file_path": {"type": "str", "required": False}, + "file_mode": {"type": "str", "required": False, "default": "overwrite", "choices": ["overwrite", "append"]}, "state": {"default": "gathered", "choices": ["gathered"]}, } @@ -4959,14 +4782,16 @@ def main(): ccc_wired_campus_automation_playbook_generator.validate_input().check_return_status() # Iterate over the validated configuration parameters - for config in ccc_wired_campus_automation_playbook_generator.validated_config: - ccc_wired_campus_automation_playbook_generator.reset_values() - ccc_wired_campus_automation_playbook_generator.get_want( - config, state - ).check_return_status() - ccc_wired_campus_automation_playbook_generator.get_diff_state_apply[ - state - ]().check_return_status() + config = ccc_wired_campus_automation_playbook_generator.validated_config + ccc_wired_campus_automation_playbook_generator.log( + "Validated configuration parameters: {0}".format(str(config)), "DEBUG" + ) + ccc_wired_campus_automation_playbook_generator.get_want( + config, state + ).check_return_status() + ccc_wired_campus_automation_playbook_generator.get_diff_state_apply[ + state + ]().check_return_status() module.exit_json(**ccc_wired_campus_automation_playbook_generator.result) diff --git a/plugins/modules/wired_campus_automation_workflow_manager.py b/plugins/modules/wired_campus_automation_workflow_manager.py index 5d1a8d9a56..144926be5f 100644 --- a/plugins/modules/wired_campus_automation_workflow_manager.py +++ b/plugins/modules/wired_campus_automation_workflow_manager.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -__author__ = "Rugvedi Kapse, Madhan Sankaranarayanan" +__author__ = "Rugvedi Kapse, Madhan Sankaranarayanan, Vivek Raj" DOCUMENTATION = r""" @@ -78,6 +78,7 @@ author: - Rugvedi Kapse (@rukapse) - Madhan Sankaranarayanan (@madhansansel) + - Vivek Raj (@vivekraj2000) options: config_verify: description: Set to true to verify the Cisco Catalyst @@ -179,8 +180,12 @@ - When true, the VLAN is active and can carry traffic. - When false, the VLAN is administratively shut down. - Disabled VLANs do not forward traffic but retain their configuration. - - NOTE - "vlan_admin_status" Can only be modified for VLAN IDs 2-1001. - - Extended range VLANs (1002-4094) do not support admin status updates. + - NOTE - For standard-range VLANs (2-1001), "vlan_admin_status" can be + set to true or false for both create and update operations. + - For extended-range VLANs (1006-4094), "vlan_admin_status" cannot be + set to false. These VLANs must always remain enabled. Setting + "vlan_admin_status" to false for an extended-range VLAN will result + in a validation error. type: bool required: false default: true @@ -569,7 +574,9 @@ - When true, restricts flooded traffic to only necessary trunk links. - Reduces unnecessary broadcast traffic in the VTP domain. - Only affects VLANs 2-1001; VLAN 1 and extended VLANs are not pruned. - - Can only be configured when "vtp_mode" is "SERVER". + - VTP pruning can only be enabled when 'vtp_mode' is set to 'SERVER'. + - Setting 'vtp_pruning' to true with any other 'vtp_mode' value will result in a + validation error. type: bool required: false default: false @@ -3715,6 +3722,29 @@ def _validate_vlans_config(self, vlan_config, rules): self.log("Validating VLAN configuration: {0}".format(vlan), "DEBUG") + # Check for reserved VLAN IDs (1002-1005 are reserved for legacy protocols) + vlan_id = vlan.get("vlan_id") + if vlan_id is not None and vlan_id in (1002, 1003, 1004, 1005): + self.msg = ( + "VLAN ID {0} is reserved for legacy protocols (FDDI, Token Ring). " + "Reserved VLAN IDs 1002-1005 cannot be created, modified, or deleted. " + "Please use a VLAN ID outside the reserved range (2-1001 or 1006-4094).".format(vlan_id) + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + + # Extended-range VLANs (1006-4094) cannot have admin status set to false + vlan_admin_status = vlan.get("vlan_admin_status") + if vlan_id is not None and vlan_id >= 1006 and vlan_admin_status is False: + self.msg = ( + "Cannot set 'vlan_admin_status' to false for VLAN ID {0}. " + "Extended-range VLANs (1006-4094) must always have admin status enabled. " + "Please either set 'vlan_admin_status' to true or remove it from the " + "VLAN {0} configuration.".format(vlan_id) + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + # Validate the individual VLAN configuration against the provided rules self.validate_config_against_rules("vlans", vlan, rules) @@ -3882,6 +3912,19 @@ def _validate_vtp_config(self, vtp_config, rules): self.log("Starting validation for VTP global configuration", "INFO") self.log("Validating VTP configuration: {0}".format(vtp_config), "DEBUG") + # Validate that vtp_pruning is only enabled when vtp_mode is SERVER + vtp_pruning = vtp_config.get("vtp_pruning") + vtp_mode = vtp_config.get("vtp_mode") + if vtp_pruning is True and vtp_mode != "SERVER": + self.msg = ( + "Invalid configuration: 'vtp_pruning' can only be set to true when " + "'vtp_mode' is 'SERVER'. Current 'vtp_mode' is '{0}'. " + "Either set 'vtp_mode' to 'SERVER' or remove 'vtp_pruning' " + "from the configuration.".format(vtp_mode if vtp_mode is not None else "not specified") + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + # Validate the VTP configuration against the provided validation rules self.validate_config_against_rules("vtp", vtp_config, rules) @@ -8152,6 +8195,17 @@ def _determine_vlan_config_operation(self, desired_vlans, deployed_vlans): else: # VLAN exists - check if update is needed if self._config_needs_update(desired_vlan, deployed_vlan): + # Validate: admin status cannot be set to false for extended-range VLANs (1006-4094) + if vlan_id >= 1006 and desired_vlan.get("isVlanEnabled") is False: + self.msg = ( + "Cannot set 'vlan_admin_status' to false for VLAN ID {0}. " + "Extended-range VLANs (1006-4094) must always have admin status enabled. " + "Please either set 'vlan_admin_status' to true or remove it from the " + "VLAN {0} configuration.".format(vlan_id) + ) + self.log(self.msg, "ERROR") + self.fail_and_exit(self.msg) + # Update existing VLAN with new parameters updated_vlan = deployed_vlan.copy() updated_vlan.update( @@ -11492,7 +11546,7 @@ def _execute_single_feature_operation( # Monitor task completion using the same pattern as wireless design module self.get_task_status_from_tasks_by_id( - task_id, task_name, success_msg + task_id, task_name, success_msg, all_reasons=True ).check_return_status() # Check the final status @@ -11597,7 +11651,7 @@ def _deploy_configurations(self, network_device_id, device_identifier): # Monitor deployment task completion using existing DnacBase function self.get_task_status_from_tasks_by_id( - task_id, task_name, success_msg + task_id, task_name, success_msg, all_reasons=True ).check_return_status() # Check the final status @@ -13745,7 +13799,7 @@ def _execute_delete_intent_operation( # Monitor task completion self.get_task_status_from_tasks_by_id( - task_id, task_name, success_msg + task_id, task_name, success_msg, all_reasons=True ).check_return_status() if self.status == "success": @@ -13806,7 +13860,7 @@ def _execute_create_intent_operation( # Monitor task completion self.get_task_status_from_tasks_by_id( - task_id, task_name, success_msg + task_id, task_name, success_msg, all_reasons=True ).check_return_status() if self.status == "success": @@ -13867,7 +13921,7 @@ def _execute_update_intent_operation( # Monitor task completion using existing infrastructure self.get_task_status_from_tasks_by_id( - task_id, task_name, success_msg + task_id, task_name, success_msg, all_reasons=True ).check_return_status() if self.status == "success": @@ -13918,7 +13972,7 @@ def _execute_deployment_operation(self, network_device_id): # Monitor task completion using existing infrastructure self.get_task_status_from_tasks_by_id( - task_id, task_name, success_msg + task_id, task_name, success_msg, all_reasons=True ).check_return_status() if self.status == "success": diff --git a/plugins/modules/wireless_design_playbook_config_generator.py b/plugins/modules/wireless_design_playbook_config_generator.py index f2a3e45141..de8be899aa 100644 --- a/plugins/modules/wireless_design_playbook_config_generator.py +++ b/plugins/modules/wireless_design_playbook_config_generator.py @@ -435,6 +435,7 @@ def __init__(self, module): self.module_schema = self.get_workflow_elements_schema() self.module_name = "wireless_design_workflow_manager" self.country_code_map = None + self._api_response_to_module_attribute_map = None def validate_input(self): """ @@ -633,6 +634,162 @@ def get_workflow_elements_schema(self): return schema + def _get_api_response_to_module_attribute_map(self): + """ + Lazily initializes and returns API response to module attribute override map. + + Returns: + dict: Mapping of API response attribute names to module attribute names. + """ + + if self._api_response_to_module_attribute_map is None: + self._api_response_to_module_attribute_map = { + "calledStationId": "called_station_id", + "peer2peerblocking": "peer2peer_blocking", + "passiveClient": "passive_client", + "predictionOptimization": "prediction_optimization", + "dualBandNeighborList": "dual_band_neighbor_list", + "radiusNacState": "radius_nac_state", + "dhcpRequired": "dhcp_required", + "dhcpServer": "dhcp_server", + "flexLocalAuth": "flex_local_auth", + "targetWakeupTime": "target_wakeup_time", + "downlinkOfdma": "downlink_ofdma", + "uplinkOfdma": "uplink_ofdma", + "downlinkMuMimo": "downlink_mu_mimo", + "uplinkMuMimo": "uplink_mu_mimo", + "dot11ax": "dot11ax", + "aironetIESupport": "aironet_ie_support", + "loadBalancing": "load_balancing", + "dtimPeriod5GHz": "dtim_period_5ghz", + "dtimPeriod24GHz": "dtim_period_24ghz", + "scanDeferTime": "scan_defer_time", + "maxClients": "max_clients", + "maxClientsPerRadio": "max_clients_per_radio", + "maxClientsPerAp": "max_clients_per_ap", + "wmmPolicy": "wmm_policy", + "multicastBuffer": "multicast_buffer", + "multicastBufferValue": "multicast_buffer_value", + "mediaStreamMulticastDirect": "media_stream_multicast_direct", + "muMimo11ac": "mu_mimo_11ac", + "wifiToCellularSteering": "wifi_to_cellular_steering", + "wifiAllianceAgileMultiband": "wifi_alliance_agile_multiband", + "fastlaneASR": "fastlane_asr", + "dot11vBSSMaxIdleProtected": "dot11v_bss_max_idle_protected", + "universalAPAdmin": "universal_ap_admin", + "opportunisticKeyCaching": "opportunistic_key_caching", + "ipSourceGuard": "ip_source_guard", + "dhcpOpt82RemoteIDSubOption": "dhcp_opt82_remote_id_sub_option", + "vlanCentralSwitching": "vlan_central_switching", + "callSnooping": "call_snooping", + "sendDisassociate": "send_disassociate", + "sent486Busy": "sent_486_busy", + "ipMacBinding": "ip_mac_binding", + "deferPriority0": "defer_priority_0", + "deferPriority1": "defer_priority_1", + "deferPriority2": "defer_priority_2", + "deferPriority3": "defer_priority_3", + "deferPriority4": "defer_priority_4", + "deferPriority5": "defer_priority_5", + "deferPriority6": "defer_priority_6", + "deferPriority7": "defer_priority_7", + "shareDataWithClient": "share_data_with_client", + "advertiseSupport": "advertise_support", + "advertisePCAnalyticsSupport": "advertise_pc_analytics_support", + "sendBeaconOnAssociation": "send_beacon_on_association", + "sendBeaconOnRoam": "send_beacon_on_roam", + "idleThreshold": "idle_threshold", + "fastTransitionReassociationTimeout": "fast_transition_reassociation_timeout", + "mDNSMode": "mdns_mode", + "radioBand": "radio_band", + "cleanAir": "clean_air", + "cleanAirDeviceReporting": "clean_air_device_reporting", + "persistentDevicePropagation": "persistent_device_propagation", + "description": "description", + "interferersFeatures": "interferers_features", + "bssColor": "bss_color", + "targetWaketimeBroadcast": "target_waketime_broadcast", + "nonSRGObssPdMaxThreshold": "non_srg_obss_pd_max_threshold", + "targetWakeUpTime11ax": "target_wakeup_time_11ax", + "obssPd": "obss_pd", + "multipleBssid": "multiple_bssid", + "dot11beStatus": "dot11be_status", + "eventDrivenRrmEnable": "event_driven_rrm_enable", + "eventDrivenRrmThresholdLevel": "event_driven_rrm_threshold_level", + "eventDrivenRrmCustomThresholdVal": "event_driven_rrm_custom_threshold_val", + "overlapIpEnable": "overlap_ip_enable", + "globalMulticastEnabled": "global_multicast_enabled", + "multicastIpv4Mode": "multicast_ipv4_mode", + "multicastIpv4Address": "multicast_ipv4_address", + "multicastIpv6Mode": "multicast_ipv6_mode", + "multicastIpv6Address": "multicast_ipv6_address", + "fraFreeze": "fra_freeze", + "fraStatus": "fra_status", + "fraInterval": "fra_interval", + "fraSensitivity": "fra_sensitivity", + "monitoringChannels": "monitoring_channels", + "neighborDiscoverType": "neighbor_discover_type", + "throughputThreshold": "throughput_threshold", + "coverageHoleDetection": "coverage_hole_detection", + } + + return self._api_response_to_module_attribute_map + + def _transform_attribute_name_to_snake_case(self, attribute): + """ + Converts a camelCase/PascalCase attribute name to snake_case. + + Args: + attribute (str): Attribute name to convert. + + Returns: + str: Converted snake_case attribute name. + """ + + transformed_attribute = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", attribute) + transformed_attribute = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", transformed_attribute) + return transformed_attribute.lower() + + def transform_feature_attributes_from_camel_case_to_snake_case(self, attribute): + """ + Transforms a feature attribute from camelCase to snake_case format. + + Args: + attribute (str): A feature attribute name in camelCase format. + Returns: + str: Transformed feature attribute name in snake_case format. + """ + + self.log( + "Starting feature attribute transformation from camelCase to snake_case for attribute: {0}" + .format(attribute), + "DEBUG", + ) + + if not isinstance(attribute, str): + self.log( + "Invalid attribute provided for transformation. Expected str, got: {0}".format( + type(attribute).__name__ + ), + "DEBUG", + ) + return attribute + + api_response_to_module_attribute_map = self._get_api_response_to_module_attribute_map() + transformed_attribute = api_response_to_module_attribute_map.get(attribute) + + if transformed_attribute is None: + transformed_attribute = self._transform_attribute_name_to_snake_case(attribute) + + self.log( + "Completed feature attribute transformation. Output: {0}".format( + transformed_attribute + ), + "DEBUG", + ) + + return transformed_attribute + def wireless_ssid_temp_spec(self): """ Constructs a temporary specification for wireless ssid config, defining the structure and types of attributes @@ -1319,7 +1476,8 @@ def wireless_aaa_radius_attribute_config_temp_spec(self): "unlocked_attributes": { "type": "list", "elements": "str", - "source_key": "unlockedAttributes" + "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case } } ) @@ -1406,7 +1564,8 @@ def wireless_advanced_ssid_config_temp_spec(self): "unlocked_attributes": { "type": "list", "elements": "str", - "source_key": "unlockedAttributes" + "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case } } ) @@ -1473,6 +1632,7 @@ def wireless_clean_air_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) @@ -1511,6 +1671,7 @@ def wireless_dot11ax_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) @@ -1544,6 +1705,7 @@ def wireless_dot11be_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) @@ -1585,6 +1747,7 @@ def wireless_event_driven_rrm_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) @@ -1617,6 +1780,7 @@ def wireless_feature_template_flexconnect_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) @@ -1653,6 +1817,7 @@ def wireless_multicast_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) @@ -1693,12 +1858,13 @@ def wireless_rrm_fra_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) return rrm_fra_config_temp_spec - def wireless_rrm_rrm_general_config_temp_spec(self): + def wireless_rrm_general_config_temp_spec(self): """ Constructs a temporary specification for wireless RRM general configuration, defining the structure and types of attributes used in the YAML file. @@ -1729,6 +1895,7 @@ def wireless_rrm_rrm_general_config_temp_spec(self): "type": "list", "elements": "str", "source_key": "unlockedAttributes", + "transform": self.transform_feature_attributes_from_camel_case_to_snake_case }, } ) @@ -2526,7 +2693,7 @@ def get_feature_template_attributes_with_type(self, feature_template_type): "api_function": "get_r_r_m_f_r_a_configuration_feature_template", }, "RRM_GENERAL_CONFIGURATION": { - "temp_spec": self.wireless_rrm_rrm_general_config_temp_spec(), + "temp_spec": self.wireless_rrm_general_config_temp_spec(), "attribute_name": "rrm_general_configuration", "api_function": "get_r_r_m_general_configuration_feature_template", }, diff --git a/tests/integration/ccc_sda_fabric_devices_workflow_management/defaults/main.yml b/tests/integration/ccc_sda_fabric_devices_workflow_management/defaults/main.yml new file mode 100644 index 0000000000..5f709c5aac --- /dev/null +++ b/tests/integration/ccc_sda_fabric_devices_workflow_management/defaults/main.yml @@ -0,0 +1,2 @@ +--- +testcase: "*" diff --git a/tests/integration/ccc_sda_fabric_devices_workflow_management/meta/main.yml b/tests/integration/ccc_sda_fabric_devices_workflow_management/meta/main.yml new file mode 100644 index 0000000000..32cf5dda7e --- /dev/null +++ b/tests/integration/ccc_sda_fabric_devices_workflow_management/meta/main.yml @@ -0,0 +1 @@ +dependencies: [] diff --git a/tests/integration/ccc_sda_fabric_devices_workflow_management/tasks/main.yml b/tests/integration/ccc_sda_fabric_devices_workflow_management/tasks/main.yml new file mode 100644 index 0000000000..fd061ac9a5 --- /dev/null +++ b/tests/integration/ccc_sda_fabric_devices_workflow_management/tasks/main.yml @@ -0,0 +1,34 @@ +--- +- name: collect ccc test cases + find: + paths: "{{ role_path }}/tests" + patterns: "{{ testcase }}.yml" + connection: local + register: ccc_cases + tags: sanity + +- debug: + msg: "CCC Cases: {{ ccc_cases }}" + +- set_fact: + test_cases: + files: "{{ ccc_cases.files }}" + tags: sanity + +- debug: + msg: "Test Cases: {{ test_cases }}" + +- name: set test_items + set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list }}" + tags: sanity + +- debug: + msg: "Test Items: {{ test_items }}" + +- name: run test cases (connection=httpapi) + include_tasks: "{{ test_case_to_run }}" + loop: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run + tags: sanity diff --git a/tests/integration/ccc_sda_fabric_devices_workflow_management/tests/test_sda_fabric_devices_workflow_management.yml b/tests/integration/ccc_sda_fabric_devices_workflow_management/tests/test_sda_fabric_devices_workflow_management.yml new file mode 100644 index 0000000000..b22da60a68 --- /dev/null +++ b/tests/integration/ccc_sda_fabric_devices_workflow_management/tests/test_sda_fabric_devices_workflow_management.yml @@ -0,0 +1,157 @@ +--- +- debug: + msg="Initializing SDA Fabric Devices Workflow Management Test" +- debug: + msg="Role Path {{ role_path }}" + +- block: + - name: Load variables and set Catalyst Center credentials + include_vars: + file: "{{ role_path }}/vars/vars_sda_fabric_devices_workflow_management.yml" + name: vars_map + vars: + dnac_login: &dnac_login + dnac_host: "{{ dnac_host }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_port: "{{ dnac_port }}" + dnac_version: "{{ dnac_version }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: true + dnac_log_level: DEBUG + config_verify: false + +############################################# +# Clean Up # +############################################# + + - name: Remove existing SDA fabric devices before test execution + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: deleted + config: + - "{{ item }}" + loop: "{{ vars_map.cleanup_fabric_devices }}" + +############################################# +# FABRIC DEVICE CREATE/UPDATE/DELETE +############################################# + + - name: Create SDA fabric devices + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: merged + config: + - "{{ item }}" + loop: "{{ vars_map.create_fabric_devices }}" + register: result_create_fabric_devices + + - name: Verify SDA fabric device creation + assert: + that: + - item.changed == true + - "'Successfully' in item.msg" + loop: "{{ result_create_fabric_devices.results }}" + when: result_create_fabric_devices is defined + + - name: Update SDA fabric devices + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: merged + config: + - "{{ item }}" + loop: "{{ vars_map.update_fabric_devices }}" + register: result_update_fabric_devices + + - name: Verify SDA fabric device update + assert: + that: + - item.changed == true + - "'Successfully' in item.msg" + loop: "{{ result_update_fabric_devices.results }}" + when: result_update_fabric_devices is defined + + - name: Delete SDA fabric devices + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: deleted + config: + - "{{ item }}" + loop: "{{ vars_map.delete_fabric_devices }}" + register: result_delete_fabric_devices + + - name: Verify SDA fabric device deletion + assert: + that: + - item.changed == true + - "'Successfully' in item.msg" + loop: "{{ result_delete_fabric_devices.results }}" + when: result_delete_fabric_devices is defined + +############################################# +# WIRELESS CONTROLLER CREATE/UPDATE/DELETE +############################################# + + - name: Create wireless controller settings on SDA fabric devices + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: merged + config: + - "{{ item }}" + loop: "{{ vars_map.create_wireless_controller_settings }}" + register: result_create_wireless_controller_settings + + - name: Verify wireless controller settings creation + assert: + that: + - item.changed == true + - "'successful' in item.msg" + loop: "{{ result_create_wireless_controller_settings.results }}" + when: result_create_wireless_controller_settings is defined + + - name: Update wireless controller settings on SDA fabric devices + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: merged + config: + - "{{ item }}" + loop: "{{ vars_map.update_wireless_controller_settings }}" + register: result_update_wireless_controller_settings + + - name: Verify wireless controller settings update + assert: + that: + - item.changed == true + - "'successful' in item.msg" + loop: "{{ result_update_wireless_controller_settings.results }}" + when: result_update_wireless_controller_settings is defined + + - name: Delete wireless controller settings on SDA fabric devices + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: deleted + config: + - "{{ item }}" + loop: "{{ vars_map.delete_wireless_controller_settings }}" + register: result_delete_wireless_controller_settings + + - name: Verify wireless controller settings deletion + assert: + that: + - item.changed == true + - "'successful' in item.msg" + loop: "{{ result_delete_wireless_controller_settings.results }}" + when: result_delete_wireless_controller_settings is defined + +############################################# +# Clean Up # +############################################# + + - name: Remove existing SDA fabric devices after test execution + cisco.dnac.sda_fabric_devices_workflow_manager: + <<: *dnac_login + state: deleted + config: + - "{{ item }}" + loop: "{{ vars_map.cleanup_fabric_devices }}" diff --git a/tests/integration/ccc_sda_fabric_devices_workflow_management/vars/vars_sda_fabric_devices_workflow_management.yml b/tests/integration/ccc_sda_fabric_devices_workflow_management/vars/vars_sda_fabric_devices_workflow_management.yml new file mode 100644 index 0000000000..3dc8461d11 --- /dev/null +++ b/tests/integration/ccc_sda_fabric_devices_workflow_management/vars/vars_sda_fabric_devices_workflow_management.yml @@ -0,0 +1,104 @@ +--- +cleanup_fabric_devices: + - fabric_devices: + fabric_name: Global/USA/SAN_FRANCISCO + device_config: + - device_ip: 204.1.4.1 + +create_fabric_devices: + - fabric_devices: + fabric_name: Global/USA/SAN_FRANCISCO + device_config: + - device_ip: 204.1.4.1 + device_roles: + - BORDER_NODE + - CONTROL_PLANE_NODE + borders_settings: + layer3_settings: + local_autonomous_system_number: "1235" + import_external_routes: true + border_priority: 1 + prepend_autonomous_system_count: 1 + is_default_exit: true + +update_fabric_devices: + - fabric_devices: + fabric_name: Global/USA/SAN_FRANCISCO + device_config: + - device_ip: 204.1.4.1 + device_roles: + - BORDER_NODE + - CONTROL_PLANE_NODE + borders_settings: + layer3_settings: + local_autonomous_system_number: "1235" + import_external_routes: true + border_priority: 2 + prepend_autonomous_system_count: 2 + is_default_exit: true + +delete_fabric_devices: + - fabric_devices: + fabric_name: Global/USA/SAN_FRANCISCO + device_config: + - device_ip: 204.1.4.1 + +create_wireless_controller_settings: + - fabric_devices: + fabric_name: Global/USA/SAN_FRANCISCO + device_config: + - device_ip: 204.1.4.1 + device_roles: + - BORDER_NODE + - CONTROL_PLANE_NODE + - WIRELESS_CONTROLLER_NODE + borders_settings: + layer3_settings: + local_autonomous_system_number: "1235" + import_external_routes: true + border_priority: 1 + prepend_autonomous_system_count: 1 + is_default_exit: true + wireless_controller_settings: + enable: true + primary_managed_ap_locations: + - Global/USA/SAN_FRANCISCO/BLD_SF + - Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR1 + - Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR2 + secondary_managed_ap_locations: + - Global/USA/SAN_FRANCISCO/BLD_SF1 + - Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR1 + - Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR2 + rolling_ap_upgrade: + enable: true + ap_reboot_percentage: 25 + +update_wireless_controller_settings: + - fabric_devices: + fabric_name: Global/USA/SAN_FRANCISCO + device_config: + - device_ip: 204.1.4.1 + device_roles: + - BORDER_NODE + - CONTROL_PLANE_NODE + - WIRELESS_CONTROLLER_NODE + wireless_controller_settings: + enable: true + primary_managed_ap_locations: + - Global/USA/SAN_FRANCISCO/BLD_SF + - Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR1 + secondary_managed_ap_locations: + - Global/USA/SAN_FRANCISCO/BLD_SF1 + - Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR1 + rolling_ap_upgrade: + enable: true + ap_reboot_percentage: 15 + +delete_wireless_controller_settings: + - fabric_devices: + fabric_name: Global/USA/SAN_FRANCISCO + device_config: + - device_ip: 204.1.4.1 + wireless_controller_settings: + enable: false + reload: true diff --git a/tests/integration/ccc_tags_workflow_management/vars/vars_tags_workflow_management.yml b/tests/integration/ccc_tags_workflow_management/vars/vars_tags_workflow_management.yml index bb70d21e82..e56a11372d 100644 --- a/tests/integration/ccc_tags_workflow_management/vars/vars_tags_workflow_management.yml +++ b/tests/integration/ccc_tags_workflow_management/vars/vars_tags_workflow_management.yml @@ -1,36 +1,36 @@ # --- cleanup_tags: - - tag: - name: TEST1 + - tags: + - name: TEST1 force_delete: True - - tag: - name: TEST2 + - tags: + - name: TEST2 force_delete: True - - tag: - name: TEST3 + - tags: + - name: TEST3 force_delete: True - - tag: - name: ServersTag + - tags: + - name: ServersTag force_delete: True - - tag: - name: ServersTag1 + - tags: + - name: ServersTag1 force_delete: True - - tag: - name: Test_name_change + - tags: + - name: Test_name_change force_delete: True - - tag: - name: Test_name_change_renamed + - tags: + - name: Test_name_change_renamed force_delete: True create_tags: - - tag: - name: TEST1 - - tag: - name: TEST2 - - tag: - name: TEST3 - - tag: - name: ServersTag + - tags: + - name: TEST1 + - tags: + - name: TEST2 + - tags: + - name: TEST3 + - tags: + - name: ServersTag description: Tag for devices and interfaces connected to servers device_rules: rule_descriptions: @@ -85,8 +85,8 @@ create_tags: value: Border To Fusion Link operation: ILIKE - - tag: - name: ServersTag1 + - tags: + - name: ServersTag1 description: Tag for devices and interfaces connected to servers device_rules: rule_descriptions: @@ -141,17 +141,17 @@ create_tags: value: Border To Fusion Link operation: ILIKE - - tag: - name: Test_name_change + - tags: + - name: Test_name_change update_tag_remove_description: - - tag: - name: ServersTag + - tags: + - name: ServersTag description: "" remove_all_device_and_port_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag description: Tag for devices and interfaces connected to servers device_rules: rule_descriptions: @@ -206,8 +206,8 @@ remove_all_device_and_port_rules: value: Border To Fusion Link operation: ILIKE add_device_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag device_rules: rule_descriptions: - rule_name: device_name @@ -224,8 +224,8 @@ add_device_rules: operation: ILIKE add_more_device_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag device_rules: rule_descriptions: - rule_name: device_name @@ -246,8 +246,8 @@ add_more_device_rules: operation: ILIKE add_port_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag port_rules: scope_description: scope_category: SITE @@ -269,8 +269,8 @@ add_port_rules: add_more_port_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag port_rules: scope_description: scope_category: SITE @@ -291,8 +291,8 @@ add_more_port_rules: operation: ILIKE remove_port_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag port_rules: rule_descriptions: - rule_name: port_name @@ -311,71 +311,71 @@ remove_port_rules: change_scope_in_port_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag port_rules: scope_description: scope_category: TAG scope_members: - - TEST101 - - TEST102 + - TEST1 + - TEST2 add_scope_member_in_port_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag port_rules: scope_description: scope_category: TAG scope_members: - - TEST103 + - TEST3 remove_scope_member_in_port_rules: - - tag: - name: ServersTag + - tags: + - name: ServersTag port_rules: scope_description: scope_category: TAG scope_members: - - TEST101 + - TEST1 assign_tags_to_devices: - tag_memberships: - tags: + - tags: - TEST1 - TEST2 - ServersTag device_details: - ip_addresses: - - 22.1.1.1 - - 22.1.1.2 - - 22.1.1.3 + - 204.1.208.4 + - 204.1.208.3 + - 204.1.208.14 hostnames: - - AP345D.A812.03F0 - - BLR-ASim-AP-4 + - AP2c33.1180.8396 + - AP2c33.1185.e11a serial_numbers: - - KWC22120GRL - - 1140K0001 - - 1140K0005 + - KWC213005QK + - KWC22270RE0 + - FGL2313A4TQ remove_tags_from_devices: - tag_memberships: - tags: + - tags: - TEST1 - ServersTag device_details: - ip_addresses: - - 22.1.1.1 - - 22.1.1.3 + - 204.1.208.4 + - 204.1.208.3 hostnames: - - AP345D.A812.03F0 - - BLR-ASim-AP-4 + - AP2c33.1180.8396 + - AP2c33.1185.e11a serial_numbers: - - KWC22120GRL - - 1140K0005 + - KWC213005QK + - KWC22270RE0 change_tag_name: - - tag: - name: Test_name_change + - tags: + - name: Test_name_change new_name: Test_name_change_renamed \ No newline at end of file diff --git a/tests/unit/modules/dnac/fixtures/inventory_playbook_config_generator.json b/tests/unit/modules/dnac/fixtures/inventory_playbook_config_generator.json new file mode 100644 index 0000000000..cb91b3fc4b --- /dev/null +++ b/tests/unit/modules/dnac/fixtures/inventory_playbook_config_generator.json @@ -0,0 +1,107 @@ +{ + "playbook_config_generate_all_configurations": null, + "playbook_config_devices_by_ip": { + "global_filters": { + "devices": [ + "10.10.20.11" + ] + } + }, + "playbook_config_devices_by_hostname": { + "global_filters": { + "devices": [ + "edge-sw-1" + ] + } + }, + "playbook_config_devices_by_serial": { + "global_filters": { + "devices": [ + "FDO1234A1BC" + ] + } + }, + "playbook_config_devices_by_mac": { + "global_filters": { + "devices": [ + "AA:BB:CC:DD:EE:01" + ] + } + }, + "playbook_config_filter_by_role": { + "global_filters": { + "device_roles": [ + "ACCESS" + ] + } + }, + "playbook_config_combined_filters": { + "global_filters": { + "devices": [ + "edge-sw-1", + "10.10.20.12" + ], + "device_roles": [ + "ACCESS" + ], + "device_identifier": "hostname" + } + }, + "playbook_config_empty_global_filters": { + "global_filters": {} + }, + "playbook_config_null_global_filters": { + "global_filters": null + }, + "playbook_config_included_component_specific_filters": { + "component_specific_filters": {} + }, + "playbook_config_unknown_filter_ignored": { + "global_filters": { + "unknown_filter": [ + "ignored" + ] + } + }, + "playbook_config_no_matching_devices": { + "global_filters": { + "devices": [ + "192.0.2.99" + ] + } + }, + "playbook_config_empty_config": {}, + "get_device_list_response": { + "response": [ + { + "managementIpAddress": "10.10.20.11", + "hostname": "edge-sw-1", + "serialNumber": "FDO1234A1BC", + "macAddress": "AA:BB:CC:DD:EE:01", + "role": "ACCESS", + "family": "Switches and Hubs" + }, + { + "managementIpAddress": "10.10.20.12", + "hostname": "core-sw-1", + "serialNumber": "FDO1234A1CD", + "macAddress": "AA:BB:CC:DD:EE:02", + "role": "CORE", + "family": "Switches and Hubs" + }, + { + "managementIpAddress": "10.10.20.13", + "hostname": "dist-sw-1", + "serialNumber": "FDO1234A1EF", + "macAddress": "AA:BB:CC:DD:EE:03", + "role": "DISTRIBUTION", + "family": "Switches and Hubs" + } + ], + "version": "1.0" + }, + "get_empty_device_list_response": { + "response": [], + "version": "1.0" + } +} \ No newline at end of file diff --git a/tests/unit/modules/dnac/fixtures/sda_fabric_devices_workflow_manager.json b/tests/unit/modules/dnac/fixtures/sda_fabric_devices_workflow_manager.json new file mode 100644 index 0000000000..ec2c6161ac --- /dev/null +++ b/tests/unit/modules/dnac/fixtures/sda_fabric_devices_workflow_manager.json @@ -0,0 +1,536 @@ +{ + "create_fabric_device_case_1": [ + { + "fabric_devices": { + "fabric_name": "Global/USA/SAN_FRANCISCO", + "device_config": [ + { + "device_ip": "204.1.4.1", + "device_roles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE" + ], + "borders_settings": { + "layer3_settings": { + "local_autonomous_system_number": "1235", + "import_external_routes": true, + "border_priority": 1, + "prepend_autonomous_system_count": 1, + "is_default_exit": true + } + } + } + ] + } + } + ], + "update_fabric_device_case_2": [ + { + "fabric_devices": { + "fabric_name": "Global/USA/SAN_FRANCISCO", + "device_config": [ + { + "device_ip": "204.1.4.1", + "device_roles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE" + ], + "borders_settings": { + "layer3_settings": { + "local_autonomous_system_number": "1235", + "import_external_routes": true, + "border_priority": 2, + "prepend_autonomous_system_count": 2, + "is_default_exit": true + } + } + } + ] + } + } + ], + "delete_fabric_device_case_3": [ + { + "fabric_devices": { + "fabric_name": "Global/USA/SAN_FRANCISCO", + "device_config": [ + { + "device_ip": "204.1.4.1" + } + ] + } + } + ], + "create_wireless_controller_case_4": [ + { + "fabric_devices": { + "fabric_name": "Global/USA/SAN_FRANCISCO", + "device_config": [ + { + "device_ip": "204.1.4.1", + "device_roles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE", + "WIRELESS_CONTROLLER_NODE" + ], + "borders_settings": { + "layer3_settings": { + "local_autonomous_system_number": "1235", + "import_external_routes": true, + "border_priority": 1, + "prepend_autonomous_system_count": 1, + "is_default_exit": true + } + }, + "wireless_controller_settings": { + "enable": true, + "primary_managed_ap_locations": [ + "Global/USA/SAN_FRANCISCO/BLD_SF", + "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR1", + "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR2" + ], + "secondary_managed_ap_locations": [ + "Global/USA/SAN_FRANCISCO/BLD_SF1", + "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR1", + "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR2" + ], + "rolling_ap_upgrade": { + "enable": true, + "ap_reboot_percentage": 25 + } + } + } + ] + } + } + ], + "update_wireless_controller_case_5": [ + { + "fabric_devices": { + "fabric_name": "Global/USA/SAN_FRANCISCO", + "device_config": [ + { + "device_ip": "204.1.4.1", + "device_roles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE", + "WIRELESS_CONTROLLER_NODE" + ], + "wireless_controller_settings": { + "enable": true, + "primary_managed_ap_locations": [ + "Global/USA/SAN_FRANCISCO/BLD_SF", + "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR1" + ], + "secondary_managed_ap_locations": [ + "Global/USA/SAN_FRANCISCO/BLD_SF1", + "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR1" + ], + "rolling_ap_upgrade": { + "enable": true, + "ap_reboot_percentage": 15 + } + } + } + ] + } + } + ], + "delete_wireless_controller_case_6": [ + { + "fabric_devices": { + "fabric_name": "Global/USA/SAN_FRANCISCO", + "device_config": [ + { + "device_ip": "204.1.4.1", + "device_roles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE", + "WIRELESS_CONTROLLER_NODE" + ], + "wireless_controller_settings": { + "enable": false, + "reload": true + } + } + ] + } + } + ], + "case_4_call_6": { + "response": [ + { + "id": "8a66e4d7-4923-4e8a-b4fe-53b5a3313e7d", + "siteHierarchyId": "72bb4dbb-1051-4561-8de8-7503bfa4cc59/3ff8b712-5734-47b7-9ae6-92ac7e3028ea/de6ea582-bd9a-441b-a999-f02ab3e8e8f8/8a66e4d7-4923-4e8a-b4fe-53b5a3313e7d", + "parentId": "de6ea582-bd9a-441b-a999-f02ab3e8e8f8", + "name": "BLD_SF", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF", + "type": "building", + "latitude": 37.398188, + "longitude": -121.912974 + } + ], + "version": "1.0" + }, + "case_4_call_7": { + "response": [ + { + "id": "54ee0ebd-5775-4711-8e5a-ca8008e93181", + "name": "FLOOR2", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR2", + "type": "floor" + }, + { + "id": "7cef4c11-8183-472e-ae9e-e4accd0ed27e", + "name": "FLOOR1", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR1", + "type": "floor" + } + ], + "version": "1.0" + }, + "case_4_call_8": { + "response": [ + { + "id": "7cef4c11-8183-472e-ae9e-e4accd0ed27e", + "name": "FLOOR1", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR1", + "type": "floor" + } + ], + "version": "1.0" + }, + "case_4_call_9": { + "response": [], + "version": "1.0" + }, + "case_4_call_10": { + "response": [ + { + "id": "54ee0ebd-5775-4711-8e5a-ca8008e93181", + "name": "FLOOR2", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR2", + "type": "floor" + } + ], + "version": "1.0" + }, + "case_4_call_11": { + "response": [], + "version": "1.0" + }, + "case_4_call_12": { + "response": [ + { + "id": "c9942624-5298-4f44-95c1-a1b1a8e84848", + "name": "BLD_SF1", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1", + "type": "building", + "latitude": 37.398188, + "longitude": -121.912974 + } + ], + "version": "1.0" + }, + "case_4_call_13": { + "response": [ + { + "id": "4a8ba938-077f-4b44-bf6c-52c1a224b174", + "name": "FLOOR2", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR2", + "type": "floor" + }, + { + "id": "feb46915-2d1c-4c28-8e29-ef8f6680050f", + "name": "FLOOR1", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR1", + "type": "floor" + } + ], + "version": "1.0" + }, + "case_4_call_14": { + "response": [ + { + "id": "feb46915-2d1c-4c28-8e29-ef8f6680050f", + "name": "FLOOR1", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR1", + "type": "floor" + } + ], + "version": "1.0" + }, + "case_4_call_15": { + "response": [], + "version": "1.0" + }, + "case_4_call_16": { + "response": [ + { + "id": "4a8ba938-077f-4b44-bf6c-52c1a224b174", + "name": "FLOOR2", + "nameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR2", + "type": "floor" + } + ], + "version": "1.0" + }, + "case_4_call_17": { + "response": [], + "version": "1.0" + }, + "get_sites_sf": { + "response": [ + { + "id": "de6ea582-bd9a-441b-a999-f02ab3e8e8f8", + "siteHierarchyId": "72bb4dbb-1051-4561-8de8-7503bfa4cc59/3ff8b712-5734-47b7-9ae6-92ac7e3028ea/de6ea582-bd9a-441b-a999-f02ab3e8e8f8", + "parentId": "3ff8b712-5734-47b7-9ae6-92ac7e3028ea", + "name": "SAN_FRANCISCO", + "nameHierarchy": "Global/USA/SAN_FRANCISCO", + "type": "area" + } + ], + "version": "1.0" + }, + "get_fabric_sites_sf": { + "response": [ + { + "id": "6149fe9c-41fd-4b84-beb0-ebfe98ca1067", + "siteId": "de6ea582-bd9a-441b-a999-f02ab3e8e8f8", + "authenticationProfileName": "No Authentication", + "isPubSubEnabled": true, + "underlayTransport": "IPV4" + } + ], + "version": "1.0" + }, + "get_device_list": { + "response": [ + { + "id": "dbf7eb5f-87de-459c-9b31-0ba9833bb303", + "instanceUuid": "dbf7eb5f-87de-459c-9b31-0ba9833bb303", + "managementIpAddress": "204.1.4.1", + "hostname": "SF-FIAB-01.autoagni.com", + "family": "Switches and Hubs", + "type": "Cisco Catalyst 9300 Switch", + "platformId": "C9300-24P", + "softwareVersion": "17.16.1", + "softwareType": "IOS-XE", + "reachabilityStatus": "Reachable", + "collectionStatus": "Managed", + "role": "DISTRIBUTION", + "series": "Cisco Catalyst 9300 Series Switches" + } + ], + "version": "1.0" + }, + "get_provisioned_devices": { + "response": [ + { + "id": "da9c924e-30c4-44df-90a2-0be57ee69bf9", + "siteId": "8a66e4d7-4923-4e8a-b4fe-53b5a3313e7d", + "networkDeviceId": "dbf7eb5f-87de-459c-9b31-0ba9833bb303" + } + ], + "version": "1.0" + }, + "get_fabric_devices_empty": { + "response": [], + "version": "1.0" + }, + "get_fabric_devices_border_cp_priority1": { + "response": [ + { + "id": "da9c924e-30c4-44df-90a2-0be57ee69bf9", + "fabricId": "6149fe9c-41fd-4b84-beb0-ebfe98ca1067", + "networkDeviceId": "dbf7eb5f-87de-459c-9b31-0ba9833bb303", + "deviceRoles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE" + ], + "borderDeviceSettings": { + "borderTypes": [ + "LAYER_3" + ], + "layer3Settings": { + "localAutonomousSystemNumber": "1235", + "importExternalRoutes": true, + "borderPriority": 1, + "prependAutonomousSystemCount": 1, + "isDefaultExit": true + } + } + } + ], + "version": "1.0" + }, + "get_fabric_devices_border_cp_priority2": { + "response": [ + { + "id": "da9c924e-30c4-44df-90a2-0be57ee69bf9", + "fabricId": "6149fe9c-41fd-4b84-beb0-ebfe98ca1067", + "networkDeviceId": "dbf7eb5f-87de-459c-9b31-0ba9833bb303", + "deviceRoles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE" + ], + "borderDeviceSettings": { + "borderTypes": [ + "LAYER_3" + ], + "layer3Settings": { + "localAutonomousSystemNumber": "1235", + "importExternalRoutes": true, + "borderPriority": 2, + "prependAutonomousSystemCount": 2, + "isDefaultExit": true + } + } + } + ], + "version": "1.0" + }, + "get_fabric_devices_wlc_have": { + "response": [ + { + "id": "da9c924e-30c4-44df-90a2-0be57ee69bf9", + "fabricId": "6149fe9c-41fd-4b84-beb0-ebfe98ca1067", + "networkDeviceId": "dbf7eb5f-87de-459c-9b31-0ba9833bb303", + "deviceRoles": [ + "BORDER_NODE", + "CONTROL_PLANE_NODE", + "WIRELESS_CONTROLLER_NODE" + ], + "borderDeviceSettings": { + "borderTypes": [ + "LAYER_3" + ], + "layer3Settings": { + "localAutonomousSystemNumber": "1235", + "importExternalRoutes": true, + "borderPriority": 1, + "prependAutonomousSystemCount": 1, + "isDefaultExit": true + } + } + } + ], + "version": "1.0" + }, + "get_sda_wireless_empty": { + "response": [], + "version": "1.0" + }, + "get_sda_wireless_enabled_25": { + "response": [ + { + "id": "dbf7eb5f-87de-459c-9b31-0ba9833bb303", + "fabricId": "6149fe9c-41fd-4b84-beb0-ebfe98ca1067", + "enableWireless": true, + "rollingApUpgrade": { + "enableRollingApUpgrade": true, + "apRebootPercentage": 25 + } + } + ], + "version": "1.0" + }, + "get_primary_ap_locations_empty": { + "response": [], + "version": "1.0" + }, + "get_secondary_ap_locations_empty": { + "response": [], + "version": "1.0" + }, + "get_primary_ap_locations_three": { + "response": { + "managedApLocations": [ + { + "siteId": "54ee0ebd-5775-4711-8e5a-ca8008e93181", + "siteNameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR2" + }, + { + "siteId": "7cef4c11-8183-472e-ae9e-e4accd0ed27e", + "siteNameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF/FLOOR1" + }, + { + "siteId": "8a66e4d7-4923-4e8a-b4fe-53b5a3313e7d", + "siteNameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF" + } + ] + }, + "version": "1.0" + }, + "get_secondary_ap_locations_three": { + "response": { + "managedApLocations": [ + { + "siteId": "4a8ba938-077f-4b44-bf6c-52c1a224b174", + "siteNameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR2" + }, + { + "siteId": "feb46915-2d1c-4c28-8e29-ef8f6680050f", + "siteNameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1/FLOOR1" + }, + { + "siteId": "c9942624-5298-4f44-95c1-a1b1a8e84848", + "siteNameHierarchy": "Global/USA/SAN_FRANCISCO/BLD_SF1" + } + ] + }, + "version": "1.0" + }, + "add_fabric_devices": { + "response": { + "taskId": "019dabe5-b8e0-7bbe-bf6a-136b35049f6d", + "url": "/dna/intent/api/v1/task/019dabe5-b8e0-7bbe-bf6a-136b35049f6d" + }, + "version": "1.0" + }, + "update_fabric_devices": { + "response": { + "taskId": "0195232e-717f-71b6-9d00-ce8a130c282e", + "url": "/dna/intent/api/v1/task/0195232e-717f-71b6-9d00-ce8a130c282e" + }, + "version": "1.0" + }, + "delete_fabric_device": { + "response": { + "taskId": "0195232e-717f-71b6-9d00-ce8a130c282f", + "url": "/dna/intent/api/v1/task/0195232e-717f-71b6-9d00-ce8a130c282f" + }, + "version": "1.0" + }, + "assign_managed_ap_locations": { + "response": { + "taskId": "0195232e-717f-71b6-9d00-ce8a130c283a", + "url": "/dna/intent/api/v1/task/0195232e-717f-71b6-9d00-ce8a130c283a" + }, + "version": "1.0" + }, + "switch_wireless_setting": { + "response": { + "taskId": "0195232e-717f-71b6-9d00-ce8a130c283b", + "url": "/dna/intent/api/v1/task/0195232e-717f-71b6-9d00-ce8a130c283b" + }, + "version": "1.0" + }, + "reload_switch": { + "response": { + "taskId": "0195232e-717f-71b6-9d00-ce8a130c283c", + "url": "/dna/intent/api/v1/task/0195232e-717f-71b6-9d00-ce8a130c283c" + }, + "version": "1.0" + }, + "task_success": { + "response": { + "id": "task-success-id", + "status": "SUCCESS", + "endTime": 1745200000000, + "startTime": 1745199999000 + }, + "version": "1.0" + } +} + diff --git a/tests/unit/modules/dnac/fixtures/sda_fabric_virtual_networks_workflow_manager.json b/tests/unit/modules/dnac/fixtures/sda_fabric_virtual_networks_workflow_manager.json index 16907c7837..58459531f9 100644 --- a/tests/unit/modules/dnac/fixtures/sda_fabric_virtual_networks_workflow_manager.json +++ b/tests/unit/modules/dnac/fixtures/sda_fabric_virtual_networks_workflow_manager.json @@ -537,6 +537,89 @@ "get_invalid_testbed_release":{ "message": "The specified version '2.3.5.3' does not support the SDA fabric devices feature. Supported versions start from '2.3.7.6' onwards." + }, + + "playbook_config_create_fabric_vlan_with_multiple_ip_to_mac": [ + { + "fabric_vlan": [ + { + "vlan_name": "vlan_multi_ip", + "fabric_site_locations": [ + { + "site_name_hierarchy": "Global/India/Fabric_Test", + "fabric_type": "fabric_site" + } + ], + "vlan_id": 1944, + "traffic_type": "DATA", + "fabric_enabled_wireless": false, + "multiple_ip_to_mac_addresses": true, + "associated_layer3_virtual_network": "L3_VN_1" + } + ] + } + ], + + "playbook_config_update_fabric_vlan_multiple_ip_to_mac": [ + { + "fabric_vlan": [ + { + "vlan_name": "vlan_multi_ip", + "fabric_site_locations": [ + { + "site_name_hierarchy": "Global/India/Fabric_Test", + "fabric_type": "fabric_site" + } + ], + "vlan_id": 1944, + "traffic_type": "DATA", + "fabric_enabled_wireless": false, + "multiple_ip_to_mac_addresses": true, + "associated_layer3_virtual_network": "L3_VN_1" + } + ] + } + ], + + "get_empty_fabric_vlan_multi_ip_response": { + "response": [], + "version": "1.0" + }, + + "get_fabric_vlan_multi_ip_response": { + "response": [ + { + "id": "aa629091-0592-440a-9dd8-e2274327b99c", + "fabricId": "879173be-e21f-472d-bc78-06407f9c5091", + "vlanName": "vlan_multi_ip", + "vlanId": 1944, + "trafficType": "DATA", + "isFabricEnabledWireless": false, + "isWirelessFloodingEnabled": false, + "isResourceGuardEnabled": false, + "isMultipleIpToMacAddresses": false, + "layer2FloodingAddressAssignment": "SHARED" + } + ], + "version": "1.0" + }, + + "get_fabric_vlan_multi_ip_created_response": { + "response": [ + { + "id": "aa629091-0592-440a-9dd8-e2274327b99c", + "fabricId": "879173be-e21f-472d-bc78-06407f9c5091", + "vlanName": "vlan_multi_ip", + "vlanId": 1944, + "trafficType": "DATA", + "isFabricEnabledWireless": false, + "isWirelessFloodingEnabled": false, + "isResourceGuardEnabled": false, + "isMultipleIpToMacAddresses": true, + "layer2FloodingAddressAssignment": "SHARED" + } + ], + "version": "1.0" } } diff --git a/tests/unit/modules/dnac/fixtures/tags_workflow_manager.json b/tests/unit/modules/dnac/fixtures/tags_workflow_manager.json index af770f728d..ebb37be947 100644 --- a/tests/unit/modules/dnac/fixtures/tags_workflow_manager.json +++ b/tests/unit/modules/dnac/fixtures/tags_workflow_manager.json @@ -1,7 +1,7 @@ { "create_a_tag_with_device_port_rules_case_1": [ { - "tag": { + "tags": [{ "name": "ServersTag", "description": "Tag for devices and interfaces connected to servers", "system_tag": false, @@ -86,7 +86,7 @@ } ] } - } + }] } ], "get_tag_case_1_call_1": { @@ -259,9 +259,9 @@ }, "delete_a_tag_with_device_port_rules_case_2": [ { - "tag": { + "tags": [{ "name": "ServersTag" - } + }] } ], "get_tag_case_2_call_1": { @@ -417,10 +417,10 @@ }, "force_delete_a_tag_with_device_port_rules_case_3": [ { - "tag": { + "tags": [{ "name": "ServersTag", "force_delete": true - } + }] } ], "get_tag_case_3_call_1": { @@ -894,7 +894,7 @@ }, "update_scope_of_a_tag_with_only_port_rule_case_4": [ { - "tag": { + "tags": [{ "name": "ServersTag", "description": "Tag for devices and interfaces connected to servers", "system_tag": false, @@ -946,7 +946,7 @@ ] } } - } + }] } ], "get_tag_case_4_call_1": { @@ -1168,7 +1168,7 @@ }, "update_scope_members_of_tag_with_device_ports_rules_case_5": [ { - "tag": { + "tags": [{ "name": "ServersTag", "description": "Tag for devices and interfaces connected to servers", "system_tag": false, @@ -1180,7 +1180,7 @@ ] } } - } + }] } ], "get_tag_case_5_call_1": { @@ -1477,14 +1477,14 @@ }, "name_not_provided_case_6": [ { - "tag": { + "tags": [{ "description": "Tag for devices and interfaces connected to servers" - } + }] } ], "rule_description_not_provided_properly_in_device_rules_case_7": [ { - "tag": { + "tags": [{ "name": "Servers_Connected_Devices_and_Interfaces", "description": "Tag for devices and interfaces connected to servers", "device_rules": { @@ -1494,12 +1494,12 @@ } ] } - } + }] } ], "rule_description_not_provided_properly_in_port_rules_case_8": [ { - "tag": { + "tags": [{ "name": "Servers_Connected_Devices_and_Interfaces", "description": "Tag for devices and interfaces connected to servers", "port_rules": { @@ -1509,12 +1509,12 @@ } ] } - } + }] } ], "scope_category_not_provided_case_9": [ { - "tag": { + "tags": [{ "name": "Servers_Connected_Devices_and_Interfaces", "description": "Tag for devices and interfaces connected to servers", "port_rules": { @@ -1524,12 +1524,12 @@ ] } } - } + }] } ], "not_enough_details_provided_in_device_details_in_tag_memberships_case_10": [ { - "tag_memberships": { + "tag_memberships": [{ "tags": [ "Servers_Connected_Devices_and_Interfaces" ], @@ -1541,7 +1541,7 @@ ] } ] - } + }] } ], "get_tag_case_10_call_1": { @@ -1557,7 +1557,7 @@ }, "tags_not_provided_in_tag_memberships_case_11": [ { - "tag_memberships": { + "tag_memberships": [{ "device_details": [ { "port_names": [ @@ -1581,12 +1581,12 @@ "interface_tag_retrieval_batch_size": 1, "network_device_tag_update_batch_size": 1, "interface_tag_update_batch_size": 1 - } + }] } ], "site_names_not_provided_in_tag_memberships_case_12": [ { - "tag_memberships": { + "tag_memberships": [{ "tags": ["Servers_Connected_Devices_and_Interfaces"], "site_details": [ { @@ -1596,12 +1596,12 @@ ] } ] - } + }] } ], "updating_only_port_rules_description_when_no_port_rules_are_present_case_13": [ { - "tag": { + "tags": [{ "name": "Test1011", "description": "Test tag created for unit testing.", "port_rules": { @@ -1614,15 +1614,15 @@ } ] } - } + }] } ], "updating_tag_name_case_14": [ { - "tag": { + "tags": [{ "name": "Test_tag_update", "new_name": "Test_tag_update_renamed" - } + }] } ], "get_tag_case_14_call_1": { diff --git a/tests/unit/modules/dnac/fixtures/user_role_workflow_manager.json b/tests/unit/modules/dnac/fixtures/user_role_workflow_manager.json index 9ee8842ed9..afaae16b62 100644 --- a/tests/unit/modules/dnac/fixtures/user_role_workflow_manager.json +++ b/tests/unit/modules/dnac/fixtures/user_role_workflow_manager.json @@ -11,6 +11,7 @@ }, "playbook_config_delete_existing_user": { "user_details": [{ + "username": "ajithandrewj", "first_name": "ajith", "last_name": "andrew", "email": "ajith.andrew@example.com", diff --git a/tests/unit/modules/dnac/test_application_policy_workflow_manager.py b/tests/unit/modules/dnac/test_application_policy_workflow_manager.py index 3630cc9d4d..17a0504838 100644 --- a/tests/unit/modules/dnac/test_application_policy_workflow_manager.py +++ b/tests/unit/modules/dnac/test_application_policy_workflow_manager.py @@ -65,6 +65,61 @@ class TestDnacApplicationPolicyWorkflowManager(TestDnacModule): playbook_error_7 = test_data.get("playbook_error_7") playbook_error_8 = test_data.get("playbook_error_8") + def _build_current_policy_entry( + self, name, policy_scope="MyPolicy", relevance_level="BUSINESS_RELEVANT" + ): + return { + "name": name, + "policyScope": policy_scope, + "advancedPolicyScope": { + "advancedPolicyScopeElement": [{"groupId": ["site-id-1"]}] + }, + "exclusiveContract": {"clause": [{"relevanceLevel": relevance_level}]}, + } + + def _build_application_policy_for_update_check( + self, current_application_policy, wanted_set_names, policy_name="MyPolicy" + ): + manager = self.module.ApplicationPolicy.__new__( + self.module.ApplicationPolicy + ) + manager.have = { + "current_application_policy": current_application_policy + } + manager.want = {"application_policy": {"name": policy_name}} + manager.config = { + "application_policy": { + "name": policy_name, + "application_queuing_profile_name": "queue1", + "site_names": ["SiteA"], + "clause": [ + { + "clause_type": "BUSINESS_RELEVANCE", + "relevance_details": [ + { + "relevance": "BUSINESS_RELEVANT", + "application_set_name": wanted_set_names, + } + ], + } + ], + } + } + manager.msg = None + manager.status = "success" + manager.no_update_application_policy = [] + manager.log = lambda *args, **kwargs: None + manager.get_site_id = lambda site_name: (True, "site-id-1") + manager.check_return_status = lambda: manager + + def set_operation_result(status, changed, msg, level): + manager.status = status + manager.msg = msg + return manager + + manager.set_operation_result = set_operation_result + return manager + def setUp(self): super(TestDnacApplicationPolicyWorkflowManager, self).setUp() @@ -411,6 +466,65 @@ def load_fixtures(self, response=None, device=""): self.test_data.get("response14"), ] + def test_extract_app_set_name_strips_prefix(self): + """ + Verify that the policyScope prefix is correctly stripped. + """ + + manager = self._build_application_policy_for_update_check([], []) + entry = {"name": "MyPolicy_collaboration-apps", "policyScope": "MyPolicy"} + + result = manager._extract_app_set_name(entry) + + self.assertEqual(result, "collaboration-apps") + + def test_exact_match_rejects_substring(self): + """ + Verify that substring application set names do not count as exact matches. + """ + + manager = self._build_application_policy_for_update_check( + [ + self._build_current_policy_entry( + "MyPolicy_email-security", policy_scope="MyPolicy" + ) + ], + ["email"], + ) + + self.assertTrue(manager.is_update_required_for_application_policy()) + + def test_extract_app_set_name_missing_scope(self): + """ + When policyScope is empty, the full application set name is returned. + """ + + manager = self._build_application_policy_for_update_check([], []) + entry = {"name": "collaboration-apps", "policyScope": ""} + + result = manager._extract_app_set_name(entry) + + self.assertEqual(result, "collaboration-apps") + + def test_no_update_when_sets_match(self): + """ + Verify no update is required when current and wanted application sets match. + """ + + manager = self._build_application_policy_for_update_check( + [ + self._build_current_policy_entry( + "MyPolicy_email", policy_scope="MyPolicy" + ), + self._build_current_policy_entry( + "MyPolicy_tunneling", policy_scope="MyPolicy" + ), + ], + ["email", "tunneling"], + ) + + self.assertFalse(manager.is_update_required_for_application_policy()) + def test_application_policy_workflow_manager_playbook_create_profile(self): """ Test the Application Policy Workflow Manager's profile creation process. diff --git a/tests/unit/modules/dnac/test_events_and_notifications_playbook_config_generator.py b/tests/unit/modules/dnac/test_events_and_notifications_playbook_config_generator.py index 3c5176a133..21f7da1769 100644 --- a/tests/unit/modules/dnac/test_events_and_notifications_playbook_config_generator.py +++ b/tests/unit/modules/dnac/test_events_and_notifications_playbook_config_generator.py @@ -282,7 +282,7 @@ def test_events_and_notifications_playbook_itsm(self): config=self.playbook_itsm ) ) - result = self.execute_module(changed=True, failed=False) + result = self.execute_module(changed=False, failed=False) print(result) self.assertEqual( result.get("response"), @@ -291,7 +291,11 @@ def test_events_and_notifications_playbook_itsm(self): "components_skipped": 0, "configurations_count": 2, "file_path": "/tmp/events_and_notifications_playbook1", - "message": "YAML configuration file generated successfully for module 'events_and_notifications_workflow_manager'", + "message": ( + "YAML configuration file already up-to-date for module" + " 'events_and_notifications_workflow_manager'." + " No changes written." + ), "status": "success" } ) diff --git a/tests/unit/modules/dnac/test_inventory_playbook_config_generator.py b/tests/unit/modules/dnac/test_inventory_playbook_config_generator.py index ad5ad3f59c..93a97eee68 100644 --- a/tests/unit/modules/dnac/test_inventory_playbook_config_generator.py +++ b/tests/unit/modules/dnac/test_inventory_playbook_config_generator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Cisco and/or its affiliates. +# 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. @@ -13,192 +13,45 @@ # limitations under the License. # Authors: -# Mridul Saurabh -# Madhan Sankaranarayanan +# Mridul Saurabh (@msaurabh12) +# Sunil Shatagopa (@shatagopasunil) +# Madhan Sankaranarayanan (@madhansansel) # # Description: # Unit tests for the Ansible module `inventory_playbook_config_generator`. -# These tests cover various brownfield inventory scenarios such as complete -# discovery, device filtering by IP, hostname, serial number, MAC address, -# role-based filtering, combined filters, and multiple device groups. +# These tests cover auto-discovery, device filters, role filters, +# combined filters, no-data behavior, idempotency behavior, and validation errors. from __future__ import absolute_import, division, print_function __metaclass__ = type -import copy -from unittest.mock import MagicMock, patch -from ansible_collections.cisco.dnac.plugins.modules import inventory_playbook_config_generator -from .dnac_module import TestDnacModule, set_module_args as base_set_module_args, loadPlaybookData - - -def set_module_args(args): - """ - Normalize legacy inventory test inputs to the current module contract. - - The inventory module now expects: - - config to be a dict, not a one-item list - - file_path/file_mode at top level - - config to contain only global_filters/component_specific_filters - - dnac_version >= 2.3.7.9 - """ - normalized_args = copy.deepcopy(args) - - config = normalized_args.get("config") - if isinstance(config, list) and len(config) == 1 and isinstance(config[0], dict): - config = copy.deepcopy(config[0]) - - if isinstance(config, dict): - file_path = config.pop("file_path", None) - file_mode = config.pop("file_mode", None) - if file_path is not None and "file_path" not in normalized_args: - normalized_args["file_path"] = file_path - if file_mode is not None and "file_mode" not in normalized_args: - normalized_args["file_mode"] = file_mode - - # generate_all_configurations is no longer accepted inside config. - generate_all = config.pop("generate_all_configurations", None) - if generate_all is True and not config: - config = None - - normalized_args["config"] = config - - dnac_version = normalized_args.get("dnac_version") - if isinstance(dnac_version, str): - try: - version_value = int(dnac_version.replace(".", "")) - except ValueError: - version_value = None - if version_value is not None and version_value < 2379: - normalized_args["dnac_version"] = "2.3.7.9" - return base_set_module_args(normalized_args) +from unittest.mock import patch, mock_open +from ansible_collections.cisco.dnac.plugins.modules import inventory_playbook_config_generator +from .dnac_module import TestDnacModule, set_module_args, loadPlaybookData -class TestBrownfieldInventoryPlaybookGenerator(TestDnacModule): - """ - Test class for inventory_playbook_config_generator module. - Tests all scenarios defined in the JSON fixture file. - """ +class TestInventoryPlaybookConfigGenerator(TestDnacModule): module = inventory_playbook_config_generator - test_data = loadPlaybookData("inventory_playbook_config_generator_fixtures") - - # Load all test configurations from fixtures - playbook_config_scenario1_complete_infrastructure_generate_all_device_configurations = test_data.get( - "playbook_config_scenario1_complete_infrastructure_generate_all_device_configurations" - ) - playbook_config_scenario2_specific_devices_by_ip_address_list = test_data.get( - "playbook_config_scenario2_specific_devices_by_ip_address_list" - ) - playbook_config_scenario3_devices_by_hostname_list = test_data.get( - "playbook_config_scenario3_devices_by_hostname_list" - ) - playbook_config_scenario4_devices_by_serial_number_list = test_data.get( - "playbook_config_scenario4_devices_by_serial_number_list" - ) - playbook_config_scenario5_devices_by_mac_address_list = test_data.get( - "playbook_config_scenario5_devices_by_mac_address_list" - ) - playbook_config_scenario6_devices_by_role_access = test_data.get( - "playbook_config_scenario6_devices_by_role_access" - ) - playbook_config_scenario7_devices_by_role_core = test_data.get( - "playbook_config_scenario7_devices_by_role_core" - ) - playbook_config_scenario8_combined_filters_multiple_criteria = test_data.get( - "playbook_config_scenario8_combined_filters_multiple_criteria" - ) - playbook_config_scenario9_multiple_device_groups = test_data.get( - "playbook_config_scenario9_multiple_device_groups" - ) - playbook_config_scenario10_provision_devices_by_site_with_role_filter = test_data.get( - "playbook_config_scenario10_provision_devices_by_site_with_role_filter" - ) - playbook_config_scenario11_multiple_roles = test_data.get( - "playbook_config_scenario11_multiple_roles" - ) - playbook_config_scenario12_global_filter_plus_site_filter = test_data.get( - "playbook_config_scenario12_global_filter_plus_site_filter" - ) - playbook_config_scenario13_interface_details_single_interface_name_filter = test_data.get( - "playbook_config_scenario13_interface_details_single_interface_name_filter" - ) - playbook_config_scenario14_interface_details_multiple_interface_name_filters = test_data.get( - "playbook_config_scenario14_interface_details_multiple_interface_name_filters" - ) - playbook_config_scenario15_global_ip_filter_plus_interface_name_filter = test_data.get( - "playbook_config_scenario15_global_ip_filter_plus_interface_name_filter" - ) - playbook_config_scenario16_device_details_plus_filtered_interfaces = test_data.get( - "playbook_config_scenario16_device_details_plus_filtered_interfaces" - ) - playbook_config_scenario17_all_components_with_interface_filter = test_data.get( - "playbook_config_scenario17_all_components_with_interface_filter" - ) - playbook_config_scenario18_interface_filter_no_match_handling = test_data.get( - "playbook_config_scenario18_interface_filter_no_match_handling" - ) - playbook_config_scenario19_gigabitethernet_interfaces_only = test_data.get( - "playbook_config_scenario19_gigabitethernet_interfaces_only" - ) - playbook_config_scenario20_access_devices_with_interface_filter = test_data.get( - "playbook_config_scenario20_access_devices_with_interface_filter" - ) - playbook_config_scenario21_user_defined_fields_only = test_data.get( - "playbook_config_scenario21_user_defined_fields_only" - ) - playbook_config_scenario22_all_components_including_user_defined_fields = test_data.get( - "playbook_config_scenario22_all_components_including_user_defined_fields" - ) - playbook_config_scenario23_device_details_plus_user_defined_fields = test_data.get( - "playbook_config_scenario23_device_details_plus_user_defined_fields" - ) - playbook_config_scenario24_global_ip_filter_plus_user_defined_fields = test_data.get( - "playbook_config_scenario24_global_ip_filter_plus_user_defined_fields" - ) - playbook_config_scenario25_provision_device_plus_user_defined_fields = test_data.get( - "playbook_config_scenario25_provision_device_plus_user_defined_fields" - ) - playbook_config_scenario26_interface_details_plus_user_defined_fields = test_data.get( - "playbook_config_scenario26_interface_details_plus_user_defined_fields" - ) - playbook_config_scenario27_interface_filter_plus_user_defined_fields = test_data.get( - "playbook_config_scenario27_interface_filter_plus_user_defined_fields" - ) - playbook_config_scenario28_role_based_device_details_plus_user_defined_fields = test_data.get( - "playbook_config_scenario28_role_based_device_details_plus_user_defined_fields" - ) - playbook_config_scenario29_complex_multi_filter_with_user_defined_fields = test_data.get( - "playbook_config_scenario29_complex_multi_filter_with_user_defined_fields" - ) - playbook_config_scenario30_udf_audit_all_devices_with_custom_metadata = test_data.get( - "playbook_config_scenario30_udf_audit_all_devices_with_custom_metadata" - ) - playbook_config_scenario31_udf_name_filter_specific_field_names = test_data.get( - "playbook_config_scenario31_udf_name_filter_specific_field_names" - ) - playbook_config_scenario32_udf_value_filter_specific_field_values = test_data.get( - "playbook_config_scenario32_udf_value_filter_specific_field_values" - ) - playbook_config_scenario33_global_ip_filter_plus_udf_name_filter = test_data.get( - "playbook_config_scenario33_global_ip_filter_plus_udf_name_filter" - ) - playbook_config_scenario34_device_details_plus_filtered_udf_names = test_data.get( - "playbook_config_scenario34_device_details_plus_filtered_udf_names" - ) - playbook_config_scenario35_all_components_plus_udf_name_and_value_filters = test_data.get( - "playbook_config_scenario35_all_components_plus_udf_name_and_value_filters" - ) - playbook_config_scenario36_udf_name_filter_single_string = test_data.get( - "playbook_config_scenario36_udf_name_filter_single_string" - ) - playbook_config_scenario37_udf_value_filter_single_string = test_data.get( - "playbook_config_scenario37_udf_value_filter_single_string" - ) + test_data = loadPlaybookData("inventory_playbook_config_generator") + + playbook_config_generate_all_configurations = test_data.get("playbook_config_generate_all_configurations") + playbook_config_devices_by_ip = test_data.get("playbook_config_devices_by_ip") + playbook_config_devices_by_hostname = test_data.get("playbook_config_devices_by_hostname") + playbook_config_devices_by_serial = test_data.get("playbook_config_devices_by_serial") + playbook_config_devices_by_mac = test_data.get("playbook_config_devices_by_mac") + playbook_config_filter_by_role = test_data.get("playbook_config_filter_by_role") + playbook_config_combined_filters = test_data.get("playbook_config_combined_filters") + playbook_config_empty_global_filters = test_data.get("playbook_config_empty_global_filters") + playbook_config_included_component_specific_filters = test_data.get("playbook_config_included_component_specific_filters") + playbook_config_unknown_filter_ignored = test_data.get("playbook_config_unknown_filter_ignored") + playbook_config_no_matching_devices = test_data.get("playbook_config_no_matching_devices") + playbook_config_empty_config = test_data.get("playbook_config_empty_config") + playbook_config_null_global_filters = test_data.get("playbook_config_null_global_filters") def setUp(self): - """Set up test fixtures and mocks.""" - super(TestBrownfieldInventoryPlaybookGenerator, self).setUp() + super(TestInventoryPlaybookConfigGenerator, self).setUp() self.mock_dnac_init = patch( "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.__init__" @@ -206,1278 +59,285 @@ def setUp(self): self.run_dnac_init = self.mock_dnac_init.start() self.run_dnac_init.side_effect = [None] - self.mock_dnac_exec = patch( - "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK._exec" + self.mock_get_with_pagination = patch( + "ansible_collections.cisco.dnac.plugins.modules." + "inventory_playbook_config_generator.InventoryPlaybookConfigGenerator.execute_get_with_pagination" ) - self.run_dnac_exec = self.mock_dnac_exec.start() + self.run_get_with_pagination = self.mock_get_with_pagination.start() - # Mock file operations - self.mock_open = patch("builtins.open", create=True) - self.run_open = self.mock_open.start() + self.mock_write_yaml = patch( + "ansible_collections.cisco.dnac.plugins.modules." + "inventory_playbook_config_generator.InventoryPlaybookConfigGenerator.write_dict_to_yaml" + ) + self.run_write_yaml = self.mock_write_yaml.start() self.load_fixtures() def tearDown(self): - """Clean up mocks.""" - super(TestBrownfieldInventoryPlaybookGenerator, self).tearDown() - self.mock_dnac_exec.stop() + super(TestInventoryPlaybookConfigGenerator, self).tearDown() + self.mock_write_yaml.stop() + self.mock_get_with_pagination.stop() self.mock_dnac_init.stop() - self.mock_open.stop() def load_fixtures(self, response=None, device=""): """ - Load fixtures for each scenario. - """ - configured_responses = None - - if "no_config_defaults_generate_all" in self._testMethodName: - configured_responses = [ - self.test_data.get("get_all_devices_response") - ] - - elif "component_filter_auto_adds_components_list" in self._testMethodName: - configured_responses = [ - self.test_data.get("get_all_devices_response") - ] - - elif "scenario1_complete_infrastructure" in self._testMethodName: - # Scenario 1: All devices - configured_responses = [ - self.test_data.get("get_all_devices_response") - ] - - elif "scenario2_specific_devices_by_ip_address" in self._testMethodName: - # Scenario 2: Specific IPs - configured_responses = [ - self.test_data.get("get_filtered_devices_by_ip_response") - ] - - elif "scenario3_devices_by_hostname" in self._testMethodName: - # Scenario 3: Hostname filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_hostname_response") - ] - - elif "scenario4_devices_by_serial_number" in self._testMethodName: - # Scenario 4: Serial number filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_serial_response") - ] - - elif "scenario5_devices_by_mac_address" in self._testMethodName: - # Scenario 5: MAC address filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_mac_response") - ] - - elif "scenario6_devices_by_role_access" in self._testMethodName: - # Scenario 6: ACCESS role filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_access_role_response") - ] - - elif "scenario7_devices_by_role_core" in self._testMethodName: - # Scenario 7: CORE role filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_core_role_response") - ] - - elif "scenario8_combined_filters" in self._testMethodName: - # Scenario 8: Combined filters (IP + role) - configured_responses = [ - self.test_data.get("get_filtered_devices_combined_response") - ] - - elif "scenario9_multiple_device_groups" in self._testMethodName: - # Scenario 9: Multiple groups - configured_responses = [ - self.test_data.get("get_filtered_devices_by_access_role_response"), - self.test_data.get("get_filtered_devices_by_core_role_response") - ] - - elif "scenario10_provision_devices_by_site" in self._testMethodName: - # Scenario 10: Site-based provisioning with role filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_site_response") - ] - - elif "scenario11_multiple_roles" in self._testMethodName: - # Scenario 11: Multiple roles filter - configured_responses = [ - self.test_data.get("get_filtered_devices_multi_role_response") - ] - - elif "scenario12_global_filter_plus_site" in self._testMethodName: - # Scenario 12: Global filter + site filter - configured_responses = [ - self.test_data.get("get_filtered_devices_global_site_filter_response") - ] - - elif "scenario13_interface_details_single_interface" in self._testMethodName: - # Scenario 13: Interface filter - Single VLAN100 - configured_responses = [ - self.test_data.get("get_filtered_devices_by_interface_name_vlan100_response") - ] - - elif "scenario14_interface_details_multiple_interface" in self._testMethodName: - # Scenario 14: Interface filter - Multiple interfaces - configured_responses = [ - self.test_data.get("get_filtered_devices_by_interface_name_multi_response") - ] - - elif "scenario15_global_ip_filter_plus_interface_name" in self._testMethodName: - # Scenario 15: IP filter + Interface filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_ip_response") - ] - - elif "scenario16_device_details_plus_filtered_interfaces" in self._testMethodName: - # Scenario 16: Device details + filtered interfaces - configured_responses = [ - self.test_data.get("get_filtered_devices_by_interface_name_loopback_response") - ] - - elif "scenario17_all_components_with_interface_filter" in self._testMethodName: - # Scenario 17: All components with interface filter - configured_responses = [ - self.test_data.get("get_all_devices_response") - ] - - elif "scenario18_interface_filter_no_match_handling" in self._testMethodName: - # Scenario 18: Interface filter - No match handling - configured_responses = [ - {"response": []} - ] - - elif "scenario19_gigabitethernet_interfaces_only" in self._testMethodName: - # Scenario 19: GigabitEthernet filter - configured_responses = [ - self.test_data.get("get_filtered_devices_by_interface_name_gigabitethernet_response") - ] - - elif "scenario20_access_devices_with_interface_filter" in self._testMethodName: - # Scenario 20: ACCESS role devices with interface filter - configured_responses = [ - self.test_data.get("get_filtered_devices_access_with_interface_filter_response") - ] - - elif "scenario21_user_defined_fields_only" in self._testMethodName: - # Scenario 21: UDF only - configured_responses = [ - self.test_data.get("get_all_devices_with_user_defined_fields_response"), - self.test_data.get("get_all_user_defined_fields_response") - ] - - elif "scenario31_udf_name_filter_specific_field_names" in self._testMethodName: - # Scenario 31: UDF name list filter - configured_responses = [ - self.test_data.get("get_all_devices_with_user_defined_fields_response"), - self.test_data.get("get_all_user_defined_fields_response") - ] - - elif "scenario32_udf_value_filter_specific_field_values" in self._testMethodName: - # Scenario 32: UDF value list filter - configured_responses = [ - self.test_data.get("get_all_devices_with_user_defined_fields_response"), - self.test_data.get("get_all_user_defined_fields_response") - ] - - elif "scenario36_udf_name_filter_single_string" in self._testMethodName: - # Scenario 36: UDF name string filter - configured_responses = [ - self.test_data.get("get_all_devices_with_user_defined_fields_response"), - self.test_data.get("get_all_user_defined_fields_response") - ] - - elif "scenario37_udf_value_filter_single_string" in self._testMethodName: - # Scenario 37: UDF value string filter - configured_responses = [ - self.test_data.get("get_all_devices_with_user_defined_fields_response"), - self.test_data.get("get_all_user_defined_fields_response") - ] - - if configured_responses: - primary_response = configured_responses[0] - udf_metadata_response = ( - configured_responses[1] if len(configured_responses) > 1 else None - ) - interface_inventory_by_ip = {} - - for fixture_value in self.test_data.values(): - if not isinstance(fixture_value, dict): - continue - records = fixture_value.get("response") - if isinstance(records, dict): - records = [records] - if not isinstance(records, list): - continue - for record in records: - if not isinstance(record, dict): - continue - ip_address = record.get("managementIpAddress") or record.get("ipAddress") - interfaces = record.get("interfaces") - if ip_address and isinstance(interfaces, list): - interface_inventory_by_ip.setdefault(ip_address, []) - existing_interfaces = interface_inventory_by_ip[ip_address] - existing_signatures = { - ( - item.get("name"), - item.get("description"), - item.get("adminStatus"), - item.get("vlanId"), - item.get("nativeVlanId"), - item.get("voiceVlan"), - ) - for item in existing_interfaces - if isinstance(item, dict) - } - for interface in interfaces: - if not isinstance(interface, dict): - continue - signature = ( - interface.get("name"), - interface.get("description"), - interface.get("adminStatus"), - interface.get("vlanId"), - interface.get("nativeVlanId"), - interface.get("voiceVlan"), - ) - if signature in existing_signatures: - continue - existing_interfaces.append(interface) - existing_signatures.add(signature) - - def mock_dnac_exec(family, function, op_modifies=False, params=None): - params = params or {} - - if function == "get_all_user_defined_fields": - return udf_metadata_response or {"response": []} - - if function == "get_device_list": - return primary_response or {"response": []} - - if function == "get_network_device_by_ip": - ip_address = params.get("ip_address") - records = (primary_response or {}).get("response", []) - if isinstance(records, dict): - records = [records] - for record in records: - if ( - isinstance(record, dict) - and ( - record.get("managementIpAddress") == ip_address - or record.get("ipAddress") == ip_address - ) - ): - return {"response": record} - return {"response": None} - - if function == "get_interface_by_ip": - ip_address = params.get("ip_address") - return {"response": interface_inventory_by_ip.get(ip_address, [])} - - if function == "get_assigned_site_for_device": - return { - "response": { - "siteNameHierarchy": "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1" - } - } - - if function == "get_provisioned_wired_device": - return { - "response": { - "status": "success", - "description": "mock provisioned device", - "deviceManagementIpAddress": params.get("device_management_ip_address"), - "siteNameHierarchy": "Global/Site_India/Karnataka/Bangalore/BLD_2/Floor_1", - } - } - - if function == "retrieve_network_devices": - return primary_response or {"response": []} - - return {"response": []} - - self.run_dnac_exec.side_effect = mock_dnac_exec - - def execute_module(self, failed=False, changed=False, response=None, sort=True, device=""): - """Normalize expectations for current inventory generator behavior.""" - no_data_scenarios = ( - "device_not_found", - "scenario18_interface_filter_no_match_handling", - ) - if not failed: - changed = not any(name in self._testMethodName for name in no_data_scenarios) - - result = super(TestBrownfieldInventoryPlaybookGenerator, self).execute_module( - failed=failed, - changed=changed, - response=response, - sort=sort, - device=device - ) - - if failed: - return result - - scenario_expectations = { - "scenario2_specific_devices_by_ip_address": {"device_count": 2}, - "scenario3_devices_by_hostname": {"device_count": 3}, - "scenario4_devices_by_serial_number": {"device_count": 3}, - "scenario5_devices_by_mac_address": {"device_count": 2}, - "scenario6_devices_by_role_access": {"role_filter": "ACCESS"}, - "scenario7_devices_by_role_core": {"role_filter": "CORE"}, - "scenario8_combined_filters": {"device_count": 1}, - "scenario9_multiple_device_groups": {"total_device_count": 5}, - "scenario10_provision_devices_by_site": {"device_count": 2}, - "scenario11_multiple_roles": {"device_count": 5}, - "scenario12_global_filter_plus_site": {"device_count": 2}, - "scenario13_interface_details_single_interface": {"filter_type": "interface_name"}, - "scenario14_interface_details_multiple_interface": {"interface_count": 3}, - "scenario15_global_ip_filter_plus_interface_name": {"ip_count": 3}, - "scenario16_device_details_plus_filtered_interfaces": {"components_count": 2}, - "scenario17_all_components_with_interface_filter": {"components_count": 3}, - "scenario18_interface_filter_no_match_handling": {"device_count": 0}, - "scenario19_gigabitethernet_interfaces_only": {"interface_type": "GigabitEthernet"}, - "scenario20_access_devices_with_interface_filter": {"role_filter": "ACCESS"}, - } - - for scenario_name, expectation in scenario_expectations.items(): - if scenario_name in self._testMethodName: - result.update(expectation) - break - - msg = result.get("msg", "") - if isinstance(msg, dict): - if "NO_DATA_TO_GENERATE" in str(msg): - result["msg"] = "NO_DATA_TO_GENERATE" - else: - result["msg"] = "configuration generated successfully" - - return result - - def _build_generator(self, extra_params=None): - """Create a module instance for validate_input-focused tests.""" - params = { - "dnac_host": "192.168.1.1", - "dnac_username": "admin", - "dnac_password": "admin123", - "dnac_verify": False, - "dnac_port": 443, - "dnac_version": "2.3.7.9", - "dnac_debug": False, - "dnac_log": False, - "dnac_log_level": "INFO", - "dnac_log_file_path": "dnac.log", - "dnac_log_append": True, - "validate_response_schema": True, - "dnac_api_task_timeout": 1200, - "dnac_task_poll_interval": 2, - "state": "gathered", - "file_mode": "overwrite", - "config": None, - } - if extra_params: - params.update(extra_params) - - module = MagicMock() - module.params = params - module.deprecate = MagicMock() - return inventory_playbook_config_generator.InventoryPlaybookConfigGenerator(module) - - def test_inventory_playbook_config_generator_scenario1_complete_infrastructure(self): - """ - Test case for scenario 1: Complete Infrastructure - Generate All Device Configurations - - Description: Auto-discovers and generates configurations for ALL devices in - Cisco Catalyst Center across all device types (Network, Compute, etc.) - Use Case: Initial migration, complete infrastructure backup, disaster recovery - Output: Single consolidated YAML with all device IPs, hostnames, serial numbers - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="DEBUG", - state="gathered", - config=self.playbook_config_scenario1_complete_infrastructure_generate_all_device_configurations - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn("configuration generated successfully", result.get('msg', '').lower() or "success" in result.get('msg', '').lower()) - - def test_inventory_playbook_config_generator_scenario2_specific_devices_by_ip_address(self): - """ - Test case for scenario 2: Specific Devices by IP Address List - - Description: Generate configurations for specific devices using IP addresses - Use Case: Targeted device migration, specific site provisioning - Output: YAML with configurations for specified IP addresses only - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario2_specific_devices_by_ip_address_list - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "2", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario3_devices_by_hostname(self): - """ - Test case for scenario 3: Devices by Hostname List - - Description: Generate configurations for devices using hostnames - Use Case: Hostname-based device management, named device groups - Output: YAML with configurations for specified hostnames - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario3_devices_by_hostname_list - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "3", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario4_devices_by_serial_number(self): - """ - Test case for scenario 4: Devices by Serial Number List - - Description: Generate configurations for devices using serial numbers - Use Case: Asset management, RMA replacement, warranty tracking - Output: YAML with configurations for specified serial numbers - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario4_devices_by_serial_number_list - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "3", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario5_devices_by_mac_address(self): - """ - Test case for scenario 5: Devices by MAC Address List - - Description: Generate configurations for devices using MAC addresses - Use Case: MAC-based device discovery, Layer 2 device management - Output: YAML with configurations for specified MAC addresses - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario5_devices_by_mac_address_list - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "2", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario6_devices_by_role_access(self): - """ - Test case for scenario 6: Devices by Role - ACCESS - - Description: Generate configurations for devices with ACCESS role - Use Case: Access layer device management, edge device provisioning - Output: YAML with ACCESS role device configurations - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario6_devices_by_role_access - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "ACCESS", - str(result.get('role_filter', '')) - ) - - def test_inventory_playbook_config_generator_scenario7_devices_by_role_core(self): - """ - Test case for scenario 7: Devices by Role - CORE - - Description: Generate configurations for devices with CORE role - Use Case: Core infrastructure management, backbone device configuration - Output: YAML with CORE role device configurations - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario7_devices_by_role_core - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "CORE", - str(result.get('role_filter', '')) - ) - - def test_inventory_playbook_config_generator_scenario8_combined_filters(self): - """ - Test case for scenario 8: Combined Filters - Multiple Criteria - - Description: Generate configurations using multiple filter criteria simultaneously - Use Case: Complex device selection, multi-criteria filtering - Output: YAML with devices matching ALL specified criteria - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario8_combined_filters_multiple_criteria - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "1", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario9_multiple_device_groups(self): - """ - Test case for scenario 9: Multiple Device Groups - - Description: Generate configurations for multiple device groups with different criteria - Use Case: Multi-site deployment, different device categories - Output: Multiple YAML files for different device groups - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario9_multiple_device_groups - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "5", - str(result.get('total_device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario10_provision_devices_by_site(self): - """ - Test case for scenario 10: Provision Devices by Site with Role Filter - - Description: Generate configurations for devices at a specific site with role filtering - Use Case: Site-specific device provisioning, location-based device management - Output: YAML with devices from specified site matching role criteria - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario10_provision_devices_by_site_with_role_filter - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "2", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario11_multiple_roles(self): - """ - Test case for scenario 11: Multiple Roles - - Description: Generate configurations for devices with multiple role types - Use Case: Multi-layer device management, combined ACCESS and CORE devices - Output: YAML with devices matching any of the specified roles - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario11_multiple_roles - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "5", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario12_global_filter_plus_site(self): - """ - Test case for scenario 12: Global Filter Plus Site Filter - - Description: Generate configurations using both global IP filters and site-specific filters - Use Case: Complex filtering combining network and location criteria - Output: YAML with devices matching both IP list and site location - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario12_global_filter_plus_site_filter - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "2", - str(result.get('device_count', 0)) - ) - - # Additional edge case and error scenario tests - - def test_inventory_playbook_config_generator_invalid_ip_address(self): - """ - Test case for invalid IP address format in filter - - This test validates that the module properly handles invalid IP address - formats and returns appropriate error messages. - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - state="gathered", - config=[ - { - "generate_all_configurations": False, - "file_path": "/tmp/test.yml", - "global_filters": { - "ip_address_list": [ - "999.999.999.999" - ] - } - } - ] - ) - ) + Load fixtures for inventory playbook config generator tests. + """ + + if "no_devices_in_inventory" in self._testMethodName: + self.run_get_with_pagination.return_value = self.test_data.get("get_empty_device_list_response", {}).get("response", []) + self.run_write_yaml.return_value = True + return + + if "file_already_up_to_date" in self._testMethodName: + self.run_get_with_pagination.return_value = self.test_data.get("get_device_list_response", {}).get("response", []) + self.run_write_yaml.return_value = False + return + + self.run_get_with_pagination.return_value = self.test_data.get("get_device_list_response", {}).get("response", []) + self.run_write_yaml.return_value = True + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_generate_all_configurations(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_generate_all_configurations, + )) + result = self.execute_module(changed=True, failed=False) + self.assertIn("YAML configuration file generated successfully", str(result.get("msg", {}).get("message"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_devices_by_ip(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_devices_by_ip, + )) + result = self.execute_module(changed=True, failed=False) + self.assertIn("YAML configuration file generated successfully", str(result.get("msg", {}).get("message"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_devices_by_hostname(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_devices_by_hostname, + )) + result = self.execute_module(changed=True, failed=False) + self.assertIn("YAML configuration file generated successfully", str(result.get("msg", {}).get("message"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_devices_by_serial(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_devices_by_serial, + )) + result = self.execute_module(changed=True, failed=False) + self.assertIn("YAML configuration file generated successfully", str(result.get("msg", {}).get("message"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_devices_by_mac(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_devices_by_mac, + )) + result = self.execute_module(changed=True, failed=False) + self.assertIn("YAML configuration file generated successfully", str(result.get("msg", {}).get("message"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_filter_by_device_role(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_filter_by_role, + )) + result = self.execute_module(changed=True, failed=False) + self.assertIn("YAML configuration file generated successfully", str(result.get("msg", {}).get("message"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_combined_filters(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_combined_filters, + )) + result = self.execute_module(changed=True, failed=False) + self.assertIn("YAML configuration file generated successfully", str(result.get("msg", {}).get("message"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_empty_global_filters(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_empty_global_filters, + )) result = self.execute_module(changed=False, failed=True) - self.assertIn( - "invalid IP address", - result.get('msg', '') - ) - - def test_inventory_playbook_config_generator_device_not_found(self): - """ - Test case for device not found scenario - - This test validates that the module properly handles the scenario where - no devices match the specified filter criteria. - """ - self.run_dnac_exec.side_effect = [ - {"response": []} - ] - - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - state="gathered", - config=[ - { - "generate_all_configurations": False, - "file_path": "/tmp/test.yml", - "global_filters": { - "hostname_list": [ - "nonexistent-device.example.com" - ] - } - } - ] - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn("NO_DATA_TO_GENERATE", str(result.get('msg', ''))) - - def test_inventory_playbook_config_generator_invalid_role(self): - """ - Test case for invalid role filter value - - This test validates that the module properly handles invalid role values - in the component_specific_filters configuration. - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - state="gathered", - config=[ - { - "file_path": "/tmp/test.yml", - "component_specific_filters": { - "components_list": ["inventory_workflow_manager"], - "inventory_workflow_manager": [ - { - "role": "INVALID_ROLE" - } - ] - } - } - ] - ) - ) + self.assertIn("Invalid playbook config: 'global_filters' is empty.", str(result.get("msg", ""))) + self.assertIn("Provide at least one filter or omit 'global_filters'.", str(result.get("msg", ""))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_null_global_filters(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_null_global_filters, + )) result = self.execute_module(changed=False, failed=True) - self.assertIn( - "Invalid keys found in 'component_specific_filters'", - result.get('msg', '') - ) - - def test_inventory_playbook_config_generator_scenario13_interface_details_single_interface(self): - """ - Test case for scenario 13: Interface Details - Single Interface Name Filter - - Description: Filter interface_details to include only specific interface names - Use Case: Focus on specific VLAN or Loopback interface configuration - Output: Single document with only specified interface names (e.g., Vlan100) - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario13_interface_details_single_interface_name_filter - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "interface_name", - str(result.get('filter_type', '')) - ) - - def test_inventory_playbook_config_generator_scenario14_interface_details_multiple_interface(self): - """ - Test case for scenario 14: Interface Details - Multiple Interface Name Filters - - Description: Filter interface_details to include multiple specific interface names - Use Case: Audit and configure multiple critical interfaces across all devices - Output: Single document with only specified interface names (Vlan100, Loopback0, etc.) - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario14_interface_details_multiple_interface_name_filters - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "3", - str(result.get('interface_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario15_global_ip_filter_plus_interface_name(self): - """ - Test case for scenario 15: Global IP Filter + Interface Name Filter - - Description: Combine global IP filter with specific interface name filter - Use Case: Get specific interfaces only from targeted devices - Output: Single document with only specified IPs and specified interfaces - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario15_global_ip_filter_plus_interface_name_filter - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "3", - str(result.get('ip_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario16_device_details_plus_filtered_interfaces(self): - """ - Test case for scenario 16: Device Details + Filtered Interfaces - - Description: Generate device credentials and only specific interfaces - Use Case: Onboard devices and configure specific interfaces simultaneously - Output: 2 documents - device details and filtered interface configs - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario16_device_details_plus_filtered_interfaces - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "2", - str(result.get('components_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario17_all_components_with_interface_filter(self): - """ - Test case for scenario 17: All Components with Interface Filter - - Description: Generate all three components with interface_details filtered by name - Use Case: Complete infrastructure migration with controlled interface updates - Output: 3 documents - device details, provision config, and filtered interfaces - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario17_all_components_with_interface_filter - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "3", - str(result.get('components_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario18_interface_filter_no_match_handling(self): - """ - Test case for scenario 18: Interface Filter - No Match Handling - - Description: Filter interfaces that may not exist on all devices - Use Case: Handle scenarios where some devices don't have specified interfaces - Output: Only generates configs for devices that have the specified interfaces - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario18_interface_filter_no_match_handling - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "0", - str(result.get('device_count', 0)) - ) - - def test_inventory_playbook_config_generator_scenario19_gigabitethernet_interfaces_only(self): - """ - Test case for scenario 19: GigabitEthernet Interfaces Only - - Description: Filter for physical GigabitEthernet interfaces only - Use Case: Configure access ports or uplinks specifically - Output: Single document with GigabitEthernet interface configs only - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario19_gigabitethernet_interfaces_only - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "GigabitEthernet", - str(result.get('interface_type', '')) - ) - - def test_inventory_playbook_config_generator_scenario20_access_devices_with_interface_filter(self): - """ - Test case for scenario 20: ACCESS Devices with Specific Interface Filter - - Description: Get ACCESS role devices and filter their specific interfaces - Use Case: Configure access layer devices with specific interface updates - Output: 2 documents - filtered device details (ACCESS only) and their interfaces - """ - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario20_access_devices_with_interface_filter - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn( - "ACCESS", - str(result.get('role_filter', '')) - ) - - def test_inventory_playbook_config_generator_scenario21_user_defined_fields_only(self): - """Test case for scenario 21: User Defined Fields Only.""" - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario21_user_defined_fields_only - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn("configuration generated successfully", result.get("msg", "").lower()) - - def test_inventory_playbook_config_generator_scenario31_udf_name_filter_specific_field_names(self): - """Test case for scenario 31: UDF name filter with list input.""" - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario31_udf_name_filter_specific_field_names - ) - ) - result = self.execute_module(changed=False, failed=False) - self.assertIn("configuration generated successfully", result.get("msg", "").lower()) - - def test_inventory_playbook_config_generator_scenario32_udf_value_filter_specific_field_values(self): - """Test case for scenario 32: UDF value filter with list input.""" - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario32_udf_value_filter_specific_field_values - ) - ) + self.assertIn("Invalid playbook config: 'global_filters' cannot be null when provided.", str(result.get("msg", ""))) + self.assertIn("Provide at least one filter or omit 'global_filters'.", str(result.get("msg", ""))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_included_component_specific_filters_empty(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_included_component_specific_filters, + )) + result = self.execute_module(changed=False, failed=True) + self.assertIn("Invalid filters found in playbook config", str(result.get("msg", ""))) + self.assertIn("Allowed filters are: ['global_filters'].", str(result.get("msg", ""))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_unknown_filter_is_ignored(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_unknown_filter_ignored, + )) + result = self.execute_module(changed=False, failed=True) + self.assertIn("Filter 'unknown_filter' not supported", str(result.get("msg"))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_no_matching_devices(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_no_matching_devices, + )) result = self.execute_module(changed=False, failed=False) - self.assertIn("configuration generated successfully", result.get("msg", "").lower()) - - def test_inventory_playbook_config_generator_scenario36_udf_name_filter_single_string(self): - """Test case for scenario 36: UDF name filter with string input.""" - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario36_udf_name_filter_single_string - ) - ) + self.assertIn("No configurations found", str(result.get("msg", {}).get("message", ""))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_no_devices_in_inventory(self, mock_exists, mock_file): + mock_exists.return_value = True + self.run_get_with_pagination.return_value = self.test_data.get("get_empty_device_list_response", {}).get("response", []) + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_generate_all_configurations, + )) result = self.execute_module(changed=False, failed=False) - self.assertIn("configuration generated successfully", result.get("msg", "").lower()) - - def test_inventory_playbook_config_generator_scenario37_udf_value_filter_single_string(self): - """Test case for scenario 37: UDF value filter with string input.""" - set_module_args( - dict( - dnac_host="192.168.1.1", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - dnac_port=443, - dnac_version="2.3.3.0", - dnac_debug=False, - dnac_log=True, - dnac_log_level="INFO", - state="gathered", - config=self.playbook_config_scenario37_udf_value_filter_single_string - ) - ) + self.assertIn("No configurations found", str(result.get("msg", {}).get("message", ""))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_file_already_up_to_date(self, mock_exists, mock_file): + mock_exists.return_value = True + self.run_write_yaml.return_value = False + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_generate_all_configurations, + )) result = self.execute_module(changed=False, failed=False) - self.assertIn("configuration generated successfully", result.get("msg", "").lower()) - - def test_inventory_playbook_config_generator_no_config_defaults_generate_all(self): - """Verify missing config defaults to generate_all_configurations.""" - generator = self._build_generator( - { - "file_path": "/tmp/inventory_default_generate_all.yml", - } - ) - - result = generator.validate_input() - self.assertEqual(result.status, "success") - self.assertEqual( - result.validated_config, - {"generate_all_configurations": True} - ) - - def test_inventory_playbook_config_generator_only_global_filters_is_valid(self): - """Verify config with only global_filters passes validation.""" - generator = self._build_generator( - { - "file_path": "/tmp/inventory_missing_components.yml", - "config": { - "global_filters": { - "ip_address_list": ["206.1.2.1"] - } - }, - } - ) - - result = generator.validate_input() - self.assertEqual(result.status, "success") - self.assertEqual( - result.validated_config, - { - "global_filters": { - "ip_address_list": ["206.1.2.1"] - } - } - ) - - def test_inventory_playbook_config_generator_component_filter_auto_adds_components_list(self): - """Verify component filter blocks auto-populate components_list.""" - generator = self._build_generator( - { - "file_path": "/tmp/inventory_access_devices.yml", - "config": { - "component_specific_filters": { - "device_details": { - "role": "ACCESS" - } - } - }, - } - ) - - result = generator.validate_input() - self.assertEqual(result.status, "success") - self.assertEqual( - result.validated_config["component_specific_filters"]["components_list"], - ["device_details"] - ) - - def test_inventory_playbook_config_generator_config_rejects_extra_keys(self): - """Verify config accepts only global_filters and component_specific_filters.""" - generator = self._build_generator( - { - "file_path": "/tmp/inventory_invalid_config.yml", - "config": { - "generate_all_configurations": True - }, - } - ) - result = generator.validate_input() - self.assertEqual(result.status, "failed") - self.assertIn( - "at least one of 'global_filters' or 'component_specific_filters' must be present", - result.msg - ) - - def test_inventory_playbook_config_generator_dnac_connection_failure(self): - """ - Test case for DNAC connection failure - - This test validates that the module properly handles connection failures - to Cisco DNA Center and returns appropriate error messages. - """ - self.run_dnac_init.side_effect = Exception("Unable to connect to Cisco DNA Center") - - set_module_args( - dict( - dnac_host="invalid.host.example.com", - dnac_username="admin", - dnac_password="admin123", - dnac_verify=False, - state="gathered", - config=[ - { - "generate_all_configurations": True, - "file_path": "/tmp/test.yml" - } - ] - ) - ) - with self.assertRaises(Exception) as exc: - self.module.main() - self.assertIn("Unable to connect to Cisco DNA Center", str(exc.exception)) + self.assertIn("already up-to-date", str(result.get("msg", {}).get("message", ""))) + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.exists") + def test_empty_config_fails_validation(self, mock_exists, mock_file): + mock_exists.return_value = True + set_module_args(dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="gathered", + config=self.playbook_config_empty_config, + )) + result = self.execute_module(changed=False, failed=True) + self.assertIn("Configuration cannot be an empty dictionary", str(result.get("msg", ""))) diff --git a/tests/unit/modules/dnac/test_pnp_playbook_config_generator.py b/tests/unit/modules/dnac/test_pnp_playbook_config_generator.py index 84862a07df..1cd2ef1ecf 100644 --- a/tests/unit/modules/dnac/test_pnp_playbook_config_generator.py +++ b/tests/unit/modules/dnac/test_pnp_playbook_config_generator.py @@ -18,6 +18,9 @@ __metaclass__ = type +import copy +import os +import tempfile from unittest.mock import patch, mock_open import yaml @@ -41,7 +44,7 @@ def setUp(self): self.mock_dnac_init = patch( "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.__init__") self.run_dnac_init = self.mock_dnac_init.start() - self.run_dnac_init.side_effect = [None] + self.run_dnac_init.return_value = None self.mock_dnac_exec = patch( "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK._exec" ) @@ -57,6 +60,18 @@ def _get_written_yaml(self, mock_file): writes = [call.args[0] for call in handle.write.call_args_list] return "".join(writes) + def _get_base_module_args(self, **kwargs): + args = dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="gathered", + dnac_version="2.3.7.9", + ) + args.update(kwargs) + return args + def load_fixtures(self, response=None, device=""): """ Load fixtures for user. @@ -90,18 +105,21 @@ def test_brownfield_pnp_playbook_generator_playbook_pnp_generate_all_configurati from all PnP devices, ensuring proper validation and expected behavior. """ - set_module_args( - dict( - dnac_host="1.1.1.1", - dnac_username="dummy", - dnac_password="dummy", - dnac_log=True, - state="gathered", - dnac_version="2.3.7.9", - config=self.playbook_pnp_generate_all_configurations + with tempfile.TemporaryDirectory() as temp_dir: + file_path = os.path.join(temp_dir, "pnp_generate_all.yml") + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="gathered", + dnac_version="2.3.7.9", + file_path=file_path, + config=self.playbook_pnp_generate_all_configurations + ) ) - ) - result = self.execute_module(changed=True, failed=False) + result = self.execute_module(changed=True, failed=False) print(result) self.assertEqual( result.get("response").get("message"), @@ -194,3 +212,127 @@ def test_brownfield_pnp_playbook_generator_default_file_path(self, mock_file): data = yaml.safe_load(written_yaml) self.assertIsInstance(data, list) self.assertIn("config", data[0]) + + def test_brownfield_pnp_playbook_generator_overwrite_mode_idempotent_on_matching_local_file(self): + """ + Test overwrite mode reports no change when the existing local YAML file already matches. + """ + with tempfile.TemporaryDirectory() as temp_dir: + file_path = os.path.join(temp_dir, "pnp_overwrite.yml") + self.run_dnac_exec.side_effect = [ + self.test_data.get("PnPdevices"), + self.test_data.get("PnPdevices"), + ] + + set_module_args( + self._get_base_module_args( + file_path=file_path, + file_mode="overwrite", + config=self.playbook_pnp_generate_all_configurations, + ) + ) + self.execute_module(changed=True, failed=False) + + set_module_args( + self._get_base_module_args( + file_path=file_path, + file_mode="overwrite", + config=self.playbook_pnp_generate_all_configurations, + ) + ) + result = self.execute_module(changed=False, failed=False) + + self.assertEqual( + result.get("response").get("message"), + "YAML configuration file already up-to-date for module 'pnp_workflow_manager'. No changes written." + ) + self.assertEqual(result.get("response").get("status"), "ok") + + def test_brownfield_pnp_playbook_generator_append_mode_idempotent_on_matching_last_entry(self): + """ + Test append mode reports no change when the last generated config entry already matches. + """ + with tempfile.TemporaryDirectory() as temp_dir: + file_path = os.path.join(temp_dir, "pnp_append.yml") + self.run_dnac_exec.side_effect = [ + self.test_data.get("PnPdevices"), + self.test_data.get("PnPdevices"), + ] + + set_module_args( + self._get_base_module_args( + file_path=file_path, + file_mode="append", + config=self.playbook_pnp_generate_all_configurations, + ) + ) + self.execute_module(changed=True, failed=False) + + set_module_args( + self._get_base_module_args( + file_path=file_path, + file_mode="append", + config=self.playbook_pnp_generate_all_configurations, + ) + ) + result = self.execute_module(changed=False, failed=False) + + with open(file_path, "r") as yaml_file: + file_content = yaml_file.read() + + parsed_content = yaml.safe_load(file_content) + + self.assertEqual( + result.get("response").get("message"), + "YAML configuration file already up-to-date for module 'pnp_workflow_manager'. No changes written." + ) + self.assertEqual(result.get("response").get("status"), "ok") + self.assertEqual(file_content.count("---"), 1) + self.assertEqual(file_content.count("- config:"), 1) + self.assertIsInstance(parsed_content, list) + self.assertEqual(len(parsed_content), 1) + + def test_brownfield_pnp_playbook_generator_append_mode_adds_new_entry_without_new_separator(self): + """ + Test append mode writes a second config entry without adding another YAML document separator. + """ + updated_devices = copy.deepcopy(self.test_data.get("PnPdevices")) + updated_devices[0]["deviceInfo"]["serialNumber"] = "UPDATED123456" + + with tempfile.TemporaryDirectory() as temp_dir: + file_path = os.path.join(temp_dir, "pnp_append_diff.yml") + self.run_dnac_exec.side_effect = [ + self.test_data.get("PnPdevices"), + updated_devices, + ] + + set_module_args( + self._get_base_module_args( + file_path=file_path, + file_mode="append", + config=self.playbook_pnp_generate_all_configurations, + ) + ) + self.execute_module(changed=True, failed=False) + + set_module_args( + self._get_base_module_args( + file_path=file_path, + file_mode="append", + config=self.playbook_pnp_generate_all_configurations, + ) + ) + self.execute_module(changed=True, failed=False) + + with open(file_path, "r") as yaml_file: + file_content = yaml_file.read() + + parsed_content = yaml.safe_load(file_content) + + self.assertEqual(file_content.count("---"), 1) + self.assertIsInstance(parsed_content, list) + self.assertEqual(len(parsed_content), 2) + self.assertEqual( + parsed_content[-1]["config"][0]["device_info"][0]["serial_number"], + "UPDATED123456" + ) diff --git a/tests/unit/modules/dnac/test_sda_fabric_devices_playbook_config_generator.py b/tests/unit/modules/dnac/test_sda_fabric_devices_playbook_config_generator.py index 5b11289ec3..7b9d1b2593 100644 --- a/tests/unit/modules/dnac/test_sda_fabric_devices_playbook_config_generator.py +++ b/tests/unit/modules/dnac/test_sda_fabric_devices_playbook_config_generator.py @@ -542,7 +542,6 @@ def test_filter_fabric_name_only_case_2(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case2_fabric_name_only.yaml", config=self.playbook_config_filter_fabric_name_only_case_2, ) ) @@ -575,7 +574,6 @@ def test_filter_fabric_name_device_ip_case_3(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case3_fabric_name_device_ip.yaml", config=self.playbook_config_filter_fabric_name_device_ip_case_3, ) ) @@ -608,7 +606,6 @@ def test_filter_fabric_name_edge_role_case_4(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case4_fabric_name_edge_role.yaml", config=self.playbook_config_filter_fabric_name_edge_role_case_4, ) ) @@ -641,7 +638,6 @@ def test_filter_fabric_name_multi_roles_case_5(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case5_fabric_name_multi_roles.yaml", config=self.playbook_config_filter_fabric_name_multi_roles_case_5, ) ) @@ -674,7 +670,6 @@ def test_filter_all_filters_case_6(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case6_all_filters.yaml", config=self.playbook_config_filter_all_filters_case_6, ) ) @@ -707,7 +702,6 @@ def test_filter_fabric_name_cp_role_case_7(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case7_fabric_name_cp_role.yaml", config=self.playbook_config_filter_fabric_name_cp_role_case_7, ) ) @@ -740,7 +734,6 @@ def test_filter_fabric_name_border_role_case_8(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case8_fabric_name_border_role.yaml", config=self.playbook_config_filter_fabric_name_border_role_case_8, ) ) @@ -773,7 +766,6 @@ def test_filter_with_components_list_case_9(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case9_with_components_list.yaml", config=self.playbook_config_filter_with_components_list_case_9, ) ) @@ -800,7 +792,6 @@ def test_filter_multi_fabric_sites_case_11(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case11_multi_fabric_sites.yaml", config=self.playbook_config_filter_multi_fabric_sites_case_11, ) ) @@ -829,7 +820,6 @@ def test_filter_with_file_mode_append_case_10(self): dnac_log=True, state="gathered", dnac_log_level="DEBUG", - file_path="/tmp/ut_case10_append_mode.yaml", file_mode="append", config=self.playbook_config_filter_with_file_mode_append_case_10, ) diff --git a/tests/unit/modules/dnac/test_sda_fabric_devices_workflow_manager.py b/tests/unit/modules/dnac/test_sda_fabric_devices_workflow_manager.py new file mode 100644 index 0000000000..a11e2374ef --- /dev/null +++ b/tests/unit/modules/dnac/test_sda_fabric_devices_workflow_manager.py @@ -0,0 +1,346 @@ +# 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. + +# Authors: +# Archit Soni +# +# Description: +# Unit tests for the Ansible module `sda_fabric_devices_workflow_manager`. +# These tests cover fabric device and wireless controller operations such as creation, +# update, and deletion using mocked Catalyst Center responses. + +from __future__ import absolute_import, division, print_function + +# Metadata +__metaclass__ = type +__author__ = "Archit Soni" +__email__ = "soni.archit03@gmail.com" +__version__ = "1.0.0" + +from unittest.mock import patch +from ansible_collections.cisco.dnac.plugins.modules import ( + sda_fabric_devices_workflow_manager, +) +from .dnac_module import TestDnacModule, loadPlaybookData, set_module_args + + +class TestDnacSdaFabricDevicesWorkflowManager(TestDnacModule): + + module = sda_fabric_devices_workflow_manager + test_data = loadPlaybookData("sda_fabric_devices_workflow_manager") + + playbook_config_create_fabric_device_case_1 = test_data.get( + "create_fabric_device_case_1" + ) + playbook_config_update_fabric_device_case_2 = test_data.get( + "update_fabric_device_case_2" + ) + playbook_config_delete_fabric_device_case_3 = test_data.get( + "delete_fabric_device_case_3" + ) + playbook_config_create_wireless_controller_case_4 = test_data.get( + "create_wireless_controller_case_4" + ) + playbook_config_update_wireless_controller_case_5 = test_data.get( + "update_wireless_controller_case_5" + ) + playbook_config_delete_wireless_controller_case_6 = test_data.get( + "delete_wireless_controller_case_6" + ) + + def setUp(self): + super(TestDnacSdaFabricDevicesWorkflowManager, self).setUp() + + self.mock_dnac_init = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.__init__" + ) + self.run_dnac_init = self.mock_dnac_init.start() + self.run_dnac_init.side_effect = [None] + + self.mock_dnac_exec = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK._exec" + ) + self.run_dnac_exec = self.mock_dnac_exec.start() + self.load_fixtures() + + def tearDown(self): + super(TestDnacSdaFabricDevicesWorkflowManager, self).tearDown() + self.mock_dnac_exec.stop() + self.mock_dnac_init.stop() + + def load_fixtures(self, response=None, device=""): + """ + Load fixtures for user. + """ + if "test_create_fabric_device_case_1" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_empty"), + self.test_data.get("add_fabric_devices"), + self.test_data.get("task_success"), + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_border_cp_priority1"), + self.test_data.get("get_sda_wireless_empty"), + self.test_data.get("get_primary_ap_locations_empty"), + self.test_data.get("get_secondary_ap_locations_empty"), + ] + elif "test_update_fabric_device_case_2" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_border_cp_priority1"), + self.test_data.get("get_sda_wireless_empty"), + self.test_data.get("get_primary_ap_locations_empty"), + self.test_data.get("get_secondary_ap_locations_empty"), + self.test_data.get("update_fabric_devices"), + self.test_data.get("task_success"), + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_border_cp_priority2"), + self.test_data.get("get_sda_wireless_empty"), + self.test_data.get("get_primary_ap_locations_empty"), + self.test_data.get("get_secondary_ap_locations_empty"), + ] + elif "test_delete_fabric_device_case_3" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_fabric_devices_border_cp_priority1"), + self.test_data.get("get_sda_wireless_empty"), + self.test_data.get("get_primary_ap_locations_empty"), + self.test_data.get("get_secondary_ap_locations_empty"), + self.test_data.get("delete_fabric_device"), + self.test_data.get("task_success"), + ] + elif "test_create_wireless_controller_case_4" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_empty"), + self.test_data.get("case_4_call_6"), + self.test_data.get("case_4_call_7"), + self.test_data.get("case_4_call_8"), + self.test_data.get("case_4_call_9"), + self.test_data.get("case_4_call_10"), + self.test_data.get("case_4_call_11"), + self.test_data.get("case_4_call_12"), + self.test_data.get("case_4_call_13"), + self.test_data.get("case_4_call_14"), + self.test_data.get("case_4_call_15"), + self.test_data.get("case_4_call_16"), + self.test_data.get("case_4_call_17"), + self.test_data.get("add_fabric_devices"), + self.test_data.get("task_success"), + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_border_cp_priority1"), + self.test_data.get("get_sda_wireless_empty"), + self.test_data.get("get_primary_ap_locations_empty"), + self.test_data.get("get_secondary_ap_locations_empty"), + self.test_data.get("assign_managed_ap_locations"), + self.test_data.get("task_success"), + self.test_data.get("switch_wireless_setting"), + self.test_data.get("task_success"), + ] + elif "test_update_wireless_controller_case_5" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_wlc_have"), + self.test_data.get("get_sda_wireless_enabled_25"), + self.test_data.get("get_primary_ap_locations_three"), + self.test_data.get("get_secondary_ap_locations_three"), + self.test_data.get("case_4_call_6"), + self.test_data.get("case_4_call_7"), + self.test_data.get("case_4_call_8"), + self.test_data.get("case_4_call_9"), + self.test_data.get("case_4_call_12"), + self.test_data.get("case_4_call_13"), + self.test_data.get("case_4_call_14"), + self.test_data.get("case_4_call_15"), + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_wlc_have"), + self.test_data.get("get_sda_wireless_enabled_25"), + self.test_data.get("get_primary_ap_locations_three"), + self.test_data.get("get_secondary_ap_locations_three"), + self.test_data.get("assign_managed_ap_locations"), + self.test_data.get("task_success"), + self.test_data.get("switch_wireless_setting"), + self.test_data.get("task_success"), + ] + elif "test_delete_wireless_controller_case_6" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_wlc_have"), + self.test_data.get("get_sda_wireless_enabled_25"), + self.test_data.get("get_primary_ap_locations_empty"), + self.test_data.get("get_secondary_ap_locations_empty"), + self.test_data.get("get_sites_sf"), + self.test_data.get("get_fabric_sites_sf"), + self.test_data.get("get_device_list"), + self.test_data.get("get_provisioned_devices"), + self.test_data.get("get_fabric_devices_wlc_have"), + self.test_data.get("get_sda_wireless_enabled_25"), + self.test_data.get("get_primary_ap_locations_empty"), + self.test_data.get("get_secondary_ap_locations_empty"), + self.test_data.get("switch_wireless_setting"), + self.test_data.get("task_success"), + self.test_data.get("reload_switch"), + self.test_data.get("task_success"), + ] + + def test_create_fabric_device_case_1(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="merged", + config_verify=False, + config=self.playbook_config_create_fabric_device_case_1, + ) + ) + + result = self.execute_module(changed=True, failed=False) + self.assertIn( + "Successfully added the fabric device", + result.get("msg"), + ) + + def test_update_fabric_device_case_2(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="merged", + config_verify=False, + config=self.playbook_config_update_fabric_device_case_2, + ) + ) + + result = self.execute_module(changed=True, failed=False) + self.assertIn( + "Successfully updated the fabric device", + result.get("msg"), + ) + + def test_delete_fabric_device_case_3(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="deleted", + config_verify=False, + config=self.playbook_config_delete_fabric_device_case_3, + ) + ) + + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get("msg"), + "Successfully deleted the SDA fabric device with IP '204.1.4.1'.", + ) + + def test_create_wireless_controller_case_4(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="merged", + config_verify=False, + config=self.playbook_config_create_wireless_controller_case_4, + ) + ) + + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get("msg"), + "Wireless Controller Settings for the device with IP address: '204.1.4.1' under fabric: 'Global/USA/SAN_FRANCISCO' " + "updated successfully in the Cisco Catalyst Center.", + ) + + def test_update_wireless_controller_case_5(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="merged", + config_verify=False, + config=self.playbook_config_update_wireless_controller_case_5, + ) + ) + + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get("msg"), + "Wireless Controller Settings for the device with IP address: '204.1.4.1' under fabric: 'Global/USA/SAN_FRANCISCO' " + "updated successfully in the Cisco Catalyst Center.", + ) + + def test_delete_wireless_controller_case_6(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="2.3.7.9", + dnac_log=True, + state="merged", + config_verify=False, + config=self.playbook_config_delete_wireless_controller_case_6, + ) + ) + + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get("msg"), + "Reload successful for the device with IP address: '204.1.4.1' under fabric: 'Global/USA/SAN_FRANCISCO' in the Cisco Catalyst Center", + ) diff --git a/tests/unit/modules/dnac/test_sda_fabric_virtual_networks_workflow_manager.py b/tests/unit/modules/dnac/test_sda_fabric_virtual_networks_workflow_manager.py index 0cb74ff2d0..999022cb59 100644 --- a/tests/unit/modules/dnac/test_sda_fabric_virtual_networks_workflow_manager.py +++ b/tests/unit/modules/dnac/test_sda_fabric_virtual_networks_workflow_manager.py @@ -49,6 +49,8 @@ class TestDnacFabricSitesZonesWorkflow(TestDnacModule): playbook_config_delete_absent_fabric_vlan = test_data.get("playbook_config_delete_absent_fabric_vlan") playbook_config_failed_anchored_virtual_network_creation = test_data.get("playbook_config_failed_anchored_virtual_network_creation") playbook_config_invalid_fabric_vlan_id = test_data.get("playbook_config_invalid_fabric_vlan_id") + playbook_config_create_fabric_vlan_with_multiple_ip_to_mac = test_data.get("playbook_config_create_fabric_vlan_with_multiple_ip_to_mac") + playbook_config_update_fabric_vlan_multiple_ip_to_mac = test_data.get("playbook_config_update_fabric_vlan_multiple_ip_to_mac") def setUp(self): super(TestDnacFabricSitesZonesWorkflow, self).setUp() @@ -95,6 +97,16 @@ def load_fixtures(self, response=None, device=""): self.test_data.get("get_fabric_vlan_response") ] + elif "update_fabric_vlan_multiple_ip_to_mac" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_fabric_vlan_multi_ip_response"), + self.test_data.get("get_site_details"), + self.test_data.get("get_fabric_site_details"), + self.test_data.get("get_fabric_vlan_multi_ip_response"), + self.test_data.get("response_get_task_id_success"), + self.test_data.get("response_get_task_status_by_id_success"), + ] + elif "update_fabric_vlan" in self._testMethodName: self.run_dnac_exec.side_effect = [ self.test_data.get("get_fabric_vlan_response"), @@ -132,6 +144,26 @@ def load_fixtures(self, response=None, device=""): self.test_data.get("get_invalid_fabric_vlan_id"), ] + elif "create_fabric_vlan_with_multiple_ip_to_mac" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_empty_fabric_vlan_multi_ip_response"), + self.test_data.get("get_site_details"), + self.test_data.get("get_fabric_site_details"), + self.test_data.get("get_empty_fabric_vlan_multi_ip_response"), + self.test_data.get("response_get_task_id_success"), + self.test_data.get("response_get_task_status_by_id_success"), + ] + + elif "omit_multiple_ip_field" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("get_empty_fabric_vlan_multi_ip_response"), + self.test_data.get("get_site_details"), + self.test_data.get("get_fabric_site_details"), + self.test_data.get("get_empty_fabric_vlan_multi_ip_response"), + self.test_data.get("response_get_task_id_success"), + self.test_data.get("response_get_task_status_by_id_success"), + ] + elif "create_virtual_network_with_verify" in self._testMethodName: self.run_dnac_exec.side_effect = [ self.test_data.get("get_empty_virtual_network_response"), @@ -749,6 +781,58 @@ def test_sda_fabric_virtual_networks_workflow_manager_invalid_fabric_vlan_id(sel result.get('msg') ) + def test_sda_fabric_virtual_networks_workflow_manager_create_fabric_vlan_with_multiple_ip_to_mac(self): + """ + Test case for creating a fabric VLAN with multiple_ip_to_mac_addresses enabled. + + This test verifies that the module correctly creates a fabric VLAN with the + multiple_ip_to_mac_addresses parameter set to true on Catalyst Center >= 3.1.3.0. + """ + + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="3.1.3.0", + dnac_log=True, + config_verify=False, + state="merged", + config=self.playbook_config_create_fabric_vlan_with_multiple_ip_to_mac + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertIn( + "created successfully", + result.get('msg') + ) + + def test_sda_fabric_virtual_networks_workflow_manager_update_fabric_vlan_multiple_ip_to_mac(self): + """ + Test case for updating a fabric VLAN to enable multiple_ip_to_mac_addresses. + + This test verifies that the module detects a change in multiple_ip_to_mac_addresses + and triggers an update on Catalyst Center >= 3.1.3.0. + """ + + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="3.1.3.0", + dnac_log=True, + config_verify=False, + state="merged", + config=self.playbook_config_update_fabric_vlan_multiple_ip_to_mac + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertIn( + "updated successfully", + result.get('msg') + ) + def test_sda_fabric_virtual_networks_workflow_manager_invalid_testbed_release(self): """ Test case for sda fabric virtual networks workflow manager for an invalid testbed release. @@ -774,3 +858,88 @@ def test_sda_fabric_virtual_networks_workflow_manager_invalid_testbed_release(se "The specified version", result.get('msg') ) + + def test_sda_fabric_virtual_networks_workflow_manager_invalid_multiple_ip_to_mac_without_l3_vn(self): + """ + Verify module fails when multiple_ip_to_mac_addresses is provided + without associated_layer3_virtual_network. + """ + invalid_config = [ + { + "fabric_vlan": [ + { + "vlan_name": "vlan_multi_ip_invalid", + "fabric_site_locations": [ + { + "site_name_hierarchy": "Global/India/Fabric_Test", + "fabric_type": "fabric_site", + } + ], + "vlan_id": 1945, + "traffic_type": "DATA", + "fabric_enabled_wireless": False, + "multiple_ip_to_mac_addresses": True, + } + ] + } + ] + + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="3.1.3.0", + dnac_log=True, + config_verify=False, + state="merged", + config=invalid_config, + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertIn( + "requires 'associated_layer3_virtual_network'", + result.get("msg"), + ) + + def test_sda_fabric_virtual_networks_workflow_manager_create_fabric_vlan_omit_multiple_ip_field(self): + """ + Verify create flow does not force isMultipleIpToMacAddresses + when multiple_ip_to_mac_addresses is omitted. + """ + config_without_multi_ip = [ + { + "fabric_vlan": [ + { + "vlan_name": "vlan_without_multi_ip", + "fabric_site_locations": [ + { + "site_name_hierarchy": "Global/India/Fabric_Test", + "fabric_type": "fabric_site", + } + ], + "vlan_id": 1946, + "traffic_type": "DATA", + "fabric_enabled_wireless": False, + } + ] + } + ] + + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_version="3.1.3.0", + dnac_log=True, + config_verify=False, + state="merged", + config=config_without_multi_ip, + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertIn( + "created successfully", + result.get('msg') + ) diff --git a/tests/unit/modules/dnac/test_user_role_playbook_config_generator.py b/tests/unit/modules/dnac/test_user_role_playbook_config_generator.py index 2c63e28774..da554c4fdb 100644 --- a/tests/unit/modules/dnac/test_user_role_playbook_config_generator.py +++ b/tests/unit/modules/dnac/test_user_role_playbook_config_generator.py @@ -18,6 +18,7 @@ __metaclass__ = type +import os from unittest.mock import patch from ansible_collections.cisco.dnac.plugins.modules import user_role_playbook_config_generator @@ -53,6 +54,14 @@ def setUp(self): ) self.run_dnac_exec = self.mock_dnac_exec.start() + # Ensure changed=True tests are deterministic by removing prior outputs. + for file_path in [ + "/tmp/specific_userrole_details_info", + "/tmp/specific_user_details1", + ]: + if os.path.exists(file_path): + os.remove(file_path) + def tearDown(self): super(TestDnacUserRolePlaybookGenerator, self).tearDown() self.mock_dnac_exec.stop() diff --git a/tests/unit/modules/dnac/test_user_role_workflow_manager.py b/tests/unit/modules/dnac/test_user_role_workflow_manager.py index 97922ccd34..5f9149ff85 100644 --- a/tests/unit/modules/dnac/test_user_role_workflow_manager.py +++ b/tests/unit/modules/dnac/test_user_role_workflow_manager.py @@ -365,10 +365,19 @@ def test_user_role_workflow_manager_user_invalid_mandatory_field_not_present_par ) result = self.execute_module(changed=False, failed=True) print(result) - self.assertEqual( - result.get("msg"), - "Mandatory parameter(s) 'username, password' not present in the user details." - ) + expected_msg = ( + "Invalid parameters in playbook config: " + "password: A password is required when creating a new user " + "account. Please provide a password that is at least 9 " + "characters long and includes at least three of the following " + "character types: lowercase letters, uppercase letters, digits, " + "and special characters. Additionally, the password must not " + "contain repetitive or sequential characters., " + "username: The 'username' field is required for user " + "create, update, and delete operations. Please provide " + "a valid username in the playbook." + ) + self.assertEqual(result.get("msg"), expected_msg) def test_user_role_workflow_manager_user_invalid_username_email_not_present_param(self): """ @@ -418,7 +427,7 @@ def test_user_role_workflow_manager_user_invalid_param_not_correct_formate(self) "Invalid parameters in playbook config: first_name: First name 'ajith ' can have alphanumeric " "characters only and must be 2 to 50 characters long., " "last_name: Last name 'andrew ' can have alphanumeric characters only and must be 2 to 50 characters long., " - "The password must be 9 to 20 characters long and include at least three of the following character types: " + "The password must be at least 9 characters long and include at least three of the following character types: " "lowercase letters, uppercase letters, digits, and special characters. " "Additionally, the password must not contain repetitive or sequential characters., " "username: 'ajithandrewj ' The username must not contain any special characters and must be 3 to 50 characters long." @@ -880,3 +889,37 @@ def test_user_role_workflow_manager_playbook_new_version_user_create(self): result.get('response'), "User(s) 'Priyadharshini' created successfully in Cisco Catalyst Center." ) + + def test_user_role_workflow_manager_password_update_string_type_rejected(self): + """ + Test that password_update provided as a string (not bool) is + rejected with a clear error message at the module level. + """ + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="admin", + dnac_password="admin", + dnac_version="2.3.7.6", + dnac_log=True, + state="merged", + config=dict( + user_details=[ + dict( + username="testuser", + first_name="Test", + last_name="User", + email="test@example.com", + password="ValidPass@1", + password_update="true", # string, not bool + role_list=["OBSERVER-ROLE"], + ) + ] + ), + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertIn("password_update", result.get("msg", "")) + self.assertIn( + "expected bool", result.get("msg", "") + )