diff --git a/.gitignore b/.gitignore index 192206246c..a14cf3596b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ docs/plugins/ docs/_build/ changelogs/.plugin-cache.yaml playbooks/credentials.yml -.DS_Store \ No newline at end of file +playbooks/device_details.template +.DS_Store diff --git a/README.md b/README.md index 6a4e0d7064..2e9befa1f4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The following table shows the supported versions. | 2.2.2.3 | 3.3.1 | 2.3.3 | | 2.2.3.3 | 6.4.0 | 2.4.11 | | 2.3.3.0 | 6.6.4 | 2.5.5 | -| 2.3.5.3 | 6.9.0 | 2.6.0 | +| 2.3.5.3 | 6.10.1 | 2.6.0 | If your Ansible collection is older please consider updating it first. diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index d16f20c382..a68a792af6 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -763,3 +763,10 @@ releases: - Changes in site intent module to support one-shot site deletion - To Support provisioning wired device, reboot AP's, export device list, delete provisioned devices. - Change the variable names into snake case in all the intent modules for better readability. + 6.10.1: + release_date: "2024-01-20" + changes: + release_summary: Changes in network settings, site, discovery, inventory, swim, credential and provisioning intent modules + minor_changes: + - Introducing config_verify to verify the state operations in Catalyst Center in network settings and site intent module + - Changes to support inventory and provisioning intent modules diff --git a/galaxy.yml b/galaxy.yml index 26cc840337..9ec2120a61 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: cisco name: dnac -version: 6.10.0 +version: 6.10.1 readme: README.md authors: - Rafael Campos @@ -15,6 +15,8 @@ authors: - Akash Bhaskaran - Abinash Mishra - Abhishek Maheshwari + - Phan Nguyen + - Rugvedi Kapse description: Ansible Modules for Cisco DNA Center license_file: "LICENSE" tags: diff --git a/playbooks/PnP.yml b/playbooks/PnP.yml index 0f3d864d57..1c3dd206ad 100644 --- a/playbooks/PnP.yml +++ b/playbooks/PnP.yml @@ -24,21 +24,20 @@ <<: *dnac_login dnac_log: True state: merged + config_verify: True config: - - device_info: - - serial_number: FKC2310E0HB - hostname: 1-5 + - device_info: + - serial_number: QD2425L8M7 state: Unclaimed pid: c9300-24P is_sudi_required: False - - serial_number: FTC2320E0HB - hostname: 1-6 + - serial_number: QTC2320E0H9 state: Unclaimed pid: c9300-24P + hostname: Test-123 - serial_number: ETC2320E0HB - hostname: 1-7 state: Unclaimed pid: c9300-24P @@ -89,7 +88,7 @@ hostname: IAC-EWLC-Claimed device_info: - serial_number: FOX2639PAY7 - hostname: WLC + hostname: New_WLC state: Unclaimed pid: C9800-CL-K9 gateway: 204.192.101.1 @@ -103,8 +102,9 @@ <<: *dnac_login dnac_log: True state: deleted + config_verify: True config: - device_info: - - serial_number: FKC2310E0HK - - serial_number: FTC2320E0HA - - serial_number: FKC2310E0HB + - serial_number: QD2425L8M7 #Will get deleted + - serial_number: FTC2320E0HA #Doesn't exist in the inventory + - serial_number: FKC2310E0HB #Doesn't exist in the inventory diff --git a/playbooks/device_credential_intent.yml b/playbooks/device_credential_intent.yml index 2049c577d8..bd5834ffe2 100644 --- a/playbooks/device_credential_intent.yml +++ b/playbooks/device_credential_intent.yml @@ -1,6 +1,6 @@ - hosts: dnac_servers vars_files: - - credentials_245.yml + - credentials.yml gather_facts: no connection: local tasks: @@ -23,26 +23,26 @@ cli_credential: - description: CLI1 username: cli1 - password: "12345" - enable_password: "12345" + password: '12345' + enable_password: '12345' # old_description: # old_username: # id: e448ea13-4de0-406b-bc6e-f72b57ed6746 # Use this for updation or deletion snmp_v2c_read: - description: SNMPv2c Read1 # use this for deletion - read_community: "123456" + read_community: '123456' # old_description: # use this for updating the description # id: 0ee7d677-8804-43f2-8b6c-599c5f18348f # Use this for updation or deletion snmp_v2c_write: - description: SNMPv2c Write1 # use this for deletion - write_community: "123456" + write_community: '123456' # old_description: # use this for updating the description # id: a96abc1b-1fd6-41f1-8a6d-a5569c17262d # Use this for updation or deletion snmp_v3: - - auth_password: "12345678" # Atleast 8 characters + - auth_password: '12345678' # Atleast 8 characters auth_type: SHA # [SHA, MD5] (SHA is recommended) snmp_mode: AUTHPRIV # [AUTHPRIV, AUTHNOPRIV, NOAUTHNOPRIV] - privacy_password: "12345678" # Atleast 8 characters + privacy_password: '12345678' # Atleast 8 characters privacy_type: AES128 # [AE128, AE192, AE256] username: snmpV31 description: snmpV31 @@ -51,7 +51,7 @@ https_read: - description: HTTP Read1 username: HTTP_Read1 - password: "12345" + password: '12345' port: 443 # old_description: # old_username: @@ -59,7 +59,7 @@ https_write: - description: HTTP Write1 username: HTTP_Write1 - password: "12345" + password: '12345' port: 443 # old_description: # old_username: @@ -68,24 +68,24 @@ cli_credential: # description: CLI # username: cli - id: e448ea13-4de0-406b-bc6e-f72b57ed6746 + id: 2fc5f7d4-cf15-4a4f-99b3-f086e8dd6350 snmp_v2c_read: # description: SNMPv2c Read - id: 0ee7d677-8804-43f2-8b6c-599c5f18348f + id: a966a4e5-9d11-4683-8edc-a5ad8fa59ee3 snmp_v2c_write: # description: SNMPv2c Write - id: a96abc1b-1fd6-41f1-8a6d-a5569c17262d + id: 7cd072a4-2263-4087-b6ec-93b20958e286 snmp_v3: # description: snmpV3 - id: d8974823-250a-41b0-8c9b-b27b2ae01472 + id: c08a1797-84ce-4add-94a3-b419b13621e4 https_read: # description: HTTP Read # username: HTTP_Read - id: d5d7af00-5a38-4ac1-9f55-03338d00c415 + id: 1009725d-373b-4e7c-a091-300777e2bbe2 https_write: # description: HTTP Write # username: HTTP_Write - id: bec9818e-30cd-468b-bf75-292beefc2e20 + id: f1ab6e3d-01e9-4d87-8271-3ac5fde83980 site_name: - Global/Chennai/Trill - Global/Chennai/Tidel diff --git a/playbooks/device_details.template b/playbooks/device_details.template new file mode 100644 index 0000000000..38c95c627d --- /dev/null +++ b/playbooks/device_details.template @@ -0,0 +1,69 @@ +template_details: + - proj_name: 'Onboarding Configuration' + device_config: 'hostname cat9k-1\n' + language: 'velocity' + family: 'Switches and Hubs' + type: 'IOS-XE' + variant: 'XE' + temp_name: 'temp_cat9k-1' + description: 'Test Template' + export_project: + - 'Cloud DayN Templates' + export_template: + - project_name: 'Cloud DayN Templates' + template_name: 'DMVPN Spoke for Branch Router - System Default' + import_project: + do_version: false + payload: + - name: 'Onboarding Configuration2' + import_template: + do_version: false + project_name: 'Onboarding Configuration' + payload: + - name: 'Platinum-Onboarding-Template-J21' + device_types: + - product_family: 'Switches and Hubs' + productSeries: 'Cisco Catalyst 9300 Series Switches' + software_type: 'IOS' + language: 'JINJA' + - name: 'Platinum-Onboarding-Template-J22' + device_types: + - product_family: 'Switches and Hubs' + productSeries: 'Cisco Catalyst 9300 Series Switches' + software_type: 'IOS' + language: 'JINJA' + - name: 'Platinum-Onboarding-Template-J23' + device_types: + - product_family: 'Switches and Hubs' + productSeries: 'Cisco Catalyst 9300 Series Switches' + software_type: 'IOS' + language: 'JINJA' + +device_details: + - site_name: 'Global/Chennai/Trill' + image_name: 'cat9k_iosxe.17.04.01.SPA.bin' + proj_name: 'Onboarding Configuration' + temp_name: 'temp_cat9k-1' + device_version: '2' + device_number: 'AB2425L8M7' + device_name: 'Cat9k-1' + device_state: 'Unclaimed' + device_id: 'C9300-48UXM' + - site_name: 'Global/Chennai/Trill' + image_name: cat9k_iosxe.17.04.01.SPA.bin' + proj_name: 'Onboarding Configuration' + temp_name: 'temp_cat9k-2' + device_version: '2' + device_number: 'CD2425L8M7' + device_name: 'Cat9k-2' + device_state: 'Unclaimed' + device_id: 'C9300-48UXM' + - site_name: 'Global/Chennai/Trill' + image_name: 'cat9k_iosxe.17.04.01.SPA.bin' + proj_name: 'Onboarding Configuration' + temp_name: 'temp_cat9k-3' + device_version: '2' + device_number: 'EF2425L8M7' + device_name: 'Cat9k-3' + device_state: 'Unclaimed' + device_id: 'C9300-48UXM' diff --git a/playbooks/device_details.yml b/playbooks/device_details.yml deleted file mode 100644 index ae4d017ffd..0000000000 --- a/playbooks/device_details.yml +++ /dev/null @@ -1,69 +0,0 @@ -template_details: - - proj_name: "Onboarding Configuration" - device_config: "hostname cat9k-1\n" - language: "velocity" - family: "Switches and Hubs" - type: "IOS-XE" - variant: "XE" - temp_name: "temp_cat9k-1" - description: "Test Template" - export_project: - - "Cloud DayN Templates" - export_template: - - project_name: "Cloud DayN Templates" - template_name: "DMVPN Spoke for Branch Router - System Default" - import_project: - do_version: false - payload: - - name: "Onboarding Configuration2" - import_template: - do_version: false - project_name: "Onboarding Configuration" - payload: - - name: "Platinum-Onboarding-Template-J21" - device_types: - - product_family: "Switches and Hubs" - productSeries: "Cisco Catalyst 9300 Series Switches" - software_type: "IOS" - language: "JINJA" - - name: "Platinum-Onboarding-Template-J22" - device_types: - - product_family: "Switches and Hubs" - productSeries: "Cisco Catalyst 9300 Series Switches" - software_type: "IOS" - language: "JINJA" - - name: "Platinum-Onboarding-Template-J23" - device_types: - - product_family: "Switches and Hubs" - productSeries: "Cisco Catalyst 9300 Series Switches" - software_type: "IOS" - language: "JINJA" - -device_details: - - site_name: "Global/Chennai/Trill" - image_name: "cat9k_iosxe.17.04.01.SPA.bin" - proj_name: "Onboarding Configuration" - temp_name: "temp_cat9k-1" - device_version: "2" - device_number: "AB2425L8M7" - device_name: "Cat9k-1" - device_state: "Unclaimed" - device_id: "C9300-25UX" - - site_name: "Global/Chennai/Trill" - image_name: "cat9k_iosxe.17.04.01.SPA.bin" - proj_name: "Onboarding Configuration" - temp_name: "temp_cat9k-2" - device_version: "2" - device_number: "CD2425L8M7" - device_name: "Cat9k-2" - device_state: "Unclaimed" - device_id: "C9300-25UX" - - site_name: "Global/Chennai/Trill" - image_name: "cat9k_iosxe.17.04.01.SPA.bin" - proj_name: "Onboarding Configuration" - temp_name: "temp_cat9k-3" - device_version: "2" - device_number: "EF2425L8M7" - device_name: "Cat9k-3" - device_state: "Unclaimed" - device_id: "C9300-25UX" diff --git a/playbooks/device_provision.yml b/playbooks/device_provision.yml index ed780ed5ab..fe3efe9190 100644 --- a/playbooks/device_provision.yml +++ b/playbooks/device_provision.yml @@ -27,4 +27,11 @@ - site_name: Global/USA/San Francisco/BGL_18 management_ip_address: 204.1.2.2 - \ No newline at end of file + + - name: Unprovision a wired device to a site + cisco.dnac.provision_intent: + <<: *dnac_login + dnac_log: True + state: deleted + config: + - management_ip_address: 204.1.2.2 diff --git a/playbooks/discovery_intent.yml b/playbooks/discovery_intent.yml index c8044e97a3..f285d71cdd 100644 --- a/playbooks/discovery_intent.yml +++ b/playbooks/discovery_intent.yml @@ -22,6 +22,7 @@ cisco.dnac.discovery_intent: <<: *dnac_login state: merged + config_verify: True config: - devices_list: - name: SJ-BN-9300 @@ -37,8 +38,28 @@ l2interface: TenGigabitEthernet1/1/6 ip: 204.1.2.3 discovery_type: "MULTI RANGE" + discovery_name: Multi_Range_Discovery_Test protocol_order: ssh start_index: 1 records_to_return: 25 snmp_version: v2 - + + - name: Execute discovery devices using CDP/LLDP/CIDR + cisco.dnac.discovery_intent: + <<: *dnac_login + state: merged + config_verify: True + config: + - devices_list: #List length should be one + - name: SJ-BN-9300 + site: Global/USA/SAN JOSE/BLD23 + role: MAPSERVER,BORDERNODE,INTERNAL,EXTERNAL,SDATRANSIT + l2interface: TenGigabitEthernet1/1/8 + ip: 204.1.2.1 + discovery_type: "CDP" #Can be LLDP and CIDR + cdp_level: 16 #Instead use lldp for LLDP and prefix length for CIDR + discovery_name: CDP_Test_1 + protocol_order: ssh + start_index: 1 + records_to_return: 25 + snmp_version: v2 \ No newline at end of file diff --git a/playbooks/network_settings_intent.yml b/playbooks/network_settings_intent.yml index 8f803522ce..5cb7cd4bfb 100644 --- a/playbooks/network_settings_intent.yml +++ b/playbooks/network_settings_intent.yml @@ -1,6 +1,6 @@ - hosts: dnac_servers vars_files: - - credentials_245.yml + - credentials.yml gather_facts: no connection: local tasks: @@ -18,12 +18,13 @@ dnac_debug: "{{ dnac_debug }}" dnac_log: True state: merged + config_verify: True config: - global_pool_details: settings: ip_pool: - name: Global_Pool2 - gateway: "" #use this for updating + gateway: '' #use this for updating ip_address_space: IPv6 #required when we are creating cidr: 2001:db8::/64 #required when we are creating type: Generic @@ -41,7 +42,7 @@ ipv6_prefix: True ipv6_prefix_length: 64 ipv6_global_pool: 2001:db8::/64 - ipv6_subnet: "2001:db8::" + ipv6_subnet: '2001:db8::' site_name: Global/Chennai/Trill slaac_support: True # prev_name: IP_Pool_4 @@ -62,7 +63,7 @@ # shared_secret: string #ISE message_of_the_day: banner_message: hello - retain_existing_banner: "true" + retain_existing_banner: 'true' netflow_collector: ip_address: 10.0.0.4 port: 443 @@ -78,7 +79,7 @@ configure_dnac_ip: false # ip_addresses: # - 10.0.0.6 - syslogServer: + syslog_server: configure_dnac_ip: false # ip_addresses: # - 10.0.0.7 @@ -95,6 +96,7 @@ dnac_debug: "{{ dnac_debug }}" dnac_log: True state: deleted + config_verify: True config: - global_pool_details: settings: diff --git a/playbooks/template_pnp_intent.yml b/playbooks/template_pnp_intent.yml index fdeaf3356b..09ea6a7226 100644 --- a/playbooks/template_pnp_intent.yml +++ b/playbooks/template_pnp_intent.yml @@ -1,14 +1,14 @@ -- hosts: dnac_servers +- hosts: localhost vars_files: - - credentials_245.yml - - device_details.yml + - credentials.yml + - device_details.template gather_facts: false connection: local tasks: # # Project Info Section # - - name: Test project template + - name: Test project template cisco.dnac.template_intent: dnac_host: "{{ dnac_host }}" dnac_port: "{{ dnac_port }}" @@ -18,18 +18,19 @@ dnac_debug: "{{ dnac_debug }}" dnac_log: true state: "merged" + config_verify: true #ignore_errors: true #Enable this to continue execution even the task fails config: - configuration_templates: project_name: "{{ item.proj_name }}" + template_name: "{{ item.temp_name }}" template_content: "{{ item.device_config }}" + version_description: "{{ item.description }}" language: "{{ item.language }}" - device_types: - - product_family: "{{ item.family }}" software_type: "{{ item.type }}" software_variant: "{{ item.variant }}" - template_name: "{{ item.temp_name }}" - version_description: "{{ item.description }}" + device_types: + - product_family: "{{ item.family }}" register: template_result with_items: '{{ template_details }}' tags: @@ -50,12 +51,11 @@ project_name: "{{ item.proj_name }}" template_name: "{{ item.temp_name }}" image_name: "{{ item.image_name }}" - device_version: "{{ item.device_version }}" - deviceInfo: - serialNumber: "{{ item.device_number }}" - hostname: "{{ item.device_name}}" - state: "{{ item.device_state }}" - pid: "{{ item.device_id }}" + device_info: + - serial_number: "{{ item.device_number }}" + hostname: "{{ item.device_name}}" + state: "{{ item.device_state }}" + pid: "{{ item.device_id }}" register: pnp_result with_items: '{{ device_details }}' tags: diff --git a/plugins/module_utils/dnac.py b/plugins/module_utils/dnac.py index b0ed83c71b..e6ada258f0 100644 --- a/plugins/module_utils/dnac.py +++ b/plugins/module_utils/dnac.py @@ -55,7 +55,16 @@ def __init__(self, module): 'rendered': self.get_diff_rendered, 'parsed': self.get_diff_parsed } + self.verify_diff_state_apply = {'merged': self.verify_diff_merged, + 'deleted': self.verify_diff_deleted, + 'replaced': self.verify_diff_replaced, + 'overridden': self.verify_diff_overridden, + 'gathered': self.verify_diff_gathered, + 'rendered': self.verify_diff_rendered, + 'parsed': self.verify_diff_parsed + } self.dnac_log = dnac_params.get("dnac_log") + self.dnac_log_level = dnac_params.get("dnac_log_level").upper() log(str(dnac_params)) self.supported_states = ["merged", "deleted", "replaced", "overridden", "gathered", "rendered", "parsed"] self.result = {"changed": False, "diff": [], "response": [], "warnings": []} @@ -100,14 +109,61 @@ def get_diff_rendered(self): def get_diff_parsed(self): # Implement logic to parse a configuration file self.parsed = True - return True + return self + + def verify_diff_merged(self): + # Implement logic to verify the merged resource configuration + self.merged = True + return self + + def verify_diff_deleted(self): + # Implement logic to verify the deleted resource + self.deleted = True + return self + + def verify_diff_replaced(self): + # Implement logic to verify the replaced resource + self.replaced = True + return self + + def verify_diff_overridden(self): + # Implement logic to verify the overwritten resource + self.overridden = True + return self + + def verify_diff_gathered(self): + # Implement logic to verify the gathered data about the resource + self.gathered = True + return self - def log(self, message, frameIncrement=0): - """Log messages into dnac.log file""" + def verify_diff_rendered(self): + # Implement logic to verify the rendered configuration template + self.rendered = True + return self + + def verify_diff_parsed(self): + # Implement logic to verify the parsed configuration file + self.parsed = True + return self - if self.dnac_log: + def log(self, message, level="info", frameIncrement=0): + """Logs/Appends messages into dnac.log file if logging is enabled and the log level is appropriate + Args: + self (obj, required): An instance of the DnacBase Class. + message (str, required): The log message to be recorded. + level (str, optional): The log level, default is "info". + The log level can be one of 'DEBUG', 'INFO', 'WARNING', 'ERROR', or 'CRITICAL'. + frameIncrement (int, optional): The number of frames to increment in the call stack, default is 0. + """ + + level = level.upper() + if ( + self.dnac_log + and self.dnac_log_level in ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') + and logging.getLevelName(level) >= logging.getLevelName(self.dnac_log_level) + ): message = "Module: " + self.__class__.__name__ + ", " + message - log(message, (1 + frameIncrement)) + log(message, level, (1 + frameIncrement)) def check_return_status(self): """API to check the return status value and exit/fail the module""" @@ -150,7 +206,8 @@ def get_dnac_params(self, params): "dnac_password": params.get("dnac_password"), "dnac_verify": params.get("dnac_verify"), "dnac_debug": params.get("dnac_debug"), - "dnac_log": params.get("dnac_log") + "dnac_log": params.get("dnac_log"), + "dnac_log_level": params.get("dnac_log_level") } return dnac_params @@ -318,14 +375,39 @@ def check_string_dictionary(self, task_details_data): pass return None + def camel_to_snake_case(self, config): + """ + Convert camel case keys to snake case keys in the config. + + Parameters: + config (list) - Playbook details provided by the user. + + Returns: + new_config (list) - Updated config after eliminating the camel cases. + """ + + if isinstance(config, dict): + new_config = {} + for key, value in config.items(): + new_key = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', key).lower() + if new_key != key: + self.log("{0} will be deprecated soon. Please use {1}.".format(key, new_key)) + new_value = self.camel_to_snake_case(value) + new_config[new_key] = new_value + elif isinstance(config, list): + return [self.camel_to_snake_case(item) for item in config] + else: + return config + return new_config + -def log(msg, frameIncrement=0): +def log(msg, level='info', frameIncrement=0): with open('dnac.log', 'a') as of: callerframerecord = inspect.stack()[1 + frameIncrement] frame = callerframerecord[0] info = inspect.getframeinfo(frame) d = datetime.datetime.now().replace(microsecond=0).isoformat() - of.write("---- %s ---- %s@%s ---- %s \n" % (d, info.lineno, info.function, msg)) + of.write("---- %s ---- %s@%s ---- %s: %s\n" % (d, info.lineno, info.function, level.upper(), msg)) def is_list_complex(x): diff --git a/plugins/modules/device_credential_intent.py b/plugins/modules/device_credential_intent.py index f7816cf7fa..0cd6b79170 100644 --- a/plugins/modules/device_credential_intent.py +++ b/plugins/modules/device_credential_intent.py @@ -25,6 +25,10 @@ author: Muthu Rakesh (@MUTHU-RAKESH-27) Madhan Sankaranarayanan (@madhansansel) options: + config_verify: + description: Set to True to verify the Cisco DNA Center after applying the playbook config. + type: bool + default: False state: description: The state of Cisco DNA Center after module completion. type: str @@ -836,6 +840,7 @@ def validate_input(self): } # Validate playbook params against the specification (temp_spec) + self.config = self.camel_to_snake_case(self.config) valid_temp, invalid_params = validate_list_of_dicts(self.config, temp_spec) if invalid_params: self.msg = "Invalid parameters in playbook: {0}".format("\n".join(invalid_params)) @@ -1056,7 +1061,7 @@ def get_snmpV3_params(self, snmpV3Details): value = { "username": item.get("username"), "description": item.get("description"), - "snmpMode": item.get("snmp_mode"), + "snmpMode": item.get("snmpMode"), "id": item.get("id"), } if value.get("snmpMode") == "AUTHNOPRIV": @@ -2407,6 +2412,103 @@ def get_diff_deleted(self, config): return self + def verify_diff_merged(self, config): + """ + Validating the DNAC configuration with the playbook details + when state is merged (Create/Update). + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self + """ + + self.log(str("Entered the verify function.")) + self.get_have(config) + self.get_want(config) + self.log("DNAC retrieved details: " + str(self.have)) + self.log("Playbook details: " + str(self.want)) + + if config.get("global_credential_details") is not None: + if self.want.get("want_create"): + self.msg = "Global Device Credentials config is not applied to the DNAC" + self.status = "failed" + return self + + if self.want.get("want_update"): + credential_types = ["cliCredential", "snmpV2cRead", "snmpV2cWrite", + "httpsRead", "httpsWrite", "snmpV3"] + value_mapping = { + "cliCredential": ["username", "description", "id"], + "snmpV2cRead": ["description", "id"], + "snmpV2cWrite": ["description", "id"], + "httpsRead": ["description", "username", "port", "id"], + "httpsWrite": ["description", "username", "port", "id"], + "snmpV3": ["username", "description", "snmpMode", "id"] + } + for credential_type in credential_types: + if self.want.get(credential_type): + want_credential = self.want.get(credential_type) + if self.have.get(credential_type): + have_credential = self.have.get(credential_type) + values = value_mapping.get(credential_type) + for value in values: + equality = have_credential.get(value) is want_credential.get(value) + if not have_credential or not equality: + self.msg = "{0} config is not applied ot the DNAC".format(credential_type) + self.status = "failed" + return self + + self.log("Successfully validated Global Device Credential") + self.result.get("response")[0].get("globalCredential").update({"Validation": "Success"}) + + if config.get("assign_credentials_to_site") is not None: + self.log("Successfully validated the Assign Device Credential to site") + self.result.get("response")[0].get("assignCredential").update({"Validation": "Success"}) + + self.msg = "Successfully validated the Global Device Credential and \ + Assign Device Credential to Site." + self.status = "success" + return self + + def verify_diff_deleted(self, config): + """ + Validating the DNAC configuration with the playbook details + when state is deleted (delete). + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self + """ + + self.get_have(config) + self.log("DNAC retrieved details: " + str(self.have)) + self.log("Playbook details: " + str(self.want)) + + if config.get("global_credential_details") is not None: + have_global_credential = self.have.get("globalCredential") + credential_types = ["cliCredential", "snmpV2cRead", "snmpV2cWrite", + "httpsRead", "httpsWrite", "snmpV3"] + for credential_type in credential_types: + for item in have_global_credential.get(credential_type): + if item is not None: + self.msg = "Delete Global Device Credentials config \ + is not applied to the config" + self.status = "failed" + return self + + self.log("Successfully validated absence of Global Device Credential.") + self.result.get("response")[0].get("globalCredential").update({"Validation": "Success"}) + + self.msg = "Successfully validated the absence of Global Device Credential." + self.status = "success" + return self + def reset_values(self): """ Reset all neccessary attributes to default values @@ -2436,6 +2538,7 @@ def main(): "dnac_version": {"type": 'str', "default": '2.2.3.3'}, "dnac_debug": {"type": 'bool', "default": False}, "dnac_log": {"type": 'bool', "default": False}, + "config_verify": {"type": 'bool', "default": False}, "config": {"type": 'list', "required": True, "elements": 'dict'}, "state": {"default": 'merged', "choices": ['merged', 'deleted']}, "validate_response_schema": {"type": 'bool', "default": True}, @@ -2445,6 +2548,7 @@ def main(): module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) dnac_credential = DnacCredential(module) state = dnac_credential.params.get("state") + config_verify = dnac_credential.params.get("config_verify") if state not in dnac_credential.supported_states: dnac_credential.status = "invalid" dnac_credential.msg = "State {0} is invalid".format(state) @@ -2458,6 +2562,8 @@ def main(): if state != "deleted": dnac_credential.get_want(config).check_return_status() dnac_credential.get_diff_state_apply[state](config).check_return_status() + if config_verify: + dnac_credential.verify_diff_state_apply[state](config).check_return_status() module.exit_json(**dnac_credential.result) diff --git a/plugins/modules/discovery_intent.py b/plugins/modules/discovery_intent.py index d1a22d800d..5ed392921d 100644 --- a/plugins/modules/discovery_intent.py +++ b/plugins/modules/discovery_intent.py @@ -22,6 +22,10 @@ author: Abinash Mishra (@abimishr) Phan Nguyen (phannguy) options: + config_verify: + description: Set to True to verify the Cisco DNA Center config after applying the playbook config. + type: bool + default: False state: description: The state of DNAC after module completion. type: str @@ -48,7 +52,7 @@ type: str required: true discovery_type: - description: Type of discovery (SINGLE/RANGE/MULTI RANGE/CDP/LLDP/CIDR) + description: Type of discovery (SINGLE/RANGE/MULTI RANGE/CDP/LLDP) type: str required: true cdp_level: @@ -60,14 +64,14 @@ type: int default: 16 start_index: - description: Start index for the header in fetching global v2 credentials + description: Start index for the header in fetching SNMP v2 credentials type: int enable_password_list: description: List of enable passwords for the CLI crfedentials type: list elements: str records_to_return: - description: Number of records to returnfor the header in fetching global v2 credentials + description: Number of records to return for the header in fetching global v2 credentials type: int http_read_credential: description: HTTP read credentials for hosting a device @@ -81,7 +85,8 @@ elements: str discovery_name: description: Name of the discovery task - type: dict + type: str + required: true netconf_port: description: Port for the netconf credentials type: str @@ -176,14 +181,14 @@ dnac_log: True state: merged config: - - device_list: + - devices_list: - name: string ip: string discovery_type: string cdp_level: string lldp_level: string start_index: integer - enable_pasword_list: list + enable_password_list: list records_to_return: integer http_read_credential: string http_write_credential: string @@ -196,7 +201,7 @@ snmp_auth_passphrase: string snmp_auth_protocol: string snmp_mode: string - snmp_priv_passphrse: string + snmp_priv_passphrase: string snmp_priv_protocol: string snmp_ro_community: string snmp_ro_community_desc: string @@ -206,6 +211,24 @@ snmp_version: string timeout: integer username_list: list +- name: Delete disovery by name + cisco.dnac.discovery_intent: + 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 + state: deleted + config: + - devices_list: + - name: string + ip: string + start_index: integer + records_to_return: integer + discovery_name: string """ RETURN = r""" @@ -300,7 +323,6 @@ def validate_input(self): self.status = "success" return self - default_dicovery_name = 'discovery_' + str(time.time()) discovery_spec = { 'cdp_level': {'type': 'int', 'required': False, 'default': 16}, @@ -309,7 +331,8 @@ def validate_input(self): 'elements': 'str'}, 'devices_list': {'type': 'list', 'required': True, 'elements': 'dict'}, - 'start_index': {'type': 'int', 'required': False}, + 'start_index': {'type': 'int', 'required': False, + 'default': 25}, 'records_to_return': {'type': 'int', 'required': False}, 'http_read_credential': {'type': 'dict', 'required': False}, 'http_write_credential': {'type': 'dict', 'required': False}, @@ -317,8 +340,9 @@ def validate_input(self): 'elements': 'str'}, 'lldp_level': {'type': 'int', 'required': False, 'default': 16}, - 'discovery_name': {'type': 'dict', 'required': False, - 'default': '{0}'.format(default_dicovery_name)}, + 'prefix_length': {'type': 'int', 'required': False, + 'default': 30}, + 'discovery_name': {'type': 'str', 'required': True}, 'netconf_port': {'type': 'str', 'required': False}, 'password_list': {'type': 'list', 'required': False, 'elements': 'str'}, @@ -391,6 +415,7 @@ def get_dnac_global_credentials_v2_info(self): params=self.validated_config[0].get('headers'), ) response = response.get('response') + self.log(response) for value in response.values(): if not value: continue @@ -436,8 +461,17 @@ def preprocessing_devices_info(self, devices_list=None): ip_address_list = [device['ip'] for device in devices_list] - if self.validated_config[0].get('discovery_type') == "SINGLE": - ip_address_list = ip_address_list[0] + if self.validated_config[0].get('discovery_type') in ["SINGLE", "CDP", "LLDP"]: + if len(ip_address_list) == 1: + ip_address_list = ip_address_list[0] + else: + self.module.fail_json(msg="Device list's length is longer than 1", response=[]) + elif self.validated_config[0].get('discovery_type') == "CIDR": + if len(ip_address_list) == 1 and self.validated_config[0].get('prefix_length'): + ip_address_list = ip_address_list[0] + ip_address_list = str(ip_address_list) + "/" + str(self.validated_config[0].get('prefix_length')) + else: + self.module.fail_json(msg="Device list's length is longer than 1", response=[]) else: ip_address_list = list( map( @@ -447,6 +481,7 @@ def preprocessing_devices_info(self, devices_list=None): ) ip_address_list = ','.join(ip_address_list) + self.log("Collected IP address/addresses are {0}".format(ip_address_list)) return ip_address_list def create_params(self, credential_ids=None, ip_address_list=None): @@ -510,6 +545,7 @@ def create_params(self, credential_ids=None, ip_address_list=None): new_object_params['snmpVersion'] = self.validated_config[0].get('snmp_version') new_object_params['timeout'] = self.validated_config[0].get('timeout') new_object_params['userNameList'] = self.validated_config[0].get('user_name_list') + self.log(new_object_params) return new_object_params @@ -541,6 +577,8 @@ def create_discovery(self, credential_ids=None, ip_address_list=None): op_modifies=True, ) + self.log(result) + self.result.update(dict(discovery_result=result)) return result.response.get('taskId') @@ -567,6 +605,7 @@ def get_task_status(self, task_id=None): params=params, ) response = response.response + self.log(response) if response.get('isError') or re.search( 'failed', response.get('progress'), flags=re.IGNORECASE ): @@ -605,6 +644,8 @@ def lookup_discovery_by_range_via_name(self): params=params ) + self.log(response) + return next( filter( lambda x: x['name'] == self.validated_config[0].get('discovery_name'), @@ -630,6 +671,7 @@ def get_discoveries_by_range_until_success(self): if not discovery: msg = 'Cannot find any discovery task with name {0} -- Discovery result: {1}'.format( self.validated_config[0].get("discovery_name"), discovery) + self.log(msg) self.module.fail_json(msg=msg) while True: @@ -643,6 +685,7 @@ def get_discoveries_by_range_until_success(self): if not result: msg = 'Cannot find any discovery task with name {0} -- Discovery result: {1}'.format( self.validated_config[0].get("discovery_name"), discovery) + self.log(msg) self.module.fail_json(msg=msg) self.result.update(dict(discovery_range=discovery)) @@ -677,6 +720,8 @@ def get_discovery_device_info(self, discovery_id=None, task_id=None): params=params, ) devices = response.response + + self.log(devices) if all(res.get('reachabilityStatus') == 'Success' for res in devices): result = True break @@ -704,7 +749,6 @@ def get_exist_discovery(self): returns None and updates the 'exist_discovery' entry in the result dictionary to None. """ - discovery = self.lookup_discovery_by_range_via_name() if not discovery: self.result.update(dict(exist_discovery=discovery)) @@ -732,6 +776,8 @@ def delete_exist_discovery(self, params): function="delete_discovery_by_id", params=params, ) + + self.log(response) self.result.update(dict(delete_discovery=response)) return response.response.get('taskId') @@ -795,6 +841,81 @@ def get_diff_deleted(self): return self + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of Discovery in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the merged status of a configuration in Cisco DNA Center by + retrieving the current state (have) and desired state (want) of the configuration, + logs the states, and validates whether the specified device(s) exists in the DNA + Center configuration's Discovery Database. + """ + + self.log(str(self.have)) + # Code to validate dnac config for merged state + discovery_task_info = self.get_discoveries_by_range_until_success() + discovery_id = discovery_task_info.get('id') + params = dict( + id=discovery_id + ) + response = self.dnac_apply['exec']( + family="discovery", + function='get_discovery_by_id', + params=params + ) + + if response: + discovery_name = response.get('response').get('name') + self.log("Requested Discovery with name {0} is completed".format(discovery_name)) + + else: + self.log("Requested Discovery with name {0} is not completed".format(discovery_name)) + self.status = "success" + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of Discovery in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the deletion status of a configuration in Cisco DNA Center. + It validates whether the specified discovery(s) exists in the DNA Center configuration's + Discovery Database. + """ + + self.log(str(self.have)) + # Code to validate dnac config for deleted state + discovery_task_info = self.get_discoveries_by_range_until_success() + discovery_id = discovery_task_info.get('id') + params = dict( + id=discovery_id + ) + response = self.dnac_apply['exec']( + family="discovery", + function='get_discovery_by_id', + params=params + ) + + if response: + discovery_name = response.get('response').get('name') + self.log("Requested Discovery with name {0} is present".format(discovery_name)) + + else: + self.log("Requested Discovery with name {0} is not present and deleted".format(discovery_name)) + self.status = "success" + + return self + def main(): """ main entry point for module execution @@ -809,6 +930,7 @@ def main(): 'dnac_debug': {'type': 'bool', 'default': False}, 'dnac_log': {'type': 'bool', 'default': False}, 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {"type": 'bool', "default": False}, 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} } @@ -817,6 +939,7 @@ def main(): supports_check_mode=False) dnac_discovery = DnacDiscovery(module) + config_verify = dnac_discovery.params.get("config_verify") state = dnac_discovery.params.get("state") if state not in dnac_discovery.supported_states: diff --git a/plugins/modules/inventory_intent.py b/plugins/modules/inventory_intent.py index 7f2d4207ad..c1c5431085 100644 --- a/plugins/modules/inventory_intent.py +++ b/plugins/modules/inventory_intent.py @@ -24,6 +24,10 @@ author: Abhishek Maheshwari (@abmahesh) Madhan Sankaranarayanan (@madhansansel) options: + config_verify: + description: Set to True to verify the Cisco DNA Center config after applying the playbook config. + type: bool + default: False state: description: The state of Cisco DNA Center after module completion. type: str @@ -59,17 +63,10 @@ http_username: description: Device's http username. Required for Adding Compute,Firepower Management Devices. type: str - id: - description: Id path parameter that is Device ID. Required for Deleting/Updating Device Roles. - type: str ip_address: description: Device's ipAddress. Required for Adding/Updating/Deleting/Resyncing Device except Meraki Devices. elements: str type: list - meraki_org_id: - description: Device's meraki org id. - elements: str - type: list netconf_port: description: Device's netconf port. type: str @@ -100,7 +97,7 @@ snmp_priv_protocol: description: Device's snmp Private Protocol. Required for Adding Network, Compute, Third Party Devices. type: str - default: "AES128" + default: "CISCOAES128" snmp_ro_community: description: Device's snmp ROCommunity. Required for Adding V2C Devices. type: str @@ -143,6 +140,26 @@ description: If forcesync is true then device sync would run in high priority thread if available, else the sync will fail. type: bool default: false + device_added: + description: Make this as true needed for the addition of device in inventory. + type: bool + default: false + device_updated: + description: Make this as true needed for the updation of device role, interface details, device credentails or details. + type: bool + default: false + device_resync: + description: Make this as true needed for the resyncing of device. + type: bool + default: false + reboot_device: + description: Make this as true needed for the Rebooting of Access Points. + type: bool + default: false + credential_update: + description: Make this as true needed for the updation of device credentials and other device details. + type: bool + default: false clean_config: description: Required if need to delete the Provisioned device by clearing current configuration. type: bool @@ -186,7 +203,33 @@ parameters: description: List of device parameters that needs to be exported to file. type: str - + managed_ap_locations: + description: Location of the sites allocated for the APs + type: list + elements: str + dynamic_interfaces: + description: Interface details of the wireless device + type: list + elements: dict + suboptions: + interface_ip_address: + description: Ip Address allocated to the interface + type: str + interface_netmask_in_cidr: + description: Ip Address allocated to the interface + type: int + interface_gateway: + description: Ip Address allocated to the interface + type: str + lag_or_port_number: + description: Ip Address allocated to the interface + type: int + vlan_id: + description: Ip Address allocated to the interface + type: int + interface_name: + description: Ip Address allocated to the interface + type: str requirements: - dnacentersdk >= 2.5.5 @@ -216,7 +259,7 @@ """ EXAMPLES = r""" -- name: Add/Update new device in Inventory with full credentials +- name: Add new device in Inventory with full credentials cisco.dnac.inventory_intent: dnac_host: "{{dnac_host}}" dnac_username: "{{dnac_username}}" @@ -229,17 +272,15 @@ state: merged config: - cli_transport: string - compute_device: true + compute_device: false enable_password: string extended_discovery_info: string http_password: string http_port: string - http_secure: true + http_secure: false http_username: string ip_address: - string - meraki_org_id: - - string netconf_port: string password: string serial_number: string @@ -256,9 +297,6 @@ snmp_version: string type: string device_added: true - update_mgmt_ipaddresslist: - - exist_mgmt_ipaddress: string - new_mgmt_ipaddress: string username: string - name: Add new Compute device in Inventory with full credentials.Inputs needed for Compute Device @@ -273,7 +311,8 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string http_username: string http_password: string http_port: string @@ -285,6 +324,7 @@ snmp_retry: 3 snmp_timeout: 5 snmp_username: string + compute_device: true username: string device_added: true type: "COMPUTE_DEVICE" @@ -317,7 +357,8 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string http_username: string http_password: string http_port: string @@ -336,7 +377,8 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string snmp_auth_passphrase: string snmp_auth_protocol: string snmp_mode: string @@ -348,6 +390,46 @@ device_added: true type: "THIRD_PARTY_DEVICE" +- name: Update device details or credentails in Inventory + cisco.dnac.inventory_intent: + 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: False + state: merged + config: + - cli_transport: string + compute_device: false + password: string + enable_password: string + extended_discovery_info: string + http_password: string + http_port: string + http_secure: false + http_username: string + ip_address: + - string + netconf_port: string + serial_number: string + snmp_auth_passphrase: string + snmp_auth_protocol: string + snmp_mode: string + snmp_priv_passphrase: string + snmp_priv_protocol: string + snmp_username: string + snmp_version: string + type: string + device_update: true + credential_update: true + update_mgmt_ipaddresslist: + - exist_mgmt_ipaddress: string + new_mgmt_ipaddress: string + username: string + - name: Associate Wired Devices to site and Provisioned it in Inventory cisco.dnac.inventory_intent: dnac_host: "{{dnac_host}}" @@ -360,10 +442,37 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string provision_wired_device: site_name: string +- name: Associate Wireless Devices to site and Provisioned it in Inventory + cisco.dnac.inventory_intent: + 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: False + state: merged + config: + - ip_address: + - string + provision_wireless_device: + - site_name: string + managed_ap_locations: + - string + dynamic_interfaces: + - interface_ip_address: string + interface_netmask_in_cidr: int + interface_gateway: string + lag_or_port_number: int + vlan_id: int + interface_name: string + - name: Update Device Role with IP Address cisco.dnac.inventory_intent: dnac_host: "{{dnac_host}}" @@ -376,7 +485,8 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string device_updated: true update_device_role: role: string @@ -394,13 +504,15 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string device_updated: true update_interface_details: description: str admin_status: str vlan_id: int voice_vlan_id: int + deployment_mode: str - name: Export Device Details in a CSV file Interface details with IP Address cisco.dnac.inventory_intent: @@ -414,7 +526,8 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string export_device_list: password: str operation_enum: str @@ -432,7 +545,8 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string add_user_defined_field: name: string description: string @@ -450,7 +564,8 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string device_resync: true force_sync: false @@ -466,10 +581,11 @@ dnac_log: False state: merged config: - - ip_address: string + - ip_address: + - string reboot_device: true -- name: Delete Provision/Unprovisioned Devices by IP Address +- name: Delete Provision/Unprovision Devices by IP Address cisco.dnac.inventory_intent: dnac_host: "{{dnac_host}}" dnac_username: "{{dnac_username}}" @@ -481,7 +597,8 @@ dnac_log: False state: deleted config: - - ip_address: string + - ip_address: + - string clean_config: false - name: Delete Global User Defined Field with name @@ -496,7 +613,8 @@ dnac_log: False state: deleted config: - - ip_address: string + - ip_address: + - string add_user_defined_field: name: string @@ -562,43 +680,75 @@ def validate_input(self): 'self.msg' will describe the validation issues. """ - temp_spec = {'cli_transport': {'default': "telnet", 'type': 'str'}, - 'compute_device': {'type': 'bool'}, - 'enable_password': {'type': 'str'}, - 'extended_discovery_info': {'type': 'str'}, - 'http_password': {'type': 'str'}, - 'http_port': {'type': 'str'}, - 'http_secure': {'type': 'bool'}, - 'http_username': {'type': 'str'}, - 'ip_address': {'type': 'list', 'elements': 'str'}, - 'meraki_org_id': {'type': 'list', 'elements': 'str'}, - 'netconf_port': {'type': 'str'}, - 'password': {'type': 'str'}, - 'serial_number': {'type': 'str'}, - 'snmp_auth_passphrase': {'type': 'str'}, - 'snmp_auth_protocol': {'default': "SHA", 'type': 'str'}, - 'snmp_mode': {'default': "AUTHPRIV", 'type': 'str'}, - 'snmp_priv_passphrase': {'type': 'str'}, - 'snmp_priv_protocol': {'default': "AES128", 'type': 'str'}, - 'snmp_ro_community': {'default': "public", 'type': 'str'}, - 'snmp_rw_community': {'default': "private", 'type': 'str'}, - 'snmp_retry': {'default': 3, 'type': 'int'}, - 'snmp_timeout': {'default': 5, 'type': 'int'}, - 'snmp_username': {'type': 'str'}, - 'snmp_version': {'default': "v3", 'type': 'str'}, - 'update_mgmt_ipaddresslist': {'type': 'list', 'elements': 'dict'}, - 'username': {'type': 'str'}, - 'update_device_role': {'type': 'dict'}, - 'device_added': {'type': 'bool'}, - 'device_resync': {'type': 'bool'}, - 'force_sync': {'type': 'bool'}, - 'clean_config': {'type': 'bool'}, - 'add_user_defined_field': {'type': 'dict'}, - 'upate_interface_details': {'type': 'dict'}, - 'deployment_mode': {'default': 'Deploy', 'type': 'str'}, - 'provision_wired_device': {'type': 'dict'}, - 'export_device_list': {'type': 'dict'} - } + temp_spec = { + 'cli_transport': {'default': "telnet", 'type': 'str'}, + 'compute_device': {'type': 'bool'}, + 'enable_password': {'type': 'str'}, + 'extended_discovery_info': {'type': 'str'}, + 'http_password': {'type': 'str'}, + 'http_port': {'type': 'str'}, + 'http_secure': {'type': 'bool'}, + 'http_username': {'type': 'str'}, + 'ip_address': {'type': 'list', 'elements': 'str'}, + 'netconf_port': {'type': 'str'}, + 'password': {'type': 'str'}, + 'serial_number': {'type': 'str'}, + 'snmp_auth_passphrase': {'type': 'str'}, + 'snmp_auth_protocol': {'default': "SHA", 'type': 'str'}, + 'snmp_mode': {'default': "AUTHPRIV", 'type': 'str'}, + 'snmp_priv_passphrase': {'type': 'str'}, + 'snmp_priv_protocol': {'default': "CISCOAES128", 'type': 'str'}, + 'snmp_ro_community': {'default': "public", 'type': 'str'}, + 'snmp_rw_community': {'default': "private", 'type': 'str'}, + 'snmp_retry': {'default': 3, 'type': 'int'}, + 'snmp_timeout': {'default': 5, 'type': 'int'}, + 'snmp_username': {'type': 'str'}, + 'snmp_version': {'default': "v3", 'type': 'str'}, + 'update_mgmt_ipaddresslist': {'type': 'list', 'elements': 'dict'}, + 'username': {'type': 'str'}, + 'update_device_role': {'type': 'dict'}, + 'device_added': {'type': 'bool'}, + 'device_updated': {'type': 'bool'}, + 'device_resync': {'type': 'bool'}, + 'reboot_device': {'type': 'bool'}, + 'credential_update': {'type': 'bool'}, + 'force_sync': {'type': 'bool'}, + 'clean_config': {'type': 'bool'}, + 'add_user_defined_field': { + 'type': 'dict', + 'name': {'type': 'str'}, + 'description': {'type': 'str'}, + 'value': {'type': 'str'}, + }, + 'update_interface_details': { + 'type': 'dict', + 'description': {'type': 'str'}, + 'vlan_id': {'type': 'int'}, + 'voice_vlan_id': {'type': 'int'}, + }, + 'export_device_list': { + 'type': 'dict', + 'password': {'type': 'str'}, + 'operation_enum': {'type': 'str'}, + 'parameters': {'type': 'str'}, + }, + 'deployment_mode': {'default': 'Deploy', 'type': 'str'}, + 'provision_wired_device': {'type': 'dict'}, + 'provision_wireless_device': { + 'type': 'list', + 'site_name': {'type': 'str'}, + 'managed_ap_locations': {'type': 'list', 'elements': 'str'}, + 'dynamic_interfaces': { + 'type': 'list', + 'interface_ip_address': {'type': 'str'}, + 'interface_netmask_in_cidr': {'type': 'int'}, + 'interface_gateway': {'type': 'str'}, + 'lag_or_port_number': {'type': 'int'}, + 'vlan_id': {'type': 'int'}, + 'interface_name': {'type': 'str'}, + }, + } + } # Validate device params valid_temp, invalid_params = validate_list_of_dicts( @@ -613,7 +763,7 @@ def validate_input(self): return self self.validated_config = valid_temp - log(str(valid_temp)) + self.log(str(valid_temp)) self.msg = "Successfully validated input" self.status = "success" @@ -759,6 +909,113 @@ def add_field_to_devices(self, device_ids): return self + def trigger_export_api(self, payload_params): + """ + Triggers the export API to generate a CSV file containing device details based on the given payload parameters. + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + payload_params (dict): A dictionary containing parameters required for the export API. + Returns: + dict: The response from the export API, including information about the task and file ID. + If the export is successful, the CSV file can be downloaded using the file ID. + Description: + The function initiates the export API in Cisco DNA Center to generate a CSV file containing detailed information + about devices.The response from the API includes task details and a file ID. + """ + + response = self.dnac._exec( + family="devices", + function='export_device_list', + op_modifies=True, + params=payload_params, + ) + self.log(str(response)) + response = response.get("response") + task_id = response.get("taskId") + + while True: + execution_details = self.get_task_details(task_id) + + if execution_details.get("additionalStatusURL"): + file_id = execution_details.get("additionalStatusURL").split("/")[-1] + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Could not get the File ID because of {0} so can't export device details in csv file".format(failure_reason) + else: + self.msg = "Could not get the File ID so can't export device details in csv file" + self.log(self.msg) + return response + + # With this File ID call the Download File by FileID API and process the response + response = self.dnac._exec( + family="file", + function='download_a_file_by_fileid', + op_modifies=True, + params={"file_id": file_id}, + ) + + return response + + def decrypt_and_read_csv(self, response, password): + """ + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + response (requests.Response): HTTP response object containing the encrypted CSV file. + password (str): Password used for decrypting the CSV file. + Returns: + csv.DictReader: A CSV reader object for the decrypted content, allowing iteration over rows as dictionaries. + Description: + Decrypts and reads a CSV-like file from the given HTTP response using the provided password. + """ + + zip_data = BytesIO(response.data) + + if not HAS_PYZIPPER: + self.msg = "pyzipper is required for this module. Install pyzipper to use this functionality." + self.log(self.msg) + self.status = "failed" + return self + + snmp_protocol = self.config[0].get('snmp_priv_protocol', 'CISCOAES128') + encryption_dict = { + 'CISCOAES128': 'pyzipper.WZ_AES128', + 'CISCOAES192': 'pyzipper.WZ_AES192', + 'CISCOAES256': 'pyzipper.WZ_AES' + } + try: + encryption_method = encryption_dict.get(snmp_protocol) + except Exception as e: + self.log("Given SNMP protcol {0} not present".format(snmp_protocol)) + + if not encryption_method: + self.msg = "Invalid SNMP protocol {0} specified for encryption.".format(snmp_protocol) + self.log(self.msg) + self.status = "failed" + return self + + # Create a PyZipper object with the password + with pyzipper.AESZipFile(zip_data, 'r', compression=pyzipper.ZIP_LZMA, encryption=encryption_method) as zip_ref: + # Assuming there is a single file in the zip archive + file_name = zip_ref.namelist()[0] + + # Extract the content of the file with the provided password + file_content_binary = zip_ref.read(file_name, pwd=password.encode('utf-8')) + + # Now 'file_content_binary' contains the binary content of the decrypted file + # Since the content is text, so we can decode it + file_content_text = file_content_binary.decode('utf-8') + + # Now 'file_content_text' contains the text content of the decrypted file + self.log(file_content_text) + + # Parse the CSV-like string into a list of dictionaries + csv_reader = csv.DictReader(StringIO(file_content_text)) + + return csv_reader + def export_device_details(self): """ Export device details from Cisco DNA Center into a CSV file. @@ -798,7 +1055,6 @@ def export_device_details(self): if not self.is_valid_password(password): self.status = "failed" - self.result['changed'] = False detailed_msg = """Invalid password. Min password length is 8 and it should contain atleast one lower case letter, one uppercase letter, one digit and one special characters from -=\\;,./~!@#$%^&*()_+{}[]|:?""" formatted_msg = ' '.join(line.strip() for line in detailed_msg.splitlines()) @@ -813,71 +1069,14 @@ def export_device_details(self): "paramters": export_device_list.get("paramters") } - response = self.dnac._exec( - family="devices", - function='export_device_list', - op_modifies=True, - params=payload_params, - ) - self.log(str(response)) - response = response.get("response") - task_id = response.get("taskId") - - while True: - execution_details = self.get_task_details(task_id) - - if execution_details.get("additionalStatusURL"): - file_id = execution_details.get("additionalStatusURL").split("/")[-1] - break - elif execution_details.get("isError"): - self.status = "failed" - failure_reason = execution_details.get("failureReason") - if failure_reason: - self.msg = "Could not get the File ID because of {0} so can't export device details in csv file".format(failure_reason) - else: - self.msg = "Could not get the File ID so can't export device details in csv file" - self.log(self.msg) - - return self - - # With this File ID call the Download File by FileID API and process the response - response = self.dnac._exec( - family="file", - function='download_a_file_by_fileid', - op_modifies=True, - params={"file_id": file_id}, - ) + response = self.trigger_export_api(payload_params) + self.check_return_status() if payload_params["operationEnum"] == "0": - zip_data = BytesIO(response.data) - - if not HAS_PYZIPPER: - self.msg = "pyzipper is required for this module. Install pyzipper to use this functionality." - self.log(self.msg) - self.status = "failed" - - return self - - # Create a PyZipper object with the password - with pyzipper.AESZipFile(zip_data, 'r', compression=pyzipper.ZIP_LZMA, encryption=pyzipper.WZ_AES) as zip_ref: - # Assuming there is a single file in the zip archive - file_name = zip_ref.namelist()[0] - - # Extract the content of the file with the provided password - file_content_binary = zip_ref.read(file_name, pwd=password.encode('utf-8')) - - # Now 'file_content_binary' contains the binary content of the decrypted file - # Since the content is text, so we can decode it - file_content_text = file_content_binary.decode('utf-8') - - # Now 'file_content_text' contains the text content of the decrypted file - self.log(file_content_text) - - # Parse the CSV-like string into a list of dictionaries - csv_reader = csv.DictReader(StringIO(file_content_text)) temp_file_name = response.filename output_file_name = temp_file_name.split(".")[0] + ".csv" - + csv_reader = self.decrypt_and_read_csv(response, password) + self.check_return_status() else: encoded_resp = response.data.decode(encoding='utf-8') self.log(str(encoded_resp)) @@ -911,6 +1110,38 @@ def export_device_details(self): return self + def get_ap_devices(self, device_ips): + """ + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + device_ip (str): The management IP address of the device for which the response is to be retrieved. + Returns: + list: A list containing Access Point device IP's obtained from the Cisco DNA Center. + Description: + This method communicates with Cisco DNA Center to retrieve the details of a device with the specified + management IP address and check if device family matched to Unified AP. It executes the 'get_device_list' + API call with the provided device IP address, logs the response, and returns list containing ap device ips. + """ + + ap_device_list = [] + for device_ip in device_ips: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + response = response.get('response', []) + + if response and response[0].get('family', '') == "Unified AP": + ap_device_list.append(device_ip) + except Exception as e: + error_message = "Error while getting the response of device from Cisco DNA Center - {0}".format(str(e)) + self.log(error_message) + raise Exception(error_message) + + return ap_device_list + def resync_devices(self): """ Resync devices in Cisco DNA Center. @@ -929,16 +1160,23 @@ def resync_devices(self): device_ips = self.config[0].get("ip_address", []) if not device_ips: - msg = "Cannot perform the Resync operation as device's are not present in Cisco DNA Center" - self.status = "failed" - self.msg = msg - self.log(msg) + self.msg = "Cannot perform the Resync operation as device's {0} are not present inCisco Catalyst Center".format(str(device_ips)) + self.status = "success" + self.result['changed'] = False + self.log(self.msg) return self - device_ids = self.get_device_ids(device_ips) + ap_devices = self.get_ap_devices(device_ips) + self.log("AP Devices from the playbook input are: {0}".format(str(ap_devices))) + if ap_devices: + for ap_ip in ap_devices: + device_ips.remove(ap_ip) + self.log("Following devices {0} are AP, so can't perform resync operation.".format(str(ap_devices))) + + device_ids = self.get_device_ids(device_ips) try: - force_sync = self.config[0].get("force_sync", "False") + force_sync = self.config[0].get("force_sync", False) resync_param_dict = { 'payload': device_ids, 'force_sync': force_sync @@ -995,32 +1233,44 @@ def reboot_access_points(self): """ device_ips = self.config[0].get("ip_address", []) + if device_ips: + ap_devices = self.get_ap_devices(device_ips) + self.log("AP Devices from the playbook input are : {0}".format(str(ap_devices))) + for device_ip in device_ips: + if device_ip not in ap_devices: + device_ips.remove(device_ip) if not device_ips: self.msg = "No AP Devices IP given in the playbook so can't perform reboot operation" self.status = "success" self.result['changed'] = False + self.result['response'] = self.msg self.log(self.msg) return self - ap_mac_address_list = [] # Get and store the apEthernetMacAddress of given devices + ap_mac_address_list = [] for device_ip in device_ips: response = self.dnac._exec( family="devices", function='get_device_list', params={"managementIpAddress": device_ip} ) - response = response.get('response')[0] + response = response.get('response') + if not response: + continue + + response = response[0] ap_mac_address = response.get('apEthernetMacAddress') if ap_mac_address is not None: ap_mac_address_list.append(ap_mac_address) if not ap_mac_address_list: - self.status = "failed" + self.status = "success" self.result['changed'] = False self.msg = "Cannot find the AP devices for rebooting" + self.result['response'] = self.msg self.log(self.msg) return self @@ -1057,10 +1307,134 @@ def reboot_access_points(self): break self.log("AP Devices Rebooted Successfully and Rebooted devices are :" + str(device_ips)) - msg = "Device " + str(device_ips) + " Rebooted Successfully !!" + self.msg = "Device " + str(device_ips) + " Rebooted Successfully !!" return self + def handle_successful_provisioning(self, device_ip, execution_details, device_type): + """ + Handle successful provisioning of Wired/Wireless device. + Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - device_ip (str): The IP address of the provisioned device. + - execution_details (str): Details of the provisioning execution. + - device_type (str): The type or category of the provisioned device(Wired/Wireless). + Return: + None + Description: + This method updates the status, result, and logs the successful provisioning of a device. + """ + + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.log("{0} Device {1} provisioned successfully!!".format(device_type, device_ip)) + + def handle_failed_provisioning(self, device_ip, execution_details, device_type): + """ + Handle failed provisioning of Wired/Wireless device. + Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - device_ip (str): The IP address of the device that failed provisioning. + - execution_details (dict): Details of the failed provisioning execution in key "failureReason" indicating reason for failure. + - device_type (str): The type or category of the provisioned device(Wired/Wireless). + Return: + None + Description: + This method updates the status, result, and logs the failure of provisioning for a device. + """ + + self.status = "failed" + failure_reason = execution_details.get("failureReason", "Unknown failure reason") + self.msg = "{0} Device Provisioning failed for {1} because of {2}".format(device_type, device_ip, failure_reason) + self.log(self.msg) + + def handle_provisioning_exception(self, device_ip, exception, device_type): + """ + Handle an exception during the provisioning process of Wired/Wireless device.. + Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - device_ip (str): The IP address of the device involved in provisioning. + - exception (Exception): The exception raised during provisioning. + - device_type (str): The type or category of the provisioned device(Wired/Wireless). + Return: + None + Description: + This method logs an error message indicating an exception occurred during the provisioning process for a device. + """ + + error_message = "Error while Provisioning the {0} device {1} in Cisco DNA Center - {2}".format(device_type, device_ip, str(exception)) + self.log(error_message) + + def handle_all_already_provisioned(self, device_ips, device_type): + """ + Handle successful provisioning for all devices(Wired/Wireless). + Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - device_type (str): The type or category of the provisioned device(Wired/Wireless). + Return: + None + Description: + This method updates the status, result, and logs the successful provisioning for all devices(Wired/Wireless). + """ + + self.status = "success" + self.msg = "All the {0} Devices - {1} given in the playbook are already Provisioned".format(device_type, str(device_ips)) + self.log(self.msg) + self.result['response'] = self.msg + self.result['changed'] = False + + def handle_all_provisioned(self, device_type): + """ + Handle successful provisioning for all devices(Wired/Wireless). + Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - device_type (str): The type or category of the provisioned devices(Wired/Wireless). + Return: + None + Description: + This method updates the status, result, and logs the successful provisioning for all devices(Wired/Wireless). + """ + + self.status = "success" + self.result['changed'] = True + self.log("All {0} Devices provisioned successfully!!".format(device_type)) + + def handle_all_failed_provision(self, device_type): + """ + Handle failure of provisioning for all devices(Wired/Wireless). + Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - device_type (str): The type or category of the devices(Wired/Wireless). + Return: + None + Description: + This method updates the status and logs a failure message indicating that + provisioning failed for all devices of a specific type. + """ + + self.status = "failed" + self.msg = "{0} Device Provisioning failed for all devices".format(device_type) + self.log(self.msg) + + def handle_partially_provisioned(self, provision_count, device_type): + """ + Handle partial success in provisioning for devices(Wired/Wireless). + Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - provision_count (int): The count of devices that were successfully provisioned. + - device_type (str): The type or category of the provisioned devices(Wired/Wireless). + Return: + None + Description: + This method updates the status, result, and logs a partial success message indicating that provisioning was successful + for a certain number of devices(Wired/Wireless). + """ + + self.status = "success" + self.result['changed'] = True + self.log("{0} Devices provisioned successfully partially for {1} devices".format(device_type, provision_count)) + def provisioned_wired_device(self): """ Provision wired devices in Cisco DNA Center. @@ -1075,9 +1449,24 @@ def provisioned_wired_device(self): """ site_name = self.config[0]['provision_wired_device']['site_name'] + device_in_dnac = self.device_exists_in_dnac() device_ips = self.config[0]['ip_address'] - provision_count, already_provision_count = 0, 0 - provision_wired_params = { + + for device_ip in device_ips: + if device_ip not in device_in_dnac: + device_ips.remove(device_ip) + + device_type = "Wired" + provision_count, already_provision_count = 0, 0 + + if not site_name and not device_ips: + self.status = "failed" + self.msg = "Site/Devices are required for Provisioning of Wired Devices." + self.log(self.msg) + self.result['response'] = self.msg + return self + + provision_wired_params = { 'siteNameHierarchy': site_name } @@ -1085,6 +1474,18 @@ def provisioned_wired_device(self): try: provision_wired_params['deviceManagementIpAddress'] = device_ip + # Check till device comes into managed state + while True: + response = self.get_device_response(device_ip) + self.log("Device is in {0} state waiting for Managed State.".format(response['managementState'])) + + if ( + response.get('managementState') == "Managed" + and response.get('collectionStatus') == "Managed" + and response.get("hostname") + ): + break + response = self.dnac._exec( family="sda", function='provision_wired_device', @@ -1106,48 +1507,213 @@ def provisioned_wired_device(self): self.log(progress) if 'TASK_PROVISION' in progress: - self.result['changed'] = True - self.result['response'] = execution_details + self.handle_successful_provisioning(device_ip, execution_details, device_type) provision_count += 1 break elif execution_details.get("isError"): - self.status = "failed" - failure_reason = execution_details.get("failureReason") - if failure_reason: - self.msg = "Device Provisioning get failed because of {0}".format(failure_reason) - else: - self.msg = "Device Provisioning get failed" + self.handle_failed_provisioning(device_ip, execution_details, device_type) break except Exception as e: # Not returning from here as there might be possiblity that for some devices it comes into exception # but for others it gets provision successfully or If some devices are already provsioned - error_message = "Error while Provisioning the device {0} in Cisco DNA Center - {1}".format(device_ip, str(e)) - self.log(error_message) - + self.handle_provisioning_exception(device_ip, e, device_type) if "already provisioned" in str(e): self.log(str(e)) already_provision_count += 1 # Check If all the devices are already provsioned, return from here only if already_provision_count == len(device_ips): - self.status = "success" - self.msg = "All the Wired Devices given in the playbook are already Provisioned" + self.handle_all_already_provisioned(device_ips, device_type) + elif provision_count == len(device_ips): + self.handle_all_provisioned(device_type) + elif provision_count == 0: + self.handle_all_failed_provision(device_type) + else: + self.handle_partially_provisioned(provision_count, device_type) + + return self + + def get_wireless_param(self, device_ip): + """ + Get wireless provisioning parameters for a device. + Parameters: + self (object): An instance of a class used for interacting with Cisco DNA Center. + device_ip (str): The IP address of the device for which to retrieve wireless provisioning parameters. + Returns: + wireless_param (list of dict): A list containing a dictionary with wireless provisioning parameters. + Description: + This function constructs a list containing a dictionary with wireless provisioning parameters based on the + configuration provided in the playbook. It validates the managed AP locations, ensuring they are of type "floor." + The function then queries Cisco DNA Center to get network device details using the provided device IP. + If the device is not found, the function returns the class instance with appropriate status and log messages and + returns the wireless provisioning parameters containing site information, managed AP + locations, dynamic interfaces, and device name. + """ + + wireless_config = self.config[0]['provision_wireless_device'][0] + wireless_param = [ + { + 'site': wireless_config['site_name'], + 'managedAPLocations': wireless_config['managed_ap_locations'], + } + ] + + for ap_loc in wireless_param[0]["managedAPLocations"]: + if self.get_site_type(site_name=ap_loc) != "floor": + self.status = "failed" + self.msg = "Managed AP Location must be a floor" + self.log(self.msg) + return self + + wireless_param[0]["dynamicInterfaces"] = [] + + for interface in wireless_config.get("dynamic_interfaces"): + interface_dict = { + "interfaceIPAddress": interface.get("interface_ip_address"), + "interfaceNetmaskInCIDR": interface.get("interface_netmask_in_cidr"), + "interfaceGateway": interface.get("interface_gateway"), + "lagOrPortNumber": interface.get("lag_or_port_number"), + "vlanId": interface.get("vlan_id"), + "interfaceName": interface.get("interface_name") + } + wireless_param[0]["dynamicInterfaces"].append(interface_dict) + + response = self.dnac_apply['exec']( + family="devices", + function='get_network_device_by_ip', + params={"ip_address": device_ip} + ) + if not response: + self.status = "failed" + self.msg = "Device Host name is not present in the Cisco DNA Center" self.log(self.msg) - self.result['response'] = self.msg - self.result['changed'] = False return self - if provision_count == len(device_ips): - self.status = "success" - msg = "Wired Device get provisioned Successfully !!" + response = response.get("response") + wireless_param[0]["deviceName"] = response.get("hostname") + self.wireless_param = wireless_param + self.status = "success" + self.log("Successfully collected all parameters required for Wireless Provisioing") + + return self + + def get_site_type(self, site_name): + """ + Get the type of a site in Cisco DNA Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco DNA Center. + site_name (str): The name of the site for which to retrieve the type. + Returns: + site_type (str or None): The type of the specified site, or None if the site is not found. + Description: + This function queries Cisco DNA Center to retrieve the type of a specified site. It uses the + get_site API with the provided site name, extracts the site type from the response, and returns it. + If the specified site is not found, the function returns None, and an appropriate log message is generated. + """ + + try: + site_type = None + response = self.dnac_apply['exec']( + family="sites", + function='get_site', + params={"name": site_name}, + ) + + if not response: + self.msg = "Site - {0} not found".format(site_name) + self.log(self.msg) + return site_type + + self.log(str(response)) + site = response.get("response") + site_additional_info = site[0].get("additionalInfo") + + for item in site_additional_info: + if item["nameSpace"] == "Location": + site_type = item.get("attributes").get("type") + + except Exception: + self.module.fail_json(msg="Site not found", response=[]) + + return site_type + + def provisioned_wireless_devices(self, device_ips): + """ + Provision Wireless devices in Cisco DNA Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco DNA Center. + device_ips (list): List of IP addresses of the devices to be provisioned. + Returns: + self (object): An instance of the class with updated result, status, and log. + Description: + This function performs wireless provisioning for the provided list of device IP addresses. + It iterates through each device, retrieves provisioning parameters using the get_wireless_param function, + and then calls the Cisco DNA Center API for wireless provisioning. If all devices are already provisioned, + it returns success with a relevant message. + """ + + provision_count, already_provision_count = 0, 0 + device_type = "Wireless" + + for device_ip in device_ips: + try: + # Collect the device parameters from the playbook to perform wireless provisioing + self.get_wireless_param(device_ip).check_return_status() + provisioning_params = self.wireless_param + + # Check till device comes into managed state + while True: + response = self.get_device_response(device_ip) + self.log("Device is in {0} state waiting for Managed State.".format(response['managementState'])) + if response['managementState'] == "Managed": + break + + # Now we have provisioning_param so we can do wireless provisioning + response = self.dnac_apply['exec']( + family="wireless", + function="provision", + op_modifies=True, + params=provisioning_params, + ) + + if response.get("status") == "failed": + description = response.get("description") + error_msg = "Cannot do Provisioning for Wireless device {0} beacuse of {1}".format(device_ip, description) + self.log(error_msg) + continue + + task_id = response.get("taskId") + + while True: + execution_details = self.get_task_details(task_id) + progress = execution_details.get("progress") + self.log(progress) + if 'TASK_PROVISION' in progress: + self.handle_successful_provisioning(device_ip, execution_details, device_type) + provision_count += 1 + break + elif execution_details.get("isError"): + self.handle_failed_provisioning(device_ip, execution_details, device_type) + break + + except Exception as e: + # Not returning from here as there might be possiblity that for some devices it comes into exception + # but for others it gets provision successfully or If some devices are already provsioned + self.handle_provisioning_exception(device_ip, e, device_type) + if "already provisioned" in str(e): + self.log(str(e)) + already_provision_count += 1 + + # Check If all the devices are already provsioned, return from here only + if already_provision_count == len(device_ips): + self.handle_all_already_provisioned(device_ips, device_type) + elif provision_count == len(device_ips): + self.handle_all_provisioned(device_type) elif provision_count == 0: - self.status = "failed" - msg = "Wired Device Provisioning get failed" + self.handle_all_failed_provision(device_type) else: - self.status = "success" - msg = "Wired Device get provisioned Successfully Partially for {0} devices!!".format(provision_count) - self.log(msg) + self.handle_partially_provisioned(provision_count, device_type) return self @@ -1194,8 +1760,8 @@ def mandatory_parameter(self): device_type = self.config[0].get("type", "NETWORK_DEVICE") params_dict = { - "NETWORK_DEVICE": ["enable_password", "ip_address", "password", "snmp_username", "snmp_auth_passphrase", "snmp_priv_passphrase", "username"], - "COMPUTE_DEVICE": ["ip_address", "http_username", "http_password", "http_port", "snmp_username", "snmp_auth_passphrase", "snmp_priv_passphrase"], + "NETWORK_DEVICE": ["enable_password", "ip_address", "password", "snmp_username", "username"], + "COMPUTE_DEVICE": ["ip_address", "http_username", "http_password", "http_port", "snmp_username"], "MERAKI_DASHBOARD": ["http_password"], "FIREPOWER_MANAGEMENT_SYSTEM": ["ip_address", "http_username", "http_password"], "THIRD_PARTY_DEVICE": ["ip_address", "snmp_username", "snmp_auth_passphrase", "snmp_priv_passphrase"] @@ -1245,7 +1811,7 @@ def get_have(self, config): if ip not in device_in_dnac: device_not_in_dnac.append(ip) - log("Device Exists in Cisco DNA Center : " + str(device_in_dnac)) + self.log("Device Exists in Cisco DNA Center : " + str(device_in_dnac)) have["want_device"] = want_device have["device_in_dnac"] = device_in_dnac have["device_not_in_dnac"] = device_not_in_dnac @@ -1288,7 +1854,6 @@ def get_device_params(self, params): "httpPort": params.get("http_port"), "httpSecure": params.get("http_secure"), "httpUserName": params.get("http_username"), - "merakiOrgId": params.get("meraki_org_id"), "netconfPort": params.get("netconf_port"), "serialNumber": params.get("serial_number"), "snmpVersion": params.get("snmp_version"), @@ -1299,13 +1864,13 @@ def get_device_params(self, params): } if device_param.get("updateMgmtIPaddressList"): - temp_dict = device_param.get("updateMgmtIPaddressList")[0] + device_mngmt_dict = device_param.get("updateMgmtIPaddressList")[0] device_param["updateMgmtIPaddressList"][0] = {} device_param["updateMgmtIPaddressList"][0].update( { - "existMgmtIpAddress": temp_dict.get("exist_mgmt_ipaddress"), - "newMgmtIpAddress": temp_dict.get("new_mgmt_ipaddress") + "existMgmtIpAddress": device_mngmt_dict.get("exist_mgmt_ipaddress"), + "newMgmtIpAddress": device_mngmt_dict.get("new_mgmt_ipaddress") }) return device_param @@ -1381,6 +1946,172 @@ def get_interface_from_ip(self, device_ip): log(error_message) raise Exception(error_message) + def get_device_response(self, device_ip): + """ + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + device_ip (str): The management IP address of the device for which the response is to be retrieved. + Returns: + dict: A dictionary containing details of the device obtained from the Cisco DNA Center. + Description: + This method communicates with Cisco DNA Center to retrieve the details of a device with the specified + management IP address. It executes the 'get_device_list' API call with the provided device IP address, + logs the response, and returns a dictionary containing information about the device. + """ + + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + response = response.get('response')[0] + + except Exception as e: + error_message = "Error while Getting the response of device from Cisco DNA Center - {0}".format(str(e)) + self.log(error_message) + raise Exception(error_message) + + return response + + def check_device_role(self, device_ip): + """ + Checks if the device role and role source for a device in Cisco DNA Center match the specified values in the configuration. + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + device_ip (str): The management IP address of the device for which the device role is to be checked. + Returns: + bool: True if the device role and role source match the specified values, False otherwise. + Description: + This method retrieves the device role and role source for a device in Cisco DNA Center using the + 'get_device_response' method and compares the retrieved values with specified values in the configuration + for updating device roles. + """ + + device_role_args = self.config[0].get('update_device_role') + role = device_role_args.get('role') + role_source = device_role_args.get('role_source') + response = self.get_device_response(device_ip) + + return response.get('role') == role and response.get('roleSource') == role_source + + def check_interface_details(self, device_ip): + """ + Checks if the interface details for a device in Cisco DNA Center match the specified values in the configuration. + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + device_ip (str): The management IP address of the device for which interface details are to be checked. + Returns: + bool: True if the interface details match the specified values, False otherwise. + Description: + This method retrieves the interface details for a device in Cisco DNA Center using the 'get_interface_by_ip' API call. + It then compares the retrieved details with the specified values in the configuration for updating interface details. + If all specified parameters match the retrieved values or are not provided in the playbook parameters, the function + returns True, indicating successful validation. + """ + + response = self.dnac._exec( + family="devices", + function='get_interface_by_ip', + params={"ip_address": device_ip} + ) + response = response.get("response")[0] + response_params = { + 'description': response.get('description'), + 'adminStatus': response.get('adminStatus'), + 'voiceVlanId': response.get('voiceVlan'), + 'vlanId': int(response.get('vlanId')) + } + + interface_playbook_params = self.config[0].get('update_interface_details') + playbook_params = { + 'description': interface_playbook_params.get('description', ''), + 'adminStatus': interface_playbook_params.get('admin_status'), + 'voiceVlanId': interface_playbook_params.get('voice_vlan_id', ''), + 'vlanId': interface_playbook_params.get('vlan_id') + } + + for key, value in playbook_params.items(): + if not value: + continue + elif response_params[key] != value: + return False + + return True + + def check_credential_update(self): + """ + Checks if the credentials for devices in the configuration match the updated values in Cisco DNA Center. + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + Returns: + bool: True if the credentials match the updated values, False otherwise. + Description: + This method triggers the export API in Cisco DNA Center to obtain the updated credential details for + the specified devices. It then decrypts and reads the CSV file containing the updated credentials, + comparing them with the credentials specified in the configuration. + """ + + device_ips = self.config[0].get("ip_address") + device_uuids = self.get_device_ids(device_ips) + password = "Testing@123" + payload_params = {"deviceUuids": device_uuids, "password": password, "operationEnum": "0"} + response = self.trigger_export_api(payload_params) + self.check_return_status() + csv_reader = self.decrypt_and_read_csv(response, password) + self.check_return_status() + device_data = next(csv_reader, None) + + if not device_data: + return False + + csv_data_dict = { + 'snmp_retry': device_data['snmp_retries'], + 'cli_transport': device_data['protocol'], + 'username': device_data['cli_username'], + 'password': device_data['cli_password'], + 'enable_password': device_data['cli_enable_password'], + 'snmp_username': device_data['snmpv3_user_name'], + 'snmp_auth_protocol': device_data['snmpv3_auth_type'] + } + + config = self.config[0] + for key in csv_data_dict: + if key in config and csv_data_dict[key] is not None: + if key == "snmp_retry" and int(csv_data_dict[key]) != int(config[key]): + return False + elif csv_data_dict[key] != config[key]: + return False + + return True + + def get_provision_wired_device(self, device_ip): + """ + Retrieves the provisioning status of a wired device with the specified management IP address in Cisco DNA Center. + Args: + self (object): An instance of a class used for interacting with Cisco DNA Center. + device_ip (str): The management IP address of the wired device for which provisioning status is to be retrieved. + Returns: + bool: True if the device is provisioned successfully, False otherwise. + Description: + This method communicates with Cisco DNA Center to check the provisioning status of a wired device. + It executes the 'get_provisioned_wired_device' API call with the provided device IP address and + logs the response. + """ + + response = self.dnac._exec( + family="sda", + function='get_provisioned_wired_device', + op_modifies=True, + params={"device_management_ip_address": device_ip} + ) + + if response.get("status") == "failed": + self.log("Cannot do provisioning for wired device {0} because of {1}.".format(device_ip, response.get('description'))) + return False + + return True + def get_want(self, config): """ Get all the device related information from playbook that is needed to be @@ -1425,6 +2156,7 @@ def get_diff_merged(self, config): device_added = self.config[0].get("device_added", False) device_updated = self.config[0].get("device_updated", False) device_reboot = self.config[0].get("reboot_device", False) + credential_update = self.config[0].get("credential_update", False) if self.config[0].get('add_user_defined_field'): field_name = self.config[0].get('add_user_defined_field').get('name') @@ -1479,72 +2211,53 @@ def get_diff_merged(self, config): self.log(msg) return self - if self.config[0].get('update_device_role'): - for device_ip in device_to_update: - device_id = self.get_device_ids([device_ip]) - device_role_args = self.config[0].get('update_device_role') - - if 'role' not in device_role_args or 'role_source' not in device_role_args: - self.msg = "Mandatory paramter(role/sourceRole) to update Device Role are missing" - self.status = "failed" - return self - - # Check if the same role of device is present in dnac then no need to change the state + if credential_update: + # Update Device details and credentails + try: + self.mandatory_parameter().check_return_status() response = self.dnac._exec( family="devices", - function='get_device_list', - params={"managementIpAddress": device_ip} + function='sync_devices', + op_modifies=True, + params=self.want.get("device_params"), ) - response = response.get('response')[0] - if response.get('role') == device_role_args.get('role'): - self.status = "success" - self.result['changed'] = False - log_msg = "Device Role - {0} same in Cisco DNA Center as well, no updation needed".format(device_role_args.get('role')) - continue + self.log(str(response)) - device_role_params = { - 'role': device_role_args.get('role'), - 'roleSource': device_role_args.get('role_source'), - 'id': device_id[0] - } + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') - try: - response = self.dnac._exec( - family="devices", - function='update_device_role', - op_modifies=True, - params=device_role_params, - ) - self.log(str(response)) + while True: + execution_details = self.get_task_details(task_id) - if response and isinstance(response, dict): - task_id = response.get('response').get('taskId') + if execution_details.get("endTime"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device Updation get failed because of {0}".format(failure_reason) + else: + self.msg = "Device Updation get failed" + self.log(self.msg) + break - while True: - execution_details = self.get_task_details(task_id) + self.log("Device Updated Successfully") + self.log("Updated devices are :" + str(device_to_update)) + self.msg = "Device " + str(device_to_update) + " updated Successfully !!" + self.log(self.msg) - if 'successfully' in execution_details.get("progress"): - self.status = "success" - self.result['changed'] = True - self.result['response'] = execution_details - log("Device Role Updated Successfully") - msg = "Device " + str(device_to_update) + " Role updated Successfully !!" - break - elif execution_details.get("isError"): - self.status = "failed" - failure_reason = execution_details.get("failureReason") - if failure_reason: - self.msg = "Device Role Updation get failed because of {0}".format(failure_reason) - else: - self.msg = "Device Role Updation get failed" - self.log(self.msg) - break + except Exception as e: + error_message = "Error while Updating device in Cisco DNA Center - {0}".format(str(e)) + self.log(error_message) + raise Exception(error_message) - except Exception as e: - error_message = "Error while Updating device role in Cisco DNA Center - {0}".format(str(e)) - self.log(error_message) - raise Exception(error_message) + self.msg = "Devices {0} present in Cisco DNA Center and updated successfully".format(config['ip_address']) + self.log(self.msg) + self.status = "success" if self.config[0].get('update_interface_details'): # Call the Get interface details by device IP API and fetch the interface Id @@ -1602,57 +2315,79 @@ def get_diff_merged(self, config): except Exception as e: error_message = "Error while Updating Interface Details in Cisco DNA Center - {0}".format(str(e)) + self.log(error_message) self.status = "success" self.result['changed'] = False self.msg = "Port actions are only supported on user facing/access ports as it's not allowed or No Updation required" - self.log(msg) + self.log(self.msg) - # Update Device details and credentails - try: - self.mandatory_parameter().check_return_status() - response = self.dnac._exec( - family="devices", - function='sync_devices', - op_modifies=True, - params=self.want.get("device_params"), - ) + if self.config[0].get('update_device_role'): + for device_ip in device_to_update: + device_id = self.get_device_ids([device_ip]) + device_role_args = self.config[0].get('update_device_role') - self.log(str(response)) + if 'role' not in device_role_args or 'role_source' not in device_role_args: + self.msg = "Mandatory paramter(role/sourceRole) to update Device Role are missing" + self.status = "failed" + return self - if response and isinstance(response, dict): - task_id = response.get('response').get('taskId') + # Check if the same role of device is present in dnac then no need to change the state + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + response = response.get('response')[0] - while True: - execution_details = self.get_task_details(task_id) + if response.get('role') == device_role_args.get('role'): + self.status = "success" + self.result['changed'] = False + log_msg = "Device Role - {0} same in Cisco DNA Center as well, no updation needed".format(device_role_args.get('role')) + self.log(log_msg) + continue - if execution_details.get("endTime"): - self.status = "success" - self.result['changed'] = True - self.result['response'] = execution_details - break - elif execution_details.get("isError"): - self.status = "failed" - failure_reason = execution_details.get("failureReason") - if failure_reason: - self.msg = "Device Updation get failed because of {0}".format(failure_reason) - else: - self.msg = "Device Updation get failed" - self.log(self.msg) - break + device_role_params = { + 'role': device_role_args.get('role'), + 'roleSource': device_role_args.get('role_source'), + 'id': device_id[0] + } - self.log("Device Updated Successfully") - self.log("Updated devices are :" + str(device_to_update)) - self.msg = "Device " + str(device_to_update) + " updated Successfully !!" - self.log(self.msg) + try: + response = self.dnac._exec( + family="devices", + function='update_device_role', + op_modifies=True, + params=device_role_params, + ) + self.log(str(response)) - except Exception as e: - error_message = "Error while Updating device in Cisco DNA Center - {0}".format(str(e)) - self.log(error_message) - raise Exception(error_message) + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') - self.msg = "Devices {0} present in Cisco DNA Center and updated successfully".format(config['ip_address']) - self.log(self.msg) - self.status = "success" + while True: + execution_details = self.get_task_details(task_id) + + if 'successfully' in execution_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.log("Device Role Updated Successfully") + msg = "Device " + str(device_to_update) + " Role updated Successfully !!" + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device Role Updation get failed because of {0}".format(failure_reason) + else: + self.msg = "Device Role Updation get failed" + self.log(self.msg) + break + + except Exception as e: + error_message = "Error while Updating device role in Cisco DNA Center - {0}".format(str(e)) + self.log(error_message) + raise Exception(error_message) # If we want to add device in inventory if device_added: @@ -1682,8 +2417,10 @@ def get_diff_merged(self, config): log("Device Added Successfully") log("Added devices are :" + str(devices_to_add)) msg = "Device " + str(devices_to_add) + " added Successfully !!" + self.result['msg'] = msg break msg = "Devices " + str(self.config[0].get("ip_address")) + " already present in Cisco DNA Center" + self.result['msg'] = msg break elif execution_details.get("isError"): self.status = "failed" @@ -1693,6 +2430,7 @@ def get_diff_merged(self, config): else: self.msg = "Device Addition get failed" self.log(self.msg) + self.result['msg'] = msg break except Exception as e: @@ -1700,10 +2438,49 @@ def get_diff_merged(self, config): self.log(error_message) raise Exception(error_message) - # Once device get added we will assign device to site and Provisioned it + if self.config[0].get('add_user_defined_field'): + field_name = self.config[0].get('add_user_defined_field').get('name') + + if field_name is None: + self.msg = "Mandatory paramter for User Define Field - name is missing" + self.status = "failed" + self.result['response'] = self.msg + return self + + # Check if the Global User defined field exist if not then create it with given field name + udf_exist = self.is_udf_exist(field_name) + + if not udf_exist: + # Create the Global UDF + self.create_user_defined_field().check_return_status() + + # Get device Id with its IP Address + device_ips = self.config[0].get("ip_address") + device_ids = self.get_device_ids(device_ips) + + if not device_ids: + self.msg = "Can't Assign Global User Defined Field to device as device's are not present in Cisco DNA Center" + self.status = "failed" + self.result['changed'] = False + self.result['response'] = self.msg + return self + + # Now add code for adding Global UDF to device with Id + self.add_field_to_devices(device_ids).check_return_status() + + self.result['changed'] = True + self.msg = "Global User Defined Added with name {0} added to device Successfully !".format(field_name) + self.log(self.msg) + + # Once Wired device get added we will assign device to site and Provisioned it if self.config[0].get('provision_wired_device'): self.provisioned_wired_device().check_return_status() + # Once Wireless device get added we will assign device to site and Provisioned it + if self.config[0].get('provision_wireless_device'): + device_ips = self.config[0]['ip_address'] + self.provisioned_wireless_devices(device_ips).check_return_status() + if device_resynced: self.resync_devices().check_return_status() @@ -1804,16 +2581,19 @@ def get_diff_deleted(self, config): function='delete_provisioned_wired_device', params=provision_params, ) - if response.get("status") == "success": - msg = "Wired device {0} unprovisioned successfully.".format(device_ip) - self.log(msg) - self.result['changed'] = True - self.status = "success" - else: - msg = response.get("description") - self.log(msg) - self.status = "failed" - + executionid = response.get("executionId") + while True: + execution_details = self.get_execution_details(executionid) + if execution_details.get("status") == "SUCCESS": + self.result['changed'] = True + self.msg = execution_details.get("bapiName") + self.log(self.msg) + self.result['response'] = self.msg + break + elif execution_details.get("bapiError"): + self.msg = execution_details.get("bapiError") + self.log(self.msg) + break except Exception as e: device_id = self.get_device_ids([device_ip]) delete_params = { @@ -1847,6 +2627,162 @@ def get_diff_deleted(self, config): self.msg = "Device Deletion get failed." self.log(self.msg) break + self.result['msg'] = self.msg + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Addition/Updation) of Devices in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the merged status of a configuration in Cisco DNA Center by retrieving the current state + (have) and desired state (want) of the configuration, logs the states, and validates whether the specified + site exists in the DNA Center configuration. + + The function performs the following verifications: + - Checks for devices added to Cisco DNA Center and logs the status. + - Verifies updated device roles and logs the status. + - Verifies updated interface details and logs the status. + - Verifies updated device credentials and logs the status. + - Verifies the creation of a global User Defined Field (UDF) and logs the status. + - Verifies the provisioning of wired devices and logs the status. + """ + + self.get_have(config) + self.log("Current config in Cisco DNA Center: {0}".format(str(self.have))) + self.log("Input paramter given in Playbook config: {0}".format(str(self.want))) + + devices_to_add = self.have["device_not_in_dnac"] + device_added = self.config[0].get("device_added", False) + device_updated = self.config[0].get("device_updated", False) + credential_update = self.config[0].get("credential_update", False) + device_type = self.config[0].get("type", "NETWORK_DEVICE") + device_ips = self.config[0].get("ip_address") + + if device_added: + if not devices_to_add: + self.status = "success" + msg = "Requested Devices - {0} Added in Cisco DNA Center and Addition verified.".format(str(device_ips)) + self.log(msg) + else: + self.log("Playbook parameter does not match with Cisco Catalyst Center, meaning device addition task not executed successfully.") + + if device_updated and self.config[0].get('update_interface_details'): + interface_update_flag = True + + for device_ip in device_ips: + if not self.check_interface_details(device_ip): + interface_update_flag = False + break + + if interface_update_flag: + self.status = "success" + msg = "Interface details updated and verified successfully for devices {0}.".format(device_ips) + self.log(msg) + else: + self.log("Playbook parameter does not match with Cisco Catalyst Center, meaning update interface details task not executed successfully.") + + if device_updated and credential_update and device_type == "NETWORK_DEVICE": + credential_update_flag = self.check_credential_update() + + if credential_update_flag: + self.status = "success" + msg = "Device credentials and details updated and verified successfully in Cisco Catalyst Center." + self.log(msg) + else: + self.log("Playbook parameter does not match with Cisco Catalyst Center, meaning device updation task not executed properly.") + elif device_type != "NETWORK_DEVICE": + self.log("Cannot compare the parameter for device type {0} in the playbook with Cisco Catalyst Center.".format(device_type)) + + if self.config[0].get('add_user_defined_field'): + field_name = self.config[0].get('add_user_defined_field').get('name') + udf_exist = self.is_udf_exist(field_name) + + if udf_exist: + self.status = "success" + msg = "Global UDF {0} created and verified successfully".format(field_name) + self.log(msg) + else: + self.log("Playbook paramater doesnot match with the Cisco DNA Center means creating Global UDF task not executed successfully.") + + if device_updated and self.config[0].get('update_device_role'): + device_role_flag = True + + for device_ip in device_ips: + if not self.check_device_role(device_ip): + device_role_flag = False + break + + if device_role_flag: + self.status = "success" + msg = "Device roles updated and verified successfully." + self.log(msg) + else: + self.log("Playbook parameter does not match with Cisco Catalyst Center, meaning update device role task not executed successfully.") + + if self.config[0].get('provision_wired_device'): + provision_wired_flag = True + + for device_ip in device_ips: + if not self.get_provision_wired_device(device_ip): + provision_wired_flag = False + break + + if provision_wired_flag: + self.status = "success" + msg = "Wired devices {0} get provisioned and verified successfully.".format(device_ips) + self.log(msg) + else: + self.log("Playbook parameter does not match with Cisco Catalyst Center, meaning provisioning task not executed successfully.") + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of Device and Global UDF in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the deletion status of a configuration in Cisco DNA Center. + It validates whether the specified Devices or Global UDF deleted from Cisco DNA Center. + """ + + self.get_have(config) + self.log(str(self.have)) + self.log(str(self.want)) + input_devices = self.have["want_device"] + device_in_dnac = self.device_exists_in_dnac() + + if self.config[0].get('add_user_defined_field'): + field_name = self.config[0].get('add_user_defined_field').get('name') + udf_id = self.get_udf_id(field_name) + + if udf_id is None: + self.status = "success" + msg = "Global UDF - {0} deleted from Cisco DNA Center and verified successfully.".format(field_name) + self.log(msg) + return self + + device_delete_flag = True + for device_ip in input_devices: + if device_ip in device_in_dnac: + device_delete_flag = False + break + + if device_delete_flag: + self.status = "success" + self.msg = "Requested Devices - {0} Deleted from Cisco DNA Center and Deletion verified.".format(str(input_devices)) + self.log(self.msg) + else: + self.log("Playbook paramater doesnot match with the Cisco DNA Center means Device Deletion task not executed successfully.") return self @@ -1864,6 +2800,7 @@ def main(): 'dnac_debug': {'type': 'bool', 'default': False}, 'dnac_log': {'type': 'bool', 'default': False}, 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} } @@ -1880,12 +2817,15 @@ def main(): dnac_device.check_return_status() dnac_device.validate_input().check_return_status() + config_verify = dnac_device.params.get("config_verify") for config in dnac_device.validated_config: dnac_device.reset_values() dnac_device.get_want(config).check_return_status() dnac_device.get_have(config).check_return_status() dnac_device.get_diff_state_apply[state](config).check_return_status() + if config_verify: + dnac_device.verify_diff_state_apply[state](config).check_return_status() module.exit_json(**dnac_device.result) diff --git a/plugins/modules/network_settings_intent.py b/plugins/modules/network_settings_intent.py index 6712fbc458..a88b8151c3 100644 --- a/plugins/modules/network_settings_intent.py +++ b/plugins/modules/network_settings_intent.py @@ -25,6 +25,10 @@ author: Muthu Rakesh (@MUTHU-RAKESH-27) Madhan Sankaranarayanan (@madhansansel) options: + config_verify: + description: Set to True to verify the Cisco DNA Center after applying the playbook config. + type: bool + default: False state: description: The state of Cisco DNA Center after module completion. type: str @@ -252,7 +256,7 @@ elements: str type: list type: dict - syslogServer: + syslog_server: description: Network V2's syslogServer. suboptions: configure_dnac_ip: @@ -355,7 +359,7 @@ snmp_server: configure_dnac_ip: True ip_addresses: list - syslogServer: + syslog_server: configure_dnac_ip: True ip_addresses: list site_name: string @@ -419,6 +423,9 @@ def __init__(self, module): {"reservePool": {"response": {}, "msg": {}}}, {"network": {"response": {}, "msg": {}}} ] + self.global_pool_obj_params = self.get_obj_params("GlobalPool") + self.reserve_pool_obj_params = self.get_obj_params("ReservePool") + self.network_obj_params = self.get_obj_params("Network") def validate_input(self): """ @@ -492,7 +499,7 @@ def validate_input(self): "primary_ip_address": {"type": 'string'}, "secondary_ip_address": {"type": 'string'} }, - "syslogServer": { + "syslog_server": { "type": 'dict', "ip_addresses": {"type": 'list'}, "configure_dnac_ip": {"type": 'bool'} @@ -537,6 +544,7 @@ def validate_input(self): } # Validate playbook params against the specification (temp_spec) + self.config = self.camel_to_snake_case(self.config) valid_temp, invalid_params = validate_list_of_dicts(self.config, temp_spec) if invalid_params: self.msg = "Invalid parameters in playbook: {0}".format("\n".join(invalid_params)) @@ -582,6 +590,51 @@ def requires_update(self, have, want, obj_params): requested_obj.get(ansible_param)) for (dnac_param, ansible_param) in obj_params) + def get_obj_params(self, get_object): + """ + Get the required comparison obj_params value + + Parameters: + get_object (str) - identifier for the required obj_params + + Returns: + obj_params (list) - obj_params value for comparison. + """ + + try: + if get_object == "GlobalPool": + obj_params = [ + ("settings", "settings"), + ] + elif get_object == "ReservePool": + obj_params = [ + ("name", "name"), + ("type", "type"), + ("ipv6AddressSpace", "ipv6AddressSpace"), + ("ipv4GlobalPool", "ipv4GlobalPool"), + ("ipv4Prefix", "ipv4Prefix"), + ("ipv4PrefixLength", "ipv4PrefixLength"), + ("ipv4GateWay", "ipv4GateWay"), + ("ipv4DhcpServers", "ipv4DhcpServers"), + ("ipv4DnsServers", "ipv4DnsServers"), + ("ipv6GateWay", "ipv6GateWay"), + ("ipv6DhcpServers", "ipv6DhcpServers"), + ("ipv6DnsServers", "ipv6DnsServers"), + ("ipv4TotalHost", "ipv4TotalHost"), + ("slaacSupport", "slaacSupport") + ] + elif get_object == "Network": + obj_params = [ + ("settings", "settings"), + ("site_name", "site_name") + ] + else: + raise ValueError("Unexpected value: {0}".format(get_object)) + except Exception as msg: + self.log("Error message:" + msg) + + return obj_params + def get_site_id(self, site_name): """ Get the site id from the site name. @@ -637,7 +690,7 @@ def get_global_pool_params(self, pool_info): "dnsServerIps": pool_info.get("dnsServerIps"), "ipPoolCidr": pool_info.get("ipPoolCidr"), "ipPoolName": pool_info.get("ipPoolName"), - "type": pool_info.get("type") + "type": pool_info.get("ipPoolType").capitalize() }] } } @@ -793,7 +846,7 @@ def get_network_params(self, site_id): } } network_settings = network_details.get("settings") - if dhcp_details.get("value") != []: + if dhcp_details and dhcp_details.get("value") != []: network_settings.update({"dhcpServer": dhcp_details.get("value")}) else: network_settings.update({"dhcpServer": [""]}) @@ -807,7 +860,7 @@ def get_network_params(self, site_id): } }) - if ntpserver_details.get("value") != []: + if ntpserver_details and ntpserver_details.get("value") != []: network_settings.update({"ntpServer": ntpserver_details.get("value")}) else: network_settings.update({"ntpServer": [""]}) @@ -1207,7 +1260,7 @@ def get_want_global_pool(self, global_ippool): # Copy existing Global Pool information if the desired configuration is not provided want_ippool.update({ "IpAddressSpace": have_ippool.get("IpAddressSpace"), - "type": have_ippool.get("ipPoolType"), + "type": have_ippool.get("type"), "ipPoolCidr": have_ippool.get("ipPoolCidr") }) want_ippool.update({}) @@ -1422,7 +1475,7 @@ def get_want_network(self, network_management_details): else: del want_network_settings["snmpServer"] - syslogServer = network_management_details.get("syslogServer") + syslogServer = network_management_details.get("syslog_server") if syslogServer is not None: if syslogServer.get("configure_dnac_ip") is not None: want_network_settings.get("syslogServer").update({ @@ -1635,11 +1688,8 @@ def update_global_pool(self, config): return # Pool exists, check update is required - obj_params = [ - ("settings", "settings"), - ] if not self.requires_update(self.have.get("globalPool").get("details"), - self.want.get("wantGlobal"), obj_params): + self.want.get("wantGlobal"), self.global_pool_obj_params): self.log("Global pool doesn't requires an update") result_global_pool.get("response").get(name).update({ "Cisco DNA Center params": @@ -1727,24 +1777,8 @@ def update_reserve_pool(self, config): return # Check update is required - obj_params = [ - ("name", "name"), - ("type", "type"), - ("ipv6AddressSpace", "ipv6AddressSpace"), - ("ipv4GlobalPool", "ipv4GlobalPool"), - ("ipv4Prefix", "ipv4Prefix"), - ("ipv4PrefixLength", "ipv4PrefixLength"), - ("ipv4GateWay", "ipv4GateWay"), - ("ipv4DhcpServers", "ipv4DhcpServers"), - ("ipv4DnsServers", "ipv4DnsServers"), - ("ipv6GateWay", "ipv6GateWay"), - ("ipv6DhcpServers", "ipv6DhcpServers"), - ("ipv6DnsServers", "ipv6DnsServers"), - ("ipv4TotalHost", "ipv4TotalHost"), - ("slaacSupport", "slaacSupport") - ] if not self.requires_update(self.have.get("reservePool").get("details"), - self.want.get("wantReserve"), obj_params): + self.want.get("wantReserve"), self.reserve_pool_obj_params): self.log("Reserved ip subpool doesn't require an update") result_reserve_pool.get("response").get(name) \ .update({"Cisco DNA Center params": self.have.get("reservePool").get("details")}) @@ -1786,14 +1820,10 @@ def update_network(self, config): site_name = config.get("network_management_details").get("site_name") result_network = self.result.get("response")[2].get("network") result_network.get("response").update({site_name: {}}) - obj_params = [ - ("settings", "settings"), - ("site_name", "site_name") - ] # Check update is required or not if not self.requires_update(self.have.get("network").get("net_details"), - self.want.get("wantNetwork"), obj_params): + self.want.get("wantNetwork"), self.network_obj_params): self.log("Network doesn't require an update") result_network.get("response").get(site_name).update({ @@ -1949,6 +1979,101 @@ def get_diff_deleted(self, config): return self + def verify_diff_merged(self, config): + """ + Validating the DNAC configuration with the playbook details + when state is merged (Create/Update). + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self + """ + + self.get_have(config) + self.log(str(self.have)) + self.log(str(self.want)) + if config.get("global_pool_details") is not None: + self.log(str(self.want.get("wantGlobal"))) + self.log(str(self.have.get("globalPool").get("details"))) + if self.requires_update(self.have.get("globalPool").get("details"), + self.want.get("wantGlobal"), self.global_pool_obj_params): + self.msg = "Global Pool Config is not applied to the DNAC" + self.status = "failed" + return self + + self.log("Successfully validated Global Pool") + self.result.get("response")[0].get("globalPool").update({"Validation": "Success"}) + + if config.get("reserve_pool_details") is not None: + if self.requires_update(self.have.get("reservePool").get("details"), + self.want.get("wantReserve"), self.reserve_pool_obj_params): + self.log(str(self.want.get("wantReserve"))) + self.log(str(self.have.get("reservePool").get("details"))) + self.msg = "Reserve Pool Config is not applied to the DNAC" + self.status = "failed" + return self + + self.log("Successfully validated the Reserve Pool") + self.result.get("response")[1].get("reservePool").update({"Validation": "Success"}) + + if config.get("network_management_details") is not None: + if self.requires_update(self.have.get("network").get("net_details"), + self.want.get("wantNetwork"), self.network_obj_params): + self.msg = "Network Functions Config is not applied to the DNAC" + self.status = "failed" + return self + + self.log("Successfully validated the Network Functions") + self.result.get("response")[2].get("network").update({"Validation": "Success"}) + + self.msg = "Successfully validated the Global Pool, Reserve Pool \ + and the Network Functions." + self.status = "success" + return self + + def verify_diff_deleted(self, config): + """ + Validating the DNAC configuration with the playbook details + when state is deleted (delete). + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self + """ + + self.get_have(config) + self.log("DNAC retrieved details: " + str(self.have)) + self.log("Playbook details: " + str(self.want)) + if config.get("global_pool_details") is not None: + global_pool_exists = self.have.get("globalPool").get("exists") + if global_pool_exists: + self.msg = "Global Pool Config is not applied to the DNAC" + self.status = "failed" + return self + + self.log("Successfully validated absence of Global Pool") + self.result.get("response")[0].get("globalPool").update({"Validation": "Success"}) + + if config.get("reserve_pool_details") is not None: + reserve_pool_exists = self.have.get("reservePool").get("exists") + if reserve_pool_exists: + self.msg = "Reserve Pool Config is not applied to the DNAC" + self.status = "failed" + return self + + self.log("Successfully validated the absence of Reserve Pool") + self.result.get("response")[1].get("reservePool").update({"Validation": "Success"}) + + self.msg = "Successfully validated the absence of Global Pool/Reserve Pool" + self.status = "success" + return self + def reset_values(self): """ Reset all neccessary attributes to default values @@ -1978,6 +2103,7 @@ def main(): "dnac_version": {"type": 'str', "default": '2.2.3.3'}, "dnac_debug": {"type": 'bool', "default": False}, "dnac_log": {"type": 'bool', "default": False}, + "config_verify": {"type": 'bool', "default": False}, "config": {"type": 'list', "required": True, "elements": 'dict'}, "state": {"default": 'merged', "choices": ['merged', 'deleted']}, "validate_response_schema": {"type": 'bool', "default": True}, @@ -1987,6 +2113,7 @@ def main(): module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) dnac_network = DnacNetwork(module) state = dnac_network.params.get("state") + config_verify = dnac_network.params.get("config_verify") if state not in dnac_network.supported_states: dnac_network.status = "invalid" dnac_network.msg = "State {0} is invalid".format(state) @@ -2000,6 +2127,8 @@ def main(): if state != "deleted": dnac_network.get_want(config).check_return_status() dnac_network.get_diff_state_apply[state](config).check_return_status() + if config_verify: + dnac_network.verify_diff_state_apply[state](config).check_return_status() module.exit_json(**dnac_network.result) diff --git a/plugins/modules/pnp_intent.py b/plugins/modules/pnp_intent.py index bc72f39484..95bedf6404 100644 --- a/plugins/modules/pnp_intent.py +++ b/plugins/modules/pnp_intent.py @@ -16,6 +16,7 @@ - Manage operations add device, claim device and unclaim device of Onboarding Configuration(PnP) resource - API to add device to pnp inventory and claim it to a site. - API to delete device from the pnp inventory. +- API to reset the device from errored state. version_added: '6.6.0' extends_documentation_fragment: - cisco.dnac.intent_params @@ -23,6 +24,10 @@ Rishita Chowdhary (@rishitachowdhary) Abinash Mishra (@abimishr) options: + config_verify: + description: Set to True to verify the Cisco DNA Center config after applying the playbook config. + type: bool + default: False state: description: The state of DNAC after module completion. type: str @@ -89,7 +94,8 @@ elements: dict suboptions: hostname: - description: Pnp Device's hostname. + description: Pnp Device's hostname that we want to keep post claiming. Hostname can only + be changed during claiming not bulk adding/ single adding type: str state: description: Pnp Device's onbording state (Unclaimed/Claimed/Provisioned). @@ -115,6 +121,7 @@ device_onboarding_pnp.DeviceOnboardingPnp.delete_device_by_id_from_pnp, device_onboarding_pnp.DeviceOnboardingPnp.get_device_count, device_onboarding_pnp.DeviceOnboardingPnp.get_device_by_id, + device_onboarding_pnp.DeviceOnboardingPnp.update_device, sites.Sites.get_site, software_image_management_swim.SoftwareImageManagementSwim.get_software_image_details, configuration_templates.ConfigurationTemplates.gets_the_templates_available @@ -125,6 +132,7 @@ post /dna/intent/api/v1/onboarding/pnp-device/{id} get /dna/intent/api/v1/onboarding/pnp-device/count get /dna/intent/api/v1/onboarding/pnp-device + put /onboarding/pnp-device/${id} get /dna/intent/api/v1/site get /dna/intent/api/v1/image/importation get /dna/intent/api/v1/template-programmer/template @@ -143,6 +151,7 @@ dnac_debug: "{{dnac_debug}}" dnac_log: True state: merged + config_verify: True config: - template_name: string image_name: string @@ -372,6 +381,7 @@ def get_pnp_params(self, params): and stores it for further processing and calling the parameters in other APIs. """ + params_list = params["device_info"] device_info_list = [] for param in params_list: @@ -471,6 +481,44 @@ def get_claim_params(self): return claim_params + def get_reset_params(self): + """ + Get the paramters needed for resetting the device in an errored state. + Parameters: + - self: The instance of the class containing the 'config' + attribute to be validated. + Returns: + The method returns an instance of the class with updated attributes: + - reset_params: A dictionary needed for calling the PUT call + for update device details API. + Example: + The stored dictionary can be used to call the API update device details + """ + + reset_params = { + "deviceResetList": [ + { + "configList": [ + { + "configId": self.have.get('template_id'), + "configParameters": [ + { + "key": "", + "value": "" + } + ] + } + ], + "deviceId": self.have.get('device_id'), + "licenseLevel": "", + "licenseType": "", + "topOfStackSerialNumber": "" + } + ] + } + + return reset_params + def get_have(self): """ Get the current image, template and site details from the DNAC. @@ -539,7 +587,7 @@ def get_have(self): # check if given site exits, if exists store current site info site_exists = False if not isinstance(self.want.get("site_name"), str) and \ - not self.want.get('pnp_params').get('deviceInfo'): + not self.want.get('pnp_params')[0].get('deviceInfo'): self.msg = "Name of the site must be a string" self.status = "failed" return self @@ -586,7 +634,7 @@ def get_have(self): return self else: - if not self.want.get('pnp_params').get('deviceInfo'): + if not self.want.get('pnp_params')[0].get('deviceInfo'): self.msg = "Either Site Name or Device details must be added" self.status = "failed" return self @@ -595,7 +643,6 @@ def get_have(self): parameters from dnac for comparison" self.status = "success" self.have = have - return self def get_want(self, config): @@ -648,6 +695,7 @@ def get_want(self, config): self.msg = "Successfully collected all parameters from playbook " + \ "for comparison" self.status = "success" + return self def get_diff_merged(self): @@ -672,7 +720,7 @@ class instance for further use. self.status = "failed" return self - if len(self.want.get("pnp_params")) >= 2: + if len(self.want.get("pnp_params")) > 1: devices_added = [] for device in self.want.get("pnp_params"): multi_device_response = self.dnac_apply['exec']( @@ -683,6 +731,7 @@ class instance for further use. if (multi_device_response and (len(multi_device_response) == 1)): devices_added.append(device) + if (len(self.want.get("pnp_params")) - len(devices_added)) == 0: self.result['response'] = [] self.result['msg'] = "Devices are already added" @@ -743,10 +792,12 @@ class instance for further use. self.result['response'] = dev_add_response self.result['diff'] = self.validated_config self.result['changed'] = True + else: self.msg = "Device Addition Failed" self.status = "failed" - return self + + return self else: self.log("Adding device to pnp database") @@ -774,52 +825,90 @@ class instance for further use. self.result['response'] = claim_response self.result['diff'] = self.validated_config self.result['changed'] = True + else: self.msg = "Device Claim Failed" self.status = "failed" - return self - else: - prov_dev_response = self.dnac_apply['exec']( - family="device_onboarding_pnp", - function='get_device_count', - op_modifies=True, - params=provisioned_count_params, - ) - plan_dev_response = self.dnac_apply['exec']( + return self + + prov_dev_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_count', + op_modifies=True, + params=provisioned_count_params, + ) + plan_dev_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_count', + op_modifies=True, + params=planned_count_params, + ) + dev_details_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="get_device_by_id", + params={"id": self.have["device_id"]} + ) + + pnp_state = dev_details_response.get("deviceInfo").get("state") + + if not self.want["site_name"]: + self.result['response'] = self.have.get("device_found") + self.result['msg'] = "Device is already added" + return self + + update_payload = {"deviceInfo": self.want.get('pnp_params')[0].get("deviceInfo")} + update_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="update_device", + params={"id": self.have["device_id"], + "payload": update_payload}, + op_modifies=True, + ) + self.log(str(update_response)) + + if pnp_state == "Error": + reset_paramters = self.get_reset_params() + reset_response = self.dnac_apply['exec']( family="device_onboarding_pnp", - function='get_device_count', + function="update_device", + params={"payload": reset_paramters}, op_modifies=True, - params=planned_count_params, ) + self.log(str(reset_response)) + self.result['msg'] = "Device reset done Successfully" + self.result['response'] = reset_response + self.result['diff'] = self.validated_config + self.result['changed'] = True - if not self.want["site_name"]: - self.result['response'] = self.have.get("device_found") - self.result['msg'] = "Device is already added" - else: - if ( - prov_dev_response.get("response") == 0 and - plan_dev_response.get("response") == 0 - ): - claim_params = self.get_claim_params() - self.log(str(claim_params)) - claim_response = self.dnac_apply['exec']( - family="device_onboarding_pnp", - function='claim_a_device_to_a_site', - op_modifies=True, - params=claim_params, - ) - self.log(str(claim_response)) - if claim_response.get("response") == "Device Claimed": - self.result['msg'] = "Only Device Claimed Successfully" - self.result['response'] = claim_response - self.result['diff'] = self.validated_config - self.result['changed'] = True - else: - self.result['response'] = self.have.get("device_found") - self.result['msg'] = "Device is already claimed" + if not ( + prov_dev_response.get("response") == 0 and + plan_dev_response.get("response") == 0 and + pnp_state == "Unclaimed" + ): + self.result['response'] = self.have.get("device_found") + self.result['msg'] = "Device is already claimed" + if update_response.get("deviceInfo"): + self.result['changed'] = True + return self - return self + claim_params = self.get_claim_params() + self.log(str(claim_params)) + + claim_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='claim_a_device_to_a_site', + op_modifies=True, + params=claim_params, + ) + self.log(str(claim_response)) + if claim_response.get("response") == "Device Claimed": + self.result['msg'] = "Only Device Claimed Successfully" + self.result['response'] = claim_response + self.result['diff'] = self.validated_config + self.result['changed'] = True + + return self def get_diff_deleted(self): """ @@ -876,6 +965,82 @@ def get_diff_deleted(self): return self + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of PnP configuration in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the merged status of a configuration in Cisco DNA Center by + retrieving the current state (have) and desired state (want) of the configuration, + logs the states, and validates whether the specified device(s) exists in the DNA + Center configuration's PnP Database. + """ + + self.log("Current State (have): {0}".format(self.have)) + self.log("Desired State (want): {0}".format(self.want)) + # Code to validate dnac config for merged state + for device in self.want.get("pnp_params"): + device_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": device["deviceInfo"]["serialNumber"]} + ) + if (device_response and (len(device_response) == 1)): + msg = ( + "Requested Device with Serial No. {0} is " + "present in Cisco DNA Center and" + " addition verified.".format(device["deviceInfo"]["serialNumber"])) + self.log(msg) + else: + msg = ( + "Requested Device with Serial No. {0} is " + "not present in Cisco DNA " + "Center".format(device["deviceInfo"]["serialNumber"])) + + self.status = "success" + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of PnP configuration in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the deletion status of a configuration in Cisco DNA Center. + It validates whether the specified device(s) exists in the DNA Center configuration's + PnP Database. + """ + + self.log("Current State (have): {0}".format(self.have)) + self.log("Desired State (want): {0}".format(self.want)) + # Code to validate dnac config for deleted state + for device in self.want.get("pnp_params"): + device_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": device["deviceInfo"]["serialNumber"]} + ) + if not (device_response and (len(device_response) == 1)): + msg = ( + "Requested Device with Serial No. {0} is " + "not present in the Cisco DNA" + "Center.".format(device["deviceInfo"]["serialNumber"])) + self.log(msg) + else: + msg = ( + "Requested Device with Serial No. {0} is " + "present in Cisco DNA Center".format(device["deviceInfo"]["serialNumber"])) + + self.status = "success" + return self + def main(): """ @@ -891,6 +1056,7 @@ def main(): 'dnac_debug': {'type': 'bool', 'default': False}, 'dnac_log': {'type': 'bool', 'default': False}, 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {"type": 'bool', "default": False}, 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} } @@ -906,12 +1072,15 @@ def main(): dnac_pnp.check_return_status() dnac_pnp.validate_input().check_return_status() + config_verify = dnac_pnp.params.get("config_verify") for config in dnac_pnp.validated_config: dnac_pnp.reset_values() dnac_pnp.get_want(config).check_return_status() dnac_pnp.get_have().check_return_status() dnac_pnp.get_diff_state_apply[state]().check_return_status() + if config_verify: + dnac_pnp.verify_diff_state_apply[state](config).check_return_status() module.exit_json(**dnac_pnp.result) diff --git a/plugins/modules/provision_intent.py b/plugins/modules/provision_intent.py index 690b1522e8..c055eb1c08 100644 --- a/plugins/modules/provision_intent.py +++ b/plugins/modules/provision_intent.py @@ -332,7 +332,6 @@ def get_site_type(self, site_name=None): return site_type def get_wired_params(self): - """ Prepares the payload for provisioning of the wired devices @@ -349,6 +348,7 @@ def get_wired_params(self): paramters and stores it for further processing and calling the parameters in other APIs. """ + wired_params = { "deviceManagementIpAddress": self.validated_config[0]["management_ip_address"], "siteNameHierarchy": self.validated_config[0].get("site_name") @@ -357,7 +357,6 @@ def get_wired_params(self): return wired_params def get_wireless_params(self): - """ Prepares the payload for provisioning of the wireless devices @@ -375,6 +374,7 @@ def get_wireless_params(self): paramters and stores it for further processing and calling the parameters in other APIs. """ + wireless_params = [ { "site": self.validated_config[0].get("site_name"), @@ -406,7 +406,6 @@ def get_wireless_params(self): return wireless_params def get_want(self): - """ Get all provision related informantion from the playbook Args: @@ -422,6 +421,7 @@ def get_want(self): It stores all the paramters passed from the playbook for further processing before calling the APIs """ + self.want = {} self.want["device_type"] = self.get_dev_type() if self.want["device_type"] == "wired": @@ -437,7 +437,6 @@ def get_want(self): return self def get_diff_merged(self): - """ Add to provision database Args: @@ -451,6 +450,7 @@ def get_diff_merged(self): Cisco DNA Center. The updated results and status are stored in the class instance for further use. """ + device_type = self.want.get("device_type") if device_type == "wired": try: @@ -470,7 +470,7 @@ class instance for further use. status = status_response.get("status") if status == "success": - response = response = self.dnac_apply['exec']( + response = self.dnac_apply['exec']( family="sda", function="re_provision_wired_device", op_modifies=True, @@ -495,6 +495,7 @@ class instance for further use. else: self.result['msg'] = "Passed device is neither wired nor wireless" self.result['response'] = self.want["prov_params"] + return self task_id = response.get("taskId") provision_info = self.get_task_status(task_id=task_id) @@ -506,7 +507,6 @@ class instance for further use. return self def get_diff_deleted(self): - """ Delete from provision database Args: @@ -519,7 +519,51 @@ def get_diff_deleted(self): raise Exception if any error occured. """ - pass + device_type = self.want.get("device_type") + + if device_type != "wired": + self.result['msg'] = "APIs are not supported for the device" + return self + + try: + status_response = self.dnac_apply['exec']( + family="sda", + function="get_provisioned_wired_device", + op_modifies=True, + params={ + "device_management_\ + ip_address": + self.validated_config[0]["management_ip_address"] + }, + ) + + except Exception: + status_response = {} + + status = status_response.get("status") + + if status != "success": + self.result['msg'] = "Passed IP address is not provisioned" + self.result['response'] = self.want["prov_params"] + return self + + response = self.dnac_apply['exec']( + family="sda", + function="delete_provisioned_wired_device", + op_modifies=True, + params={ + "device_management_\ + ip_address": + self.validated_config[0]["management_ip_address"] + }, + ) + + task_id = response.get("taskId") + deletion_info = self.get_task_status(task_id=task_id) + self.result["changed"] = True + self.result['msg'] = "Deletion done Successfully" + self.result['diff'] = self.validated_config + self.result['response'] = task_id return self diff --git a/plugins/modules/site_intent.py b/plugins/modules/site_intent.py index 83f412f8b2..a76dc45b55 100644 --- a/plugins/modules/site_intent.py +++ b/plugins/modules/site_intent.py @@ -25,6 +25,10 @@ Rishita Chowdhary (@rishitachowdhary) Abhishek Maheshwari (@abhishekmaheshwari) options: + config_verify: + description: Set to True to verify the Cisco DNA Center config after applying the playbook config. + type: bool + default: False state: description: The state of DNAC after module completion. type: str @@ -52,7 +56,7 @@ description: Name of the area (eg Area1). type: str parentName: - description: Parent name of the area to be created. + description: Complete Parent name of the Area to be created/deleted(eg Global/). type: str building: description: Building Details. @@ -71,7 +75,7 @@ description: Name of the building (eg building1). type: str parent_name: - description: Parent name of building to be created. + description: Complete Parent name of the Building to be created/deleted(eg Global/USA/San Francisco). type: str floor: description: Site Create's floor. @@ -303,7 +307,6 @@ validate_list_of_dicts, log, get_dict_result, - dnac_compare_equality, ) floor_plan = { @@ -406,6 +409,7 @@ def get_current_site(self, site): address=location.get("attributes").get("address"), latitude=location.get("attributes").get("latitude"), longitude=location.get("attributes").get("longitude"), + country=location.get("attributes").get("country"), ) ) @@ -541,9 +545,8 @@ def get_site_params(self, params): def get_site_name(self, site): """ Get and Return the site name. - Parameters: - self (object): An instance of a class used for interacting with Cisco DNA Center. + - self (object): An instance of a class used for interacting with Cisco DNA Center. - site (dict): A dictionary containing information about the site. Returns: - str: The constructed site name. @@ -561,10 +564,97 @@ def get_site_name(self, site): return site_name + def compare_float_values(self, ele1, ele2, precision=2): + """ + Compare two floating-point values with a specified precision. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - ele1 (float): The first floating-point value to be compared. + - ele2 (float): The second floating-point value to be compared. + - precision (int, optional): The number of decimal places to consider in the comparison, Defaults to 2. + Return: + bool: True if the rounded values are equal within the specified precision, False otherwise. + Description: + This method compares two floating-point values, ele1 and ele2, by rounding them + to the specified precision and checking if the rounded values are equal. It returns + True if the rounded values are equal within the specified precision, and False otherwise. + """ + + return round(float(ele1), precision) == round(float(ele2), precision) + + def is_area_updated(self, updated_site, requested_site): + """ + Check if the area site details have been updated. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - updated_site (dict): The site details after the update. + - requested_site (dict): The site details as requested for the update. + Return: + bool: True if the area details (name and parentName) have been updated, False otherwise. + Description: + This method compares the area details (name and parentName) of the updated site + with the requested site and returns True if they are equal, indicating that the area + details have been updated. Returns False if there is a mismatch in the area site details. + """ + + return ( + updated_site['name'] == requested_site['name'] and + updated_site['parentName'] == requested_site['parentName'] + ) + + def is_building_updated(self, updated_site, requested_site): + """ + Check if the building details in a site have been updated. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - updated_site (dict): The site details after the update. + - requested_site (dict): The site details as requested for the update. + Return: + bool: True if the building details have been updated, False otherwise. + Description: + This method compares the building details of the updated site with the requested site. + It checks if the name, parentName, latitude, longitude, and address (if provided) are + equal, indicating that the building details have been updated. Returns True if the + details match, and False otherwise. + """ + + return ( + updated_site['name'] == requested_site['name'] and + updated_site['parentName'] == requested_site['parentName'] and + self.compare_float_values(updated_site['latitude'], requested_site['latitude']) and + self.compare_float_values(updated_site['longitude'], requested_site['longitude']) and + (requested_site['address'] is None or updated_site['address'] == requested_site['address']) + ) + + def is_floor_updated(self, updated_site, requested_site): + """ + Check if the floor details in a site have been updated. + + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - updated_site (dict): The site details after the update. + - requested_site (dict): The site details as requested for the update. + Return: + bool: True if the floor details have been updated, False otherwise. + Description: + This method compares the floor details of the updated site with the requested site. + It checks if the name, rf_model, length, width, and height are equal, indicating + that the floor details have been updated. Returns True if the details match, and False otherwise. + """ + + keys_to_compare = ['length', 'width', 'height'] + if updated_site['name'] != requested_site['name'] or updated_site['rf_model'] != requested_site['rfModel']: + return False + + for key in keys_to_compare: + if not self.compare_float_values(updated_site[key], requested_site[key]): + return False + + return True + def site_requires_update(self): """ Check if the site requires updates. - Parameters: self (object): An instance of a class used for interacting with Cisco DNA Center. Returns: @@ -576,29 +666,19 @@ def site_requires_update(self): specified parameters, such as the site type and site details. """ - requested_site = self.want.get("site_params") - current_site = self.have.get("current_site") - - self.log("Current Site: " + str(current_site)) + type = self.have['current_site']['type'] + updated_site = self.have['current_site']['site'][type] + requested_site = self.want['site_params']['site'][type] + self.log("Current Site: " + str(updated_site)) self.log("Requested Site: " + str(requested_site)) - if requested_site.get('type') == "building": - requested_address = requested_site['site']['building']['address'] - current_address = current_site['site']['building']['address'] - - if requested_address is None or requested_address == current_address: - return False - - return True + if type == "building": + return not self.is_building_updated(updated_site, requested_site) - obj_params = [ - ("type", "type"), - ("site", "site") - ] + elif type == "floor": + return not self.is_floor_updated(updated_site, requested_site) - return any(not dnac_compare_equality(current_site.get(dnac_param), - requested_site.get(ansible_param)) - for (dnac_param, ansible_param) in obj_params) + return not self.is_area_updated(updated_site, requested_site) def get_have(self, config): """ @@ -610,9 +690,9 @@ def get_have(self, config): - self (object): An instance of a class used for interacting with Cisco DNA Center. Description: This method queries Cisco DNA Center to check if a specified site - exists. If the site exists, it retrieves details about the current - site, including the site ID and other relevant information. The - results are stored in the 'have' attribute for later reference. + exists. If the site exists, it retrieves details about the current + site, including the site ID and other relevant information. The + results are stored in the 'have' attribute for later reference. """ site_exists = False @@ -635,9 +715,7 @@ def get_have(self, config): def get_want(self, config): """ - Get all site-related information from the playbook needed for - creation in Cisco DNA Center. - + Get all site-related information from the playbook needed for creation/updation/deletion of site in Cisco DNA Center. Parameters: self (object): An instance of a class used for interacting with Cisco DNA Center. config (dict): A dictionary containing configuration information. @@ -664,11 +742,9 @@ def get_diff_merged(self, config): """ Update/Create site information in Cisco DNA Center with fields provided in the playbook. - Parameters: self (object): An instance of a class used for interacting with Cisco DNA Center. config (dict): A dictionary containing configuration information. - Returns: self (object): An instance of a class used for interacting with Cisco DNA Center. Description: @@ -700,8 +776,10 @@ def get_diff_merged(self, config): else: # Site does not neet update self.result['response'] = self.have.get("current_site") - self.result['msg'] = "Site - {0} does not need update".format(self.have.get("current_site")) - self.module.exit_json(**self.result) + self.msg = "Site - {0} does not need any update".format(self.have.get("current_site")) + self.log(self.msg) + self.result['msg'] = self.msg + return self else: # Creating New Site @@ -799,16 +877,10 @@ def delete_single_site(self, site_id, site_name): def get_diff_deleted(self, config): """ Call Cisco DNA Center API to delete sites with provided inputs. - Parameters: + - self (object): An instance of a class used for interacting with Cisco DNA Center. - config (dict): Dictionary containing information for site deletion. Returns: - If the deletion is successful, 'changed' is set to True, and the - 'response' includes execution details and the deleted site ID. If - an error occurs during the deletion, the method uses 'fail_json' to - raise an exception with the error message. If the site does not - exist, the method raises an exception with a message indicating that - the site was not found. - self: The result dictionary includes the following keys: - 'changed' (bool): Indicates whether changes were made during the deletion process. @@ -816,9 +888,8 @@ def get_diff_deleted(self, config): and the deleted site ID. - 'msg' (str): A message indicating the status of the deletion operation. Description: - This method initiates the deletion of a site by calling the - 'delete_site' function in the 'sites' family of the Cisco DNA - Center API. It uses the site ID obtained from the 'have' attribute. + This method initiates the deletion of a site by calling the 'delete_site' function in the 'sites' family + of the Cisco DNA Center API. It uses the site ID obtained from the 'have' attribute. """ site_exists = self.have.get("site_exists") @@ -860,6 +931,71 @@ def get_diff_deleted(self, config): return self + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of site configuration in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the merged status of a configuration in Cisco DNA Center by retrieving the current state + (have) and desired state (want) of the configuration, logs the states, and validates whether the specified + site exists in the DNA Center configuration. + """ + + self.get_have(config) + self.log(str(self.have)) + self.log(str(self.want)) + # Code to validate dnac config for merged state + site_exist = self.have.get("site_exists") + + if site_exist: + self.status = "success" + msg = "Requested Site - {0} present in Cisco DNA Center and creation verified.".format(self.want.get("site_name")) + self.log(msg) + + require_update = self.site_requires_update() + + if not require_update: + self.log("Site - {0} Updation Verified Successfully.".format(self.want.get("site_name"))) + self. status = "success" + return self + + self.log("Playbook paramater doesnot match with the Cisco DNA Center means Merged task not executed successfully.") + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of site configuration in Cisco DNA Center. + Args: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco DNA Center. + Description: + This method checks the deletion status of a configuration in Cisco DNA Center. + It validates whether the specified site exists in the DNA Center configuration. + """ + + self.get_have(config) + self.log(str(self.have)) + self.log(str(self.want)) + # Code to validate dnac config for delete state + site_exist = self.have.get("site_exists") + + if not site_exist: + self.status = "success" + msg = "Requested Site - {0} already deleted from Cisco DNA Center and verified successfully.".format(self.want.get("site_name")) + self.log(msg) + return self + + self.log("Playbook paramater doesnot match with the Cisco DNA Center means Deletion not executed successfully.") + + return self + def main(): """ main entry point for module execution @@ -874,6 +1010,7 @@ def main(): 'dnac_debug': {'type': 'bool', 'default': False}, 'dnac_log': {'type': 'bool', 'default': False}, 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} } @@ -890,12 +1027,15 @@ def main(): dnac_site.check_return_status() dnac_site.validate_input().check_return_status() + config_verify = dnac_site.params.get("config_verify") for config in dnac_site.validated_config: dnac_site.reset_values() dnac_site.get_want(config).check_return_status() dnac_site.get_have(config).check_return_status() dnac_site.get_diff_state_apply[state](config).check_return_status() + if config_verify: + dnac_site.verify_diff_state_apply[state](config).check_return_status() module.exit_json(**dnac_site.result) diff --git a/plugins/modules/swim_intent.py b/plugins/modules/swim_intent.py index b21bf2a1e4..0996440b70 100644 --- a/plugins/modules/swim_intent.py +++ b/plugins/modules/swim_intent.py @@ -234,10 +234,10 @@ config: - import_image_details: type: string - urlDetails: + url_details: payload: - source_url: string - is_third_party: bool + third_party: bool image_family: string vendor: string application_type: string @@ -260,6 +260,33 @@ device_serial_number: string image_name: string +- name: Import an image from local, tag it as golden. + cisco.dnac.swim_intent: + 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 + config: + - import_image_details: + type: string + local_image_details: + file_path: string + is_third_party: bool + third_party_vendor: string + third_party_image_family: string + third_party_application_type: string + tagging_details: + image_name: string + device_role: string + device_family_name: string + device_type: string + site_name: string + tagging: bool + - name: Tag the given image as golden and load it on device cisco.dnac.swim_intent: dnac_host: "{{dnac_host}}" @@ -348,7 +375,6 @@ from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( DnacBase, validate_list_of_dicts, - log, get_dict_result, ) from ansible.module_utils.basic import AnsibleModule @@ -434,7 +460,7 @@ def site_exists(self, site_name): self.module.fail_json(msg="Site not found") if response: - log(str(response)) + self.log(str(response)) site = response.get("response") site_id = site[0].get("id") @@ -464,14 +490,15 @@ def get_image_id(self, name): params={"image_name": name}, ) - log(str(image_response)) + self.log(str(image_response)) image_list = image_response.get("response") if (len(image_list) == 1): image_id = image_list[0].get("imageUuid") - log("Image Id: " + str(image_id)) + self.log("Image Id: " + str(image_id)) else: error_message = "Image {0} not found".format(name) + self.log(error_message) self.module.fail_json(msg="Image not found", response=image_response) return image_id @@ -498,7 +525,7 @@ def is_image_exist(self, name): function='get_software_image_details', params={"image_name": name}, ) - log(str(image_response)) + self.log(str(image_response)) image_list = image_response.get("response") if (len(image_list) == 1): image_exist = True @@ -524,12 +551,12 @@ def get_device_id(self, params): function='get_device_list', params=params, ) - log(str(response)) + self.log(str(response)) device_list = response.get("response") if (len(device_list) == 1): device_id = device_list[0].get("id") - log("Device Id: " + str(device_id)) + self.log("Device Id: " + str(device_id)) else: self.log("Device not found") @@ -600,14 +627,14 @@ def get_device_family_identifier(self, family_name): family="software_image_management_swim", function='get_device_family_identifiers', ) - log(str(response)) + self.log(str(response)) device_family_db = response.get("response") if device_family_db: device_family_details = get_dict_result(device_family_db, 'deviceFamily', family_name) if device_family_details: device_family_identifier = device_family_details.get("deviceFamilyIdentifier") have["device_family_identifier"] = device_family_identifier - log("Family device indentifier:" + str(device_family_identifier)) + self.log("Family device indentifier:" + str(device_family_identifier)) else: self.module.fail_json(msg="Family Device Name not found", response=[]) self.have.update(have) @@ -647,11 +674,11 @@ def get_have(self): (site_exists, site_id) = self.site_exists(site_name) if site_exists: have["site_id"] = site_id - log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) + self.log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) else: # For global site, use -1 as siteId have["site_id"] = "-1" - log("Site Name not given by user. Using global site.") + self.log("Site Name not given by user. Using global site.") self.have.update(have) # check if given device family name exists, store indentifier value @@ -661,6 +688,15 @@ def get_have(self): if self.want.get("distribution_details"): have = {} distribution_details = self.want.get("distribution_details") + site_name = distribution_details.get("site_name") + if site_name: + site_exists = False + (site_exists, site_id) = self.site_exists(site_name) + + if site_exists: + have["site_id"] = site_id + self.log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) + # check if image for distributon is available if distribution_details.get("image_name"): name = distribution_details.get("image_name").split("/")[-1] @@ -675,9 +711,9 @@ def get_have(self): device_params = dict( hostname=distribution_details.get("device_hostname"), - serial_number=distribution_details.get("device_serial_number"), - management_ip_address=distribution_details.get("device_ip_address"), - mac_address=distribution_details.get("device_mac_address"), + serialNumber=distribution_details.get("device_serial_number"), + managementIpAddress=distribution_details.get("device_ip_address"), + macAddress=distribution_details.get("device_mac_address"), ) device_id = self.get_device_id(device_params) if device_id is not None: @@ -705,13 +741,13 @@ def get_have(self): (site_exists, site_id) = self.site_exists(site_name) if site_exists: have["site_id"] = site_id - log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) + self.log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) device_params = dict( hostname=activation_details.get("device_hostname"), - serial_number=activation_details.get("device_serial_number"), - management_ip_address=activation_details.get("device_ip_address"), - mac_address=activation_details.get("device_mac_address"), + serialNumber=activation_details.get("device_serial_number"), + managementIpAddress=activation_details.get("device_ip_address"), + macAddress=activation_details.get("device_mac_address"), ) device_id = self.get_device_id(device_params) if device_id is not None: @@ -752,7 +788,7 @@ def get_want(self, config): want["activation_details"] = config.get("image_activation_details") self.want = want - log(str(self.want)) + self.log(str(self.want)) return self @@ -776,36 +812,53 @@ def get_diff_import(self): if import_type == "url": image_name = self.want.get("url_import_details").get("payload")[0].get("source_url") else: - image_name = self.want.get("local_import_details").get("filePath") + image_name = self.want.get("local_import_details").get("file_path") # Code to check if the image already exists in DNAC name = image_name.split('/')[-1] image_exist = self.is_image_exist(name) + import_key_mapping = { + 'source_url': 'sourceURL', + 'image_family': 'imageFamily', + 'application_type': 'applicationType', + 'third_party': 'thirdParty', + } + if image_exist: image_id = self.get_image_id(name) self.have["imported_image_id"] = image_id - log_msg = "Image {0} already exists in the Cisco DNA Center".format(name) - self.result['msg'] = log_msg - self.log(log_msg) + self.msg = "Image {0} already exists in the Cisco DNA Center".format(name) + self.result['msg'] = self.msg + self.log(self.msg) self.status = "success" self.result['changed'] = False return self if self.want.get("import_type") == "url": + import_payload_dict = {} + temp_payload = self.want.get("url_import_details").get("payload")[0] + keys_to_change = list(import_key_mapping.keys()) + + for key, val in temp_payload.items(): + if key in keys_to_change: + api_key_name = import_key_mapping[key] + import_payload_dict[api_key_name] = val + + import_image_payload = [import_payload_dict] import_params = dict( - payload=self.want.get("url_import_details").get("payload"), - schedule_at=self.want.get("url_import_details").get("schedule_at"), - schedule_desc=self.want.get("url_import_details").get("schedule_desc"), - schedule_origin=self.want.get("url_import_details").get("schedule_origin"), + payload=import_image_payload, + scheduleAt=self.want.get("url_import_details").get("schedule_at"), + scheduleDesc=self.want.get("url_import_details").get("schedule_desc"), + scheduleOrigin=self.want.get("url_import_details").get("schedule_origin"), ) import_function = 'import_software_image_via_url' else: import_params = dict( - is_third_party=self.want.get("local_import_details").get("is_third_party"), - third_party_vendor=self.want.get("local_import_details").get("third_party_vendor"), - third_party_image_family=self.want.get("local_import_details").get("third_party_image_family"), - third_party_application_type=self.want.get("local_import_details").get("third_party_application_type"), + isThirdParty=self.want.get("local_import_details").get("is_third_party"), + thirdPartyVendor=self.want.get("local_import_details").get("third_party_vendor"), + thirdPartyImageFamily=self.want.get("local_import_details").get("third_party_image_family"), + thirdPartyApplicationType=self.want.get("local_import_details").get("third_party_application_type"), file_path=self.want.get("local_import_details").get("file_path"), ) import_function = 'import_local_software_image' @@ -828,16 +881,16 @@ def get_diff_import(self): ("completed successfully" in task_details.get("progress").lower()): self.result['changed'] = True self.status = "success" - log_msg = "Swim Image {0} imported successfully".format(name) - self.result['msg'] = log_msg - self.log(log_msg) + self.msg = "Swim Image {0} imported successfully".format(name) + self.result['msg'] = self.msg + self.log(self.msg) break if task_details and task_details.get("isError"): if "already exists" in task_details.get("failureReason"): - log_msg = "SWIM Image {0} already exists in the Cisco DNA Center".format(name) - self.result['msg'] = log_msg - self.log(log_msg) + self.msg = "SWIM Image {0} already exists in the Cisco DNA Center".format(name) + self.result['msg'] = self.msg + self.log(self.msg) self.status = "success" self.result['changed'] = False break @@ -858,7 +911,7 @@ def get_diff_import(self): except Exception as e: self.log("Import Image details are not given in the playbook") - self.status = "success" + self.status = "failed" self.result['changed'] = False return self @@ -888,7 +941,7 @@ def get_diff_tagging(self): deviceFamilyIdentifier=self.have.get("device_family_identifier"), deviceRole=tagging_details.get("device_role") ) - log("Image params for tagging image as golden:" + str(image_params)) + self.log("Image params for tagging image as golden:" + str(image_params)) response = self.dnac._exec( family="software_image_management_swim", @@ -896,16 +949,16 @@ def get_diff_tagging(self): op_modifies=True, params=image_params ) - log(str(response)) + self.log(str(response)) else: image_params = dict( image_id=self.have.get("tagging_image_id"), site_id=self.have.get("site_id"), device_family_identifier=self.have.get("device_family_identifier"), - device_role=tagging_details.get("deviceRole") + device_role=tagging_details.get("device_role") ) - log("Image params for un-tagging image as golden:" + str(image_params)) + self.log("Image params for un-tagging image as golden:" + str(image_params)) response = self.dnac._exec( family="software_image_management_swim", @@ -913,7 +966,7 @@ def get_diff_tagging(self): op_modifies=True, params=image_params ) - log(str(response)) + self.log(str(response)) if response: task_details = {} @@ -978,13 +1031,13 @@ def get_diff_distribution(self): break if task_details.get("isError"): - error_msg = "Image with Id {0} Distribution Failed".format(image_id) self.status = "failed" + self.msg = "Image with Id {0} Distribution Failed".format(image_id) + self.log(self.msg) self.result['response'] = task_details - self.msg = error_msg - return self + break - self.result['response'] = task_details if task_details else response + self.result['response'] = task_details if task_details else response return self @@ -1004,7 +1057,7 @@ def get_diff_distribution(self): imageUuid=image_id )] ) - log("Distribution Params: " + str(distribution_params)) + self.log("Distribution Params: " + str(distribution_params)) response = self.dnac._exec( family="software_image_management_swim", function='trigger_software_image_distribution', @@ -1028,23 +1081,24 @@ def get_diff_distribution(self): if task_details.get("isError"): error_msg = "Image with Id {0} Distribution Failed".format(image_id) + self.log(error_msg) self.result['response'] = task_details break if device_distribution_count == 0: self.status = "failed" - msg = "Image with Id {0} Distribution Failed for all devices".format(image_id) + self.msg = "Image with Id {0} Distribution Failed for all devices".format(image_id) elif device_distribution_count == len(device_uuid_list): self.result['changed'] = True self.status = "success" - msg = "Image with Id {0} Distributed Successfully for all devices".format(image_id) + self.msg = "Image with Id {0} Distributed Successfully for all devices".format(image_id) else: self.result['changed'] = True self.status = "success" - msg = "Image with Id {0} Distributed and partially Successfull".format(image_id) + self.msg = "Image with Id {0} Distributed and partially Successfull".format(image_id) - self.result['msg'] = msg - self.log(msg) + self.result['msg'] = self.msg + self.log(self.msg) return self @@ -1136,7 +1190,7 @@ def get_diff_activation(self): schedule_validate=activation_details.get("scehdule_validate"), payload=payload ) - log("Activation Params: " + str(activation_params)) + self.log("Activation Params: " + str(activation_params)) response = self.dnac._exec( family="software_image_management_swim", diff --git a/plugins/modules/template_intent.py b/plugins/modules/template_intent.py index 8675417ea5..33448313a7 100644 --- a/plugins/modules/template_intent.py +++ b/plugins/modules/template_intent.py @@ -30,6 +30,10 @@ Akash Bhaskaran (@akabhask) Muthu Rakesh (@MUTHU-RAKESH-27) options: + config_verify: + description: Set to True to verify the Cisco DNA Center after applying the playbook config. + type: bool + default: False state: description: The state of DNAC after module completion. type: str @@ -1436,8 +1440,7 @@ def validate_input(self): } } # Validate template params - self.log(str(self.config)) - self.log(str(temp_spec)) + self.config = self.camel_to_snake_case(self.config) valid_temp, invalid_params = validate_list_of_dicts( self.config, temp_spec ) @@ -2143,7 +2146,7 @@ def create_project_or_template(self, is_create_project=False): if value is None: creation_id = task_details.get("data") else: - creation_id = value.get("data").get("templateId") + creation_id = value.get("templateId") if not creation_id: self.log("data is not found for taskid: {0}".format(task_id)) continue @@ -2292,19 +2295,18 @@ def get_export_template_values(self, export_values): function='get_projects_details' ) for values in export_values: - self.log(str(values.get("projectName"))) + self.log(str(values.get("project_name"))) template_details = template_details.get("response") self.log(str(template_details)) - self.log(str(values.get("projectName"))) all_template_details = get_dict_result(template_details, "name", - values.get("projectName")) + values.get("project_name")) self.log(str(all_template_details)) all_template_details = all_template_details.get("templates") self.log(str(all_template_details)) template_detail = get_dict_result(all_template_details, "name", - values.get("templateName")) + values.get("template_name")) self.log(str(template_detail)) if template_detail is None: self.msg = "Invalid project_name and template_name in export" @@ -2637,6 +2639,81 @@ def get_diff_deleted(self, config): self.status = "success" return self + def verify_diff_merged(self, config): + """ + Validating the DNAC configuration with the playbook details + when state is merged (Create/Update). + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self + """ + + if config.get("configuration_templates") is not None: + is_template_available = self.get_have_project(config) + self.log(str(is_template_available)) + if not is_template_available: + self.msg = "Configuration Template config is not applied to the DNAC." + self.status = "failed" + return self + + self.get_have_template(config, is_template_available) + self.log("DNAC retrieved details: " + str(self.have_template.get("template"))) + self.log("Playbook details: " + str(self.want.get("template_params"))) + template_params = ["language", "name", "projectName", "softwareType", + "softwareVariant", "templateContent"] + for item in template_params: + if self.have_template.get("template").get(item) != self.want.get("template_params").get(item): + self.msg = " Configuration Template config is not applied to the DNAC." + self.status = "failed" + return self + self.result.get("response").update({"Validation": "Success"}) + + self.msg = "Successfully validated the Configuration Templates." + self.status = "success" + return self + + def verify_diff_deleted(self, config): + """ + Validating the DNAC configuration with the playbook details + when state is deleted (delete). + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self + """ + + if config.get("configuration_templates") is not None: + self.log("DNAC retrieved details: " + str(self.have)) + self.log("Playbook details: " + str(self.want)) + template_list = self.dnac_apply['exec']( + family="configuration_templates", + function="gets_the_templates_available", + params={"projectNames": config.get("projectName")}, + ) + if template_list and isinstance(template_list, list): + templateName = config.get("configuration_templates").get("template_name") + template_info = get_dict_result(template_list, + "name", + templateName) + if template_info: + self.msg = "Configuration Template config is not applied to the DNAC." + self.status = "failed" + return self + + self.log("Successfully validated absence of Template in the DNAC.") + self.result.get("response").update({"Validation": "Success"}) + + self.msg = "Successfully validated the absence of Template in the DNAC." + self.status = "success" + return self + def reset_values(self): """ Reset all neccessary attributes to default values. @@ -2665,6 +2742,7 @@ def main(): 'dnac_debug': {'type': 'bool', 'default': False}, 'dnac_log': {'type': 'bool', 'default': False}, 'validate_response_schema': {'type': 'bool', 'default': True}, + "config_verify": {"type": 'bool', "default": False}, 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} } @@ -2673,6 +2751,7 @@ def main(): dnac_template = DnacTemplate(module) dnac_template.validate_input().check_return_status() state = dnac_template.params.get("state") + config_verify = dnac_template.params.get("config_verify") if state not in dnac_template.supported_states: dnac_template.status = "invalid" dnac_template.msg = "State {0} is invalid".format(state) @@ -2683,6 +2762,8 @@ def main(): dnac_template.get_have(config).check_return_status() dnac_template.get_want(config).check_return_status() dnac_template.get_diff_state_apply[state](config).check_return_status() + if config_verify: + dnac_template.verify_diff_state_apply[state](config).check_return_status() module.exit_json(**dnac_template.result)