diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 301299bea4..a68a792af6 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -764,9 +764,9 @@ releases: - 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: "2023-12-22" + release_date: "2024-01-20" changes: - release_summary: Changes in network settings, site, inventory and provisioning intent modules + 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 295bec57c7..1c3dd206ad 100644 --- a/playbooks/PnP.yml +++ b/playbooks/PnP.yml @@ -24,17 +24,18 @@ <<: *dnac_login dnac_log: True state: merged + config_verify: True config: - - site_name: Global/USA/San Francisco/BGL_18 - device_info: - - serial_number: CD2425L8M7 + - device_info: + - serial_number: QD2425L8M7 state: Unclaimed pid: c9300-24P is_sudi_required: False - - serial_number: FTC2320E0H9 + - serial_number: QTC2320E0H9 state: Unclaimed pid: c9300-24P + hostname: Test-123 - serial_number: ETC2320E0HB state: Unclaimed @@ -101,8 +102,9 @@ <<: *dnac_login dnac_log: True state: deleted + config_verify: True config: - device_info: - - serial_number: FTC2320E0HB #Will get deleted + - 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/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/template_pnp_intent.yml b/playbooks/template_pnp_intent.yml index c1e2b896ac..09ea6a7226 100644 --- a/playbooks/template_pnp_intent.yml +++ b/playbooks/template_pnp_intent.yml @@ -18,6 +18,7 @@ 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: 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 95fc1becda..c1c5431085 100644 --- a/plugins/modules/inventory_intent.py +++ b/plugins/modules/inventory_intent.py @@ -512,6 +512,7 @@ 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: @@ -1109,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. @@ -1127,14 +1160,21 @@ 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) resync_param_dict = { @@ -1193,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 @@ -1255,7 +1307,7 @@ 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 @@ -1397,7 +1449,13 @@ 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'] + + 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 @@ -1405,6 +1463,7 @@ def provisioned_wired_device(self): 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 = { @@ -1419,7 +1478,12 @@ def provisioned_wired_device(self): 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": + + if ( + response.get('managementState') == "Managed" + and response.get('collectionStatus') == "Managed" + and response.get("hostname") + ): break response = self.dnac._exec( @@ -2380,6 +2444,7 @@ def get_diff_merged(self, config): 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 @@ -2397,6 +2462,7 @@ def get_diff_merged(self, config): 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 @@ -2515,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 = { @@ -2689,7 +2758,8 @@ def verify_diff_deleted(self, config): self.get_have(config) self.log(str(self.have)) self.log(str(self.want)) - devices_not_in_dnac = self.have["device_not_in_dnac"] + 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') @@ -2701,13 +2771,18 @@ def verify_diff_deleted(self, config): self.log(msg) return self - if sorted(devices_not_in_dnac) == sorted(self.have["want_device"]): - self.status = "success" - msg = "Requested Devices - {0} Deleted from Cisco DNA Center and Deletion verified.".format(str(devices_not_in_dnac)) - 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 - self.log("Playbook paramater doesnot match with the Cisco DNA Center means Device Deletion task not executed successfully.") + 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 diff --git a/plugins/modules/pnp_intent.py b/plugins/modules/pnp_intent.py index 1305290ef8..95bedf6404 100644 --- a/plugins/modules/pnp_intent.py +++ b/plugins/modules/pnp_intent.py @@ -24,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 @@ -147,6 +151,7 @@ dnac_debug: "{{dnac_debug}}" dnac_log: True state: merged + config_verify: True config: - template_name: string image_name: string @@ -477,6 +482,18 @@ 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": [ @@ -775,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") @@ -806,10 +825,12 @@ 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 + + return self prov_dev_response = self.dnac_apply['exec']( family="device_onboarding_pnp", @@ -823,7 +844,6 @@ class instance for further use. op_modifies=True, params=planned_count_params, ) - dev_details_response = self.dnac_apply['exec']( family="device_onboarding_pnp", function="get_device_by_id", @@ -945,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(): """ @@ -960,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']} } @@ -975,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/site_intent.py b/plugins/modules/site_intent.py index ea8d7c5849..a76dc45b55 100644 --- a/plugins/modules/site_intent.py +++ b/plugins/modules/site_intent.py @@ -56,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. @@ -75,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. @@ -1010,7 +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_verify': {'type': 'bool', "default": False}, 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} } 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 ef221201e2..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 @@ -2635,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. @@ -2663,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']} } @@ -2671,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) @@ -2681,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)