diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-18 05:52:22 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-18 05:52:22 +0000 |
commit | 38b7c80217c4e72b1d8988eb1e60bb6e77334114 (patch) | |
tree | 356e9fd3762877d07cde52d21e77070aeff7e789 /ansible_collections/cisco/dnac/plugins | |
parent | Adding upstream version 7.7.0+dfsg. (diff) | |
download | ansible-38b7c80217c4e72b1d8988eb1e60bb6e77334114.tar.xz ansible-38b7c80217c4e72b1d8988eb1e60bb6e77334114.zip |
Adding upstream version 9.4.0+dfsg.upstream/9.4.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/cisco/dnac/plugins')
43 files changed, 35493 insertions, 2071 deletions
diff --git a/ansible_collections/cisco/dnac/plugins/action/cli_credential.py b/ansible_collections/cisco/dnac/plugins/action/cli_credential.py index d10c90049..2b42190fa 100644 --- a/ansible_collections/cisco/dnac/plugins/action/cli_credential.py +++ b/ansible_collections/cisco/dnac/plugins/action/cli_credential.py @@ -127,7 +127,7 @@ class CliCredential(object): id_exists = False name_exists = False o_id = self.new_object.get("id") - name = self.new_object.get("description") or self.new_object.get("username") + name = self.new_object.get("username") or self.new_object.get("description") if o_id: prev_obj = self.get_object_by_id(o_id) id_exists = prev_obj is not None and isinstance(prev_obj, dict) diff --git a/ansible_collections/cisco/dnac/plugins/action/http_read_credential.py b/ansible_collections/cisco/dnac/plugins/action/http_read_credential.py index 1d1ac0acd..c73050be8 100644 --- a/ansible_collections/cisco/dnac/plugins/action/http_read_credential.py +++ b/ansible_collections/cisco/dnac/plugins/action/http_read_credential.py @@ -130,7 +130,7 @@ class HttpReadCredential(object): id_exists = False name_exists = False o_id = self.new_object.get("id") - name = self.new_object.get("description") or self.new_object.get("username") + name = self.new_object.get("username") or self.new_object.get("description") if o_id: prev_obj = self.get_object_by_id(o_id) id_exists = prev_obj is not None and isinstance(prev_obj, dict) diff --git a/ansible_collections/cisco/dnac/plugins/action/http_write_credential.py b/ansible_collections/cisco/dnac/plugins/action/http_write_credential.py index 5a63c0d5e..7ae396a1b 100644 --- a/ansible_collections/cisco/dnac/plugins/action/http_write_credential.py +++ b/ansible_collections/cisco/dnac/plugins/action/http_write_credential.py @@ -130,7 +130,7 @@ class HttpWriteCredential(object): id_exists = False name_exists = False o_id = self.new_object.get("id") - name = self.new_object.get("description") or self.new_object.get("username") + name = self.new_object.get("username") or self.new_object.get("description") if o_id: prev_obj = self.get_object_by_id(o_id) id_exists = prev_obj is not None and isinstance(prev_obj, dict) diff --git a/ansible_collections/cisco/dnac/plugins/action/netconf_credential.py b/ansible_collections/cisco/dnac/plugins/action/netconf_credential.py index 08aa9d19c..5ea30373d 100644 --- a/ansible_collections/cisco/dnac/plugins/action/netconf_credential.py +++ b/ansible_collections/cisco/dnac/plugins/action/netconf_credential.py @@ -121,7 +121,7 @@ class NetconfCredential(object): id_exists = False name_exists = False o_id = self.new_object.get("id") - name = self.new_object.get("description") or self.new_object.get("username") + name = self.new_object.get("username") or self.new_object.get("description") if o_id: prev_obj = self.get_object_by_id(o_id) id_exists = prev_obj is not None and isinstance(prev_obj, dict) diff --git a/ansible_collections/cisco/dnac/plugins/action/pnp_device_claim_to_site.py b/ansible_collections/cisco/dnac/plugins/action/pnp_device_claim_to_site.py index 1ec73115c..6bceab4a1 100644 --- a/ansible_collections/cisco/dnac/plugins/action/pnp_device_claim_to_site.py +++ b/ansible_collections/cisco/dnac/plugins/action/pnp_device_claim_to_site.py @@ -29,7 +29,7 @@ argument_spec.update(dict( siteId=dict(type="str"), type=dict(type="str"), imageInfo=dict(type="dict"), - configInfo=dict(type="list"), + configInfo=dict(type="dict"), rfProfile=dict(type="str"), staticIP=dict(type="str"), subnetMask=dict(type="str"), diff --git a/ansible_collections/cisco/dnac/plugins/action/role_permissions_info.py b/ansible_collections/cisco/dnac/plugins/action/role_permissions_info.py index 39728ba46..a1b758c74 100644 --- a/ansible_collections/cisco/dnac/plugins/action/role_permissions_info.py +++ b/ansible_collections/cisco/dnac/plugins/action/role_permissions_info.py @@ -78,8 +78,8 @@ class ActionModule(ActionBase): dnac = DNACSDK(params=self._task.args) response = dnac.exec( - family="userand_roles", - function='get_permissions_ap_i', + family="user_and_roles", + function='get_permissions_api', params=self.get_object(self._task.args), ) self._result.update(dict(dnac_response=response)) diff --git a/ansible_collections/cisco/dnac/plugins/action/roles_info.py b/ansible_collections/cisco/dnac/plugins/action/roles_info.py index 380c9c687..4283de11b 100644 --- a/ansible_collections/cisco/dnac/plugins/action/roles_info.py +++ b/ansible_collections/cisco/dnac/plugins/action/roles_info.py @@ -78,8 +78,8 @@ class ActionModule(ActionBase): dnac = DNACSDK(params=self._task.args) response = dnac.exec( - family="userand_roles", - function='get_roles_ap_i', + family="user_and_roles", + function='get_roles_api', params=self.get_object(self._task.args), ) self._result.update(dict(dnac_response=response)) diff --git a/ansible_collections/cisco/dnac/plugins/action/snmpv2_read_community_credential.py b/ansible_collections/cisco/dnac/plugins/action/snmpv2_read_community_credential.py index 60edf5c83..cda2e0c06 100644 --- a/ansible_collections/cisco/dnac/plugins/action/snmpv2_read_community_credential.py +++ b/ansible_collections/cisco/dnac/plugins/action/snmpv2_read_community_credential.py @@ -115,7 +115,7 @@ class Snmpv2ReadCommunityCredential(object): id_exists = False name_exists = False o_id = self.new_object.get("id") - name = self.new_object.get("description") or self.new_object.get("username") + name = self.new_object.get("username") or self.new_object.get("description") if o_id: prev_obj = self.get_object_by_id(o_id) id_exists = prev_obj is not None and isinstance(prev_obj, dict) diff --git a/ansible_collections/cisco/dnac/plugins/action/snmpv2_write_community_credential.py b/ansible_collections/cisco/dnac/plugins/action/snmpv2_write_community_credential.py index 97982d2c7..2ff190d75 100644 --- a/ansible_collections/cisco/dnac/plugins/action/snmpv2_write_community_credential.py +++ b/ansible_collections/cisco/dnac/plugins/action/snmpv2_write_community_credential.py @@ -115,7 +115,7 @@ class Snmpv2WriteCommunityCredential(object): id_exists = False name_exists = False o_id = self.new_object.get("id") - name = self.new_object.get("description") or self.new_object.get("username") + name = self.new_object.get("username") or self.new_object.get("description") if o_id: prev_obj = self.get_object_by_id(o_id) id_exists = prev_obj is not None and isinstance(prev_obj, dict) diff --git a/ansible_collections/cisco/dnac/plugins/action/snmpv3_credential.py b/ansible_collections/cisco/dnac/plugins/action/snmpv3_credential.py index 480c82e66..313087715 100644 --- a/ansible_collections/cisco/dnac/plugins/action/snmpv3_credential.py +++ b/ansible_collections/cisco/dnac/plugins/action/snmpv3_credential.py @@ -137,7 +137,7 @@ class Snmpv3Credential(object): id_exists = False name_exists = False o_id = self.new_object.get("id") - name = self.new_object.get("description") or self.new_object.get("username") + name = self.new_object.get("username") or self.new_object.get("description") if o_id: prev_obj = self.get_object_by_id(o_id) id_exists = prev_obj is not None and isinstance(prev_obj, dict) @@ -167,6 +167,9 @@ class Snmpv3Credential(object): ("snmpMode", "snmpMode"), ("username", "username"), ] + + print("requested_obj: ", requested_obj) + print("current_obj: ", current_obj) # Method 1. Params present in request (Ansible) obj are the same as the current (ISE) params # If any does not have eq params, it requires update return any(not dnac_compare_equality(current_obj.get(dnac_param), diff --git a/ansible_collections/cisco/dnac/plugins/action/swim_import_local.py b/ansible_collections/cisco/dnac/plugins/action/swim_import_local.py index 57946e44e..b5e3b8a6c 100644 --- a/ansible_collections/cisco/dnac/plugins/action/swim_import_local.py +++ b/ansible_collections/cisco/dnac/plugins/action/swim_import_local.py @@ -20,6 +20,7 @@ from ansible_collections.cisco.dnac.plugins.plugin_utils.dnac import ( DNACSDK, dnac_argument_spec, ) +import os # Get common arguements specification argument_spec = dnac_argument_spec() @@ -41,7 +42,8 @@ required_together = [] class ActionModule(ActionBase): def __init__(self, *args, **kwargs): if not ANSIBLE_UTILS_IS_INSTALLED: - raise AnsibleActionFail("ansible.utils is not installed. Execute 'ansible-galaxy collection install ansible.utils'") + raise AnsibleActionFail( + "ansible.utils is not installed. Execute 'ansible-galaxy collection install ansible.utils'") super(ActionModule, self).__init__(*args, **kwargs) self._supports_async = False self._supports_check_mode = False @@ -70,8 +72,12 @@ class ActionModule(ActionBase): is_third_party=params.get("isThirdParty"), third_party_vendor=params.get("thirdPartyVendor"), third_party_image_family=params.get("thirdPartyImageFamily"), - third_party_application_type=params.get("thirdPartyApplicationType"), + third_party_application_type=params.get( + "thirdPartyApplicationType"), file_path=params.get("filePath"), + multipart_fields={'file': (os.path.basename(params.get( + "filePath")), open(params.get("filePath"), 'rb'))}, + multipart_monitor_callback=None ) return new_object diff --git a/ansible_collections/cisco/dnac/plugins/action/tag_member.py b/ansible_collections/cisco/dnac/plugins/action/tag_member.py index e92fdf505..54ae99e17 100644 --- a/ansible_collections/cisco/dnac/plugins/action/tag_member.py +++ b/ansible_collections/cisco/dnac/plugins/action/tag_member.py @@ -54,6 +54,7 @@ class TagMember(object): object=params.get("object"), id=params.get("id"), member_id=params.get("memberId"), + member_type=params.get("memberType"), ) def create_params(self): @@ -81,7 +82,8 @@ class TagMember(object): items = self.dnac.exec( family="tag", function="get_tag_members_by_id", - params={"id": id} + params={"id": id, "memberType": self.new_object.get( + 'member_type'), } ) if isinstance(items, dict): if 'response' in items: @@ -108,7 +110,8 @@ class TagMember(object): _id = prev_obj.get("id") _id = _id or prev_obj.get("memberId") if id_exists and name_exists and o_id != _id: - raise InconsistentParameters("The 'id' and 'name' params don't refer to the same object") + raise InconsistentParameters( + "The 'id' and 'name' params don't refer to the same object") if _id: self.new_object.update(dict(id=_id)) self.new_object.update(dict(member_id=_id)) @@ -164,7 +167,8 @@ class TagMember(object): class ActionModule(ActionBase): def __init__(self, *args, **kwargs): if not ANSIBLE_UTILS_IS_INSTALLED: - raise AnsibleActionFail("ansible.utils is not installed. Execute 'ansible-galaxy collection install ansible.utils'") + raise AnsibleActionFail( + "ansible.utils is not installed. Execute 'ansible-galaxy collection install ansible.utils'") super(ActionModule, self).__init__(*args, **kwargs) self._supports_async = False self._supports_check_mode = False diff --git a/ansible_collections/cisco/dnac/plugins/action/user.py b/ansible_collections/cisco/dnac/plugins/action/user.py index b9c8d5c4e..1896e57ff 100644 --- a/ansible_collections/cisco/dnac/plugins/action/user.py +++ b/ansible_collections/cisco/dnac/plugins/action/user.py @@ -91,8 +91,8 @@ class User(object): # NOTE: Does not have a get by name method, using get all try: items = self.dnac.exec( - family="userand_roles", - function="get_users_ap_i", + family="user_and_roles", + function="get_users_api", params=self.get_all_params(name=name), ) if isinstance(items, dict): @@ -148,8 +148,8 @@ class User(object): def create(self): result = self.dnac.exec( - family="userand_roles", - function="add_user_ap_i", + family="user_and_roles", + function="add_user_api", params=self.create_params(), op_modifies=True, ) @@ -160,8 +160,8 @@ class User(object): name = self.new_object.get("name") result = None result = self.dnac.exec( - family="userand_roles", - function="update_user_ap_i", + family="user_and_roles", + function="update_user_api", params=self.update_all_params(), op_modifies=True, ) diff --git a/ansible_collections/cisco/dnac/plugins/action/user_info.py b/ansible_collections/cisco/dnac/plugins/action/user_info.py index eb9ccc38e..7a5906fcc 100644 --- a/ansible_collections/cisco/dnac/plugins/action/user_info.py +++ b/ansible_collections/cisco/dnac/plugins/action/user_info.py @@ -80,8 +80,8 @@ class ActionModule(ActionBase): dnac = DNACSDK(params=self._task.args) response = dnac.exec( - family="userand_roles", - function='get_users_ap_i', + family="user_and_roles", + function='get_users_api', params=self.get_object(self._task.args), ) self._result.update(dict(dnac_response=response)) diff --git a/ansible_collections/cisco/dnac/plugins/action/users_external_servers_info.py b/ansible_collections/cisco/dnac/plugins/action/users_external_servers_info.py index 1436338cc..b29ce6bb2 100644 --- a/ansible_collections/cisco/dnac/plugins/action/users_external_servers_info.py +++ b/ansible_collections/cisco/dnac/plugins/action/users_external_servers_info.py @@ -80,8 +80,8 @@ class ActionModule(ActionBase): dnac = DNACSDK(params=self._task.args) response = dnac.exec( - family="userand_roles", - function='get_external_authentication_servers_ap_i', + family="user_and_roles", + function='get_external_authentication_servers_api', params=self.get_object(self._task.args), ) self._result.update(dict(dnac_response=response)) diff --git a/ansible_collections/cisco/dnac/plugins/doc_fragments/intent_params.py b/ansible_collections/cisco/dnac/plugins/doc_fragments/intent_params.py index fe95d684d..7e61fedc8 100644 --- a/ansible_collections/cisco/dnac/plugins/doc_fragments/intent_params.py +++ b/ansible_collections/cisco/dnac/plugins/doc_fragments/intent_params.py @@ -50,10 +50,60 @@ options: default: false dnac_log: description: - - Flag for logging playbook execution details. - If set to true the log file will be created at the location of the execution with the name dnac.log + - Flag to enable/disable playbook execution logging. + - When true and dnac_log_file_path is provided, + - Create the log file at the execution location with the specified name. + - When true and dnac_log_file_path is not provided, + - Create the log file at the execution location with the name 'dnac.log'. + - When false, + - Logging is disabled. + - If the log file doesn't exist, + - It is created in append or write mode based on the "dnac_log_append" flag. + - If the log file exists, + - It is overwritten or appended based on the "dnac_log_append" flag. type: bool default: false + dnac_log_level: + description: + - Sets the threshold for log level. Messages with a level equal to or higher than + this will be logged. Levels are listed in order of severity [CRITICAL, ERROR, WARNING, INFO, DEBUG]. + - CRITICAL indicates serious errors halting the program. Displays only CRITICAL messages. + - ERROR indicates problems preventing a function. Displays ERROR and CRITICAL messages. + - WARNING indicates potential future issues. Displays WARNING, ERROR, CRITICAL messages. + - INFO tracks normal operation. Displays INFO, WARNING, ERROR, CRITICAL messages. + - DEBUG provides detailed diagnostic info. Displays all log messages. + type: str + default: WARNING + dnac_log_file_path: + description: + - Governs logging. Logs are recorded if dnac_log is True. + - If path is not specified, + - When 'dnac_log_append' is True, 'dnac.log' is generated in the + current Ansible directory; logs are appended. + - When 'dnac_log_append' is False, 'dnac.log' is generated; logs + are overwritten. + - If path is specified, + - When 'dnac_log_append' is True, the file opens in append mode. + - When 'dnac_log_append' is False, the file opens in write (w) mode. + - In shared file scenarios, without append mode, content is + overwritten after each module execution. + - For a shared log file, set append to False for the 1st module + (to overwrite); for subsequent modules, set append to True. + type: str + default: dnac.log + dnac_log_append: + description: Determines the mode of the file. Set to True for 'append' mode. Set to False for 'write' mode. + type: bool + default: True + dnac_api_task_timeout: + description: Defines the timeout in seconds for API calls to retrieve task details. If the task details + are not received within this period, the process will end, and a timeout notification will be logged. + type: int + default: 1200 + dnac_task_poll_interval: + description: Specifies the interval in seconds between successive calls to the API to retrieve task details. + type: int + default: 2 validate_response_schema: description: - Flag for Cisco DNA Center SDK to enable the validation of request bodies against a JSON schema. diff --git a/ansible_collections/cisco/dnac/plugins/doc_fragments/workflow_manager_params.py b/ansible_collections/cisco/dnac/plugins/doc_fragments/workflow_manager_params.py new file mode 100644 index 000000000..8656bcb28 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/doc_fragments/workflow_manager_params.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type +__author__ = ['Madhan Sankaranarayanan, Muthu Rakesh'] + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +options: + dnac_host: + description: + - The hostname of the Cisco Catalyst Center. + type: str + required: true + dnac_port: + description: + - Specifies the port number associated with the Cisco Catalyst Center. + type: str + default: '443' + dnac_username: + description: + - The username for authentication at the Cisco Catalyst Center. + type: str + default: admin + aliases: [ user ] + dnac_password: + description: + - The password for authentication at the Cisco Catalyst Center. + type: str + dnac_verify: + description: + - Flag to enable or disable SSL certificate verification. + type: bool + default: true + dnac_version: + description: + - Specifies the version of the Cisco Catalyst Center that the SDK should use. + type: str + default: 2.2.3.3 + dnac_debug: + description: + - Indicates whether debugging is enabled in the Cisco Catalyst Center SDK. + type: bool + default: false + dnac_log: + description: + - Flag to enable/disable playbook execution logging. + - When true and dnac_log_file_path is provided, + - Create the log file at the execution location with the specified name. + - When true and dnac_log_file_path is not provided, + - Create the log file at the execution location with the name 'dnac.log'. + - When false, + - Logging is disabled. + - If the log file doesn't exist, + - It is created in append or write mode based on the "dnac_log_append" flag. + - If the log file exists, + - It is overwritten or appended based on the "dnac_log_append" flag. + type: bool + default: false + dnac_log_level: + description: + - Sets the threshold for log level. Messages with a level equal to or higher than + this will be logged. Levels are listed in order of severity [CRITICAL, ERROR, WARNING, INFO, DEBUG]. + - CRITICAL indicates serious errors halting the program. Displays only CRITICAL messages. + - ERROR indicates problems preventing a function. Displays ERROR and CRITICAL messages. + - WARNING indicates potential future issues. Displays WARNING, ERROR, CRITICAL messages. + - INFO tracks normal operation. Displays INFO, WARNING, ERROR, CRITICAL messages. + - DEBUG provides detailed diagnostic info. Displays all log messages. + type: str + default: WARNING + dnac_log_file_path: + description: + - Governs logging. Logs are recorded if dnac_log is True. + - If path is not specified, + - When 'dnac_log_append' is True, 'dnac.log' is generated in the + current Ansible directory; logs are appended. + - When 'dnac_log_append' is False, 'dnac.log' is generated; logs + are overwritten. + - If path is specified, + - When 'dnac_log_append' is True, the file opens in append mode. + - When 'dnac_log_append' is False, the file opens in write (w) mode. + - In shared file scenarios, without append mode, content is + overwritten after each module execution. + - For a shared log file, set append to False for the 1st module + (to overwrite); for subsequent modules, set append to True. + type: str + default: dnac.log + dnac_log_append: + description: Determines the mode of the file. Set to True for 'append' mode. Set to False for 'write' mode. + type: bool + default: True + validate_response_schema: + description: + - Flag for Cisco Catalyst Center SDK to enable the validation of request bodies against a JSON schema. + type: bool + default: true + dnac_api_task_timeout: + description: Defines the timeout in seconds for API calls to retrieve task details. If the task details + are not received within this period, the process will end, and a timeout notification will be logged. + type: int + default: 1200 + dnac_task_poll_interval: + description: Specifies the interval in seconds between successive calls to the API to retrieve task details. + type: int + default: 2 +notes: + - "Does not support C(check_mode)" + - "The plugin runs on the control node and does not use any ansible connection plugins instead embedded connection manager from Cisco Catalyst Center SDK" + - "The parameters starting with dnac_ are used by the Cisco Catalyst Center Python SDK to establish the connection" +''' diff --git a/ansible_collections/cisco/dnac/plugins/module_utils/dnac.py b/ansible_collections/cisco/dnac/plugins/module_utils/dnac.py index e9d61527d..a12e7eaf4 100644 --- a/ansible_collections/cisco/dnac/plugins/module_utils/dnac.py +++ b/ansible_collections/cisco/dnac/plugins/module_utils/dnac.py @@ -14,6 +14,7 @@ else: DNAC_SDK_IS_INSTALLED = True from ansible.module_utils._text import to_native from ansible.module_utils.common import validation +from abc import ABCMeta, abstractmethod try: import logging except ImportError: @@ -21,17 +22,468 @@ except ImportError: else: LOGGING_IN_STANDARD = True import os.path -import datetime +import copy +import json +# import datetime import inspect +import re + + +class DnacBase(): + + """Class contains members which can be reused for all intent modules""" + + __metaclass__ = ABCMeta + __is_log_init = False + + def __init__(self, module): + self.module = module + self.params = module.params + self.config = copy.deepcopy(module.params.get("config")) + self.have = {} + self.want = {} + self.validated_config = [] + self.msg = "" + self.status = "success" + dnac_params = self.get_dnac_params(self.params) + self.dnac = DNACSDK(params=dnac_params) + self.dnac_apply = {'exec': self.dnac._exec} + self.get_diff_state_apply = {'merged': self.get_diff_merged, + 'deleted': self.get_diff_deleted, + 'replaced': self.get_diff_replaced, + 'overridden': self.get_diff_overridden, + 'gathered': self.get_diff_gathered, + '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") + + if self.dnac_log and not DnacBase.__is_log_init: + self.dnac_log_level = dnac_params.get("dnac_log_level") or 'WARNING' + self.dnac_log_level = self.dnac_log_level.upper() + self.validate_dnac_log_level() + self.dnac_log_file_path = dnac_params.get("dnac_log_file_path") or 'dnac.log' + self.validate_dnac_log_file_path() + self.dnac_log_mode = 'w' if not dnac_params.get("dnac_log_append") else 'a' + self.setup_logger('logger') + self.logger = logging.getLogger('logger') + DnacBase.__is_log_init = True + self.log('Logging configured and initiated', "DEBUG") + elif not self.dnac_log: + # If dnac_log is False, return an empty logger + self.logger = logging.getLogger('empty_logger') + + self.log('Cisco Catalyst Center parameters: {0}'.format(dnac_params), "DEBUG") + self.supported_states = ["merged", "deleted", "replaced", "overridden", "gathered", "rendered", "parsed"] + self.result = {"changed": False, "diff": [], "response": [], "warnings": []} + + @abstractmethod + def validate_input(self): + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "failed" + return self + + def get_diff_merged(self): + # Implement logic to merge the resource configuration + self.merged = True + return self + + def get_diff_deleted(self): + # Implement logic to delete the resource + self.deleted = True + return self + + def get_diff_replaced(self): + # Implement logic to replace the resource + self.replaced = True + return self + + def get_diff_overridden(self): + # Implement logic to overwrite the resource + self.overridden = True + return self + + def get_diff_gathered(self): + # Implement logic to gather data about the resource + self.gathered = True + return self + + def get_diff_rendered(self): + # Implement logic to render a configuration template + self.rendered = True + return self + + def get_diff_parsed(self): + # Implement logic to parse a configuration file + self.parsed = 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 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 + + def setup_logger(self, logger_name): + """Set up a logger with specified name and configuration based on dnac_log_level""" + level_mapping = { + 'INFO': logging.INFO, + 'DEBUG': logging.DEBUG, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL + } + level = level_mapping.get(self.dnac_log_level, logging.WARNING) + + logger = logging.getLogger(logger_name) + # formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(module)s: %(funcName)s: %(lineno)d --- %(message)s', datefmt='%m-%d-%Y %H:%M:%S') + formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s', datefmt='%m-%d-%Y %H:%M:%S') + + file_handler = logging.FileHandler(self.dnac_log_file_path, mode=self.dnac_log_mode) + file_handler.setFormatter(formatter) + + logger.setLevel(level) + logger.addHandler(file_handler) + + def validate_dnac_log_level(self): + """Validates if the logging level is string and of expected value""" + if self.dnac_log_level not in ('INFO', 'DEBUG', 'WARNING', 'ERROR', 'CRITICAL'): + raise ValueError("Invalid log level: 'dnac_log_level:{0}'".format(self.dnac_log_level)) + + def validate_dnac_log_file_path(self): + """ + Validates the specified log file path, ensuring it is either absolute or relative, + the directory exists, and has a .log extension. + """ + # Convert the path to absolute if it's relative + dnac_log_file_path = os.path.abspath(self.dnac_log_file_path) + + # Validate if the directory exists + log_directory = os.path.dirname(dnac_log_file_path) + if not os.path.exists(log_directory): + raise FileNotFoundError("The directory for log file '{0}' does not exist.".format(dnac_log_file_path)) + + def log(self, message, level="WARNING", frameIncrement=0): + """Logs formatted messages with specified log level and incrementing the call stack frame + 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'. + """ + + if self.dnac_log: + # of.write("---- %s ---- %s@%s ---- %s \n" % (d, info.lineno, info.function, msg)) + # message = "Module: " + self.__class__.__name__ + ", " + message + class_name = self.__class__.__name__ + callerframerecord = inspect.stack()[1 + frameIncrement] + frame = callerframerecord[0] + info = inspect.getframeinfo(frame) + log_message = " %s: %s: %s: %s \n" % (class_name, info.function, info.lineno, message) + log_method = getattr(self.logger, level.lower()) + log_method(log_message) + + def check_return_status(self): + """API to check the return status value and exit/fail the module""" + + # self.log("status: {0}, msg:{1}".format(self.status, self.msg), frameIncrement=1) + self.log("status: {0}, msg: {1}".format(self.status, self.msg), "DEBUG") + if "failed" in self.status: + self.module.fail_json(msg=self.msg, response=[]) + elif "exited" in self.status: + self.module.exit_json(**self.result) + elif "invalid" in self.status: + self.module.fail_json(msg=self.msg, response=[]) + + def is_valid_password(self, password): + """ + Check if a password is valid. + Args: + self (object): An instance of a class that provides access to Cisco Catalyst Center. + password (str): The password to be validated. + Returns: + bool: True if the password is valid, False otherwise. + Description: + The function checks the validity of a password based on the following criteria: + - Minimum 8 characters. + - At least one lowercase letter. + - At least one uppercase letter. + - At least one digit. + - At least one special character + """ + + pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[-=\\;,./~!@#$%^&*()_+{}[\]|:?]).{8,}$" + + return re.match(pattern, password) is not None + + def get_dnac_params(self, params): + """Store the Cisco Catalyst Center parameters from the playbook""" + + dnac_params = {"dnac_host": params.get("dnac_host"), + "dnac_port": params.get("dnac_port"), + "dnac_username": params.get("dnac_username"), + "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_level": params.get("dnac_log_level"), + "dnac_log_file_path": params.get("dnac_log_file_path"), + "dnac_log_append": params.get("dnac_log_append") + } + return dnac_params + + def get_task_details(self, task_id): + """ + Get the details of a specific task in Cisco Catalyst Center. + Args: + self (object): An instance of a class that provides access to Cisco Catalyst Center. + task_id (str): The unique identifier of the task for which you want to retrieve details. + Returns: + dict or None: A dictionary containing detailed information about the specified task, + or None if the task with the given task_id is not found. + Description: + If the task with the specified task ID is not found in Cisco Catalyst Center, this function will return None. + """ + result = None + response = self.dnac._exec( + family="task", + function='get_task_by_id', + params={"task_id": task_id} + ) + + self.log('Task Details: {0}'.format(str(response)), 'DEBUG') + self.log("Retrieving task details by the API 'get_task_by_id' using task ID: {0}, Response: {1}".format(task_id, response), "DEBUG") + + if response and isinstance(response, dict): + result = response.get('response') + + return result + + def check_task_response_status(self, response, validation_string, data=False): + """ + Get the site id from the site name. + + Parameters: + self - The current object details. + response (dict) - API response. + validation_string (string) - String used to match the progress status. + + Returns: + self + """ + + if not response: + self.msg = "response is empty" + self.status = "exited" + return self + + if not isinstance(response, dict): + self.msg = "response is not a dictionary" + self.status = "exited" + return self + + response = response.get("response") + if response.get("errorcode") is not None: + self.msg = response.get("response").get("detail") + self.status = "failed" + return self + + task_id = response.get("taskId") + while True: + task_details = self.get_task_details(task_id) + self.log('Getting task details from task ID {0}: {1}'.format(task_id, task_details), "DEBUG") + + if task_details.get("isError") is True: + if task_details.get("failureReason"): + self.msg = str(task_details.get("failureReason")) + else: + self.msg = str(task_details.get("progress")) + self.status = "failed" + break + + if validation_string in task_details.get("progress").lower(): + self.result['changed'] = True + if data is True: + self.msg = task_details.get("data") + self.status = "success" + break + + self.log("progress set to {0} for taskid: {1}".format(task_details.get('progress'), task_id), "DEBUG") + + return self + + def reset_values(self): + """Reset all neccessary attributes to default values""" + + self.have.clear() + self.want.clear() + + def get_execution_details(self, execid): + """ + Get the execution details of an API + + Parameters: + execid (str) - Id for API execution + + Returns: + response (dict) - Status for API execution + """ + + self.log("Execution Id: {0}".format(execid), "DEBUG") + response = self.dnac._exec( + family="task", + function='get_business_api_execution_details', + params={"execution_id": execid} + ) + self.log("Response for the current execution: {0}".format(response)) + return response + + def check_execution_response_status(self, response): + """ + Checks the reponse status provided by API in the Cisco Catalyst Center + + Parameters: + response (dict) - API response + + Returns: + self + """ + + if not response: + self.msg = "response is empty" + self.status = "failed" + return self + + if not isinstance(response, dict): + self.msg = "response is not a dictionary" + self.status = "failed" + return self + + 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 = "Successfully executed" + self.status = "success" + break + + if execution_details.get("bapiError"): + self.msg = execution_details.get("bapiError") + self.status = "failed" + break + + return self -def log(msg): - with open('dnac.log', 'a') as of: - callerframerecord = inspect.stack()[1] - 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)) + def check_string_dictionary(self, task_details_data): + """ + Check whether the input is string dictionary or string. + + Parameters: + task_details_data (string) - Input either string dictionary or string. + + Returns: + value (dict) - If the input is string dictionary, else returns None. + """ + + try: + value = json.loads(task_details_data) + if isinstance(value, dict): + return value + except json.JSONDecodeError: + 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), "DEBUG") + 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 update_site_type_key(self, config): + """ + Replace 'site_type' key with 'type' in the config. + + Parameters: + config (list or dict) - Configuration details. + + Returns: + updated_config (list or dict) - Updated config after replacing the keys. + """ + + if isinstance(config, dict): + new_config = {} + for key, value in config.items(): + if key == "site_type": + new_key = "type" + else: + new_key = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', key).lower() + new_value = self.update_site_type_key(value) + new_config[new_key] = new_value + elif isinstance(config, list): + return [self.update_site_type_key(item) for item in config] + else: + return config + + return new_config def is_list_complex(x): @@ -127,6 +579,167 @@ def dnac_argument_spec(): return argument_spec +def validate_str(item, param_spec, param_name, invalid_params): + """ + This function checks that the input `item` is a valid string and confirms to + the constraints specified in `param_spec`. If the string is not valid or does + not meet the constraints, an error message is added to `invalid_params`. + + Args: + item (str): The input string to be validated. + param_spec (dict): The parameter's specification, including validation constraints. + param_name (str): The name of the parameter being validated. + invalid_params (list): A list to collect validation error messages. + + Returns: + str: The validated and possibly normalized string. + + Example `param_spec`: + { + "type": "str", + "length_max": 255 # Optional: maximum allowed length + } + """ + + item = validation.check_type_str(item) + if param_spec.get("length_max"): + if 1 <= len(item) <= param_spec.get("length_max"): + return item + else: + invalid_params.append( + "{0}:{1} : The string exceeds the allowed " + "range of max {2} char".format(param_name, item, param_spec.get("length_max")) + ) + return item + + +def validate_int(item, param_spec, param_name, invalid_params): + """ + This function checks that the input `item` is a valid integer and conforms to + the constraints specified in `param_spec`. If the integer is not valid or does + not meet the constraints, an error message is added to `invalid_params`. + + Args: + item (int): The input integer to be validated. + param_spec (dict): The parameter's specification, including validation constraints. + param_name (str): The name of the parameter being validated. + invalid_params (list): A list to collect validation error messages. + + Returns: + int: The validated integer. + + Example `param_spec`: + { + "type": "int", + "range_min": 1, # Optional: minimum allowed value + "range_max": 100 # Optional: maximum allowed value + } + """ + + item = validation.check_type_int(item) + min_value = 1 + if param_spec.get("range_min") is not None: + min_value = param_spec.get("range_min") + if param_spec.get("range_max"): + if min_value <= item <= param_spec.get("range_max"): + return item + else: + invalid_params.append( + "{0}:{1} : The item exceeds the allowed " + "range of max {2}".format(param_name, item, param_spec.get("range_max")) + ) + return item + + +def validate_bool(item, param_spec, param_name, invalid_params): + """ + This function checks that the input `item` is a valid boolean value. If it does + not represent a valid boolean value, an error message is added to `invalid_params`. + + Args: + item (bool): The input boolean value to be validated. + param_spec (dict): The parameter's specification, including validation constraints. + param_name (str): The name of the parameter being validated. + invalid_params (list): A list to collect validation error messages. + + Returns: + bool: The validated boolean value. + """ + + return validation.check_type_bool(item) + + +def validate_list(item, param_spec, param_name, invalid_params): + """ + This function checks if the input `item` is a valid list based on the specified `param_spec`. + It also verifies that the elements of the list match the expected data type specified in the + `param_spec`. If any validation errors occur, they are appended to the `invalid_params` list. + + Args: + item (list): The input list to be validated. + param_spec (dict): The parameter's specification, including validation constraints. + param_name (str): The name of the parameter being validated. + invalid_params (list): A list to collect validation error messages. + + Returns: + list: The validated list, potentially normalized based on the specification. + """ + + try: + if param_spec.get("type") == type(item).__name__: + keys_list = [] + for dict_key in param_spec: + keys_list.append(dict_key) + if len(keys_list) == 1: + return validation.check_type_list(item) + + temp_dict = {keys_list[1]: param_spec[keys_list[1]]} + try: + if param_spec['elements']: + get_spec_type = param_spec['type'] + get_spec_element = param_spec['elements'] + if type(item).__name__ == get_spec_type: + for element in item: + if type(element).__name__ != get_spec_element: + invalid_params.append( + "{0} is not of the same datatype as expected which is {1}".format(element, get_spec_element) + ) + else: + invalid_params.append( + "{0} is not of the same datatype as expected which is {1}".format(item, get_spec_type) + ) + except Exception as e: + item, list_invalid_params = validate_list_of_dicts(item, temp_dict) + invalid_params.extend(list_invalid_params) + else: + invalid_params.append("{0} : is not a valid list".format(item)) + except Exception as e: + invalid_params.append("{0} : comes into the exception".format(e)) + + return item + + +def validate_dict(item, param_spec, param_name, invalid_params): + """ + This function checks if the input `item` is a valid dictionary based on the specified `param_spec`. + If the dictionary does not match the expected data type specified in the `param_spec`, + a validation error is appended to the `invalid_params` list. + + Args: + item (dict): The input dictionary to be validated. + param_spec (dict): The parameter's specification, including validation constraints. + param_name (str): The name of the parameter being validated. + invalid_params (list): A list to collect validation error messages. + + Returns: + dict: The validated dictionary. + """ + + if param_spec.get("type") != type(item).__name__: + invalid_params.append("{0} : is not a valid dictionary".format(item)) + return validation.check_type_dict(item) + + def validate_list_of_dicts(param_list, spec, module=None): """Validate/Normalize playbook params. Will raise when invalid parameters found. param_list: a playbook parameter list of dicts @@ -135,14 +748,19 @@ def validate_list_of_dicts(param_list, spec, module=None): foo=dict(type='str', default='bar')) return: list of normalized input data """ + v = validation normalized = [] invalid_params = [] + for list_entry in param_list: valid_params_dict = {} + if not spec: + # Handle the case when spec becomes empty but param list is still there + invalid_params.append("No more spec to validate, but parameters remain") + break for param in spec: item = list_entry.get(param) - log(str(item)) if item is None: if spec[param].get("required"): invalid_params.append( @@ -150,58 +768,41 @@ def validate_list_of_dicts(param_list, spec, module=None): ) else: item = spec[param].get("default") + valid_params_dict[param] = item + continue + data_type = spec[param].get("type") + switch = { + "str": validate_str, + "int": validate_int, + "bool": validate_bool, + "list": validate_list, + "dict": validate_dict, + } + + validator = switch.get(data_type) + if validator: + item = validator(item, spec[param], param, invalid_params) else: - type = spec[param].get("type") - if type == "str": - item = v.check_type_str(item) - if spec[param].get("length_max"): - if 1 <= len(item) <= spec[param].get("length_max"): - pass - else: - invalid_params.append( - "{0}:{1} : The string exceeds the allowed " - "range of max {2} char".format( - param, item, spec[param].get("length_max") - ) - ) - elif type == "int": - item = v.check_type_int(item) - min_value = 1 - if spec[param].get("range_min") is not None: - min_value = spec[param].get("range_min") - if spec[param].get("range_max"): - if min_value <= item <= spec[param].get("range_max"): - pass - else: - invalid_params.append( - "{0}:{1} : The item exceeds the allowed " - "range of max {2}".format( - param, item, spec[param].get("range_max") - ) - ) - elif type == "bool": - item = v.check_type_bool(item) - elif type == "list": - item = v.check_type_list(item) - elif type == "dict": - item = v.check_type_dict(item) - - choice = spec[param].get("choices") - if choice: - if item not in choice: - invalid_params.append( - "{0} : Invalid choice provided".format(item) - ) + invalid_params.append( + "{0}:{1} : Unsupported data type {2}.".format(param, item, data_type) + ) - no_log = spec[param].get("no_log") - if no_log: - if module is not None: - module.no_log_values.add(item) - else: - msg = "\n\n'{0}' is a no_log parameter".format(param) - msg += "\nAnsible module object must be passed to this " - msg += "\nfunction to ensure it is not logged\n\n" - raise Exception(msg) + choice = spec[param].get("choices") + if choice: + if item not in choice: + invalid_params.append( + "{0} : Invalid choice provided".format(item) + ) + + no_log = spec[param].get("no_log") + if no_log: + if module is not None: + module.no_log_values.add(item) + else: + msg = "\n\n'{0}' is a no_log parameter".format(param) + msg += "\nAnsible module object must be passed to this " + msg += "\nfunction to ensure it is not logged\n\n" + raise Exception(msg) valid_params_dict[param] = item normalized.append(valid_params_dict) diff --git a/ansible_collections/cisco/dnac/plugins/modules/device_credential_intent.py b/ansible_collections/cisco/dnac/plugins/modules/device_credential_intent.py new file mode 100644 index 000000000..8e2f41384 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/device_credential_intent.py @@ -0,0 +1,2618 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2023, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module to perform operations on device credentials in Cisco DNA Center.""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ['Muthu Rakesh, Madhan Sankaranarayanan'] + +DOCUMENTATION = r""" +--- +module: device_credential_intent +short_description: Resource module for Global Device Credentials and Assigning Credentials to sites. +description: +- Manage operations on Global Device Credentials and Assigning Credentials to sites. +- API to create global device credentials. +- API to update global device credentials. +- API to delete global device credentials. +- API to assign the device credential to the site. +version_added: '6.7.0' +extends_documentation_fragment: + - cisco.dnac.intent_params +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 + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of global device credentials and site names. + type: list + elements: dict + required: true + suboptions: + global_credential_details: + description: Manages global device credentials + type: dict + suboptions: + cli_credential: + description: Global Credential V2's cliCredential. + type: list + elements: dict + suboptions: + description: + description: Description. Required for creating the credential. + type: str + enable_password: + description: + - cli_credential credential Enable Password. + - Password cannot contain spaces or angle brackets (< >) + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + password: + description: + - cli_credential credential Password. + - Required for creating/updating the credential. + - Password cannot contain spaces or angle brackets (< >). + type: str + username: + description: + - cli_credential credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description/Username. + type: str + old_username: + description: Old Username. Use this for updating the description/Username. + type: str + https_read: + description: Global Credential V2's httpsRead. + type: list + elements: dict + suboptions: + id: + description: Credential Id. Use this for updating the device credential. + type: str + name: + description: Name. Required for creating the credential. + type: str + password: + description: + - https_read credential Password. + - Required for creating/updating the credential. + - Password cannot contain spaces or angle brackets (< >). + type: str + port: + description: Port. Default port is 443. + type: int + username: + description: + - https_read credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description/Username. + type: str + old_username: + description: Old Username. Use this for updating the description/Username. + type: str + https_write: + description: Global Credential V2's httpsWrite. + type: list + elements: dict + suboptions: + id: + description: Credential Id. Use this for updating the device credential. + type: str + name: + description: Name. Required for creating the credential. + type: str + password: + description: + - https_write credential Password. + - Required for creating/updating the credential. + - Password cannot contain spaces or angle brackets (< >). + type: str + port: + description: Port. Default port is 443. + type: int + username: + description: + - https_write credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description/Username. + type: str + old_username: + description: Old Username. Use this for updating the description/Username. + type: str + snmp_v2c_read: + description: Global Credential V2's snmpV2cRead. + type: list + elements: dict + suboptions: + description: + description: Description. Required for creating the credential. + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + read_community: + description: + - snmp_v2c_read Read Community. + - Password cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description. + type: str + snmp_v2c_write: + description: Global Credential V2's snmpV2cWrite. + type: list + elements: dict + suboptions: + description: + description: Description. Required for creating the credential. + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + write_community: + description: + - snmp_v2c_write Write Community. + - Password cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description. + type: str + snmp_v3: + description: Global Credential V2's snmpV3. + type: list + elements: dict + suboptions: + auth_password: + description: + - snmp_v3 Auth Password. + - Password must contain minimum 8 characters. + - Password cannot contain spaces or angle brackets (< >). + type: str + auth_type: + description: Auth Type. ["SHA", "MD5"]. + type: str + description: + description: + - snmp_v3 Description. + - Should be unique from other snmp_v3 credentials. + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + privacy_password: + description: + - snmp_v3 Privacy Password. + - Password must contain minimum 8 characters. + - Password cannot contain spaces or angle brackets (< >). + type: str + privacy_type: + description: Privacy Type. ["AES128", "AES192", "AES256"]. + type: str + snmp_mode: + description: Snmp Mode. ["AUTHPRIV", "AUTHNOPRIV", "NOAUTHNOPRIV"]. + type: str + username: + description: + - snmp_v3 credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description. + type: str + assign_credentials_to_site: + description: Assign Device Credentials to Site. + type: dict + suboptions: + cli_credential: + description: CLI Credential. + type: dict + suboptions: + description: + description: CLI Credential Description. + type: str + username: + description: CLI Credential Username. + type: str + id: + description: CLI Credential Id. Use (Description, Username) or Id. + type: str + https_read: + description: HTTP(S) Read Credential + type: dict + suboptions: + description: + description: HTTP(S) Read Credential Description. + type: str + username: + description: HTTP(S) Read Credential Username. + type: str + id: + description: HTTP(S) Read Credential Id. Use (Description, Username) or Id. + type: str + https_write: + description: HTTP(S) Write Credential + type: dict + suboptions: + description: + description: HTTP(S) Write Credential Description. + type: str + username: + description: HTTP(S) Write Credential Username. + type: str + id: + description: HTTP(S) Write Credential Id. Use (Description, Username) or Id. + type: str + site_name: + description: Site Name to assign credential. + type: list + elements: str + snmp_v2c_read: + description: SNMPv2c Read Credential + type: dict + suboptions: + description: + description: SNMPv2c Read Credential Description. + type: str + id: + description: SNMPv2c Read Credential Id. Use Description or Id. + type: str + snmp_v2c_write: + description: SNMPv2c Write Credential + type: dict + suboptions: + description: + description: SNMPv2c Write Credential Description. + type: str + id: + description: SNMPv2c Write Credential Id. Use Description or Id. + type: str + snmp_v3: + description: snmp_v3 Credential + type: dict + suboptions: + description: + description: snmp_v3 Credential Description. + type: str + id: + description: snmp_v3 Credential Id. Use Description or Id. + type: str +requirements: +- dnacentersdk >= 2.5.5 +- python >= 3.5 +seealso: +- name: Cisco DNA Center documentation for Discovery CreateGlobalCredentialsV2 + description: Complete reference of the CreateGlobalCredentialsV2 API. + link: https://developer.cisco.com/docs/dna-center/#!create-global-credentials-v-2 +- name: Cisco DNA Center documentation for Discovery DeleteGlobalCredentialV2 + description: Complete reference of the DeleteGlobalCredentialV2 API. + link: https://developer.cisco.com/docs/dna-center/#!delete-global-credential-v-2 +- name: Cisco DNA Center documentation for Discovery UpdateGlobalCredentialsV2 + description: Complete reference of the UpdateGlobalCredentialsV2 API. + link: https://developer.cisco.com/docs/dna-center/#!update-global-credentials-v-2 +- name: Cisco DNA Center documentation for Network Settings AssignDeviceCredentialToSiteV2 + description: Complete reference of the AssignDeviceCredentialToSiteV2 API. + link: https://developer.cisco.com/docs/dna-center/#!assign-device-credential-to-site-v-2 +notes: + - SDK Method used are + discovery.Discovery.create_global_credentials_v2, + discovery.Discovery.delete_global_credential_v2, + discovery.Discovery.update_global_credentials_v2, + network_settings.NetworkSettings.assign_device_credential_to_site_v2, + + - Paths used are + post /dna/intent/api/v2/global-credential, + delete /dna/intent/api/v2/global-credential/{id}, + put /dna/intent/api/v2/global-credential, + post /dna/intent/api/v2/credential-to-site/{siteId}, +""" + +EXAMPLES = r""" +--- + - name: Create Credentials and assign it to a site. + cisco.dnac.device_credential_intent: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + snmp_v2c_read: + - description: string + read_community: string + snmp_v2c_write: + - description: string + write_community: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + https_read: + - description: string + username: string + password: string + port: 443 + https_write: + - description: string + username: string + password: string + port: 443 + assign_credentials_to_site: + cli_credential: + id: string + snmp_v2c_read: + id: string + snmp_v2c_write: + id: string + snmp_v3: + id: string + https_read: + id: string + https_write: + id: string + site_name: + - string + + - name: Create Multiple Credentials. + cisco.dnac.device_credential_intent: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + - description: string + username: string + password: string + enable_password: string + snmp_v2c_read: + - description: string + read_community: string + - description: string + read_community: string + snmp_v2c_write: + - description: string + write_community: string + - description: string + write_community: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + https_read: + - description: string + username: string + password: string + port: 443 + - description: string + username: string + password: string + port: 443 + https_write: + - description: string + username: string + password: string + port: 443 + - description: string + username: string + password: string + port: 443 + + - name: Update global device credentials using id + cisco.dnac.device_credential_intent: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + id: string + snmp_v2c_read: + - description: string + read_community: string + id: string + snmp_v2c_write: + - description: string + write_community: string + id: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + id: string + https_read: + - description: string + username: string + password: string + port: 443 + id: string + https_write: + - description: string + username: string + password: string + port: 443 + id: string + + - name: Update multiple global device credentials using id + cisco.dnac.device_credential_intent: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + id: string + - description: string + username: string + password: string + enable_password: string + id: string + snmp_v2c_read: + - description: string + read_community: string + id: string + - description: string + read_community: string + id: string + snmp_v2c_write: + - description: string + write_community: string + id: string + - description: string + write_community: string + id: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + id: string + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + id: string + https_read: + - description: string + username: string + password: string + port: 443 + id: string + - description: string + username: string + password: string + port: 443 + id: string + https_write: + - description: string + username: string + password: string + port: 443 + id: string + - description: string + username: string + password: string + port: 443 + id: string + + - name: Update global device credential name/description using old name and description. + cisco.dnac.device_credential_intent: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + old_description: string + old_username: string + snmp_v2c_read: + - description: string + read_community: string + old_description: string + snmp_v2c_write: + - description: string + write_community: string + old_description: string + snmp_v3: + - auth_password: string + auth_type: string + snmp_mode: string + privacy_password: string + privacy_type: string + username: string + description: string + https_read: + - description: string + username: string + password: string + port: string + old_description: string + old_username: string + https_write: + - description: string + username: string + password: string + port: string + old_description: string + old_username: string + + - name: Assign Credentials to sites using old description and username. + cisco.dnac.device_credential_intent: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - assign_credentials_to_site: + cli_credential: + description: string + username: string + snmp_v2c_read: + description: string + snmp_v2c_write: + description: string + snmp_v3: + description: string + https_read: + description: string + username: string + https_write: + description: string + username: string + site_name: + - string + - string + +""" + +RETURN = r""" +# Case_1: Successful creation/updation/deletion of global device credentials +dnac_response1: + description: A dictionary or list with the response returned by the Cisco DNAC Python SDK + returned: always + type: dict + sample: > + { + "response": { + "taskId": "string", + "url": "string" + }, + "version": "string" + } + +# Case_2: Successful assignment of global device credentials to a site. +dnac_response2: + description: A dictionary or list with the response returned by the Cisco DNAC Python SDK + returned: always + type: dict + sample: > + { + "response": { + "taskId": "string", + "url": "string" + }, + "version": "string" + } +""" + +import copy +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result, +) + + +class DnacCredential(DnacBase): + """Class containing member attributes for device credential intent module""" + + def __init__(self, module): + super().__init__(module) + self.result["response"] = [ + { + "globalCredential": {}, + "assignCredential": {} + } + ] + + def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', + 'self.msg' will describe the validation issues. + + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + # temp_spec is the specification for the expected structure of configuration parameters + temp_spec = { + "global_credential_details": { + "type": 'dict', + "cli_credential": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "password": {"type": 'string'}, + "enable_password": {"type": 'string'}, + "old_description": {"type": 'string'}, + "old_username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_read": { + "type": 'list', + "description": {"type": 'string'}, + "read_community": {"type": 'string'}, + "old_description": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_write": { + "type": 'list', + "description": {"type": 'string'}, + "write_community": {"type": 'string'}, + "old_description": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v3": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "snmp_mode": {"type": 'string'}, + "auth_type": {"type": 'string'}, + "auth_password": {"type": 'string'}, + "privacy_type": {"type": 'string'}, + "privacy_password": {"type": 'string'}, + "old_description": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "https_read": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "password": {"type": 'string'}, + "port": {"type": 'integer'}, + "old_description": {"type": 'string'}, + "old_username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "https_write": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "password": {"type": 'string'}, + "port": {"type": 'integer'}, + "old_description": {"type": 'string'}, + "old_username": {"type": 'string'}, + "id": {"type": 'string'}, + } + }, + "assign_credentials_to_site": { + "type": 'dict', + "cli_credential": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_read": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_write": { + "type": 'dict', + "description": {"type: 'string'"}, + "id": {"type": 'string'}, + }, + "snmp_v3": { + "type": 'dict', + "description": {"type: 'string'"}, + "id": {"type": 'string'}, + }, + "https_read": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "https_write": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "site_name": { + "type": 'list', + "elements": 'string' + } + } + } + + # 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)) + self.status = "failed" + return self + + self.validated_config = valid_temp + self.log("Successfully validated playbook config params: {0}".format(valid_temp), "INFO") + self.msg = "Successfully validated input from the playbook" + self.status = "success" + return self + + def get_site_id(self, site_name): + """ + Get the site id from the site name. + Use check_return_status() to check for failure + + Parameters: + site_name (str) - Site name + + Returns: + str or None - The Site Id if found, or None if not found or error + """ + + try: + response = self.dnac._exec( + family="sites", + function='get_site', + params={"name": site_name}, + ) + self.log("Received API response from 'get_site': {0}".format(response), "DEBUG") + if not response: + self.log("Failed to retrieve the site ID for the site name: {0}" + .format(site_name), "ERROR") + return None + + _id = response.get("response")[0].get("id") + self.log("Site ID for the site name {0}: {1}".format(site_name, _id), "INFO") + except Exception as exec: + self.log("Exception occurred while getting site_id from the site_name: {0}" + .format(exec), "CRITICAL") + return None + + return _id + + def get_global_credentials_params(self): + """ + Get the current Global Device Credentials from Cisco DNA Center. + + Parameters: + self - The current object details. + + Returns: + global_credentials (dict) - All global device credentials details. + """ + + try: + global_credentials = self.dnac._exec( + family="discovery", + function='get_all_global_credentials_v2', + ) + global_credentials = global_credentials.get("response") + self.log("All global device credentials details: {0}" + .format(global_credentials), "DEBUG") + except Exception as exec: + self.log("Exception occurred while getting global device credentials: {0}" + .format(exec), "CRITICAL") + return None + + return global_credentials + + def get_cli_params(self, cliDetails): + """ + Format the CLI parameters for the CLI credential configuration in Cisco DNA Center. + + Parameters: + cliDetails (list of dict) - Cisco DNA Center details containing CLI Credentials. + + Returns: + cliCredential (list of dict) - Processed CLI credential data + in the format suitable for the Cisco DNA Center config. + """ + + cliCredential = [] + for item in cliDetails: + if item is None: + cliCredential.append(None) + else: + value = { + "username": item.get("username"), + "description": item.get("description"), + "id": item.get("id") + } + cliCredential.append(value) + return cliCredential + + def get_snmpV2cRead_params(self, snmpV2cReadDetails): + """ + Format the snmpV2cRead parameters for the snmpV2cRead + credential configuration in Cisco DNA Center. + + Parameters: + snmpV2cReadDetails (list of dict) - Cisco DNA Center + Details containing snmpV2cRead Credentials. + + Returns: + snmpV2cRead (list of dict) - Processed snmpV2cRead credential + data in the format suitable for the Cisco DNA Center config. + """ + + snmpV2cRead = [] + for item in snmpV2cReadDetails: + if item is None: + snmpV2cRead.append(None) + else: + value = { + "description": item.get("description"), + "id": item.get("id") + } + snmpV2cRead.append(value) + return snmpV2cRead + + def get_snmpV2cWrite_params(self, snmpV2cWriteDetails): + """ + Format the snmpV2cWrite parameters for the snmpV2cWrite + credential configuration in Cisco DNA Center. + + Parameters: + snmpV2cWriteDetails (list of dict) - Cisco DNA Center + Details containing snmpV2cWrite Credentials. + + Returns: + snmpV2cWrite (list of dict) - Processed snmpV2cWrite credential + data in the format suitable for the Cisco DNA Center config. + """ + + snmpV2cWrite = [] + for item in snmpV2cWriteDetails: + if item is None: + snmpV2cWrite.append(None) + else: + value = { + "description": item.get("description"), + "id": item.get("id") + } + snmpV2cWrite.append(value) + return snmpV2cWrite + + def get_httpsRead_params(self, httpsReadDetails): + """ + Format the httpsRead parameters for the httpsRead + credential configuration in Cisco DNA Center. + + Parameters: + httpsReadDetails (list of dict) - Cisco DNA Center + Details containing httpsRead Credentials. + + Returns: + httpsRead (list of dict) - Processed httpsRead credential + data in the format suitable for the Cisco DNA Center config. + """ + + httpsRead = [] + for item in httpsReadDetails: + if item is None: + httpsRead.append(None) + else: + value = { + "description": item.get("description"), + "username": item.get("username"), + "port": item.get("port"), + "id": item.get("id") + } + httpsRead.append(value) + return httpsRead + + def get_httpsWrite_params(self, httpsWriteDetails): + """ + Format the httpsWrite parameters for the httpsWrite + credential configuration in Cisco DNA Center. + + Parameters: + httpsWriteDetails (list of dict) - Cisco DNA Center + Details containing httpsWrite Credentials. + + Returns: + httpsWrite (list of dict) - Processed httpsWrite credential + data in the format suitable for the Cisco DNA Center config. + """ + + httpsWrite = [] + for item in httpsWriteDetails: + if item is None: + httpsWrite.append(None) + else: + value = { + "description": item.get("description"), + "username": item.get("username"), + "port": item.get("port"), + "id": item.get("id") + } + httpsWrite.append(value) + return httpsWrite + + def get_snmpV3_params(self, snmpV3Details): + """ + Format the snmpV3 parameters for the snmpV3 credential configuration in Cisco DNA Center. + + Parameters: + snmpV3Details (list of dict) - Cisco DNA Center details containing snmpV3 Credentials. + + Returns: + snmpV3 (list of dict) - Processed snmpV3 credential + data in the format suitable for the Cisco DNA Center config. + """ + + snmpV3 = [] + for item in snmpV3Details: + if item is None: + snmpV3.append(None) + else: + value = { + "username": item.get("username"), + "description": item.get("description"), + "snmpMode": item.get("snmpMode"), + "id": item.get("id"), + } + if value.get("snmpMode") == "AUTHNOPRIV": + value["authType"] = item.get("authType") + elif value.get("snmpMode") == "AUTHPRIV": + value.update({ + "authType": item.get("authType"), + "privacyType": item.get("privacyType") + }) + snmpV3.append(value) + return snmpV3 + + def get_cli_credentials(self, CredentialDetails, global_credentials): + """ + Get the current CLI Credential from + Cisco DNA Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + cliDetails (List) - The current CLI credentials. + """ + + # playbook CLI Credential details + all_CLI = CredentialDetails.get("cli_credential") + # All CLI details from Cisco DNA Center + cli_details = global_credentials.get("cliCredential") + # Cisco DNA Center details for the CLI Credential given in the playbook + cliDetails = [] + if all_CLI and cli_details: + for cliCredential in all_CLI: + cliDetail = None + cliId = cliCredential.get("id") + if cliId: + cliDetail = get_dict_result(cli_details, "id", cliId) + if not cliDetail: + self.msg = "CLI credential ID is invalid" + self.status = "failed" + return self + + cliOldDescription = cliCredential.get("old_description") + cliOldUsername = cliCredential.get("old_username") + if cliOldDescription and cliOldUsername and (not cliDetail): + for item in cli_details: + if item.get("description") == cliOldDescription \ + and item.get("username") == cliOldUsername: + if cliDetail: + self.msg = "More than one CLI credential with same \ + old_description and old_username. Pass ID." + self.status = "failed" + return self + cliDetail = item + if not cliDetail: + self.msg = "CLI credential old_description or old_username is invalid" + self.status = "failed" + return self + + cliDescription = cliCredential.get("description") + cliUsername = cliCredential.get("username") + if cliDescription and cliUsername and (not cliDetail): + for item in cli_details: + if item.get("description") == cliDescription \ + and item.get("username") == cliUsername: + if cliDetail: + self.msg = "More than one CLI Credential with same \ + description and username. Pass ID." + self.status = "failed" + return self + cliDetail = item + cliDetails.append(cliDetail) + return cliDetails + + def get_snmpV2cRead_credentials(self, CredentialDetails, global_credentials): + """ + Get the current snmpV2cRead Credential from + Cisco DNA Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + snmpV2cReadDetails (List) - The current snmpV2cRead. + """ + + # Playbook snmpV2cRead Credential details + all_snmpV2cRead = CredentialDetails.get("snmp_v2c_read") + # All snmpV2cRead details from the Cisco DNA Center + snmpV2cRead_details = global_credentials.get("snmpV2cRead") + # Cisco DNA Center details for the snmpV2cRead Credential given in the playbook + snmpV2cReadDetails = [] + if all_snmpV2cRead and snmpV2cRead_details: + for snmpV2cReadCredential in all_snmpV2cRead: + snmpV2cReadDetail = None + snmpV2cReadId = snmpV2cReadCredential.get("id") + if snmpV2cReadId: + snmpV2cReadDetail = get_dict_result(snmpV2cRead_details, "id", snmpV2cReadId) + if not snmpV2cReadDetail: + self.msg = "snmpV2cRead credential ID is invalid" + self.status = "failed" + return self + + snmpV2cReadOldDescription = snmpV2cReadCredential.get("old_description") + if snmpV2cReadOldDescription and (not snmpV2cReadDetail): + snmpV2cReadDetail = get_dict_result( + snmpV2cRead_details, + "description", + snmpV2cReadOldDescription + ) + if not snmpV2cReadDetail: + self.msg = "snmpV2cRead credential old_description is invalid" + self.status = "failed" + return self + + snmpV2cReadDescription = snmpV2cReadCredential.get("description") + if snmpV2cReadDescription and (not snmpV2cReadDetail): + snmpV2cReadDetail = get_dict_result( + snmpV2cRead_details, + "description", + snmpV2cReadDescription + ) + snmpV2cReadDetails.append(snmpV2cReadDetail) + return snmpV2cReadDetails + + def get_snmpV2cWrite_credentials(self, CredentialDetails, global_credentials): + """ + Get the current snmpV2cWrite Credential from + Cisco DNA Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + snmpV2cWriteDetails (List) - The current snmpV2cWrite. + """ + + # Playbook snmpV2cWrite Credential details + all_snmpV2cWrite = CredentialDetails.get("snmp_v2c_write") + # All snmpV2cWrite details from the Cisco DNA Center + snmpV2cWrite_details = global_credentials.get("snmpV2cWrite") + # Cisco DNA Center details for the snmpV2cWrite Credential given in the playbook + snmpV2cWriteDetails = [] + if all_snmpV2cWrite and snmpV2cWrite_details: + for snmpV2cWriteCredential in all_snmpV2cWrite: + snmpV2cWriteDetail = None + snmpV2cWriteId = snmpV2cWriteCredential.get("id") + if snmpV2cWriteId: + snmpV2cWriteDetail = get_dict_result(snmpV2cWrite_details, "id", snmpV2cWriteId) + if not snmpV2cWriteDetail: + self.msg = "snmpV2cWrite credential ID is invalid" + self.status = "failed" + return self + + snmpV2cWriteOldDescription = snmpV2cWriteCredential.get("old_description") + if snmpV2cWriteOldDescription and (not snmpV2cWriteDetail): + snmpV2cWriteDetail = get_dict_result( + snmpV2cWrite_details, + "description", + snmpV2cWriteOldDescription + ) + if not snmpV2cWriteDetail: + self.msg = "snmpV2cWrite credential old_description is invalid " + self.status = "failed" + return self + + snmpV2cWriteDescription = snmpV2cWriteCredential.get("description") + if snmpV2cWriteDescription and (not snmpV2cWriteDetail): + snmpV2cWriteDetail = get_dict_result( + snmpV2cWrite_details, + "description", + snmpV2cWriteDescription + ) + snmpV2cWriteDetails.append(snmpV2cWriteDetail) + return snmpV2cWriteDetails + + def get_httpsRead_credentials(self, CredentialDetails, global_credentials): + """ + Get the current httpsRead Credential from + Cisco DNA Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + httpsReadDetails (List) - The current httpsRead. + """ + + # Playbook httpsRead Credential details + all_httpsRead = CredentialDetails.get("https_read") + # All httpsRead details from the Cisco DNA Center + httpsRead_details = global_credentials.get("httpsRead") + # Cisco DNA Center details for the httpsRead Credential given in the playbook + httpsReadDetails = [] + if all_httpsRead and httpsRead_details: + for httpsReadCredential in all_httpsRead: + httpsReadDetail = None + httpsReadId = httpsReadCredential.get("id") + if httpsReadId: + httpsReadDetail = get_dict_result(httpsRead_details, "id", httpsReadId) + if not httpsReadDetail: + self.msg = "httpsRead credential Id is invalid" + self.status = "failed" + return self + + httpsReadOldDescription = httpsReadCredential.get("old_description") + httpsReadOldUsername = httpsReadCredential.get("old_username") + if httpsReadOldDescription and httpsReadOldUsername and (not httpsReadDetail): + for item in httpsRead_details: + if item.get("description") == httpsReadOldDescription \ + and item.get("username") == httpsReadOldUsername: + if httpsReadDetail: + self.msg = "More than one httpsRead credential with same \ + old_description and old_username. Pass ID." + self.status = "failed" + return self + httpsReadDetail = item + if not httpsReadDetail: + self.msg = "httpsRead credential old_description or old_username is invalid" + self.status = "failed" + return self + + httpsReadDescription = httpsReadCredential.get("description") + httpsReadUsername = httpsReadCredential.get("username") + if httpsReadDescription and httpsReadUsername and (not httpsReadDetail): + for item in httpsRead_details: + if item.get("description") == httpsReadDescription \ + and item.get("username") == httpsReadUsername: + if httpsReadDetail: + self.msg = "More than one httpsRead credential with same \ + description and username. Pass ID." + self.status = "failed" + return self + httpsReadDetail = item + httpsReadDetails.append(httpsReadDetail) + return httpsReadDetails + + def get_httpsWrite_credentials(self, CredentialDetails, global_credentials): + """ + Get the current httpsWrite Credential from + Cisco DNA Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + httpsWriteDetails (List) - The current httpsWrite. + """ + + # Playbook httpsWrite Credential details + all_httpsWrite = CredentialDetails.get("https_write") + # All httpsWrite details from the Cisco DNA Center + httpsWrite_details = global_credentials.get("httpsWrite") + # Cisco DNA Center details for the httpsWrite Credential given in the playbook + httpsWriteDetails = [] + if all_httpsWrite and httpsWrite_details: + for httpsWriteCredential in all_httpsWrite: + httpsWriteDetail = None + httpsWriteId = httpsWriteCredential.get("id") + if httpsWriteId: + httpsWriteDetail = get_dict_result(httpsWrite_details, "id", httpsWriteId) + if not httpsWriteDetail: + self.msg = "httpsWrite credential Id is invalid" + self.status = "failed" + return self + + httpsWriteOldDescription = httpsWriteCredential.get("old_description") + httpsWriteOldUsername = httpsWriteCredential.get("old_username") + if httpsWriteOldDescription and httpsWriteOldUsername and (not httpsWriteDetail): + for item in httpsWrite_details: + if item.get("description") == httpsWriteOldDescription \ + and item.get("username") == httpsWriteOldUsername: + if httpsWriteDetail: + self.msg = "More than one httpsWrite credential with same \ + old_description and old_username. Pass ID" + self.status = "failed" + return self + httpsWriteDetail = item + if not httpsWriteDetail: + self.msg = "httpsWrite credential old_description or \ + old_username is invalid" + self.status = "failed" + return self + + httpsWriteDescription = httpsWriteCredential.get("description") + httpsWriteUsername = httpsWriteCredential.get("username") + if httpsWriteDescription and httpsWriteUsername and (not httpsWriteDetail): + for item in httpsWrite_details: + if item.get("description") == httpsWriteDescription \ + and item.get("username") == httpsWriteUsername: + httpsWriteDetail = item + httpsWriteDetails.append(httpsWriteDetail) + return httpsWriteDetails + + def get_snmpV3_credentials(self, CredentialDetails, global_credentials): + """ + Get the current snmpV3 Credential from + Cisco DNA Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + snmpV3Details (List) - The current snmpV3. + """ + + # Playbook snmpV3 Credential details + all_snmpV3 = CredentialDetails.get("snmp_v3") + # All snmpV3 details from the Cisco DNA Center + snmpV3_details = global_credentials.get("snmpV3") + # Cisco DNA Center details for the snmpV3 Credential given in the playbook + snmpV3Details = [] + if all_snmpV3 and snmpV3_details: + for snmpV3Credential in all_snmpV3: + snmpV3Detail = None + snmpV3Id = snmpV3Credential.get("id") + if snmpV3Id: + snmpV3Detail = get_dict_result(snmpV3_details, "id", snmpV3Id) + if not snmpV3Detail: + self.msg = "snmpV3 credential id is invalid" + self.status = "failed" + return self + + snmpV3OldDescription = snmpV3Credential.get("old_description") + if snmpV3OldDescription and (not snmpV3Detail): + snmpV3Detail = get_dict_result(snmpV3_details, + "description", snmpV3OldDescription) + if not snmpV3Detail: + self.msg = "snmpV3 credential old_description is invalid" + self.status = "failed" + return self + + snmpV3Description = snmpV3Credential.get("description") + if snmpV3Description and (not snmpV3Detail): + snmpV3Detail = get_dict_result(snmpV3_details, "description", snmpV3Description) + snmpV3Details.append(snmpV3Detail) + return snmpV3Details + + def get_have_device_credentials(self, CredentialDetails): + """ + Get the current Global Device Credentials from + Cisco DNA Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + + Returns: + self - The current object with updated information. + """ + + global_credentials = self.get_global_credentials_params() + cliDetails = self.get_cli_credentials(CredentialDetails, global_credentials) + snmpV2cReadDetails = self.get_snmpV2cRead_credentials(CredentialDetails, global_credentials) + snmpV2cWriteDetails = self.get_snmpV2cWrite_credentials(CredentialDetails, + global_credentials) + httpsReadDetails = self.get_httpsRead_credentials(CredentialDetails, global_credentials) + httpsWriteDetails = self.get_httpsWrite_credentials(CredentialDetails, global_credentials) + snmpV3Details = self.get_snmpV3_credentials(CredentialDetails, global_credentials) + self.have.update({"globalCredential": {}}) + if cliDetails: + cliCredential = self.get_cli_params(cliDetails) + self.have.get("globalCredential").update({"cliCredential": cliCredential}) + if snmpV2cReadDetails: + snmpV2cRead = self.get_snmpV2cRead_params(snmpV2cReadDetails) + self.have.get("globalCredential").update({"snmpV2cRead": snmpV2cRead}) + if snmpV2cWriteDetails: + snmpV2cWrite = self.get_snmpV2cWrite_params(snmpV2cWriteDetails) + self.have.get("globalCredential").update({"snmpV2cWrite": snmpV2cWrite}) + if httpsReadDetails: + httpsRead = self.get_httpsRead_params(httpsReadDetails) + self.have.get("globalCredential").update({"httpsRead": httpsRead}) + if httpsWriteDetails: + httpsWrite = self.get_httpsWrite_params(httpsWriteDetails) + self.have.get("globalCredential").update({"httpsWrite": httpsWrite}) + if snmpV3Details: + snmpV3 = self.get_snmpV3_params(snmpV3Details) + self.have.get("globalCredential").update({"snmpV3": snmpV3}) + + self.log("Global device credential details: {0}" + .format(self.have.get("globalCredential")), "DEBUG") + self.msg = "Collected the Global Device Credential Details from the Cisco DNA Center" + self.status = "success" + return self + + def get_have(self, config): + """ + Get the current Global Device Credentials and + Device Credentials assigned to a site in Cisco DNA Center. + + Parameters: + config (dict) - Playbook details containing Global Device + Credentials configurations and Device Credentials should + be assigned to a site. + + Returns: + self - The current object with updated information of Global + Device Credentials and Device Credentials assigned to a site. + """ + + if config.get("global_credential_details") is not None: + CredentialDetails = config.get("global_credential_details") + self.get_have_device_credentials(CredentialDetails).check_return_status() + + self.log("Current State (have): {0}".format(self.have), "INFO") + self.msg = "Successfully retrieved the details from the Cisco DNA Center" + self.status = "success" + return self + + def get_want_device_credentials(self, CredentialDetails): + """ + Get the Global Device Credentials from the playbook. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + + Returns: + self - The current object with updated information of + Global Device Credentials from the playbook. + """ + + want = { + "want_create": {}, + "want_update": {} + } + if CredentialDetails.get("cli_credential"): + cli = CredentialDetails.get("cli_credential") + have_cli_ptr = 0 + create_cli_ptr = 0 + update_cli_ptr = 0 + values = ["password", "description", "username", "id"] + have_cliCredential = self.have.get("globalCredential").get("cliCredential") + for item in cli: + if not have_cliCredential or have_cliCredential[have_cli_ptr] is None: + if want.get("want_create").get("cliCredential") is None: + want.get("want_create").update({"cliCredential": []}) + create_credential = want.get("want_create").get("cliCredential") + create_credential.append({}) + for i in range(0, 3): + if item.get(values[i]): + create_credential[create_cli_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + cliCredential " + str(have_cli_ptr) + self.status = "failed" + return self + + if item.get("enable_password"): + create_credential[create_cli_ptr] \ + .update({"enablePassword": item.get("enable_password")}) + create_cli_ptr = create_cli_ptr + 1 + else: + if want.get("want_update").get("cliCredential") is None: + want.get("want_update").update({"cliCredential": []}) + update_credential = want.get("want_update").get("cliCredential") + update_credential.append({}) + if item.get("password"): + update_credential[update_cli_ptr] \ + .update({"password": item.get("password")}) + else: + self.msg = "password is mandatory for udpating \ + cliCredential " + str(have_cli_ptr) + self.status = "failed" + return self + + for i in range(1, 4): + if item.get(values[i]): + update_credential[update_cli_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_cli_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("cliCredential")[have_cli_ptr].get(values[i]) + }) + + if item.get("enable_password"): + update_credential[update_cli_ptr].update({ + "enablePassword": item.get("enable_password") + }) + update_cli_ptr = update_cli_ptr + 1 + have_cli_ptr = have_cli_ptr + 1 + + if CredentialDetails.get("snmp_v2c_read"): + snmpV2cRead = CredentialDetails.get("snmp_v2c_read") + have_snmpv2cread_ptr = 0 + create_snmpv2cread_ptr = 0 + update_snmpv2cread_ptr = 0 + values = ["read_community", "description", "id"] + keys = ["readCommunity", "description", "id"] + have_snmpV2cRead = self.have.get("globalCredential").get("snmpV2cRead") + for item in snmpV2cRead: + if not have_snmpV2cRead or have_snmpV2cRead[have_snmpv2cread_ptr] is None: + if want.get("want_create").get("snmpV2cRead") is None: + want.get("want_create").update({"snmpV2cRead": []}) + create_credential = want.get("want_create").get("snmpV2cRead") + create_credential.append({}) + for i in range(0, 2): + if item.get(values[i]): + create_credential[create_snmpv2cread_ptr] \ + .update({keys[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + snmpV2cRead " + str(have_snmpv2cread_ptr) + self.status = "failed" + return self + create_snmpv2cread_ptr = create_snmpv2cread_ptr + 1 + else: + if want.get("want_update").get("snmpV2cRead") is None: + want.get("want_update").update({"snmpV2cRead": []}) + update_credential = want.get("want_update").get("snmpV2cRead") + update_credential.append({}) + if item.get("read_community"): + update_credential[update_snmpv2cread_ptr] \ + .update({"readCommunity": item.get("read_community")}) + else: + self.msg = "read_community is mandatory for updating \ + snmpV2cRead " + str(have_snmpv2cread_ptr) + self.status = "failed" + return self + for i in range(1, 3): + if item.get(values[i]): + update_credential[update_snmpv2cread_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_snmpv2cread_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("snmpV2cRead")[have_snmpv2cread_ptr].get(values[i]) + }) + update_snmpv2cread_ptr = update_snmpv2cread_ptr + 1 + have_snmpv2cread_ptr = have_snmpv2cread_ptr + 1 + + if CredentialDetails.get("snmp_v2c_write"): + snmpV2cWrite = CredentialDetails.get("snmp_v2c_write") + have_snmpv2cwrite_ptr = 0 + create_snmpv2cwrite_ptr = 0 + update_snmpv2cwrite_ptr = 0 + values = ["write_community", "description", "id"] + keys = ["writeCommunity", "description", "id"] + have_snmpV2cWrite = self.have.get("globalCredential").get("snmpV2cWrite") + for item in snmpV2cWrite: + if not have_snmpV2cWrite or have_snmpV2cWrite[have_snmpv2cwrite_ptr] is None: + if want.get("want_create").get("snmpV2cWrite") is None: + want.get("want_create").update({"snmpV2cWrite": []}) + create_credential = want.get("want_create").get("snmpV2cWrite") + create_credential.append({}) + for i in range(0, 2): + if item.get(values[i]): + create_credential[create_snmpv2cwrite_ptr] \ + .update({keys[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + snmpV2cWrite " + str(have_snmpv2cwrite_ptr) + self.status = "failed" + return self + create_snmpv2cwrite_ptr = create_snmpv2cwrite_ptr + 1 + else: + if want.get("want_update").get("snmpV2cWrite") is None: + want.get("want_update").update({"snmpV2cWrite": []}) + update_credential = want.get("want_update").get("snmpV2cWrite") + update_credential.append({}) + if item.get("write_community"): + update_credential[update_snmpv2cwrite_ptr] \ + .update({"writeCommunity": item.get("write_community")}) + else: + self.msg = "write_community is mandatory for updating \ + snmpV2cWrite " + str(have_snmpv2cwrite_ptr) + self.status = "failed" + return self + for i in range(1, 3): + if item.get(values[i]): + update_credential[update_snmpv2cwrite_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_snmpv2cwrite_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("snmpV2cWrite")[have_snmpv2cwrite_ptr].get(values[i]) + }) + update_snmpv2cwrite_ptr = update_snmpv2cwrite_ptr + 1 + have_snmpv2cwrite_ptr = have_snmpv2cwrite_ptr + 1 + + if CredentialDetails.get("https_read"): + httpsRead = CredentialDetails.get("https_read") + have_httpsread_ptr = 0 + create_httpsread_ptr = 0 + update_httpsread_ptr = 0 + values = ["password", "description", "username", "id", "port"] + have_httpsRead = self.have.get("globalCredential").get("httpsRead") + for item in httpsRead: + self.log("Global credentials details: {0}" + .format(self.have.get("globalCredential")), "DEBUG") + if not have_httpsRead or have_httpsRead[have_httpsread_ptr] is None: + if want.get("want_create").get("httpsRead") is None: + want.get("want_create").update({"httpsRead": []}) + create_credential = want.get("want_create").get("httpsRead") + create_credential.append({}) + for i in range(0, 3): + if item.get(values[i]): + create_credential[create_httpsread_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + httpsRead " + str(have_httpsread_ptr) + self.status = "failed" + return self + if item.get("port"): + create_credential[create_httpsread_ptr] \ + .update({"port": item.get("port")}) + else: + create_credential[create_httpsread_ptr] \ + .update({"port": "443"}) + create_httpsread_ptr = create_httpsread_ptr + 1 + else: + if want.get("want_update").get("httpsRead") is None: + want.get("want_update").update({"httpsRead": []}) + update_credential = want.get("want_update").get("httpsRead") + update_credential.append({}) + if item.get("password"): + update_credential[update_httpsread_ptr] \ + .update({"password": item.get("password")}) + else: + self.msg = "password is mandatory for updating \ + httpsRead " + str(have_httpsread_ptr) + self.status = "failed" + return self + for i in range(1, 5): + if item.get(values[i]): + update_credential[update_httpsread_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_httpsread_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("httpsRead")[have_httpsread_ptr].get(values[i]) + }) + update_httpsread_ptr = update_httpsread_ptr + 1 + have_httpsread_ptr = have_httpsread_ptr + 1 + + if CredentialDetails.get("https_write"): + httpsWrite = CredentialDetails.get("https_write") + have_httpswrite_ptr = 0 + create_httpswrite_ptr = 0 + update_httpswrite_ptr = 0 + values = ["password", "description", "username", "id", "port"] + have_httpsWrite = self.have.get("globalCredential").get("httpsWrite") + for item in httpsWrite: + if not have_httpsWrite or have_httpsWrite[have_httpswrite_ptr] is None: + if want.get("want_create").get("httpsWrite") is None: + want.get("want_create").update({"httpsWrite": []}) + create_credential = want.get("want_create").get("httpsWrite") + create_credential.append({}) + for i in range(0, 3): + if item.get(values[i]): + create_credential[create_httpswrite_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + httpsWrite " + str(have_httpswrite_ptr) + self.status = "failed" + return self + if item.get("port"): + create_credential[create_httpswrite_ptr] \ + .update({"port": item.get("port")}) + else: + create_credential[create_httpswrite_ptr] \ + .update({"port": "443"}) + create_httpswrite_ptr = create_httpswrite_ptr + 1 + else: + if want.get("want_update").get("httpsWrite") is None: + want.get("want_update").update({"httpsWrite": []}) + update_credential = want.get("want_update").get("httpsWrite") + update_credential.append({}) + if item.get("password"): + update_credential[update_httpswrite_ptr] \ + .update({"password": item.get("password")}) + else: + self.msg = "password is mandatory for updating \ + httpsRead " + str(have_httpswrite_ptr) + self.status = "failed" + return self + for i in range(1, 5): + if item.get(values[i]): + update_credential[update_httpswrite_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_httpswrite_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("httpsWrite")[have_httpswrite_ptr].get(values[i]) + }) + update_httpswrite_ptr = update_httpswrite_ptr + 1 + have_httpswrite_ptr = have_httpswrite_ptr + 1 + + if CredentialDetails.get("snmp_v3"): + snmpV3 = CredentialDetails.get("snmp_v3") + have_snmpv3_ptr = 0 + create_snmpv3_ptr = 0 + update_snmpv3_ptr = 0 + values = ["description", "username", "id"] + have_snmpV3 = self.have.get("globalCredential").get("snmpV3") + for item in snmpV3: + if not have_snmpV3 or have_snmpV3[have_snmpv3_ptr] is None: + if want.get("want_create").get("snmpV3") is None: + want.get("want_create").update({"snmpV3": []}) + create_credential = want.get("want_create").get("snmpV3") + create_credential.append({}) + for i in range(0, 2): + if item.get(values[i]): + create_credential[create_snmpv3_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + snmpV3 " + str(have_snmpv3_ptr) + self.status = "failed" + return self + if item.get("snmp_mode"): + create_credential[create_snmpv3_ptr] \ + .update({"snmpMode": item.get("snmp_mode")}) + else: + create_credential[create_snmpv3_ptr] \ + .update({"snmpMode": "AUTHPRIV"}) + if create_credential[create_snmpv3_ptr].get("snmpMode") == "AUTHNOPRIV" or \ + create_credential[create_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + auths = ["auth_password", "auth_type"] + keys = { + "auth_password": "authPassword", + "auth_type": "authType" + } + for auth in auths: + if item.get(auth): + create_credential[create_snmpv3_ptr] \ + .update({keys[auth]: item.get(auth)}) + else: + self.msg = auth + " is mandatory for creating \ + snmpV3 " + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("auth_password")) < 8: + self.msg = "auth_password length should be greater than 8" + self.status = "failed" + return self + self.log("snmp_mode: {0}".format(create_credential[create_snmpv3_ptr] + .get("snmpMode")), "DEBUG") + if create_credential[create_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + privs = ["privacy_password", "privacy_type"] + key = { + "privacy_password": "privacyPassword", + "privacy_type": "privacyType" + } + for priv in privs: + if item.get(priv): + create_credential[create_snmpv3_ptr] \ + .update({key[priv]: item.get(priv)}) + else: + self.msg = priv + " is mandatory for creating \ + snmpV3 " + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("privacy_password")) < 8: + self.msg = "privacy_password should be greater than 8" + self.status = "failed" + return self + elif create_credential[create_snmpv3_ptr].get("snmpMode") != "NOAUTHNOPRIV": + self.msg = "snmp_mode in snmpV3 is not \ + ['AUTHPRIV', 'AUTHNOPRIV', 'NOAUTHNOPRIV']" + self.status = "failed" + return self + create_snmpv3_ptr = create_snmpv3_ptr + 1 + else: + if want.get("want_update").get("snmpV3") is None: + want.get("want_update").update({"snmpV3": []}) + update_credential = want.get("want_update").get("snmpV3") + update_credential.append({}) + for value in values: + if item.get(value): + update_credential[update_snmpv3_ptr] \ + .update({value: item.get(value)}) + else: + update_credential[update_snmpv3_ptr].update({ + value: self.have.get("globalCredential") + .get("snmpV3")[have_snmpv3_ptr].get(value) + }) + if item.get("snmp_mode"): + update_credential[update_snmpv3_ptr] \ + .update({"snmpMode": item.get("snmp_mode")}) + if update_credential[update_snmpv3_ptr].get("snmpMode") == "AUTHNOPRIV" or \ + update_credential[update_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + if item.get("auth_type"): + update_credential[update_snmpv3_ptr] \ + .update({"authType": item.get("auth_type")}) + elif self.have.get("globalCredential") \ + .get("snmpMode")[have_snmpv3_ptr].get("authType"): + update_credential[update_snmpv3_ptr].update({ + "authType": self.have.get("globalCredential") + .get("snmpMode")[have_snmpv3_ptr].get("authType") + }) + else: + self.msg = "auth_type is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if item.get("auth_password"): + update_credential[update_snmpv3_ptr] \ + .update({"authPassword": item.get("auth_password")}) + else: + self.msg = "auth_password is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("auth_password")) < 8: + self.msg = "auth_password length should be greater than 8" + self.status = "failed" + return self + elif update_credential[update_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + if item.get("privacy_type"): + update_credential[update_snmpv3_ptr] \ + .update({"privacyType": item.get("privacy_type")}) + elif self.have.get("globalCredential") \ + .get("snmpMode")[have_snmpv3_ptr].get("privacyType"): + update_credential[update_snmpv3_ptr].update({ + "privacyType": self.have.get("globalCredential") + .get("snmpMode")[have_snmpv3_ptr].get("privacyType") + }) + else: + self.msg = "privacy_type is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if item.get("privacy_password"): + update_credential[update_snmpv3_ptr] \ + .update({"privacyPassword": item.get("privacy_password")}) + else: + self.msg = "privacy_password is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("privacy_password")) < 8: + self.msg = "privacy_password length should be greater than 8" + self.status = "failed" + return self + update_snmpv3_ptr = update_snmpv3_ptr + 1 + have_snmpv3_ptr = have_snmpv3_ptr + 1 + self.want.update(want) + self.msg = "Collected the Global Credentials from the Cisco DNA Center" + self.status = "success" + return self + + def get_want_assign_credentials(self, AssignCredentials): + """ + Get the Credentials to be assigned to a site from the playbook. + Check this API using the check_return_status. + + Parameters: + AssignCredentials (dict) - Playbook details containing + credentials that need to be assigned to a site. + + Returns: + self - The current object with updated information of credentials + that need to be assigned to a site from the playbook. + """ + want = { + "assign_credentials": {} + } + site_name = AssignCredentials.get("site_name") + if not site_name: + self.msg = "site_name is required for AssignCredentials" + self.status = "failed" + return self + site_id = [] + for site_name in site_name: + siteId = self.get_site_id(site_name) + if not site_name: + self.msg = "site_name is invalid in AssignCredentials" + self.status = "failed" + return self + site_id.append(siteId) + want.update({"site_id": site_id}) + global_credentials = self.get_global_credentials_params() + cli_credential = AssignCredentials.get("cli_credential") + if cli_credential: + cliId = cli_credential.get("id") + cliDescription = cli_credential.get("description") + cliUsername = cli_credential.get("username") + + if cliId or cliDescription and cliUsername: + # All CLI details from the Cisco DNA Center + cli_details = global_credentials.get("cliCredential") + if not cli_details: + self.msg = "Global CLI credential is not available" + self.status = "failed" + return self + cliDetail = None + if cliId: + cliDetail = get_dict_result(cli_details, "id", cliId) + if not cliDetail: + self.msg = "The ID for the CLI credential is not valid." + self.status = "failed" + return self + elif cliDescription and cliUsername: + for item in cli_details: + if item.get("description") == cliDescription and \ + item.get("username") == cliUsername: + cliDetail = item + if not cliDetail: + self.msg = "The username and description of the CLI credential are invalid" + self.status = "failed" + return self + want.get("assign_credentials").update({"cliId": cliDetail.get("id")}) + + snmp_v2c_read = AssignCredentials.get("snmp_v2c_read") + if snmp_v2c_read: + snmpV2cReadId = snmp_v2c_read.get("id") + snmpV2cReadDescription = snmp_v2c_read.get("description") + if snmpV2cReadId or snmpV2cReadDescription: + + # All snmpV2cRead details from the Cisco DNA Center + snmpV2cRead_details = global_credentials.get("snmpV2cRead") + if not snmpV2cRead_details: + self.msg = "Global snmpV2cRead credential is not available" + self.status = "failed" + return self + snmpV2cReadDetail = None + if snmpV2cReadId: + snmpV2cReadDetail = get_dict_result(snmpV2cRead_details, "id", snmpV2cReadId) + if not snmpV2cReadDetail: + self.msg = "The ID of the snmpV2cRead credential is not valid." + self.status = "failed" + return self + elif snmpV2cReadDescription: + for item in snmpV2cRead_details: + if item.get("description") == snmpV2cReadDescription: + snmpV2cReadDetail = item + if not snmpV2cReadDetail: + self.msg = "The username and description for the snmpV2cRead credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"snmpV2ReadId": snmpV2cReadDetail.get("id")}) + + snmp_v2c_write = AssignCredentials.get("snmp_v2c_write") + if snmp_v2c_write: + snmpV2cWriteId = snmp_v2c_write.get("id") + snmpV2cWriteDescription = snmp_v2c_write.get("description") + if snmpV2cWriteId or snmpV2cWriteDescription: + + # All snmpV2cWrite details from the Cisco DNA Center + snmpV2cWrite_details = global_credentials.get("snmpV2cWrite") + if not snmpV2cWrite_details: + self.msg = "Global snmpV2cWrite Credential is not available" + self.status = "failed" + return self + snmpV2cWriteDetail = None + if snmpV2cWriteId: + snmpV2cWriteDetail = get_dict_result(snmpV2cWrite_details, "id", snmpV2cWriteId) + if not snmpV2cWriteDetail: + self.msg = "The ID of the snmpV2cWrite credential is invalid." + self.status = "failed" + return self + elif snmpV2cWriteDescription: + for item in snmpV2cWrite_details: + if item.get("description") == snmpV2cWriteDescription: + snmpV2cWriteDetail = item + if not snmpV2cWriteDetail: + self.msg = "The username and description of the snmpV2cWrite credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"snmpV2WriteId": snmpV2cWriteDetail.get("id")}) + + https_read = AssignCredentials.get("https_read") + if https_read: + httpReadId = https_read.get("id") + httpReadDescription = https_read.get("description") + httpReadUsername = https_read.get("username") + if httpReadId or httpReadDescription and httpReadUsername: + + # All httpRead details from the Cisco DNA Center + httpRead_details = global_credentials.get("httpsRead") + if not httpRead_details: + self.msg = "Global httpRead Credential is not available." + self.status = "failed" + return self + httpReadDetail = None + if httpReadId: + httpReadDetail = get_dict_result(httpRead_details, "id", httpReadId) + if not httpReadDetail: + self.msg = "The ID of the httpRead credential is not valid." + self.status = "failed" + return self + elif httpReadDescription and httpReadUsername: + for item in httpRead_details: + if item.get("description") == httpReadDescription and \ + item.get("username") == httpReadUsername: + httpReadDetail = item + if not httpReadDetail: + self.msg = "The description and username for the httpRead credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"httpRead": httpReadDetail.get("id")}) + + https_write = AssignCredentials.get("https_write") + if https_write: + httpWriteId = https_write.get("id") + httpWriteDescription = https_write.get("description") + httpWriteUsername = https_write.get("username") + if httpWriteId or httpWriteDescription and httpWriteUsername: + + # All httpWrite details from the Cisco DNA Center + httpWrite_details = global_credentials.get("httpsWrite") + if not httpWrite_details: + self.msg = "Global httpWrite credential is not available." + self.status = "failed" + return self + httpWriteDetail = None + if httpWriteId: + httpWriteDetail = get_dict_result(httpWrite_details, "id", httpWriteId) + if not httpWriteDetail: + self.msg = "The ID of the httpWrite credential is not valid." + self.status = "failed" + return self + elif httpWriteDescription and httpWriteUsername: + for item in httpWrite_details: + if item.get("description") == httpWriteDescription and \ + item.get("username") == httpWriteUsername: + httpWriteDetail = item + if not httpWriteDetail: + self.msg = "The description and username for the httpWrite credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"httpWrite": httpWriteDetail.get("id")}) + + snmp_v3 = AssignCredentials.get("snmp_v3") + if snmp_v3: + snmpV3Id = snmp_v3.get("id") + snmpV3Description = snmp_v3.get("description") + if snmpV3Id or snmpV3Description: + + # All snmpV3 details from the Cisco DNA Center + snmpV3_details = global_credentials.get("snmpV3") + if not snmpV3_details: + self.msg = "Global snmpV3 Credential is not available." + self.status = "failed" + return self + snmpV3Detail = None + if snmpV3Id: + snmpV3Detail = get_dict_result(snmpV3_details, "id", snmpV3Id) + if not snmpV3Detail: + self.msg = "The ID of the snmpV3 credential is not valid." + self.status = "failed" + return self + elif snmpV3Description: + for item in snmpV3_details: + if item.get("description") == snmpV3Description: + snmpV3Detail = item + if not snmpV3Detail: + self.msg = "The username and description for the snmpV2cWrite credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"snmpV3Id": snmpV3Detail.get("id")}) + self.log("Desired State (want): {0}".format(want), "INFO") + self.want.update(want) + self.msg = "Collected the Credentials needed to be assigned from the Cisco DNA Center" + self.status = "success" + return self + + def get_want(self, config): + """ + Get the current Global Device Credentials and Device + Credentials assigned to a site form the playbook. + + Parameters: + config (dict) - Playbook details containing Global Device + Credentials configurations and Device Credentials should + be assigned to a site. + + Returns: + self - The current object with updated information of Global + Device Credentials and Device Credentials assigned to a site. + """ + + if config.get("global_credential_details"): + CredentialDetails = config.get("global_credential_details") + self.get_want_device_credentials(CredentialDetails).check_return_status() + + if config.get("assign_credentials_to_site"): + AssignCredentials = config.get("assign_credentials_to_site") + self.get_want_assign_credentials(AssignCredentials).check_return_status() + + self.log("Desired State (want): {0}".format(self.want), "INFO") + self.msg = "Successfully retrieved details from the playbook" + self.status = "success" + return self + + def create_device_credentials(self): + """ + Create Global Device Credential to the Cisco DNA + Center based on the provided playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + self + + Returns: + self + """ + + result_global_credential = self.result.get("response")[0].get("globalCredential") + want_create = self.want.get("want_create") + if not want_create: + result_global_credential.update({ + "No Creation": { + "response": "No Response", + "msg": "No Creation is available" + } + }) + return self + + credential_params = want_create + self.log("Creating global credential API input parameters: {0}" + .format(credential_params), "DEBUG") + response = self.dnac._exec( + family="discovery", + function='create_global_credentials_v2', + params=credential_params, + ) + self.log("Received API response from 'create_global_credentials_v2': {0}" + .format(response), "DEBUG") + validation_string = "global credential addition performed" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Global credential created successfully", "INFO") + result_global_credential.update({ + "Creation": { + "response": credential_params, + "msg": "Global Credential Created Successfully" + } + }) + self.msg = "Global Device Credential Created Successfully" + self.status = "success" + return self + + def update_device_credentials(self): + """ + Update Device Credential to the Cisco DNA Center based on the provided playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + self + + Returns: + self + """ + + result_global_credential = self.result.get("response")[0].get("globalCredential") + + # Get the result global credential and want_update from the current object + want_update = self.want.get("want_update") + # If no credentials to update, update the result and return + if not want_update: + result_global_credential.update({ + "No Updation": { + "response": "No Response", + "msg": "No Updation is available" + } + }) + self.msg = "No Updation is available" + self.status = "success" + return self + i = 0 + flag = True + values = ["cliCredential", "snmpV2cRead", "snmpV2cWrite", + "httpsRead", "httpsWrite", "snmpV3"] + final_response = [] + self.log("Desired State for global device credentials updation: {0}" + .format(want_update), "DEBUG") + while flag: + flag = False + credential_params = {} + for value in values: + if want_update.get(value) and i < len(want_update.get(value)): + flag = True + credential_params.update({value: want_update.get(value)[i]}) + i = i + 1 + if credential_params: + final_response.append(credential_params) + response = self.dnac._exec( + family="discovery", + function='update_global_credentials_v2', + params=credential_params, + ) + self.log("Received API response for 'update_global_credentials_v2': {0}" + .format(response), "DEBUG") + validation_string = "global credential update performed" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Updating device credential API input parameters: {0}" + .format(final_response), "DEBUG") + self.log("Global device credential updated successfully", "INFO") + result_global_credential.update({ + "Updation": { + "response": final_response, + "msg": "Global Device Credential Updated Successfully" + } + }) + self.msg = "Global Device Credential Updated Successfully" + self.status = "success" + return self + + def assign_credentials_to_site(self): + """ + Assign Global Device Credential to the Cisco DNA + Center based on the provided playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + self + + Returns: + self + """ + + result_assign_credential = self.result.get("response")[0].get("assignCredential") + credential_params = self.want.get("assign_credentials") + final_response = [] + self.log("Assigning device credential to site API input parameters: {0}" + .format(credential_params), "DEBUG") + if not credential_params: + result_assign_credential.update({ + "No Assign Credentials": { + "response": "No Response", + "msg": "No Assignment is available" + } + }) + self.msg = "No Assignment is available" + self.status = "success" + return self + + site_ids = self.want.get("site_id") + for site_id in site_ids: + credential_params.update({"site_id": site_id}) + final_response.append(copy.deepcopy(credential_params)) + response = self.dnac._exec( + family="network_settings", + function='assign_device_credential_to_site_v2', + params=credential_params, + ) + self.log("Received API response for 'assign_device_credential_to_site_v2': {0}" + .format(response), "DEBUG") + validation_string = "desired common settings operation successful" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Device credential assigned to site {0} is successfully." + .format(site_ids), "INFO") + self.log("Desired State for assign credentials to a site: {0}" + .format(final_response), "DEBUG") + result_assign_credential.update({ + "Assign Credentials": { + "response": final_response, + "msg": "Device Credential Assigned to a site is Successfully" + } + }) + self.msg = "Global Credential is assigned Successfully" + self.status = "success" + return self + + def get_diff_merged(self, config): + """ + Update or Create Global Device Credential and assign device + credential to a site in Cisco DNA Center based on the playbook provided. + + Parameters: + config (list of dict) - Playbook details containing Global + Device Credential and assign credentials to a site information. + + Returns: + self + """ + + if config.get("global_credential_details") is not None: + self.create_device_credentials().check_return_status() + + if config.get("global_credential_details") is not None: + self.update_device_credentials().check_return_status() + + if config.get("assign_credentials_to_site") is not None: + self.assign_credentials_to_site().check_return_status() + + return self + + def delete_device_credential(self, config): + """ + Delete Global Device Credential in Cisco DNA Center based on the playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + config (dict) - Playbook details containing Global Device Credential information. + self - The current object details. + + Returns: + self + """ + + result_global_credential = self.result.get("response")[0].get("globalCredential") + have_values = self.have.get("globalCredential") + final_response = {} + self.log("Global device credentials to be deleted: {0}".format(have_values), "DEBUG") + credential_mapping = { + "cliCredential": "cli_credential", + "snmpV2cRead": "snmp_v2c_read", + "snmpV2cWrite": "snmp_v2c_write", + "snmpV3": "snmp_v3", + "httpsRead": "https_read", + "httpsWrite": "https_write" + } + for item in have_values: + config_itr = 0 + final_response.update({item: []}) + for value in have_values.get(item): + if value is None: + self.log("Credential Name: {0}".format(item), "DEBUG") + self.log("Credential Item: {0}".format(config.get("global_credential_details") + .get(credential_mapping.get(item))), "DEBUG") + final_response.get(item).append( + str(config.get("global_credential_details") + .get(credential_mapping.get(item))[config_itr]) + " is not found." + ) + continue + _id = have_values.get(item)[config_itr].get("id") + response = self.dnac._exec( + family="discovery", + function="delete_global_credential_v2", + params={"id": _id}, + ) + self.log("Received API response for 'delete_global_credential_v2': {0}" + .format(response), "DEBUG") + validation_string = "global credential deleted successfully" + self.check_task_response_status(response, validation_string).check_return_status() + final_response.get(item).append(_id) + config_itr = config_itr + 1 + + self.log("Deleting device credential API input parameters: {0}" + .format(final_response), "DEBUG") + self.log("Successfully deleted global device credential.", "INFO") + result_global_credential.update({ + "Deletion": { + "response": final_response, + "msg": "Global Device Credentials Deleted Successfully" + } + }) + self.msg = "Global Device Credentials Updated Successfully" + self.status = "success" + return self + + def get_diff_deleted(self, config): + """ + Delete Global Device Credential in Cisco DNA Center based on the playbook details. + + Parameters: + config (dict) - Playbook details containing Global Device Credential information. + self - The current object details. + + Returns: + self + """ + + if config.get("global_credential_details") is not None: + self.delete_device_credential(config).check_return_status() + + 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."), "DEBUG") + self.get_have(config) + self.get_want(config) + self.log("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + + 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", "INFO") + 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", "INFO") + 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + + 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.", "INFO") + 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 + + Parameters: + self + + Returns: + self + """ + + self.have.clear() + self.want.clear() + return self + + +def main(): + """main entry point for module execution""" + + # Define the specification for module arguments + element_spec = { + "dnac_host": {"type": 'str', "required": True}, + "dnac_port": {"type": 'str', "default": '443'}, + "dnac_username": {"type": 'str', "default": 'admin', "aliases": ['user']}, + "dnac_password": {"type": 'str', "no_log": True}, + "dnac_verify": {"type": 'bool', "default": 'True'}, + "dnac_version": {"type": 'str', "default": '2.2.3.3'}, + "dnac_debug": {"type": 'bool', "default": False}, + "dnac_log": {"type": 'bool', "default": False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + "config_verify": {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + "config": {"type": 'list', "required": True, "elements": 'dict'}, + "state": {"default": 'merged', "choices": ['merged', 'deleted']}, + "validate_response_schema": {"type": 'bool', "default": True}, + } + + # Create an AnsibleModule object with argument specifications + 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) + dnac_credential.check_return_status() + + dnac_credential.validate_input().check_return_status() + + for config in dnac_credential.config: + dnac_credential.reset_values() + dnac_credential.get_have(config).check_return_status() + 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) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/device_credential_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/device_credential_workflow_manager.py new file mode 100644 index 000000000..3db97ce05 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/device_credential_workflow_manager.py @@ -0,0 +1,2617 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module to perform operations on device credentials in Cisco Catalyst Center.""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ['Muthu Rakesh, Madhan Sankaranarayanan'] + +DOCUMENTATION = r""" +--- +module: device_credential_workflow_manager +short_description: Resource module for Global Device Credentials and Assigning Credentials to sites. +description: +- Manage operations on Global Device Credentials and Assigning Credentials to sites. +- API to create global device credentials. +- API to update global device credentials. +- API to delete global device credentials. +- API to assign the device credential to the site. +version_added: '6.7.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Muthu Rakesh (@MUTHU-RAKESH-27) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of global device credentials and site names. + type: list + elements: dict + required: true + suboptions: + global_credential_details: + description: Manages global device credentials + type: dict + suboptions: + cli_credential: + description: Global Credential V2's cliCredential. + type: list + elements: dict + suboptions: + description: + description: Description. Required for creating the credential. + type: str + enable_password: + description: + - cli_credential credential Enable Password. + - Password cannot contain spaces or angle brackets (< >) + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + password: + description: + - cli_credential credential Password. + - Required for creating/updating the credential. + - Password cannot contain spaces or angle brackets (< >). + type: str + username: + description: + - cli_credential credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description/Username. + type: str + old_username: + description: Old Username. Use this for updating the description/Username. + type: str + https_read: + description: Global Credential V2's httpsRead. + type: list + elements: dict + suboptions: + id: + description: Credential Id. Use this for updating the device credential. + type: str + name: + description: Name. Required for creating the credential. + type: str + password: + description: + - https_read credential Password. + - Required for creating/updating the credential. + - Password cannot contain spaces or angle brackets (< >). + type: str + port: + description: Port. Default port is 443. + type: int + username: + description: + - https_read credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description/Username. + type: str + old_username: + description: Old Username. Use this for updating the description/Username. + type: str + https_write: + description: Global Credential V2's httpsWrite. + type: list + elements: dict + suboptions: + id: + description: Credential Id. Use this for updating the device credential. + type: str + name: + description: Name. Required for creating the credential. + type: str + password: + description: + - https_write credential Password. + - Required for creating/updating the credential. + - Password cannot contain spaces or angle brackets (< >). + type: str + port: + description: Port. Default port is 443. + type: int + username: + description: + - https_write credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description/Username. + type: str + old_username: + description: Old Username. Use this for updating the description/Username. + type: str + snmp_v2c_read: + description: Global Credential V2's snmpV2cRead. + type: list + elements: dict + suboptions: + description: + description: Description. Required for creating the credential. + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + read_community: + description: + - snmp_v2c_read Read Community. + - Password cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description. + type: str + snmp_v2c_write: + description: Global Credential V2's snmpV2cWrite. + type: list + elements: dict + suboptions: + description: + description: Description. Required for creating the credential. + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + write_community: + description: + - snmp_v2c_write Write Community. + - Password cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description. + type: str + snmp_v3: + description: Global Credential V2's snmpV3. + type: list + elements: dict + suboptions: + auth_password: + description: + - snmp_v3 Auth Password. + - Password must contain minimum 8 characters. + - Password cannot contain spaces or angle brackets (< >). + type: str + auth_type: + description: Auth Type. ["SHA", "MD5"]. + type: str + description: + description: + - snmp_v3 Description. + - Should be unique from other snmp_v3 credentials. + type: str + id: + description: Credential Id. Use this for updating the device credential. + type: str + privacy_password: + description: + - snmp_v3 Privacy Password. + - Password must contain minimum 8 characters. + - Password cannot contain spaces or angle brackets (< >). + type: str + privacy_type: + description: Privacy Type. ["AES128", "AES192", "AES256"]. + type: str + snmp_mode: + description: Snmp Mode. ["AUTHPRIV", "AUTHNOPRIV", "NOAUTHNOPRIV"]. + type: str + username: + description: + - snmp_v3 credential Username. + - Username cannot contain spaces or angle brackets (< >). + type: str + old_description: + description: Old Description. Use this for updating the description. + type: str + assign_credentials_to_site: + description: Assign Device Credentials to Site. + type: dict + suboptions: + cli_credential: + description: CLI Credential. + type: dict + suboptions: + description: + description: CLI Credential Description. + type: str + username: + description: CLI Credential Username. + type: str + id: + description: CLI Credential Id. Use (Description, Username) or Id. + type: str + https_read: + description: HTTP(S) Read Credential + type: dict + suboptions: + description: + description: HTTP(S) Read Credential Description. + type: str + username: + description: HTTP(S) Read Credential Username. + type: str + id: + description: HTTP(S) Read Credential Id. Use (Description, Username) or Id. + type: str + https_write: + description: HTTP(S) Write Credential + type: dict + suboptions: + description: + description: HTTP(S) Write Credential Description. + type: str + username: + description: HTTP(S) Write Credential Username. + type: str + id: + description: HTTP(S) Write Credential Id. Use (Description, Username) or Id. + type: str + site_name: + description: Site Name to assign credential. + type: list + elements: str + snmp_v2c_read: + description: SNMPv2c Read Credential + type: dict + suboptions: + description: + description: SNMPv2c Read Credential Description. + type: str + id: + description: SNMPv2c Read Credential Id. Use Description or Id. + type: str + snmp_v2c_write: + description: SNMPv2c Write Credential + type: dict + suboptions: + description: + description: SNMPv2c Write Credential Description. + type: str + id: + description: SNMPv2c Write Credential Id. Use Description or Id. + type: str + snmp_v3: + description: snmp_v3 Credential + type: dict + suboptions: + description: + description: snmp_v3 Credential Description. + type: str + id: + description: snmp_v3 Credential Id. Use Description or Id. + type: str +requirements: +- dnacentersdk >= 2.5.5 +- python >= 3.5 +seealso: +- name: Cisco Catalyst Center documentation for Discovery CreateGlobalCredentialsV2 + description: Complete reference of the CreateGlobalCredentialsV2 API. + link: https://developer.cisco.com/docs/dna-center/#!create-global-credentials-v-2 +- name: Cisco Catalyst Center documentation for Discovery DeleteGlobalCredentialV2 + description: Complete reference of the DeleteGlobalCredentialV2 API. + link: https://developer.cisco.com/docs/dna-center/#!delete-global-credential-v-2 +- name: Cisco Catalyst Center documentation for Discovery UpdateGlobalCredentialsV2 + description: Complete reference of the UpdateGlobalCredentialsV2 API. + link: https://developer.cisco.com/docs/dna-center/#!update-global-credentials-v-2 +- name: Cisco Catalyst Center documentation for Network Settings AssignDeviceCredentialToSiteV2 + description: Complete reference of the AssignDeviceCredentialToSiteV2 API. + link: https://developer.cisco.com/docs/dna-center/#!assign-device-credential-to-site-v-2 +notes: + - SDK Method used are + discovery.Discovery.create_global_credentials_v2, + discovery.Discovery.delete_global_credential_v2, + discovery.Discovery.update_global_credentials_v2, + network_settings.NetworkSettings.assign_device_credential_to_site_v2, + + - Paths used are + post /dna/intent/api/v2/global-credential, + delete /dna/intent/api/v2/global-credential/{id}, + put /dna/intent/api/v2/global-credential, + post /dna/intent/api/v2/credential-to-site/{siteId}, +""" + +EXAMPLES = r""" +--- + - name: Create Credentials and assign it to a site. + cisco.dnac.device_credential_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + snmp_v2c_read: + - description: string + read_community: string + snmp_v2c_write: + - description: string + write_community: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + https_read: + - description: string + username: string + password: string + port: 443 + https_write: + - description: string + username: string + password: string + port: 443 + assign_credentials_to_site: + cli_credential: + id: string + snmp_v2c_read: + id: string + snmp_v2c_write: + id: string + snmp_v3: + id: string + https_read: + id: string + https_write: + id: string + site_name: + - string + + - name: Create Multiple Credentials. + cisco.dnac.device_credential_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + - description: string + username: string + password: string + enable_password: string + snmp_v2c_read: + - description: string + read_community: string + - description: string + read_community: string + snmp_v2c_write: + - description: string + write_community: string + - description: string + write_community: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + https_read: + - description: string + username: string + password: string + port: 443 + - description: string + username: string + password: string + port: 443 + https_write: + - description: string + username: string + password: string + port: 443 + - description: string + username: string + password: string + port: 443 + + - name: Update global device credentials using id + cisco.dnac.device_credential_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + id: string + snmp_v2c_read: + - description: string + read_community: string + id: string + snmp_v2c_write: + - description: string + write_community: string + id: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + id: string + https_read: + - description: string + username: string + password: string + port: 443 + id: string + https_write: + - description: string + username: string + password: string + port: 443 + id: string + + - name: Update multiple global device credentials using id + cisco.dnac.device_credential_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + id: string + - description: string + username: string + password: string + enable_password: string + id: string + snmp_v2c_read: + - description: string + read_community: string + id: string + - description: string + read_community: string + id: string + snmp_v2c_write: + - description: string + write_community: string + id: string + - description: string + write_community: string + id: string + snmp_v3: + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + id: string + - auth_password: string + auth_type: SHA + snmp_mode: AUTHPRIV + privacy_password: string + privacy_type: AES128 + username: string + description: string + id: string + https_read: + - description: string + username: string + password: string + port: 443 + id: string + - description: string + username: string + password: string + port: 443 + id: string + https_write: + - description: string + username: string + password: string + port: 443 + id: string + - description: string + username: string + password: string + port: 443 + id: string + + - name: Update global device credential name/description using old name and description. + cisco.dnac.device_credential_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_credential_details: + cli_credential: + - description: string + username: string + password: string + enable_password: string + old_description: string + old_username: string + snmp_v2c_read: + - description: string + read_community: string + old_description: string + snmp_v2c_write: + - description: string + write_community: string + old_description: string + snmp_v3: + - auth_password: string + auth_type: string + snmp_mode: string + privacy_password: string + privacy_type: string + username: string + description: string + https_read: + - description: string + username: string + password: string + port: string + old_description: string + old_username: string + https_write: + - description: string + username: string + password: string + port: string + old_description: string + old_username: string + + - name: Assign Credentials to sites using old description and username. + cisco.dnac.device_credential_workflow_manager: + dnac_host: "{{ dnac_host }}" + dnac_port: "{{ dnac_port }}" + dnac_username: "{{ dnac_username }}" + dnac_password: "{{ dnac_password }}" + dnac_verify: "{{ dnac_verify }}" + dnac_debug: "{{ dnac_debug }}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - assign_credentials_to_site: + cli_credential: + description: string + username: string + snmp_v2c_read: + description: string + snmp_v2c_write: + description: string + snmp_v3: + description: string + https_read: + description: string + username: string + https_write: + description: string + username: string + site_name: + - string + - string + +""" + +RETURN = r""" +# Case_1: Successful creation/updation/deletion of global device credentials +dnac_response1: + description: A dictionary or list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": { + "taskId": "string", + "url": "string" + }, + "version": "string" + } + +# Case_2: Successful assignment of global device credentials to a site. +dnac_response2: + description: A dictionary or list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": { + "taskId": "string", + "url": "string" + }, + "version": "string" + } +""" + +import copy +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result, +) + + +class DeviceCredential(DnacBase): + """Class containing member attributes for device_credential_workflow_manager module""" + + def __init__(self, module): + super().__init__(module) + self.result["response"] = [ + { + "globalCredential": {}, + "assignCredential": {} + } + ] + + def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', + 'self.msg' will describe the validation issues. + + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + # temp_spec is the specification for the expected structure of configuration parameters + temp_spec = { + "global_credential_details": { + "type": 'dict', + "cli_credential": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "password": {"type": 'string'}, + "enable_password": {"type": 'string'}, + "old_description": {"type": 'string'}, + "old_username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_read": { + "type": 'list', + "description": {"type": 'string'}, + "read_community": {"type": 'string'}, + "old_description": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_write": { + "type": 'list', + "description": {"type": 'string'}, + "write_community": {"type": 'string'}, + "old_description": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v3": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "snmp_mode": {"type": 'string'}, + "auth_type": {"type": 'string'}, + "auth_password": {"type": 'string'}, + "privacy_type": {"type": 'string'}, + "privacy_password": {"type": 'string'}, + "old_description": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "https_read": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "password": {"type": 'string'}, + "port": {"type": 'integer'}, + "old_description": {"type": 'string'}, + "old_username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "https_write": { + "type": 'list', + "description": {"type": 'string'}, + "username": {"type": 'string'}, + "password": {"type": 'string'}, + "port": {"type": 'integer'}, + "old_description": {"type": 'string'}, + "old_username": {"type": 'string'}, + "id": {"type": 'string'}, + } + }, + "assign_credentials_to_site": { + "type": 'dict', + "cli_credential": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_read": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "snmp_v2c_write": { + "type": 'dict', + "description": {"type: 'string'"}, + "id": {"type": 'string'}, + }, + "snmp_v3": { + "type": 'dict', + "description": {"type: 'string'"}, + "id": {"type": 'string'}, + }, + "https_read": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "https_write": { + "type": 'dict', + "description": {"type: 'string'"}, + "username": {"type": 'string'}, + "id": {"type": 'string'}, + }, + "site_name": { + "type": 'list', + "elements": 'string' + } + } + } + + # Validate playbook params against the specification (temp_spec) + 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)) + self.status = "failed" + return self + + self.validated_config = valid_temp + self.log("Successfully validated playbook config params: {0}".format(valid_temp), "INFO") + self.msg = "Successfully validated input from the playbook" + self.status = "success" + return self + + def get_site_id(self, site_name): + """ + Get the site id from the site name. + Use check_return_status() to check for failure + + Parameters: + site_name (str) - Site name + + Returns: + str or None - The Site Id if found, or None if not found or error + """ + + try: + response = self.dnac._exec( + family="sites", + function='get_site', + params={"name": site_name}, + ) + self.log("Received API response from 'get_site': {0}".format(response), "DEBUG") + if not response: + self.log("Failed to retrieve the site ID for the site name: {0}" + .format(site_name), "ERROR") + return None + + _id = response.get("response")[0].get("id") + self.log("Site ID for the site name {0}: {1}".format(site_name, _id), "INFO") + except Exception as exec: + self.log("Exception occurred while getting site_id from the site_name: {0}" + .format(exec), "CRITICAL") + return None + + return _id + + def get_global_credentials_params(self): + """ + Get the current Global Device Credentials from Cisco Catalyst Center. + + Parameters: + self - The current object details. + + Returns: + global_credentials (dict) - All global device credentials details. + """ + + try: + global_credentials = self.dnac._exec( + family="discovery", + function='get_all_global_credentials_v2', + ) + global_credentials = global_credentials.get("response") + self.log("All global device credentials details: {0}" + .format(global_credentials), "DEBUG") + except Exception as exec: + self.log("Exception occurred while getting global device credentials: {0}" + .format(exec), "CRITICAL") + return None + + return global_credentials + + def get_cli_params(self, cliDetails): + """ + Format the CLI parameters for the CLI credential configuration in Cisco Catalyst Center. + + Parameters: + cliDetails (list of dict) - Cisco Catalyst Center details containing CLI Credentials. + + Returns: + cliCredential (list of dict) - Processed CLI credential data + in the format suitable for the Cisco Catalyst Center config. + """ + + cliCredential = [] + for item in cliDetails: + if item is None: + cliCredential.append(None) + else: + value = { + "username": item.get("username"), + "description": item.get("description"), + "id": item.get("id") + } + cliCredential.append(value) + return cliCredential + + def get_snmpV2cRead_params(self, snmpV2cReadDetails): + """ + Format the snmpV2cRead parameters for the snmpV2cRead + credential configuration in Cisco Catalyst Center. + + Parameters: + snmpV2cReadDetails (list of dict) - Cisco Catalyst Center + Details containing snmpV2cRead Credentials. + + Returns: + snmpV2cRead (list of dict) - Processed snmpV2cRead credential + data in the format suitable for the Cisco Catalyst Center config. + """ + + snmpV2cRead = [] + for item in snmpV2cReadDetails: + if item is None: + snmpV2cRead.append(None) + else: + value = { + "description": item.get("description"), + "id": item.get("id") + } + snmpV2cRead.append(value) + return snmpV2cRead + + def get_snmpV2cWrite_params(self, snmpV2cWriteDetails): + """ + Format the snmpV2cWrite parameters for the snmpV2cWrite + credential configuration in Cisco Catalyst Center. + + Parameters: + snmpV2cWriteDetails (list of dict) - Cisco Catalyst Center + Details containing snmpV2cWrite Credentials. + + Returns: + snmpV2cWrite (list of dict) - Processed snmpV2cWrite credential + data in the format suitable for the Cisco Catalyst Center config. + """ + + snmpV2cWrite = [] + for item in snmpV2cWriteDetails: + if item is None: + snmpV2cWrite.append(None) + else: + value = { + "description": item.get("description"), + "id": item.get("id") + } + snmpV2cWrite.append(value) + return snmpV2cWrite + + def get_httpsRead_params(self, httpsReadDetails): + """ + Format the httpsRead parameters for the httpsRead + credential configuration in Cisco Catalyst Center. + + Parameters: + httpsReadDetails (list of dict) - Cisco Catalyst Center + Details containing httpsRead Credentials. + + Returns: + httpsRead (list of dict) - Processed httpsRead credential + data in the format suitable for the Cisco Catalyst Center config. + """ + + httpsRead = [] + for item in httpsReadDetails: + if item is None: + httpsRead.append(None) + else: + value = { + "description": item.get("description"), + "username": item.get("username"), + "port": item.get("port"), + "id": item.get("id") + } + httpsRead.append(value) + return httpsRead + + def get_httpsWrite_params(self, httpsWriteDetails): + """ + Format the httpsWrite parameters for the httpsWrite + credential configuration in Cisco Catalyst Center. + + Parameters: + httpsWriteDetails (list of dict) - Cisco Catalyst Center + Details containing httpsWrite Credentials. + + Returns: + httpsWrite (list of dict) - Processed httpsWrite credential + data in the format suitable for the Cisco Catalyst Center config. + """ + + httpsWrite = [] + for item in httpsWriteDetails: + if item is None: + httpsWrite.append(None) + else: + value = { + "description": item.get("description"), + "username": item.get("username"), + "port": item.get("port"), + "id": item.get("id") + } + httpsWrite.append(value) + return httpsWrite + + def get_snmpV3_params(self, snmpV3Details): + """ + Format the snmpV3 parameters for the snmpV3 credential configuration in Cisco Catalyst Center. + + Parameters: + snmpV3Details (list of dict) - Cisco Catalyst Center details containing snmpV3 Credentials. + + Returns: + snmpV3 (list of dict) - Processed snmpV3 credential + data in the format suitable for the Cisco Catalyst Center config. + """ + + snmpV3 = [] + for item in snmpV3Details: + if item is None: + snmpV3.append(None) + else: + value = { + "username": item.get("username"), + "description": item.get("description"), + "snmpMode": item.get("snmpMode"), + "id": item.get("id"), + } + if value.get("snmpMode") == "AUTHNOPRIV": + value["authType"] = item.get("authType") + elif value.get("snmpMode") == "AUTHPRIV": + value.update({ + "authType": item.get("authType"), + "privacyType": item.get("privacyType") + }) + snmpV3.append(value) + return snmpV3 + + def get_cli_credentials(self, CredentialDetails, global_credentials): + """ + Get the current CLI Credential from + Cisco Catalyst Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + cliDetails (List) - The current CLI credentials. + """ + + # playbook CLI Credential details + all_CLI = CredentialDetails.get("cli_credential") + # All CLI details from Cisco Catalyst Center + cli_details = global_credentials.get("cliCredential") + # Cisco Catalyst Center details for the CLI Credential given in the playbook + cliDetails = [] + if all_CLI and cli_details: + for cliCredential in all_CLI: + cliDetail = None + cliId = cliCredential.get("id") + if cliId: + cliDetail = get_dict_result(cli_details, "id", cliId) + if not cliDetail: + self.msg = "CLI credential ID is invalid" + self.status = "failed" + return self + + cliOldDescription = cliCredential.get("old_description") + cliOldUsername = cliCredential.get("old_username") + if cliOldDescription and cliOldUsername and (not cliDetail): + for item in cli_details: + if item.get("description") == cliOldDescription \ + and item.get("username") == cliOldUsername: + if cliDetail: + self.msg = "More than one CLI credential with same \ + old_description and old_username. Pass ID." + self.status = "failed" + return self + cliDetail = item + if not cliDetail: + self.msg = "CLI credential old_description or old_username is invalid" + self.status = "failed" + return self + + cliDescription = cliCredential.get("description") + cliUsername = cliCredential.get("username") + if cliDescription and cliUsername and (not cliDetail): + for item in cli_details: + if item.get("description") == cliDescription \ + and item.get("username") == cliUsername: + if cliDetail: + self.msg = "More than one CLI Credential with same \ + description and username. Pass ID." + self.status = "failed" + return self + cliDetail = item + cliDetails.append(cliDetail) + return cliDetails + + def get_snmpV2cRead_credentials(self, CredentialDetails, global_credentials): + """ + Get the current snmpV2cRead Credential from + Cisco Catalyst Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + snmpV2cReadDetails (List) - The current snmpV2cRead. + """ + + # Playbook snmpV2cRead Credential details + all_snmpV2cRead = CredentialDetails.get("snmp_v2c_read") + # All snmpV2cRead details from the Cisco Catalyst Center + snmpV2cRead_details = global_credentials.get("snmpV2cRead") + # Cisco Catalyst Center details for the snmpV2cRead Credential given in the playbook + snmpV2cReadDetails = [] + if all_snmpV2cRead and snmpV2cRead_details: + for snmpV2cReadCredential in all_snmpV2cRead: + snmpV2cReadDetail = None + snmpV2cReadId = snmpV2cReadCredential.get("id") + if snmpV2cReadId: + snmpV2cReadDetail = get_dict_result(snmpV2cRead_details, "id", snmpV2cReadId) + if not snmpV2cReadDetail: + self.msg = "snmpV2cRead credential ID is invalid" + self.status = "failed" + return self + + snmpV2cReadOldDescription = snmpV2cReadCredential.get("old_description") + if snmpV2cReadOldDescription and (not snmpV2cReadDetail): + snmpV2cReadDetail = get_dict_result( + snmpV2cRead_details, + "description", + snmpV2cReadOldDescription + ) + if not snmpV2cReadDetail: + self.msg = "snmpV2cRead credential old_description is invalid" + self.status = "failed" + return self + + snmpV2cReadDescription = snmpV2cReadCredential.get("description") + if snmpV2cReadDescription and (not snmpV2cReadDetail): + snmpV2cReadDetail = get_dict_result( + snmpV2cRead_details, + "description", + snmpV2cReadDescription + ) + snmpV2cReadDetails.append(snmpV2cReadDetail) + return snmpV2cReadDetails + + def get_snmpV2cWrite_credentials(self, CredentialDetails, global_credentials): + """ + Get the current snmpV2cWrite Credential from + Cisco Catalyst Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + snmpV2cWriteDetails (List) - The current snmpV2cWrite. + """ + + # Playbook snmpV2cWrite Credential details + all_snmpV2cWrite = CredentialDetails.get("snmp_v2c_write") + # All snmpV2cWrite details from the Cisco Catalyst Center + snmpV2cWrite_details = global_credentials.get("snmpV2cWrite") + # Cisco Catalyst Center details for the snmpV2cWrite Credential given in the playbook + snmpV2cWriteDetails = [] + if all_snmpV2cWrite and snmpV2cWrite_details: + for snmpV2cWriteCredential in all_snmpV2cWrite: + snmpV2cWriteDetail = None + snmpV2cWriteId = snmpV2cWriteCredential.get("id") + if snmpV2cWriteId: + snmpV2cWriteDetail = get_dict_result(snmpV2cWrite_details, "id", snmpV2cWriteId) + if not snmpV2cWriteDetail: + self.msg = "snmpV2cWrite credential ID is invalid" + self.status = "failed" + return self + + snmpV2cWriteOldDescription = snmpV2cWriteCredential.get("old_description") + if snmpV2cWriteOldDescription and (not snmpV2cWriteDetail): + snmpV2cWriteDetail = get_dict_result( + snmpV2cWrite_details, + "description", + snmpV2cWriteOldDescription + ) + if not snmpV2cWriteDetail: + self.msg = "snmpV2cWrite credential old_description is invalid " + self.status = "failed" + return self + + snmpV2cWriteDescription = snmpV2cWriteCredential.get("description") + if snmpV2cWriteDescription and (not snmpV2cWriteDetail): + snmpV2cWriteDetail = get_dict_result( + snmpV2cWrite_details, + "description", + snmpV2cWriteDescription + ) + snmpV2cWriteDetails.append(snmpV2cWriteDetail) + return snmpV2cWriteDetails + + def get_httpsRead_credentials(self, CredentialDetails, global_credentials): + """ + Get the current httpsRead Credential from + Cisco Catalyst Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + httpsReadDetails (List) - The current httpsRead. + """ + + # Playbook httpsRead Credential details + all_httpsRead = CredentialDetails.get("https_read") + # All httpsRead details from the Cisco Catalyst Center + httpsRead_details = global_credentials.get("httpsRead") + # Cisco Catalyst Center details for the httpsRead Credential given in the playbook + httpsReadDetails = [] + if all_httpsRead and httpsRead_details: + for httpsReadCredential in all_httpsRead: + httpsReadDetail = None + httpsReadId = httpsReadCredential.get("id") + if httpsReadId: + httpsReadDetail = get_dict_result(httpsRead_details, "id", httpsReadId) + if not httpsReadDetail: + self.msg = "httpsRead credential Id is invalid" + self.status = "failed" + return self + + httpsReadOldDescription = httpsReadCredential.get("old_description") + httpsReadOldUsername = httpsReadCredential.get("old_username") + if httpsReadOldDescription and httpsReadOldUsername and (not httpsReadDetail): + for item in httpsRead_details: + if item.get("description") == httpsReadOldDescription \ + and item.get("username") == httpsReadOldUsername: + if httpsReadDetail: + self.msg = "More than one httpsRead credential with same \ + old_description and old_username. Pass ID." + self.status = "failed" + return self + httpsReadDetail = item + if not httpsReadDetail: + self.msg = "httpsRead credential old_description or old_username is invalid" + self.status = "failed" + return self + + httpsReadDescription = httpsReadCredential.get("description") + httpsReadUsername = httpsReadCredential.get("username") + if httpsReadDescription and httpsReadUsername and (not httpsReadDetail): + for item in httpsRead_details: + if item.get("description") == httpsReadDescription \ + and item.get("username") == httpsReadUsername: + if httpsReadDetail: + self.msg = "More than one httpsRead credential with same \ + description and username. Pass ID." + self.status = "failed" + return self + httpsReadDetail = item + httpsReadDetails.append(httpsReadDetail) + return httpsReadDetails + + def get_httpsWrite_credentials(self, CredentialDetails, global_credentials): + """ + Get the current httpsWrite Credential from + Cisco Catalyst Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + httpsWriteDetails (List) - The current httpsWrite. + """ + + # Playbook httpsWrite Credential details + all_httpsWrite = CredentialDetails.get("https_write") + # All httpsWrite details from the Cisco Catalyst Center + httpsWrite_details = global_credentials.get("httpsWrite") + # Cisco Catalyst Center details for the httpsWrite Credential given in the playbook + httpsWriteDetails = [] + if all_httpsWrite and httpsWrite_details: + for httpsWriteCredential in all_httpsWrite: + httpsWriteDetail = None + httpsWriteId = httpsWriteCredential.get("id") + if httpsWriteId: + httpsWriteDetail = get_dict_result(httpsWrite_details, "id", httpsWriteId) + if not httpsWriteDetail: + self.msg = "httpsWrite credential Id is invalid" + self.status = "failed" + return self + + httpsWriteOldDescription = httpsWriteCredential.get("old_description") + httpsWriteOldUsername = httpsWriteCredential.get("old_username") + if httpsWriteOldDescription and httpsWriteOldUsername and (not httpsWriteDetail): + for item in httpsWrite_details: + if item.get("description") == httpsWriteOldDescription \ + and item.get("username") == httpsWriteOldUsername: + if httpsWriteDetail: + self.msg = "More than one httpsWrite credential with same \ + old_description and old_username. Pass ID" + self.status = "failed" + return self + httpsWriteDetail = item + if not httpsWriteDetail: + self.msg = "httpsWrite credential old_description or \ + old_username is invalid" + self.status = "failed" + return self + + httpsWriteDescription = httpsWriteCredential.get("description") + httpsWriteUsername = httpsWriteCredential.get("username") + if httpsWriteDescription and httpsWriteUsername and (not httpsWriteDetail): + for item in httpsWrite_details: + if item.get("description") == httpsWriteDescription \ + and item.get("username") == httpsWriteUsername: + httpsWriteDetail = item + httpsWriteDetails.append(httpsWriteDetail) + return httpsWriteDetails + + def get_snmpV3_credentials(self, CredentialDetails, global_credentials): + """ + Get the current snmpV3 Credential from + Cisco Catalyst Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + global_credentials (dict) - All global device credentials details. + + Returns: + snmpV3Details (List) - The current snmpV3. + """ + + # Playbook snmpV3 Credential details + all_snmpV3 = CredentialDetails.get("snmp_v3") + # All snmpV3 details from the Cisco Catalyst Center + snmpV3_details = global_credentials.get("snmpV3") + # Cisco Catalyst Center details for the snmpV3 Credential given in the playbook + snmpV3Details = [] + if all_snmpV3 and snmpV3_details: + for snmpV3Credential in all_snmpV3: + snmpV3Detail = None + snmpV3Id = snmpV3Credential.get("id") + if snmpV3Id: + snmpV3Detail = get_dict_result(snmpV3_details, "id", snmpV3Id) + if not snmpV3Detail: + self.msg = "snmpV3 credential id is invalid" + self.status = "failed" + return self + + snmpV3OldDescription = snmpV3Credential.get("old_description") + if snmpV3OldDescription and (not snmpV3Detail): + snmpV3Detail = get_dict_result(snmpV3_details, + "description", snmpV3OldDescription) + if not snmpV3Detail: + self.msg = "snmpV3 credential old_description is invalid" + self.status = "failed" + return self + + snmpV3Description = snmpV3Credential.get("description") + if snmpV3Description and (not snmpV3Detail): + snmpV3Detail = get_dict_result(snmpV3_details, "description", snmpV3Description) + snmpV3Details.append(snmpV3Detail) + return snmpV3Details + + def get_have_device_credentials(self, CredentialDetails): + """ + Get the current Global Device Credentials from + Cisco Catalyst Center based on the provided playbook details. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + + Returns: + self - The current object with updated information. + """ + + global_credentials = self.get_global_credentials_params() + cliDetails = self.get_cli_credentials(CredentialDetails, global_credentials) + snmpV2cReadDetails = self.get_snmpV2cRead_credentials(CredentialDetails, global_credentials) + snmpV2cWriteDetails = self.get_snmpV2cWrite_credentials(CredentialDetails, + global_credentials) + httpsReadDetails = self.get_httpsRead_credentials(CredentialDetails, global_credentials) + httpsWriteDetails = self.get_httpsWrite_credentials(CredentialDetails, global_credentials) + snmpV3Details = self.get_snmpV3_credentials(CredentialDetails, global_credentials) + self.have.update({"globalCredential": {}}) + if cliDetails: + cliCredential = self.get_cli_params(cliDetails) + self.have.get("globalCredential").update({"cliCredential": cliCredential}) + if snmpV2cReadDetails: + snmpV2cRead = self.get_snmpV2cRead_params(snmpV2cReadDetails) + self.have.get("globalCredential").update({"snmpV2cRead": snmpV2cRead}) + if snmpV2cWriteDetails: + snmpV2cWrite = self.get_snmpV2cWrite_params(snmpV2cWriteDetails) + self.have.get("globalCredential").update({"snmpV2cWrite": snmpV2cWrite}) + if httpsReadDetails: + httpsRead = self.get_httpsRead_params(httpsReadDetails) + self.have.get("globalCredential").update({"httpsRead": httpsRead}) + if httpsWriteDetails: + httpsWrite = self.get_httpsWrite_params(httpsWriteDetails) + self.have.get("globalCredential").update({"httpsWrite": httpsWrite}) + if snmpV3Details: + snmpV3 = self.get_snmpV3_params(snmpV3Details) + self.have.get("globalCredential").update({"snmpV3": snmpV3}) + + self.log("Global device credential details: {0}" + .format(self.have.get("globalCredential")), "DEBUG") + self.msg = "Collected the Global Device Credential Details from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_have(self, config): + """ + Get the current Global Device Credentials and + Device Credentials assigned to a site in Cisco Catalyst Center. + + Parameters: + config (dict) - Playbook details containing Global Device + Credentials configurations and Device Credentials should + be assigned to a site. + + Returns: + self - The current object with updated information of Global + Device Credentials and Device Credentials assigned to a site. + """ + + if config.get("global_credential_details") is not None: + CredentialDetails = config.get("global_credential_details") + self.get_have_device_credentials(CredentialDetails).check_return_status() + + self.log("Current State (have): {0}".format(self.have), "INFO") + self.msg = "Successfully retrieved the details from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_want_device_credentials(self, CredentialDetails): + """ + Get the Global Device Credentials from the playbook. + Check this API using the check_return_status. + + Parameters: + CredentialDetails (dict) - Playbook details containing Global Device Credentials. + + Returns: + self - The current object with updated information of + Global Device Credentials from the playbook. + """ + + want = { + "want_create": {}, + "want_update": {} + } + if CredentialDetails.get("cli_credential"): + cli = CredentialDetails.get("cli_credential") + have_cli_ptr = 0 + create_cli_ptr = 0 + update_cli_ptr = 0 + values = ["password", "description", "username", "id"] + have_cliCredential = self.have.get("globalCredential").get("cliCredential") + for item in cli: + if not have_cliCredential or have_cliCredential[have_cli_ptr] is None: + if want.get("want_create").get("cliCredential") is None: + want.get("want_create").update({"cliCredential": []}) + create_credential = want.get("want_create").get("cliCredential") + create_credential.append({}) + for i in range(0, 3): + if item.get(values[i]): + create_credential[create_cli_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + cliCredential " + str(have_cli_ptr) + self.status = "failed" + return self + + if item.get("enable_password"): + create_credential[create_cli_ptr] \ + .update({"enablePassword": item.get("enable_password")}) + create_cli_ptr = create_cli_ptr + 1 + else: + if want.get("want_update").get("cliCredential") is None: + want.get("want_update").update({"cliCredential": []}) + update_credential = want.get("want_update").get("cliCredential") + update_credential.append({}) + if item.get("password"): + update_credential[update_cli_ptr] \ + .update({"password": item.get("password")}) + else: + self.msg = "password is mandatory for udpating \ + cliCredential " + str(have_cli_ptr) + self.status = "failed" + return self + + for i in range(1, 4): + if item.get(values[i]): + update_credential[update_cli_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_cli_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("cliCredential")[have_cli_ptr].get(values[i]) + }) + + if item.get("enable_password"): + update_credential[update_cli_ptr].update({ + "enablePassword": item.get("enable_password") + }) + update_cli_ptr = update_cli_ptr + 1 + have_cli_ptr = have_cli_ptr + 1 + + if CredentialDetails.get("snmp_v2c_read"): + snmpV2cRead = CredentialDetails.get("snmp_v2c_read") + have_snmpv2cread_ptr = 0 + create_snmpv2cread_ptr = 0 + update_snmpv2cread_ptr = 0 + values = ["read_community", "description", "id"] + keys = ["readCommunity", "description", "id"] + have_snmpV2cRead = self.have.get("globalCredential").get("snmpV2cRead") + for item in snmpV2cRead: + if not have_snmpV2cRead or have_snmpV2cRead[have_snmpv2cread_ptr] is None: + if want.get("want_create").get("snmpV2cRead") is None: + want.get("want_create").update({"snmpV2cRead": []}) + create_credential = want.get("want_create").get("snmpV2cRead") + create_credential.append({}) + for i in range(0, 2): + if item.get(values[i]): + create_credential[create_snmpv2cread_ptr] \ + .update({keys[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + snmpV2cRead " + str(have_snmpv2cread_ptr) + self.status = "failed" + return self + create_snmpv2cread_ptr = create_snmpv2cread_ptr + 1 + else: + if want.get("want_update").get("snmpV2cRead") is None: + want.get("want_update").update({"snmpV2cRead": []}) + update_credential = want.get("want_update").get("snmpV2cRead") + update_credential.append({}) + if item.get("read_community"): + update_credential[update_snmpv2cread_ptr] \ + .update({"readCommunity": item.get("read_community")}) + else: + self.msg = "read_community is mandatory for updating \ + snmpV2cRead " + str(have_snmpv2cread_ptr) + self.status = "failed" + return self + for i in range(1, 3): + if item.get(values[i]): + update_credential[update_snmpv2cread_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_snmpv2cread_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("snmpV2cRead")[have_snmpv2cread_ptr].get(values[i]) + }) + update_snmpv2cread_ptr = update_snmpv2cread_ptr + 1 + have_snmpv2cread_ptr = have_snmpv2cread_ptr + 1 + + if CredentialDetails.get("snmp_v2c_write"): + snmpV2cWrite = CredentialDetails.get("snmp_v2c_write") + have_snmpv2cwrite_ptr = 0 + create_snmpv2cwrite_ptr = 0 + update_snmpv2cwrite_ptr = 0 + values = ["write_community", "description", "id"] + keys = ["writeCommunity", "description", "id"] + have_snmpV2cWrite = self.have.get("globalCredential").get("snmpV2cWrite") + for item in snmpV2cWrite: + if not have_snmpV2cWrite or have_snmpV2cWrite[have_snmpv2cwrite_ptr] is None: + if want.get("want_create").get("snmpV2cWrite") is None: + want.get("want_create").update({"snmpV2cWrite": []}) + create_credential = want.get("want_create").get("snmpV2cWrite") + create_credential.append({}) + for i in range(0, 2): + if item.get(values[i]): + create_credential[create_snmpv2cwrite_ptr] \ + .update({keys[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + snmpV2cWrite " + str(have_snmpv2cwrite_ptr) + self.status = "failed" + return self + create_snmpv2cwrite_ptr = create_snmpv2cwrite_ptr + 1 + else: + if want.get("want_update").get("snmpV2cWrite") is None: + want.get("want_update").update({"snmpV2cWrite": []}) + update_credential = want.get("want_update").get("snmpV2cWrite") + update_credential.append({}) + if item.get("write_community"): + update_credential[update_snmpv2cwrite_ptr] \ + .update({"writeCommunity": item.get("write_community")}) + else: + self.msg = "write_community is mandatory for updating \ + snmpV2cWrite " + str(have_snmpv2cwrite_ptr) + self.status = "failed" + return self + for i in range(1, 3): + if item.get(values[i]): + update_credential[update_snmpv2cwrite_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_snmpv2cwrite_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("snmpV2cWrite")[have_snmpv2cwrite_ptr].get(values[i]) + }) + update_snmpv2cwrite_ptr = update_snmpv2cwrite_ptr + 1 + have_snmpv2cwrite_ptr = have_snmpv2cwrite_ptr + 1 + + if CredentialDetails.get("https_read"): + httpsRead = CredentialDetails.get("https_read") + have_httpsread_ptr = 0 + create_httpsread_ptr = 0 + update_httpsread_ptr = 0 + values = ["password", "description", "username", "id", "port"] + have_httpsRead = self.have.get("globalCredential").get("httpsRead") + for item in httpsRead: + self.log("Global credentials details: {0}" + .format(self.have.get("globalCredential")), "DEBUG") + if not have_httpsRead or have_httpsRead[have_httpsread_ptr] is None: + if want.get("want_create").get("httpsRead") is None: + want.get("want_create").update({"httpsRead": []}) + create_credential = want.get("want_create").get("httpsRead") + create_credential.append({}) + for i in range(0, 3): + if item.get(values[i]): + create_credential[create_httpsread_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + httpsRead " + str(have_httpsread_ptr) + self.status = "failed" + return self + if item.get("port"): + create_credential[create_httpsread_ptr] \ + .update({"port": item.get("port")}) + else: + create_credential[create_httpsread_ptr] \ + .update({"port": "443"}) + create_httpsread_ptr = create_httpsread_ptr + 1 + else: + if want.get("want_update").get("httpsRead") is None: + want.get("want_update").update({"httpsRead": []}) + update_credential = want.get("want_update").get("httpsRead") + update_credential.append({}) + if item.get("password"): + update_credential[update_httpsread_ptr] \ + .update({"password": item.get("password")}) + else: + self.msg = "password is mandatory for updating \ + httpsRead " + str(have_httpsread_ptr) + self.status = "failed" + return self + for i in range(1, 5): + if item.get(values[i]): + update_credential[update_httpsread_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_httpsread_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("httpsRead")[have_httpsread_ptr].get(values[i]) + }) + update_httpsread_ptr = update_httpsread_ptr + 1 + have_httpsread_ptr = have_httpsread_ptr + 1 + + if CredentialDetails.get("https_write"): + httpsWrite = CredentialDetails.get("https_write") + have_httpswrite_ptr = 0 + create_httpswrite_ptr = 0 + update_httpswrite_ptr = 0 + values = ["password", "description", "username", "id", "port"] + have_httpsWrite = self.have.get("globalCredential").get("httpsWrite") + for item in httpsWrite: + if not have_httpsWrite or have_httpsWrite[have_httpswrite_ptr] is None: + if want.get("want_create").get("httpsWrite") is None: + want.get("want_create").update({"httpsWrite": []}) + create_credential = want.get("want_create").get("httpsWrite") + create_credential.append({}) + for i in range(0, 3): + if item.get(values[i]): + create_credential[create_httpswrite_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + httpsWrite " + str(have_httpswrite_ptr) + self.status = "failed" + return self + if item.get("port"): + create_credential[create_httpswrite_ptr] \ + .update({"port": item.get("port")}) + else: + create_credential[create_httpswrite_ptr] \ + .update({"port": "443"}) + create_httpswrite_ptr = create_httpswrite_ptr + 1 + else: + if want.get("want_update").get("httpsWrite") is None: + want.get("want_update").update({"httpsWrite": []}) + update_credential = want.get("want_update").get("httpsWrite") + update_credential.append({}) + if item.get("password"): + update_credential[update_httpswrite_ptr] \ + .update({"password": item.get("password")}) + else: + self.msg = "password is mandatory for updating \ + httpsRead " + str(have_httpswrite_ptr) + self.status = "failed" + return self + for i in range(1, 5): + if item.get(values[i]): + update_credential[update_httpswrite_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + update_credential[update_httpswrite_ptr].update({ + values[i]: self.have.get("globalCredential") + .get("httpsWrite")[have_httpswrite_ptr].get(values[i]) + }) + update_httpswrite_ptr = update_httpswrite_ptr + 1 + have_httpswrite_ptr = have_httpswrite_ptr + 1 + + if CredentialDetails.get("snmp_v3"): + snmpV3 = CredentialDetails.get("snmp_v3") + have_snmpv3_ptr = 0 + create_snmpv3_ptr = 0 + update_snmpv3_ptr = 0 + values = ["description", "username", "id"] + have_snmpV3 = self.have.get("globalCredential").get("snmpV3") + for item in snmpV3: + if not have_snmpV3 or have_snmpV3[have_snmpv3_ptr] is None: + if want.get("want_create").get("snmpV3") is None: + want.get("want_create").update({"snmpV3": []}) + create_credential = want.get("want_create").get("snmpV3") + create_credential.append({}) + for i in range(0, 2): + if item.get(values[i]): + create_credential[create_snmpv3_ptr] \ + .update({values[i]: item.get(values[i])}) + else: + self.msg = values[i] + " is mandatory for creating \ + snmpV3 " + str(have_snmpv3_ptr) + self.status = "failed" + return self + if item.get("snmp_mode"): + create_credential[create_snmpv3_ptr] \ + .update({"snmpMode": item.get("snmp_mode")}) + else: + create_credential[create_snmpv3_ptr] \ + .update({"snmpMode": "AUTHPRIV"}) + if create_credential[create_snmpv3_ptr].get("snmpMode") == "AUTHNOPRIV" or \ + create_credential[create_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + auths = ["auth_password", "auth_type"] + keys = { + "auth_password": "authPassword", + "auth_type": "authType" + } + for auth in auths: + if item.get(auth): + create_credential[create_snmpv3_ptr] \ + .update({keys[auth]: item.get(auth)}) + else: + self.msg = auth + " is mandatory for creating \ + snmpV3 " + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("auth_password")) < 8: + self.msg = "auth_password length should be greater than 8" + self.status = "failed" + return self + self.log("snmp_mode: {0}".format(create_credential[create_snmpv3_ptr] + .get("snmpMode")), "DEBUG") + if create_credential[create_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + privs = ["privacy_password", "privacy_type"] + key = { + "privacy_password": "privacyPassword", + "privacy_type": "privacyType" + } + for priv in privs: + if item.get(priv): + create_credential[create_snmpv3_ptr] \ + .update({key[priv]: item.get(priv)}) + else: + self.msg = priv + " is mandatory for creating \ + snmpV3 " + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("privacy_password")) < 8: + self.msg = "privacy_password should be greater than 8" + self.status = "failed" + return self + elif create_credential[create_snmpv3_ptr].get("snmpMode") != "NOAUTHNOPRIV": + self.msg = "snmp_mode in snmpV3 is not \ + ['AUTHPRIV', 'AUTHNOPRIV', 'NOAUTHNOPRIV']" + self.status = "failed" + return self + create_snmpv3_ptr = create_snmpv3_ptr + 1 + else: + if want.get("want_update").get("snmpV3") is None: + want.get("want_update").update({"snmpV3": []}) + update_credential = want.get("want_update").get("snmpV3") + update_credential.append({}) + for value in values: + if item.get(value): + update_credential[update_snmpv3_ptr] \ + .update({value: item.get(value)}) + else: + update_credential[update_snmpv3_ptr].update({ + value: self.have.get("globalCredential") + .get("snmpV3")[have_snmpv3_ptr].get(value) + }) + if item.get("snmp_mode"): + update_credential[update_snmpv3_ptr] \ + .update({"snmpMode": item.get("snmp_mode")}) + if update_credential[update_snmpv3_ptr].get("snmpMode") == "AUTHNOPRIV" or \ + update_credential[update_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + if item.get("auth_type"): + update_credential[update_snmpv3_ptr] \ + .update({"authType": item.get("auth_type")}) + elif self.have.get("globalCredential") \ + .get("snmpMode")[have_snmpv3_ptr].get("authType"): + update_credential[update_snmpv3_ptr].update({ + "authType": self.have.get("globalCredential") + .get("snmpMode")[have_snmpv3_ptr].get("authType") + }) + else: + self.msg = "auth_type is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if item.get("auth_password"): + update_credential[update_snmpv3_ptr] \ + .update({"authPassword": item.get("auth_password")}) + else: + self.msg = "auth_password is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("auth_password")) < 8: + self.msg = "auth_password length should be greater than 8" + self.status = "failed" + return self + elif update_credential[update_snmpv3_ptr].get("snmpMode") == "AUTHPRIV": + if item.get("privacy_type"): + update_credential[update_snmpv3_ptr] \ + .update({"privacyType": item.get("privacy_type")}) + elif self.have.get("globalCredential") \ + .get("snmpMode")[have_snmpv3_ptr].get("privacyType"): + update_credential[update_snmpv3_ptr].update({ + "privacyType": self.have.get("globalCredential") + .get("snmpMode")[have_snmpv3_ptr].get("privacyType") + }) + else: + self.msg = "privacy_type is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if item.get("privacy_password"): + update_credential[update_snmpv3_ptr] \ + .update({"privacyPassword": item.get("privacy_password")}) + else: + self.msg = "privacy_password is required for updating snmpV3 " + \ + str(have_snmpv3_ptr) + self.status = "failed" + return self + if len(item.get("privacy_password")) < 8: + self.msg = "privacy_password length should be greater than 8" + self.status = "failed" + return self + update_snmpv3_ptr = update_snmpv3_ptr + 1 + have_snmpv3_ptr = have_snmpv3_ptr + 1 + self.want.update(want) + self.msg = "Collected the Global Credentials from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_want_assign_credentials(self, AssignCredentials): + """ + Get the Credentials to be assigned to a site from the playbook. + Check this API using the check_return_status. + + Parameters: + AssignCredentials (dict) - Playbook details containing + credentials that need to be assigned to a site. + + Returns: + self - The current object with updated information of credentials + that need to be assigned to a site from the playbook. + """ + want = { + "assign_credentials": {} + } + site_name = AssignCredentials.get("site_name") + if not site_name: + self.msg = "site_name is required for AssignCredentials" + self.status = "failed" + return self + site_id = [] + for site_name in site_name: + siteId = self.get_site_id(site_name) + if not site_name: + self.msg = "site_name is invalid in AssignCredentials" + self.status = "failed" + return self + site_id.append(siteId) + want.update({"site_id": site_id}) + global_credentials = self.get_global_credentials_params() + cli_credential = AssignCredentials.get("cli_credential") + if cli_credential: + cliId = cli_credential.get("id") + cliDescription = cli_credential.get("description") + cliUsername = cli_credential.get("username") + + if cliId or cliDescription and cliUsername: + # All CLI details from the Cisco Catalyst Center + cli_details = global_credentials.get("cliCredential") + if not cli_details: + self.msg = "Global CLI credential is not available" + self.status = "failed" + return self + cliDetail = None + if cliId: + cliDetail = get_dict_result(cli_details, "id", cliId) + if not cliDetail: + self.msg = "The ID for the CLI credential is not valid." + self.status = "failed" + return self + elif cliDescription and cliUsername: + for item in cli_details: + if item.get("description") == cliDescription and \ + item.get("username") == cliUsername: + cliDetail = item + if not cliDetail: + self.msg = "The username and description of the CLI credential are invalid" + self.status = "failed" + return self + want.get("assign_credentials").update({"cliId": cliDetail.get("id")}) + + snmp_v2c_read = AssignCredentials.get("snmp_v2c_read") + if snmp_v2c_read: + snmpV2cReadId = snmp_v2c_read.get("id") + snmpV2cReadDescription = snmp_v2c_read.get("description") + if snmpV2cReadId or snmpV2cReadDescription: + + # All snmpV2cRead details from the Cisco Catalyst Center + snmpV2cRead_details = global_credentials.get("snmpV2cRead") + if not snmpV2cRead_details: + self.msg = "Global snmpV2cRead credential is not available" + self.status = "failed" + return self + snmpV2cReadDetail = None + if snmpV2cReadId: + snmpV2cReadDetail = get_dict_result(snmpV2cRead_details, "id", snmpV2cReadId) + if not snmpV2cReadDetail: + self.msg = "The ID of the snmpV2cRead credential is not valid." + self.status = "failed" + return self + elif snmpV2cReadDescription: + for item in snmpV2cRead_details: + if item.get("description") == snmpV2cReadDescription: + snmpV2cReadDetail = item + if not snmpV2cReadDetail: + self.msg = "The username and description for the snmpV2cRead credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"snmpV2ReadId": snmpV2cReadDetail.get("id")}) + + snmp_v2c_write = AssignCredentials.get("snmp_v2c_write") + if snmp_v2c_write: + snmpV2cWriteId = snmp_v2c_write.get("id") + snmpV2cWriteDescription = snmp_v2c_write.get("description") + if snmpV2cWriteId or snmpV2cWriteDescription: + + # All snmpV2cWrite details from the Cisco Catalyst Center + snmpV2cWrite_details = global_credentials.get("snmpV2cWrite") + if not snmpV2cWrite_details: + self.msg = "Global snmpV2cWrite Credential is not available" + self.status = "failed" + return self + snmpV2cWriteDetail = None + if snmpV2cWriteId: + snmpV2cWriteDetail = get_dict_result(snmpV2cWrite_details, "id", snmpV2cWriteId) + if not snmpV2cWriteDetail: + self.msg = "The ID of the snmpV2cWrite credential is invalid." + self.status = "failed" + return self + elif snmpV2cWriteDescription: + for item in snmpV2cWrite_details: + if item.get("description") == snmpV2cWriteDescription: + snmpV2cWriteDetail = item + if not snmpV2cWriteDetail: + self.msg = "The username and description of the snmpV2cWrite credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"snmpV2WriteId": snmpV2cWriteDetail.get("id")}) + + https_read = AssignCredentials.get("https_read") + if https_read: + httpReadId = https_read.get("id") + httpReadDescription = https_read.get("description") + httpReadUsername = https_read.get("username") + if httpReadId or httpReadDescription and httpReadUsername: + + # All httpRead details from the Cisco Catalyst Center + httpRead_details = global_credentials.get("httpsRead") + if not httpRead_details: + self.msg = "Global httpRead Credential is not available." + self.status = "failed" + return self + httpReadDetail = None + if httpReadId: + httpReadDetail = get_dict_result(httpRead_details, "id", httpReadId) + if not httpReadDetail: + self.msg = "The ID of the httpRead credential is not valid." + self.status = "failed" + return self + elif httpReadDescription and httpReadUsername: + for item in httpRead_details: + if item.get("description") == httpReadDescription and \ + item.get("username") == httpReadUsername: + httpReadDetail = item + if not httpReadDetail: + self.msg = "The description and username for the httpRead credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"httpRead": httpReadDetail.get("id")}) + + https_write = AssignCredentials.get("https_write") + if https_write: + httpWriteId = https_write.get("id") + httpWriteDescription = https_write.get("description") + httpWriteUsername = https_write.get("username") + if httpWriteId or httpWriteDescription and httpWriteUsername: + + # All httpWrite details from the Cisco Catalyst Center + httpWrite_details = global_credentials.get("httpsWrite") + if not httpWrite_details: + self.msg = "Global httpWrite credential is not available." + self.status = "failed" + return self + httpWriteDetail = None + if httpWriteId: + httpWriteDetail = get_dict_result(httpWrite_details, "id", httpWriteId) + if not httpWriteDetail: + self.msg = "The ID of the httpWrite credential is not valid." + self.status = "failed" + return self + elif httpWriteDescription and httpWriteUsername: + for item in httpWrite_details: + if item.get("description") == httpWriteDescription and \ + item.get("username") == httpWriteUsername: + httpWriteDetail = item + if not httpWriteDetail: + self.msg = "The description and username for the httpWrite credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"httpWrite": httpWriteDetail.get("id")}) + + snmp_v3 = AssignCredentials.get("snmp_v3") + if snmp_v3: + snmpV3Id = snmp_v3.get("id") + snmpV3Description = snmp_v3.get("description") + if snmpV3Id or snmpV3Description: + + # All snmpV3 details from the Cisco Catalyst Center + snmpV3_details = global_credentials.get("snmpV3") + if not snmpV3_details: + self.msg = "Global snmpV3 Credential is not available." + self.status = "failed" + return self + snmpV3Detail = None + if snmpV3Id: + snmpV3Detail = get_dict_result(snmpV3_details, "id", snmpV3Id) + if not snmpV3Detail: + self.msg = "The ID of the snmpV3 credential is not valid." + self.status = "failed" + return self + elif snmpV3Description: + for item in snmpV3_details: + if item.get("description") == snmpV3Description: + snmpV3Detail = item + if not snmpV3Detail: + self.msg = "The username and description for the snmpV2cWrite credential are invalid." + self.status = "failed" + return self + want.get("assign_credentials").update({"snmpV3Id": snmpV3Detail.get("id")}) + self.log("Desired State (want): {0}".format(want), "INFO") + self.want.update(want) + self.msg = "Collected the Credentials needed to be assigned from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_want(self, config): + """ + Get the current Global Device Credentials and Device + Credentials assigned to a site form the playbook. + + Parameters: + config (dict) - Playbook details containing Global Device + Credentials configurations and Device Credentials should + be assigned to a site. + + Returns: + self - The current object with updated information of Global + Device Credentials and Device Credentials assigned to a site. + """ + + if config.get("global_credential_details"): + CredentialDetails = config.get("global_credential_details") + self.get_want_device_credentials(CredentialDetails).check_return_status() + + if config.get("assign_credentials_to_site"): + AssignCredentials = config.get("assign_credentials_to_site") + self.get_want_assign_credentials(AssignCredentials).check_return_status() + + self.log("Desired State (want): {0}".format(self.want), "INFO") + self.msg = "Successfully retrieved details from the playbook" + self.status = "success" + return self + + def create_device_credentials(self): + """ + Create Global Device Credential to the Cisco Catalyst + Center based on the provided playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + self + + Returns: + self + """ + + result_global_credential = self.result.get("response")[0].get("globalCredential") + want_create = self.want.get("want_create") + if not want_create: + result_global_credential.update({ + "No Creation": { + "response": "No Response", + "msg": "No Creation is available" + } + }) + return self + + credential_params = want_create + self.log("Creating global credential API input parameters: {0}" + .format(credential_params), "DEBUG") + response = self.dnac._exec( + family="discovery", + function='create_global_credentials_v2', + params=credential_params, + ) + self.log("Received API response from 'create_global_credentials_v2': {0}" + .format(response), "DEBUG") + validation_string = "global credential addition performed" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Global credential created successfully", "INFO") + result_global_credential.update({ + "Creation": { + "response": credential_params, + "msg": "Global Credential Created Successfully" + } + }) + self.msg = "Global Device Credential Created Successfully" + self.status = "success" + return self + + def update_device_credentials(self): + """ + Update Device Credential to the Cisco Catalyst Center based on the provided playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + self + + Returns: + self + """ + + result_global_credential = self.result.get("response")[0].get("globalCredential") + + # Get the result global credential and want_update from the current object + want_update = self.want.get("want_update") + # If no credentials to update, update the result and return + if not want_update: + result_global_credential.update({ + "No Updation": { + "response": "No Response", + "msg": "No Updation is available" + } + }) + self.msg = "No Updation is available" + self.status = "success" + return self + i = 0 + flag = True + values = ["cliCredential", "snmpV2cRead", "snmpV2cWrite", + "httpsRead", "httpsWrite", "snmpV3"] + final_response = [] + self.log("Desired State for global device credentials updation: {0}" + .format(want_update), "DEBUG") + while flag: + flag = False + credential_params = {} + for value in values: + if want_update.get(value) and i < len(want_update.get(value)): + flag = True + credential_params.update({value: want_update.get(value)[i]}) + i = i + 1 + if credential_params: + final_response.append(credential_params) + response = self.dnac._exec( + family="discovery", + function='update_global_credentials_v2', + params=credential_params, + ) + self.log("Received API response for 'update_global_credentials_v2': {0}" + .format(response), "DEBUG") + validation_string = "global credential update performed" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Updating device credential API input parameters: {0}" + .format(final_response), "DEBUG") + self.log("Global device credential updated successfully", "INFO") + result_global_credential.update({ + "Updation": { + "response": final_response, + "msg": "Global Device Credential Updated Successfully" + } + }) + self.msg = "Global Device Credential Updated Successfully" + self.status = "success" + return self + + def assign_credentials_to_site(self): + """ + Assign Global Device Credential to the Cisco Catalyst + Center based on the provided playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + self + + Returns: + self + """ + + result_assign_credential = self.result.get("response")[0].get("assignCredential") + credential_params = self.want.get("assign_credentials") + final_response = [] + self.log("Assigning device credential to site API input parameters: {0}" + .format(credential_params), "DEBUG") + if not credential_params: + result_assign_credential.update({ + "No Assign Credentials": { + "response": "No Response", + "msg": "No Assignment is available" + } + }) + self.msg = "No Assignment is available" + self.status = "success" + return self + + site_ids = self.want.get("site_id") + for site_id in site_ids: + credential_params.update({"site_id": site_id}) + final_response.append(copy.deepcopy(credential_params)) + response = self.dnac._exec( + family="network_settings", + function='assign_device_credential_to_site_v2', + params=credential_params, + ) + self.log("Received API response for 'assign_device_credential_to_site_v2': {0}" + .format(response), "DEBUG") + validation_string = "desired common settings operation successful" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Device credential assigned to site {0} is successfully." + .format(site_ids), "INFO") + self.log("Desired State for assign credentials to a site: {0}" + .format(final_response), "DEBUG") + result_assign_credential.update({ + "Assign Credentials": { + "response": final_response, + "msg": "Device Credential Assigned to a site is Successfully" + } + }) + self.msg = "Global Credential is assigned Successfully" + self.status = "success" + return self + + def get_diff_merged(self, config): + """ + Update or Create Global Device Credential and assign device + credential to a site in Cisco Catalyst Center based on the playbook provided. + + Parameters: + config (list of dict) - Playbook details containing Global + Device Credential and assign credentials to a site information. + + Returns: + self + """ + + if config.get("global_credential_details") is not None: + self.create_device_credentials().check_return_status() + + if config.get("global_credential_details") is not None: + self.update_device_credentials().check_return_status() + + if config.get("assign_credentials_to_site") is not None: + self.assign_credentials_to_site().check_return_status() + + return self + + def delete_device_credential(self, config): + """ + Delete Global Device Credential in Cisco Catalyst Center based on the playbook details. + Check the return value of the API with check_return_status(). + + Parameters: + config (dict) - Playbook details containing Global Device Credential information. + self - The current object details. + + Returns: + self + """ + + result_global_credential = self.result.get("response")[0].get("globalCredential") + have_values = self.have.get("globalCredential") + final_response = {} + self.log("Global device credentials to be deleted: {0}".format(have_values), "DEBUG") + credential_mapping = { + "cliCredential": "cli_credential", + "snmpV2cRead": "snmp_v2c_read", + "snmpV2cWrite": "snmp_v2c_write", + "snmpV3": "snmp_v3", + "httpsRead": "https_read", + "httpsWrite": "https_write" + } + for item in have_values: + config_itr = 0 + final_response.update({item: []}) + for value in have_values.get(item): + if value is None: + self.log("Credential Name: {0}".format(item), "DEBUG") + self.log("Credential Item: {0}".format(config.get("global_credential_details") + .get(credential_mapping.get(item))), "DEBUG") + final_response.get(item).append( + str(config.get("global_credential_details") + .get(credential_mapping.get(item))[config_itr]) + " is not found." + ) + continue + _id = have_values.get(item)[config_itr].get("id") + response = self.dnac._exec( + family="discovery", + function="delete_global_credential_v2", + params={"id": _id}, + ) + self.log("Received API response for 'delete_global_credential_v2': {0}" + .format(response), "DEBUG") + validation_string = "global credential deleted successfully" + self.check_task_response_status(response, validation_string).check_return_status() + final_response.get(item).append(_id) + config_itr = config_itr + 1 + + self.log("Deleting device credential API input parameters: {0}" + .format(final_response), "DEBUG") + self.log("Successfully deleted global device credential.", "INFO") + result_global_credential.update({ + "Deletion": { + "response": final_response, + "msg": "Global Device Credentials Deleted Successfully" + } + }) + self.msg = "Global Device Credentials Updated Successfully" + self.status = "success" + return self + + def get_diff_deleted(self, config): + """ + Delete Global Device Credential in Cisco Catalyst Center based on the playbook details. + + Parameters: + config (dict) - Playbook details containing Global Device Credential information. + self - The current object details. + + Returns: + self + """ + + if config.get("global_credential_details") is not None: + self.delete_device_credential(config).check_return_status() + + return self + + def verify_diff_merged(self, config): + """ + Validating the Cisco Catalyst Center 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."), "DEBUG") + self.get_have(config) + self.get_want(config) + self.log("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + + 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 Cisco Catalyst Center" + 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 Cisco Catalyst Center".format(credential_type) + self.status = "failed" + return self + + self.log("Successfully validated global device credential", "INFO") + 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", "INFO") + 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 Cisco Catalyst Center 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + + 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.", "INFO") + 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 + + Parameters: + self + + Returns: + self + """ + + self.have.clear() + self.want.clear() + return self + + +def main(): + """main entry point for module execution""" + + # Define the specification for module arguments + element_spec = { + "dnac_host": {"type": 'str', "required": True}, + "dnac_port": {"type": 'str', "default": '443'}, + "dnac_username": {"type": 'str', "default": 'admin', "aliases": ['user']}, + "dnac_password": {"type": 'str', "no_log": True}, + "dnac_verify": {"type": 'bool', "default": 'True'}, + "dnac_version": {"type": 'str', "default": '2.2.3.3'}, + "dnac_debug": {"type": 'bool', "default": False}, + "dnac_log": {"type": 'bool', "default": False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + "config_verify": {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + "config": {"type": 'list', "required": True, "elements": 'dict'}, + "state": {"default": 'merged', "choices": ['merged', 'deleted']}, + "validate_response_schema": {"type": 'bool', "default": True}, + } + + # Create an AnsibleModule object with argument specifications + module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) + ccc_credential = DeviceCredential(module) + state = ccc_credential.params.get("state") + config_verify = ccc_credential.params.get("config_verify") + if state not in ccc_credential.supported_states: + ccc_credential.status = "invalid" + ccc_credential.msg = "State {0} is invalid".format(state) + ccc_credential.check_return_status() + + ccc_credential.validate_input().check_return_status() + + for config in ccc_credential.config: + ccc_credential.reset_values() + ccc_credential.get_have(config).check_return_status() + if state != "deleted": + ccc_credential.get_want(config).check_return_status() + ccc_credential.get_diff_state_apply[state](config).check_return_status() + if config_verify: + ccc_credential.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_credential.result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/discovery_intent.py b/ansible_collections/cisco/dnac/plugins/modules/discovery_intent.py new file mode 100644 index 000000000..96759bb9c --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/discovery_intent.py @@ -0,0 +1,1713 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Abinash Mishra, Phan Nguyen, Madhan Sankaranarayanan") + +DOCUMENTATION = r""" +--- +module: discovery_intent +short_description: A resource module for handling device discovery tasks. +description: +- Manages device discovery using IP address, address range, CDP, and LLDP, including deletion of discovered devices. +- API to discover a device or multiple devices +- API to delete a discovery of a device or multiple devices +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.intent_params +author: Abinash Mishra (@abimishr) + Phan Nguyen (@phannguy) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of device being managed. + type: list + elements: dict + required: true + suboptions: + discovery_name: + description: Name of the discovery task + type: str + required: true + discovery_type: + description: Determines the method of device discovery. Here are the available options. + - SINGLE discovers a single device using a single IP address. + - RANGE discovers multiple devices within a single IP address range. + - MULTI RANGE discovers devices across multiple IP address ranges. + - CDP uses Cisco Discovery Protocol to discover devices in subsequent layers of the given IP address. + - LLDP uses Link Layer Discovery Protocol to discover devices in subsequent layers of the specified IP address. + - CIDR discovers devices based on subnet filtering using Classless Inter-Domain Routing. + type: str + required: true + choices: [ 'SINGLE', 'RANGE', 'MULTI RANGE', 'CDP', 'LLDP', 'CIDR'] + ip_address_list: + description: List of IP addresses to be discovered. For CDP/LLDP/SINGLE based discovery, we should + pass a list with single element like - 10.197.156.22. For CIDR based discovery, we should pass a list with + single element like - 10.197.156.22/22. For RANGE based discovery, we should pass a list with single element + and range like - 10.197.156.1-10.197.156.100. For MULTI RANGE based discovery, we should pass a list with multiple + elementd like - 10.197.156.1-10.197.156.100 and in next line - 10.197.157.1-10.197.157.100. + type: list + elements: str + required: true + ip_filter_list: + description: List of IP adddrsess that needs to get filtered out from the IP addresses passed. + type: list + elements: str + cdp_level: + description: Total number of levels that are there in cdp's method of discovery + type: int + default: 16 + lldp_level: + description: Total number of levels that are there in lldp's method of discovery + type: int + default: 16 + preferred_mgmt_ip_method: + description: Preferred method for the management of the IP (None/UseLoopBack) + type: str + default: None + use_global_credentials: + description: + - Determines if device discovery should utilize pre-configured global credentials. + - Setting to True employs the predefined global credentials for discovery tasks. This is the default setting. + - Setting to False requires manually provided, device-specific credentials for discovery, as global credentials will be bypassed. + type: bool + default: True + discovery_specific_credentials: + description: Credentials specifically created by the user for performing device discovery. + type: dict + suboptions: + cli_credentials_list: + description: List of CLI credentials to be used during device discovery. + type: list + elements: dict + suboptions: + username: + description: Username for CLI authentication, mandatory when using CLI credentials. + type: str + password: + description: Password for CLI authentication, mandatory when using CLI credential. + type: str + enable_password: + description: Enable password for CLI authentication, mandatory when using CLI credential. + type: str + http_read_credential: + description: HTTP read credential is used for authentication purposes and specifically utilized to + grant read-only access to certain resources from the device. + type: dict + suboptions: + username: + description: Username for HTTP(S) Read authentication, mandatory when using HTTP credentials. + type: str + password: + description: Password for HTTP(S) Read authentication, mandatory when using HTTP credentials. + type: str + port: + description: Port for HTTP(S) Read authentication, mandatory for using HTTP credentials. + type: int + secure: + description: Flag for HTTP(S) Read authentication, not mandatory when using HTTP credentials. + type: bool + http_write_credential: + description: HTTP write credential is used for authentication purposes and grants Cisco Catalyst Center the + ability to alter configurations, update software, or perform other modifications on a network device. + type: dict + suboptions: + username: + description: Username for HTTP(S) Write authentication, mandatory when using HTTP credentials. + type: str + password: + description: Password for HTTP(S) Write authentication, mandatory when using HTTP credentials. + type: str + port: + description: Port for HTTP(S) Write authentication, mandatory when using HTTP credentials. + type: int + secure: + description: Flag for HTTP(S) Write authentication, not mandatory when using HTTP credentials. + type: bool + snmp_v2_read_credential: + description: + - The SNMP v2 credentials to be created and used for contacting a device via SNMP protocol in read mode. + - SNMP v2 also delivers data encryptions, but it uses data types. + type: dict + suboptions: + desc: + description: Name/Description of the SNMP read credential to be used for creation of snmp_v2_read_credential. + type: str + community: + description: SNMP V2 Read community string enables Cisco Catalyst Center to extract read-only data from device. + type: str + snmp_v2_write_credential: + description: + - The SNMP v2 credentials to be created and used for contacting a device via SNMP protocol in read and write mode. + - SNMP v2 also delivers data encryptions, but it uses data types. + type: dict + suboptions: + desc: + description: Name/Description of the SNMP write credential to be used for creation of snmp_v2_write_credential. + type: str + community: + description: SNMP V2 Write community string is used to extract data and alter device configurations. + type: str + snmp_v3_credential: + description: + - The SNMP v3 credentials to be created and used for contacting a device via SNMP protocol in read and write mode. + - SNMPv3 is the most secure version of SNMP, allowing users to fully encrypt transmissions, keeping us safe from external attackers. + type: dict + suboptions: + username: + description: Username of the SNMP v3 protocol to be used. + type: str + snmp_mode: + description: + - Mode of SNMP which determines the encryption level of our community string. + - AUTHPRIV mode uses both Authentication and Encryption. + - AUTHNOPRIV mode uses Authentication but no Encryption. + - NOAUTHNOPRIV mode does not use either Authentication or Encryption. + type: str + choices: [ 'AUTHPRIV', 'AUTHNOPRIV', 'NOAUTHNOPRIV' ] + auth_password: + description: + - Authentication Password of the SNMP v3 protocol to be used. + - Must be of length greater than 7 characters. + - Not required for NOAUTHNOPRIV snmp_mode. + type: str + auth_type: + description: + - Authentication type of the SNMP v3 protocol to be used. + - SHA uses Secure Hash Algorithm (SHA) as your authentication protocol. + - MD5 uses Message Digest 5 (MD5) as your authentication protocol and is not recommended. + - Not required for NOAUTHNOPRIV snmp_mode. + type: str + choices: [ 'SHA', 'MD5' ] + privacy_type: + description: + - Privacy type/protocol of the SNMP v3 protocol to be used in AUTHPRIV SNMP mode + - Not required for AUTHNOPRIV and NOAUTHNOPRIV snmp_mode. + type: str + choices: [ 'AES128', 'AES192', 'AES256' ] + privacy_password: + description: + - Privacy password of the SNMP v3 protocol to be used in AUTHPRIV SNMP mode + - Not required for AUTHNOPRIV and NOAUTHNOPRIV snmp_mode. + type: str + net_conf_port: + description: + - To be used when network contains IOS XE-based wireless controllers. + - This is used for discovery and the enabling of wireless services on the controllers. + - Requires valid SSH credentials to work. + - Avoid standard ports like 22, 80, and 8080. + type: str + global_credentials: + description: + - Set of various credential types, including CLI, SNMP, HTTP, and NETCONF, that a user has pre-configured in + the Device Credentials section of the Cisco Catalyst Center. + - If user doesn't pass any global credentials in the playbook, then by default, we will use all the global + credentials present in the Cisco Catalyst Center of each type for performing discovery. (Max 5 allowed) + type: dict + version_added: 6.12.0 + suboptions: + cli_credentials_list: + description: + - Accepts a list of global CLI credentials for use in device discovery. + - It's recommended to create device credentials with both a unique username and a clear description. + type: list + elements: dict + suboptions: + username: + description: Username required for CLI authentication and is mandatory when using global CLI credentials. + type: str + description: + description: Name of the CLI credential, mandatory when using global CLI credentials. + type: str + http_read_credential_list: + description: + - List of global HTTP Read credentials that will be used in the process of discovering devices. + - It's recommended to create device credentials with both a unique username and a clear description for easy identification. + type: list + elements: dict + suboptions: + username: + description: Username for HTTP Read authentication, mandatory when using global HTTP credentials. + type: str + description: + description: Name of the HTTP Read credential, mandatory when using global HTTP credentials. + type: str + http_write_credential_list: + description: + - List of global HTTP Write credentials that will be used in the process of discovering devices. + - It's recommended to create device credentials with both a unique username and a clear description for easy identification. + type: list + elements: dict + suboptions: + username: + description: Username for HTTP Write authentication, mandatory when using global HTTP credentials. + type: str + description: + description: Name of the HTTP Write credential, mandatory when using global HTTP credentials. + type: str + snmp_v2_read_credential_list: + description: + - List of Global SNMP V2 Read credentials to be used during device discovery. + - It's recommended to create device credentials with a clear description for easy identification. + type: list + elements: dict + suboptions: + description: + description: Name of the SNMP Read credential, mandatory when using global SNMP credentials. + type: str + snmp_v2_write_credential_list: + description: + - List of Global SNMP V2 Write credentials to be used during device discovery. + - It's recommended to create device credentials with a clear description for easy identification. + type: list + elements: dict + suboptions: + description: + description: Name of the SNMP Write credential, mandatory when using global SNMP credentials. + type: str + snmp_v3_credential_list: + description: + - List of Global SNMP V3 credentials to be used during device discovery, giving read and write mode. + - It's recommended to create device credentials with both a unique username and a clear description for easy identification. + type: list + elements: dict + suboptions: + username: + description: Username for SNMP V3 authentication, mandatory when using global SNMP credentials. + type: str + description: + description: Name of the SNMP V3 credential, mandatory when using global SNMP credentials. + type: str + net_conf_port_list: + description: + - List of Global Net conf ports to be used during device discovery. + - It's recommended to create device credentials with unique description. + type: list + elements: dict + suboptions: + description: + description: Name of the Net Conf Port credential, mandatory when using global Net conf port. + type: str + start_index: + description: Start index for the header in fetching SNMP v2 credentials + type: int + default: 1 + records_to_return: + description: Number of records to return for the header in fetching global v2 credentials + type: int + default: 100 + protocol_order: + description: Determines the order in which device connections will be attempted. Here are the options + - "telnet" Only telnet connections will be tried. + - "ssh, telnet" SSH (Secure Shell) will be attempted first, followed by telnet if SSH fails. + type: str + default: ssh + retry: + description: Number of times to try establishing connection to device + type: int + timeout: + description: Time to wait for device response in seconds + type: int + delete_all: + description: Parameter to delete all the discoveries at one go + type: bool + default: False +requirements: +- dnacentersdk == 2.6.10 +- python >= 3.5 +notes: + - SDK Method used are + discovery.Discovery.get_all_global_credentials_v2, + discovery.Discovery.start_discovery, + task.Task.get_task_by_id, + discovery.Discovery.get_discoveries_by_range, + discovery.Discovery.get_discovered_network_devices_by_discovery_id', + discovery.Discovery.delete_discovery_by_id + discovery.Discovery.delete_all_discovery + discovery.Discovery.get_count_of_all_discovery_jobs + + - Paths used are + get /dna/intent/api/v2/global-credential + post /dna/intent/api/v1/discovery + get /dna/intent/api/v1/task/{taskId} + get /dna/intent/api/v1/discovery/{startIndex}/{recordsToReturn} + get /dna/intent/api/v1/discovery/{id}/network-device + delete /dna/intent/api/v1/discovery/{id} + delete /dna/intent/api/v1/delete + get /dna/intent/api/v1/discovery/count + + - Removed 'global_cli_len' option in v6.12.0. + +""" + +EXAMPLES = r""" +- name: Execute discovery of devices with both global credentials and discovery specific credentials + 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 + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: Discovery with both global and job specific credentials + discovery_type: RANGE + ip_address_list: + - 201.1.1.1-201.1.1.100 + ip_filter_list: + - 201.1.1.2 + - 201.1.1.10 + discovery_specific_credentials: + cli_credentials_list: + - username: cisco + password: Cisco123 + enable_password: Cisco123 + http_read_credential: + username: cisco + password: Cisco123 + port: 443 + secure: true + http_write_credential: + username: cisco + password: Cisco123 + port: 443 + secure: True + snmp_v2_read_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v2_write_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v3_credential: + username: v3Public2 + snmp_mode: AUTHPRIV + auth_type: SHA + auth_password: Lablab123 + privacy_type: AES256 + privacy_password: Lablab123 + net_conf_port: 750 + global_credentials: + cli_credentials_list: + - description: ISE + username: cisco + - description: CLI1234 + username: cli + http_read_credential_list: + - description: HTTP Read + username: HTTP_Read + http_write_credential_list: + - description: HTTP Write + username: HTTP_Write + snmp_v3_credential_list: + - description: snmpV3 + username: snmpV3 + snmp_v2_read_credential_list: + - description: snmpV2_read + snmp_v2_write_credential_list: + - description: snmpV2_write + net_conf_port_list: + - description: Old_one + start_index: 1 + records_to_return: 100 + protocol_order: ssh + retry: 5 + timeout: 3 + +- name: Execute discovery of devices with discovery specific credentials only + 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 + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: Single with discovery specific credentials only + discovery_type: SINGLE + ip_address_list: + - 204.1.1.10 + discovery_specific_credentials: + cli_credentials_list: + - username: cisco + password: Cisco123 + enable_password: Cisco123 + http_read_credential: + username: cisco + password: Cisco123 + port: 443 + secure: true + http_write_credential: + username: cisco + password: Cisco123 + port: 443 + secure: True + snmp_v2_read_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v2_write_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v3_credential: + username: v3Public2 + snmp_mode: AUTHPRIV + auth_type: SHA + auth_password: Lablab123 + privacy_type: AES256 + privacy_password: Lablab123 + net_conf_port: 750 + use_global_credentials: False + start_index: 1 + records_to_return: 100 + protocol_order: ssh + retry: 5 + timeout: 3 + +- name: Execute discovery of devices with global credentials only + 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 + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: CDP with global credentials only + discovery_type: CDP + ip_address_list: + - 204.1.1.1 + cdp_level: 16 + global_credentials: + cli_credentials_list: + - description: ISE + username: cisco + - description: CLI1234 + username: cli + http_read_credential_list: + - description: HTTP Read + username: HTTP_Read + http_write_credential_list: + - description: HTTP Write + username: HTTP_Write + snmp_v3_credential_list: + - description: snmpV3 + username: snmpV3 + snmp_v2_read_credential_list: + - description: snmpV2_read + snmp_v2_write_credential_list: + - description: snmpV2_write + net_conf_port_list: + - description: Old_one + start_index: 1 + records_to_return: 100 + protocol_order: ssh + retry: 5 + timeout: 3 + +- name: Execute discovery of devices with all the global credentials (max 5 allowed) + 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 + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: CIDR with all global credentials + discovery_type: CIDR + ip_address_list: + - 204.1.2.0/24 + ip_filter_list: + - 204.1.2.10 + preferred_mgmt_ip_method: None + start_index: 1 + records_to_return: 100 + protocol_order: telnet + retry: 10 + timeout: 3 + use_global_credentials: True + +- 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 + dnac_log_level: "{{dnac_log_level}}" + state: deleted + config_verify: True + config: + - discovery_name: Single discovery +""" + +RETURN = r""" +#Case_1: When the device(s) are discovered successfully. +response_1: + description: A dictionary with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": + { + "response": String, + "version": String + }, + "msg": String + } + +#Case_2: Given device details or SNMP mode are not provided +response_2: + description: A list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } + +#Case_3: Error while deleting a discovery +response_3: + description: A string with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": String, + "msg": String + } +""" +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts +) +import time +import re + + +class Discovery(DnacBase): + def __init__(self, module): + """ + Initialize an instance of the class. It also initializes an empty + list for 'creds_ids_list' attribute. + + Parameters: + - module: The module associated with the class instance. + + Returns: + The method does not return a value. Instead, it initializes the + following instance attributes: + - self.creds_ids_list: An empty list that will be used to store + credentials IDs. + """ + + super().__init__(module) + self.creds_ids_list = [] + + def validate_input(self, state=None): + """ + Validate the fields provided in the playbook. Checks the + configuration provided in the playbook against a predefined + specification to ensure it adheres to the expected structure + and data types. + + Returns: + The method returns an instance of the class with updated attributes: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the + 'config' parameter. + Example: + To use this method, create an instance of the class and call + 'validate_input' on it.If the validation succeeds, 'self.status' + will be 'success'and 'self.validated_config' will contain the + validated configuration. If it fails, 'self.status' will be + 'failed', and 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + discovery_spec = { + 'cdp_level': {'type': 'int', 'required': False, + 'default': 16}, + 'start_index': {'type': 'int', 'required': False, + 'default': 1}, + 'records_to_return': {'type': 'int', 'required': False, + 'default': 100}, + 'discovery_specific_credentials': {'type': 'dict', 'required': False}, + 'ip_filter_list': {'type': 'list', 'required': False, + 'elements': 'str'}, + 'lldp_level': {'type': 'int', 'required': False, + 'default': 16}, + 'discovery_name': {'type': 'str', 'required': True}, + 'netconf_port': {'type': 'str', 'required': False}, + 'preferred_mgmt_ip_method': {'type': 'str', 'required': False, + 'default': 'None'}, + 'retry': {'type': 'int', 'required': False}, + 'timeout': {'type': 'str', 'required': False}, + 'global_credentials': {'type': 'dict', 'required': False}, + 'protocol_order': {'type': 'str', 'required': False, 'default': 'ssh'}, + 'use_global_credentials': {'type': 'bool', 'required': False, 'default': True} + } + + if state == "merged": + discovery_spec["ip_address_list"] = {'type': 'list', 'required': True, + 'elements': 'str'} + discovery_spec["discovery_type"] = {'type': 'str', 'required': True} + + elif state == "deleted": + if self.config[0].get("delete_all") is True: + self.validated_config = [{"delete_all": True}] + self.msg = "Sucessfully collected input for deletion of all the discoveries" + self.log(self.msg, "WARNING") + return self + + # Validate discovery params + valid_discovery, invalid_params = validate_list_of_dicts( + self.config, discovery_spec + ) + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format( + "\n".join(invalid_params)) + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_discovery + self.msg = "Successfully validated playbook configuration parameters using 'validate_input': {0}".format(str(valid_discovery)) + self.log(str(self.msg), "INFO") + self.status = "success" + return self + + def get_creds_ids_list(self): + """ + Retrieve the list of credentials IDs associated with class instance. + + Returns: + The method returns the list of credentials IDs: + - self.creds_ids_list: The list of credentials IDs associated with + the class instance. + """ + + self.log("Credential Ids list passed is {0}".format(str(self.creds_ids_list)), "INFO") + return self.creds_ids_list + + def handle_global_credentials(self, response=None): + """ + Method to convert values for create_params API when global paramters + are passed as input. + + Parameters: + - response: The response collected from the get_all_global_credentials_v2 API + + Returns: + - global_credentials_all : The dictionary containing list of IDs of various types of + Global credentials. + """ + + global_credentials = self.validated_config[0].get("global_credentials") + global_credentials_all = {} + + cli_credentials_list = global_credentials.get('cli_credentials_list') + if cli_credentials_list: + if not isinstance(cli_credentials_list, list): + msg = "Global CLI credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("cliCredential") is None: + msg = "Global CLI credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(cli_credentials_list) > 0: + global_credentials_all["cliCredential"] = [] + cred_len = len(cli_credentials_list) + if cred_len > 5: + cred_len = 5 + for cli_cred in cli_credentials_list: + if cli_cred.get('description') and cli_cred.get('username'): + for cli in response.get("cliCredential"): + if cli.get("description") == cli_cred.get('description') and cli.get("username") == cli_cred.get('username'): + global_credentials_all["cliCredential"].append(cli.get("id")) + global_credentials_all["cliCredential"] = global_credentials_all["cliCredential"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global CLI credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + http_read_credential_list = global_credentials.get('http_read_credential_list') + if http_read_credential_list: + if not isinstance(http_read_credential_list, list): + msg = "Global HTTP read credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("httpsRead") is None: + msg = "Global HTTP read credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(http_read_credential_list) > 0: + global_credentials_all["httpsRead"] = [] + cred_len = len(http_read_credential_list) + if cred_len > 5: + cred_len = 5 + for http_cred in http_read_credential_list: + if http_cred.get('description') and http_cred.get('username'): + for http in response.get("httpsRead"): + if http.get("description") == http.get('description') and http.get("username") == http.get('username'): + global_credentials_all["httpsRead"].append(http.get("id")) + global_credentials_all["httpsRead"] = global_credentials_all["httpsRead"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global HTTP Read credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + http_write_credential_list = global_credentials.get('http_write_credential_list') + if http_write_credential_list: + if not isinstance(http_write_credential_list, list): + msg = "Global HTTP write credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("httpsWrite") is None: + msg = "Global HTTP write credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(http_write_credential_list) > 0: + global_credentials_all["httpsWrite"] = [] + cred_len = len(http_write_credential_list) + if cred_len > 5: + cred_len = 5 + for http_cred in http_write_credential_list: + if http_cred.get('description') and http_cred.get('username'): + for http in response.get("httpsWrite"): + if http.get("description") == http.get('description') and http.get("username") == http.get('username'): + global_credentials_all["httpsWrite"].append(http.get("id")) + global_credentials_all["httpsWrite"] = global_credentials_all["httpsWrite"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global HTTP Write credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + snmp_v2_read_credential_list = global_credentials.get('snmp_v2_read_credential_list') + if snmp_v2_read_credential_list: + if not isinstance(snmp_v2_read_credential_list, list): + msg = "Global SNMPv2 read credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("snmpV2cRead") is None: + msg = "Global SNMPv2 read credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(snmp_v2_read_credential_list) > 0: + global_credentials_all["snmpV2cRead"] = [] + cred_len = len(snmp_v2_read_credential_list) + if cred_len > 5: + cred_len = 5 + for snmp_cred in snmp_v2_read_credential_list: + if snmp_cred.get('description'): + for snmp in response.get("snmpV2cRead"): + if snmp.get("description") == snmp_cred.get('description'): + global_credentials_all["snmpV2cRead"].append(snmp.get("id")) + global_credentials_all["snmpV2cRead"] = global_credentials_all["snmpV2cRead"][:cred_len] + else: + msg = "Kindly ensure you include the description for the Global SNMPv2 Read \ + credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + snmp_v2_write_credential_list = global_credentials.get('snmp_v2_write_credential_list') + if snmp_v2_write_credential_list: + if not isinstance(snmp_v2_write_credential_list, list): + msg = "Global SNMPv2 write credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("snmpV2cWrite") is None: + msg = "Global SNMPv2 write credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(snmp_v2_write_credential_list) > 0: + global_credentials_all["snmpV2cWrite"] = [] + cred_len = len(snmp_v2_write_credential_list) + if cred_len > 5: + cred_len = 5 + for snmp_cred in snmp_v2_write_credential_list: + if snmp_cred.get('description'): + for snmp in response.get("snmpV2cWrite"): + if snmp.get("description") == snmp_cred.get('description'): + global_credentials_all["snmpV2cWrite"].append(snmp.get("id")) + global_credentials_all["snmpV2cWrite"] = global_credentials_all["snmpV2cWrite"][:cred_len] + else: + msg = "Kindly ensure you include the description for the Global SNMPV2 write credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + snmp_v3_credential_list = global_credentials.get('snmp_v3_credential_list') + if snmp_v3_credential_list: + if not isinstance(snmp_v3_credential_list, list): + msg = "Global SNMPv3 write credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("snmpV3") is None: + msg = "Global SNMPv3 credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(snmp_v3_credential_list) > 0: + global_credentials_all["snmpV3"] = [] + cred_len = len(snmp_v3_credential_list) + if cred_len > 5: + cred_len = 5 + for snmp_cred in snmp_v3_credential_list: + if snmp_cred.get('description') and snmp_cred.get('username'): + for snmp in response.get("snmpV3"): + if snmp.get("description") == snmp_cred.get('description') and snmp.get("username") == snmp_cred.get('username'): + global_credentials_all["snmpV3"].append(snmp.get("id")) + global_credentials_all["snmpV3"] = global_credentials_all["snmpV3"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global SNMPv3 \ + to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + net_conf_port_list = global_credentials.get('net_conf_port_list') + if net_conf_port_list: + if not isinstance(net_conf_port_list, list): + msg = "Global net Conf Ports be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("netconfCredential") is None: + msg = "Global netconf ports are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(net_conf_port_list) > 0: + global_credentials_all["netconfCredential"] = [] + cred_len = len(net_conf_port_list) + if cred_len > 5: + cred_len = 5 + for port in net_conf_port_list: + if port.get("description"): + for netconf in response.get("netconfCredential"): + if port.get('description') == netconf.get('description'): + global_credentials_all["netconfCredential"].append(netconf.get("id")) + global_credentials_all["netconfCredential"] = global_credentials_all["netconfCredential"][:cred_len] + else: + msg = "Please provide valid description of the Global Netconf port to be used" + self.discovery_specific_cred_failure(msg=msg) + + self.log("Fetched Global credentials IDs are {0}".format(global_credentials_all), "INFO") + return global_credentials_all + + def get_ccc_global_credentials_v2_info(self): + """ + Retrieve the global credentials information (version 2). + It applies the 'get_all_global_credentials_v2' function and extracts + the IDs of the credentials. If no credentials are found, the + function fails with a message. + + Returns: + This method does not return a value. However, updates the attributes: + - self.creds_ids_list: The list of credentials IDs is extended with + the IDs extracted from the response. + - self.result: A dictionary that is updated with the credentials IDs. + """ + + response = self.dnac_apply['exec']( + family="discovery", + function='get_all_global_credentials_v2', + params=self.validated_config[0].get('headers'), + ) + response = response.get('response') + self.log("The Global credentials response from 'get all global credentials v2' API is {0}".format(str(response)), "DEBUG") + global_credentials_all = {} + global_credentials = self.validated_config[0].get("global_credentials") + if global_credentials: + global_credentials_all = self.handle_global_credentials(response=response) + + global_cred_set = set(global_credentials_all.keys()) + response_cred_set = set(response.keys()) + diff_keys = response_cred_set.difference(global_cred_set) + + for key in diff_keys: + global_credentials_all[key] = [] + if response[key] is None: + response[key] = [] + total_len = len(response[key]) + if total_len > 5: + total_len = 5 + for element in response.get(key): + global_credentials_all[key].append(element.get('id')) + global_credentials_all[key] = global_credentials_all[key][:total_len] + + if global_credentials_all == {}: + msg = 'Not found any global credentials to perform discovery' + self.log(msg, "WARNING") + + return global_credentials_all + + def get_devices_list_info(self): + """ + Retrieve the list of devices from the validated configuration. + It then updates the result attribute with this list. + + Returns: + - ip_address_list: The list of devices extracted from the + 'validated_config' attribute. + """ + ip_address_list = self.validated_config[0].get('ip_address_list') + self.result.update(dict(devices_info=ip_address_list)) + self.log("Details of the device list passed: {0}".format(str(ip_address_list)), "INFO") + return ip_address_list + + def preprocess_device_discovery(self, ip_address_list=None): + """ + Preprocess the devices' information. Extract the IP addresses from + the list of devices and perform additional processing based on the + 'discovery_type' in the validated configuration. + + Parameters: + - ip_address_list: The list of devices' IP addresses intended for preprocessing. + If not provided, an empty list will be used. + + Returns: + - ip_address_list: It returns IP address list for the API to process. The value passed + for single, CDP, LLDP, CIDR, Range and Multi Range varies depending + on the need. + """ + + if ip_address_list is None: + ip_address_list = [] + discovery_type = self.validated_config[0].get('discovery_type') + self.log("Discovery type passed for the discovery is {0}".format(discovery_type), "INFO") + if discovery_type in ["SINGLE", "CDP", "LLDP"]: + if len(ip_address_list) == 1: + ip_address_list = ip_address_list[0] + else: + self.preprocess_device_discovery_handle_error() + elif discovery_type == "CIDR": + if len(ip_address_list) == 1: + cidr_notation = ip_address_list[0] + if len(cidr_notation.split("/")) == 2: + ip_address_list = cidr_notation + else: + ip_address_list = "{0}/30".format(cidr_notation) + self.log("CIDR notation is being used for discovery and it requires a prefix length to be specified, such as 1.1.1.1/24.\ + As no prefix length was provided, it will default to 30.", "WARNING") + else: + self.preprocess_device_discovery_handle_error() + elif discovery_type == "RANGE": + if len(ip_address_list) == 1: + if len(str(ip_address_list[0]).split("-")) == 2: + ip_address_list = ip_address_list[0] + else: + ip_address_list = "{0}-{1}".format(ip_address_list[0], ip_address_list[0]) + else: + self.preprocess_device_discovery_handle_error() + else: + new_ip_collected = [] + for ip in ip_address_list: + if len(str(ip).split("-")) != 2: + ip_collected = "{0}-{0}".format(ip) + new_ip_collected.append(ip_collected) + else: + new_ip_collected.append(ip) + ip_address_list = ','.join(new_ip_collected) + self.log("Collected IP address/addresses are {0}".format(str(ip_address_list)), "INFO") + return str(ip_address_list) + + def preprocess_device_discovery_handle_error(self): + """ + Method for failing discovery based on the length of list of IP Addresses passed + for performing discovery. + """ + + self.log("IP Address list's length is longer than 1", "ERROR") + self.module.fail_json(msg="IP Address list's length is longer than 1", response=[]) + + def discovery_specific_cred_failure(self, msg=None): + """ + Method for failing discovery if there is any discrepancy in the credentials + passed by the user + """ + + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + + def handle_discovery_specific_credentials(self, new_object_params=None): + """ + Method to convert values for create_params API when discovery specific paramters + are passed as input. + + Parameters: + - new_object_params: The dictionary storing various parameters for calling the + start discovery API + + Returns: + - new_object_params: The dictionary storing various parameters for calling the + start discovery API in an updated fashion + """ + + discovery_specific_credentials = self.validated_config[0].get('discovery_specific_credentials') + cli_credentials_list = discovery_specific_credentials.get('cli_credentials_list') + http_read_credential = discovery_specific_credentials.get('http_read_credential') + http_write_credential = discovery_specific_credentials.get('http_write_credential') + snmp_v2_read_credential = discovery_specific_credentials.get('snmp_v2_read_credential') + snmp_v2_write_credential = discovery_specific_credentials.get('snmp_v2_write_credential') + snmp_v3_credential = discovery_specific_credentials.get('snmp_v3_credential') + net_conf_port = discovery_specific_credentials.get('net_conf_port') + + if cli_credentials_list: + if not isinstance(cli_credentials_list, list): + msg = "Device Specific ClI credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if len(cli_credentials_list) > 0: + username_list = [] + password_list = [] + enable_password_list = [] + for cli_cred in cli_credentials_list: + if cli_cred.get('username') and cli_cred.get('password') and cli_cred.get('enable_password'): + username_list.append(cli_cred.get('username')) + password_list.append(cli_cred.get('password')) + enable_password_list.append(cli_cred.get('enable_password')) + else: + msg = "username, password and enable_password must be passed toether for creating CLI credentials" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['userNameList'] = username_list + new_object_params['passwordList'] = password_list + new_object_params['enablePasswordList'] = enable_password_list + + if http_read_credential: + if not (http_read_credential.get('password') and isinstance(http_read_credential.get('password'), str)): + msg = "The password for the HTTP read credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_read_credential.get('username') and isinstance(http_read_credential.get('username'), str)): + msg = "The username for the HTTP read credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_read_credential.get('port') and isinstance(http_read_credential.get('port'), int)): + msg = "The port for the HTTP read Credential must be of integer type." + self.discovery_specific_cred_failure(msg=msg) + if not isinstance(http_read_credential.get('secure'), bool): + msg = "Secure for HTTP read Credential must be of type boolean." + self.discovery_specific_cred_failure(msg=msg) + new_object_params['httpReadCredential'] = http_read_credential + + if http_write_credential: + if not (http_write_credential.get('password') and isinstance(http_write_credential.get('password'), str)): + msg = "The password for the HTTP write credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_write_credential.get('username') and isinstance(http_write_credential.get('username'), str)): + msg = "The username for the HTTP write credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_write_credential.get('port') and isinstance(http_write_credential.get('port'), int)): + msg = "The port for the HTTP write Credential must be of integer type." + self.discovery_specific_cred_failure(msg=msg) + if not isinstance(http_write_credential.get('secure'), bool): + msg = "Secure for HTTP write Credential must be of type boolean." + self.discovery_specific_cred_failure(msg=msg) + new_object_params['httpWriteCredential'] = http_write_credential + + if snmp_v2_read_credential: + if not (snmp_v2_read_credential.get('desc')) and isinstance(snmp_v2_read_credential.get('desc'), str): + msg = "Name/description for the SNMP v2 read credential must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v2_read_credential.get('community')) and isinstance(snmp_v2_read_credential.get('community'), str): + msg = "The community string must be of string type" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['snmpROCommunityDesc'] = snmp_v2_read_credential.get('desc') + new_object_params['snmpROCommunity'] = snmp_v2_read_credential.get('community') + new_object_params['snmpVersion'] = "v2" + + if snmp_v2_write_credential: + if not (snmp_v2_write_credential.get('desc')) and isinstance(snmp_v2_write_credential.get('desc'), str): + msg = "Name/description for the SNMP v2 write credential must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v2_write_credential.get('community')) and isinstance(snmp_v2_write_credential.get('community'), str): + msg = "The community string must be of string type" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['snmpRWCommunityDesc'] = snmp_v2_write_credential.get('desc') + new_object_params['snmpRWCommunity'] = snmp_v2_write_credential.get('community') + new_object_params['snmpVersion'] = "v2" + + if snmp_v3_credential: + if not (snmp_v3_credential.get('username')) and isinstance(snmp_v3_credential.get('username'), str): + msg = "Username of SNMP v3 protocol must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v3_credential.get('snmp_mode')) and isinstance(snmp_v3_credential.get('snmp_mode'), str): + msg = "Mode of SNMP is madantory to use SNMPv3 protocol and must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if (snmp_v3_credential.get('snmp_mode')) == "AUTHPRIV" or snmp_v3_credential.get('snmp_mode') == "AUTHNOPRIV": + if not (snmp_v3_credential.get('auth_password')) and isinstance(snmp_v3_credential.get('auth_password'), str): + msg = "Authorization password must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v3_credential.get('auth_type')) and isinstance(snmp_v3_credential.get('auth_type'), str): + msg = "Authorization type must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if snmp_v3_credential.get('snmp_mode') == "AUTHPRIV": + if not (snmp_v3_credential.get('privacy_type')) and isinstance(snmp_v3_credential.get('privacy_type'), str): + msg = "Privacy type must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v3_credential.get('privacy_password')) and isinstance(snmp_v3_credential.get('privacy_password'), str): + msg = "Privacy password must be of string type" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['snmpUserName'] = snmp_v3_credential.get('username') + new_object_params['snmpMode'] = snmp_v3_credential.get('snmp_mode') + new_object_params['snmpAuthPassphrase'] = snmp_v3_credential.get('auth_password') + new_object_params['snmpAuthProtocol'] = snmp_v3_credential.get('auth_type') + new_object_params['snmpPrivProtocol'] = snmp_v3_credential.get('privacy_type') + new_object_params['snmpPrivPassphrase'] = snmp_v3_credential.get('privacy_password') + new_object_params['snmpVersion'] = "v3" + + if net_conf_port: + new_object_params['netconfPort'] = str(net_conf_port) + + return new_object_params + + def create_params(self, ip_address_list=None): + """ + Create a new parameter object based on the validated configuration, + credential IDs, and IP address list. + + Parameters: + - credential_ids: The list of credential IDs to include in the + parameters. If not provided, an empty list is used. + - ip_address_list: The list of IP addresses to include in the + parameters. If not provided, None is used. + + Returns: + - new_object_params: A dictionary containing the newly created + parameters. + """ + + credential_ids = [] + + new_object_params = {} + new_object_params['cdpLevel'] = self.validated_config[0].get('cdp_level') + new_object_params['discoveryType'] = self.validated_config[0].get('discovery_type') + new_object_params['ipAddressList'] = ip_address_list + new_object_params['ipFilterList'] = self.validated_config[0].get('ip_filter_list') + new_object_params['lldpLevel'] = self.validated_config[0].get('lldp_level') + new_object_params['name'] = self.validated_config[0].get('discovery_name') + new_object_params['preferredMgmtIPMethod'] = self.validated_config[0].get('preferred_mgmt_ip_method') + new_object_params['protocolOrder'] = self.validated_config[0].get('protocol_order') + new_object_params['retry'] = self.validated_config[0].get('retry') + new_object_params['timeout'] = self.validated_config[0].get('timeout') + + if self.validated_config[0].get('discovery_specific_credentials'): + self.handle_discovery_specific_credentials(new_object_params=new_object_params) + + global_cred_flag = self.validated_config[0].get('use_global_credentials') + global_credentials_all = {} + + if global_cred_flag is True: + global_credentials_all = self.get_ccc_global_credentials_v2_info() + for global_cred_list in global_credentials_all.values(): + credential_ids.extend(global_cred_list) + new_object_params['globalCredentialIdList'] = credential_ids + + self.log("All the global credentials used for the discovery task are {0}".format(str(global_credentials_all)), "DEBUG") + + if not (new_object_params.get('snmpUserName') or new_object_params.get('snmpROCommunityDesc') or new_object_params.get('snmpRWCommunityDesc') + or global_credentials_all.get('snmpV2cRead') or global_credentials_all.get('snmpV2cWrite') or global_credentials_all.get('snmpV3')): + msg = "Please provide atleast one valid SNMP credential to perform Discovery" + self.discovery_specific_cred_failure(msg=msg) + + if not (new_object_params.get('userNameList') or global_credentials_all.get('cliCredential')): + msg = "Please provide atleast one valid CLI credential to perform Discovery" + self.discovery_specific_cred_failure(msg=msg) + + self.log("The payload/object created for calling the start discovery API is {0}".format(str(new_object_params)), "INFO") + + return new_object_params + + def create_discovery(self, ip_address_list=None): + """ + Start a new discovery process in the Cisco Catalyst Center. It creates the + parameters required for the discovery and then calls the + 'start_discovery' function. The result of the discovery process + is added to the 'result' attribute. + + Parameters: + - credential_ids: The list of credential IDs to include in the + discovery. If not provided, an empty list is used. + - ip_address_list: The list of IP addresses to include in the + discovery. If not provided, None is used. + + Returns: + - task_id: The ID of the task created for the discovery process. + """ + + result = self.dnac_apply['exec']( + family="discovery", + function="start_discovery", + params=self.create_params(ip_address_list=ip_address_list), + op_modifies=True, + ) + + self.log("The response received post discovery creation API called is {0}".format(str(result)), "DEBUG") + + self.result.update(dict(discovery_result=result)) + self.log("Task Id of the API task created is {0}".format(result.response.get('taskId')), "INFO") + return result.response.get('taskId') + + def get_task_status(self, task_id=None): + """ + Monitor the status of a task in the Cisco Catalyst Center. It checks the task + status periodically until the task is no longer 'In Progress'. + If the task encounters an error or fails, it immediately fails the + module and returns False. + + Parameters: + - task_id: The ID of the task to monitor. + + Returns: + - result: True if the task completed successfully, False otherwise. + """ + + result = False + params = dict(task_id=task_id) + while True: + response = self.dnac_apply['exec']( + family="task", + function='get_task_by_id', + params=params, + ) + response = response.response + self.log("Task status for the task id {0} is {1}".format(str(task_id), str(response)), "INFO") + if response.get('isError') or re.search( + 'failed', response.get('progress'), flags=re.IGNORECASE + ): + msg = 'Discovery task with id {0} has not completed - Reason: {1}'.format( + task_id, response.get("failureReason")) + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + return False + self.log("Task status for the task id (before checking status) {0} is {1}".format(str(task_id), str(response)), "INFO") + progress = response.get('progress') + if progress in ('In Progress', 'Inventory service initiating discovery'): + time.sleep(3) + continue + else: + result = True + self.log("The Process is completed", "INFO") + break + self.result.update(dict(discovery_task=response)) + return result + + def lookup_discovery_by_range_via_name(self): + """ + Retrieve a specific discovery by name from a range of + discoveries in the Cisco Catalyst Center. + + Returns: + - discovery: The discovery with the specified name from the range + of discoveries. If no matching discovery is found, it + returns None. + """ + start_index = self.validated_config[0].get("start_index") + records_to_return = self.validated_config[0].get("records_to_return") + + response = {"response": []} + if records_to_return > 500: + num_intervals = records_to_return // 500 + for num in range(0, num_intervals + 1): + params = dict( + start_index=1 + num * 500, + records_to_return=500, + headers=self.validated_config[0].get("headers") + ) + response_part = self.dnac_apply['exec']( + family="discovery", + function='get_discoveries_by_range', + params=params + ) + response["response"].extend(response_part["response"]) + else: + params = dict( + start_index=self.validated_config[0].get("start_index"), + records_to_return=self.validated_config[0].get("records_to_return"), + headers=self.validated_config[0].get("headers"), + ) + + response = self.dnac_apply['exec']( + family="discovery", + function='get_discoveries_by_range', + params=params + ) + self.log("Response of the get discoveries via range API is {0}".format(str(response)), "DEBUG") + + return next( + filter( + lambda x: x['name'] == self.validated_config[0].get('discovery_name'), + response.get("response") + ), None + ) + + def get_discoveries_by_range_until_success(self): + """ + Continuously retrieve a specific discovery by name from a range of + discoveries in the Cisco Catalyst Center until the discovery is complete. + + Returns: + - discovery: The completed discovery with the specified name from + the range of discoveries. If the discovery is not + found or not completed, the function fails the module + and returns None. + """ + + result = False + discovery = self.lookup_discovery_by_range_via_name() + + if not discovery: + msg = 'Cannot find any discovery task with name {0} -- Discovery result: {1}'.format( + str(self.validated_config[0].get("discovery_name")), str(discovery)) + self.log(msg, "INFO") + self.module.fail_json(msg=msg) + + while True: + discovery = self.lookup_discovery_by_range_via_name() + if discovery.get('discoveryCondition') == 'Complete': + result = True + break + + time.sleep(3) + + if not result: + msg = 'Cannot find any discovery task with name {0} -- Discovery result: {1}'.format( + str(self.validated_config[0].get("discovery_name")), str(discovery)) + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + + self.result.update(dict(discovery_range=discovery)) + return discovery + + def get_discovery_device_info(self, discovery_id=None, task_id=None): + """ + Retrieve the information of devices discovered by a specific discovery + process in the Cisco Catalyst Center. It checks the reachability status of the + devices periodically until all devices are reachable or until a + maximum of 3 attempts. + + Parameters: + - discovery_id: ID of the discovery process to retrieve devices from. + - task_id: ID of the task associated with the discovery process. + + Returns: + - result: True if all devices are reachable, False otherwise. + """ + + params = dict( + id=discovery_id, + task_id=task_id, + headers=self.validated_config[0].get("headers"), + ) + result = False + count = 0 + while True: + response = self.dnac_apply['exec']( + family="discovery", + function='get_discovered_network_devices_by_discovery_id', + params=params, + ) + devices = response.response + + self.log("Retrieved device details using the API 'get_discovered_network_devices_by_discovery_id': {0}".format(str(devices)), "DEBUG") + if all(res.get('reachabilityStatus') == 'Success' for res in devices): + result = True + self.log("All devices in the range are reachable", "INFO") + break + + elif any(res.get('reachabilityStatus') == 'Success' for res in devices): + result = True + self.log("Some devices in the range are reachable", "INFO") + break + + elif all(res.get('reachabilityStatus') != 'Success' for res in devices): + result = True + self.log("All devices are not reachable, but discovery is completed", "WARNING") + break + + count += 1 + if count == 3: + break + + time.sleep(3) + + if not result: + msg = 'Discovery network device with id {0} has not completed'.format(discovery_id) + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + + self.log('Discovery network device with id {0} got completed'.format(discovery_id), "INFO") + self.result.update(dict(discovery_device_info=devices)) + return result + + def get_exist_discovery(self): + """ + Retrieve an existing discovery by its name from a range of discoveries. + + Returns: + - discovery: The discovery with the specified name from the range of + discoveries. If no matching discovery is found, it + 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)) + return None + + have = dict(exist_discovery=discovery) + self.have = have + self.result.update(dict(exist_discovery=discovery)) + return discovery + + def delete_exist_discovery(self, params): + """ + Delete an existing discovery in the Cisco Catalyst Center by its ID. + + Parameters: + - params: A dictionary containing the parameters for the delete + operation, including the ID of the discovery to delete. + + Returns: + - task_id: The ID of the task created for the delete operation. + """ + + response = self.dnac_apply['exec']( + family="discovery", + function="delete_discovery_by_id", + params=params, + ) + + self.log("Response collected from API 'delete_discovery_by_id': {0}".format(str(response)), "DEBUG") + self.result.update(dict(delete_discovery=response)) + self.log("Task Id of the deletion task is {0}".format(response.response.get('taskId')), "INFO") + return response.response.get('taskId') + + def get_diff_merged(self): + """ + Retrieve the information of devices discovered by a specific discovery + process in the Cisco Catalyst Center, delete existing discoveries if they exist, + and create a new discovery. The function also updates various + attributes of the class instance. + + Returns: + - self: The instance of the class with updated attributes. + """ + + devices_list_info = self.get_devices_list_info() + ip_address_list = self.preprocess_device_discovery(devices_list_info) + exist_discovery = self.get_exist_discovery() + if exist_discovery: + params = dict(id=exist_discovery.get('id')) + discovery_task_id = self.delete_exist_discovery(params=params) + complete_discovery = self.get_task_status(task_id=discovery_task_id) + + discovery_task_id = self.create_discovery( + ip_address_list=ip_address_list) + complete_discovery = self.get_task_status(task_id=discovery_task_id) + discovery_task_info = self.get_discoveries_by_range_until_success() + result = self.get_discovery_device_info(discovery_id=discovery_task_info.get('id')) + self.result["changed"] = True + self.result['msg'] = "Discovery Created Successfully" + self.result['diff'] = self.validated_config + self.result['response'] = discovery_task_id + self.result.update(dict(msg='Discovery Created Successfully')) + self.log(self.result['msg'], "INFO") + return self + + def get_diff_deleted(self): + """ + Delete an existing discovery in the Cisco Catalyst Center by its name, and + updates various attributes of the class instance. If no + discovery with the specified name is found, the function + updates the 'msg' attribute with an appropriate message. + + Returns: + - self: The instance of the class with updated attributes. + """ + + if self.validated_config[0].get("delete_all"): + count_discoveries = self.dnac_apply['exec']( + family="discovery", + function="get_count_of_all_discovery_jobs", + ) + if count_discoveries.get("response") == 0: + msg = "There are no discoveries present in the Discovery Dashboard for deletion" + self.result['msg'] = msg + self.log(msg, "WARNING") + self.result['response'] = self.validated_config[0] + return self + + delete_all_response = self.dnac_apply['exec']( + family="discovery", + function="delete_all_discovery", + ) + discovery_task_id = delete_all_response.get('response').get('taskId') + self.result["changed"] = True + self.result['msg'] = "All of the Discoveries Deleted Successfully" + self.result['diff'] = self.validated_config + + else: + exist_discovery = self.get_exist_discovery() + if not exist_discovery: + self.result['msg'] = "Discovery {0} Not Found".format( + self.validated_config[0].get("discovery_name")) + self.log(self.result['msg'], "ERROR") + return self + + params = dict(id=exist_discovery.get('id')) + discovery_task_id = self.delete_exist_discovery(params=params) + complete_discovery = self.get_task_status(task_id=discovery_task_id) + self.result["changed"] = True + self.result['msg'] = "Successfully deleted discovery" + self.result['diff'] = self.validated_config + self.result['response'] = discovery_task_id + + self.log(self.result['msg'], "INFO") + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of Discovery in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center 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 + ) + discovery_name = config.get('discovery_name') + if response: + self.log("Requested Discovery with name {0} is completed".format(discovery_name), "INFO") + + else: + self.log("Requested Discovery with name {0} is not completed".format(discovery_name), "WARNING") + self.status = "success" + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of Discovery in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified discovery(s) exists in the Cisco Catalyst Center configuration's + Discovery Database. + """ + + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center config for deleted state + if config.get("delete_all") is True: + count_discoveries = self.dnac_apply['exec']( + family="discovery", + function="get_count_of_all_discovery_jobs", + ) + if count_discoveries == 0: + self.log("All discoveries are deleted", "INFO") + else: + self.log("All discoveries are not deleted", "WARNING") + self.status = "success" + return self + + discovery_task_info = self.lookup_discovery_by_range_via_name() + discovery_name = config.get('discovery_name') + if discovery_task_info: + self.log("Requested Discovery with name {0} is present".format(discovery_name), "WARNING") + + else: + self.log("Requested Discovery with name {0} is not present and deleted".format(discovery_name), "INFO") + self.status = "success" + + return self + + +def main(): + """ main entry point for module execution + """ + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + + ccc_discovery = Discovery(module) + config_verify = ccc_discovery.params.get("config_verify") + + state = ccc_discovery.params.get("state") + if state not in ccc_discovery.supported_states: + ccc_discovery.status = "invalid" + ccc_discovery.msg = "State {0} is invalid".format(state) + ccc_discovery.check_return_status() + + ccc_discovery.validate_input(state=state).check_return_status() + for config in ccc_discovery.validated_config: + ccc_discovery.reset_values() + ccc_discovery.get_diff_state_apply[state]().check_return_status() + if config_verify: + ccc_discovery.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_discovery.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/discovery_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/discovery_workflow_manager.py new file mode 100644 index 000000000..88ce124a3 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/discovery_workflow_manager.py @@ -0,0 +1,1713 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Abinash Mishra, Phan Nguyen, Madhan Sankaranarayanan") + +DOCUMENTATION = r""" +--- +module: discovery_workflow_manager +short_description: A resource module for handling device discovery tasks. +description: +- Manages device discovery using IP address, address range, CDP, and LLDP, including deletion of discovered devices. +- API to discover a device or multiple devices +- API to delete a discovery of a device or multiple devices +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Abinash Mishra (@abimishr) + Phan Nguyen (@phannguy) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of device being managed. + type: list + elements: dict + required: true + suboptions: + discovery_name: + description: Name of the discovery task + type: str + required: true + discovery_type: + description: Determines the method of device discovery. Here are the available options. + - SINGLE discovers a single device using a single IP address. + - RANGE discovers multiple devices within a single IP address range. + - MULTI RANGE discovers devices across multiple IP address ranges. + - CDP uses Cisco Discovery Protocol to discover devices in subsequent layers of the given IP address. + - LLDP uses Link Layer Discovery Protocol to discover devices in subsequent layers of the specified IP address. + - CIDR discovers devices based on subnet filtering using Classless Inter-Domain Routing. + type: str + required: true + choices: [ 'SINGLE', 'RANGE', 'MULTI RANGE', 'CDP', 'LLDP', 'CIDR'] + ip_address_list: + description: List of IP addresses to be discovered. For CDP/LLDP/SINGLE based discovery, we should + pass a list with single element like - 10.197.156.22. For CIDR based discovery, we should pass a list with + single element like - 10.197.156.22/22. For RANGE based discovery, we should pass a list with single element + and range like - 10.197.156.1-10.197.156.100. For MULTI RANGE based discovery, we should pass a list with multiple + elementd like - 10.197.156.1-10.197.156.100 and in next line - 10.197.157.1-10.197.157.100. + type: list + elements: str + required: true + ip_filter_list: + description: List of IP adddrsess that needs to get filtered out from the IP addresses passed. + type: list + elements: str + cdp_level: + description: Total number of levels that are there in cdp's method of discovery + type: int + default: 16 + lldp_level: + description: Total number of levels that are there in lldp's method of discovery + type: int + default: 16 + preferred_mgmt_ip_method: + description: Preferred method for the management of the IP (None/UseLoopBack) + type: str + default: None + use_global_credentials: + description: + - Determines if device discovery should utilize pre-configured global credentials. + - Setting to True employs the predefined global credentials for discovery tasks. This is the default setting. + - Setting to False requires manually provided, device-specific credentials for discovery, as global credentials will be bypassed. + type: bool + default: True + discovery_specific_credentials: + description: Credentials specifically created by the user for performing device discovery. + type: dict + suboptions: + cli_credentials_list: + description: List of CLI credentials to be used during device discovery. + type: list + elements: dict + suboptions: + username: + description: Username for CLI authentication, mandatory when using CLI credentials. + type: str + password: + description: Password for CLI authentication, mandatory when using CLI credential. + type: str + enable_password: + description: Enable password for CLI authentication, mandatory when using CLI credential. + type: str + http_read_credential: + description: HTTP read credential is used for authentication purposes and specifically utilized to + grant read-only access to certain resources from the device. + type: dict + suboptions: + username: + description: Username for HTTP(S) Read authentication, mandatory when using HTTP credentials. + type: str + password: + description: Password for HTTP(S) Read authentication, mandatory when using HTTP credentials. + type: str + port: + description: Port for HTTP(S) Read authentication, mandatory for using HTTP credentials. + type: int + secure: + description: Flag for HTTP(S) Read authentication, not mandatory when using HTTP credentials. + type: bool + http_write_credential: + description: HTTP write credential is used for authentication purposes and grants Cisco Catalyst Center the + ability to alter configurations, update software, or perform other modifications on a network device. + type: dict + suboptions: + username: + description: Username for HTTP(S) Write authentication, mandatory when using HTTP credentials. + type: str + password: + description: Password for HTTP(S) Write authentication, mandatory when using HTTP credentials. + type: str + port: + description: Port for HTTP(S) Write authentication, mandatory when using HTTP credentials. + type: int + secure: + description: Flag for HTTP(S) Write authentication, not mandatory when using HTTP credentials. + type: bool + snmp_v2_read_credential: + description: + - The SNMP v2 credentials to be created and used for contacting a device via SNMP protocol in read mode. + - SNMP v2 also delivers data encryptions, but it uses data types. + type: dict + suboptions: + desc: + description: Name/Description of the SNMP read credential to be used for creation of snmp_v2_read_credential. + type: str + community: + description: SNMP V2 Read community string enables Cisco Catalyst Center to extract read-only data from device. + type: str + snmp_v2_write_credential: + description: + - The SNMP v2 credentials to be created and used for contacting a device via SNMP protocol in read and write mode. + - SNMP v2 also delivers data encryptions, but it uses data types. + type: dict + suboptions: + desc: + description: Name/Description of the SNMP write credential to be used for creation of snmp_v2_write_credential. + type: str + community: + description: SNMP V2 Write community string is used to extract data and alter device configurations. + type: str + snmp_v3_credential: + description: + - The SNMP v3 credentials to be created and used for contacting a device via SNMP protocol in read and write mode. + - SNMPv3 is the most secure version of SNMP, allowing users to fully encrypt transmissions, keeping us safe from external attackers. + type: dict + suboptions: + username: + description: Username of the SNMP v3 protocol to be used. + type: str + snmp_mode: + description: + - Mode of SNMP which determines the encryption level of our community string. + - AUTHPRIV mode uses both Authentication and Encryption. + - AUTHNOPRIV mode uses Authentication but no Encryption. + - NOAUTHNOPRIV mode does not use either Authentication or Encryption. + type: str + choices: [ 'AUTHPRIV', 'AUTHNOPRIV', 'NOAUTHNOPRIV' ] + auth_password: + description: + - Authentication Password of the SNMP v3 protocol to be used. + - Must be of length greater than 7 characters. + - Not required for NOAUTHNOPRIV snmp_mode. + type: str + auth_type: + description: + - Authentication type of the SNMP v3 protocol to be used. + - SHA uses Secure Hash Algorithm (SHA) as your authentication protocol. + - MD5 uses Message Digest 5 (MD5) as your authentication protocol and is not recommended. + - Not required for NOAUTHNOPRIV snmp_mode. + type: str + choices: [ 'SHA', 'MD5' ] + privacy_type: + description: + - Privacy type/protocol of the SNMP v3 protocol to be used in AUTHPRIV SNMP mode + - Not required for AUTHNOPRIV and NOAUTHNOPRIV snmp_mode. + type: str + choices: [ 'AES128', 'AES192', 'AES256' ] + privacy_password: + description: + - Privacy password of the SNMP v3 protocol to be used in AUTHPRIV SNMP mode + - Not required for AUTHNOPRIV and NOAUTHNOPRIV snmp_mode. + type: str + net_conf_port: + description: + - To be used when network contains IOS XE-based wireless controllers. + - This is used for discovery and the enabling of wireless services on the controllers. + - Requires valid SSH credentials to work. + - Avoid standard ports like 22, 80, and 8080. + type: str + global_credentials: + description: + - Set of various credential types, including CLI, SNMP, HTTP, and NETCONF, that a user has pre-configured in + the Device Credentials section of the Cisco Catalyst Center. + - If user doesn't pass any global credentials in the playbook, then by default, we will use all the global + credentials present in the Cisco Catalyst Center of each type for performing discovery. (Max 5 allowed) + type: dict + version_added: 6.12.0 + suboptions: + cli_credentials_list: + description: + - Accepts a list of global CLI credentials for use in device discovery. + - It's recommended to create device credentials with both a unique username and a clear description. + type: list + elements: dict + suboptions: + username: + description: Username required for CLI authentication and is mandatory when using global CLI credentials. + type: str + description: + description: Name of the CLI credential, mandatory when using global CLI credentials. + type: str + http_read_credential_list: + description: + - List of global HTTP Read credentials that will be used in the process of discovering devices. + - It's recommended to create device credentials with both a unique username and a clear description for easy identification. + type: list + elements: dict + suboptions: + username: + description: Username for HTTP Read authentication, mandatory when using global HTTP credentials. + type: str + description: + description: Name of the HTTP Read credential, mandatory when using global HTTP credentials. + type: str + http_write_credential_list: + description: + - List of global HTTP Write credentials that will be used in the process of discovering devices. + - It's recommended to create device credentials with both a unique username and a clear description for easy identification. + type: list + elements: dict + suboptions: + username: + description: Username for HTTP Write authentication, mandatory when using global HTTP credentials. + type: str + description: + description: Name of the HTTP Write credential, mandatory when using global HTTP credentials. + type: str + snmp_v2_read_credential_list: + description: + - List of Global SNMP V2 Read credentials to be used during device discovery. + - It's recommended to create device credentials with a clear description for easy identification. + type: list + elements: dict + suboptions: + description: + description: Name of the SNMP Read credential, mandatory when using global SNMP credentials. + type: str + snmp_v2_write_credential_list: + description: + - List of Global SNMP V2 Write credentials to be used during device discovery. + - It's recommended to create device credentials with a clear description for easy identification. + type: list + elements: dict + suboptions: + description: + description: Name of the SNMP Write credential, mandatory when using global SNMP credentials. + type: str + snmp_v3_credential_list: + description: + - List of Global SNMP V3 credentials to be used during device discovery, giving read and write mode. + - It's recommended to create device credentials with both a unique username and a clear description for easy identification. + type: list + elements: dict + suboptions: + username: + description: Username for SNMP V3 authentication, mandatory when using global SNMP credentials. + type: str + description: + description: Name of the SNMP V3 credential, mandatory when using global SNMP credentials. + type: str + net_conf_port_list: + description: + - List of Global Net conf ports to be used during device discovery. + - It's recommended to create device credentials with unique description. + type: list + elements: dict + suboptions: + description: + description: Name of the Net Conf Port credential, mandatory when using global Net conf port. + type: str + start_index: + description: Start index for the header in fetching SNMP v2 credentials + type: int + default: 1 + records_to_return: + description: Number of records to return for the header in fetching global v2 credentials + type: int + default: 100 + protocol_order: + description: Determines the order in which device connections will be attempted. Here are the options + - "telnet" Only telnet connections will be tried. + - "ssh, telnet" SSH (Secure Shell) will be attempted first, followed by telnet if SSH fails. + type: str + default: ssh + retry: + description: Number of times to try establishing connection to device + type: int + timeout: + description: Time to wait for device response in seconds + type: int + delete_all: + description: Parameter to delete all the discoveries at one go + type: bool + default: False +requirements: +- dnacentersdk == 2.6.10 +- python >= 3.5 +notes: + - SDK Method used are + discovery.Discovery.get_all_global_credentials_v2, + discovery.Discovery.start_discovery, + task.Task.get_task_by_id, + discovery.Discovery.get_discoveries_by_range, + discovery.Discovery.get_discovered_network_devices_by_discovery_id', + discovery.Discovery.delete_discovery_by_id + discovery.Discovery.delete_all_discovery + discovery.Discovery.get_count_of_all_discovery_jobs + + - Paths used are + get /dna/intent/api/v2/global-credential + post /dna/intent/api/v1/discovery + get /dna/intent/api/v1/task/{taskId} + get /dna/intent/api/v1/discovery/{startIndex}/{recordsToReturn} + get /dna/intent/api/v1/discovery/{id}/network-device + delete /dna/intent/api/v1/discovery/{id} + delete /dna/intent/api/v1/delete + get /dna/intent/api/v1/discovery/count + + - Removed 'global_cli_len' option in v6.12.0. + +""" + +EXAMPLES = r""" +- name: Execute discovery of devices with both global credentials and discovery specific credentials + cisco.dnac.discovery_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: Discovery with both global and job specific credentials + discovery_type: RANGE + ip_address_list: + - 201.1.1.1-201.1.1.100 + ip_filter_list: + - 201.1.1.2 + - 201.1.1.10 + discovery_specific_credentials: + cli_credentials_list: + - username: cisco + password: Cisco123 + enable_password: Cisco123 + http_read_credential: + username: cisco + password: Cisco123 + port: 443 + secure: true + http_write_credential: + username: cisco + password: Cisco123 + port: 443 + secure: True + snmp_v2_read_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v2_write_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v3_credential: + username: v3Public2 + snmp_mode: AUTHPRIV + auth_type: SHA + auth_password: Lablab123 + privacy_type: AES256 + privacy_password: Lablab123 + net_conf_port: 750 + global_credentials: + cli_credentials_list: + - description: ISE + username: cisco + - description: CLI1234 + username: cli + http_read_credential_list: + - description: HTTP Read + username: HTTP_Read + http_write_credential_list: + - description: HTTP Write + username: HTTP_Write + snmp_v3_credential_list: + - description: snmpV3 + username: snmpV3 + snmp_v2_read_credential_list: + - description: snmpV2_read + snmp_v2_write_credential_list: + - description: snmpV2_write + net_conf_port_list: + - description: Old_one + start_index: 1 + records_to_return: 100 + protocol_order: ssh + retry: 5 + timeout: 3 + +- name: Execute discovery of devices with discovery specific credentials only + cisco.dnac.discovery_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: Single with discovery specific credentials only + discovery_type: SINGLE + ip_address_list: + - 204.1.1.10 + discovery_specific_credentials: + cli_credentials_list: + - username: cisco + password: Cisco123 + enable_password: Cisco123 + http_read_credential: + username: cisco + password: Cisco123 + port: 443 + secure: true + http_write_credential: + username: cisco + password: Cisco123 + port: 443 + secure: True + snmp_v2_read_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v2_write_credential: + desc: snmp_v2-new + community: Cisco123 + snmp_v3_credential: + username: v3Public2 + snmp_mode: AUTHPRIV + auth_type: SHA + auth_password: Lablab123 + privacy_type: AES256 + privacy_password: Lablab123 + net_conf_port: 750 + use_global_credentials: False + start_index: 1 + records_to_return: 100 + protocol_order: ssh + retry: 5 + timeout: 3 + +- name: Execute discovery of devices with global credentials only + cisco.dnac.discovery_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: CDP with global credentials only + discovery_type: CDP + ip_address_list: + - 204.1.1.1 + cdp_level: 16 + global_credentials: + cli_credentials_list: + - description: ISE + username: cisco + - description: CLI1234 + username: cli + http_read_credential_list: + - description: HTTP Read + username: HTTP_Read + http_write_credential_list: + - description: HTTP Write + username: HTTP_Write + snmp_v3_credential_list: + - description: snmpV3 + username: snmpV3 + snmp_v2_read_credential_list: + - description: snmpV2_read + snmp_v2_write_credential_list: + - description: snmpV2_write + net_conf_port_list: + - description: Old_one + start_index: 1 + records_to_return: 100 + protocol_order: ssh + retry: 5 + timeout: 3 + +- name: Execute discovery of devices with all the global credentials (max 5 allowed) + cisco.dnac.discovery_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - discovery_name: CIDR with all global credentials + discovery_type: CIDR + ip_address_list: + - 204.1.2.0/24 + ip_filter_list: + - 204.1.2.10 + preferred_mgmt_ip_method: None + start_index: 1 + records_to_return: 100 + protocol_order: telnet + retry: 10 + timeout: 3 + use_global_credentials: True + +- name: Delete disovery by name + cisco.dnac.discovery_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: deleted + config_verify: True + config: + - discovery_name: Single discovery +""" + +RETURN = r""" +#Case_1: When the device(s) are discovered successfully. +response_1: + description: A dictionary with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": + { + "response": String, + "version": String + }, + "msg": String + } + +#Case_2: Given device details or SNMP mode are not provided +response_2: + description: A list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } + +#Case_3: Error while deleting a discovery +response_3: + description: A string with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": String, + "msg": String + } +""" +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts +) +import time +import re + + +class Discovery(DnacBase): + def __init__(self, module): + """ + Initialize an instance of the class. It also initializes an empty + list for 'creds_ids_list' attribute. + + Parameters: + - module: The module associated with the class instance. + + Returns: + The method does not return a value. Instead, it initializes the + following instance attributes: + - self.creds_ids_list: An empty list that will be used to store + credentials IDs. + """ + + super().__init__(module) + self.creds_ids_list = [] + + def validate_input(self, state=None): + """ + Validate the fields provided in the playbook. Checks the + configuration provided in the playbook against a predefined + specification to ensure it adheres to the expected structure + and data types. + + Returns: + The method returns an instance of the class with updated attributes: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the + 'config' parameter. + Example: + To use this method, create an instance of the class and call + 'validate_input' on it.If the validation succeeds, 'self.status' + will be 'success'and 'self.validated_config' will contain the + validated configuration. If it fails, 'self.status' will be + 'failed', and 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + discovery_spec = { + 'cdp_level': {'type': 'int', 'required': False, + 'default': 16}, + 'start_index': {'type': 'int', 'required': False, + 'default': 1}, + 'records_to_return': {'type': 'int', 'required': False, + 'default': 100}, + 'discovery_specific_credentials': {'type': 'dict', 'required': False}, + 'ip_filter_list': {'type': 'list', 'required': False, + 'elements': 'str'}, + 'lldp_level': {'type': 'int', 'required': False, + 'default': 16}, + 'discovery_name': {'type': 'str', 'required': True}, + 'netconf_port': {'type': 'str', 'required': False}, + 'preferred_mgmt_ip_method': {'type': 'str', 'required': False, + 'default': 'None'}, + 'retry': {'type': 'int', 'required': False}, + 'timeout': {'type': 'str', 'required': False}, + 'global_credentials': {'type': 'dict', 'required': False}, + 'protocol_order': {'type': 'str', 'required': False, 'default': 'ssh'}, + 'use_global_credentials': {'type': 'bool', 'required': False, 'default': True} + } + + if state == "merged": + discovery_spec["ip_address_list"] = {'type': 'list', 'required': True, + 'elements': 'str'} + discovery_spec["discovery_type"] = {'type': 'str', 'required': True} + + elif state == "deleted": + if self.config[0].get("delete_all") is True: + self.validated_config = [{"delete_all": True}] + self.msg = "Sucessfully collected input for deletion of all the discoveries" + self.log(self.msg, "WARNING") + return self + + # Validate discovery params + valid_discovery, invalid_params = validate_list_of_dicts( + self.config, discovery_spec + ) + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format( + "\n".join(invalid_params)) + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_discovery + self.msg = "Successfully validated playbook configuration parameters using 'validate_input': {0}".format(str(valid_discovery)) + self.log(str(self.msg), "INFO") + self.status = "success" + return self + + def get_creds_ids_list(self): + """ + Retrieve the list of credentials IDs associated with class instance. + + Returns: + The method returns the list of credentials IDs: + - self.creds_ids_list: The list of credentials IDs associated with + the class instance. + """ + + self.log("Credential Ids list passed is {0}".format(str(self.creds_ids_list)), "INFO") + return self.creds_ids_list + + def handle_global_credentials(self, response=None): + """ + Method to convert values for create_params API when global paramters + are passed as input. + + Parameters: + - response: The response collected from the get_all_global_credentials_v2 API + + Returns: + - global_credentials_all : The dictionary containing list of IDs of various types of + Global credentials. + """ + + global_credentials = self.validated_config[0].get("global_credentials") + global_credentials_all = {} + + cli_credentials_list = global_credentials.get('cli_credentials_list') + if cli_credentials_list: + if not isinstance(cli_credentials_list, list): + msg = "Global CLI credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("cliCredential") is None: + msg = "Global CLI credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(cli_credentials_list) > 0: + global_credentials_all["cliCredential"] = [] + cred_len = len(cli_credentials_list) + if cred_len > 5: + cred_len = 5 + for cli_cred in cli_credentials_list: + if cli_cred.get('description') and cli_cred.get('username'): + for cli in response.get("cliCredential"): + if cli.get("description") == cli_cred.get('description') and cli.get("username") == cli_cred.get('username'): + global_credentials_all["cliCredential"].append(cli.get("id")) + global_credentials_all["cliCredential"] = global_credentials_all["cliCredential"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global CLI credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + http_read_credential_list = global_credentials.get('http_read_credential_list') + if http_read_credential_list: + if not isinstance(http_read_credential_list, list): + msg = "Global HTTP read credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("httpsRead") is None: + msg = "Global HTTP read credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(http_read_credential_list) > 0: + global_credentials_all["httpsRead"] = [] + cred_len = len(http_read_credential_list) + if cred_len > 5: + cred_len = 5 + for http_cred in http_read_credential_list: + if http_cred.get('description') and http_cred.get('username'): + for http in response.get("httpsRead"): + if http.get("description") == http.get('description') and http.get("username") == http.get('username'): + global_credentials_all["httpsRead"].append(http.get("id")) + global_credentials_all["httpsRead"] = global_credentials_all["httpsRead"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global HTTP Read credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + http_write_credential_list = global_credentials.get('http_write_credential_list') + if http_write_credential_list: + if not isinstance(http_write_credential_list, list): + msg = "Global HTTP write credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("httpsWrite") is None: + msg = "Global HTTP write credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(http_write_credential_list) > 0: + global_credentials_all["httpsWrite"] = [] + cred_len = len(http_write_credential_list) + if cred_len > 5: + cred_len = 5 + for http_cred in http_write_credential_list: + if http_cred.get('description') and http_cred.get('username'): + for http in response.get("httpsWrite"): + if http.get("description") == http.get('description') and http.get("username") == http.get('username'): + global_credentials_all["httpsWrite"].append(http.get("id")) + global_credentials_all["httpsWrite"] = global_credentials_all["httpsWrite"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global HTTP Write credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + snmp_v2_read_credential_list = global_credentials.get('snmp_v2_read_credential_list') + if snmp_v2_read_credential_list: + if not isinstance(snmp_v2_read_credential_list, list): + msg = "Global SNMPv2 read credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("snmpV2cRead") is None: + msg = "Global SNMPv2 read credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(snmp_v2_read_credential_list) > 0: + global_credentials_all["snmpV2cRead"] = [] + cred_len = len(snmp_v2_read_credential_list) + if cred_len > 5: + cred_len = 5 + for snmp_cred in snmp_v2_read_credential_list: + if snmp_cred.get('description'): + for snmp in response.get("snmpV2cRead"): + if snmp.get("description") == snmp_cred.get('description'): + global_credentials_all["snmpV2cRead"].append(snmp.get("id")) + global_credentials_all["snmpV2cRead"] = global_credentials_all["snmpV2cRead"][:cred_len] + else: + msg = "Kindly ensure you include the description for the Global SNMPv2 Read \ + credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + snmp_v2_write_credential_list = global_credentials.get('snmp_v2_write_credential_list') + if snmp_v2_write_credential_list: + if not isinstance(snmp_v2_write_credential_list, list): + msg = "Global SNMPv2 write credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("snmpV2cWrite") is None: + msg = "Global SNMPv2 write credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(snmp_v2_write_credential_list) > 0: + global_credentials_all["snmpV2cWrite"] = [] + cred_len = len(snmp_v2_write_credential_list) + if cred_len > 5: + cred_len = 5 + for snmp_cred in snmp_v2_write_credential_list: + if snmp_cred.get('description'): + for snmp in response.get("snmpV2cWrite"): + if snmp.get("description") == snmp_cred.get('description'): + global_credentials_all["snmpV2cWrite"].append(snmp.get("id")) + global_credentials_all["snmpV2cWrite"] = global_credentials_all["snmpV2cWrite"][:cred_len] + else: + msg = "Kindly ensure you include the description for the Global SNMPV2 write credential to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + snmp_v3_credential_list = global_credentials.get('snmp_v3_credential_list') + if snmp_v3_credential_list: + if not isinstance(snmp_v3_credential_list, list): + msg = "Global SNMPv3 write credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("snmpV3") is None: + msg = "Global SNMPv3 credentials are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(snmp_v3_credential_list) > 0: + global_credentials_all["snmpV3"] = [] + cred_len = len(snmp_v3_credential_list) + if cred_len > 5: + cred_len = 5 + for snmp_cred in snmp_v3_credential_list: + if snmp_cred.get('description') and snmp_cred.get('username'): + for snmp in response.get("snmpV3"): + if snmp.get("description") == snmp_cred.get('description') and snmp.get("username") == snmp_cred.get('username'): + global_credentials_all["snmpV3"].append(snmp.get("id")) + global_credentials_all["snmpV3"] = global_credentials_all["snmpV3"][:cred_len] + else: + msg = "Kindly ensure you include both the description and the username for the Global SNMPv3 \ + to discover the devices" + self.discovery_specific_cred_failure(msg=msg) + + net_conf_port_list = global_credentials.get('net_conf_port_list') + if net_conf_port_list: + if not isinstance(net_conf_port_list, list): + msg = "Global net Conf Ports be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if response.get("netconfCredential") is None: + msg = "Global netconf ports are not present in the Cisco Catalyst Center" + self.discovery_specific_cred_failure(msg=msg) + if len(net_conf_port_list) > 0: + global_credentials_all["netconfCredential"] = [] + cred_len = len(net_conf_port_list) + if cred_len > 5: + cred_len = 5 + for port in net_conf_port_list: + if port.get("description"): + for netconf in response.get("netconfCredential"): + if port.get('description') == netconf.get('description'): + global_credentials_all["netconfCredential"].append(netconf.get("id")) + global_credentials_all["netconfCredential"] = global_credentials_all["netconfCredential"][:cred_len] + else: + msg = "Please provide valid description of the Global Netconf port to be used" + self.discovery_specific_cred_failure(msg=msg) + + self.log("Fetched Global credentials IDs are {0}".format(global_credentials_all), "INFO") + return global_credentials_all + + def get_ccc_global_credentials_v2_info(self): + """ + Retrieve the global credentials information (version 2). + It applies the 'get_all_global_credentials_v2' function and extracts + the IDs of the credentials. If no credentials are found, the + function fails with a message. + + Returns: + This method does not return a value. However, updates the attributes: + - self.creds_ids_list: The list of credentials IDs is extended with + the IDs extracted from the response. + - self.result: A dictionary that is updated with the credentials IDs. + """ + + response = self.dnac_apply['exec']( + family="discovery", + function='get_all_global_credentials_v2', + params=self.validated_config[0].get('headers'), + ) + response = response.get('response') + self.log("The Global credentials response from 'get all global credentials v2' API is {0}".format(str(response)), "DEBUG") + global_credentials_all = {} + global_credentials = self.validated_config[0].get("global_credentials") + if global_credentials: + global_credentials_all = self.handle_global_credentials(response=response) + + global_cred_set = set(global_credentials_all.keys()) + response_cred_set = set(response.keys()) + diff_keys = response_cred_set.difference(global_cred_set) + + for key in diff_keys: + global_credentials_all[key] = [] + if response[key] is None: + response[key] = [] + total_len = len(response[key]) + if total_len > 5: + total_len = 5 + for element in response.get(key): + global_credentials_all[key].append(element.get('id')) + global_credentials_all[key] = global_credentials_all[key][:total_len] + + if global_credentials_all == {}: + msg = 'Not found any global credentials to perform discovery' + self.log(msg, "WARNING") + + return global_credentials_all + + def get_devices_list_info(self): + """ + Retrieve the list of devices from the validated configuration. + It then updates the result attribute with this list. + + Returns: + - ip_address_list: The list of devices extracted from the + 'validated_config' attribute. + """ + ip_address_list = self.validated_config[0].get('ip_address_list') + self.result.update(dict(devices_info=ip_address_list)) + self.log("Details of the device list passed: {0}".format(str(ip_address_list)), "INFO") + return ip_address_list + + def preprocess_device_discovery(self, ip_address_list=None): + """ + Preprocess the devices' information. Extract the IP addresses from + the list of devices and perform additional processing based on the + 'discovery_type' in the validated configuration. + + Parameters: + - ip_address_list: The list of devices' IP addresses intended for preprocessing. + If not provided, an empty list will be used. + + Returns: + - ip_address_list: It returns IP address list for the API to process. The value passed + for single, CDP, LLDP, CIDR, Range and Multi Range varies depending + on the need. + """ + + if ip_address_list is None: + ip_address_list = [] + discovery_type = self.validated_config[0].get('discovery_type') + self.log("Discovery type passed for the discovery is {0}".format(discovery_type), "INFO") + if discovery_type in ["SINGLE", "CDP", "LLDP"]: + if len(ip_address_list) == 1: + ip_address_list = ip_address_list[0] + else: + self.preprocess_device_discovery_handle_error() + elif discovery_type == "CIDR": + if len(ip_address_list) == 1: + cidr_notation = ip_address_list[0] + if len(cidr_notation.split("/")) == 2: + ip_address_list = cidr_notation + else: + ip_address_list = "{0}/30".format(cidr_notation) + self.log("CIDR notation is being used for discovery and it requires a prefix length to be specified, such as 1.1.1.1/24.\ + As no prefix length was provided, it will default to 30.", "WARNING") + else: + self.preprocess_device_discovery_handle_error() + elif discovery_type == "RANGE": + if len(ip_address_list) == 1: + if len(str(ip_address_list[0]).split("-")) == 2: + ip_address_list = ip_address_list[0] + else: + ip_address_list = "{0}-{1}".format(ip_address_list[0], ip_address_list[0]) + else: + self.preprocess_device_discovery_handle_error() + else: + new_ip_collected = [] + for ip in ip_address_list: + if len(str(ip).split("-")) != 2: + ip_collected = "{0}-{0}".format(ip) + new_ip_collected.append(ip_collected) + else: + new_ip_collected.append(ip) + ip_address_list = ','.join(new_ip_collected) + self.log("Collected IP address/addresses are {0}".format(str(ip_address_list)), "INFO") + return str(ip_address_list) + + def preprocess_device_discovery_handle_error(self): + """ + Method for failing discovery based on the length of list of IP Addresses passed + for performing discovery. + """ + + self.log("IP Address list's length is longer than 1", "ERROR") + self.module.fail_json(msg="IP Address list's length is longer than 1", response=[]) + + def discovery_specific_cred_failure(self, msg=None): + """ + Method for failing discovery if there is any discrepancy in the credentials + passed by the user + """ + + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + + def handle_discovery_specific_credentials(self, new_object_params=None): + """ + Method to convert values for create_params API when discovery specific paramters + are passed as input. + + Parameters: + - new_object_params: The dictionary storing various parameters for calling the + start discovery API + + Returns: + - new_object_params: The dictionary storing various parameters for calling the + start discovery API in an updated fashion + """ + + discovery_specific_credentials = self.validated_config[0].get('discovery_specific_credentials') + cli_credentials_list = discovery_specific_credentials.get('cli_credentials_list') + http_read_credential = discovery_specific_credentials.get('http_read_credential') + http_write_credential = discovery_specific_credentials.get('http_write_credential') + snmp_v2_read_credential = discovery_specific_credentials.get('snmp_v2_read_credential') + snmp_v2_write_credential = discovery_specific_credentials.get('snmp_v2_write_credential') + snmp_v3_credential = discovery_specific_credentials.get('snmp_v3_credential') + net_conf_port = discovery_specific_credentials.get('net_conf_port') + + if cli_credentials_list: + if not isinstance(cli_credentials_list, list): + msg = "Device Specific ClI credentials must be passed as a list" + self.discovery_specific_cred_failure(msg=msg) + if len(cli_credentials_list) > 0: + username_list = [] + password_list = [] + enable_password_list = [] + for cli_cred in cli_credentials_list: + if cli_cred.get('username') and cli_cred.get('password') and cli_cred.get('enable_password'): + username_list.append(cli_cred.get('username')) + password_list.append(cli_cred.get('password')) + enable_password_list.append(cli_cred.get('enable_password')) + else: + msg = "username, password and enable_password must be passed toether for creating CLI credentials" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['userNameList'] = username_list + new_object_params['passwordList'] = password_list + new_object_params['enablePasswordList'] = enable_password_list + + if http_read_credential: + if not (http_read_credential.get('password') and isinstance(http_read_credential.get('password'), str)): + msg = "The password for the HTTP read credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_read_credential.get('username') and isinstance(http_read_credential.get('username'), str)): + msg = "The username for the HTTP read credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_read_credential.get('port') and isinstance(http_read_credential.get('port'), int)): + msg = "The port for the HTTP read Credential must be of integer type." + self.discovery_specific_cred_failure(msg=msg) + if not isinstance(http_read_credential.get('secure'), bool): + msg = "Secure for HTTP read Credential must be of type boolean." + self.discovery_specific_cred_failure(msg=msg) + new_object_params['httpReadCredential'] = http_read_credential + + if http_write_credential: + if not (http_write_credential.get('password') and isinstance(http_write_credential.get('password'), str)): + msg = "The password for the HTTP write credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_write_credential.get('username') and isinstance(http_write_credential.get('username'), str)): + msg = "The username for the HTTP write credential must be of string type." + self.discovery_specific_cred_failure(msg=msg) + if not (http_write_credential.get('port') and isinstance(http_write_credential.get('port'), int)): + msg = "The port for the HTTP write Credential must be of integer type." + self.discovery_specific_cred_failure(msg=msg) + if not isinstance(http_write_credential.get('secure'), bool): + msg = "Secure for HTTP write Credential must be of type boolean." + self.discovery_specific_cred_failure(msg=msg) + new_object_params['httpWriteCredential'] = http_write_credential + + if snmp_v2_read_credential: + if not (snmp_v2_read_credential.get('desc')) and isinstance(snmp_v2_read_credential.get('desc'), str): + msg = "Name/description for the SNMP v2 read credential must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v2_read_credential.get('community')) and isinstance(snmp_v2_read_credential.get('community'), str): + msg = "The community string must be of string type" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['snmpROCommunityDesc'] = snmp_v2_read_credential.get('desc') + new_object_params['snmpROCommunity'] = snmp_v2_read_credential.get('community') + new_object_params['snmpVersion'] = "v2" + + if snmp_v2_write_credential: + if not (snmp_v2_write_credential.get('desc')) and isinstance(snmp_v2_write_credential.get('desc'), str): + msg = "Name/description for the SNMP v2 write credential must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v2_write_credential.get('community')) and isinstance(snmp_v2_write_credential.get('community'), str): + msg = "The community string must be of string type" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['snmpRWCommunityDesc'] = snmp_v2_write_credential.get('desc') + new_object_params['snmpRWCommunity'] = snmp_v2_write_credential.get('community') + new_object_params['snmpVersion'] = "v2" + + if snmp_v3_credential: + if not (snmp_v3_credential.get('username')) and isinstance(snmp_v3_credential.get('username'), str): + msg = "Username of SNMP v3 protocol must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v3_credential.get('snmp_mode')) and isinstance(snmp_v3_credential.get('snmp_mode'), str): + msg = "Mode of SNMP is madantory to use SNMPv3 protocol and must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if (snmp_v3_credential.get('snmp_mode')) == "AUTHPRIV" or snmp_v3_credential.get('snmp_mode') == "AUTHNOPRIV": + if not (snmp_v3_credential.get('auth_password')) and isinstance(snmp_v3_credential.get('auth_password'), str): + msg = "Authorization password must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v3_credential.get('auth_type')) and isinstance(snmp_v3_credential.get('auth_type'), str): + msg = "Authorization type must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if snmp_v3_credential.get('snmp_mode') == "AUTHPRIV": + if not (snmp_v3_credential.get('privacy_type')) and isinstance(snmp_v3_credential.get('privacy_type'), str): + msg = "Privacy type must be of string type" + self.discovery_specific_cred_failure(msg=msg) + if not (snmp_v3_credential.get('privacy_password')) and isinstance(snmp_v3_credential.get('privacy_password'), str): + msg = "Privacy password must be of string type" + self.discovery_specific_cred_failure(msg=msg) + new_object_params['snmpUserName'] = snmp_v3_credential.get('username') + new_object_params['snmpMode'] = snmp_v3_credential.get('snmp_mode') + new_object_params['snmpAuthPassphrase'] = snmp_v3_credential.get('auth_password') + new_object_params['snmpAuthProtocol'] = snmp_v3_credential.get('auth_type') + new_object_params['snmpPrivProtocol'] = snmp_v3_credential.get('privacy_type') + new_object_params['snmpPrivPassphrase'] = snmp_v3_credential.get('privacy_password') + new_object_params['snmpVersion'] = "v3" + + if net_conf_port: + new_object_params['netconfPort'] = str(net_conf_port) + + return new_object_params + + def create_params(self, ip_address_list=None): + """ + Create a new parameter object based on the validated configuration, + credential IDs, and IP address list. + + Parameters: + - credential_ids: The list of credential IDs to include in the + parameters. If not provided, an empty list is used. + - ip_address_list: The list of IP addresses to include in the + parameters. If not provided, None is used. + + Returns: + - new_object_params: A dictionary containing the newly created + parameters. + """ + + credential_ids = [] + + new_object_params = {} + new_object_params['cdpLevel'] = self.validated_config[0].get('cdp_level') + new_object_params['discoveryType'] = self.validated_config[0].get('discovery_type') + new_object_params['ipAddressList'] = ip_address_list + new_object_params['ipFilterList'] = self.validated_config[0].get('ip_filter_list') + new_object_params['lldpLevel'] = self.validated_config[0].get('lldp_level') + new_object_params['name'] = self.validated_config[0].get('discovery_name') + new_object_params['preferredMgmtIPMethod'] = self.validated_config[0].get('preferred_mgmt_ip_method') + new_object_params['protocolOrder'] = self.validated_config[0].get('protocol_order') + new_object_params['retry'] = self.validated_config[0].get('retry') + new_object_params['timeout'] = self.validated_config[0].get('timeout') + + if self.validated_config[0].get('discovery_specific_credentials'): + self.handle_discovery_specific_credentials(new_object_params=new_object_params) + + global_cred_flag = self.validated_config[0].get('use_global_credentials') + global_credentials_all = {} + + if global_cred_flag is True: + global_credentials_all = self.get_ccc_global_credentials_v2_info() + for global_cred_list in global_credentials_all.values(): + credential_ids.extend(global_cred_list) + new_object_params['globalCredentialIdList'] = credential_ids + + self.log("All the global credentials used for the discovery task are {0}".format(str(global_credentials_all)), "DEBUG") + + if not (new_object_params.get('snmpUserName') or new_object_params.get('snmpROCommunityDesc') or new_object_params.get('snmpRWCommunityDesc') + or global_credentials_all.get('snmpV2cRead') or global_credentials_all.get('snmpV2cWrite') or global_credentials_all.get('snmpV3')): + msg = "Please provide atleast one valid SNMP credential to perform Discovery" + self.discovery_specific_cred_failure(msg=msg) + + if not (new_object_params.get('userNameList') or global_credentials_all.get('cliCredential')): + msg = "Please provide atleast one valid CLI credential to perform Discovery" + self.discovery_specific_cred_failure(msg=msg) + + self.log("The payload/object created for calling the start discovery API is {0}".format(str(new_object_params)), "INFO") + + return new_object_params + + def create_discovery(self, ip_address_list=None): + """ + Start a new discovery process in the Cisco Catalyst Center. It creates the + parameters required for the discovery and then calls the + 'start_discovery' function. The result of the discovery process + is added to the 'result' attribute. + + Parameters: + - credential_ids: The list of credential IDs to include in the + discovery. If not provided, an empty list is used. + - ip_address_list: The list of IP addresses to include in the + discovery. If not provided, None is used. + + Returns: + - task_id: The ID of the task created for the discovery process. + """ + + result = self.dnac_apply['exec']( + family="discovery", + function="start_discovery", + params=self.create_params(ip_address_list=ip_address_list), + op_modifies=True, + ) + + self.log("The response received post discovery creation API called is {0}".format(str(result)), "DEBUG") + + self.result.update(dict(discovery_result=result)) + self.log("Task Id of the API task created is {0}".format(result.response.get('taskId')), "INFO") + return result.response.get('taskId') + + def get_task_status(self, task_id=None): + """ + Monitor the status of a task in the Cisco Catalyst Center. It checks the task + status periodically until the task is no longer 'In Progress'. + If the task encounters an error or fails, it immediately fails the + module and returns False. + + Parameters: + - task_id: The ID of the task to monitor. + + Returns: + - result: True if the task completed successfully, False otherwise. + """ + + result = False + params = dict(task_id=task_id) + while True: + response = self.dnac_apply['exec']( + family="task", + function='get_task_by_id', + params=params, + ) + response = response.response + self.log("Task status for the task id {0} is {1}".format(str(task_id), str(response)), "INFO") + if response.get('isError') or re.search( + 'failed', response.get('progress'), flags=re.IGNORECASE + ): + msg = 'Discovery task with id {0} has not completed - Reason: {1}'.format( + task_id, response.get("failureReason")) + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + return False + self.log("Task status for the task id (before checking status) {0} is {1}".format(str(task_id), str(response)), "INFO") + progress = response.get('progress') + if progress in ('In Progress', 'Inventory service initiating discovery'): + time.sleep(3) + continue + else: + result = True + self.log("The Process is completed", "INFO") + break + self.result.update(dict(discovery_task=response)) + return result + + def lookup_discovery_by_range_via_name(self): + """ + Retrieve a specific discovery by name from a range of + discoveries in the Cisco Catalyst Center. + + Returns: + - discovery: The discovery with the specified name from the range + of discoveries. If no matching discovery is found, it + returns None. + """ + start_index = self.validated_config[0].get("start_index") + records_to_return = self.validated_config[0].get("records_to_return") + + response = {"response": []} + if records_to_return > 500: + num_intervals = records_to_return // 500 + for num in range(0, num_intervals + 1): + params = dict( + start_index=1 + num * 500, + records_to_return=500, + headers=self.validated_config[0].get("headers") + ) + response_part = self.dnac_apply['exec']( + family="discovery", + function='get_discoveries_by_range', + params=params + ) + response["response"].extend(response_part["response"]) + else: + params = dict( + start_index=self.validated_config[0].get("start_index"), + records_to_return=self.validated_config[0].get("records_to_return"), + headers=self.validated_config[0].get("headers"), + ) + + response = self.dnac_apply['exec']( + family="discovery", + function='get_discoveries_by_range', + params=params + ) + self.log("Response of the get discoveries via range API is {0}".format(str(response)), "DEBUG") + + return next( + filter( + lambda x: x['name'] == self.validated_config[0].get('discovery_name'), + response.get("response") + ), None + ) + + def get_discoveries_by_range_until_success(self): + """ + Continuously retrieve a specific discovery by name from a range of + discoveries in the Cisco Catalyst Center until the discovery is complete. + + Returns: + - discovery: The completed discovery with the specified name from + the range of discoveries. If the discovery is not + found or not completed, the function fails the module + and returns None. + """ + + result = False + discovery = self.lookup_discovery_by_range_via_name() + + if not discovery: + msg = 'Cannot find any discovery task with name {0} -- Discovery result: {1}'.format( + str(self.validated_config[0].get("discovery_name")), str(discovery)) + self.log(msg, "INFO") + self.module.fail_json(msg=msg) + + while True: + discovery = self.lookup_discovery_by_range_via_name() + if discovery.get('discoveryCondition') == 'Complete': + result = True + break + + time.sleep(3) + + if not result: + msg = 'Cannot find any discovery task with name {0} -- Discovery result: {1}'.format( + str(self.validated_config[0].get("discovery_name")), str(discovery)) + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + + self.result.update(dict(discovery_range=discovery)) + return discovery + + def get_discovery_device_info(self, discovery_id=None, task_id=None): + """ + Retrieve the information of devices discovered by a specific discovery + process in the Cisco Catalyst Center. It checks the reachability status of the + devices periodically until all devices are reachable or until a + maximum of 3 attempts. + + Parameters: + - discovery_id: ID of the discovery process to retrieve devices from. + - task_id: ID of the task associated with the discovery process. + + Returns: + - result: True if all devices are reachable, False otherwise. + """ + + params = dict( + id=discovery_id, + task_id=task_id, + headers=self.validated_config[0].get("headers"), + ) + result = False + count = 0 + while True: + response = self.dnac_apply['exec']( + family="discovery", + function='get_discovered_network_devices_by_discovery_id', + params=params, + ) + devices = response.response + + self.log("Retrieved device details using the API 'get_discovered_network_devices_by_discovery_id': {0}".format(str(devices)), "DEBUG") + if all(res.get('reachabilityStatus') == 'Success' for res in devices): + result = True + self.log("All devices in the range are reachable", "INFO") + break + + elif any(res.get('reachabilityStatus') == 'Success' for res in devices): + result = True + self.log("Some devices in the range are reachable", "INFO") + break + + elif all(res.get('reachabilityStatus') != 'Success' for res in devices): + result = True + self.log("All devices are not reachable, but discovery is completed", "WARNING") + break + + count += 1 + if count == 3: + break + + time.sleep(3) + + if not result: + msg = 'Discovery network device with id {0} has not completed'.format(discovery_id) + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + + self.log('Discovery network device with id {0} got completed'.format(discovery_id), "INFO") + self.result.update(dict(discovery_device_info=devices)) + return result + + def get_exist_discovery(self): + """ + Retrieve an existing discovery by its name from a range of discoveries. + + Returns: + - discovery: The discovery with the specified name from the range of + discoveries. If no matching discovery is found, it + 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)) + return None + + have = dict(exist_discovery=discovery) + self.have = have + self.result.update(dict(exist_discovery=discovery)) + return discovery + + def delete_exist_discovery(self, params): + """ + Delete an existing discovery in the Cisco Catalyst Center by its ID. + + Parameters: + - params: A dictionary containing the parameters for the delete + operation, including the ID of the discovery to delete. + + Returns: + - task_id: The ID of the task created for the delete operation. + """ + + response = self.dnac_apply['exec']( + family="discovery", + function="delete_discovery_by_id", + params=params, + ) + + self.log("Response collected from API 'delete_discovery_by_id': {0}".format(str(response)), "DEBUG") + self.result.update(dict(delete_discovery=response)) + self.log("Task Id of the deletion task is {0}".format(response.response.get('taskId')), "INFO") + return response.response.get('taskId') + + def get_diff_merged(self): + """ + Retrieve the information of devices discovered by a specific discovery + process in the Cisco Catalyst Center, delete existing discoveries if they exist, + and create a new discovery. The function also updates various + attributes of the class instance. + + Returns: + - self: The instance of the class with updated attributes. + """ + + devices_list_info = self.get_devices_list_info() + ip_address_list = self.preprocess_device_discovery(devices_list_info) + exist_discovery = self.get_exist_discovery() + if exist_discovery: + params = dict(id=exist_discovery.get('id')) + discovery_task_id = self.delete_exist_discovery(params=params) + complete_discovery = self.get_task_status(task_id=discovery_task_id) + + discovery_task_id = self.create_discovery( + ip_address_list=ip_address_list) + complete_discovery = self.get_task_status(task_id=discovery_task_id) + discovery_task_info = self.get_discoveries_by_range_until_success() + result = self.get_discovery_device_info(discovery_id=discovery_task_info.get('id')) + self.result["changed"] = True + self.result['msg'] = "Discovery Created Successfully" + self.result['diff'] = self.validated_config + self.result['response'] = discovery_task_id + self.result.update(dict(msg='Discovery Created Successfully')) + self.log(self.result['msg'], "INFO") + return self + + def get_diff_deleted(self): + """ + Delete an existing discovery in the Cisco Catalyst Center by its name, and + updates various attributes of the class instance. If no + discovery with the specified name is found, the function + updates the 'msg' attribute with an appropriate message. + + Returns: + - self: The instance of the class with updated attributes. + """ + + if self.validated_config[0].get("delete_all"): + count_discoveries = self.dnac_apply['exec']( + family="discovery", + function="get_count_of_all_discovery_jobs", + ) + if count_discoveries.get("response") == 0: + msg = "There are no discoveries present in the Discovery Dashboard for deletion" + self.result['msg'] = msg + self.log(msg, "WARNING") + self.result['response'] = self.validated_config[0] + return self + + delete_all_response = self.dnac_apply['exec']( + family="discovery", + function="delete_all_discovery", + ) + discovery_task_id = delete_all_response.get('response').get('taskId') + self.result["changed"] = True + self.result['msg'] = "All of the Discoveries Deleted Successfully" + self.result['diff'] = self.validated_config + + else: + exist_discovery = self.get_exist_discovery() + if not exist_discovery: + self.result['msg'] = "Discovery {0} Not Found".format( + self.validated_config[0].get("discovery_name")) + self.log(self.result['msg'], "ERROR") + return self + + params = dict(id=exist_discovery.get('id')) + discovery_task_id = self.delete_exist_discovery(params=params) + complete_discovery = self.get_task_status(task_id=discovery_task_id) + self.result["changed"] = True + self.result['msg'] = "Successfully deleted discovery" + self.result['diff'] = self.validated_config + self.result['response'] = discovery_task_id + + self.log(self.result['msg'], "INFO") + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of Discovery in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center 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 + ) + discovery_name = config.get('discovery_name') + if response: + self.log("Requested Discovery with name {0} is completed".format(discovery_name), "INFO") + + else: + self.log("Requested Discovery with name {0} is not completed".format(discovery_name), "WARNING") + self.status = "success" + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of Discovery in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified discovery(s) exists in the Cisco Catalyst Center configuration's + Discovery Database. + """ + + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center config for deleted state + if config.get("delete_all") is True: + count_discoveries = self.dnac_apply['exec']( + family="discovery", + function="get_count_of_all_discovery_jobs", + ) + if count_discoveries == 0: + self.log("All discoveries are deleted", "INFO") + else: + self.log("All discoveries are not deleted", "WARNING") + self.status = "success" + return self + + discovery_task_info = self.lookup_discovery_by_range_via_name() + discovery_name = config.get('discovery_name') + if discovery_task_info: + self.log("Requested Discovery with name {0} is present".format(discovery_name), "WARNING") + + else: + self.log("Requested Discovery with name {0} is not present and deleted".format(discovery_name), "INFO") + self.status = "success" + + return self + + +def main(): + """ main entry point for module execution + """ + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + + ccc_discovery = Discovery(module) + config_verify = ccc_discovery.params.get("config_verify") + + state = ccc_discovery.params.get("state") + if state not in ccc_discovery.supported_states: + ccc_discovery.status = "invalid" + ccc_discovery.msg = "State {0} is invalid".format(state) + ccc_discovery.check_return_status() + + ccc_discovery.validate_input(state=state).check_return_status() + for config in ccc_discovery.validated_config: + ccc_discovery.reset_values() + ccc_discovery.get_diff_state_apply[state]().check_return_status() + if config_verify: + ccc_discovery.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_discovery.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/inventory_intent.py b/ansible_collections/cisco/dnac/plugins/modules/inventory_intent.py new file mode 100644 index 000000000..675c11c91 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/inventory_intent.py @@ -0,0 +1,3644 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Madhan Sankaranarayanan, Abhishek Maheshwari") + +DOCUMENTATION = r""" +--- +module: inventory_intent +short_description: Resource module for Network Device +description: +- Manage operations create, update and delete of the resource Network Device. +- Adds the device with given credential. +- Deletes the network device for the given Id. +- Sync the devices provided as input. +version_added: '6.8.0' +extends_documentation_fragment: + - cisco.dnac.intent_params +author: Abhishek Maheshwari (@abmahesh) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: List of devices with credentails to perform Add/Update/Delete/Resync operation + type: list + elements: dict + required: True + suboptions: + type: + description: Select Device's type from NETWORK_DEVICE, COMPUTE_DEVICE, MERAKI_DASHBOARD, THIRD_PARTY_DEVICE, FIREPOWER_MANAGEMENT_SYSTEM. + NETWORK_DEVICE - This refers to traditional networking equipment such as routers, switches, access points, and firewalls. These devices + are responsible for routing, switching, and providing connectivity within the network. + COMPUTE_DEVICE - These are computing resources such as servers, virtual machines, or containers that are part of the network infrastructure. + Cisco Catalyst Center can integrate with compute devices to provide visibility and management capabilities, ensuring that the network and + compute resources work together seamlessly to support applications and services. + MERAKI_DASHBOARD - It is cloud-based platform used to manage Meraki networking devices, including wireless access points, switches, security + appliances, and cameras. + THIRD_PARTY_DEVICE - This category encompasses devices from vendors other than Cisco or Meraki. Cisco Catalyst Center is designed to support + integration with third-party devices through open standards and APIs. This allows organizations to manage heterogeneous network + environments efficiently using Cisco Catalyst Center's centralized management and automation capabilities. + FIREPOWER_MANAGEMENT_SYSTEM - It is a centralized management console used to manage Cisco's Firepower Next-Generation Firewall (NGFW) devices. + It provides features such as policy management, threat detection, and advanced security analytics. + type: str + default: "NETWORK_DEVICE" + cli_transport: + description: The essential prerequisite for adding Network devices is the specification of the transport + protocol (either SSH or Telnet) used by the device. + type: str + compute_device: + description: Indicates whether a device is a compute device. + type: bool + password: + description: Password for accessing the device and for file encryption during device export. Required for + adding Network Device. Also needed for file encryption while exporting device in a csv file. + type: str + enable_password: + description: Password required for enabling configurations on the device. + type: str + extended_discovery_info: + description: Additional discovery information for the device. + type: str + http_password: + description: HTTP password required for adding compute, Meraki, and Firepower Management Devices. + type: str + http_port: + description: HTTP port number required for adding compute and Firepower Management Devices. + type: str + http_secure: + description: Flag indicating HTTP security. + type: bool + http_username: + description: HTTP username required for adding compute and Firepower Management Devices. + type: str + ip_address_list: + description: A list of the IP addresses for the devices. It is required for tasks such as adding, updating, deleting, + or resyncing devices, with Meraki devices being the exception. + elements: str + type: list + hostname_list: + description: "A list of hostnames representing devices. Operations such as updating, deleting, resyncing, or rebooting + can be performed as alternatives to using IP addresses." + type: list + elements: str + serial_number_list: + description: A list of serial numbers representing devices. Operations such as updating, deleting, resyncing, or rebooting + can be performed as alternatives to using IP addresses. + type: list + elements: str + mac_address_list: + description: "A list of MAC addresses representing devices. Operations such as updating, deleting, resyncing, or rebooting + can be performed as alternatives to using IP addresses." + type: list + elements: str + netconf_port: + description: Specifies the port number for connecting to devices using the Netconf protocol. Netconf (Network Configuration Protocol) + is used for managing network devices. Ensure that the provided port number corresponds to the Netconf service port configured + on your network devices. + NETCONF with user privilege 15 is mandatory for enabling Wireless Services on Wireless capable devices such as Catalyst 9000 series + Switches and C9800 Series Wireless Controllers. The NETCONF credentials are required to connect to C9800 Series Wireless Controllers + as the majority of data collection is done using NETCONF for these Devices. + type: str + username: + description: Username for accessing the device. Required for Adding Network Device. + type: str + snmp_auth_passphrase: + description: SNMP authentication passphrase required for adding network, compute, and third-party devices. + type: str + snmp_auth_protocol: + description: SNMP authentication protocol. + SHA (Secure Hash Algorithm) - cryptographic hash function commonly used for data integrity verification and authentication purposes. + type: str + default: "SHA" + snmp_mode: + description: Device's snmp Mode refer to different SNMP (Simple Network Management Protocol) versions and their corresponding security levels. + NOAUTHNOPRIV - This mode provides no authentication or encryption for SNMP messages. It means that devices communicating using SNMPv1 do + not require any authentication (username/password) or encryption (data confidentiality). This makes it the least secure option. + AUTHNOPRIV - This mode provides authentication but no encryption for SNMP messages. Authentication involves validating the source of the + SNMP messages using a community string (similar to a password). However, the data transmitted between devices is not encrypted, + so it's susceptible to eavesdropping. + AUTHPRIV - This mode provides both authentication and encryption for SNMP messages. It offers the highest level of security among the three + options. Authentication ensures that the source of the messages is genuine, and encryption ensures that the data exchanged between + devices is confidential and cannot be intercepted by unauthorized parties. + type: str + snmp_priv_passphrase: + description: SNMP private passphrase required for adding network, compute, and third-party devices. + type: str + snmp_priv_protocol: + description: SNMP private protocol required for adding network, compute, and third-party devices. + type: str + snmp_ro_community: + description: SNMP Read-Only community required for adding V2C devices. + type: str + snmp_rw_community: + description: SNMP Read-Write community required for adding V2C devices. + type: str + snmp_retry: + description: SNMP retry count. + type: int + default: 3 + snmp_timeout: + description: SNMP timeout duration. + type: int + default: 5 + snmp_username: + description: SNMP username required for adding network, compute, and third-party devices. + type: str + snmp_version: + description: It is a standard protocol used for managing and monitoring network devices. + v2 - In this communication between the SNMP manager (such as Cisco Catalyst) and the managed devices + (such as routers, switches, or access points) is based on community strings.Community strings serve + as form of authentication and they are transmitted in clear text, providing no encryption. + v3 - It is the most secure version of SNMP, providing authentication, integrity, and encryption features. + It allows for the use of usernames, authentication passwords, and encryption keys, providing stronger + security compared to v2. + type: str + update_mgmt_ipaddresslist: + description: List of updated management IP addresses for network devices. + type: list + elements: dict + suboptions: + exist_mgmt_ipaddress: + description: Device's existing Mgmt IpAddress. + type: str + new_mgmt_ipaddress: + description: Device's new Mgmt IpAddress. + type: str + force_sync: + 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_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 + default: False + role: + description: Role of device which can be ACCESS, CORE, DISTRIBUTION, BORDER ROUTER, UNKNOWN. + ALL - This role typically represents all devices within the network, regardless of their specific roles or functions. + UNKNOWN - This role is assigned to devices whose roles or functions have not been identified or classified within Cisco Catalsyt Center. + This could happen if the platform is unable to determine the device's role based on available information. + ACCESS - This role typically represents switches or access points that serve as access points for end-user devices to connect to the network. + These devices are often located at the edge of the network and provide connectivity to end-user devices. + BORDER ROUTER - These are devices that connect different network domains or segments together. They often serve as + gateways between different networks, such as connecting an enterprise network to the internet or connecting + multiple branch offices. + DISTRIBUTION - This role represents function as distribution switches or routers in hierarchical network designs. They aggregate traffic + from access switches and route it toward the core of the network or toward other distribution switches. + CORE - This role typically represents high-capacity switches or routers that form the backbone of the network. They handle large volumes + of traffic and provide connectivity between different parts of network, such as connecting distribution switches or + providing interconnection between different network segments. + type: str + add_user_defined_field: + description: This operation will take dictionary as a parameter and in this we give details to + create/update/delete/assign multiple UDF to a device. + type: dict + suboptions: + name: + description: Name of Global User Defined Field. Required for creating/deleting UDF and then assigning it to device. + type: str + description: + description: Info about the global user defined field. Also used while updating interface details. + type: str + value: + description: Value to assign to tag with or without the same user defined field name. + type: str + update_interface_details: + description: This operation will take dictionary as a parameter and in this we give details to update interface details of device. + type: dict + suboptions: + description: + description: Specifies the description of the interface of the device. + type: str + interface_name: + description: Specify the list of interface names to update the details of the device interface. + (For example, GigabitEthernet1/0/11, FortyGigabitEthernet1/1/2) + type: list + elements: str + vlan_id: + description: Unique Id number assigned to a VLAN within a network used only while updating interface details. + type: int + voice_vlan_id: + description: Identifier used to distinguish a specific VLAN that is dedicated to voice traffic used only while updating interface details. + type: int + deployment_mode: + description: Preview/Deploy [Preview means the configuration is not pushed to the device. Deploy makes the configuration pushed to the device] + type: str + default: "Deploy" + clear_mac_address_table: + description: Set this to true if you need to clear the MAC address table for a specific device's interface. It's a boolean type, + with a default value of False. + type: bool + default: False + admin_status: + description: Status of Interface of a device, it can be (UP/DOWN). + type: str + export_device_list: + description: This operation take dictionary as parameter and export the device details as well as device credentials + details in a csv file. + type: dict + suboptions: + password: + description: Specifies the password for the encryption of file while exporting the device credentails into the file. + type: str + site_name: + description: Indicates the exact location where the wired device will be provisioned. This is a string value that should + represent the complete hierarchical path of the site (For example, "Global/USA/San Francisco/BGL_18/floor_pnp"). + type: str + operation_enum: + description: enum(CREDENTIALDETAILS, DEVICEDETAILS) 0 to export Device Credential Details Or 1 to export Device Details. + CREDENTIALDETAILS - Used for exporting device credentials details like snpm credntials, device crdentails etc. + DEVICEDETAILS - Used for exporting device specific details like device hostname, serial number, type, family etc. + type: str + parameters: + description: List of device parameters that needs to be exported to file.(For example, ["componentName", "SerialNumber", "Last Sync Status"]) + type: list + elements: str + provision_wired_device: + description: This parameter takes a list of dictionaries. Each dictionary provides the IP address of a wired device and + the name of the site where the device will be provisioned. + type: list + elements: dict + suboptions: + device_ip: + description: Specifies the IP address of the wired device. This is a string value that should be in the format of + standard IPv4 or IPv6 addresses. + type: str + version_added: 6.12.0 + site_name: + description: Indicates the exact location where the wired device will be provisioned. This is a string value that should + represent the complete hierarchical path of the site (For example, "Global/USA/San Francisco/BGL_18/floor_pnp"). + type: str + resync_retry_count: + description: Determines the total number of retry attempts for checking if the device has reached a managed state during + the provisioning process. If unspecified, the default value is set to 200 retries. + type: int + default: 200 + version_added: 6.12.0 + resync_retry_interval: + description: Sets the interval, in seconds, at which the system will recheck the device status throughout the provisioning + process. If unspecified, the system will check the device status every 2 seconds by default. + type: int + default: 2 + version_added: 6.12.0 + +requirements: +- dnacentersdk >= 2.5.5 +- python >= 3.5 +seealso: +- name: Cisco Catalyst Center documentation for Devices AddDevice2 + description: Complete reference of the AddDevice2 API. + link: https://developer.cisco.com/docs/dna-center/#!add-device +- name: Cisco Catalyst Center documentation for Devices DeleteDeviceById + description: Complete reference of the DeleteDeviceById API. + link: https://developer.cisco.com/docs/dna-center/#!delete-device-by-id +- name: Cisco Catalyst Center documentation for Devices SyncDevices2 + description: Complete reference of the SyncDevices2 API. + link: https://developer.cisco.com/docs/dna-center/#!sync-devices +notes: + - SDK Method used are + devices.Devices.add_device, + devices.Devices.delete_device_by_id, + devices.Devices.sync_devices, + + - Paths used are + post /dna/intent/api/v1/network-device, + delete /dna/intent/api/v1/network-device/{id}, + put /dna/intent/api/v1/network-device, + + - Removed 'managementIpAddress' options in v4.3.0. + - Renamed argument 'ip_address' to 'ip_address_list' option in v6.12.0. + - Removed 'serial_number', 'device_added', 'role_source', options in v6.12.0. + - Added 'add_user_defined_field', 'update_interface_details', 'export_device_list' options in v6.13.1. + - Removed 'provision_wireless_device', 'reprovision_wired_device' options in v6.13.1. + - Added the parameter 'admin_status' options in v6.13.1. + - Removed 'device_updated' options in v6.13.1. + +""" + +EXAMPLES = r""" +- name: Add new device in Inventory with full credentials + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - cli_transport: ssh + compute_device: False + password: Test@123 + enable_password: Test@1234 + extended_discovery_info: test + http_username: "testuser" + http_password: "test" + http_port: "443" + http_secure: False + ip_address_list: ["1.1.1.1", "2.2.2.2"] + netconf_port: 830 + snmp_auth_passphrase: "Lablab@12" + snmp_auth_protocol: SHA + snmp_mode: AUTHPRIV + snmp_priv_passphrase: "Lablab@123" + snmp_priv_protocol: AES256 + snmp_retry: 3 + snmp_timeout: 5 + snmp_username: v3Public + snmp_version: v3 + type: NETWORK_DEVICE + username: cisco + +- name: Add new Compute device in Inventory with full credentials.Inputs needed for Compute Device + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + http_username: "testuser" + http_password: "test" + http_port: "443" + snmp_auth_passphrase: "Lablab@12" + snmp_auth_protocol: SHA + snmp_mode: AUTHPRIV + snmp_priv_passphrase: "Lablab@123" + snmp_priv_protocol: AES256 + snmp_retry: 3 + snmp_timeout: 5 + snmp_username: v3Public + compute_device: True + username: cisco + type: "COMPUTE_DEVICE" + +- name: Add new Meraki device in Inventory with full credentials.Inputs needed for Meraki Device. + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - http_password: "test" + type: "MERAKI_DASHBOARD" + +- name: Add new Firepower Management device in Inventory with full credentials.Input needed to add Device. + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + http_username: "testuser" + http_password: "test" + http_port: "443" + type: "FIREPOWER_MANAGEMENT_SYSTEM" + +- name: Add new Third Party device in Inventory with full credentials.Input needed to add Device. + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + snmp_auth_passphrase: "Lablab@12" + snmp_auth_protocol: SHA + snmp_mode: AUTHPRIV + snmp_priv_passphrase: "Lablab@123" + snmp_priv_protocol: AES256 + snmp_retry: 3 + snmp_timeout: 5 + snmp_username: v3Public + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - cli_transport: telnet + compute_device: False + password: newtest123 + enable_password: newtest1233 + ip_address_list: ["1.1.1.1", "2.2.2.2"] + type: NETWORK_DEVICE + credential_update: True + +- name: Update new management IP address of device 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1"] + credential_update: True + update_mgmt_ipaddresslist: + - exist_mgmt_ipaddress: "1.1.1.1" + new_mgmt_ipaddress: "12.12.12.12" + +- name: Associate Wired 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - provision_wired_device: + - device_ip: "1.1.1.1" + site_name: "Global/USA/San Francisco/BGL_18/floor_pnp" + resync_retry_count: 200 + resync_interval: 2 + - device_ip: "2.2.2.2" + site_name: "Global/USA/San Francisco/BGL_18/floor_test" + resync_retry_count: 200 + resync_retry_interval: 2 + +- name: Update Device Role with IP Address + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + role: ACCESS + +- name: Update Interface details with IP Address + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + update_interface_details: + description: "Testing for updating interface details" + admin_status: "UP" + vlan_id: 23 + voice_vlan_id: 45 + deployment_mode: "Deploy" + interface_name: ["GigabitEthernet1/0/11", FortyGigabitEthernet1/1/1] + clear_mac_address_table: True + +- name: Export Device Details in a CSV file Interface details with IP Address + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + export_device_list: + password: "File_password" + operation_enum: "0" + parameters: ["componentName", "SerialNumber", "Last Sync Status"] + +- name: Create Global User Defined with IP Address + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + add_user_defined_field: + - name: Test123 + description: "Added first udf for testing" + value: "value123" + - name: Test321 + description: "Added second udf for testing" + value: "value321" + +- name: Resync Device with IP Addresses + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + device_resync: True + force_sync: False + +- name: Reboot AP Devices with IP Addresses + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + reboot_device: True + +- name: Delete Provision/Unprovision Devices by IP Address + 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 + dnac_log_level: "{{dnac_log_level}}" + state: deleted + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + clean_config: False + +- name: Delete Global User Defined Field with name + 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_level: "{{dnac_log_level}}" + dnac_log: False + state: deleted + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + add_user_defined_field: + - name: Test123 + - name: Test321 + +""" + +RETURN = r""" + +dnac_response: + description: A dictionary or list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": { + "taskId": "string", + "url": "string" + }, + "version": "string" + } +""" +# common approach when a module relies on optional dependencies that are not available during the validation process. +try: + import pyzipper + HAS_PYZIPPER = True +except ImportError: + HAS_PYZIPPER = False + pyzipper = None + +import csv +import time +from datetime import datetime +from io import BytesIO, StringIO +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, +) +# Defer this feature as API issue is there once it's fixed we will addresses it in upcoming release iac2.0 +support_for_provisioning_wireless = False + + +class DnacDevice(DnacBase): + """Class containing member attributes for Inventory intent module""" + + def __init__(self, module): + super().__init__(module) + self.supported_states = ["merged", "deleted"] + + def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', and + 'self.msg' will describe the validation issues. + """ + + temp_spec = { + 'cli_transport': {'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_list': {'type': 'list', 'elements': 'str'}, + 'hostname_list': {'type': 'list', 'elements': 'str'}, + 'serial_number_list': {'type': 'list', 'elements': 'str'}, + 'mac_address_list': {'type': 'list', 'elements': 'str'}, + 'netconf_port': {'type': 'str'}, + 'password': {'type': 'str'}, + 'snmp_auth_passphrase': {'type': 'str'}, + 'snmp_auth_protocol': {'default': "SHA", 'type': 'str'}, + 'snmp_mode': {'type': 'str'}, + 'snmp_priv_passphrase': {'type': 'str'}, + 'snmp_priv_protocol': {'type': 'str'}, + 'snmp_ro_community': {'type': 'str'}, + 'snmp_rw_community': {'type': 'str'}, + 'snmp_retry': {'default': 3, 'type': 'int'}, + 'snmp_timeout': {'default': 5, 'type': 'int'}, + 'snmp_username': {'type': 'str'}, + 'snmp_version': {'type': 'str'}, + 'update_mgmt_ipaddresslist': {'type': 'list', 'elements': 'dict'}, + 'username': {'type': 'str'}, + 'role': {'type': 'str'}, + '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': 'list', + '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'}, + 'interface_name': {'type': 'list', 'elements': 'str'}, + 'deployment_mode': {'default': 'Deploy', 'type': 'str'}, + 'clear_mac_address_table': {'default': False, 'type': 'bool'}, + 'admin_status': {'type': 'str'}, + }, + 'export_device_list': { + 'type': 'dict', + 'password': {'type': 'str'}, + 'operation_enum': {'type': 'str'}, + 'parameters': {'type': 'list', 'elements': 'str'}, + }, + 'provision_wired_device': { + 'type': 'list', + 'device_ip': {'type': 'str'}, + 'site_name': {'type': 'str'}, + 'resync_retry_count': {'default': 200, 'type': 'int'}, + 'resync_retry_interval': {'default': 2, 'type': 'int'}, + } + } + + # Validate device params + valid_temp, invalid_params = validate_list_of_dicts( + self.config, temp_spec + ) + + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format(invalid_params) + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_temp + self.msg = "Successfully validated playbook configuration parameters using 'validate_input': {0}".format(str(valid_temp)) + self.log(self.msg, "INFO") + self.status = "success" + + return self + + def get_device_ips_from_config_priority(self): + """ + Retrieve device IPs based on the configuration. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + Returns: + list: A list containing device IPs. + Description: + This method retrieves device IPs based on the priority order specified in the configuration. + It first checks if device IPs are available. If not, it checks hostnames, serial numbers, + and MAC addresses in order and retrieves IPs based on availability. + If none of the information is available, an empty list is returned. + """ + # Retrieve device IPs from the configuration + device_ips = self.config[0].get("ip_address_list") + + if device_ips: + return device_ips + + # If device IPs are not available, check hostnames + device_hostnames = self.config[0].get("hostname_list") + if device_hostnames: + return self.get_device_ips_from_hostname(device_hostnames) + + # If hostnames are not available, check serial numbers + device_serial_numbers = self.config[0].get("serial_number_list") + if device_serial_numbers: + return self.get_device_ips_from_serial_number(device_serial_numbers) + + # If serial numbers are not available, check MAC addresses + device_mac_addresses = self.config[0].get("mac_address_list") + if device_mac_addresses: + return self.get_device_ips_from_mac_address(device_mac_addresses) + + # If no information is available, return an empty list + return [] + + def device_exists_in_dnac(self): + """ + Check which devices already exists in Cisco Catalyst Center and return both device_exist and device_not_exist in dnac. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + Returns: + list: A list of devices that exist in Cisco Catalyst Center. + Description: + Queries Cisco Catalyst Center to check which devices are already present in Cisco Catalyst Center and store + its management IP address in the list of devices that exist. + Example: + To use this method, create an instance of the class and call 'device_exists_in_dnac' on it, + The method returns a list of management IP addressesfor devices that exist in Cisco Catalyst Center. + """ + + device_in_dnac = [] + + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + ) + + except Exception as e: + error_message = "Error while fetching device from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "CRITICAL") + raise Exception(error_message) + + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + for ip in response: + device_ip = ip["managementIpAddress"] + device_in_dnac.append(device_ip) + + return device_in_dnac + + def is_udf_exist(self, field_name): + """ + Check if a Global User Defined Field exists in Cisco Catalyst Center based on its name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + field_name (str): The name of the Global User Defined Field. + Returns: + bool: True if the Global User Defined Field exists, False otherwise. + Description: + The function sends a request to Cisco Catalyst Center to retrieve all Global User Defined Fields + with the specified name. If matching field is found, the function returns True, indicating that + the field exists else returns False. + """ + + response = self.dnac._exec( + family="devices", + function='get_all_user_defined_fields', + params={"name": field_name}, + ) + + self.log("Received API response from 'get_all_user_defined_fields': {0}".format(str(response)), "DEBUG") + udf = response.get("response") + + if (len(udf) == 1): + return True + + message = "Global User Defined Field with name '{0}' doesnot exist in Cisco Catalyst Center".format(field_name) + self.log(message, "INFO") + + return False + + def create_user_defined_field(self, udf): + """ + Create a Global User Defined Field in Cisco Catalyst Center based on the provided configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + udf (dict): A dictionary having the payload for the creation of user defined field(UDF) in Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + The function retrieves the configuration for adding a user-defined field from the configuration object, + sends the request to Cisco Catalyst Center to create the field, and logs the response. + """ + try: + response = self.dnac._exec( + family="devices", + function='create_user_defined_field', + params=udf, + ) + self.log("Received API response from 'create_user_defined_field': {0}".format(str(response)), "DEBUG") + response = response.get("response") + field_name = udf.get('name') + self.log("Global User Defined Field with name '{0}' created successfully".format(field_name), "INFO") + self.status = "success" + + except Exception as e: + error_message = "Error while creating Global UDF(User Defined Field) in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return self + + def add_field_to_devices(self, device_ids, udf): + """ + Add a Global user-defined field with specified details to a list of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ids (list): A list of device IDs to which the user-defined field will be added. + udf (dict): A dictionary having the user defined field details including name and value. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + The function retrieves the details of the user-defined field from the configuration object, + including the field name and default value then iterates over list of device IDs, creating a payload for + each device and sending the request to Cisco Catalyst Center to add the user-defined field. + """ + # field_details = self.config[0].get('add_user_defined_field') + field_name = udf.get('name') + field_value = udf.get('value', '1') + for device_id in device_ids: + payload = {} + payload['name'] = field_name + payload['value'] = field_value + udf_param_dict = { + 'payload': [payload], + 'device_id': device_id + } + try: + response = self.dnac._exec( + family="devices", + function='add_user_defined_field_to_device', + params=udf_param_dict, + ) + self.log("Received API response from 'add_user_defined_field_to_device': {0}".format(str(response)), "DEBUG") + response = response.get("response") + self.status = "success" + self.result['changed'] = True + + except Exception as e: + self.status = "failed" + error_message = "Error while adding Global UDF to device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + self.result['changed'] = False + + 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. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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("Received API response from 'export_device_list': {0}".format(str(response)), "DEBUG") + 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, "ERROR") + + 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}, + ) + self.log("Received API response from 'download_a_file_by_fileid': {0}".format(str(response)), "DEBUG") + + return response + + def decrypt_and_read_csv(self, response, password): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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, "CRITICAL") + self.status = "failed" + return self + + snmp_protocol = self.config[0].get('snmp_priv_protocol', 'AES128') + encryption_dict = { + 'AES128': 'pyzipper.WZ_AES128', + 'AES192': 'pyzipper.WZ_AES192', + 'AES256': 'pyzipper.WZ_AES', + '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), "WARNING") + + if not encryption_method: + self.msg = "Invalid SNMP protocol '{0}' specified for encryption.".format(snmp_protocol) + self.log(self.msg, "ERROR") + 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("Text content of decrypted file: {0}".format(file_content_text), "DEBUG") + + # 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 Catalyst Center into a CSV file. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of the class with updated result, status, and log. + Description: + This function exports device details from Cisco Catalyst Center based on the provided IP addresses in the configuration. + It retrieves the device UUIDs, calls the export device list API, and downloads the exported data of both device details and + and device credentials with an encrtypted zip file with password into CSV format. + The CSV data is then parsed and written to a file. + """ + + device_ips = self.get_device_ips_from_config_priority() + + if not device_ips: + self.status = "failed" + self.msg = "Cannot export device details as no devices are specified in the playbook" + self.log(self.msg, "ERROR") + return self + + try: + device_uuids = self.get_device_ids(device_ips) + + if not device_uuids: + self.status = "failed" + self.result['changed'] = False + self.msg = "Could not find device UUIDs for exporting device details" + self.log(self.msg, "ERROR") + return self + + # Now all device UUID get collected so call the export device list API + export_device_list = self.config[0].get('export_device_list') + password = export_device_list.get("password") + + if not self.is_valid_password(password): + self.status = "failed" + 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()) + self.msg = formatted_msg + self.log(formatted_msg, "INFO") + return self + + payload_params = { + "deviceUuids": device_uuids, + "password": password, + "operationEnum": export_device_list.get("operation_enum", "0"), + "parameters": export_device_list.get("parameters") + } + + response = self.trigger_export_api(payload_params) + self.check_return_status() + + if payload_params["operationEnum"] == "0": + 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: + decoded_resp = response.data.decode(encoding='utf-8') + self.log("Decoded response of Export Device Credential file: {0}".format(str(decoded_resp)), "DEBUG") + + # Parse the CSV-like string into a list of dictionaries + csv_reader = csv.DictReader(StringIO(decoded_resp)) + current_date = datetime.now() + formatted_date = current_date.strftime("%m-%d-%Y") + output_file_name = "devices-" + str(formatted_date) + ".csv" + + device_data = [] + for row in csv_reader: + device_data.append(row) + + # Write the data to a CSV file + with open(output_file_name, 'w', newline='') as csv_file: + fieldnames = device_data[0].keys() + csv_writer = csv.DictWriter(csv_file, fieldnames=fieldnames) + csv_writer.writeheader() + csv_writer.writerows(device_data) + + self.msg = "Device Details Exported Successfully to the CSV file: {0}".format(output_file_name) + self.log(self.msg, "INFO") + self.status = "success" + self.result['changed'] = True + self.result['response'] = self.msg + + except Exception as e: + self.msg = "Error while exporting device details into CSV file for device(s): '{0}'".format(str(device_ips)) + self.log(self.msg, "ERROR") + self.status = "failed" + + return self + + def get_ap_devices(self, device_ips): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst Center. + Description: + This method communicates with Cisco Catalyst 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 Catalyst Center: {0}".format(str(e)) + self.log(error_message, "CRITICAL") + raise Exception(error_message) + + return ap_device_list + + def resync_devices(self): + """ + Resync devices in Cisco Catalyst Center. + This function performs the Resync operation for the devices specified in the playbook. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + The function expects the following parameters in the configuration: + - "ip_address_list": List of device IP addresses to be resynced. + - "force_sync": (Optional) Whether to force sync the devices. Defaults to "False". + """ + + # Code for triggers the resync operation using the retrieved device IDs and force sync parameter. + device_ips = self.get_device_ips_from_config_priority() + input_device_ips = device_ips.copy() + device_in_dnac = self.device_exists_in_dnac() + + for device_ip in input_device_ips: + if device_ip not in device_in_dnac: + input_device_ips.remove(device_ip) + + ap_devices = self.get_ap_devices(input_device_ips) + self.log("AP Devices from the playbook input are: {0}".format(str(ap_devices)), "INFO") + + if ap_devices: + for ap_ip in ap_devices: + input_device_ips.remove(ap_ip) + self.log("Following devices {0} are AP, so can't perform resync operation.".format(str(ap_devices)), "WARNING") + + if not input_device_ips: + self.msg = "Cannot perform the Resync operation as the device(s) with IP(s) {0} are not present in Cisco Catalyst Center".format(str(device_ips)) + self.status = "success" + self.result['changed'] = False + self.result['response'] = self.msg + self.log(self.msg, "WARNING") + return self + + device_ids = self.get_device_ids(input_device_ips) + try: + force_sync = self.config[0].get("force_sync", False) + resync_param_dict = { + 'payload': device_ids, + 'force_sync': force_sync + } + response = self.dnac._exec( + family="devices", + function='sync_devices_using_forcesync', + op_modifies=True, + params=resync_param_dict, + ) + self.log("Received API response from 'sync_devices_using_forcesync': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'Synced' in execution_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Devices have been successfully resynced. Devices resynced: {0}".format(str(input_device_ips)) + self.log(self.msg, "INFO") + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device resynced get failed because of {0}".format(failure_reason) + else: + self.msg = "Device resynced get failed." + self.log(self.msg, "ERROR") + break + + except Exception as e: + self.status = "failed" + error_message = "Error while resyncing device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return self + + def reboot_access_points(self): + """ + Reboot access points in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of the class with updated result, status, and log. + Description: + This function performs a reboot operation on access points in Cisco Catalyst Center based on the provided IP addresses + in the configuration. It retrieves the AP devices' MAC addresses, calls the reboot access points API, and monitors + the progress of the reboot operation. + """ + + device_ips = self.get_device_ips_from_config_priority() + input_device_ips = device_ips.copy() + + if input_device_ips: + ap_devices = self.get_ap_devices(input_device_ips) + self.log("AP Devices from the playbook input are: {0}".format(str(ap_devices)), "INFO") + for device_ip in input_device_ips: + if device_ip not in ap_devices: + input_device_ips.remove(device_ip) + + if not input_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, "WARNING") + return self + + # Get and store the apEthernetMacAddress of given devices + ap_mac_address_list = [] + for device_ip in input_device_ips: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + 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 = "success" + self.result['changed'] = False + self.msg = "Cannot find the AP devices for rebooting" + self.result['response'] = self.msg + self.log(self.msg, "INFO") + return self + + # Now call the Reboot Access Point API + reboot_params = { + "apMacAddresses": ap_mac_address_list + } + response = self.dnac._exec( + family="wireless", + function='reboot_access_points', + op_modifies=True, + params=reboot_params, + ) + self.log(str(response)) + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'url' in execution_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "AP Device(s) {0} successfully rebooted!".format(str(input_device_ips)) + self.log(self.msg, "INFO") + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "AP Device Rebooting get failed because of {0}".format(failure_reason) + else: + self.msg = "AP Device Rebooting get failed" + self.log(self.msg, "ERROR") + break + + 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 Catalyst 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), "INFO") + + 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 Catalyst 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, "WARNING") + + 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 Catalyst 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 Catalyst Center: {2}".format(device_type, device_ip, str(exception)) + self.log(error_message, "ERROR") + + 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 Catalyst 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, "INFO") + 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 Catalyst 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), "INFO") + + 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 Catalyst 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, "INFO") + + 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 Catalyst 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), "INFO") + + def provisioned_wired_device(self): + """ + Provision wired devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of the class with updated result, status, and log. + Description: + This function provisions wired devices in Cisco Catalyst Center based on the configuration provided. + It retrieves the site name and IP addresses of the devices from the list of configuration, + attempts to provision each device with site, and monitors the provisioning process. + """ + + provision_wired_list = self.config[0]['provision_wired_device'] + total_devices_to_provisioned = len(provision_wired_list) + device_ip_list = [] + provision_count, already_provision_count = 0, 0 + + for prov_dict in provision_wired_list: + managed_flag = False + device_ip = prov_dict['device_ip'] + device_ip_list.append(device_ip) + site_name = prov_dict['site_name'] + device_type = "Wired" + resync_retry_count = prov_dict.get("resync_retry_count", 200) + # This resync retry interval will be in seconds which will check device status at given interval + resync_retry_interval = prov_dict.get("resync_retry_interval", 2) + + if not site_name or not device_ip: + self.status = "failed" + self.msg = "Site and Device IP are required for Provisioning of Wired Devices." + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + return self + + provision_wired_params = { + 'deviceManagementIpAddress': device_ip, + 'siteNameHierarchy': site_name + } + + # Check till device comes into managed state + while resync_retry_count: + response = self.get_device_response(device_ip) + self.log("Device is in {0} state waiting for Managed State.".format(response['managementState']), "DEBUG") + + if ( + response.get('managementState') == "Managed" + and response.get('collectionStatus') == "Managed" + and response.get("hostname") + ): + msg = """Device '{0}' comes to managed state and ready for provisioning with the resync_retry_count + '{1}' left having resync interval of {2} seconds""".format(device_ip, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = True + break + + if response.get('collectionStatus') == "Partial Collection Failure" or response.get('collectionStatus') == "Could Not Synchronize": + device_status = response.get('collectionStatus') + msg = """Device '{0}' comes to '{1}' state and never goes for provisioning with the resync_retry_count + '{2}' left having resync interval of {3} seconds""".format(device_ip, device_status, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = False + break + + time.sleep(resync_retry_interval) + resync_retry_count = resync_retry_count - 1 + + if not managed_flag: + self.log("""Device {0} is not transitioning to the managed state, so provisioning operation cannot + be performed.""".format(device_ip), "WARNING") + continue + + try: + response = self.dnac._exec( + family="sda", + function='provision_wired_device', + op_modifies=True, + params=provision_wired_params, + ) + + if response.get("status") == "failed": + description = response.get("description") + error_msg = "Cannot do Provisioning for 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") + + 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), "INFO") + already_provision_count += 1 + + # Check If all the devices are already provsioned, return from here only + if already_provision_count == total_devices_to_provisioned: + self.handle_all_already_provisioned(device_ip_list, device_type) + elif provision_count == total_devices_to_provisioned: + 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, prov_dict): + """ + Get wireless provisioning parameters for a device. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + prov_dict (dict): A dictionary containing configuration parameters for wireless provisioning. + 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 Catalyst 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. + """ + + try: + device_ip_address = prov_dict['device_ip'] + site_name = prov_dict['site_name'] + + wireless_param = [ + { + 'site': site_name, + 'managedAPLocations': prov_dict['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, "ERROR") + return self + + wireless_param[0]["dynamicInterfaces"] = [] + + for interface in prov_dict.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_address} + ) + + if not response: + self.status = "failed" + self.msg = "Device Host name is not present in the Cisco Catalyst Center" + self.log(self.msg, "INFO") + return self + + response = response.get("response") + wireless_param[0]["deviceName"] = response.get("hostname") + self.wireless_param = wireless_param + self.status = "success" + self.log("Successfully collected all the parameters required for Wireless Provisioning", "DEBUG") + + except Exception as e: + self.msg = """An exception occured while fetching the details for wireless provisioning of + device '{0}' due to - {1}""".format(device_ip_address, str(e)) + self.log(self.msg, "ERROR") + + return self + + def get_site_type(self, site_name): + """ + Get the type of a site in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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, "INFO") + return site_type + + self.log("Received API response from 'get_site': {0}".format(str(response)), "DEBUG") + 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 as e: + self.msg = "Error while fetching the site '{0}' and the specified site was not found in Cisco Catalyst Center.".format(site_name) + self.module.fail_json(msg=self.msg, response=[self.msg]) + + return site_type + + def provisioned_wireless_devices(self): + """ + Provision Wireless devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + 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 Catalyst 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" + device_ip_list = [] + provision_wireless_list = self.config[0]['provision_wireless_device'] + + for prov_dict in provision_wireless_list: + try: + # Collect the device parameters from the playbook to perform wireless provisioing + self.get_wireless_param(prov_dict).check_return_status() + device_ip = prov_dict['device_ip'] + device_ip_list.append(device_ip) + provisioning_params = self.wireless_param + resync_retry_count = prov_dict.get("resync_retry_count", 200) + # This resync retry interval will be in seconds which will check device status at given interval + resync_retry_interval = prov_dict.get("resync_retry_interval", 2) + managed_flag = True + + # Check till device comes into managed state + while resync_retry_count: + response = self.get_device_response(device_ip) + self.log("Device is in {0} state waiting for Managed State.".format(response['managementState']), "DEBUG") + + if ( + response.get('managementState') == "Managed" + and response.get('collectionStatus') == "Managed" + and response.get("hostname") + ): + msg = """Device '{0}' comes to managed state and ready for provisioning with the resync_retry_count + '{1}' left having resync interval of {2} seconds""".format(device_ip, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = True + break + + if response.get('collectionStatus') == "Partial Collection Failure" or response.get('collectionStatus') == "Could Not Synchronize": + device_status = response.get('collectionStatus') + msg = """Device '{0}' comes to '{1}' state and never goes for provisioning with the resync_retry_count + '{2}' left having resync interval of {3} seconds""".format(device_ip, device_status, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = False + break + + time.sleep(resync_retry_interval) + resync_retry_count = resync_retry_count - 1 + + if not managed_flag: + self.log("""Device {0} is not transitioning to the managed state, so provisioning operation cannot + be performed.""".format(device_ip), "WARNING") + continue + + # 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, "ERROR") + continue + + task_id = response.get("taskId") + + while True: + execution_details = self.get_task_details(task_id) + progress = execution_details.get("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.msg = "Device '{0}' already provisioned".format(device_ip) + self.log(self.msg, "INFO") + already_provision_count += 1 + + # Check If all the devices are already provsioned, return from here only + if already_provision_count == len(device_ip_list): + self.handle_all_already_provisioned(device_ip_list, device_type) + elif provision_count == len(device_ip_list): + 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_udf_id(self, field_name): + """ + Get the ID of a Global User Defined Field in Cisco Catalyst Center based on its name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + field_name (str): The name of the Global User Defined Field. + Returns: + str: The ID of the Global User Defined Field. + Description: + The function sends a request to Cisco Catalyst Center to retrieve all Global User Defined Fields + with the specified name and extracts the ID of the first matching field.If successful, it returns + the ID else returns None. + """ + + try: + udf_id = None + response = self.dnac._exec( + family="devices", + function='get_all_user_defined_fields', + params={"name": field_name}, + ) + self.log("Received API response from 'get_all_user_defined_fields': {0}".format(str(response)), "DEBUG") + udf = response.get("response") + if udf: + udf_id = udf[0].get("id") + + except Exception as e: + error_message = "Exception occurred while getting Global User Defined Fields(UDF) ID from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return udf_id + + def mandatory_parameter(self): + """ + Check for and validate mandatory parameters for adding network devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + Returns: + dict: The input `config` dictionary if all mandatory parameters are present. + Description: + It will check the mandatory parameters for adding the devices in Cisco Catalyst Center. + """ + + device_type = self.config[0].get("type", "NETWORK_DEVICE") + params_dict = { + "NETWORK_DEVICE": ["ip_address_list", "password", "username"], + "COMPUTE_DEVICE": ["ip_address_list", "http_username", "http_password", "http_port"], + "MERAKI_DASHBOARD": ["http_password"], + "FIREPOWER_MANAGEMENT_SYSTEM": ["ip_address_list", "http_username", "http_password"], + "THIRD_PARTY_DEVICE": ["ip_address_list"] + } + + params_list = params_dict.get(device_type, []) + + mandatory_params_absent = [] + for param in params_list: + if param not in self.config[0]: + mandatory_params_absent.append(param) + + if mandatory_params_absent: + self.status = "failed" + self.msg = "Required parameters {0} for adding devices are not present".format(str(mandatory_params_absent)) + self.result['msg'] = self.msg + self.log(self.msg, "ERROR") + else: + self.status = "success" + self.msg = "Required parameter for Adding the devices in Inventory are present." + self.log(self.msg, "INFO") + + return self + + def get_have(self, config): + """ + Retrieve and check device information with Cisco Catalyst Center to determine if devices already exist. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + config (dict): A dictionary containing the configuration details of devices to be checked. + Returns: + dict: A dictionary containing information about the devices in the playbook, devices that exist in + Cisco Catalyst Center, and devices that are not present in Cisco Catalyst Center. + Description: + This function checks the specified devices in the playbook against the devices existing in Cisco Catalyst Center with following keys: + - "want_device": A list of devices specified in the playbook. + - "device_in_dnac": A list of devices that already exist in Cisco Catalyst Center. + - "device_not_in_dnac": A list of devices that are not present in Cisco Catalyst Center. + """ + + have = {} + want_device = self.get_device_ips_from_config_priority() + + # Get the list of device that are present in Cisco Catalyst Center + device_in_dnac = self.device_exists_in_dnac() + device_not_in_dnac, devices_in_playbook = [], [] + + for ip in want_device: + devices_in_playbook.append(ip) + if ip not in device_in_dnac: + device_not_in_dnac.append(ip) + + if self.config[0].get('provision_wired_device'): + provision_wired_list = self.config[0].get('provision_wired_device') + + for prov_dict in provision_wired_list: + device_ip_address = prov_dict['device_ip'] + if device_ip_address not in want_device: + devices_in_playbook.append(device_ip_address) + if device_ip_address not in device_in_dnac: + device_not_in_dnac.append(device_ip_address) + + if support_for_provisioning_wireless: + if self.config[0].get('provision_wireless_device'): + provision_wireless_list = self.config[0].get('provision_wireless_device') + + for prov_dict in provision_wireless_list: + device_ip_address = prov_dict['device_ip'] + if device_ip_address not in want_device and device_ip_address not in devices_in_playbook: + devices_in_playbook.append(device_ip_address) + if device_ip_address not in device_in_dnac and device_ip_address not in device_not_in_dnac: + device_not_in_dnac.append(device_ip_address) + + self.log("Device(s) {0} exists in Cisco Catalyst Center".format(str(device_in_dnac)), "INFO") + have["want_device"] = want_device + have["device_in_dnac"] = device_in_dnac + have["device_not_in_dnac"] = device_not_in_dnac + have["devices_in_playbook"] = devices_in_playbook + + self.have = have + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + + return self + + def get_device_params(self, params): + """ + Extract and store device parameters from the playbook for device processing in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + params (dict): A dictionary containing device parameters retrieved from the playbook. + Returns: + dict: A dictionary containing the extracted device parameters. + Description: + This function will extract and store parameters in dictionary for adding, updating, editing, or deleting devices Cisco Catalyst Center. + """ + + device_param = { + "cliTransport": params.get("cli_transport"), + "enablePassword": params.get("enable_password"), + "password": params.get("password"), + "ipAddress": params.get("ip_address_list"), + "snmpAuthPassphrase": params.get("snmp_auth_passphrase"), + "snmpAuthProtocol": params.get("snmp_auth_protocol"), + "snmpMode": params.get("snmp_mode"), + "snmpPrivPassphrase": params.get("snmp_priv_passphrase"), + "snmpPrivProtocol": params.get("snmp_priv_protocol"), + "snmpROCommunity": params.get("snmp_ro_community"), + "snmpRWCommunity": params.get("snmp_rw_community"), + "snmpRetry": params.get("snmp_retry"), + "snmpTimeout": params.get("snmp_timeout"), + "snmpUserName": params.get("snmp_username"), + "userName": params.get("username"), + "computeDevice": params.get("compute_device"), + "extendedDiscoveryInfo": params.get("extended_discovery_info"), + "httpPassword": params.get("http_password"), + "httpPort": params.get("http_port"), + "httpSecure": params.get("http_secure"), + "httpUserName": params.get("http_username"), + "netconfPort": params.get("netconf_port"), + "snmpVersion": params.get("snmp_version"), + "type": params.get("type"), + "updateMgmtIPaddressList": params.get("update_mgmt_ipaddresslist"), + "forceSync": params.get("force_sync"), + "cleanConfig": params.get("clean_config") + } + + if device_param.get("updateMgmtIPaddressList"): + device_mngmt_dict = device_param.get("updateMgmtIPaddressList")[0] + device_param["updateMgmtIPaddressList"][0] = {} + + device_param["updateMgmtIPaddressList"][0].update( + { + "existMgmtIpAddress": device_mngmt_dict.get("exist_mgmt_ipaddress"), + "newMgmtIpAddress": device_mngmt_dict.get("new_mgmt_ipaddress") + }) + + return device_param + + def get_device_ids(self, device_ips): + """ + Get the list of unique device IDs for list of specified management IP addresses of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ips (list): The management IP addresses of devices for which you want to retrieve the device IDs. + Returns: + list: The list of unique device IDs for the specified devices. + Description: + Queries Cisco Catalyst Center to retrieve the unique device ID associated with a device having the specified + IP address. If the device is not found in Cisco Catalyst Center, then print the log message with error severity. + """ + + device_ids = [] + + for device_ip in device_ips: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if not response: + continue + device_id = response[0]["id"] + device_ids.append(device_id) + + except Exception as e: + error_message = "Error while fetching device '{0}' from Cisco Catalyst Center: {1}".format(device_ip, str(e)) + self.log(error_message, "ERROR") + + return device_ids + + def get_device_ips_from_hostname(self, hostname_list): + """ + Get the list of unique device IPs for list of specified hostnames of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + hostname_list (list): The hostnames of devices for which you want to retrieve the device IPs. + Returns: + list: The list of unique device IPs for the specified devices hostname list. + Description: + Queries Cisco Catalyst Center to retrieve the unique device IP's associated with a device having the specified + list of hostnames. If a device is not found in Cisco Catalyst Center, an error log message is printed. + """ + + device_ips = [] + for hostname in hostname_list: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"hostname": hostname} + ) + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if response: + device_ip = response[0]["managementIpAddress"] + if device_ip: + device_ips.append(device_ip) + except Exception as e: + error_message = "Exception occurred while fetching device from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return device_ips + + def get_device_ips_from_serial_number(self, serial_number_list): + """ + Get the list of unique device IPs for a specified list of serial numbers in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + serial_number_list (list): The list of serial number of devices for which you want to retrieve the device IPs. + Returns: + list: The list of unique device IPs for the specified devices with serial numbers. + Description: + Queries Cisco Catalyst Center to retrieve the unique device IPs associated with a device having the specified + serial numbers.If a device is not found in Cisco Catalyst Center, an error log message is printed. + """ + + device_ips = [] + for serial_number in serial_number_list: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"serialNumber": serial_number} + ) + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if response: + device_ip = response[0]["managementIpAddress"] + if device_ip: + device_ips.append(device_ip) + except Exception as e: + error_message = "Exception occurred while fetching device from Cisco Catalyst Center - {0}".format(str(e)) + self.log(error_message, "ERROR") + + return device_ips + + def get_device_ips_from_mac_address(self, mac_address_list): + """ + Get the list of unique device IPs for list of specified mac address of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + mac_address_list (list): The list of mac address of devices for which you want to retrieve the device IPs. + Returns: + list: The list of unique device IPs for the specified devices. + Description: + Queries Cisco Catalyst Center to retrieve the unique device IPs associated with a device having the specified + mac addresses. If a device is not found in Cisco Catalyst Center, an error log message is printed. + """ + + device_ips = [] + for mac_address in mac_address_list: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"macAddress": mac_address} + ) + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if response: + device_ip = response[0]["managementIpAddress"] + if device_ip: + device_ips.append(device_ip) + except Exception as e: + error_message = "Exception occurred while fetching device from Cisco Catalyst Center - {0}".format(str(e)) + self.log(error_message, "ERROR") + + return device_ips + + def get_interface_from_id_and_name(self, device_id, interface_name): + """ + Retrieve the interface ID for a device in Cisco Catalyst Center based on device id and interface name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_id (str): The id of the device. + interface_name (str): Name of the interface for which details need to be collected. + Returns: + str: The interface ID for the specified device and interface name. + Description: + The function sends a request to Cisco Catalyst Center to retrieve the interface information + for the device with the provided device id and interface name and extracts the interface ID from the + response, and returns the interface ID. + """ + + try: + interface_detail_params = { + 'device_id': device_id, + 'name': interface_name + } + response = self.dnac._exec( + family="devices", + function='get_interface_details', + params=interface_detail_params + ) + self.log("Received API response from 'get_interface_details': {0}".format(str(response)), "DEBUG") + response = response.get("response") + + if response: + self.status = "success" + interface_id = response["id"] + self.log("""Successfully fetched interface ID ({0}) by using device id {1} and interface name {2}.""" + .format(interface_id, device_id, interface_name), "INFO") + return interface_id + + except Exception as e: + error_message = "Error while fetching interface id for interface({0}) from Cisco Catalyst Center: {1}".format(interface_name, str(e)) + self.log(error_message, "ERROR") + self.msg = error_message + self.status = "failed" + return self + + def get_interface_from_ip(self, device_ip): + """ + Get the interface ID for a device in Cisco Catalyst Center based on its IP address. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ip (str): The IP address of the device. + Returns: + str: The interface ID for the specified device. + Description: + The function sends a request to Cisco Catalyst Center to retrieve the interface information + for the device with the provided IP address and extracts the interface ID from the + response, and returns the interface ID. + """ + + try: + response = self.dnac._exec( + family="devices", + function='get_interface_by_ip', + params={"ip_address": device_ip} + ) + self.log("Received API response from 'get_interface_by_ip': {0}".format(str(response)), "DEBUG") + response = response.get("response") + + if response: + interface_id = response[0]["id"] + self.log("Fetch Interface Id for device '{0}' successfully !!".format(device_ip)) + return interface_id + + except Exception as e: + error_message = "Error while fetching Interface Id for device '{0}' from Cisco Catalyst Center: {1}".format(device_ip, str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + def get_device_response(self, device_ip): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst Center. + Description: + This method communicates with Cisco Catalyst 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 Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + 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 Catalyst Center match the specified values in the configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst Center using the + 'get_device_response' method and compares the retrieved values with specified values in the configuration + for updating device roles. + """ + + role = self.config[0].get('role') + response = self.get_device_response(device_ip) + + return response.get('role') == role + + def check_interface_details(self, device_ip, interface_name): + """ + Checks if the interface details for a device in Cisco Catalyst Center match the specified values in the configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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. + """ + device_id = self.get_device_ids([device_ip]) + + if not device_id: + self.log("""Error: Device with IP '{0}' not found in Cisco Catalyst Center.Unable to update interface details.""" + .format(device_ip), "ERROR") + return False + + interface_detail_params = { + 'device_id': device_id[0], + 'name': interface_name + } + response = self.dnac._exec( + family="devices", + function='get_interface_details', + params=interface_detail_params + ) + self.log("Received API response from 'get_interface_details': {0}".format(str(response)), "DEBUG") + response = response.get("response") + + if not response: + self.log("No response received from the API 'get_interface_details'.", "DEBUG") + return False + + 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 Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + bool: True if the credentials match the updated values, False otherwise. + Description: + This method triggers the export API in Cisco Catalyst 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.get_device_ips_from_config_priority() + 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'], + '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 Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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')), "ERROR") + return False + + return True + + def clear_mac_address(self, interface_id, deploy_mode, interface_name): + """ + Clear the MAC address table on a specific interface of a device. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + interface_id (str): The UUID of the interface where the MAC addresses will be cleared. + deploy_mode (str): The deployment mode of the device. + interface_name(str): The name of the interface for which the MAC addresses will be cleared. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function clears the MAC address table on a specific interface of a device. + The 'deploy_mode' parameter specifies the deployment mode of the device. + If the operation is successful, the function returns the response from the API call. + If an error occurs during the operation, the function logs the error details and updates the status accordingly. + """ + + try: + payload = { + "operation": "ClearMacAddress", + "payload": {} + } + clear_mac_address_payload = { + 'payload': payload, + 'interface_uuid': interface_id, + 'deployment_mode': deploy_mode + } + response = self.dnac._exec( + family="devices", + function='clear_mac_address_table', + op_modifies=True, + params=clear_mac_address_payload, + ) + self.log("Received API response from 'clear_mac_address_table': {0}".format(str(response)), "DEBUG") + + if not (response and isinstance(response, dict)): + self.status = "failed" + self.msg = """Received an empty response from the API 'clear_mac_address_table'. This indicates a failure to clear + the Mac address table for the interface '{0}'""".format(interface_name) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + return self + + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Failed to clear the Mac address table for the interface '{0}' due to {1}".format(interface_name, failure_reason) + else: + self.msg = "Failed to clear the Mac address table for the interface '{0}'".format(interface_name) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + break + elif 'clear mac address-table' in execution_details.get("data"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Successfully executed the task of clearing the Mac address table for interface '{0}'".format(interface_name) + self.log(self.msg, "INFO") + break + + except Exception as e: + error_msg = """An exception occurred during the process of clearing the MAC address table for interface {0}, due to - + {1}""".format(interface_name, str(e)) + self.log(error_msg, "WARNING") + self.result['changed'] = False + self.result['response'] = error_msg + + return self + + def update_interface_detail_of_device(self, device_to_update): + """ + Update interface details for a device in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_to_update (list): A list of IP addresses of devices to be updated. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method updates interface details for devices in Cisco Catalyst Center. + It iterates over the list of devices to be updated, retrieves interface parameters from the configuration, + calls the update interface details API with the required parameters, and checks the execution response. + If the update is successful, it sets the status to 'success' and logs an informational message. + """ + + # Call the Get interface details by device IP API and fetch the interface Id + for device_ip in device_to_update: + interface_params = self.config[0].get('update_interface_details') + interface_names_list = interface_params.get('interface_name') + for interface_name in interface_names_list: + device_id = self.get_device_ids([device_ip]) + interface_id = self.get_interface_from_id_and_name(device_id[0], interface_name) + self.check_return_status() + + # Now we call update interface details api with required parameter + try: + interface_params = self.config[0].get('update_interface_details') + clear_mac_address_table = interface_params.get("clear_mac_address_table", False) + + if clear_mac_address_table: + response = self.get_device_response(device_ip) + + if response.get('role').upper() != "ACCESS": + self.msg = "The action to clear the MAC Address table is only supported for devices with the ACCESS role." + self.log(self.msg, "WARNING") + self.result['response'] = self.msg + else: + deploy_mode = interface_params.get('deployment_mode', 'Deploy') + self.clear_mac_address(interface_id, deploy_mode, interface_name) + self.check_return_status() + + temp_params = { + 'description': interface_params.get('description', ''), + 'adminStatus': interface_params.get('admin_status'), + 'voiceVlanId': interface_params.get('voice_vlan_id'), + 'vlanId': interface_params.get('vlan_id') + } + payload_params = {} + for key, value in temp_params.items(): + if value is not None: + payload_params[key] = value + + update_interface_params = { + 'payload': payload_params, + 'interface_uuid': interface_id, + 'deployment_mode': interface_params.get('deployment_mode', 'Deploy') + } + response = self.dnac._exec( + family="devices", + function='update_interface_details', + op_modifies=True, + params=update_interface_params, + ) + self.log("Received API response from 'update_interface_details': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'SUCCESS' in execution_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Updated Interface Details for device '{0}' successfully".format(device_ip) + self.log(self.msg, "INFO") + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Interface Updation get failed because of {0}".format(failure_reason) + else: + self.msg = "Interface Updation get failed" + self.log(self.msg, "ERROR") + break + + except Exception as e: + error_message = "Error while updating interface details in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "INFO") + 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(self.msg, "INFO") + + return self + + def check_managementip_execution_response(self, response, device_ip, new_mgmt_ipaddress): + """ + Check the execution response of a management IP update task. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + response (dict): The response received after initiating the management IP update task. + device_ip (str): The IP address of the device for which the management IP was updated. + new_mgmt_ipaddress (str): The new management IP address of the device. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the execution response of a management IP update task in Cisco Catalyst Center. + It continuously queries the task details until the task is completed or an error occurs. + If the task is successful, it sets the status to 'success' and logs an informational message. + If the task fails, it sets the status to 'failed' and logs an error message with the failure reason, if available. + """ + + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + if execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device new management IP updation for device '{0}' get failed due to {1}".format(device_ip, failure_reason) + else: + self.msg = "Device new management IP updation for device '{0}' get failed".format(device_ip) + self.log(self.msg, "ERROR") + break + elif execution_details.get("endTime"): + self.status = "success" + self.result['changed'] = True + self.msg = """Device '{0}' present in Cisco Catalyst Center and new management ip '{1}' have been + updated successfully""".format(device_ip, new_mgmt_ipaddress) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + break + + return self + + def check_device_update_execution_response(self, response, device_ip): + """ + Check the execution response of a device update task. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + response (dict): The response received after initiating the device update task. + device_ip (str): The IP address of the device for which the update is performed. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the execution response of a device update task in Cisco Catalyst Center. + It continuously queries the task details until the task is completed or an error occurs. + If the task is successful, it sets the status to 'success' and logs an informational message. + If the task fails, it sets the status to 'failed' and logs an error message with the failure reason, if available. + """ + + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device Updation for device '{0}' get failed due to {1}".format(device_ip, failure_reason) + else: + self.msg = "Device Updation for device '{0}' get failed".format(device_ip) + self.log(self.msg, "ERROR") + break + elif execution_details.get("endTime"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Device '{0}' present in Cisco Catalyst Center and have been updated successfully".format(device_ip) + self.log(self.msg, "INFO") + break + + return self + + def is_device_exist_in_ccc(self, device_ip): + """ + Check if a device with the given IP exists in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ip (str): The IP address of the device to check. + Returns: + bool: True if the device exists, False otherwise. + Description: + This method queries Cisco Catalyst Center to check if a device with the specified + management IP address exists. If the device exists, it returns True; otherwise, + it returns False. If an error occurs during the process, it logs an error message + and raises an exception. + """ + + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + response = response.get('response') + if not response: + self.log("Device with given IP '{0}' is not present in Cisco Catalyst Center".format(device_ip), "INFO") + return False + + return True + + except Exception as e: + error_message = "Error while getting the response of device '{0}' from Cisco Catalyst Center: {1}".format(device_ip, str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + def is_device_exist_for_update(self, device_to_update): + """ + Check if the device(s) exist in Cisco Catalyst Center for update operation. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_to_update (list): A list of device(s) to be be checked present in Cisco Catalyst Center. + Returns: + bool: True if at least one of the devices to be updated exists in Cisco Catalyst Center, + False otherwise. + Description: + This function checks if any of the devices specified in the 'device_to_update' list + exists in Cisco Catalyst Center. It iterates through the list of devices and compares + each device with the list of devices present in Cisco Catalyst Center obtained from + 'self.have.get("device_in_ccc")'. If a match is found, it sets 'device_exist' to True + and breaks the loop. + """ + + # First check if device present in Cisco Catalyst Center or not + device_exist = False + for device in device_to_update: + if device in self.have.get("device_in_ccc"): + device_exist = True + break + + return device_exist + + def get_want(self, config): + """ + Get all the device related information from playbook that is needed to be + add/update/delete/resync device in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing device-related information from the playbook. + Returns: + dict: A dictionary containing the extracted device parameters and other relevant information. + Description: + Retrieve all the device-related information from the playbook needed for adding, updating, deleting, + or resyncing devices in Cisco Catalyst Center. + """ + + want = {} + device_params = self.get_device_params(config) + want["device_params"] = device_params + + self.want = want + self.msg = "Successfully collected all parameters from the playbook " + self.status = "success" + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + return self + + def get_diff_merged(self, config): + """ + Merge and process differences between existing devices and desired device configuration in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing the desired device configuration and relevant information from the playbook. + Returns: + object: An instance of the class with updated results and status based on the processing of differences. + Description: + The function processes the differences and, depending on the changes required, it may add, update, + or resynchronize devices in Cisco Catalyst Center. + The updated results and status are stored in the class instance for further use. + """ + + devices_to_add = self.have["device_not_in_dnac"] + device_type = self.config[0].get("type", "NETWORK_DEVICE") + device_resynced = self.config[0].get("device_resync", False) + device_reboot = self.config[0].get("reboot_device", False) + credential_update = self.config[0].get("credential_update", False) + + config['type'] = device_type + if device_type == "FIREPOWER_MANAGEMENT_SYSTEM": + config['http_port'] = self.config[0].get("http_port", "443") + + config['ip_address_list'] = devices_to_add + + if self.config[0].get('update_mgmt_ipaddresslist'): + device_ip = self.config[0].get('update_mgmt_ipaddresslist')[0].get('existMgmtIpAddress') + is_device_exists = self.is_device_exist_in_ccc(device_ip) + + if not is_device_exists: + self.status = "failed" + self.msg = """Unable to update the Management IP address because the device with IP '{0}' is not + found in Cisco Catalyst Center.""".format(device_ip) + self.log(self.msg, "ERROR") + return self + + if self.config[0].get('update_interface_details'): + device_to_update = self.get_device_ips_from_config_priority() + device_exist = self.is_device_exist_for_update(device_to_update) + + if not device_exist: + self.msg = """Unable to update interface details because the device(s) listed: {0} are not present in the + Cisco Catalyst Center.""".format(str(device_to_update)) + self.status = "failed" + self.result['response'] = self.msg + self.log(self.msg, "ERROR") + return self + + if self.config[0].get('role'): + devices_to_update_role = self.get_device_ips_from_config_priority() + device_exist = self.is_device_exist_for_update(devices_to_update_role) + + if not device_exist: + self.msg = """Unable to update device role because the device(s) listed: {0} are not present in the Cisco + Catalyst Center.""".format(str(devices_to_update_role)) + self.status = "failed" + self.result['response'] = self.msg + self.log(self.msg, "ERROR") + return self + + if credential_update: + device_to_update = self.get_device_ips_from_config_priority() + device_exist = self.is_device_exist_for_update(device_to_update) + + if not device_exist: + self.msg = """Unable to edit device credentials/details because the device(s) listed: {0} are not present in the + Cisco Catalyst Center.""".format(str(device_to_update)) + self.status = "failed" + self.result['response'] = self.msg + self.log(self.msg, "ERROR") + return self + + if not config['ip_address_list']: + self.msg = "Devices '{0}' already present in Cisco Catalyst Center".format(self.have['devices_in_playbook']) + self.log(self.msg, "INFO") + self.result['changed'] = False + self.result['response'] = self.msg + else: + # To add the devices in inventory + input_params = self.want.get("device_params") + device_params = input_params.copy() + + if not device_params['snmpVersion']: + device_params['snmpVersion'] = "v3" + + device_params['ipAddress'] = config['ip_address_list'] + if device_params['snmpVersion'] == "v2": + params_to_remove = ["snmpAuthPassphrase", "snmpAuthProtocol", "snmpMode", "snmpPrivPassphrase", "snmpPrivProtocol", "snmpUserName"] + for param in params_to_remove: + device_params.pop(param, None) + + if not device_params['snmpROCommunity']: + self.status = "failed" + self.msg = "Required parameter 'snmpROCommunity' for adding device with snmmp version v2 is not present" + self.result['msg'] = self.msg + self.log(self.msg, "ERROR") + return self + else: + if not device_params['snmpMode']: + device_params['snmpMode'] = "AUTHPRIV" + + if not device_params['cliTransport']: + device_params['cliTransport'] = "ssh" + + if not device_params['snmpPrivProtocol']: + device_params['snmpPrivProtocol'] = "AES128" + + if device_params['snmpPrivProtocol'] == "AES192": + device_params['snmpPrivProtocol'] = "CISCOAES192" + elif device_params['snmpPrivProtocol'] == "AES256": + device_params['snmpPrivProtocol'] = "CISCOAES256" + + if device_params['snmpMode'] == "NOAUTHNOPRIV": + device_params.pop('snmpAuthPassphrase', None) + device_params.pop('snmpPrivPassphrase', None) + device_params.pop('snmpPrivProtocol', None) + device_params.pop('snmpAuthProtocol', None) + elif device_params['snmpMode'] == "AUTHNOPRIV": + device_params.pop('snmpPrivPassphrase', None) + device_params.pop('snmpPrivProtocol', None) + + self.mandatory_parameter().check_return_status() + try: + response = self.dnac._exec( + family="devices", + function='add_device', + op_modifies=True, + params=device_params, + ) + self.log("Received API response from 'add_device': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if '/task/' in execution_details.get("progress"): + self.status = "success" + self.result['response'] = execution_details + + if len(devices_to_add) > 0: + self.result['changed'] = True + self.msg = "Device(s) '{0}' added to Cisco Catalyst Center".format(str(devices_to_add)) + self.log(self.msg, "INFO") + self.result['msg'] = self.msg + break + self.msg = "Device(s) '{0}' already present in Cisco Catalyst Center".format(str(self.config[0].get("ip_address_list"))) + self.log(self.msg, "INFO") + self.result['msg'] = self.msg + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device addition get failed because of {0}".format(failure_reason) + else: + self.msg = "Device addition get failed" + self.log(self.msg, "ERROR") + self.result['msg'] = self.msg + return self + + except Exception as e: + error_message = "Error while adding device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + # Update the role of devices having the role source as Manual + if self.config[0].get('role'): + devices_to_update_role = self.get_device_ips_from_config_priority() + device_role = self.config[0].get('role') + role_update_count = 0 + for device_ip in devices_to_update_role: + device_id = self.get_device_ids([device_ip]) + + # 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] + + if response.get('role') == device_role: + self.status = "success" + self.result['changed'] = False + role_update_count += 1 + log_msg = "The device role '{0}' is already set in Cisco Catalyst Center, no update is needed.".format(device_role) + self.log(log_msg, "INFO") + continue + + device_role_params = { + 'role': device_role, + 'roleSource': "MANUAL", + 'id': device_id[0] + } + + try: + response = self.dnac._exec( + family="devices", + function='update_device_role', + op_modifies=True, + params=device_role_params, + ) + self.log("Received API response from 'update_device_role': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + progress = execution_details.get("progress") + + if 'successfully' in progress or 'succesfully' in progress: + self.status = "success" + self.result['changed'] = True + self.msg = "Device(s) '{0}' role updated successfully to '{1}'".format(str(devices_to_update_role), device_role) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + 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, "ERROR") + self.result['response'] = self.msg + break + + except Exception as e: + error_message = "Error while updating device role '{0}' in Cisco Catalyst Center: {1}".format(device_role, str(e)) + self.log(error_message, "ERROR") + + if role_update_count == len(devices_to_update_role): + self.status = "success" + self.result['changed'] = False + self.msg = """The device role '{0}' is already set in Cisco Catalyst Center, no device role update is needed for the + devices {1}.""".format(device_role, str(devices_to_update_role)) + self.log(self.msg, "INFO") + self.result['response'] = self.msg + + if credential_update: + device_to_update = self.get_device_ips_from_config_priority() + # Update Device details and credentails + device_uuids = self.get_device_ids(device_to_update) + password = "Testing@123" + export_payload = {"deviceUuids": device_uuids, "password": password, "operationEnum": "0"} + export_response = self.trigger_export_api(export_payload) + self.check_return_status() + csv_reader = self.decrypt_and_read_csv(export_response, password) + self.check_return_status() + device_details = {} + + for row in csv_reader: + ip_address = row['ip_address'] + device_details[ip_address] = row + + for device_ip in device_to_update: + playbook_params = self.want.get("device_params").copy() + playbook_params['ipAddress'] = [device_ip] + device_data = device_details[device_ip] + if device_data['snmpv3_privacy_password'] == ' ': + device_data['snmpv3_privacy_password'] = None + if device_data['snmpv3_auth_password'] == ' ': + device_data['snmpv3_auth_password'] = None + + if not playbook_params['snmpMode']: + if device_data['snmpv3_privacy_password']: + playbook_params['snmpMode'] = "AUTHPRIV" + elif device_data['snmpv3_auth_password']: + playbook_params['snmpMode'] = "AUTHNOPRIV" + else: + playbook_params['snmpMode'] = "NOAUTHNOPRIV" + + if not playbook_params['cliTransport']: + if device_data['protocol'] == "ssh2": + playbook_params['cliTransport'] = "ssh" + else: + playbook_params['cliTransport'] = device_data['protocol'] + if not playbook_params['snmpPrivProtocol']: + playbook_params['snmpPrivProtocol'] = device_data['snmpv3_privacy_type'] + + csv_data_dict = { + 'username': device_data['cli_username'], + 'password': device_data['cli_password'], + 'enable_password': device_data['cli_enable_password'], + 'netconf_port': device_data['netconf_port'], + } + + if device_data['snmp_version'] == '3': + csv_data_dict['snmp_username'] = device_data['snmpv3_user_name'] + if device_data['snmpv3_privacy_password']: + csv_data_dict['snmp_auth_passphrase'] = device_data['snmpv3_auth_password'] + csv_data_dict['snmp_priv_passphrase'] = device_data['snmpv3_privacy_password'] + else: + csv_data_dict['snmp_username'] = None + + device_key_mapping = { + 'username': 'userName', + 'password': 'password', + 'enable_password': 'enablePassword', + 'snmp_username': 'snmpUserName', + 'netconf_port': 'netconfPort' + } + device_update_key_list = ["username", "password", "enable_password", "snmp_username", "netconf_port"] + + for key in device_update_key_list: + mapped_key = device_key_mapping[key] + + if playbook_params[mapped_key] is None: + playbook_params[mapped_key] = csv_data_dict[key] + + if playbook_params['snmpMode'] == "AUTHPRIV": + if not playbook_params['snmpAuthPassphrase']: + playbook_params['snmpAuthPassphrase'] = csv_data_dict['snmp_auth_passphrase'] + if not playbook_params['snmpPrivPassphrase']: + playbook_params['snmpPrivPassphrase'] = csv_data_dict['snmp_priv_passphrase'] + + if playbook_params['snmpPrivProtocol'] == "AES192": + playbook_params['snmpPrivProtocol'] = "CISCOAES192" + elif playbook_params['snmpPrivProtocol'] == "AES256": + playbook_params['snmpPrivProtocol'] = "CISCOAES256" + + if playbook_params['snmpMode'] == "NOAUTHNOPRIV": + playbook_params.pop('snmpAuthPassphrase', None) + playbook_params.pop('snmpPrivPassphrase', None) + playbook_params.pop('snmpPrivProtocol', None) + playbook_params.pop('snmpAuthProtocol', None) + elif playbook_params['snmpMode'] == "AUTHNOPRIV": + playbook_params.pop('snmpPrivPassphrase', None) + playbook_params.pop('snmpPrivProtocol', None) + + if playbook_params['netconfPort'] == " ": + playbook_params['netconfPort'] = None + + if playbook_params['enablePassword'] == " ": + playbook_params['enablePassword'] = None + + if playbook_params['netconfPort'] and playbook_params['cliTransport'] == "telnet": + self.log("""Updating the device cli transport from ssh to telnet with netconf port '{0}' so make + netconf port as None to perform the device update task""".format(playbook_params['netconfPort']), "DEBUG") + playbook_params['netconfPort'] = None + + if not playbook_params['snmpVersion']: + if device_data['snmp_version'] == '3': + playbook_params['snmpVersion'] = "v3" + else: + playbook_params['snmpVersion'] = "v2" + + if playbook_params['snmpVersion'] == 'v2': + params_to_remove = ["snmpAuthPassphrase", "snmpAuthProtocol", "snmpMode", "snmpPrivPassphrase", "snmpPrivProtocol", "snmpUserName"] + for param in params_to_remove: + playbook_params.pop(param, None) + + if not playbook_params['snmpROCommunity']: + playbook_params['snmpROCommunity'] = device_data.get('snmp_community', None) + + try: + if playbook_params['updateMgmtIPaddressList']: + new_mgmt_ipaddress = playbook_params['updateMgmtIPaddressList'][0]['newMgmtIpAddress'] + if new_mgmt_ipaddress in self.have['device_in_dnac']: + self.status = "failed" + self.msg = "Device with IP address '{0}' already exists in inventory".format(new_mgmt_ipaddress) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + else: + self.log("Playbook parameter for updating device new management ip address: {0}".format(str(playbook_params)), "DEBUG") + response = self.dnac._exec( + family="devices", + function='sync_devices', + op_modifies=True, + params=playbook_params, + ) + self.log("Received API response from 'sync_devices': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + self.check_managementip_execution_response(response, device_ip, new_mgmt_ipaddress) + self.check_return_status() + + else: + self.log("Playbook parameter for updating devices: {0}".format(str(playbook_params)), "DEBUG") + response = self.dnac._exec( + family="devices", + function='sync_devices', + op_modifies=True, + params=playbook_params, + ) + self.log("Received API response from 'sync_devices': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + self.check_device_update_execution_response(response, device_ip) + self.check_return_status() + + except Exception as e: + error_message = "Error while updating device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + # Update list of interface details on specific or list of devices. + if self.config[0].get('update_interface_details'): + device_to_update = self.get_device_ips_from_config_priority() + self.update_interface_detail_of_device(device_to_update).check_return_status() + + # If User defined field(UDF) not present then create it and add multiple udf to specific or list of devices + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + + for udf in udf_field_list: + field_name = udf.get('name') + + if field_name is None: + self.status = "failed" + self.msg = "Error: The mandatory parameter 'name' for the User Defined Field is missing. Please provide the required information." + self.log(self.msg, "ERROR") + 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.log("Global User Defined Field '{0}' does not present in Cisco Catalyst Center, we need to create it".format(field_name), "DEBUG") + self.create_user_defined_field(udf).check_return_status() + + # Get device Id based on config priority + device_ips = self.get_device_ips_from_config_priority() + device_ids = self.get_device_ids(device_ips) + + if len(device_ids) == 0: + self.status = "failed" + self.msg = """Unable to assign Global User Defined Field: No devices found in Cisco Catalyst Center. + Please add devices to proceed.""" + self.log(self.msg, "INFO") + self.result['changed'] = False + return self + + # Now add code for adding Global UDF to device with Id + self.add_field_to_devices(device_ids, udf).check_return_status() + + self.result['changed'] = True + self.msg = "Global User Defined Field(UDF) named '{0}' has been successfully added to the device.".format(field_name) + self.log(self.msg, "INFO") + + # 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 + # Defer this feature as API issue is there once it's fixed we will addresses it in upcoming release iac2.0 + if support_for_provisioning_wireless: + if self.config[0].get('provision_wireless_device'): + self.provisioned_wireless_devices().check_return_status() + + if device_resynced: + self.resync_devices().check_return_status() + + if device_reboot: + self.reboot_access_points().check_return_status() + + if self.config[0].get('export_device_list'): + self.export_device_details().check_return_status() + + return self + + def get_diff_deleted(self, config): + """ + Delete devices in Cisco Catalyst Center based on device IP Address. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center + config (dict): A dictionary containing the list of device IP addresses to be deleted. + Returns: + object: An instance of the class with updated results and status based on the deletion operation. + Description: + This function is responsible for removing devices from the Cisco Catalyst Center inventory and + also unprovsioned and removed wired provsion devices from the Inventory page and also delete + the Global User Defined Field that are associated to the devices. + """ + + device_to_delete = self.get_device_ips_from_config_priority() + self.result['msg'] = [] + + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + for udf in udf_field_list: + field_name = udf.get('name') + udf_id = self.get_udf_id(field_name) + + if udf_id is None: + self.status = "success" + self.msg = "Global UDF '{0}' is not present in Cisco Catalyst Center".format(field_name) + self.log(self.msg, "INFO") + self.result['changed'] = False + self.result['msg'] = self.msg + self.result['response'] = self.msg + return self + + try: + response = self.dnac._exec( + family="devices", + function='delete_user_defined_field', + params={"id": udf_id}, + ) + if response and isinstance(response, dict): + self.log("Received API response from 'delete_user_defined_field': {0}".format(str(response)), "DEBUG") + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'success' in execution_details.get("progress"): + self.status = "success" + self.msg = "Global UDF '{0}' deleted successfully from Cisco Catalyst Center".format(field_name) + self.log(self.msg, "INFO") + 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 = "Failed to delete Global User Defined Field(UDF) due to: {0}".format(failure_reason) + else: + self.msg = "Global UDF deletion get failed." + self.log(self.msg, "ERROR") + break + + except Exception as e: + error_message = "Error while deleting Global UDF from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + return self + + for device_ip in device_to_delete: + if device_ip not in self.have.get("device_in_dnac"): + self.status = "success" + self.result['changed'] = False + self.msg = "Device '{0}' is not present in Cisco Catalyst Center so can't perform delete operation".format(device_ip) + self.result['msg'].append(self.msg) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + continue + + try: + provision_params = { + "device_management_ip_address": device_ip + } + prov_respone = self.dnac._exec( + family="sda", + function='get_provisioned_wired_device', + params=provision_params, + ) + + if prov_respone.get("status") == "success": + response = self.dnac._exec( + family="sda", + function='delete_provisioned_wired_device', + params=provision_params, + ) + 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, "INFO") + self.result['response'].append(self.msg) + break + elif execution_details.get("bapiError"): + self.msg = execution_details.get("bapiError") + self.log(self.msg, "ERROR") + self.result['response'].append(self.msg) + break + except Exception as e: + device_id = self.get_device_ids([device_ip]) + delete_params = { + "id": device_id[0], + "clean_config": self.config[0].get("clean_config", False) + } + response = self.dnac._exec( + family="devices", + function='delete_device_by_id', + params=delete_params, + ) + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'success' in execution_details.get("progress"): + self.status = "success" + self.msg = "Device '{0}' was successfully deleted from Cisco Catalyst Center".format(device_ip) + self.log(self.msg, "INFO") + 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 '{0}' deletion get failed due to: {1}".format(device_ip, failure_reason) + else: + self.msg = "Device '{0}' deletion get failed.".format(device_ip) + self.log(self.msg, "ERROR") + break + self.result['msg'].append(self.msg) + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Addition/Updation) of Devices in Cisco Catalyst Center. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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 Catalyst Center configuration. + + The function performs the following verifications: + - Checks for devices added to Cisco Catalyst 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 State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + devices_to_add = self.have["device_not_in_dnac"] + credential_update = self.config[0].get("credential_update", False) + device_type = self.config[0].get("type", "NETWORK_DEVICE") + device_ips = self.get_device_ips_from_config_priority() + + if not devices_to_add: + self.status = "success" + msg = """Requested device(s) '{0}' have been successfully added to the Cisco Catalyst Center and their + addition has been verified.""".format(str(self.have['devices_in_playbook'])) + self.log(msg, "INFO") + else: + self.log("""Playbook's input does not match with Cisco Catalyst Center, indicating that the device addition + task may not have executed successfully.""", "INFO") + + if self.config[0].get('update_interface_details'): + interface_update_flag = True + interface_names_list = self.config[0].get('update_interface_details').get('interface_name') + + for device_ip in device_ips: + for interface_name in interface_names_list: + if not self.check_interface_details(device_ip, interface_name): + 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, "INFO") + else: + self.log("""Playbook's input does not match with Cisco Catalyst Center, indicating that the update + interface details task may not have executed successfully.""", "INFO") + + if 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, "INFO") + else: + self.log("Playbook parameter does not match with Cisco Catalyst Center, meaning device updation task not executed properly.", "INFO") + elif device_type != "NETWORK_DEVICE": + self.log("""Unable to compare the parameter for device type '{0}' in the playbook with the one in Cisco Catalyst Center.""" + .format(device_type), "WARNING") + + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + for udf in udf_field_list: + field_name = udf.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, "INFO") + else: + self.log("""Mismatch between playbook parameter and Cisco Catalyst Center detected, indicating that + the task of creating Global UDF may not have executed successfully.""", "INFO") + + if self.config[0].get('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, "INFO") + else: + self.log("""Mismatch between playbook parameter 'role' and Cisco Catalyst Center detected, indicating the + device role update task may not have executed successfully.""", "INFO") + + if self.config[0].get('provision_wired_device'): + provision_wired_list = self.config[0].get('provision_wired_device') + provision_wired_flag = True + provision_device_list = [] + + for prov_dict in provision_wired_list: + device_ip = prov_dict['device_ip'] + provision_device_list.append(device_ip) + 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(provision_device_list) + self.log(msg, "INFO") + else: + self.log("""Mismatch between playbook's input and Cisco Catalyst Center detected, indicating that + the provisioning task may not have executed successfully.""", "INFO") + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of Device and Global UDF in Cisco Catalyst Center. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified Devices or Global UDF deleted from Cisco Catalyst Center. + """ + + self.get_have(config) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + input_devices = self.have["want_device"] + device_in_dnac = self.device_exists_in_dnac() + + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + for udf in udf_field_list: + field_name = udf.get('name') + udf_id = self.get_udf_id(field_name) + + if udf_id is None: + self.status = "success" + msg = """Global UDF named '{0}' has been successfully deleted from Cisco Catalyst Center and the deletion + has been verified.""".format(field_name) + self.log(msg, "INFO") + + return self + + device_delete_flag = True + for device_ip in input_devices: + if device_ip in device_in_dnac: + device_after_deletion = device_ip + device_delete_flag = False + break + + if device_delete_flag: + self.status = "success" + self.msg = "Requested device(s) '{0}' deleted from Cisco Catalyst Center and the deletion has been verified.".format(str(input_devices)) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between playbook parameter device({0}) and Cisco Catalyst Center detected, indicating that + the device deletion task may not have executed successfully.""".format(device_after_deletion), "INFO") + + return self + + +def main(): + """ main entry point for module execution + """ + + element_spec = {'dnac_host': {'type': 'str', 'required': True, }, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + + dnac_device = DnacDevice(module) + state = dnac_device.params.get("state") + + if state not in dnac_device.supported_states: + dnac_device.status = "invalid" + dnac_device.msg = "State {0} is invalid".format(state) + 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) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/inventory_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/inventory_workflow_manager.py new file mode 100644 index 000000000..3eda0e2cc --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/inventory_workflow_manager.py @@ -0,0 +1,3638 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Madhan Sankaranarayanan, Abhishek Maheshwari") + +DOCUMENTATION = r""" +--- +module: inventory_workflow_manager +short_description: Resource module for Network Device +description: +- Manage operations create, update and delete of the resource Network Device. +- Adds the device with given credential. +- Deletes the network device for the given Id. +- Sync the devices provided as input. +version_added: '6.8.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Abhishek Maheshwari (@abmahesh) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: List of devices with credentails to perform Add/Update/Delete/Resync operation + type: list + elements: dict + required: True + suboptions: + type: + description: Select Device's type from NETWORK_DEVICE, COMPUTE_DEVICE, MERAKI_DASHBOARD, THIRD_PARTY_DEVICE, FIREPOWER_MANAGEMENT_SYSTEM. + NETWORK_DEVICE - This refers to traditional networking equipment such as routers, switches, access points, and firewalls. These devices + are responsible for routing, switching, and providing connectivity within the network. + COMPUTE_DEVICE - These are computing resources such as servers, virtual machines, or containers that are part of the network infrastructure. + Cisco Catalyst Center can integrate with compute devices to provide visibility and management capabilities, ensuring that the network and + compute resources work together seamlessly to support applications and services. + MERAKI_DASHBOARD - It is cloud-based platform used to manage Meraki networking devices, including wireless access points, switches, security + appliances, and cameras. + THIRD_PARTY_DEVICE - This category encompasses devices from vendors other than Cisco or Meraki. Cisco Catalyst Center is designed to support + integration with third-party devices through open standards and APIs. This allows organizations to manage heterogeneous network + environments efficiently using Cisco Catalyst Center's centralized management and automation capabilities. + FIREPOWER_MANAGEMENT_SYSTEM - It is a centralized management console used to manage Cisco's Firepower Next-Generation Firewall (NGFW) devices. + It provides features such as policy management, threat detection, and advanced security analytics. + type: str + default: "NETWORK_DEVICE" + cli_transport: + description: The essential prerequisite for adding Network devices is the specification of the transport + protocol (either SSH or Telnet) used by the device. + type: str + compute_device: + description: Indicates whether a device is a compute device. + type: bool + password: + description: Password for accessing the device and for file encryption during device export. Required for + adding Network Device. Also needed for file encryption while exporting device in a csv file. + type: str + enable_password: + description: Password required for enabling configurations on the device. + type: str + extended_discovery_info: + description: Additional discovery information for the device. + type: str + http_password: + description: HTTP password required for adding compute, Meraki, and Firepower Management Devices. + type: str + http_port: + description: HTTP port number required for adding compute and Firepower Management Devices. + type: str + http_secure: + description: Flag indicating HTTP security. + type: bool + http_username: + description: HTTP username required for adding compute and Firepower Management Devices. + type: str + ip_address_list: + description: A list of the IP addresses for the devices. It is required for tasks such as adding, updating, deleting, + or resyncing devices, with Meraki devices being the exception. + elements: str + type: list + hostname_list: + description: "A list of hostnames representing devices. Operations such as updating, deleting, resyncing, or rebooting + can be performed as alternatives to using IP addresses." + type: list + elements: str + serial_number_list: + description: A list of serial numbers representing devices. Operations such as updating, deleting, resyncing, or rebooting + can be performed as alternatives to using IP addresses. + type: list + elements: str + mac_address_list: + description: "A list of MAC addresses representing devices. Operations such as updating, deleting, resyncing, or rebooting + can be performed as alternatives to using IP addresses." + type: list + elements: str + netconf_port: + description: Specifies the port number for connecting to devices using the Netconf protocol. Netconf (Network Configuration Protocol) + is used for managing network devices. Ensure that the provided port number corresponds to the Netconf service port configured + on your network devices. + NETCONF with user privilege 15 is mandatory for enabling Wireless Services on Wireless capable devices such as Catalyst 9000 series + Switches and C9800 Series Wireless Controllers. The NETCONF credentials are required to connect to C9800 Series Wireless Controllers + as the majority of data collection is done using NETCONF for these Devices. + type: str + username: + description: Username for accessing the device. Required for Adding Network Device. + type: str + snmp_auth_passphrase: + description: SNMP authentication passphrase required for adding network, compute, and third-party devices. + type: str + snmp_auth_protocol: + description: SNMP authentication protocol. + SHA (Secure Hash Algorithm) - cryptographic hash function commonly used for data integrity verification and authentication purposes. + type: str + default: "SHA" + snmp_mode: + description: Device's snmp Mode refer to different SNMP (Simple Network Management Protocol) versions and their corresponding security levels. + NOAUTHNOPRIV - This mode provides no authentication or encryption for SNMP messages. It means that devices communicating using SNMPv1 do + not require any authentication (username/password) or encryption (data confidentiality). This makes it the least secure option. + AUTHNOPRIV - This mode provides authentication but no encryption for SNMP messages. Authentication involves validating the source of the + SNMP messages using a community string (similar to a password). However, the data transmitted between devices is not encrypted, + so it's susceptible to eavesdropping. + AUTHPRIV - This mode provides both authentication and encryption for SNMP messages. It offers the highest level of security among the three + options. Authentication ensures that the source of the messages is genuine, and encryption ensures that the data exchanged between + devices is confidential and cannot be intercepted by unauthorized parties. + type: str + snmp_priv_passphrase: + description: SNMP private passphrase required for adding network, compute, and third-party devices. + type: str + snmp_priv_protocol: + description: SNMP private protocol required for adding network, compute, and third-party devices. + type: str + snmp_ro_community: + description: SNMP Read-Only community required for adding V2C devices. + type: str + snmp_rw_community: + description: SNMP Read-Write community required for adding V2C devices. + type: str + snmp_retry: + description: SNMP retry count. + type: int + default: 3 + snmp_timeout: + description: SNMP timeout duration. + type: int + default: 5 + snmp_username: + description: SNMP username required for adding network, compute, and third-party devices. + type: str + snmp_version: + description: It is a standard protocol used for managing and monitoring network devices. + v2 - In this communication between the SNMP manager (such as Cisco Catalyst) and the managed devices + (such as routers, switches, or access points) is based on community strings.Community strings serve + as form of authentication and they are transmitted in clear text, providing no encryption. + v3 - It is the most secure version of SNMP, providing authentication, integrity, and encryption features. + It allows for the use of usernames, authentication passwords, and encryption keys, providing stronger + security compared to v2. + type: str + update_mgmt_ipaddresslist: + description: List of updated management IP addresses for network devices. + type: list + elements: dict + suboptions: + exist_mgmt_ipaddress: + description: Device's existing Mgmt IpAddress. + type: str + new_mgmt_ipaddress: + description: Device's new Mgmt IpAddress. + type: str + force_sync: + 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_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 + default: False + role: + description: Role of device which can be ACCESS, CORE, DISTRIBUTION, BORDER ROUTER, UNKNOWN. + ALL - This role typically represents all devices within the network, regardless of their specific roles or functions. + UNKNOWN - This role is assigned to devices whose roles or functions have not been identified or classified within Cisco Catalsyt Center. + This could happen if the platform is unable to determine the device's role based on available information. + ACCESS - This role typically represents switches or access points that serve as access points for end-user devices to connect to the network. + These devices are often located at the edge of the network and provide connectivity to end-user devices. + BORDER ROUTER - These are devices that connect different network domains or segments together. They often serve as + gateways between different networks, such as connecting an enterprise network to the internet or connecting + multiple branch offices. + DISTRIBUTION - This role represents function as distribution switches or routers in hierarchical network designs. They aggregate traffic + from access switches and route it toward the core of the network or toward other distribution switches. + CORE - This role typically represents high-capacity switches or routers that form the backbone of the network. They handle large volumes + of traffic and provide connectivity between different parts of network, such as connecting distribution switches or + providing interconnection between different network segments. + type: str + add_user_defined_field: + description: This operation will take dictionary as a parameter and in this we give details to + create/update/delete/assign multiple UDF to a device. + type: dict + suboptions: + name: + description: Name of Global User Defined Field. Required for creating/deleting UDF and then assigning it to device. + type: str + description: + description: Info about the global user defined field. Also used while updating interface details. + type: str + value: + description: Value to assign to tag with or without the same user defined field name. + type: str + update_interface_details: + description: This operation will take dictionary as a parameter and in this we give details to update interface details of device. + type: dict + suboptions: + description: + description: Specifies the description of the interface of the device. + type: str + interface_name: + description: Specify the list of interface names to update the details of the device interface. + (For example, GigabitEthernet1/0/11, FortyGigabitEthernet1/1/2) + type: list + elements: str + vlan_id: + description: Unique Id number assigned to a VLAN within a network used only while updating interface details. + type: int + voice_vlan_id: + description: Identifier used to distinguish a specific VLAN that is dedicated to voice traffic used only while updating interface details. + type: int + deployment_mode: + description: Preview/Deploy [Preview means the configuration is not pushed to the device. Deploy makes the configuration pushed to the device] + type: str + default: "Deploy" + clear_mac_address_table: + description: Set this to true if you need to clear the MAC address table for a specific device's interface. It's a boolean type, + with a default value of False. + type: bool + default: False + admin_status: + description: Status of Interface of a device, it can be (UP/DOWN). + type: str + export_device_list: + description: This operation take dictionary as parameter and export the device details as well as device credentials + details in a csv file. + type: dict + suboptions: + password: + description: Specifies the password for the encryption of file while exporting the device credentails into the file. + type: str + site_name: + description: Indicates the exact location where the wired device will be provisioned. This is a string value that should + represent the complete hierarchical path of the site (For example, "Global/USA/San Francisco/BGL_18/floor_pnp"). + type: str + operation_enum: + description: enum(CREDENTIALDETAILS, DEVICEDETAILS) 0 to export Device Credential Details Or 1 to export Device Details. + CREDENTIALDETAILS - Used for exporting device credentials details like snpm credntials, device crdentails etc. + DEVICEDETAILS - Used for exporting device specific details like device hostname, serial number, type, family etc. + type: str + parameters: + description: List of device parameters that needs to be exported to file.(For example, ["componentName", "SerialNumber", "Last Sync Status"]) + type: list + elements: str + provision_wired_device: + description: This parameter takes a list of dictionaries. Each dictionary provides the IP address of a wired device and + the name of the site where the device will be provisioned. + type: list + elements: dict + suboptions: + device_ip: + description: Specifies the IP address of the wired device. This is a string value that should be in the format of + standard IPv4 or IPv6 addresses. + type: str + version_added: 6.12.0 + site_name: + description: Indicates the exact location where the wired device will be provisioned. This is a string value that should + represent the complete hierarchical path of the site (For example, "Global/USA/San Francisco/BGL_18/floor_pnp"). + type: str + resync_retry_count: + description: Determines the total number of retry attempts for checking if the device has reached a managed state during + the provisioning process. If unspecified, the default value is set to 200 retries. + type: int + default: 200 + version_added: 6.12.0 + resync_retry_interval: + description: Sets the interval, in seconds, at which the system will recheck the device status throughout the provisioning + process. If unspecified, the system will check the device status every 2 seconds by default. + type: int + default: 2 + version_added: 6.12.0 + +requirements: +- dnacentersdk >= 2.5.5 +- python >= 3.5 +seealso: +- name: Cisco Catalyst Center documentation for Devices AddDevice2 + description: Complete reference of the AddDevice2 API. + link: https://developer.cisco.com/docs/dna-center/#!add-device +- name: Cisco Catalyst Center documentation for Devices DeleteDeviceById + description: Complete reference of the DeleteDeviceById API. + link: https://developer.cisco.com/docs/dna-center/#!delete-device-by-id +- name: Cisco Catalyst Center documentation for Devices SyncDevices2 + description: Complete reference of the SyncDevices2 API. + link: https://developer.cisco.com/docs/dna-center/#!sync-devices +notes: + - SDK Method used are + devices.Devices.add_device, + devices.Devices.delete_device_by_id, + devices.Devices.sync_devices, + + - Paths used are + post /dna/intent/api/v1/network-device, + delete /dna/intent/api/v1/network-device/{id}, + put /dna/intent/api/v1/network-device, + + - Removed 'managementIpAddress' options in v4.3.0. + - Renamed argument 'ip_address' to 'ip_address_list' option in v6.12.0. + - Removed 'serial_number', 'device_added', 'role_source', options in v6.12.0. + - Added 'add_user_defined_field', 'update_interface_details', 'export_device_list' options in v6.13.1. + - Removed 'provision_wireless_device', 'reprovision_wired_device' options in v6.13.1. + - Added the parameter 'admin_status' options in v6.13.1. + - Removed 'device_updated' options in v6.13.1. + +""" + +EXAMPLES = r""" +- name: Add new device in Inventory with full credentials + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - cli_transport: ssh + compute_device: False + password: Test@123 + enable_password: Test@1234 + extended_discovery_info: test + http_username: "testuser" + http_password: "test" + http_port: "443" + http_secure: False + ip_address_list: ["1.1.1.1", "2.2.2.2"] + netconf_port: 830 + snmp_auth_passphrase: "Lablab@12" + snmp_auth_protocol: SHA + snmp_mode: AUTHPRIV + snmp_priv_passphrase: "Lablab@123" + snmp_priv_protocol: AES256 + snmp_retry: 3 + snmp_timeout: 5 + snmp_username: v3Public + snmp_version: v3 + type: NETWORK_DEVICE + username: cisco + +- name: Add new Compute device in Inventory with full credentials.Inputs needed for Compute Device + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + http_username: "testuser" + http_password: "test" + http_port: "443" + snmp_auth_passphrase: "Lablab@12" + snmp_auth_protocol: SHA + snmp_mode: AUTHPRIV + snmp_priv_passphrase: "Lablab@123" + snmp_priv_protocol: AES256 + snmp_retry: 3 + snmp_timeout: 5 + snmp_username: v3Public + compute_device: True + username: cisco + type: "COMPUTE_DEVICE" + +- name: Add new Meraki device in Inventory with full credentials.Inputs needed for Meraki Device. + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - http_password: "test" + type: "MERAKI_DASHBOARD" + +- name: Add new Firepower Management device in Inventory with full credentials.Input needed to add Device. + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + http_username: "testuser" + http_password: "test" + http_port: "443" + type: "FIREPOWER_MANAGEMENT_SYSTEM" + +- name: Add new Third Party device in Inventory with full credentials.Input needed to add Device. + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + snmp_auth_passphrase: "Lablab@12" + snmp_auth_protocol: SHA + snmp_mode: AUTHPRIV + snmp_priv_passphrase: "Lablab@123" + snmp_priv_protocol: AES256 + snmp_retry: 3 + snmp_timeout: 5 + snmp_username: v3Public + type: "THIRD_PARTY_DEVICE" + +- name: Update device details or credentails in Inventory + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - cli_transport: telnet + compute_device: False + password: newtest123 + enable_password: newtest1233 + ip_address_list: ["1.1.1.1", "2.2.2.2"] + type: NETWORK_DEVICE + credential_update: True + +- name: Update new management IP address of device in inventory + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1"] + credential_update: True + update_mgmt_ipaddresslist: + - exist_mgmt_ipaddress: "1.1.1.1" + new_mgmt_ipaddress: "12.12.12.12" + +- name: Associate Wired Devices to site and Provisioned it in Inventory + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - provision_wired_device: + - device_ip: "1.1.1.1" + site_name: "Global/USA/San Francisco/BGL_18/floor_pnp" + resync_retry_count: 200 + resync_interval: 2 + - device_ip: "2.2.2.2" + site_name: "Global/USA/San Francisco/BGL_18/floor_test" + resync_retry_count: 200 + resync_retry_interval: 2 + +- name: Update Device Role with IP Address + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + role: ACCESS + +- name: Update Interface details with IP Address + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + update_interface_details: + description: "Testing for updating interface details" + admin_status: "UP" + vlan_id: 23 + voice_vlan_id: 45 + deployment_mode: "Deploy" + interface_name: ["GigabitEthernet1/0/11", FortyGigabitEthernet1/1/1] + clear_mac_address_table: True + +- name: Export Device Details in a CSV file Interface details with IP Address + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + export_device_list: + password: "File_password" + operation_enum: "0" + parameters: ["componentName", "SerialNumber", "Last Sync Status"] + +- name: Create Global User Defined with IP Address + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + add_user_defined_field: + - name: Test123 + description: "Added first udf for testing" + value: "value123" + - name: Test321 + description: "Added second udf for testing" + value: "value321" + +- name: Resync Device with IP Addresses + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + device_resync: True + force_sync: False + +- name: Reboot AP Devices with IP Addresses + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: merged + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + reboot_device: True + +- name: Delete Provision/Unprovision Devices by IP Address + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: False + dnac_log_level: "{{dnac_log_level}}" + state: deleted + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + clean_config: False + +- name: Delete Global User Defined Field with name + cisco.dnac.inventory_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: False + state: deleted + config: + - ip_address_list: ["1.1.1.1", "2.2.2.2"] + add_user_defined_field: + name: "Test123" + +""" + +RETURN = r""" + +dnac_response: + description: A dictionary or list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": { + "taskId": "string", + "url": "string" + }, + "version": "string" + } +""" +# common approach when a module relies on optional dependencies that are not available during the validation process. +try: + import pyzipper + HAS_PYZIPPER = True +except ImportError: + HAS_PYZIPPER = False + pyzipper = None + +import csv +import time +from datetime import datetime +from io import BytesIO, StringIO +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, +) +# Defer this feature as API issue is there once it's fixed we will addresses it in upcoming release iac2.0 +support_for_provisioning_wireless = False + + +class Inventory(DnacBase): + """Class containing member attributes for inventory workflow manager module""" + + def __init__(self, module): + super().__init__(module) + self.supported_states = ["merged", "deleted"] + + def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', and + 'self.msg' will describe the validation issues. + """ + + temp_spec = { + 'cli_transport': {'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_list': {'type': 'list', 'elements': 'str'}, + 'hostname_list': {'type': 'list', 'elements': 'str'}, + 'mac_address_list': {'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': {'type': 'str'}, + 'snmp_priv_passphrase': {'type': 'str'}, + 'snmp_priv_protocol': {'type': 'str'}, + 'snmp_ro_community': {'type': 'str'}, + 'snmp_rw_community': {'type': 'str'}, + 'snmp_retry': {'default': 3, 'type': 'int'}, + 'snmp_timeout': {'default': 5, 'type': 'int'}, + 'snmp_username': {'type': 'str'}, + 'snmp_version': {'type': 'str'}, + 'update_mgmt_ipaddresslist': {'type': 'list', 'elements': 'dict'}, + 'username': {'type': 'str'}, + 'role': {'type': 'str'}, + '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': 'list', + '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'}, + 'interface_name': {'type': 'list', 'elements': 'str'}, + 'deployment_mode': {'default': 'Deploy', 'type': 'str'}, + 'clear_mac_address_table': {'default': False, 'type': 'bool'}, + 'admin_status': {'type': 'str'}, + }, + 'export_device_list': { + 'type': 'dict', + 'password': {'type': 'str'}, + 'operation_enum': {'type': 'str'}, + 'parameters': {'type': 'list', 'elements': 'str'}, + }, + 'provision_wired_device': { + 'type': 'list', + 'device_ip': {'type': 'str'}, + 'site_name': {'type': 'str'}, + 'resync_retry_count': {'default': 200, 'type': 'int'}, + 'resync_retry_interval': {'default': 2, 'type': 'int'}, + } + } + + # Validate device params + valid_temp, invalid_params = validate_list_of_dicts( + self.config, temp_spec + ) + + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format(invalid_params) + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_temp + self.msg = "Successfully validated playbook configuration parameters using 'validate_input': {0}".format(str(valid_temp)) + self.log(self.msg, "INFO") + self.status = "success" + + return self + + def get_device_ips_from_config_priority(self): + """ + Retrieve device IPs based on the configuration. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + Returns: + list: A list containing device IPs. + Description: + This method retrieves device IPs based on the priority order specified in the configuration. + It first checks if device IPs are available. If not, it checks hostnames, serial numbers, + and MAC addresses in order and retrieves IPs based on availability. + If none of the information is available, an empty list is returned. + """ + # Retrieve device IPs from the configuration + device_ips = self.config[0].get("ip_address_list") + + if device_ips: + return device_ips + + # If device IPs are not available, check hostnames + device_hostnames = self.config[0].get("hostname_list") + if device_hostnames: + return self.get_device_ips_from_hostname(device_hostnames) + + # If hostnames are not available, check serial numbers + device_serial_numbers = self.config[0].get("serial_number_list") + if device_serial_numbers: + return self.get_device_ips_from_serial_number(device_serial_numbers) + + # If serial numbers are not available, check MAC addresses + device_mac_addresses = self.config[0].get("mac_address_list") + if device_mac_addresses: + return self.get_device_ips_from_mac_address(device_mac_addresses) + + # If no information is available, return an empty list + return [] + + def device_exists_in_ccc(self): + """ + Check which devices already exists in Cisco Catalyst Center and return both device_exist and device_not_exist in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + Returns: + list: A list of devices that exist in Cisco Catalyst Center. + Description: + Queries Cisco Catalyst Center to check which devices are already present in Cisco Catalyst Center and store + its management IP address in the list of devices that exist. + Example: + To use this method, create an instance of the class and call 'device_exists_in_ccc' on it, + The method returns a list of management IP addressesfor devices that exist in Cisco Catalyst Center. + """ + + device_in_ccc = [] + + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + ) + + except Exception as e: + error_message = "Error while fetching device from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "CRITICAL") + raise Exception(error_message) + + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + for ip in response: + device_ip = ip["managementIpAddress"] + device_in_ccc.append(device_ip) + + return device_in_ccc + + def is_udf_exist(self, field_name): + """ + Check if a Global User Defined Field exists in Cisco Catalyst Center based on its name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + field_name (str): The name of the Global User Defined Field. + Returns: + bool: True if the Global User Defined Field exists, False otherwise. + Description: + The function sends a request to Cisco Catalyst Center to retrieve all Global User Defined Fields + with the specified name. If matching field is found, the function returns True, indicating that + the field exists else returns False. + """ + + response = self.dnac._exec( + family="devices", + function='get_all_user_defined_fields', + params={"name": field_name}, + ) + + self.log("Received API response from 'get_all_user_defined_fields': {0}".format(str(response)), "DEBUG") + udf = response.get("response") + + if (len(udf) == 1): + return True + + message = "Global User Defined Field with name '{0}' doesnot exist in Cisco Catalyst Center".format(field_name) + self.log(message, "INFO") + + return False + + def create_user_defined_field(self, udf): + """ + Create a Global User Defined Field in Cisco Catalyst Center based on the provided configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + udf (dict): A dictionary having the payload for the creation of user defined field(UDF) in Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + The function retrieves the configuration for adding a user-defined field from the configuration object, + sends the request to Cisco Catalyst Center to create the field, and logs the response. + """ + try: + response = self.dnac._exec( + family="devices", + function='create_user_defined_field', + params=udf, + ) + self.log("Received API response from 'create_user_defined_field': {0}".format(str(response)), "DEBUG") + response = response.get("response") + field_name = udf.get('name') + self.log("Global User Defined Field with name '{0}' created successfully".format(field_name), "INFO") + self.status = "success" + + except Exception as e: + error_message = "Error while creating Global UDF(User Defined Field) in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return self + + def add_field_to_devices(self, device_ids, udf): + """ + Add a Global user-defined field with specified details to a list of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ids (list): A list of device IDs to which the user-defined field will be added. + udf (dict): A dictionary having the user defined field details including name and value. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + The function retrieves the details of the user-defined field from the configuration object, + including the field name and default value then iterates over list of device IDs, creating a payload for + each device and sending the request to Cisco Catalyst Center to add the user-defined field. + """ + field_name = udf.get('name') + field_value = udf.get('value', '1') + for device_id in device_ids: + payload = {} + payload['name'] = field_name + payload['value'] = field_value + udf_param_dict = { + 'payload': [payload], + 'device_id': device_id + } + try: + response = self.dnac._exec( + family="devices", + function='add_user_defined_field_to_device', + params=udf_param_dict, + ) + self.log("Received API response from 'add_user_defined_field_to_device': {0}".format(str(response)), "DEBUG") + response = response.get("response") + self.status = "success" + self.result['changed'] = True + + except Exception as e: + self.status = "failed" + error_message = "Error while adding Global UDF to device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + self.result['changed'] = False + + 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. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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("Received API response from 'export_device_list': {0}".format(str(response)), "DEBUG") + 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, "ERROR") + + 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}, + ) + self.log("Received API response from 'download_a_file_by_fileid': {0}".format(str(response)), "DEBUG") + + return response + + def decrypt_and_read_csv(self, response, password): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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, "CRITICAL") + self.status = "failed" + return self + + snmp_protocol = self.config[0].get('snmp_priv_protocol', 'AES128') + encryption_dict = { + 'AES128': 'pyzipper.WZ_AES128', + 'AES192': 'pyzipper.WZ_AES192', + 'AES256': 'pyzipper.WZ_AES', + '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), "WARNING") + + if not encryption_method: + self.msg = "Invalid SNMP protocol '{0}' specified for encryption.".format(snmp_protocol) + self.log(self.msg, "ERROR") + 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("Text content of decrypted file: {0}".format(file_content_text), "DEBUG") + + # 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 Catalyst Center into a CSV file. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of the class with updated result, status, and log. + Description: + This function exports device details from Cisco Catalyst Center based on the provided IP addresses in the configuration. + It retrieves the device UUIDs, calls the export device list API, and downloads the exported data of both device details and + and device credentials with an encrtypted zip file with password into CSV format. + The CSV data is then parsed and written to a file. + """ + + device_ips = self.get_device_ips_from_config_priority() + + if not device_ips: + self.status = "failed" + self.msg = "Cannot export device details as no devices are specified in the playbook" + self.log(self.msg, "ERROR") + return self + + try: + device_uuids = self.get_device_ids(device_ips) + + if not device_uuids: + self.status = "failed" + self.result['changed'] = False + self.msg = "Could not find device UUIDs for exporting device details" + self.log(self.msg, "ERROR") + return self + + # Now all device UUID get collected so call the export device list API + export_device_list = self.config[0].get('export_device_list') + password = export_device_list.get("password") + + if not self.is_valid_password(password): + self.status = "failed" + 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()) + self.msg = formatted_msg + self.log(formatted_msg, "INFO") + return self + + payload_params = { + "deviceUuids": device_uuids, + "password": password, + "operationEnum": export_device_list.get("operation_enum", "0"), + "parameters": export_device_list.get("parameters") + } + + response = self.trigger_export_api(payload_params) + self.check_return_status() + + if payload_params["operationEnum"] == "0": + 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: + decoded_resp = response.data.decode(encoding='utf-8') + self.log("Decoded response of Export Device Credential file: {0}".format(str(decoded_resp)), "DEBUG") + + # Parse the CSV-like string into a list of dictionaries + csv_reader = csv.DictReader(StringIO(decoded_resp)) + current_date = datetime.now() + formatted_date = current_date.strftime("%m-%d-%Y") + output_file_name = "devices-" + str(formatted_date) + ".csv" + + device_data = [] + for row in csv_reader: + device_data.append(row) + + # Write the data to a CSV file + with open(output_file_name, 'w', newline='') as csv_file: + fieldnames = device_data[0].keys() + csv_writer = csv.DictWriter(csv_file, fieldnames=fieldnames) + csv_writer.writeheader() + csv_writer.writerows(device_data) + + self.msg = "Device Details Exported Successfully to the CSV file: {0}".format(output_file_name) + self.log(self.msg, "INFO") + self.status = "success" + self.result['changed'] = True + self.result['response'] = self.msg + + except Exception as e: + self.msg = "Error while exporting device details into CSV file for device(s): '{0}'".format(str(device_ips)) + self.log(self.msg, "ERROR") + self.status = "failed" + + return self + + def get_ap_devices(self, device_ips): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst Center. + Description: + This method communicates with Cisco Catalyst 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 Catalyst Center: {0}".format(str(e)) + self.log(error_message, "CRITICAL") + raise Exception(error_message) + + return ap_device_list + + def resync_devices(self): + """ + Resync devices in Cisco Catalyst Center. + This function performs the Resync operation for the devices specified in the playbook. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + The function expects the following parameters in the configuration: + - "ip_address_list": List of device IP addresses to be resynced. + - "force_sync": (Optional) Whether to force sync the devices. Defaults to "False". + """ + + # Code for triggers the resync operation using the retrieved device IDs and force sync parameter. + device_ips = self.get_device_ips_from_config_priority() + input_device_ips = device_ips.copy() + device_in_ccc = self.device_exists_in_ccc() + + for device_ip in input_device_ips: + if device_ip not in device_in_ccc: + input_device_ips.remove(device_ip) + + ap_devices = self.get_ap_devices(input_device_ips) + self.log("AP Devices from the playbook input are: {0}".format(str(ap_devices)), "INFO") + + if ap_devices: + for ap_ip in ap_devices: + input_device_ips.remove(ap_ip) + self.log("Following devices {0} are AP, so can't perform resync operation.".format(str(ap_devices)), "WARNING") + + if not input_device_ips: + self.msg = "Cannot perform the Resync operation as the device(s) with IP(s) {0} are not present in Cisco Catalyst Center".format(str(device_ips)) + self.status = "success" + self.result['changed'] = False + self.result['response'] = self.msg + self.log(self.msg, "WARNING") + return self + + device_ids = self.get_device_ids(input_device_ips) + try: + force_sync = self.config[0].get("force_sync", False) + resync_param_dict = { + 'payload': device_ids, + 'force_sync': force_sync + } + response = self.dnac._exec( + family="devices", + function='sync_devices_using_forcesync', + op_modifies=True, + params=resync_param_dict, + ) + self.log("Received API response from 'sync_devices_using_forcesync': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'Synced' in execution_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Devices have been successfully resynced. Devices resynced: {0}".format(str(input_device_ips)) + self.log(self.msg, "INFO") + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device resynced get failed because of {0}".format(failure_reason) + else: + self.msg = "Device resynced get failed." + self.log(self.msg, "ERROR") + break + + except Exception as e: + self.status = "failed" + error_message = "Error while resyncing device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return self + + def reboot_access_points(self): + """ + Reboot access points in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of the class with updated result, status, and log. + Description: + This function performs a reboot operation on access points in Cisco Catalyst Center based on the provided IP addresses + in the configuration. It retrieves the AP devices' MAC addresses, calls the reboot access points API, and monitors + the progress of the reboot operation. + """ + + device_ips = self.get_device_ips_from_config_priority() + input_device_ips = device_ips.copy() + + if input_device_ips: + ap_devices = self.get_ap_devices(input_device_ips) + self.log("AP Devices from the playbook input are: {0}".format(str(ap_devices)), "INFO") + for device_ip in input_device_ips: + if device_ip not in ap_devices: + input_device_ips.remove(device_ip) + + if not input_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, "WARNING") + return self + + # Get and store the apEthernetMacAddress of given devices + ap_mac_address_list = [] + for device_ip in input_device_ips: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + 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 = "success" + self.result['changed'] = False + self.msg = "Cannot find the AP devices for rebooting" + self.result['response'] = self.msg + self.log(self.msg, "INFO") + return self + + # Now call the Reboot Access Point API + reboot_params = { + "apMacAddresses": ap_mac_address_list + } + response = self.dnac._exec( + family="wireless", + function='reboot_access_points', + op_modifies=True, + params=reboot_params, + ) + self.log(str(response)) + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'url' in execution_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "AP Device(s) {0} successfully rebooted!".format(str(input_device_ips)) + self.log(self.msg, "INFO") + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "AP Device Rebooting get failed because of {0}".format(failure_reason) + else: + self.msg = "AP Device Rebooting get failed" + self.log(self.msg, "ERROR") + break + + 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 Catalyst 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), "INFO") + + 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 Catalyst 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, "WARNING") + + 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 Catalyst 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 Catalyst Center: {2}".format(device_type, device_ip, str(exception)) + self.log(error_message, "ERROR") + + 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 Catalyst 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, "INFO") + 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 Catalyst 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), "INFO") + + 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 Catalyst 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, "INFO") + + 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 Catalyst 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), "INFO") + + def provisioned_wired_device(self): + """ + Provision wired devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of the class with updated result, status, and log. + Description: + This function provisions wired devices in Cisco Catalyst Center based on the configuration provided. + It retrieves the site name and IP addresses of the devices from the list of configuration, + attempts to provision each device with site, and monitors the provisioning process. + """ + + provision_wired_list = self.config[0]['provision_wired_device'] + total_devices_to_provisioned = len(provision_wired_list) + device_ip_list = [] + provision_count, already_provision_count = 0, 0 + + for prov_dict in provision_wired_list: + managed_flag = False + device_ip = prov_dict['device_ip'] + device_ip_list.append(device_ip) + site_name = prov_dict['site_name'] + device_type = "Wired" + resync_retry_count = prov_dict.get("resync_retry_count", 200) + # This resync retry interval will be in seconds which will check device status at given interval + resync_retry_interval = prov_dict.get("resync_retry_interval", 2) + + if not site_name or not device_ip: + self.status = "failed" + self.msg = "Site and Device IP are required for Provisioning of Wired Devices." + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + return self + + provision_wired_params = { + 'deviceManagementIpAddress': device_ip, + 'siteNameHierarchy': site_name + } + + # Check till device comes into managed state + while resync_retry_count: + response = self.get_device_response(device_ip) + self.log("Device is in {0} state waiting for Managed State.".format(response['managementState']), "DEBUG") + + if ( + response.get('managementState') == "Managed" + and response.get('collectionStatus') == "Managed" + and response.get("hostname") + ): + msg = """Device '{0}' comes to managed state and ready for provisioning with the resync_retry_count + '{1}' left having resync interval of {2} seconds""".format(device_ip, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = True + break + if response.get('collectionStatus') == "Partial Collection Failure" or response.get('collectionStatus') == "Could Not Synchronize": + device_status = response.get('collectionStatus') + msg = """Device '{0}' comes to '{1}' state and never goes for provisioning with the resync_retry_count + '{2}' left having resync interval of {3} seconds""".format(device_ip, device_status, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = False + break + + time.sleep(resync_retry_interval) + resync_retry_count = resync_retry_count - 1 + + if not managed_flag: + self.log("""Device {0} is not transitioning to the managed state, so provisioning operation cannot + be performed.""".format(device_ip), "WARNING") + continue + + try: + response = self.dnac._exec( + family="sda", + function='provision_wired_device', + op_modifies=True, + params=provision_wired_params, + ) + + if response.get("status") == "failed": + description = response.get("description") + error_msg = "Cannot do Provisioning for 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") + + 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), "INFO") + already_provision_count += 1 + + # Check If all the devices are already provsioned, return from here only + if already_provision_count == total_devices_to_provisioned: + self.handle_all_already_provisioned(device_ip_list, device_type) + elif provision_count == total_devices_to_provisioned: + 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, prov_dict): + """ + Get wireless provisioning parameters for a device. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + prov_dict (dict): A dictionary containing configuration parameters for wireless provisioning. + 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 Catalyst 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. + """ + + try: + device_ip_address = prov_dict['device_ip'] + site_name = prov_dict['site_name'] + + wireless_param = [ + { + 'site': site_name, + 'managedAPLocations': prov_dict['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, "ERROR") + return self + wireless_param[0]["dynamicInterfaces"] = [] + + for interface in prov_dict.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_address} + ) + + response = response.get("response") + wireless_param[0]["deviceName"] = response.get("hostname") + self.wireless_param = wireless_param + self.status = "success" + self.log("Successfully collected all the parameters required for Wireless Provisioning", "DEBUG") + + except Exception as e: + self.msg = """An exception occured while fetching the details for wireless provisioning of + device '{0}' due to - {1}""".format(device_ip_address, str(e)) + self.log(self.msg, "ERROR") + + return self + + def get_site_type(self, site_name): + """ + Get the type of a site in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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, "INFO") + return site_type + + self.log("Received API response from 'get_site': {0}".format(str(response)), "DEBUG") + 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 as e: + self.msg = "Error while fetching the site '{0}' and the specified site was not found in Cisco Catalyst Center.".format(site_name) + self.log(self.msg, "ERROR") + self.module.fail_json(msg=self.msg, response=[self.msg]) + + return site_type + + def provisioned_wireless_devices(self): + """ + Provision Wireless devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + 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 Catalyst 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" + device_ip_list = [] + provision_wireless_list = self.config[0]['provision_wireless_device'] + + for prov_dict in provision_wireless_list: + try: + # Collect the device parameters from the playbook to perform wireless provisioing + self.get_wireless_param(prov_dict).check_return_status() + device_ip = prov_dict['device_ip'] + device_ip_list.append(device_ip) + provisioning_params = self.wireless_param + resync_retry_count = prov_dict.get("resync_retry_count", 200) + # This resync retry interval will be in seconds which will check device status at given interval + resync_retry_interval = prov_dict.get("resync_retry_interval", 2) + managed_flag = True + + # Check till device comes into managed state + while resync_retry_count: + response = self.get_device_response(device_ip) + self.log("Device is in {0} state waiting for Managed State.".format(response['managementState']), "DEBUG") + + if ( + response.get('managementState') == "Managed" + and response.get('collectionStatus') == "Managed" + and response.get("hostname") + ): + msg = """Device '{0}' comes to managed state and ready for provisioning with the resync_retry_count + '{1}' left having resync interval of {2} seconds""".format(device_ip, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = True + break + + if response.get('collectionStatus') == "Partial Collection Failure" or response.get('collectionStatus') == "Could Not Synchronize": + device_status = response.get('collectionStatus') + msg = """Device '{0}' comes to '{1}' state and never goes for provisioning with the resync_retry_count + '{2}' left having resync interval of {3} seconds""".format(device_ip, device_status, resync_retry_count, resync_retry_interval) + self.log(msg, "INFO") + managed_flag = False + break + + time.sleep(resync_retry_interval) + resync_retry_count = resync_retry_count - 1 + + if not managed_flag: + self.log("""Device {0} is not transitioning to the managed state, so provisioning operation cannot + be performed.""".format(device_ip), "WARNING") + continue + + # 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, "ERROR") + continue + + task_id = response.get("taskId") + + while True: + execution_details = self.get_task_details(task_id) + progress = execution_details.get("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.msg = "Device '{0}' already provisioned".format(device_ip) + self.log(self.msg, "INFO") + already_provision_count += 1 + + # Check If all the devices are already provsioned, return from here only + if already_provision_count == len(device_ip_list): + self.handle_all_already_provisioned(device_ip_list, device_type) + elif provision_count == len(device_ip_list): + 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_udf_id(self, field_name): + """ + Get the ID of a Global User Defined Field in Cisco Catalyst Center based on its name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + field_name (str): The name of the Global User Defined Field. + Returns: + str: The ID of the Global User Defined Field. + Description: + The function sends a request to Cisco Catalyst Center to retrieve all Global User Defined Fields + with the specified name and extracts the ID of the first matching field.If successful, it returns + the ID else returns None. + """ + + try: + udf_id = None + response = self.dnac._exec( + family="devices", + function='get_all_user_defined_fields', + params={"name": field_name}, + ) + self.log("Received API response from 'get_all_user_defined_fields': {0}".format(str(response)), "DEBUG") + udf = response.get("response") + if udf: + udf_id = udf[0].get("id") + + except Exception as e: + error_message = "Exception occurred while getting Global User Defined Fields(UDF) ID from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return udf_id + + def mandatory_parameter(self): + """ + Check for and validate mandatory parameters for adding network devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + Returns: + dict: The input `config` dictionary if all mandatory parameters are present. + Description: + It will check the mandatory parameters for adding the devices in Cisco Catalyst Center. + """ + + device_type = self.config[0].get("type", "NETWORK_DEVICE") + params_dict = { + "NETWORK_DEVICE": ["ip_address_list", "password", "username"], + "COMPUTE_DEVICE": ["ip_address_list", "http_username", "http_password", "http_port"], + "MERAKI_DASHBOARD": ["http_password"], + "FIREPOWER_MANAGEMENT_SYSTEM": ["ip_address_list", "http_username", "http_password"], + "THIRD_PARTY_DEVICE": ["ip_address_list"] + } + + params_list = params_dict.get(device_type, []) + + mandatory_params_absent = [] + for param in params_list: + if param not in self.config[0]: + mandatory_params_absent.append(param) + + if mandatory_params_absent: + self.status = "failed" + self.msg = "Required parameters {0} for adding devices are not present".format(str(mandatory_params_absent)) + self.result['msg'] = self.msg + self.log(self.msg, "ERROR") + else: + self.status = "success" + self.msg = "Required parameter for Adding the devices in Inventory are present." + self.log(self.msg, "INFO") + + return self + + def get_have(self, config): + """ + Retrieve and check device information with Cisco Catalyst Center to determine if devices already exist. + Parameters: + self (object): An instance of a class used for interacting with Cisco Cisco Catalyst Center. + config (dict): A dictionary containing the configuration details of devices to be checked. + Returns: + dict: A dictionary containing information about the devices in the playbook, devices that exist in + Cisco Catalyst Center, and devices that are not present in Cisco Catalyst Center. + Description: + This function checks the specified devices in the playbook against the devices existing in Cisco Catalyst Center with following keys: + - "want_device": A list of devices specified in the playbook. + - "device_in_ccc": A list of devices that already exist in Cisco Catalyst Center. + - "device_not_in_ccc": A list of devices that are not present in Cisco Catalyst Center. + """ + + have = {} + want_device = self.get_device_ips_from_config_priority() + + # Get the list of device that are present in Cisco Catalyst Center + device_in_ccc = self.device_exists_in_ccc() + device_not_in_ccc, devices_in_playbook = [], [] + + for ip in want_device: + devices_in_playbook.append(ip) + if ip not in device_in_ccc: + device_not_in_ccc.append(ip) + + if self.config[0].get('provision_wired_device'): + provision_wired_list = self.config[0].get('provision_wired_device') + + for prov_dict in provision_wired_list: + device_ip_address = prov_dict['device_ip'] + if device_ip_address not in want_device: + devices_in_playbook.append(device_ip_address) + if device_ip_address not in device_in_ccc: + device_not_in_ccc.append(device_ip_address) + + if support_for_provisioning_wireless: + if self.config[0].get('provision_wireless_device'): + provision_wireless_list = self.config[0].get('provision_wireless_device') + + for prov_dict in provision_wireless_list: + device_ip_address = prov_dict['device_ip'] + if device_ip_address not in want_device and device_ip_address not in devices_in_playbook: + devices_in_playbook.append(device_ip_address) + if device_ip_address not in device_in_ccc and device_ip_address not in device_not_in_ccc: + device_not_in_ccc.append(device_ip_address) + + self.log("Device(s) {0} exists in Cisco Catalyst Center".format(str(device_in_ccc)), "INFO") + have["want_device"] = want_device + have["device_in_ccc"] = device_in_ccc + have["device_not_in_ccc"] = device_not_in_ccc + have["devices_in_playbook"] = devices_in_playbook + + self.have = have + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + + return self + + def get_device_params(self, params): + """ + Extract and store device parameters from the playbook for device processing in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + params (dict): A dictionary containing device parameters retrieved from the playbook. + Returns: + dict: A dictionary containing the extracted device parameters. + Description: + This function will extract and store parameters in dictionary for adding, updating, editing, or deleting devices Cisco Catalyst Center. + """ + + device_param = { + "cliTransport": params.get("cli_transport"), + "enablePassword": params.get("enable_password"), + "password": params.get("password"), + "ipAddress": params.get("ip_address_list"), + "snmpAuthPassphrase": params.get("snmp_auth_passphrase"), + "snmpAuthProtocol": params.get("snmp_auth_protocol"), + "snmpMode": params.get("snmp_mode"), + "snmpPrivPassphrase": params.get("snmp_priv_passphrase"), + "snmpPrivProtocol": params.get("snmp_priv_protocol"), + "snmpROCommunity": params.get("snmp_ro_community"), + "snmpRWCommunity": params.get("snmp_rw_community"), + "snmpRetry": params.get("snmp_retry"), + "snmpTimeout": params.get("snmp_timeout"), + "snmpUserName": params.get("snmp_username"), + "userName": params.get("username"), + "computeDevice": params.get("compute_device"), + "extendedDiscoveryInfo": params.get("extended_discovery_info"), + "httpPassword": params.get("http_password"), + "httpPort": params.get("http_port"), + "httpSecure": params.get("http_secure"), + "httpUserName": params.get("http_username"), + "netconfPort": params.get("netconf_port"), + "serialNumber": params.get("serial_number"), + "snmpVersion": params.get("snmp_version"), + "type": params.get("type"), + "updateMgmtIPaddressList": params.get("update_mgmt_ipaddresslist"), + "forceSync": params.get("force_sync"), + "cleanConfig": params.get("clean_config") + } + + if device_param.get("updateMgmtIPaddressList"): + device_mngmt_dict = device_param.get("updateMgmtIPaddressList")[0] + device_param["updateMgmtIPaddressList"][0] = {} + + device_param["updateMgmtIPaddressList"][0].update( + { + "existMgmtIpAddress": device_mngmt_dict.get("exist_mgmt_ipaddress"), + "newMgmtIpAddress": device_mngmt_dict.get("new_mgmt_ipaddress") + }) + + return device_param + + def get_device_ids(self, device_ips): + """ + Get the list of unique device IDs for list of specified management IP addresses of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ips (list): The management IP addresses of devices for which you want to retrieve the device IDs. + Returns: + list: The list of unique device IDs for the specified devices. + Description: + Queries Cisco Catalyst Center to retrieve the unique device ID associated with a device having the specified + IP address. If the device is not found in Cisco Catalyst Center, then print the log message with error severity. + """ + + device_ids = [] + + for device_ip in device_ips: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if not response: + continue + device_id = response[0]["id"] + device_ids.append(device_id) + + except Exception as e: + error_message = "Error while fetching device '{0}' from Cisco Catalyst Center: {1}".format(device_ip, str(e)) + self.log(error_message, "ERROR") + + return device_ids + + def get_device_ips_from_hostname(self, hostname_list): + """ + Get the list of unique device IPs for list of specified hostnames of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + hostname_list (list): The hostnames of devices for which you want to retrieve the device IPs. + Returns: + list: The list of unique device IPs for the specified devices hostname list. + Description: + Queries Cisco Catalyst Center to retrieve the unique device IP's associated with a device having the specified + list of hostnames. If a device is not found in Cisco Catalyst Center, an error log message is printed. + """ + + device_ips = [] + for hostname in hostname_list: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"hostname": hostname} + ) + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if response: + device_ip = response[0]["managementIpAddress"] + if device_ip: + device_ips.append(device_ip) + except Exception as e: + error_message = "Exception occurred while fetching device from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + + return device_ips + + def get_device_ips_from_serial_number(self, serial_number_list): + """ + Get the list of unique device IPs for a specified list of serial numbers in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + serial_number_list (list): The list of serial number of devices for which you want to retrieve the device IPs. + Returns: + list: The list of unique device IPs for the specified devices with serial numbers. + Description: + Queries Cisco Catalyst Center to retrieve the unique device IPs associated with a device having the specified + serial numbers.If a device is not found in Cisco Catalyst Center, an error log message is printed. + """ + + device_ips = [] + for serial_number in serial_number_list: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"serialNumber": serial_number} + ) + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if response: + device_ip = response[0]["managementIpAddress"] + if device_ip: + device_ips.append(device_ip) + except Exception as e: + error_message = "Exception occurred while fetching device from Cisco Catalyst Center - {0}".format(str(e)) + self.log(error_message, "ERROR") + + return device_ips + + def get_device_ips_from_mac_address(self, mac_address_list): + """ + Get the list of unique device IPs for list of specified mac address of devices in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + mac_address_list (list): The list of mac address of devices for which you want to retrieve the device IPs. + Returns: + list: The list of unique device IPs for the specified devices. + Description: + Queries Cisco Catalyst Center to retrieve the unique device IPs associated with a device having the specified + mac addresses. If a device is not found in Cisco Catalyst Center, an error log message is printed. + """ + + device_ips = [] + for mac_address in mac_address_list: + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"macAddress": mac_address} + ) + if response: + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get("response") + if response: + device_ip = response[0]["managementIpAddress"] + if device_ip: + device_ips.append(device_ip) + except Exception as e: + error_message = "Exception occurred while fetching device from Cisco Catalyst Center - {0}".format(str(e)) + self.log(error_message, "ERROR") + + return device_ips + + def get_interface_from_id_and_name(self, device_id, interface_name): + """ + Retrieve the interface ID for a device in Cisco Catalyst Center based on device id and interface name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_id (str): The id of the device. + interface_name (str): Name of the interface for which details need to be collected. + Returns: + str: The interface ID for the specified device and interface name. + Description: + The function sends a request to Cisco Catalyst Center to retrieve the interface information + for the device with the provided device id and interface name and extracts the interface ID from the + response, and returns the interface ID. + """ + + try: + interface_detail_params = { + 'device_id': device_id, + 'name': interface_name + } + response = self.dnac._exec( + family="devices", + function='get_interface_details', + params=interface_detail_params + ) + self.log("Received API response from 'get_interface_details': {0}".format(str(response)), "DEBUG") + response = response.get("response") + + if response: + self.status = "success" + interface_id = response["id"] + self.log("""Successfully fetched interface ID ({0}) by using device id {1} and interface name {2}.""" + .format(interface_id, device_id, interface_name), "INFO") + return interface_id + + except Exception as e: + error_message = "Error while fetching interface id for interface({0}) from Cisco Catalyst Center: {1}".format(interface_name, str(e)) + self.log(error_message, "ERROR") + self.msg = error_message + self.status = "failed" + return self + + def get_interface_from_ip(self, device_ip): + """ + Get the interface ID for a device in Cisco Catalyst Center based on its IP address. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ip (str): The IP address of the device. + Returns: + str: The interface ID for the specified device. + Description: + The function sends a request to Cisco Catalyst Center to retrieve the interface information + for the device with the provided IP address and extracts the interface ID from the + response, and returns the interface ID. + """ + + try: + response = self.dnac._exec( + family="devices", + function='get_interface_by_ip', + params={"ip_address": device_ip} + ) + self.log("Received API response from 'get_interface_by_ip': {0}".format(str(response)), "DEBUG") + response = response.get("response") + + if response: + interface_id = response[0]["id"] + self.log("Fetch Interface Id for device '{0}' successfully !!".format(device_ip)) + return interface_id + + except Exception as e: + error_message = "Error while fetching Interface Id for device '{0}' from Cisco Catalyst Center: {1}".format(device_ip, str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + def get_device_response(self, device_ip): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst Center. + Description: + This method communicates with Cisco Catalyst 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 Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + 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 Catalyst Center match the specified values in the configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst Center using the + 'get_device_response' method and compares the retrieved values with specified values in the configuration + for updating device roles. + """ + + role = self.config[0].get('role') + response = self.get_device_response(device_ip) + + return response.get('role') == role + + def check_interface_details(self, device_ip, interface_name): + """ + Checks if the interface details for a device in Cisco Catalyst Center match the specified values in the configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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. + """ + device_id = self.get_device_ids([device_ip]) + + if not device_id: + self.log("""Error: Device with IP '{0}' not found in Cisco Catalyst Center.Unable to update interface details.""" + .format(device_ip), "ERROR") + return False + + interface_detail_params = { + 'device_id': device_id[0], + 'name': interface_name + } + response = self.dnac._exec( + family="devices", + function='get_interface_details', + params=interface_detail_params + ) + self.log("Received API response from 'get_interface_details': {0}".format(str(response)), "DEBUG") + response = response.get("response") + + if not response: + self.log("No response received from the API 'get_interface_details'.", "DEBUG") + return False + + 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 Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + bool: True if the credentials match the updated values, False otherwise. + Description: + This method triggers the export API in Cisco Catalyst 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.get_device_ips_from_config_priority() + 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'], + '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 Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst 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 Catalyst 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')), "ERROR") + return False + + return True + + def clear_mac_address(self, interface_id, deploy_mode, interface_name): + """ + Clear the MAC address table on a specific interface of a device. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + interface_id (str): The UUID of the interface where the MAC addresses will be cleared. + deploy_mode (str): The deployment mode of the device. + interface_name(str): The name of the interface for which the MAC addresses will be cleared. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function clears the MAC address table on a specific interface of a device. + The 'deploy_mode' parameter specifies the deployment mode of the device. + If the operation is successful, the function returns the response from the API call. + If an error occurs during the operation, the function logs the error details and updates the status accordingly. + """ + + try: + payload = { + "operation": "ClearMacAddress", + "payload": {} + } + clear_mac_address_payload = { + 'payload': payload, + 'interface_uuid': interface_id, + 'deployment_mode': deploy_mode + } + response = self.dnac._exec( + family="devices", + function='clear_mac_address_table', + op_modifies=True, + params=clear_mac_address_payload, + ) + self.log("Received API response from 'clear_mac_address_table': {0}".format(str(response)), "DEBUG") + + if not (response and isinstance(response, dict)): + self.status = "failed" + self.msg = """Received an empty response from the API 'clear_mac_address_table'. This indicates a failure to clear + the Mac address table for the interface '{0}'""".format(interface_name) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + return self + + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Failed to clear the Mac address table for the interface '{0}' due to {1}".format(interface_name, failure_reason) + else: + self.msg = "Failed to clear the Mac address table for the interface '{0}'".format(interface_name) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + break + elif 'clear mac address-table' in execution_details.get("data"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Successfully executed the task of clearing the Mac address table for interface '{0}'".format(interface_name) + self.log(self.msg, "INFO") + break + + except Exception as e: + error_msg = """An exception occurred during the process of clearing the MAC address table for interface {0}, due to - + {1}""".format(interface_name, str(e)) + self.log(error_msg, "WARNING") + self.result['changed'] = False + self.result['response'] = error_msg + + return self + + def update_interface_detail_of_device(self, device_to_update): + """ + Update interface details for a device in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_to_update (list): A list of IP addresses of devices to be updated. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method updates interface details for devices in Cisco Catalyst Center. + It iterates over the list of devices to be updated, retrieves interface parameters from the configuration, + calls the update interface details API with the required parameters, and checks the execution response. + If the update is successful, it sets the status to 'success' and logs an informational message. + """ + + # Call the Get interface details by device IP API and fetch the interface Id + for device_ip in device_to_update: + interface_params = self.config[0].get('update_interface_details') + interface_names_list = interface_params.get('interface_name') + for interface_name in interface_names_list: + device_id = self.get_device_ids([device_ip]) + interface_id = self.get_interface_from_id_and_name(device_id[0], interface_name) + self.check_return_status() + + # Now we call update interface details api with required parameter + try: + interface_params = self.config[0].get('update_interface_details') + clear_mac_address_table = interface_params.get("clear_mac_address_table", False) + + if clear_mac_address_table: + response = self.get_device_response(device_ip) + + if response.get('role').upper() != "ACCESS": + self.msg = "The action to clear the MAC Address table is only supported for devices with the ACCESS role." + self.log(self.msg, "WARNING") + self.result['response'] = self.msg + else: + deploy_mode = interface_params.get('deployment_mode', 'Deploy') + self.clear_mac_address(interface_id, deploy_mode, interface_name) + self.check_return_status() + + temp_params = { + 'description': interface_params.get('description', ''), + 'adminStatus': interface_params.get('admin_status'), + 'voiceVlanId': interface_params.get('voice_vlan_id'), + 'vlanId': interface_params.get('vlan_id') + } + payload_params = {} + for key, value in temp_params.items(): + if value is not None: + payload_params[key] = value + + update_interface_params = { + 'payload': payload_params, + 'interface_uuid': interface_id, + 'deployment_mode': interface_params.get('deployment_mode', 'Deploy') + } + response = self.dnac._exec( + family="devices", + function='update_interface_details', + op_modifies=True, + params=update_interface_params, + ) + self.log("Received API response from 'update_interface_details': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'SUCCESS' in execution_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Updated Interface Details for device '{0}' successfully".format(device_ip) + self.log(self.msg, "INFO") + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Interface Updation get failed because of {0}".format(failure_reason) + else: + self.msg = "Interface Updation get failed" + self.log(self.msg, "ERROR") + break + + except Exception as e: + error_message = "Error while updating interface details in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "INFO") + 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(self.msg, "INFO") + + return self + + def check_managementip_execution_response(self, response, device_ip, new_mgmt_ipaddress): + """ + Check the execution response of a management IP update task. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + response (dict): The response received after initiating the management IP update task. + device_ip (str): The IP address of the device for which the management IP was updated. + new_mgmt_ipaddress (str): The new management IP address of the device. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the execution response of a management IP update task in Cisco Catalyst Center. + It continuously queries the task details until the task is completed or an error occurs. + If the task is successful, it sets the status to 'success' and logs an informational message. + If the task fails, it sets the status to 'failed' and logs an error message with the failure reason, if available. + """ + + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + if execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device new management IP updation for device '{0}' get failed due to {1}".format(device_ip, failure_reason) + else: + self.msg = "Device new management IP updation for device '{0}' get failed".format(device_ip) + self.log(self.msg, "ERROR") + break + elif execution_details.get("endTime"): + self.status = "success" + self.result['changed'] = True + self.msg = """Device '{0}' present in Cisco Catalyst Center and new management ip '{1}' have been + updated successfully""".format(device_ip, new_mgmt_ipaddress) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + break + + return self + + def check_device_update_execution_response(self, response, device_ip): + """ + Check the execution response of a device update task. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + response (dict): The response received after initiating the device update task. + device_ip (str): The IP address of the device for which the update is performed. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the execution response of a device update task in Cisco Catalyst Center. + It continuously queries the task details until the task is completed or an error occurs. + If the task is successful, it sets the status to 'success' and logs an informational message. + If the task fails, it sets the status to 'failed' and logs an error message with the failure reason, if available. + """ + + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device Updation for device '{0}' get failed due to {1}".format(device_ip, failure_reason) + else: + self.msg = "Device Updation for device '{0}' get failed".format(device_ip) + self.log(self.msg, "ERROR") + break + elif execution_details.get("endTime"): + self.status = "success" + self.result['changed'] = True + self.result['response'] = execution_details + self.msg = "Device '{0}' present in Cisco Catalyst Center and have been updated successfully".format(device_ip) + self.log(self.msg, "INFO") + break + + return self + + def is_device_exist_in_ccc(self, device_ip): + """ + Check if a device with the given IP exists in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_ip (str): The IP address of the device to check. + Returns: + bool: True if the device exists, False otherwise. + Description: + This method queries Cisco Catalyst Center to check if a device with the specified + management IP address exists. If the device exists, it returns True; otherwise, + it returns False. If an error occurs during the process, it logs an error message + and raises an exception. + """ + + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"managementIpAddress": device_ip} + ) + response = response.get('response') + if not response: + self.log("Device with given IP '{0}' is not present in Cisco Catalyst Center".format(device_ip), "INFO") + return False + + return True + + except Exception as e: + error_message = "Error while getting the response of device '{0}' from Cisco Catalyst Center: {1}".format(device_ip, str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + def is_device_exist_for_update(self, device_to_update): + """ + Check if the device(s) exist in Cisco Catalyst Center for update operation. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + device_to_update (list): A list of device(s) to be be checked present in Cisco Catalyst Center. + Returns: + bool: True if at least one of the devices to be updated exists in Cisco Catalyst Center, + False otherwise. + Description: + This function checks if any of the devices specified in the 'device_to_update' list + exists in Cisco Catalyst Center. It iterates through the list of devices and compares + each device with the list of devices present in Cisco Catalyst Center obtained from + 'self.have.get("device_in_ccc")'. If a match is found, it sets 'device_exist' to True + and breaks the loop. + """ + + # First check if device present in Cisco Catalyst Center or not + device_exist = False + for device in device_to_update: + if device in self.have.get("device_in_ccc"): + device_exist = True + break + + return device_exist + + def get_want(self, config): + """ + Get all the device related information from playbook that is needed to be + add/update/delete/resync device in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing device-related information from the playbook. + Returns: + dict: A dictionary containing the extracted device parameters and other relevant information. + Description: + Retrieve all the device-related information from the playbook needed for adding, updating, deleting, + or resyncing devices in Cisco Catalyst Center. + """ + + want = {} + device_params = self.get_device_params(config) + want["device_params"] = device_params + + self.want = want + self.msg = "Successfully collected all parameters from the playbook " + self.status = "success" + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + return self + + def get_diff_merged(self, config): + """ + Merge and process differences between existing devices and desired device configuration in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing the desired device configuration and relevant information from the playbook. + Returns: + object: An instance of the class with updated results and status based on the processing of differences. + Description: + The function processes the differences and, depending on the changes required, it may add, update, + or resynchronize devices in Cisco Catalyst Center. + The updated results and status are stored in the class instance for further use. + """ + devices_to_add = self.have["device_not_in_ccc"] + device_type = self.config[0].get("type", "NETWORK_DEVICE") + device_resynced = self.config[0].get("device_resync", False) + device_reboot = self.config[0].get("reboot_device", False) + credential_update = self.config[0].get("credential_update", False) + + config['type'] = device_type + if device_type == "FIREPOWER_MANAGEMENT_SYSTEM": + config['http_port'] = self.config[0].get("http_port", "443") + + config['ip_address_list'] = devices_to_add + + if self.config[0].get('update_mgmt_ipaddresslist'): + device_ip = self.config[0].get('update_mgmt_ipaddresslist')[0].get('existMgmtIpAddress') + is_device_exists = self.is_device_exist_in_ccc(device_ip) + + if not is_device_exists: + self.status = "failed" + self.msg = """Unable to update the Management IP address because the device with IP '{0}' is not + found in Cisco Catalyst Center.""".format(device_ip) + self.log(self.msg, "ERROR") + return self + + if self.config[0].get('update_interface_details'): + device_to_update = self.get_device_ips_from_config_priority() + device_exist = self.is_device_exist_for_update(device_to_update) + + if not device_exist: + self.msg = """Unable to update interface details because the device(s) listed: {0} are not present in the + Cisco Catalyst Center.""".format(str(device_to_update)) + self.status = "failed" + self.result['response'] = self.msg + self.log(self.msg, "ERROR") + return self + + if self.config[0].get('role'): + devices_to_update_role = self.get_device_ips_from_config_priority() + device_exist = self.is_device_exist_for_update(devices_to_update_role) + + if not device_exist: + self.msg = """Unable to update device role because the device(s) listed: {0} are not present in the Cisco + Catalyst Center.""".format(str(devices_to_update_role)) + self.status = "failed" + self.result['response'] = self.msg + self.log(self.msg, "ERROR") + return self + + if credential_update: + device_to_update = self.get_device_ips_from_config_priority() + device_exist = self.is_device_exist_for_update(device_to_update) + + if not device_exist: + self.msg = """Unable to edit device credentials/details because the device(s) listed: {0} are not present in the + Cisco Catalyst Center.""".format(str(device_to_update)) + self.status = "failed" + self.result['response'] = self.msg + self.log(self.msg, "ERROR") + return self + + if not config['ip_address_list']: + self.msg = "Devices '{0}' already present in Cisco Catalyst Center".format(self.have['devices_in_playbook']) + self.log(self.msg, "INFO") + self.result['changed'] = False + self.result['response'] = self.msg + else: + # To add the devices in inventory + input_params = self.want.get("device_params") + device_params = input_params.copy() + + if not device_params['snmpVersion']: + device_params['snmpVersion'] = "v3" + device_params['ipAddress'] = config['ip_address_list'] + + if device_params['snmpVersion'] == "v2": + params_to_remove = ["snmpAuthPassphrase", "snmpAuthProtocol", "snmpMode", "snmpPrivPassphrase", "snmpPrivProtocol", "snmpUserName"] + for param in params_to_remove: + device_params.pop(param, None) + + if not device_params['snmpROCommunity']: + self.status = "failed" + self.msg = "Required parameter 'snmpROCommunity' for adding device with snmmp version v2 is not present" + self.result['msg'] = self.msg + self.log(self.msg, "ERROR") + return self + else: + if not device_params['snmpMode']: + device_params['snmpMode'] = "AUTHPRIV" + + if not device_params['cliTransport']: + device_params['cliTransport'] = "ssh" + + if not device_params['snmpPrivProtocol']: + device_params['snmpPrivProtocol'] = "AES128" + + if device_params['snmpPrivProtocol'] == "AES192": + device_params['snmpPrivProtocol'] = "CISCOAES192" + elif device_params['snmpPrivProtocol'] == "AES256": + device_params['snmpPrivProtocol'] = "CISCOAES256" + + if device_params['snmpMode'] == "NOAUTHNOPRIV": + device_params.pop('snmpAuthPassphrase', None) + device_params.pop('snmpPrivPassphrase', None) + device_params.pop('snmpPrivProtocol', None) + device_params.pop('snmpAuthProtocol', None) + elif device_params['snmpMode'] == "AUTHNOPRIV": + device_params.pop('snmpPrivPassphrase', None) + device_params.pop('snmpPrivProtocol', None) + + self.mandatory_parameter().check_return_status() + try: + response = self.dnac._exec( + family="devices", + function='add_device', + op_modifies=True, + params=device_params, + ) + self.log("Received API response from 'add_device': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if '/task/' in execution_details.get("progress"): + self.status = "success" + self.result['response'] = execution_details + + if len(devices_to_add) > 0: + self.result['changed'] = True + self.msg = "Device(s) '{0}' added to Cisco Catalyst Center".format(str(devices_to_add)) + self.log(self.msg, "INFO") + self.result['msg'] = self.msg + break + self.msg = "Device(s) '{0}' already present in Cisco Catalyst Center".format(str(self.config[0].get("ip_address_list"))) + self.log(self.msg, "INFO") + self.result['msg'] = self.msg + break + elif execution_details.get("isError"): + self.status = "failed" + failure_reason = execution_details.get("failureReason") + if failure_reason: + self.msg = "Device addition get failed because of {0}".format(failure_reason) + else: + self.msg = "Device addition get failed" + self.log(self.msg, "ERROR") + self.result['msg'] = self.msg + break + + except Exception as e: + error_message = "Error while adding device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + # Update the role of devices having the role source as Manual + if self.config[0].get('role'): + devices_to_update_role = self.get_device_ips_from_config_priority() + device_role = self.config[0].get('role') + role_update_count = 0 + for device_ip in devices_to_update_role: + device_id = self.get_device_ids([device_ip]) + + # 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] + + if response.get('role') == device_role: + self.status = "success" + self.result['changed'] = False + role_update_count += 1 + log_msg = "The device role '{0}' is already set in Cisco Catalyst Center, no update is needed.".format(device_role) + self.log(log_msg, "INFO") + continue + + device_role_params = { + 'role': device_role, + 'roleSource': "MANUAL", + 'id': device_id[0] + } + + try: + response = self.dnac._exec( + family="devices", + function='update_device_role', + op_modifies=True, + params=device_role_params, + ) + self.log("Received API response from 'update_device_role': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + progress = execution_details.get("progress") + + if 'successfully' in progress or 'succesfully' in progress: + self.status = "success" + self.result['changed'] = True + self.msg = "Device(s) '{0}' role updated successfully to '{1}'".format(str(devices_to_update_role), device_role) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + 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, "ERROR") + self.result['response'] = self.msg + break + + except Exception as e: + error_message = "Error while updating device role '{0}' in Cisco Catalyst Center: {1}".format(device_role, str(e)) + self.log(error_message, "ERROR") + + if role_update_count == len(devices_to_update_role): + self.status = "success" + self.result['changed'] = False + self.msg = """The device role '{0}' is already set in Cisco Catalyst Center, no device role update is needed for the + devices {1}.""".format(device_role, str(devices_to_update_role)) + self.log(self.msg, "INFO") + self.result['response'] = self.msg + + if credential_update: + device_to_update = self.get_device_ips_from_config_priority() + + # Update Device details and credentails + device_uuids = self.get_device_ids(device_to_update) + password = "Testing@123" + export_payload = {"deviceUuids": device_uuids, "password": password, "operationEnum": "0"} + export_response = self.trigger_export_api(export_payload) + self.check_return_status() + csv_reader = self.decrypt_and_read_csv(export_response, password) + self.check_return_status() + device_details = {} + + for row in csv_reader: + ip_address = row['ip_address'] + device_details[ip_address] = row + + for device_ip in device_to_update: + playbook_params = self.want.get("device_params").copy() + playbook_params['ipAddress'] = [device_ip] + device_data = device_details[device_ip] + if device_data['snmpv3_privacy_password'] == ' ': + device_data['snmpv3_privacy_password'] = None + if device_data['snmpv3_auth_password'] == ' ': + device_data['snmpv3_auth_password'] = None + + if not playbook_params['snmpMode']: + if device_data['snmpv3_privacy_password']: + playbook_params['snmpMode'] = "AUTHPRIV" + elif device_data['snmpv3_auth_password']: + playbook_params['snmpMode'] = "AUTHNOPRIV" + else: + playbook_params['snmpMode'] = "NOAUTHNOPRIV" + + if not playbook_params['cliTransport']: + if device_data['protocol'] == "ssh2": + playbook_params['cliTransport'] = "ssh" + else: + playbook_params['cliTransport'] = device_data['protocol'] + if not playbook_params['snmpPrivProtocol']: + playbook_params['snmpPrivProtocol'] = device_data['snmpv3_privacy_type'] + + csv_data_dict = { + 'username': device_data['cli_username'], + 'password': device_data['cli_password'], + 'enable_password': device_data['cli_enable_password'], + 'netconf_port': device_data['netconf_port'], + } + + if device_data['snmp_version'] == '3': + csv_data_dict['snmp_username'] = device_data['snmpv3_user_name'] + if device_data['snmpv3_privacy_password']: + csv_data_dict['snmp_auth_passphrase'] = device_data['snmpv3_auth_password'] + csv_data_dict['snmp_priv_passphrase'] = device_data['snmpv3_privacy_password'] + else: + csv_data_dict['snmp_username'] = None + + device_key_mapping = { + 'username': 'userName', + 'password': 'password', + 'enable_password': 'enablePassword', + 'snmp_username': 'snmpUserName', + 'netconf_port': 'netconfPort' + } + device_update_key_list = ["username", "password", "enable_password", "snmp_username", "netconf_port"] + + for key in device_update_key_list: + mapped_key = device_key_mapping[key] + + if playbook_params[mapped_key] is None: + playbook_params[mapped_key] = csv_data_dict[key] + + if playbook_params['snmpMode'] == "AUTHPRIV": + if not playbook_params['snmpAuthPassphrase']: + playbook_params['snmpAuthPassphrase'] = csv_data_dict['snmp_auth_passphrase'] + if not playbook_params['snmpPrivPassphrase']: + playbook_params['snmpPrivPassphrase'] = csv_data_dict['snmp_priv_passphrase'] + + if playbook_params['snmpPrivProtocol'] == "AES192": + playbook_params['snmpPrivProtocol'] = "CISCOAES192" + elif playbook_params['snmpPrivProtocol'] == "AES256": + playbook_params['snmpPrivProtocol'] = "CISCOAES256" + + if playbook_params['snmpMode'] == "NOAUTHNOPRIV": + playbook_params.pop('snmpAuthPassphrase', None) + playbook_params.pop('snmpPrivPassphrase', None) + playbook_params.pop('snmpPrivProtocol', None) + playbook_params.pop('snmpAuthProtocol', None) + elif playbook_params['snmpMode'] == "AUTHNOPRIV": + playbook_params.pop('snmpPrivPassphrase', None) + playbook_params.pop('snmpPrivProtocol', None) + + if playbook_params['netconfPort'] == " ": + playbook_params['netconfPort'] = None + + if playbook_params['enablePassword'] == " ": + playbook_params['enablePassword'] = None + + if playbook_params['netconfPort'] and playbook_params['cliTransport'] == "telnet": + self.log("""Updating the device cli transport from ssh to telnet with netconf port '{0}' so make + netconf port as None to perform the device update task""".format(playbook_params['netconfPort']), "DEBUG") + playbook_params['netconfPort'] = None + + if not playbook_params['snmpVersion']: + if device_data['snmp_version'] == '3': + playbook_params['snmpVersion'] = "v3" + else: + playbook_params['snmpVersion'] = "v2" + + if playbook_params['snmpVersion'] == 'v2': + params_to_remove = ["snmpAuthPassphrase", "snmpAuthProtocol", "snmpMode", "snmpPrivPassphrase", "snmpPrivProtocol", "snmpUserName"] + for param in params_to_remove: + playbook_params.pop(param, None) + + if not playbook_params['snmpROCommunity']: + playbook_params['snmpROCommunity'] = device_data.get('snmp_community', None) + + try: + if playbook_params['updateMgmtIPaddressList']: + new_mgmt_ipaddress = playbook_params['updateMgmtIPaddressList'][0]['newMgmtIpAddress'] + if new_mgmt_ipaddress in self.have['device_in_ccc']: + self.status = "failed" + self.msg = "Device with IP address '{0}' already exists in inventory".format(new_mgmt_ipaddress) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + else: + self.log("Playbook parameter for updating device new management ip address: {0}".format(str(playbook_params)), "DEBUG") + response = self.dnac._exec( + family="devices", + function='sync_devices', + op_modifies=True, + params=playbook_params, + ) + self.log("Received API response from 'sync_devices': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + self.check_managementip_execution_response(response, device_ip, new_mgmt_ipaddress) + self.check_return_status() + + else: + self.log("Playbook parameter for updating devices: {0}".format(str(playbook_params)), "DEBUG") + response = self.dnac._exec( + family="devices", + function='sync_devices', + op_modifies=True, + params=playbook_params, + ) + self.log("Received API response from 'sync_devices': {0}".format(str(response)), "DEBUG") + + if response and isinstance(response, dict): + self.check_device_update_execution_response(response, device_ip) + self.check_return_status() + + except Exception as e: + error_message = "Error while updating device in Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + # Update list of interface details on specific or list of devices. + if self.config[0].get('update_interface_details'): + device_to_update = self.get_device_ips_from_config_priority() + self.update_interface_detail_of_device(device_to_update).check_return_status() + + # If User defined field(UDF) not present then create it and add multiple udf to specific or list of devices + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + + for udf in udf_field_list: + field_name = udf.get('name') + + if field_name is None: + self.status = "failed" + self.msg = "Error: The mandatory parameter 'name' for the User Defined Field is missing. Please provide the required information." + self.log(self.msg, "ERROR") + 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.log("Global User Defined Field '{0}' does not present in Cisco Catalyst Center, we need to create it".format(field_name), "DEBUG") + self.create_user_defined_field(udf).check_return_status() + + # Get device Id based on config priority + device_ips = self.get_device_ips_from_config_priority() + device_ids = self.get_device_ids(device_ips) + + if not device_ids: + self.status = "failed" + self.msg = """Unable to assign Global User Defined Field: No devices found in Cisco Catalyst Center. + Please add devices to proceed.""" + self.result['changed'] = False + self.result['response'] = self.msg + self.log(self.msg, "INFO") + return self + + # Now add code for adding Global UDF to device with Id + self.add_field_to_devices(device_ids, udf).check_return_status() + + self.result['changed'] = True + self.msg = "Global User Defined Field(UDF) named '{0}' has been successfully added to the device.".format(field_name) + self.log(self.msg, "INFO") + + # 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 + # Defer this feature as API issue is there once it's fixed we will addresses it in upcoming release iac2.0 + if support_for_provisioning_wireless: + if self.config[0].get('provision_wireless_device'): + self.provisioned_wireless_devices().check_return_status() + + if device_resynced: + self.resync_devices().check_return_status() + + if device_reboot: + self.reboot_access_points().check_return_status() + + if self.config[0].get('export_device_list'): + self.export_device_details().check_return_status() + + return self + + def get_diff_deleted(self, config): + """ + Delete devices in Cisco Catalyst Center based on device IP Address. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center + config (dict): A dictionary containing the list of device IP addresses to be deleted. + Returns: + object: An instance of the class with updated results and status based on the deletion operation. + Description: + This function is responsible for removing devices from the Cisco Catalyst Center inventory and + also unprovsioned and removed wired provsion devices from the Inventory page and also delete + the Global User Defined Field that are associated to the devices. + """ + + device_to_delete = self.get_device_ips_from_config_priority() + self.result['msg'] = [] + + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + for udf in udf_field_list: + field_name = udf.get('name') + udf_id = self.get_udf_id(field_name) + + if udf_id is None: + self.status = "success" + self.msg = "Global UDF '{0}' is not present in Cisco Catalyst Center".format(field_name) + self.log(self.msg, "INFO") + self.result['changed'] = False + self.result['msg'] = self.msg + return self + + try: + response = self.dnac._exec( + family="devices", + function='delete_user_defined_field', + params={"id": udf_id}, + ) + if response and isinstance(response, dict): + self.log("Received API response from 'delete_user_defined_field': {0}".format(str(response)), "DEBUG") + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'success' in execution_details.get("progress"): + self.status = "success" + self.msg = "Global UDF '{0}' deleted successfully from Cisco Catalyst Center".format(field_name) + self.log(self.msg, "INFO") + 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 = "Failed to delete Global User Defined Field(UDF) due to: {0}".format(failure_reason) + else: + self.msg = "Global UDF deletion get failed." + self.log(self.msg, "ERROR") + break + + except Exception as e: + error_message = "Error while deleting Global UDF from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + return self + + for device_ip in device_to_delete: + if device_ip not in self.have.get("device_in_ccc"): + self.status = "success" + self.result['changed'] = False + self.msg = "Device '{0}' is not present in Cisco Catalyst Center so can't perform delete operation".format(device_ip) + self.result['msg'].append(self.msg) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + continue + + try: + provision_params = { + "device_management_ip_address": device_ip + } + prov_respone = self.dnac._exec( + family="sda", + function='get_provisioned_wired_device', + params=provision_params, + ) + + if prov_respone.get("status") == "success": + response = self.dnac._exec( + family="sda", + function='delete_provisioned_wired_device', + params=provision_params, + ) + 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, "INFO") + self.result['response'] = self.msg + self.result['msg'].append(self.msg) + break + elif execution_details.get("bapiError"): + self.msg = execution_details.get("bapiError") + self.result['msg'].append(self.msg) + self.log(self.msg, "ERROR") + break + except Exception as e: + device_id = self.get_device_ids([device_ip]) + delete_params = { + "id": device_id[0], + "clean_config": self.config[0].get("clean_config", False) + } + response = self.dnac._exec( + family="devices", + function='delete_device_by_id', + params=delete_params, + ) + + if response and isinstance(response, dict): + task_id = response.get('response').get('taskId') + + while True: + execution_details = self.get_task_details(task_id) + + if 'success' in execution_details.get("progress"): + self.status = "success" + self.msg = "Device '{0}' was successfully deleted from Cisco Catalyst Center".format(device_ip) + self.log(self.msg, "INFO") + 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 '{0}' deletion get failed due to: {1}".format(device_ip, failure_reason) + else: + self.msg = "Device '{0}' deletion get failed.".format(device_ip) + self.log(self.msg, "ERROR") + break + self.result['msg'].append(self.msg) + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Addition/Updation) of Devices in Cisco Catalyst Center. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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 Catalyst Center configuration. + + The function performs the following verifications: + - Checks for devices added to Cisco Catalyst 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 State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + devices_to_add = self.have["device_not_in_ccc"] + credential_update = self.config[0].get("credential_update", False) + device_type = self.config[0].get("type", "NETWORK_DEVICE") + device_ips = self.get_device_ips_from_config_priority() + + if not devices_to_add: + self.status = "success" + msg = """Requested device(s) '{0}' have been successfully added to the Cisco Catalyst Center and their + addition has been verified.""".format(str(self.have['devices_in_playbook'])) + self.log(msg, "INFO") + else: + self.log("""Playbook's input does not match with Cisco Catalyst Center, indicating that the device addition + task may not have executed successfully.""", "INFO") + + if self.config[0].get('update_interface_details'): + interface_update_flag = True + interface_names_list = self.config[0].get('update_interface_details').get('interface_name') + + for device_ip in device_ips: + for interface_name in interface_names_list: + if not self.check_interface_details(device_ip, interface_name): + 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, "INFO") + else: + self.log("""Playbook's input does not match with Cisco Catalyst Center, indicating that the update + interface details task may not have executed successfully.""", "INFO") + + if 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, "INFO") + else: + self.log("Playbook parameter does not match with Cisco Catalyst Center, meaning device updation task not executed properly.", "INFO") + elif device_type != "NETWORK_DEVICE": + self.log("""Unable to compare the parameter for device type '{0}' in the playbook with the one in Cisco Catalyst Center.""" + .format(device_type), "WARNING") + + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + for udf in udf_field_list: + field_name = udf.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, "INFO") + else: + self.log("""Mismatch between playbook parameter and Cisco Catalyst Center detected, indicating that + the task of creating Global UDF may not have executed successfully.""", "INFO") + + if self.config[0].get('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, "INFO") + else: + self.log("""Mismatch between playbook parameter 'role' and Cisco Catalyst Center detected, indicating the + device role update task may not have executed successfully.""", "INFO") + + if self.config[0].get('provision_wired_device'): + provision_wired_list = self.config[0].get('provision_wired_device') + provision_wired_flag = True + provision_device_list = [] + + for prov_dict in provision_wired_list: + device_ip = prov_dict['device_ip'] + provision_device_list.append(device_ip) + 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(provision_device_list) + self.log(msg, "INFO") + else: + self.log("""Mismatch between playbook's input and Cisco Catalyst Center detected, indicating that + the provisioning task may not have executed successfully.""", "INFO") + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of Device and Global UDF in Cisco Catalyst Center. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified Devices or Global UDF deleted from Cisco Catalyst Center. + """ + + self.get_have(config) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + input_devices = self.have["want_device"] + device_in_ccc = self.device_exists_in_ccc() + + if self.config[0].get('add_user_defined_field'): + udf_field_list = self.config[0].get('add_user_defined_field') + for udf in udf_field_list: + field_name = udf.get('name') + udf_id = self.get_udf_id(field_name) + + if udf_id is None: + self.status = "success" + msg = """Global UDF named '{0}' has been successfully deleted from Cisco Catalyst Center and the deletion + has been verified.""".format(field_name) + self.log(msg, "INFO") + + return self + + device_delete_flag = True + for device_ip in input_devices: + if device_ip in device_in_ccc: + device_after_deletion = device_ip + device_delete_flag = False + break + + if device_delete_flag: + self.status = "success" + self.msg = "Requested device(s) '{0}' deleted from Cisco Catalyst Center and the deletion has been verified.".format(str(input_devices)) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between playbook parameter device({0}) and Cisco Catalyst Center detected, indicating that + the device deletion task may not have executed successfully.""".format(device_after_deletion), "INFO") + + return self + + +def main(): + """ main entry point for module execution + """ + + element_spec = {'dnac_host': {'type': 'str', 'required': True, }, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + + ccc_device = Inventory(module) + state = ccc_device.params.get("state") + + if state not in ccc_device.supported_states: + ccc_device.status = "invalid" + ccc_device.msg = "State {0} is invalid".format(state) + ccc_device.check_return_status() + + ccc_device.validate_input().check_return_status() + config_verify = ccc_device.params.get("config_verify") + + for config in ccc_device.validated_config: + ccc_device.reset_values() + ccc_device.get_want(config).check_return_status() + ccc_device.get_have(config).check_return_status() + ccc_device.get_diff_state_apply[state](config).check_return_status() + if config_verify: + ccc_device.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_device.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/network_settings_intent.py b/ansible_collections/cisco/dnac/plugins/modules/network_settings_intent.py new file mode 100644 index 000000000..49d6fa5d4 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/network_settings_intent.py @@ -0,0 +1,2225 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2023, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module to perform operations on global pool, reserve pool and network in DNAC.""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ['Muthu Rakesh, Madhan Sankaranarayanan'] + +DOCUMENTATION = r""" +--- +module: network_settings_intent +short_description: Resource module for IP Address pools and network functions +description: +- Manage operations on Global Pool, Reserve Pool, Network resources. +- API to create/update/delete global pool. +- API to reserve/update/delete an ip subpool from the global pool. +- API to update network settings for DHCP, Syslog, SNMP, NTP, Network AAA, Client and Endpoint AAA, + and/or DNS center server settings. +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.intent_params +author: Muthu Rakesh (@MUTHU-RAKESH-27) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of global pool, reserved pool, network being managed. + type: list + elements: dict + required: true + suboptions: + global_pool_details: + description: Manages IPv4 and IPv6 IP pools in the global level. + type: dict + suboptions: + settings: + description: Global Pool's settings. + type: dict + suboptions: + ip_pool: + description: Contains a list of global IP pool configurations. + elements: dict + type: list + suboptions: + dhcp_server_ips: + description: > + The DHCP server IPs responsible for automatically assigning IP addresses + and network configuration parameters to devices on a local network. + elements: str + type: list + dns_server_ips: + description: Responsible for translating domain names into corresponding IP addresses. + elements: str + type: list + gateway: + description: Serves as an entry or exit point for data traffic between networks. + type: str + ip_address_space: + description: IP address space either IPv4 or IPv6. + type: str + cidr: + description: > + Defines the IP pool's Classless Inter-Domain Routing block, + enabling systematic IP address distribution within a network. + type: str + prev_name: + description: > + The former identifier for the global pool. It should be used + exclusively when you need to update the global pool's name. + type: str + name: + description: Specifies the name assigned to the Global IP Pool. + type: str + pool_type: + description: > + Includes both the Generic Ip Pool and Tunnel Ip Pool. + Generic - Used for general purpose within the network such as device + management or communication between the network devices. + Tunnel - Designated for the tunnel interfaces to encapsulate packets + within the network protocol. It is used in VPN connections, + GRE tunnels, or other types of overlay networks. + default: Generic + choices: [Generic, Tunnel] + type: str + + reserve_pool_details: + description: Reserved IP subpool details from the global pool. + type: dict + suboptions: + ipv4_dhcp_servers: + description: Specifies the IPv4 addresses for DHCP servers, for example, "1.1.1.1". + elements: str + type: list + ipv4_dns_servers: + description: Specifies the IPv4 addresses for DNS servers, for example, "4.4.4.4". + elements: str + type: list + ipv4_gateway: + description: Provides the gateway's IPv4 address, for example, "175.175.0.1". + type: str + version_added: 4.0.0 + ipv4_global_pool: + description: IP v4 Global pool address with cidr, example 175.175.0.0/16. + type: str + ipv4_prefix: + description: ip4 prefix length is enabled or ipv4 total Host input is enabled + type: bool + ipv4_prefix_length: + description: The ipv4 prefix length is required when ipv4_prefix value is true. + type: int + ipv4_subnet: + description: Indicates the IPv4 subnet address, for example, "175.175.0.0". + type: str + ipv4_total_host: + description: The total number of hosts for IPv4, required when the 'ipv4_prefix' is set to false. + type: int + ipv6_address_space: + description: > + Determines whether both IPv6 and IPv4 inputs are required. + If set to false, only IPv4 inputs are required. + If set to true, both IPv6 and IPv4 inputs are required. + type: bool + ipv6_dhcp_servers: + description: > + Specifies the IPv6 addresses for DHCP servers in the format. + For example, "2001:0db8:0123:4567:89ab:cdef:0001:0001". + elements: str + type: list + ipv6_dns_servers: + description: > + Specifies the IPv6 addresses for DNS servers. + For example, "2001:0db8:0123:4567:89ab:cdef:0002:0002". + elements: str + type: list + ipv6_gateway: + description: > + Provides the gateway's IPv6 address. + For example, "2001:0db8:0123:4567:89ab:cdef:0003:0003". + type: str + ipv6_global_pool: + description: > + IPv6 Global pool address with cidr this is required when ipv6_address_space + value is true, example 2001 db8 85a3 /64. + type: str + ipv6_prefix: + description: > + Ipv6 prefix value is true, the ip6 prefix length input field is enabled, + if it is false ipv6 total Host input is enable. + type: bool + ipv6_prefix_length: + description: IPv6 prefix length is required when the ipv6_prefix value is true. + type: int + ipv6_subnet: + description: IPv6 Subnet address, example 2001 db8 85a3 0 100. + type: str + ipv6_total_host: + description: The total number of hosts for IPv6 is required if the 'ipv6_prefix' is set to false. + type: int + name: + description: Name of the reserve IP subpool. + type: str + prev_name: + description: The former name associated with the reserved IP sub-pool. + type: str + site_name: + description: > + The name of the site provided as a path parameter, used + to specify where the IP sub-pool will be reserved. + type: str + slaac_support: + description: > + Allows devices on IPv6 networks to self-configure their + IP addresses autonomously, eliminating the need for manual setup. + type: bool + pool_type: + description: Type of the reserve ip sub pool. + Generic - Used for general purpose within the network such as device + management or communication between the network devices. + LAN - Used for the devices and the resources within the Local Area Network + such as device connectivity, internal communication, or services. + Management - Used for the management purposes such as device management interfaces, + management access, or other administrative functions. + Service - Used for the network services and application such as DNS (Domain Name System), + DHCP (Dynamic Host Configuration Protocol), NTP (Network Time Protocol). + WAN - Used for the devices and resources with the Wide Area Network such as remote + sites interconnection with other network or services hosted within WAN. + default: Generic + choices: [Generic, LAN, Management, Service, WAN] + type: str + network_management_details: + description: Set default network settings for the site + type: dict + suboptions: + settings: + description: Network management details settings. + type: dict + suboptions: + client_and_endpoint_aaa: + description: Network V2's clientAndEndpoint_aaa. + suboptions: + ip_address: + description: IP address for ISE serve (eg 1.1.1.4). + type: str + network: + description: IP address for AAA or ISE server (eg 2.2.2.1). + type: str + protocol: + description: Protocol for AAA or ISE serve (eg RADIUS). + type: str + servers: + description: Server type AAA or ISE server (eg AAA). + type: str + shared_secret: + description: Shared secret for ISE server. + type: str + type: dict + dhcp_server: + description: DHCP Server IP (eg 1.1.1.1). + elements: str + type: list + dns_server: + description: Network V2's dnsServer. + suboptions: + domain_name: + description: Domain Name of DHCP (eg; cisco). + type: str + primary_ip_address: + description: Primary IP Address for DHCP (eg 2.2.2.2). + type: str + secondary_ip_address: + description: Secondary IP Address for DHCP (eg 3.3.3.3). + type: str + type: dict + message_of_the_day: + description: Network V2's messageOfTheday. + suboptions: + banner_message: + description: Massage for Banner message (eg; Good day). + type: str + retain_existing_banner: + description: Retain existing Banner Message (eg "true" or "false"). + type: str + type: dict + netflow_collector: + description: Network V2's netflowcollector. + suboptions: + ip_address: + description: IP Address for NetFlow collector (eg 3.3.3.1). + type: str + port: + description: Port for NetFlow Collector (eg; 443). + type: int + type: dict + network_aaa: + description: Network V2's network_aaa. + suboptions: + ip_address: + description: IP address for AAA and ISE server (eg 1.1.1.1). + type: str + network: + description: IP Address for AAA or ISE server (eg 2.2.2.2). + type: str + protocol: + description: Protocol for AAA or ISE serve (eg RADIUS). + type: str + servers: + description: Server type for AAA Network (eg AAA). + type: str + shared_secret: + description: Shared secret for ISE Server. + type: str + type: dict + ntp_server: + description: IP address for NTP server (eg 1.1.1.2). + elements: str + type: list + snmp_server: + description: Network V2's snmpServer. + suboptions: + configure_dnac_ip: + description: Configuration Cisco Catalyst Center IP for SNMP Server (eg true). + type: bool + ip_addresses: + description: IP Address for SNMP Server (eg 4.4.4.1). + elements: str + type: list + type: dict + syslog_server: + description: Network V2's syslogServer. + suboptions: + configure_dnac_ip: + description: Configuration Cisco Catalyst Center IP for syslog server (eg true). + type: bool + ip_addresses: + description: IP Address for syslog server (eg 4.4.4.4). + elements: str + type: list + type: dict + timezone: + description: Input for time zone (eg Africa/Abidjan). + type: str + site_name: + description: > + The name of the site provided as a path parameter, used + to specify where the IP sub-pool will be reserved. + type: str +requirements: +- dnacentersdk == 2.4.5 +- python >= 3.5 +notes: + - SDK Method used are + network_settings.NetworkSettings.create_global_pool, + network_settings.NetworkSettings.delete_global_ip_pool, + network_settings.NetworkSettings.update_global_pool, + network_settings.NetworkSettings.release_reserve_ip_subpool, + network_settings.NetworkSettings.reserve_ip_subpool, + network_settings.NetworkSettings.update_reserve_ip_subpool, + network_settings.NetworkSettings.update_network_v2, + + - Paths used are + post /dna/intent/api/v1/global-pool, + delete /dna/intent/api/v1/global-pool/{id}, + put /dna/intent/api/v1/global-pool, + post /dna/intent/api/v1/reserve-ip-subpool/{siteId}, + delete /dna/intent/api/v1/reserve-ip-subpool/{id}, + put /dna/intent/api/v1/reserve-ip-subpool/{siteId}, + put /dna/intent/api/v2/network/{siteId}, + +""" + +EXAMPLES = r""" +- name: Create global pool, reserve an ip pool and network + cisco.dnac.network_settings_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 + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_pool_details: + settings: + ip_pool: + - name: string + gateway: string + ip_address_space: string + cidr: string + pool_type: Generic + dhcp_server_ips: list + dns_server_ips: list + reserve_pool_details: + ipv6_address_space: True + ipv4_global_pool: string + ipv4_prefix: True + ipv4_prefix_length: 9 + ipv4_subnet: string + name: string + ipv6_prefix: True + ipv6_prefix_length: 64 + ipv6_global_pool: string + ipv6_subnet: string + site_name: string + slaac_support: True + pool_type: LAN + network_management_details: + settings: + dhcp_server: list + dns_server: + domain_name: string + primary_ip_address: string + secondary_ip_address: string + client_and_endpoint_aaa: + network: string + protocol: string + servers: string + message_of_the_day: + banner_message: string + retain_existing_banner: string + netflow_collector: + ip_address: string + port: 443 + network_aaa: + network: string + protocol: string + servers: string + ntp_server: list + snmp_server: + configure_dnac_ip: True + ip_addresses: list + syslog_server: + configure_dnac_ip: True + ip_addresses: list + site_name: string +""" + +RETURN = r""" +# Case_1: Successful creation/updation/deletion of global pool +response_1: + description: A dictionary or list with the response returned by the Cisco DNA Center Python SDK + returned: always + type: dict + sample: > + { + "executionId": "string", + "executionStatusUrl": "string", + "message": "string" + } + +# Case_2: Successful creation/updation/deletion of reserve pool +response_2: + description: A dictionary or list with the response returned by the Cisco DNA Center Python SDK + returned: always + type: dict + sample: > + { + "executionId": "string", + "executionStatusUrl": "string", + "message": "string" + } + +# Case_3: Successful creation/updation of network +response_3: + description: A dictionary or list with the response returned by the Cisco DNA Center Python SDK + returned: always + type: dict + sample: > + { + "executionId": "string", + "executionStatusUrl": "string", + "message": "string" + } +""" + +import copy +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result, + dnac_compare_equality, +) + + +class NetworkSettings(DnacBase): + """Class containing member attributes for network intent module""" + + def __init__(self, module): + super().__init__(module) + self.result["response"] = [ + {"globalPool": {"response": {}, "msg": {}}}, + {"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): + """ + Checks if the configuration parameters provided in the playbook + meet the expected structure and data types, + as defined in the 'temp_spec' dictionary. + + Parameters: + None + + Returns: + self + + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + # temp_spec is the specification for the expected structure of configuration parameters + temp_spec = { + "global_pool_details": { + "type": 'dict', + "settings": { + "type": 'dict', + "ip_pool": { + "type": 'list', + "ip_address_space": {"type": 'string'}, + "dhcp_server_ips": {"type": 'list'}, + "dns_server_ips": {"type": 'list'}, + "gateway": {"type": 'string'}, + "cidr": {"type": 'string'}, + "name": {"type": 'string'}, + "prevName": {"type": 'string'}, + "pool_type": { + "type": 'string', + "choices": ["Generic", "LAN", "Management", "Service", "WAN"] + }, + } + } + }, + "reserve_pool_details": { + "type": 'dict', + "name": {"type": 'string'}, + "prevName": {"type": 'string'}, + "ipv6_address_space": {"type": 'bool'}, + "ipv4_global_pool": {"type": 'string'}, + "ipv4_prefix": {"type": 'bool'}, + "ipv4_prefix_length": {"type": 'string'}, + "ipv4_subnet": {"type": 'string'}, + "ipv4GateWay": {"type": 'string'}, + "ipv4DhcpServers": {"type": 'list'}, + "ipv4_dns_servers": {"type": 'list'}, + "ipv6_global_pool": {"type": 'string'}, + "ipv6_prefix": {"type": 'bool'}, + "ipv6_prefix_length": {"type": 'integer'}, + "ipv6_subnet": {"type": 'string'}, + "ipv6GateWay": {"type": 'string'}, + "ipv6DhcpServers": {"type": 'list'}, + "ipv6DnsServers": {"type": 'list'}, + "ipv4TotalHost": {"type": 'integer'}, + "ipv6TotalHost": {"type": 'integer'}, + "slaac_support": {"type": 'bool'}, + "site_name": {"type": 'string'}, + "pool_type": { + "type": 'string', + "choices": ["Generic", "LAN", "Management", "Service", "WAN"] + }, + }, + "network_management_details": { + "type": 'dict', + "settings": { + "type": 'dict', + "dhcp_server": {"type": 'list'}, + "dns_server": { + "type": 'dict', + "domain_name": {"type": 'string'}, + "primary_ip_address": {"type": 'string'}, + "secondary_ip_address": {"type": 'string'} + }, + "syslog_server": { + "type": 'dict', + "ip_addresses": {"type": 'list'}, + "configure_dnac_ip": {"type": 'bool'} + }, + "snmp_server": { + "type": 'dict', + "ip_addresses": {"type": 'list'}, + "configure_dnac_ip": {"type": 'bool'} + }, + "netflow_collector": { + "type": 'dict', + "ip_address": {"type": 'string'}, + "port": {"type": 'integer'}, + }, + "timezone": {"type": 'string'}, + "ntp_server": {"type": 'list'}, + "message_of_the_day": { + "type": 'dict', + "banner_message": {"type": 'string'}, + "retain_existing_banner": {"type": 'bool'}, + }, + "network_aaa": { + "type": 'dict', + "servers": {"type": 'string', "choices": ["ISE", "AAA"]}, + "ip_address": {"type": 'string'}, + "network": {"type": 'string'}, + "protocol": {"type": 'string', "choices": ["RADIUS", "TACACS"]}, + "shared_secret": {"type": 'string'} + + }, + "client_and_endpoint_aaa": { + "type": 'dict', + "servers": {"type": 'string', "choices": ["ISE", "AAA"]}, + "ip_address": {"type": 'string'}, + "network": {"type": 'string'}, + "protocol": {"type": 'string', "choices": ["RADIUS", "TACACS"]}, + "shared_secret": {"type": 'string'} + } + }, + "site_name": {"type": 'string'}, + } + } + + # 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)) + self.status = "failed" + return self + + self.validated_config = valid_temp + self.log("Successfully validated playbook config params: {0}".format(valid_temp), "INFO") + self.msg = "Successfully validated input from the playbook" + self.status = "success" + return self + + def requires_update(self, have, want, obj_params): + """ + Check if the template config given requires update by comparing + current information wih the requested information. + + This method compares the current global pool, reserve pool, + or network details from Cisco DNA Center with the user-provided details + from the playbook, using a specified schema for comparison. + + Parameters: + have (dict) - Current information from the Cisco DNA Center + (global pool, reserve pool, network details) + want (dict) - Users provided information from the playbook + obj_params (list of tuples) - A list of parameter mappings specifying which + Cisco DNA Center parameters (dnac_param) correspond to + the user-provided parameters (ansible_param). + + Returns: + bool - True if any parameter specified in obj_params differs between + current_obj and requested_obj, indicating that an update is required. + False if all specified parameters are equal. + + """ + + current_obj = have + requested_obj = want + self.log("Current State (have): {0}".format(current_obj), "DEBUG") + self.log("Desired State (want): {0}".format(requested_obj), "DEBUG") + + return any(not dnac_compare_equality(current_obj.get(dnac_param), + 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("Received an unexpected value for 'get_object': {0}" + .format(get_object)) + except Exception as msg: + self.log("Received exception: {0}".format(msg), "CRITICAL") + + return obj_params + + def get_site_id(self, site_name): + """ + Get the site id from the site name. + Use check_return_status() to check for failure + + Parameters: + site_name (str) - Site name + + Returns: + str or None - The Site Id if found, or None if not found or error + """ + + try: + response = self.dnac._exec( + family="sites", + function='get_site', + params={"name": site_name}, + ) + self.log("Received API response from 'get_site': {0}".format(response), "DEBUG") + if not response: + self.log("Failed to retrieve the site ID for the site name: {0}" + .format(site_name), "ERROR") + return None + + _id = response.get("response")[0].get("id") + self.log("Site ID for site name '{0}': {1}".format(site_name, _id), "DEBUG") + except Exception as msg: + self.log("Exception occurred while retrieving site_id from the site_name: {0}" + .format(msg), "CRITICAL") + return None + + return _id + + def get_global_pool_params(self, pool_info): + """ + Process Global Pool params from playbook data for Global Pool config in Cisco DNA Center + + Parameters: + pool_info (dict) - Playbook data containing information about the global pool + + Returns: + dict or None - Processed Global Pool data in a format suitable + for Cisco DNA Center configuration, or None if pool_info is empty. + """ + + if not pool_info: + self.log("Global Pool is empty", "INFO") + return None + + self.log("Global Pool Details: {0}".format(pool_info), "DEBUG") + global_pool = { + "settings": { + "ippool": [{ + "dhcpServerIps": pool_info.get("dhcpServerIps"), + "dnsServerIps": pool_info.get("dnsServerIps"), + "ipPoolCidr": pool_info.get("ipPoolCidr"), + "ipPoolName": pool_info.get("ipPoolName"), + "type": pool_info.get("ipPoolType").capitalize() + }] + } + } + self.log("Formated global pool details: {0}".format(global_pool), "DEBUG") + global_ippool = global_pool.get("settings").get("ippool")[0] + if pool_info.get("ipv6") is False: + global_ippool.update({"IpAddressSpace": "IPv4"}) + else: + global_ippool.update({"IpAddressSpace": "IPv6"}) + + self.log("ip_address_space: {0}".format(global_ippool.get("IpAddressSpace")), "DEBUG") + if not pool_info["gateways"]: + global_ippool.update({"gateway": ""}) + else: + global_ippool.update({"gateway": pool_info.get("gateways")[0]}) + + return global_pool + + def get_reserve_pool_params(self, pool_info): + """ + Process Reserved Pool parameters from playbook data + for Reserved Pool configuration in Cisco DNA Center + + Parameters: + pool_info (dict) - Playbook data containing information about the reserved pool + + Returns: + reserve_pool (dict) - Processed Reserved pool data + in the format suitable for the Cisco DNA Center config + """ + + reserve_pool = { + "name": pool_info.get("groupName"), + "site_id": pool_info.get("siteId"), + } + if len(pool_info.get("ipPools")) == 1: + reserve_pool.update({ + "ipv4DhcpServers": pool_info.get("ipPools")[0].get("dhcpServerIps"), + "ipv4DnsServers": pool_info.get("ipPools")[0].get("dnsServerIps"), + "ipv6AddressSpace": "False" + }) + if pool_info.get("ipPools")[0].get("gateways") != []: + reserve_pool.update({"ipv4GateWay": pool_info.get("ipPools")[0].get("gateways")[0]}) + else: + reserve_pool.update({"ipv4GateWay": ""}) + reserve_pool.update({"ipv6AddressSpace": "False"}) + elif len(pool_info.get("ipPools")) == 2: + if not pool_info.get("ipPools")[0].get("ipv6"): + reserve_pool.update({ + "ipv4DhcpServers": pool_info.get("ipPools")[0].get("dhcpServerIps"), + "ipv4DnsServers": pool_info.get("ipPools")[0].get("dnsServerIps"), + "ipv6AddressSpace": "True", + "ipv6DhcpServers": pool_info.get("ipPools")[1].get("dhcpServerIps"), + "ipv6DnsServers": pool_info.get("ipPools")[1].get("dnsServerIps"), + + }) + + if pool_info.get("ipPools")[0].get("gateways") != []: + reserve_pool.update({"ipv4GateWay": + pool_info.get("ipPools")[0].get("gateways")[0]}) + else: + reserve_pool.update({"ipv4GateWay": ""}) + + if pool_info.get("ipPools")[1].get("gateways") != []: + reserve_pool.update({"ipv6GateWay": + pool_info.get("ipPools")[1].get("gateways")[0]}) + else: + reserve_pool.update({"ipv6GateWay": ""}) + + elif not pool_info.get("ipPools")[1].get("ipv6"): + reserve_pool.update({ + "ipv4DhcpServers": pool_info.get("ipPools")[1].get("dhcpServerIps"), + "ipv4DnsServers": pool_info.get("ipPools")[1].get("dnsServerIps"), + "ipv6AddressSpace": "True", + "ipv6DnsServers": pool_info.get("ipPools")[0].get("dnsServerIps"), + "ipv6DhcpServers": pool_info.get("ipPools")[0].get("dhcpServerIps") + }) + if pool_info.get("ipPools")[1].get("gateways") != []: + reserve_pool.update({"ipv4GateWay": + pool_info.get("ipPools")[1].get("gateways")[0]}) + else: + reserve_pool.update({"ipv4GateWay": ""}) + + if pool_info.get("ipPools")[0].get("gateways") != []: + reserve_pool.update({"ipv6GateWay": + pool_info.get("ipPools")[0].get("gateways")[0]}) + else: + reserve_pool.update({"ipv6GateWay": ""}) + reserve_pool.update({"slaacSupport": True}) + self.log("Formatted reserve pool details: {0}".format(reserve_pool), "DEBUG") + return reserve_pool + + def get_network_params(self, site_id): + """ + Process the Network parameters from the playbook + for Network configuration in Cisco DNA Center + + Parameters: + site_id (str) - The Site ID for which network parameters are requested + + Returns: + dict or None: Processed Network data in a format + suitable for Cisco DNA Center configuration, or None + if the response is not a dictionary or there was an error. + """ + + response = self.dnac._exec( + family="network_settings", + function='get_network_v2', + params={"site_id": site_id} + ) + self.log("Received API response from 'get_network_v2': {0}".format(response), "DEBUG") + if not isinstance(response, dict): + self.log("Failed to retrieve the network details - " + "Response is not a dictionary", "ERROR") + return None + + # Extract various network-related details from the response + all_network_details = response.get("response") + dhcp_details = get_dict_result(all_network_details, "key", "dhcp.server") + dns_details = get_dict_result(all_network_details, "key", "dns.server") + snmp_details = get_dict_result(all_network_details, "key", "snmp.trap.receiver") + syslog_details = get_dict_result(all_network_details, "key", "syslog.server") + netflow_details = get_dict_result(all_network_details, "key", "netflow.collector") + ntpserver_details = get_dict_result(all_network_details, "key", "ntp.server") + timezone_details = get_dict_result(all_network_details, "key", "timezone.site") + messageoftheday_details = get_dict_result(all_network_details, "key", "device.banner") + network_aaa = get_dict_result(all_network_details, "key", "aaa.network.server.1") + network_aaa2 = get_dict_result(all_network_details, "key", "aaa.network.server.2") + network_aaa_pan = get_dict_result(all_network_details, "key", "aaa.server.pan.network") + clientAndEndpoint_aaa = get_dict_result(all_network_details, "key", "aaa.endpoint.server.1") + clientAndEndpoint_aaa2 = get_dict_result(all_network_details, + "key", + "aaa.endpoint.server.2") + clientAndEndpoint_aaa_pan = \ + get_dict_result(all_network_details, "key", "aaa.server.pan.endpoint") + + # Prepare the network details for Cisco DNA Center configuration + network_details = { + "settings": { + "snmpServer": { + "configureDnacIP": snmp_details.get("value")[0].get("configureDnacIP"), + "ipAddresses": snmp_details.get("value")[0].get("ipAddresses"), + }, + "syslogServer": { + "configureDnacIP": syslog_details.get("value")[0].get("configureDnacIP"), + "ipAddresses": syslog_details.get("value")[0].get("ipAddresses"), + }, + "netflowcollector": { + "ipAddress": netflow_details.get("value")[0].get("ipAddress"), + "port": netflow_details.get("value")[0].get("port") + }, + "timezone": timezone_details.get("value")[0], + } + } + network_settings = network_details.get("settings") + if dhcp_details and dhcp_details.get("value") != []: + network_settings.update({"dhcpServer": dhcp_details.get("value")}) + else: + network_settings.update({"dhcpServer": [""]}) + + if dns_details is not None: + network_settings.update({ + "dnsServer": { + "domainName": dns_details.get("value")[0].get("domainName"), + "primaryIpAddress": dns_details.get("value")[0].get("primaryIpAddress"), + "secondaryIpAddress": dns_details.get("value")[0].get("secondaryIpAddress") + } + }) + + if ntpserver_details and ntpserver_details.get("value") != []: + network_settings.update({"ntpServer": ntpserver_details.get("value")}) + else: + network_settings.update({"ntpServer": [""]}) + + if messageoftheday_details is not None: + network_settings.update({ + "messageOfTheday": { + "bannerMessage": messageoftheday_details.get("value")[0].get("bannerMessage"), + } + }) + retain_existing_banner = messageoftheday_details.get("value")[0] \ + .get("retainExistingBanner") + if retain_existing_banner is True: + network_settings.get("messageOfTheday").update({ + "retainExistingBanner": "true" + }) + else: + network_settings.get("messageOfTheday").update({ + "retainExistingBanner": "false" + }) + + if network_aaa and network_aaa_pan: + aaa_pan_value = network_aaa_pan.get("value")[0] + aaa_value = network_aaa.get("value")[0] + if aaa_pan_value == "None": + network_settings.update({ + "network_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": network_aaa2.get("value")[0].get("ipAddress"), + "servers": "AAA" + } + }) + else: + network_settings.update({ + "network_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": aaa_pan_value, + "servers": "ISE" + } + }) + + if clientAndEndpoint_aaa and clientAndEndpoint_aaa_pan: + aaa_pan_value = clientAndEndpoint_aaa_pan.get("value")[0] + aaa_value = clientAndEndpoint_aaa.get("value")[0] + if aaa_pan_value == "None": + network_settings.update({ + "clientAndEndpoint_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": clientAndEndpoint_aaa2.get("value")[0].get("ipAddress"), + "servers": "AAA" + } + }) + else: + network_settings.update({ + "clientAndEndpoint_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": aaa_pan_value, + "servers": "ISE" + } + }) + + self.log("Formatted playbook network details: {0}".format(network_details), "DEBUG") + return network_details + + def global_pool_exists(self, name): + """ + Check if the Global Pool with the given name exists + + Parameters: + name (str) - The name of the Global Pool to check for existence + + Returns: + dict - A dictionary containing information about the Global Pool's existence: + - 'exists' (bool): True if the Global Pool exists, False otherwise. + - 'id' (str or None): The ID of the Global Pool if it exists, or None if it doesn't. + - 'details' (dict or None): Details of the Global Pool if it exists, else None. + """ + + global_pool = { + "exists": False, + "details": None, + "id": None + } + response = self.dnac._exec( + family="network_settings", + function="get_global_pool", + ) + if not isinstance(response, dict): + self.log("Failed to retrieve the global pool details - " + "Response is not a dictionary", "CRITICAL") + return global_pool + + all_global_pool_details = response.get("response") + global_pool_details = get_dict_result(all_global_pool_details, "ipPoolName", name) + self.log("Global ip pool name: {0}".format(name), "DEBUG") + self.log("Global pool details: {0}".format(global_pool_details), "DEBUG") + if not global_pool_details: + self.log("Global pool {0} does not exist".format(name), "INFO") + return global_pool + global_pool.update({"exists": True}) + global_pool.update({"id": global_pool_details.get("id")}) + global_pool["details"] = self.get_global_pool_params(global_pool_details) + + self.log("Formatted global pool details: {0}".format(global_pool), "DEBUG") + return global_pool + + def reserve_pool_exists(self, name, site_name): + """ + Check if the Reserved pool with the given name exists in a specific site + Use check_return_status() to check for failure + + Parameters: + name (str) - The name of the Reserved pool to check for existence. + site_name (str) - The name of the site where the Reserved pool is located. + + Returns: + dict - A dictionary containing information about the Reserved pool's existence: + - 'exists' (bool): True if the Reserved pool exists in the specified site, else False. + - 'id' (str or None): The ID of the Reserved pool if it exists, or None if it doesn't. + - 'details' (dict or None): Details of the Reserved pool if it exists, or else None. + """ + + reserve_pool = { + "exists": False, + "details": None, + "id": None, + "success": True + } + site_id = self.get_site_id(site_name) + self.log("Site ID for the site name {0}: {1}".format(site_name, site_id), "DEBUG") + if not site_id: + reserve_pool.update({"success": False}) + self.msg = "Failed to get the site id from the site name {0}".format(site_name) + self.status = "failed" + return reserve_pool + + response = self.dnac._exec( + family="network_settings", + function="get_reserve_ip_subpool", + params={"siteId": site_id} + ) + if not isinstance(response, dict): + reserve_pool.update({"success": False}) + self.msg = "Error in getting reserve pool - Response is not a dictionary" + self.status = "exited" + return reserve_pool + + all_reserve_pool_details = response.get("response") + reserve_pool_details = get_dict_result(all_reserve_pool_details, "groupName", name) + if not reserve_pool_details: + self.log("Reserved pool {0} does not exist in the site {1}" + .format(name, site_name), "DEBUG") + return reserve_pool + + reserve_pool.update({"exists": True}) + reserve_pool.update({"id": reserve_pool_details.get("id")}) + reserve_pool.update({"details": self.get_reserve_pool_params(reserve_pool_details)}) + + self.log("Reserved pool details: {0}".format(reserve_pool.get("details")), "DEBUG") + self.log("Reserved pool id: {0}".format(reserve_pool.get("id")), "DEBUG") + return reserve_pool + + def get_have_global_pool(self, config): + """ + Get the current Global Pool information from + Cisco DNA Center based on the provided playbook details. + check this API using check_return_status. + + Parameters: + config (dict) - Playbook details containing Global Pool configuration. + + Returns: + self - The current object with updated information. + """ + + global_pool = { + "exists": False, + "details": None, + "id": None + } + global_pool_settings = config.get("global_pool_details").get("settings") + if global_pool_settings is None: + self.msg = "settings in global_pool_details is missing in the playbook" + self.status = "failed" + return self + + global_pool_ippool = global_pool_settings.get("ip_pool") + if global_pool_ippool is None: + self.msg = "ip_pool in global_pool_details is missing in the playbook" + self.status = "failed" + return self + + name = global_pool_ippool[0].get("name") + if name is None: + self.msg = "Mandatory Parameter name required" + self.status = "failed" + return self + + # If the Global Pool doesn't exist and a previous name is provided + # Else try using the previous name + global_pool = self.global_pool_exists(name) + self.log("Global pool details: {0}".format(global_pool), "DEBUG") + prev_name = global_pool_ippool[0].get("prev_name") + if global_pool.get("exists") is False and \ + prev_name is not None: + global_pool = self.global_pool_exists(prev_name) + if global_pool.get("exists") is False: + self.msg = "Prev name {0} doesn't exist in global_pool_details".format(prev_name) + self.status = "failed" + return self + + self.log("Global pool exists: {0}".format(global_pool.get("exists")), "DEBUG") + self.log("Current Site: {0}".format(global_pool.get("details")), "DEBUG") + self.have.update({"globalPool": global_pool}) + self.msg = "Collecting the global pool details from the Cisco DNA Center" + self.status = "success" + return self + + def get_have_reserve_pool(self, config): + """ + Get the current Reserved Pool information from Cisco DNA Center + based on the provided playbook details. + Check this API using check_return_status + + Parameters: + config (list of dict) - Playbook details containing Reserved Pool configuration. + + Returns: + self - The current object with updated information. + """ + + reserve_pool = { + "exists": False, + "details": None, + "id": None + } + reserve_pool_details = config.get("reserve_pool_details") + name = reserve_pool_details.get("name") + if name is None: + self.msg = "Mandatory Parameter name required in reserve_pool_details\n" + self.status = "failed" + return self + + site_name = reserve_pool_details.get("site_name") + self.log("Site Name: {0}".format(site_name), "DEBUG") + if site_name is None: + self.msg = "Missing parameter 'site_name' in reserve_pool_details" + self.status = "failed" + return self + + # Check if the Reserved Pool exists in Cisco DNA Center + # based on the provided name and site name + reserve_pool = self.reserve_pool_exists(name, site_name) + if not reserve_pool.get("success"): + return self.check_return_status() + self.log("Reserved pool details: {0}".format(reserve_pool), "DEBUG") + + # If the Reserved Pool doesn't exist and a previous name is provided + # Else try using the previous name + prev_name = reserve_pool_details.get("prev_name") + if reserve_pool.get("exists") is False and \ + prev_name is not None: + reserve_pool = self.reserve_pool_exists(prev_name, site_name) + if not reserve_pool.get("success"): + return self.check_return_status() + + # If the previous name doesn't exist in Cisco DNA Center, return with error + if reserve_pool.get("exists") is False: + self.msg = "Prev name {0} doesn't exist in reserve_pool_details".format(prev_name) + self.status = "failed" + return self + + self.log("Reserved pool exists: {0}".format(reserve_pool.get("exists")), "DEBUG") + self.log("Reserved pool: {0}".format(reserve_pool.get("details")), "DEBUG") + + # If reserve pool exist, convert ipv6AddressSpace to the required format (boolean) + if reserve_pool.get("exists"): + reserve_pool_details = reserve_pool.get("details") + if reserve_pool_details.get("ipv6AddressSpace") == "False": + reserve_pool_details.update({"ipv6AddressSpace": False}) + else: + reserve_pool_details.update({"ipv6AddressSpace": True}) + + self.log("Reserved pool details: {0}".format(reserve_pool), "DEBUG") + self.have.update({"reservePool": reserve_pool}) + self.msg = "Collecting the reserve pool details from the Cisco DNA Center" + self.status = "success" + return self + + def get_have_network(self, config): + """ + Get the current Network details from Cisco DNA + Center based on the provided playbook details. + + Parameters: + config (dict) - Playbook details containing Network Management configuration. + + Returns: + self - The current object with updated Network information. + """ + network = {} + site_name = config.get("network_management_details").get("site_name") + if site_name is None: + self.msg = "Mandatory Parameter 'site_name' missing" + self.status = "failed" + return self + + site_id = self.get_site_id(site_name) + if site_id is None: + self.msg = "Failed to get site id from {0}".format(site_name) + self.status = "failed" + return self + + network["site_id"] = site_id + network["net_details"] = self.get_network_params(site_id) + self.log("Network details from the Catalyst Center: {0}".format(network), "DEBUG") + self.have.update({"network": network}) + self.msg = "Collecting the network details from the Cisco DNA Center" + self.status = "success" + return self + + def get_have(self, config): + """ + Get the current Global Pool Reserved Pool and Network details from Cisco DNA Center + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self - The current object with updated Global Pool, + Reserved Pool, and Network information. + """ + + if config.get("global_pool_details") is not None: + self.get_have_global_pool(config).check_return_status() + + if config.get("reserve_pool_details") is not None: + self.get_have_reserve_pool(config).check_return_status() + + if config.get("network_management_details") is not None: + self.get_have_network(config).check_return_status() + + self.log("Current State (have): {0}".format(self.have), "INFO") + self.msg = "Successfully retrieved the details from the Cisco DNA Center" + self.status = "success" + return self + + def get_want_global_pool(self, global_ippool): + """ + Get all the Global Pool information from playbook + Set the status and the msg before returning from the API + Check the return value of the API with check_return_status() + + Parameters: + global_ippool (dict) - Playbook global pool details containing IpAddressSpace, + DHCP server IPs, DNS server IPs, IP pool name, IP pool CIDR, gateway, and type. + + Returns: + self - The current object with updated desired Global Pool information. + """ + + # Initialize the desired Global Pool configuration + want_global = { + "settings": { + "ippool": [{ + "IpAddressSpace": global_ippool.get("ip_address_space"), + "dhcpServerIps": global_ippool.get("dhcp_server_ips"), + "dnsServerIps": global_ippool.get("dns_server_ips"), + "ipPoolName": global_ippool.get("name"), + "ipPoolCidr": global_ippool.get("cidr"), + "gateway": global_ippool.get("gateway"), + "type": global_ippool.get("pool_type"), + }] + } + } + want_ippool = want_global.get("settings").get("ippool")[0] + + # Converting to the required format based on the existing Global Pool + if not self.have.get("globalPool").get("exists"): + if want_ippool.get("dhcpServerIps") is None: + want_ippool.update({"dhcpServerIps": []}) + if want_ippool.get("dnsServerIps") is None: + want_ippool.update({"dnsServerIps": []}) + if want_ippool.get("IpAddressSpace") is None: + want_ippool.update({"IpAddressSpace": ""}) + if want_ippool.get("gateway") is None: + want_ippool.update({"gateway": ""}) + if want_ippool.get("type") is None: + global_ippool_type = global_ippool.get("type") + if not global_ippool_type: + want_ippool.update({"type": "Generic"}) + else: + want_ippool.update({"type": global_ippool_type}) + self.log("'type' is deprecated and use 'pool_type'", "WARNING") + + else: + have_ippool = self.have.get("globalPool").get("details") \ + .get("settings").get("ippool")[0] + + # Copy existing Global Pool information if the desired configuration is not provided + want_ippool.update({ + "IpAddressSpace": have_ippool.get("IpAddressSpace"), + "type": have_ippool.get("type"), + "ipPoolCidr": have_ippool.get("ipPoolCidr") + }) + want_ippool.update({}) + want_ippool.update({}) + + for key in ["dhcpServerIps", "dnsServerIps", "gateway"]: + if want_ippool.get(key) is None and have_ippool.get(key) is not None: + want_ippool[key] = have_ippool[key] + + self.log("Global pool playbook details: {0}".format(want_global), "DEBUG") + self.want.update({"wantGlobal": want_global}) + self.msg = "Collecting the global pool details from the playbook" + self.status = "success" + return self + + def get_want_reserve_pool(self, reserve_pool): + """ + Get all the Reserved Pool information from playbook + Set the status and the msg before returning from the API + Check the return value of the API with check_return_status() + + Parameters: + reserve_pool (dict) - Playbook reserved pool + details containing various properties. + + Returns: + self - The current object with updated desired Reserved Pool information. + """ + + want_reserve = { + "name": reserve_pool.get("name"), + "type": reserve_pool.get("pool_type"), + "ipv6AddressSpace": reserve_pool.get("ipv6_address_space"), + "ipv4GlobalPool": reserve_pool.get("ipv4_global_pool"), + "ipv4Prefix": reserve_pool.get("ipv4_prefix"), + "ipv4PrefixLength": reserve_pool.get("ipv4_prefix_length"), + "ipv4GateWay": reserve_pool.get("ipv4_gateway"), + "ipv4DhcpServers": reserve_pool.get("ipv4_dhcp_servers"), + "ipv4DnsServers": reserve_pool.get("ipv4_dns_servers"), + "ipv4Subnet": reserve_pool.get("ipv4_subnet"), + "ipv6GlobalPool": reserve_pool.get("ipv6_global_pool"), + "ipv6Prefix": reserve_pool.get("ipv6_prefix"), + "ipv6PrefixLength": reserve_pool.get("ipv6_prefix_length"), + "ipv6GateWay": reserve_pool.get("ipv6_gateway"), + "ipv6DhcpServers": reserve_pool.get("ipv6_dhcp_servers"), + "ipv6Subnet": reserve_pool.get("ipv6_subnet"), + "ipv6DnsServers": reserve_pool.get("ipv6_dns_servers"), + "ipv4TotalHost": reserve_pool.get("ipv4_total_host"), + "ipv6TotalHost": reserve_pool.get("ipv6_total_host") + } + + # Check for missing mandatory parameters in the playbook + if not want_reserve.get("name"): + self.msg = "Missing mandatory parameter 'name' in reserve_pool_details" + self.status = "failed" + return self + + if want_reserve.get("ipv4Prefix") is True: + if want_reserve.get("ipv4Subnet") is None and \ + want_reserve.get("ipv4TotalHost") is None: + self.msg = "missing parameter 'ipv4_subnet' or 'ipv4TotalHost' \ + while adding the ipv4 in reserve_pool_details" + self.status = "failed" + return self + + if want_reserve.get("ipv6Prefix") is True: + if want_reserve.get("ipv6Subnet") is None and \ + want_reserve.get("ipv6TotalHost") is None: + self.msg = "missing parameter 'ipv6_subnet' or 'ipv6TotalHost' \ + while adding the ipv6 in reserve_pool_details" + self.status = "failed" + return self + + self.log("Reserved IP pool playbook details: {0}".format(want_reserve), "DEBUG") + + # If there are no existing Reserved Pool details, validate and set defaults + if not self.have.get("reservePool").get("details"): + if not want_reserve.get("ipv4GlobalPool"): + self.msg = "missing parameter 'ipv4GlobalPool' in reserve_pool_details" + self.status = "failed" + return self + + if not want_reserve.get("ipv4PrefixLength"): + self.msg = "missing parameter 'ipv4_prefix_length' in reserve_pool_details" + self.status = "failed" + return self + + if want_reserve.get("type") is None: + reserve_pool_type = reserve_pool.get("type") + if not reserve_pool_type: + want_reserve.update({"type": "Generic"}) + else: + want_reserve.update({"type": reserve_pool_type}) + self.log("'type' is deprecated and use 'pool_type'", "WARNING") + if want_reserve.get("ipv4GateWay") is None: + want_reserve.update({"ipv4GateWay": ""}) + if want_reserve.get("ipv4DhcpServers") is None: + want_reserve.update({"ipv4DhcpServers": []}) + if want_reserve.get("ipv4DnsServers") is None: + want_reserve.update({"ipv4DnsServers": []}) + if want_reserve.get("ipv6AddressSpace") is None: + want_reserve.update({"ipv6AddressSpace": False}) + if want_reserve.get("slaacSupport") is None: + want_reserve.update({"slaacSupport": True}) + if want_reserve.get("ipv4TotalHost") is None: + del want_reserve['ipv4TotalHost'] + if want_reserve.get("ipv6AddressSpace") is True: + want_reserve.update({"ipv6Prefix": True}) + else: + del want_reserve['ipv6Prefix'] + + if not want_reserve.get("ipv6AddressSpace"): + keys_to_check = ['ipv6GlobalPool', 'ipv6PrefixLength', + 'ipv6GateWay', 'ipv6DhcpServers', + 'ipv6DnsServers', 'ipv6TotalHost'] + for key in keys_to_check: + if want_reserve.get(key) is None: + del want_reserve[key] + else: + keys_to_delete = ['type', 'ipv4GlobalPool', + 'ipv4Prefix', 'ipv4PrefixLength', + 'ipv4TotalHost', 'ipv4Subnet'] + for key in keys_to_delete: + if key in want_reserve: + del want_reserve[key] + + self.want.update({"wantReserve": want_reserve}) + self.log("Desired State (want): {0}".format(self.want), "INFO") + self.msg = "Collecting the reserve pool details from the playbook" + self.status = "success" + return self + + def get_want_network(self, network_management_details): + """ + Get all the Network related information from playbook + Set the status and the msg before returning from the API + Check the return value of the API with check_return_status() + + Parameters: + network_management_details (dict) - Playbook network + details containing various network settings. + + Returns: + self - The current object with updated desired Network-related information. + """ + + want_network = { + "settings": { + "dhcpServer": {}, + "dnsServer": {}, + "snmpServer": {}, + "syslogServer": {}, + "netflowcollector": {}, + "ntpServer": {}, + "timezone": "", + "messageOfTheday": {}, + "network_aaa": {}, + "clientAndEndpoint_aaa": {} + } + } + want_network_settings = want_network.get("settings") + self.log("Current state (have): {0}".format(self.have), "DEBUG") + if network_management_details.get("dhcp_server") is not None: + want_network_settings.update({ + "dhcpServer": network_management_details.get("dhcp_server") + }) + else: + del want_network_settings["dhcpServer"] + + if network_management_details.get("ntp_server") is not None: + want_network_settings.update({ + "ntpServer": network_management_details.get("ntp_server") + }) + else: + del want_network_settings["ntpServer"] + + if network_management_details.get("timezone") is not None: + want_network_settings["timezone"] = \ + network_management_details.get("timezone") + else: + self.msg = "missing parameter timezone in network" + self.status = "failed" + return self + + dnsServer = network_management_details.get("dns_server") + if dnsServer is not None: + if dnsServer.get("domain_name") is not None: + want_network_settings.get("dnsServer").update({ + "domainName": + dnsServer.get("domain_name") + }) + + if dnsServer.get("primary_ip_address") is not None: + want_network_settings.get("dnsServer").update({ + "primaryIpAddress": + dnsServer.get("primary_ip_address") + }) + + if dnsServer.get("secondary_ip_address") is not None: + want_network_settings.get("dnsServer").update({ + "secondaryIpAddress": + dnsServer.get("secondary_ip_address") + }) + else: + del want_network_settings["dnsServer"] + + snmpServer = network_management_details.get("snmp_server") + if snmpServer is not None: + if snmpServer.get("configure_dnac_ip") is not None: + want_network_settings.get("snmpServer").update({ + "configureDnacIP": snmpServer.get("configure_dnac_ip") + }) + if snmpServer.get("ip_addresses") is not None: + want_network_settings.get("snmpServer").update({ + "ipAddresses": snmpServer.get("ip_addresses") + }) + else: + del want_network_settings["snmpServer"] + + 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({ + "configureDnacIP": syslogServer.get("configure_dnac_ip") + }) + if syslogServer.get("ip_addresses") is not None: + want_network_settings.get("syslogServer").update({ + "ipAddresses": syslogServer.get("ip_addresses") + }) + else: + del want_network_settings["syslogServer"] + + netflowcollector = network_management_details.get("netflow_collector") + if netflowcollector is not None: + if netflowcollector.get("ip_address") is not None: + want_network_settings.get("netflowcollector").update({ + "ipAddress": + netflowcollector.get("ip_address") + }) + if netflowcollector.get("port") is not None: + want_network_settings.get("netflowcollector").update({ + "port": + netflowcollector.get("port") + }) + else: + del want_network_settings["netflowcollector"] + + messageOfTheday = network_management_details.get("message_of_the_day") + if messageOfTheday is not None: + if messageOfTheday.get("banner_message") is not None: + want_network_settings.get("messageOfTheday").update({ + "bannerMessage": + messageOfTheday.get("banner_message") + }) + if messageOfTheday.get("retain_existing_banner") is not None: + want_network_settings.get("messageOfTheday").update({ + "retainExistingBanner": + messageOfTheday.get("retain_existing_banner") + }) + else: + del want_network_settings["messageOfTheday"] + + network_aaa = network_management_details.get("network_aaa") + if network_aaa: + if network_aaa.get("ip_address"): + want_network_settings.get("network_aaa").update({ + "ipAddress": + network_aaa.get("ip_address") + }) + else: + if network_aaa.get("servers") == "ISE": + self.msg = "missing parameter ip_address in network_aaa, server ISE is set" + self.status = "failed" + return self + + if network_aaa.get("network"): + want_network_settings.get("network_aaa").update({ + "network": network_aaa.get("network") + }) + else: + self.msg = "missing parameter network in network_aaa" + self.status = "failed" + return self + + if network_aaa.get("protocol"): + want_network_settings.get("network_aaa").update({ + "protocol": + network_aaa.get("protocol") + }) + else: + self.msg = "missing parameter protocol in network_aaa" + self.status = "failed" + return self + + if network_aaa.get("servers"): + want_network_settings.get("network_aaa").update({ + "servers": + network_aaa.get("servers") + }) + else: + self.msg = "missing parameter servers in network_aaa" + self.status = "failed" + return self + + if network_aaa.get("shared_secret"): + want_network_settings.get("network_aaa").update({ + "sharedSecret": + network_aaa.get("shared_secret") + }) + else: + del want_network_settings["network_aaa"] + + clientAndEndpoint_aaa = network_management_details.get("client_and_endpoint_aaa") + if clientAndEndpoint_aaa: + if clientAndEndpoint_aaa.get("ip_address"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "ipAddress": + clientAndEndpoint_aaa.get("ip_address") + }) + else: + if clientAndEndpoint_aaa.get("servers") == "ISE": + self.msg = "missing parameter ip_address in clientAndEndpoint_aaa, \ + server ISE is set" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("network"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "network": + clientAndEndpoint_aaa.get("network") + }) + else: + self.msg = "missing parameter network in clientAndEndpoint_aaa" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("protocol"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "protocol": + clientAndEndpoint_aaa.get("protocol") + }) + else: + self.msg = "missing parameter protocol in clientAndEndpoint_aaa" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("servers"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "servers": + clientAndEndpoint_aaa.get("servers") + }) + else: + self.msg = "missing parameter servers in clientAndEndpoint_aaa" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("shared_secret"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "sharedSecret": + clientAndEndpoint_aaa.get("shared_secret") + }) + else: + del want_network_settings["clientAndEndpoint_aaa"] + + self.log("Network playbook details: {0}".format(want_network), "DEBUG") + self.want.update({"wantNetwork": want_network}) + self.msg = "Collecting the network details from the playbook" + self.status = "success" + return self + + def get_want(self, config): + """ + Get all the Global Pool Reserved Pool and Network related information from playbook + + Parameters: + config (list of dict) - Playbook details + + Returns: + None + """ + + if config.get("global_pool_details"): + global_ippool = config.get("global_pool_details").get("settings").get("ip_pool")[0] + self.get_want_global_pool(global_ippool).check_return_status() + + if config.get("reserve_pool_details"): + reserve_pool = config.get("reserve_pool_details") + self.get_want_reserve_pool(reserve_pool).check_return_status() + + if config.get("network_management_details"): + network_management_details = config.get("network_management_details") \ + .get("settings") + self.get_want_network(network_management_details).check_return_status() + + self.log("Desired State (want): {0}".format(self.want), "INFO") + self.msg = "Successfully retrieved details from the playbook" + self.status = "success" + return self + + def update_global_pool(self, config): + """ + Update/Create Global Pool in Cisco DNA Center with fields provided in playbook + + Parameters: + config (list of dict) - Playbook details + + Returns: + None + """ + + name = config.get("global_pool_details") \ + .get("settings").get("ip_pool")[0].get("name") + result_global_pool = self.result.get("response")[0].get("globalPool") + result_global_pool.get("response").update({name: {}}) + + # Check pool exist, if not create and return + if not self.have.get("globalPool").get("exists"): + pool_params = self.want.get("wantGlobal") + self.log("Desired State for global pool (want): {0}".format(pool_params), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="create_global_pool", + params=pool_params, + ) + self.check_execution_response_status(response).check_return_status() + self.log("Successfully created global pool '{0}'.".format(name), "INFO") + result_global_pool.get("response").get(name) \ + .update({"globalPool Details": self.want.get("wantGlobal")}) + result_global_pool.get("msg").update({name: "Global Pool Created Successfully"}) + return + + # Pool exists, check update is required + if not self.requires_update(self.have.get("globalPool").get("details"), + self.want.get("wantGlobal"), self.global_pool_obj_params): + self.log("Global pool '{0}' doesn't require an update".format(name), "INFO") + result_global_pool.get("response").get(name).update({ + "Cisco DNA Center params": + self.have.get("globalPool").get("details").get("settings").get("ippool")[0] + }) + result_global_pool.get("response").get(name).update({ + "Id": self.have.get("globalPool").get("id") + }) + result_global_pool.get("msg").update({ + name: "Global pool doesn't require an update" + }) + return + + self.log("Global pool requires update", "DEBUG") + # Pool Exists + pool_params = copy.deepcopy(self.want.get("wantGlobal")) + pool_params_ippool = pool_params.get("settings").get("ippool")[0] + pool_params_ippool.update({"id": self.have.get("globalPool").get("id")}) + self.log("Desired State for global pool (want): {0}".format(pool_params), "DEBUG") + keys_to_remove = ["IpAddressSpace", "ipPoolCidr", "type"] + for key in keys_to_remove: + del pool_params["settings"]["ippool"][0][key] + + have_ippool = self.have.get("globalPool").get("details").get("settings").get("ippool")[0] + keys_to_update = ["dhcpServerIps", "dnsServerIps", "gateway"] + for key in keys_to_update: + if pool_params_ippool.get(key) is None: + pool_params_ippool[key] = have_ippool.get(key) + + self.log("Desired global pool details (want): {0}".format(pool_params), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="update_global_pool", + params=pool_params, + ) + + self.check_execution_response_status(response).check_return_status() + self.log("Global pool '{0}' updated successfully".format(name), "INFO") + result_global_pool.get("response").get(name) \ + .update({"Id": self.have.get("globalPool").get("details").get("id")}) + result_global_pool.get("msg").update({name: "Global Pool Updated Successfully"}) + return + + def update_reserve_pool(self, config): + """ + Update or Create a Reserve Pool in Cisco DNA Center based on the provided configuration. + This method checks if a reserve pool with the specified name exists in Cisco DNA Center. + If it exists and requires an update, it updates the pool. If not, it creates a new pool. + + Parameters: + config (list of dict) - Playbook details containing Reserve Pool information. + + Returns: + None + """ + + name = config.get("reserve_pool_details").get("name") + result_reserve_pool = self.result.get("response")[1].get("reservePool") + result_reserve_pool.get("response").update({name: {}}) + self.log("Current reserved pool details in Catalyst Center: {0}" + .format(self.have.get("reservePool").get("details")), "DEBUG") + self.log("Desired reserved pool details in Catalyst Center: {0}" + .format(self.want.get("wantReserve")), "DEBUG") + + # Check pool exist, if not create and return + self.log("IPv4 global pool: {0}" + .format(self.want.get("wantReserve").get("ipv4GlobalPool")), "DEBUG") + site_name = config.get("reserve_pool_details").get("site_name") + reserve_params = self.want.get("wantReserve") + site_id = self.get_site_id(site_name) + reserve_params.update({"site_id": site_id}) + if not self.have.get("reservePool").get("exists"): + self.log("Desired reserved pool details (want): {0}".format(reserve_params), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="reserve_ip_subpool", + params=reserve_params, + ) + self.check_execution_response_status(response).check_return_status() + self.log("Successfully created IP subpool reservation '{0}'.".format(name), "INFO") + result_reserve_pool.get("response").get(name) \ + .update({"reservePool Details": self.want.get("wantReserve")}) + result_reserve_pool.get("msg") \ + .update({name: "Ip Subpool Reservation Created Successfully"}) + return + + # Check update is required + if not self.requires_update(self.have.get("reservePool").get("details"), + self.want.get("wantReserve"), self.reserve_pool_obj_params): + self.log("Reserved ip subpool '{0}' doesn't require an update".format(name), "INFO") + result_reserve_pool.get("response").get(name) \ + .update({"Cisco DNA Center params": self.have.get("reservePool").get("details")}) + result_reserve_pool.get("response").get(name) \ + .update({"Id": self.have.get("reservePool").get("id")}) + result_reserve_pool.get("msg") \ + .update({name: "Reserve ip subpool doesn't require an update"}) + return + + self.log("Reserved ip pool '{0}' requires an update".format(name), "DEBUG") + # Pool Exists + self.log("Current reserved ip pool '{0}' details in Catalyst Center: {1}" + .format(name, self.have.get("reservePool")), "DEBUG") + self.log("Desired reserved ip pool '{0}' details: {1}" + .format(name, self.want.get("wantReserve")), "DEBUG") + reserve_params.update({"id": self.have.get("reservePool").get("id")}) + response = self.dnac._exec( + family="network_settings", + function="update_reserve_ip_subpool", + params=reserve_params, + ) + self.check_execution_response_status(response).check_return_status() + self.log("Reserved ip subpool '{0}' updated successfully.".format(name), "INFO") + result_reserve_pool['msg'] = "Reserved Ip Subpool Updated Successfully" + result_reserve_pool.get("response").get(name) \ + .update({"Reservation details": self.have.get("reservePool").get("details")}) + return + + def update_network(self, config): + """ + Update or create a network configuration in Cisco DNA + Center based on the provided playbook details. + + Parameters: + config (list of dict) - Playbook details containing Network Management information. + + Returns: + None + """ + + 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: {}}) + + # Check update is required or not + if not self.requires_update(self.have.get("network").get("net_details"), + self.want.get("wantNetwork"), self.network_obj_params): + + self.log("Network in site '{0}' doesn't require an update.".format(site_name), "INFO") + result_network.get("response").get(site_name).update({ + "Cisco DNA Center params": self.have.get("network") + .get("net_details").get("settings") + }) + result_network.get("msg").update({site_name: "Network doesn't require an update"}) + return + + self.log("Network in site '{0}' requires update.".format(site_name), "INFO") + self.log("Current State of network in Catalyst Center: {0}" + .format(self.have.get("network")), "DEBUG") + self.log("Desired State of network: {0}".format(self.want.get("wantNetwork")), "DEBUG") + + net_params = copy.deepcopy(self.want.get("wantNetwork")) + net_params.update({"site_id": self.have.get("network").get("site_id")}) + response = self.dnac._exec( + family="network_settings", + function='update_network_v2', + params=net_params, + ) + self.log("Received API response of 'update_network_v2': {0}".format(response), "DEBUG") + validation_string = "desired common settings operation successful" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Network has been changed successfully", "INFO") + result_network.get("msg") \ + .update({site_name: "Network Updated successfully"}) + result_network.get("response").get(site_name) \ + .update({"Network Details": self.want.get("wantNetwork").get("settings")}) + return + + def get_diff_merged(self, config): + """ + Update or create Global Pool, Reserve Pool, and + Network configurations in Cisco DNA Center based on the playbook details + + Parameters: + config (list of dict) - Playbook details containing + Global Pool, Reserve Pool, and Network Management information. + + Returns: + self + """ + + if config.get("global_pool_details") is not None: + self.update_global_pool(config) + + if config.get("reserve_pool_details") is not None: + self.update_reserve_pool(config) + + if config.get("network_management_details") is not None: + self.update_network(config) + + return self + + def delete_reserve_pool(self, name): + """ + Delete a Reserve Pool by name in Cisco DNA Center + + Parameters: + name (str) - The name of the Reserve Pool to be deleted. + + Returns: + self + """ + + reserve_pool_exists = self.have.get("reservePool").get("exists") + result_reserve_pool = self.result.get("response")[1].get("reservePool") + + if not reserve_pool_exists: + result_reserve_pool.get("response").update({name: "Reserve Pool not found"}) + self.msg = "Reserved Ip Subpool Not Found" + self.status = "success" + return self + + self.log("Reserved IP pool scheduled for deletion: {0}" + .format(self.have.get("reservePool").get("name")), "INFO") + _id = self.have.get("reservePool").get("id") + self.log("Reserved pool {0} id: {1}".format(name, _id), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="release_reserve_ip_subpool", + params={"id": _id}, + ) + self.check_execution_response_status(response).check_return_status() + executionid = response.get("executionId") + result_reserve_pool = self.result.get("response")[1].get("reservePool") + result_reserve_pool.get("response").update({name: {}}) + result_reserve_pool.get("response").get(name) \ + .update({"Execution Id": executionid}) + result_reserve_pool.get("msg") \ + .update({name: "Ip subpool reservation released successfully"}) + self.msg = "Reserved pool - {0} released successfully".format(name) + self.status = "success" + return self + + def delete_global_pool(self, name): + """ + Delete a Global Pool by name in Cisco DNA Center + + Parameters: + name (str) - The name of the Global Pool to be deleted. + + Returns: + self + """ + + global_pool_exists = self.have.get("globalPool").get("exists") + result_global_pool = self.result.get("response")[0].get("globalPool") + if not global_pool_exists: + result_global_pool.get("response").update({name: "Global Pool not found"}) + self.msg = "Global pool Not Found" + self.status = "success" + return self + + response = self.dnac._exec( + family="network_settings", + function="delete_global_ip_pool", + params={"id": self.have.get("globalPool").get("id")}, + ) + + # Check the execution status + self.check_execution_response_status(response).check_return_status() + executionid = response.get("executionId") + + # Update result information + result_global_pool = self.result.get("response")[0].get("globalPool") + result_global_pool.get("response").update({name: {}}) + result_global_pool.get("response").get(name).update({"Execution Id": executionid}) + result_global_pool.get("msg").update({name: "Pool deleted successfully"}) + self.msg = "Global pool - {0} deleted successfully".format(name) + self.status = "success" + return self + + def get_diff_deleted(self, config): + """ + Delete Reserve Pool and Global Pool in Cisco DNA Center based on playbook details. + + Parameters: + config (list of dict) - Playbook details + + Returns: + self + """ + + if config.get("reserve_pool_details") is not None: + name = config.get("reserve_pool_details").get("name") + self.delete_reserve_pool(name).check_return_status() + + if config.get("global_pool_details") is not None: + name = config.get("global_pool_details") \ + .get("settings").get("ip_pool")[0].get("name") + self.delete_global_pool(name).check_return_status() + + 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Requested State (want): {0}".format(self.want), "INFO") + if config.get("global_pool_details") is not None: + self.log("Desired State of global pool (want): {0}" + .format(self.want.get("wantGlobal")), "DEBUG") + self.log("Current State of global pool (have): {0}" + .format(self.have.get("globalPool").get("details")), "DEBUG") + 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 '{0}'.".format(self.want + .get("wantGlobal").get("settings").get("ippool")[0].get("ipPoolName")), "INFO") + 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("Desired State for reserve pool (want): {0}" + .format(self.want.get("wantReserve")), "DEBUG") + self.log("Current State for reserve pool (have): {0}" + .format(self.have.get("reservePool").get("details")), "DEBUG") + self.msg = "Reserved Pool Config is not applied to the DNAC" + self.status = "failed" + return self + + self.log("Successfully validated the reserved pool '{0}'." + .format(self.want.get("wantReserve").get("name")), "INFO") + 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 '{0}'." + .format(config.get("network_management_details").get("site_name")), "INFO") + 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + 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 '{0}'." + .format(config.get("global_pool_details") + .get("settings").get("ip_pool")[0].get("name")), "INFO") + 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 = "Reserved Pool Config is not applied to the Catalyst Center" + self.status = "failed" + return self + + self.log("Successfully validated the absence of Reserve Pool '{0}'." + .format(config.get("reserve_pool_details").get("name")), "INFO") + 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 + + Parameters: + None + + Returns: + None + """ + + self.have.clear() + self.want.clear() + return + + +def main(): + """main entry point for module execution""" + + # Define the specification for module arguments + element_spec = { + "dnac_host": {"type": 'str', "required": True}, + "dnac_port": {"type": 'str', "default": '443'}, + "dnac_username": {"type": 'str', "default": 'admin', "aliases": ['user']}, + "dnac_password": {"type": 'str', "no_log": True}, + "dnac_verify": {"type": 'bool', "default": 'True'}, + "dnac_version": {"type": 'str', "default": '2.2.3.3'}, + "dnac_debug": {"type": 'bool', "default": False}, + "dnac_log": {"type": 'bool', "default": False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + "config_verify": {"type": 'bool', "default": False}, + "dnac_api_task_timeout": {"type": 'int', "default": 1200}, + "dnac_task_poll_interval": {"type": 'int', "default": 2}, + "config": {"type": 'list', "required": True, "elements": 'dict'}, + "state": {"default": 'merged', "choices": ['merged', 'deleted']}, + "validate_response_schema": {"type": 'bool', "default": True}, + } + + # Create an AnsibleModule object with argument specifications + module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) + dnac_network = NetworkSettings(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) + dnac_network.check_return_status() + + dnac_network.validate_input().check_return_status() + + for config in dnac_network.config: + dnac_network.reset_values() + dnac_network.get_have(config).check_return_status() + 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) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/network_settings_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/network_settings_workflow_manager.py new file mode 100644 index 000000000..bbae36463 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/network_settings_workflow_manager.py @@ -0,0 +1,2210 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module to perform operations on global pool, reserve pool and network in Cisco Catalyst Center.""" +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ['Muthu Rakesh, Madhan Sankaranarayanan'] + +DOCUMENTATION = r""" +--- +module: network_settings_workflow_manager +short_description: Resource module for IP Address pools and network functions +description: +- Manage operations on Global Pool, Reserve Pool, Network resources. +- API to create/update/delete global pool. +- API to reserve/update/delete an ip subpool from the global pool. +- API to update network settings for DHCP, Syslog, SNMP, NTP, Network AAA, Client and Endpoint AAA, + and/or DNS center server settings. +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Muthu Rakesh (@MUTHU-RAKESH-27) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of global pool, reserved pool, network being managed. + type: list + elements: dict + required: true + suboptions: + global_pool_details: + description: Manages IPv4 and IPv6 IP pools in the global level. + type: dict + suboptions: + settings: + description: Global Pool's settings. + type: dict + suboptions: + ip_pool: + description: Contains a list of global IP pool configurations. + elements: dict + type: list + suboptions: + dhcp_server_ips: + description: > + The DHCP server IPs responsible for automatically assigning IP addresses + and network configuration parameters to devices on a local network. + elements: str + type: list + dns_server_ips: + description: Responsible for translating domain names into corresponding IP addresses. + elements: str + type: list + gateway: + description: Serves as an entry or exit point for data traffic between networks. + type: str + ip_address_space: + description: IP address space either IPv4 or IPv6. + type: str + cidr: + description: > + Defines the IP pool's Classless Inter-Domain Routing block, + enabling systematic IP address distribution within a network. + type: str + prev_name: + description: > + The former identifier for the global pool. It should be used + exclusively when you need to update the global pool's name. + type: str + name: + description: Specifies the name assigned to the Global IP Pool. + type: str + pool_type: + description: > + Includes both the Generic Ip Pool and Tunnel Ip Pool. + Generic - Used for general purpose within the network such as device + management or communication between the network devices. + Tunnel - Designated for the tunnel interfaces to encapsulate packets + within the network protocol. It is used in VPN connections, + GRE tunnels, or other types of overlay networks. + default: Generic + choices: [Generic, Tunnel] + type: str + + reserve_pool_details: + description: Reserved IP subpool details from the global pool. + type: dict + suboptions: + ipv4_dhcp_servers: + description: Specifies the IPv4 addresses for DHCP servers, for example, "1.1.1.1". + elements: str + type: list + ipv4_dns_servers: + description: Specifies the IPv4 addresses for DNS servers, for example, "4.4.4.4". + elements: str + type: list + ipv4_gateway: + description: Provides the gateway's IPv4 address, for example, "175.175.0.1". + type: str + version_added: 4.0.0 + ipv4_global_pool: + description: IP v4 Global pool address with cidr, example 175.175.0.0/16. + type: str + ipv4_prefix: + description: ip4 prefix length is enabled or ipv4 total Host input is enabled + type: bool + ipv4_prefix_length: + description: The ipv4 prefix length is required when ipv4_prefix value is true. + type: int + ipv4_subnet: + description: Indicates the IPv4 subnet address, for example, "175.175.0.0". + type: str + ipv4_total_host: + description: The total number of hosts for IPv4, required when the 'ipv4_prefix' is set to false. + type: int + ipv6_address_space: + description: > + Determines whether both IPv6 and IPv4 inputs are required. + If set to false, only IPv4 inputs are required. + If set to true, both IPv6 and IPv4 inputs are required. + type: bool + ipv6_dhcp_servers: + description: > + Specifies the IPv6 addresses for DHCP servers in the format. + For example, "2001:0db8:0123:4567:89ab:cdef:0001:0001". + elements: str + type: list + ipv6_dns_servers: + description: > + Specifies the IPv6 addresses for DNS servers. + For example, "2001:0db8:0123:4567:89ab:cdef:0002:0002". + elements: str + type: list + ipv6_gateway: + description: > + Provides the gateway's IPv6 address. + For example, "2001:0db8:0123:4567:89ab:cdef:0003:0003". + type: str + ipv6_global_pool: + description: > + IPv6 Global pool address with cidr this is required when ipv6_address_space + value is true, example 2001 db8 85a3 /64. + type: str + ipv6_prefix: + description: > + Ipv6 prefix value is true, the ip6 prefix length input field is enabled, + if it is false ipv6 total Host input is enable. + type: bool + ipv6_prefix_length: + description: IPv6 prefix length is required when the ipv6_prefix value is true. + type: int + ipv6_subnet: + description: IPv6 Subnet address, example 2001 db8 85a3 0 100. + type: str + ipv6_total_host: + description: The total number of hosts for IPv6 is required if the 'ipv6_prefix' is set to false. + type: int + name: + description: Name of the reserve IP subpool. + type: str + prev_name: + description: The former name associated with the reserved IP sub-pool. + type: str + site_name: + description: > + The name of the site provided as a path parameter, used + to specify where the IP sub-pool will be reserved. + type: str + slaac_support: + description: > + Allows devices on IPv6 networks to self-configure their + IP addresses autonomously, eliminating the need for manual setup. + type: bool + pool_type: + description: Type of the reserve ip sub pool. + Generic - Used for general purpose within the network such as device + management or communication between the network devices. + LAN - Used for the devices and the resources within the Local Area Network + such as device connectivity, internal communication, or services. + Management - Used for the management purposes such as device management interfaces, + management access, or other administrative functions. + Service - Used for the network services and application such as DNS (Domain Name System), + DHCP (Dynamic Host Configuration Protocol), NTP (Network Time Protocol). + WAN - Used for the devices and resources with the Wide Area Network such as remote + sites interconnection with other network or services hosted within WAN. + default: Generic + choices: [Generic, LAN, Management, Service, WAN] + type: str + network_management_details: + description: Set default network settings for the site + type: dict + suboptions: + settings: + description: Network management details settings. + type: dict + suboptions: + client_and_endpoint_aaa: + description: Network V2's clientAndEndpoint_aaa. + suboptions: + ip_address: + description: IP address for ISE serve (eg 1.1.1.4). + type: str + network: + description: IP address for AAA or ISE server (eg 2.2.2.1). + type: str + protocol: + description: Protocol for AAA or ISE serve (eg RADIUS). + type: str + servers: + description: Server type AAA or ISE server (eg AAA). + type: str + shared_secret: + description: Shared secret for ISE server. + type: str + type: dict + dhcp_server: + description: DHCP Server IP (eg 1.1.1.1). + elements: str + type: list + dns_server: + description: Network V2's dnsServer. + suboptions: + domain_name: + description: Domain Name of DHCP (eg; cisco). + type: str + primary_ip_address: + description: Primary IP Address for DHCP (eg 2.2.2.2). + type: str + secondary_ip_address: + description: Secondary IP Address for DHCP (eg 3.3.3.3). + type: str + type: dict + message_of_the_day: + description: Network V2's messageOfTheday. + suboptions: + banner_message: + description: Massage for Banner message (eg; Good day). + type: str + retain_existing_banner: + description: Retain existing Banner Message (eg "true" or "false"). + type: str + type: dict + netflow_collector: + description: Network V2's netflowcollector. + suboptions: + ip_address: + description: IP Address for NetFlow collector (eg 3.3.3.1). + type: str + port: + description: Port for NetFlow Collector (eg; 443). + type: int + type: dict + network_aaa: + description: Network V2's network_aaa. + suboptions: + ip_address: + description: IP address for AAA and ISE server (eg 1.1.1.1). + type: str + network: + description: IP Address for AAA or ISE server (eg 2.2.2.2). + type: str + protocol: + description: Protocol for AAA or ISE serve (eg RADIUS). + type: str + servers: + description: Server type for AAA Network (eg AAA). + type: str + shared_secret: + description: Shared secret for ISE Server. + type: str + type: dict + ntp_server: + description: IP address for NTP server (eg 1.1.1.2). + elements: str + type: list + snmp_server: + description: Network V2's snmpServer. + suboptions: + configure_dnac_ip: + description: Configuration Cisco Catalyst Center IP for SNMP Server (eg true). + type: bool + ip_addresses: + description: IP Address for SNMP Server (eg 4.4.4.1). + elements: str + type: list + type: dict + syslog_server: + description: Network V2's syslogServer. + suboptions: + configure_dnac_ip: + description: Configuration Cisco Catalyst Center IP for syslog server (eg true). + type: bool + ip_addresses: + description: IP Address for syslog server (eg 4.4.4.4). + elements: str + type: list + type: dict + timezone: + description: Input for time zone (eg Africa/Abidjan). + type: str + site_name: + description: > + The name of the site provided as a path parameter, used + to specify where the IP sub-pool will be reserved. + type: str +requirements: +- dnacentersdk == 2.4.5 +- python >= 3.5 +notes: + - SDK Method used are + network_settings.NetworkSettings.create_global_pool, + network_settings.NetworkSettings.delete_global_ip_pool, + network_settings.NetworkSettings.update_global_pool, + network_settings.NetworkSettings.release_reserve_ip_subpool, + network_settings.NetworkSettings.reserve_ip_subpool, + network_settings.NetworkSettings.update_reserve_ip_subpool, + network_settings.NetworkSettings.update_network_v2, + + - Paths used are + post /dna/intent/api/v1/global-pool, + delete /dna/intent/api/v1/global-pool/{id}, + put /dna/intent/api/v1/global-pool, + post /dna/intent/api/v1/reserve-ip-subpool/{siteId}, + delete /dna/intent/api/v1/reserve-ip-subpool/{id}, + put /dna/intent/api/v1/reserve-ip-subpool/{siteId}, + put /dna/intent/api/v2/network/{siteId}, + +""" + +EXAMPLES = r""" +- name: Create global pool, reserve an ip pool and network + cisco.dnac.network_settings_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{ dnac_log_level }}" + state: merged + config_verify: True + config: + - global_pool_details: + settings: + ip_pool: + - name: string + gateway: string + ip_address_space: string + cidr: string + pool_type: Generic + dhcp_server_ips: list + dns_server_ips: list + reserve_pool_details: + ipv6_address_space: True + ipv4_global_pool: string + ipv4_prefix: True + ipv4_prefix_length: 9 + ipv4_subnet: string + name: string + ipv6_prefix: True + ipv6_prefix_length: 64 + ipv6_global_pool: string + ipv6_subnet: string + site_name: string + slaac_support: True + pool_type: LAN + network_management_details: + settings: + dhcp_server: list + dns_server: + domain_name: string + primary_ip_address: string + secondary_ip_address: string + client_and_endpoint_aaa: + network: string + protocol: string + servers: string + message_of_the_day: + banner_message: string + retain_existing_banner: string + netflow_collector: + ip_address: string + port: 443 + network_aaa: + network: string + protocol: string + servers: string + ntp_server: list + snmp_server: + configure_dnac_ip: True + ip_addresses: list + syslog_server: + configure_dnac_ip: True + ip_addresses: list + site_name: string +""" + +RETURN = r""" +# Case_1: Successful creation/updation/deletion of global pool +response_1: + description: A dictionary or list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "executionId": "string", + "executionStatusUrl": "string", + "message": "string" + } + +# Case_2: Successful creation/updation/deletion of reserve pool +response_2: + description: A dictionary or list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "executionId": "string", + "executionStatusUrl": "string", + "message": "string" + } + +# Case_3: Successful creation/updation of network +response_3: + description: A dictionary or list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "executionId": "string", + "executionStatusUrl": "string", + "message": "string" + } +""" + +import copy +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result, + dnac_compare_equality, +) + + +class NetworkSettings(DnacBase): + """Class containing member attributes for network_settings_workflow_manager module""" + + def __init__(self, module): + super().__init__(module) + self.result["response"] = [ + {"globalPool": {"response": {}, "msg": {}}}, + {"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): + """ + Checks if the configuration parameters provided in the playbook + meet the expected structure and data types, + as defined in the 'temp_spec' dictionary. + + Parameters: + None + + Returns: + self + + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + # temp_spec is the specification for the expected structure of configuration parameters + temp_spec = { + "global_pool_details": { + "type": 'dict', + "settings": { + "type": 'dict', + "ip_pool": { + "type": 'list', + "ip_address_space": {"type": 'string'}, + "dhcp_server_ips": {"type": 'list'}, + "dns_server_ips": {"type": 'list'}, + "gateway": {"type": 'string'}, + "cidr": {"type": 'string'}, + "name": {"type": 'string'}, + "prev_name": {"type": 'string'}, + "pool_type": {"type": 'string', "choices": ["Generic", "Tunnel"]}, + } + } + }, + "reserve_pool_details": { + "type": 'dict', + "name": {"type": 'string'}, + "prev_name": {"type": 'string'}, + "ipv6_address_space": {"type": 'bool'}, + "ipv4_global_pool": {"type": 'string'}, + "ipv4_prefix": {"type": 'bool'}, + "ipv4_prefix_length": {"type": 'string'}, + "ipv4_subnet": {"type": 'string'}, + "ipv4_gateway": {"type": 'string'}, + "ipv4_dhcp_servers": {"type": 'list'}, + "ipv4_dns_servers": {"type": 'list'}, + "ipv6_global_pool": {"type": 'string'}, + "ipv6_prefix": {"type": 'bool'}, + "ipv6_prefix_length": {"type": 'integer'}, + "ipv6_subnet": {"type": 'string'}, + "ipv6_gateway": {"type": 'string'}, + "ipv6_dhcp_servers": {"type": 'list'}, + "ipv6_dns_servers": {"type": 'list'}, + "ipv4_total_host": {"type": 'integer'}, + "ipv6_total_host": {"type": 'integer'}, + "slaac_support": {"type": 'bool'}, + "site_name": {"type": 'string'}, + "pool_type": { + "type": 'string', + "choices": ["Generic", "LAN", "Management", "Service", "WAN"] + }, + }, + "network_management_details": { + "type": 'dict', + "settings": { + "type": 'dict', + "dhcp_server": {"type": 'list'}, + "dns_server": { + "type": 'dict', + "domain_name": {"type": 'string'}, + "primary_ip_address": {"type": 'string'}, + "secondary_ip_address": {"type": 'string'} + }, + "syslog_server": { + "type": 'dict', + "ip_addresses": {"type": 'list'}, + "configure_dnac_ip": {"type": 'bool'} + }, + "snmp_server": { + "type": 'dict', + "ip_addresses": {"type": 'list'}, + "configure_dnac_ip": {"type": 'bool'} + }, + "netflow_collector": { + "type": 'dict', + "ip_address": {"type": 'string'}, + "port": {"type": 'integer'}, + }, + "timezone": {"type": 'string'}, + "ntp_server": {"type": 'list'}, + "message_of_the_day": { + "type": 'dict', + "banner_message": {"type": 'string'}, + "retain_existing_banner": {"type": 'bool'}, + }, + "network_aaa": { + "type": 'dict', + "servers": {"type": 'string', "choices": ["ISE", "AAA"]}, + "ip_address": {"type": 'string'}, + "network": {"type": 'string'}, + "protocol": {"type": 'string', "choices": ["RADIUS", "TACACS"]}, + "shared_secret": {"type": 'string'} + + }, + "client_and_endpoint_aaa": { + "type": 'dict', + "servers": {"type": 'string', "choices": ["ISE", "AAA"]}, + "ip_address": {"type": 'string'}, + "network": {"type": 'string'}, + "protocol": {"type": 'string', "choices": ["RADIUS", "TACACS"]}, + "shared_secret": {"type": 'string'} + } + }, + "site_name": {"type": 'string'}, + } + } + + # Validate playbook params against the specification (temp_spec) + 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)) + self.status = "failed" + return self + + self.validated_config = valid_temp + self.log("Successfully validated playbook config params: {0}".format(valid_temp), "INFO") + self.msg = "Successfully validated input from the playbook" + self.status = "success" + return self + + def requires_update(self, have, want, obj_params): + """ + Check if the template config given requires update by comparing + current information wih the requested information. + + This method compares the current global pool, reserve pool, + or network details from Cisco Catalyst Center with the user-provided details + from the playbook, using a specified schema for comparison. + + Parameters: + have (dict) - Current information from the Cisco Catalyst Center + (global pool, reserve pool, network details) + want (dict) - Users provided information from the playbook + obj_params (list of tuples) - A list of parameter mappings specifying which + Cisco Catalyst Center parameters (dnac_param) correspond to + the user-provided parameters (ansible_param). + + Returns: + bool - True if any parameter specified in obj_params differs between + current_obj and requested_obj, indicating that an update is required. + False if all specified parameters are equal. + + """ + + current_obj = have + requested_obj = want + self.log("Current State (have): {0}".format(current_obj), "DEBUG") + self.log("Desired State (want): {0}".format(requested_obj), "DEBUG") + + return any(not dnac_compare_equality(current_obj.get(dnac_param), + 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("Received an unexpected value for 'get_object': {0}" + .format(get_object)) + except Exception as msg: + self.log("Received exception: {0}".format(msg), "CRITICAL") + + return obj_params + + def get_site_id(self, site_name): + """ + Get the site id from the site name. + Use check_return_status() to check for failure + + Parameters: + site_name (str) - Site name + + Returns: + str or None - The Site Id if found, or None if not found or error + """ + + try: + response = self.dnac._exec( + family="sites", + function='get_site', + params={"name": site_name}, + ) + self.log("Received API response from 'get_site': {0}".format(response), "DEBUG") + if not response: + self.log("Failed to retrieve the site ID for the site name: {0}" + .format(site_name), "ERROR") + return None + + _id = response.get("response")[0].get("id") + self.log("Site ID for site name '{0}': {1}".format(site_name, _id), "DEBUG") + except Exception as msg: + self.log("Exception occurred while retrieving site_id from the site_name: {0}" + .format(msg), "CRITICAL") + return None + + return _id + + def get_global_pool_params(self, pool_info): + """ + Process Global Pool params from playbook data for Global Pool config in Cisco Catalyst Center + + Parameters: + pool_info (dict) - Playbook data containing information about the global pool + + Returns: + dict or None - Processed Global Pool data in a format suitable + for Cisco Catalyst Center configuration, or None if pool_info is empty. + """ + + if not pool_info: + self.log("Global Pool is empty", "INFO") + return None + + self.log("Global Pool Details: {0}".format(pool_info), "DEBUG") + global_pool = { + "settings": { + "ippool": [{ + "dhcpServerIps": pool_info.get("dhcpServerIps"), + "dnsServerIps": pool_info.get("dnsServerIps"), + "ipPoolCidr": pool_info.get("ipPoolCidr"), + "ipPoolName": pool_info.get("ipPoolName"), + "type": pool_info.get("ipPoolType").capitalize() + }] + } + } + self.log("Formated global pool details: {0}".format(global_pool), "DEBUG") + global_ippool = global_pool.get("settings").get("ippool")[0] + if pool_info.get("ipv6") is False: + global_ippool.update({"IpAddressSpace": "IPv4"}) + else: + global_ippool.update({"IpAddressSpace": "IPv6"}) + + self.log("ip_address_space: {0}".format(global_ippool.get("IpAddressSpace")), "DEBUG") + if not pool_info["gateways"]: + global_ippool.update({"gateway": ""}) + else: + global_ippool.update({"gateway": pool_info.get("gateways")[0]}) + + return global_pool + + def get_reserve_pool_params(self, pool_info): + """ + Process Reserved Pool parameters from playbook data + for Reserved Pool configuration in Cisco Catalyst Center + + Parameters: + pool_info (dict) - Playbook data containing information about the reserved pool + + Returns: + reserve_pool (dict) - Processed Reserved pool data + in the format suitable for the Cisco Catalyst Center config + """ + + reserve_pool = { + "name": pool_info.get("groupName"), + "site_id": pool_info.get("siteId"), + } + if len(pool_info.get("ipPools")) == 1: + reserve_pool.update({ + "ipv4DhcpServers": pool_info.get("ipPools")[0].get("dhcpServerIps"), + "ipv4DnsServers": pool_info.get("ipPools")[0].get("dnsServerIps"), + "ipv6AddressSpace": "False" + }) + if pool_info.get("ipPools")[0].get("gateways") != []: + reserve_pool.update({"ipv4GateWay": pool_info.get("ipPools")[0].get("gateways")[0]}) + else: + reserve_pool.update({"ipv4GateWay": ""}) + reserve_pool.update({"ipv6AddressSpace": "False"}) + elif len(pool_info.get("ipPools")) == 2: + if not pool_info.get("ipPools")[0].get("ipv6"): + reserve_pool.update({ + "ipv4DhcpServers": pool_info.get("ipPools")[0].get("dhcpServerIps"), + "ipv4DnsServers": pool_info.get("ipPools")[0].get("dnsServerIps"), + "ipv6AddressSpace": "True", + "ipv6DhcpServers": pool_info.get("ipPools")[1].get("dhcpServerIps"), + "ipv6DnsServers": pool_info.get("ipPools")[1].get("dnsServerIps"), + + }) + + if pool_info.get("ipPools")[0].get("gateways") != []: + reserve_pool.update({"ipv4GateWay": + pool_info.get("ipPools")[0].get("gateways")[0]}) + else: + reserve_pool.update({"ipv4GateWay": ""}) + + if pool_info.get("ipPools")[1].get("gateways") != []: + reserve_pool.update({"ipv6GateWay": + pool_info.get("ipPools")[1].get("gateways")[0]}) + else: + reserve_pool.update({"ipv6GateWay": ""}) + + elif not pool_info.get("ipPools")[1].get("ipv6"): + reserve_pool.update({ + "ipv4DhcpServers": pool_info.get("ipPools")[1].get("dhcpServerIps"), + "ipv4DnsServers": pool_info.get("ipPools")[1].get("dnsServerIps"), + "ipv6AddressSpace": "True", + "ipv6DnsServers": pool_info.get("ipPools")[0].get("dnsServerIps"), + "ipv6DhcpServers": pool_info.get("ipPools")[0].get("dhcpServerIps") + }) + if pool_info.get("ipPools")[1].get("gateways") != []: + reserve_pool.update({"ipv4GateWay": + pool_info.get("ipPools")[1].get("gateways")[0]}) + else: + reserve_pool.update({"ipv4GateWay": ""}) + + if pool_info.get("ipPools")[0].get("gateways") != []: + reserve_pool.update({"ipv6GateWay": + pool_info.get("ipPools")[0].get("gateways")[0]}) + else: + reserve_pool.update({"ipv6GateWay": ""}) + reserve_pool.update({"slaacSupport": True}) + self.log("Formatted reserve pool details: {0}".format(reserve_pool), "DEBUG") + return reserve_pool + + def get_network_params(self, site_id): + """ + Process the Network parameters from the playbook + for Network configuration in Cisco Catalyst Center + + Parameters: + site_id (str) - The Site ID for which network parameters are requested + + Returns: + dict or None: Processed Network data in a format + suitable for Cisco Catalyst Center configuration, or None + if the response is not a dictionary or there was an error. + """ + + response = self.dnac._exec( + family="network_settings", + function='get_network_v2', + params={"site_id": site_id} + ) + self.log("Received API response from 'get_network_v2': {0}".format(response), "DEBUG") + if not isinstance(response, dict): + self.log("Failed to retrieve the network details - " + "Response is not a dictionary", "ERROR") + return None + + # Extract various network-related details from the response + all_network_details = response.get("response") + dhcp_details = get_dict_result(all_network_details, "key", "dhcp.server") + dns_details = get_dict_result(all_network_details, "key", "dns.server") + snmp_details = get_dict_result(all_network_details, "key", "snmp.trap.receiver") + syslog_details = get_dict_result(all_network_details, "key", "syslog.server") + netflow_details = get_dict_result(all_network_details, "key", "netflow.collector") + ntpserver_details = get_dict_result(all_network_details, "key", "ntp.server") + timezone_details = get_dict_result(all_network_details, "key", "timezone.site") + messageoftheday_details = get_dict_result(all_network_details, "key", "device.banner") + network_aaa = get_dict_result(all_network_details, "key", "aaa.network.server.1") + network_aaa2 = get_dict_result(all_network_details, "key", "aaa.network.server.2") + network_aaa_pan = get_dict_result(all_network_details, "key", "aaa.server.pan.network") + clientAndEndpoint_aaa = get_dict_result(all_network_details, "key", "aaa.endpoint.server.1") + clientAndEndpoint_aaa2 = get_dict_result(all_network_details, + "key", + "aaa.endpoint.server.2") + clientAndEndpoint_aaa_pan = \ + get_dict_result(all_network_details, "key", "aaa.server.pan.endpoint") + + # Prepare the network details for Cisco Catalyst Center configuration + network_details = { + "settings": { + "snmpServer": { + "configureDnacIP": snmp_details.get("value")[0].get("configureDnacIP"), + "ipAddresses": snmp_details.get("value")[0].get("ipAddresses"), + }, + "syslogServer": { + "configureDnacIP": syslog_details.get("value")[0].get("configureDnacIP"), + "ipAddresses": syslog_details.get("value")[0].get("ipAddresses"), + }, + "netflowcollector": { + "ipAddress": netflow_details.get("value")[0].get("ipAddress"), + "port": netflow_details.get("value")[0].get("port") + }, + "timezone": timezone_details.get("value")[0], + } + } + network_settings = network_details.get("settings") + if dhcp_details and dhcp_details.get("value") != []: + network_settings.update({"dhcpServer": dhcp_details.get("value")}) + else: + network_settings.update({"dhcpServer": [""]}) + + if dns_details is not None: + network_settings.update({ + "dnsServer": { + "domainName": dns_details.get("value")[0].get("domainName"), + "primaryIpAddress": dns_details.get("value")[0].get("primaryIpAddress"), + "secondaryIpAddress": dns_details.get("value")[0].get("secondaryIpAddress") + } + }) + + if ntpserver_details and ntpserver_details.get("value") != []: + network_settings.update({"ntpServer": ntpserver_details.get("value")}) + else: + network_settings.update({"ntpServer": [""]}) + + if messageoftheday_details is not None: + network_settings.update({ + "messageOfTheday": { + "bannerMessage": messageoftheday_details.get("value")[0].get("bannerMessage"), + } + }) + retain_existing_banner = messageoftheday_details.get("value")[0] \ + .get("retainExistingBanner") + if retain_existing_banner is True: + network_settings.get("messageOfTheday").update({ + "retainExistingBanner": "true" + }) + else: + network_settings.get("messageOfTheday").update({ + "retainExistingBanner": "false" + }) + + if network_aaa and network_aaa_pan: + aaa_pan_value = network_aaa_pan.get("value")[0] + aaa_value = network_aaa.get("value")[0] + if aaa_pan_value == "None": + network_settings.update({ + "network_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": network_aaa2.get("value")[0].get("ipAddress"), + "servers": "AAA" + } + }) + else: + network_settings.update({ + "network_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": aaa_pan_value, + "servers": "ISE" + } + }) + + if clientAndEndpoint_aaa and clientAndEndpoint_aaa_pan: + aaa_pan_value = clientAndEndpoint_aaa_pan.get("value")[0] + aaa_value = clientAndEndpoint_aaa.get("value")[0] + if aaa_pan_value == "None": + network_settings.update({ + "clientAndEndpoint_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": clientAndEndpoint_aaa2.get("value")[0].get("ipAddress"), + "servers": "AAA" + } + }) + else: + network_settings.update({ + "clientAndEndpoint_aaa": { + "network": aaa_value.get("ipAddress"), + "protocol": aaa_value.get("protocol"), + "ipAddress": aaa_pan_value, + "servers": "ISE" + } + }) + + self.log("Formatted playbook network details: {0}".format(network_details), "DEBUG") + return network_details + + def global_pool_exists(self, name): + """ + Check if the Global Pool with the given name exists + + Parameters: + name (str) - The name of the Global Pool to check for existence + + Returns: + dict - A dictionary containing information about the Global Pool's existence: + - 'exists' (bool): True if the Global Pool exists, False otherwise. + - 'id' (str or None): The ID of the Global Pool if it exists, or None if it doesn't. + - 'details' (dict or None): Details of the Global Pool if it exists, else None. + """ + + global_pool = { + "exists": False, + "details": None, + "id": None + } + response = self.dnac._exec( + family="network_settings", + function="get_global_pool", + ) + if not isinstance(response, dict): + self.log("Failed to retrieve the global pool details - " + "Response is not a dictionary", "CRITICAL") + return global_pool + + all_global_pool_details = response.get("response") + global_pool_details = get_dict_result(all_global_pool_details, "ipPoolName", name) + self.log("Global ip pool name: {0}".format(name), "DEBUG") + self.log("Global pool details: {0}".format(global_pool_details), "DEBUG") + if not global_pool_details: + self.log("Global pool {0} does not exist".format(name), "INFO") + return global_pool + global_pool.update({"exists": True}) + global_pool.update({"id": global_pool_details.get("id")}) + global_pool["details"] = self.get_global_pool_params(global_pool_details) + + self.log("Formatted global pool details: {0}".format(global_pool), "DEBUG") + return global_pool + + def reserve_pool_exists(self, name, site_name): + """ + Check if the Reserved pool with the given name exists in a specific site + Use check_return_status() to check for failure + + Parameters: + name (str) - The name of the Reserved pool to check for existence. + site_name (str) - The name of the site where the Reserved pool is located. + + Returns: + dict - A dictionary containing information about the Reserved pool's existence: + - 'exists' (bool): True if the Reserved pool exists in the specified site, else False. + - 'id' (str or None): The ID of the Reserved pool if it exists, or None if it doesn't. + - 'details' (dict or None): Details of the Reserved pool if it exists, or else None. + """ + + reserve_pool = { + "exists": False, + "details": None, + "id": None, + "success": True + } + site_id = self.get_site_id(site_name) + self.log("Site ID for the site name {0}: {1}".format(site_name, site_id), "DEBUG") + if not site_id: + reserve_pool.update({"success": False}) + self.msg = "Failed to get the site id from the site name {0}".format(site_name) + self.status = "failed" + return reserve_pool + + response = self.dnac._exec( + family="network_settings", + function="get_reserve_ip_subpool", + params={"siteId": site_id} + ) + if not isinstance(response, dict): + reserve_pool.update({"success": False}) + self.msg = "Error in getting reserve pool - Response is not a dictionary" + self.status = "exited" + return reserve_pool + + all_reserve_pool_details = response.get("response") + reserve_pool_details = get_dict_result(all_reserve_pool_details, "groupName", name) + if not reserve_pool_details: + self.log("Reserved pool {0} does not exist in the site {1}" + .format(name, site_name), "DEBUG") + return reserve_pool + + reserve_pool.update({"exists": True}) + reserve_pool.update({"id": reserve_pool_details.get("id")}) + reserve_pool.update({"details": self.get_reserve_pool_params(reserve_pool_details)}) + + self.log("Reserved pool details: {0}".format(reserve_pool.get("details")), "DEBUG") + self.log("Reserved pool id: {0}".format(reserve_pool.get("id")), "DEBUG") + return reserve_pool + + def get_have_global_pool(self, config): + """ + Get the current Global Pool information from + Cisco Catalyst Center based on the provided playbook details. + check this API using check_return_status. + + Parameters: + config (dict) - Playbook details containing Global Pool configuration. + + Returns: + self - The current object with updated information. + """ + + global_pool = { + "exists": False, + "details": None, + "id": None + } + global_pool_settings = config.get("global_pool_details").get("settings") + if global_pool_settings is None: + self.msg = "settings in global_pool_details is missing in the playbook" + self.status = "failed" + return self + + global_pool_ippool = global_pool_settings.get("ip_pool") + if global_pool_ippool is None: + self.msg = "ip_pool in global_pool_details is missing in the playbook" + self.status = "failed" + return self + + name = global_pool_ippool[0].get("name") + if name is None: + self.msg = "Mandatory Parameter name required" + self.status = "failed" + return self + + # If the Global Pool doesn't exist and a previous name is provided + # Else try using the previous name + global_pool = self.global_pool_exists(name) + self.log("Global pool details: {0}".format(global_pool), "DEBUG") + prev_name = global_pool_ippool[0].get("prev_name") + if global_pool.get("exists") is False and \ + prev_name is not None: + global_pool = self.global_pool_exists(prev_name) + if global_pool.get("exists") is False: + self.msg = "Prev name {0} doesn't exist in global_pool_details".format(prev_name) + self.status = "failed" + return self + + self.log("Global pool exists: {0}".format(global_pool.get("exists")), "DEBUG") + self.log("Current Site: {0}".format(global_pool.get("details")), "DEBUG") + self.have.update({"globalPool": global_pool}) + self.msg = "Collecting the global pool details from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_have_reserve_pool(self, config): + """ + Get the current Reserved Pool information from Cisco Catalyst Center + based on the provided playbook details. + Check this API using check_return_status + + Parameters: + config (list of dict) - Playbook details containing Reserved Pool configuration. + + Returns: + self - The current object with updated information. + """ + + reserve_pool = { + "exists": False, + "details": None, + "id": None + } + reserve_pool_details = config.get("reserve_pool_details") + name = reserve_pool_details.get("name") + if name is None: + self.msg = "Mandatory Parameter name required in reserve_pool_details\n" + self.status = "failed" + return self + + site_name = reserve_pool_details.get("site_name") + self.log("Site Name: {0}".format(site_name), "DEBUG") + if site_name is None: + self.msg = "Missing parameter 'site_name' in reserve_pool_details" + self.status = "failed" + return self + + # Check if the Reserved Pool exists in Cisco Catalyst Center + # based on the provided name and site name + reserve_pool = self.reserve_pool_exists(name, site_name) + if not reserve_pool.get("success"): + return self.check_return_status() + self.log("Reserved pool details: {0}".format(reserve_pool), "DEBUG") + + # If the Reserved Pool doesn't exist and a previous name is provided + # Else try using the previous name + prev_name = reserve_pool_details.get("prev_name") + if reserve_pool.get("exists") is False and \ + prev_name is not None: + reserve_pool = self.reserve_pool_exists(prev_name, site_name) + if not reserve_pool.get("success"): + return self.check_return_status() + + # If the previous name doesn't exist in Cisco Catalyst Center, return with error + if reserve_pool.get("exists") is False: + self.msg = "Prev name {0} doesn't exist in reserve_pool_details".format(prev_name) + self.status = "failed" + return self + + self.log("Reserved pool exists: {0}".format(reserve_pool.get("exists")), "DEBUG") + self.log("Reserved pool: {0}".format(reserve_pool.get("details")), "DEBUG") + + # If reserve pool exist, convert ipv6AddressSpace to the required format (boolean) + if reserve_pool.get("exists"): + reserve_pool_details = reserve_pool.get("details") + if reserve_pool_details.get("ipv6AddressSpace") == "False": + reserve_pool_details.update({"ipv6AddressSpace": False}) + else: + reserve_pool_details.update({"ipv6AddressSpace": True}) + + self.log("Reserved pool details: {0}".format(reserve_pool), "DEBUG") + self.have.update({"reservePool": reserve_pool}) + self.msg = "Collecting the reserve pool details from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_have_network(self, config): + """ + Get the current Network details from Cisco Catalyst + Center based on the provided playbook details. + + Parameters: + config (dict) - Playbook details containing Network Management configuration. + + Returns: + self - The current object with updated Network information. + """ + network = {} + site_name = config.get("network_management_details").get("site_name") + if site_name is None: + self.msg = "Mandatory Parameter 'site_name' missing" + self.status = "failed" + return self + + site_id = self.get_site_id(site_name) + if site_id is None: + self.msg = "Failed to get site id from {0}".format(site_name) + self.status = "failed" + return self + + network["site_id"] = site_id + network["net_details"] = self.get_network_params(site_id) + self.log("Network details from the Catalyst Center: {0}".format(network), "DEBUG") + self.have.update({"network": network}) + self.msg = "Collecting the network details from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_have(self, config): + """ + Get the current Global Pool Reserved Pool and Network details from Cisco Catalyst Center + + Parameters: + config (dict) - Playbook details containing Global Pool, + Reserved Pool, and Network Management configuration. + + Returns: + self - The current object with updated Global Pool, + Reserved Pool, and Network information. + """ + + if config.get("global_pool_details") is not None: + self.get_have_global_pool(config).check_return_status() + + if config.get("reserve_pool_details") is not None: + self.get_have_reserve_pool(config).check_return_status() + + if config.get("network_management_details") is not None: + self.get_have_network(config).check_return_status() + + self.log("Current State (have): {0}".format(self.have), "INFO") + self.msg = "Successfully retrieved the details from the Cisco Catalyst Center" + self.status = "success" + return self + + def get_want_global_pool(self, global_ippool): + """ + Get all the Global Pool information from playbook + Set the status and the msg before returning from the API + Check the return value of the API with check_return_status() + + Parameters: + global_ippool (dict) - Playbook global pool details containing IpAddressSpace, + DHCP server IPs, DNS server IPs, IP pool name, IP pool CIDR, gateway, and type. + + Returns: + self - The current object with updated desired Global Pool information. + """ + + # Initialize the desired Global Pool configuration + want_global = { + "settings": { + "ippool": [{ + "IpAddressSpace": global_ippool.get("ip_address_space"), + "dhcpServerIps": global_ippool.get("dhcp_server_ips"), + "dnsServerIps": global_ippool.get("dns_server_ips"), + "ipPoolName": global_ippool.get("name"), + "ipPoolCidr": global_ippool.get("cidr"), + "gateway": global_ippool.get("gateway"), + "type": global_ippool.get("pool_type"), + }] + } + } + want_ippool = want_global.get("settings").get("ippool")[0] + + # Converting to the required format based on the existing Global Pool + if not self.have.get("globalPool").get("exists"): + if want_ippool.get("dhcpServerIps") is None: + want_ippool.update({"dhcpServerIps": []}) + if want_ippool.get("dnsServerIps") is None: + want_ippool.update({"dnsServerIps": []}) + if want_ippool.get("IpAddressSpace") is None: + want_ippool.update({"IpAddressSpace": ""}) + if want_ippool.get("gateway") is None: + want_ippool.update({"gateway": ""}) + if want_ippool.get("type") is None: + want_ippool.update({"type": "Generic"}) + else: + have_ippool = self.have.get("globalPool").get("details") \ + .get("settings").get("ippool")[0] + + # Copy existing Global Pool information if the desired configuration is not provided + want_ippool.update({ + "IpAddressSpace": have_ippool.get("IpAddressSpace"), + "type": have_ippool.get("type"), + "ipPoolCidr": have_ippool.get("ipPoolCidr") + }) + want_ippool.update({}) + want_ippool.update({}) + + for key in ["dhcpServerIps", "dnsServerIps", "gateway"]: + if want_ippool.get(key) is None and have_ippool.get(key) is not None: + want_ippool[key] = have_ippool[key] + + self.log("Global pool playbook details: {0}".format(want_global), "DEBUG") + self.want.update({"wantGlobal": want_global}) + self.msg = "Collecting the global pool details from the playbook" + self.status = "success" + return self + + def get_want_reserve_pool(self, reserve_pool): + """ + Get all the Reserved Pool information from playbook + Set the status and the msg before returning from the API + Check the return value of the API with check_return_status() + + Parameters: + reserve_pool (dict) - Playbook reserved pool + details containing various properties. + + Returns: + self - The current object with updated desired Reserved Pool information. + """ + + want_reserve = { + "name": reserve_pool.get("name"), + "type": reserve_pool.get("pool_type"), + "ipv6AddressSpace": reserve_pool.get("ipv6_address_space"), + "ipv4GlobalPool": reserve_pool.get("ipv4_global_pool"), + "ipv4Prefix": reserve_pool.get("ipv4_prefix"), + "ipv4PrefixLength": reserve_pool.get("ipv4_prefix_length"), + "ipv4GateWay": reserve_pool.get("ipv4_gateway"), + "ipv4DhcpServers": reserve_pool.get("ipv4_dhcp_servers"), + "ipv4DnsServers": reserve_pool.get("ipv4_dns_servers"), + "ipv4Subnet": reserve_pool.get("ipv4_subnet"), + "ipv6GlobalPool": reserve_pool.get("ipv6_global_pool"), + "ipv6Prefix": reserve_pool.get("ipv6_prefix"), + "ipv6PrefixLength": reserve_pool.get("ipv6_prefix_length"), + "ipv6GateWay": reserve_pool.get("ipv6_gateway"), + "ipv6DhcpServers": reserve_pool.get("ipv6_dhcp_servers"), + "ipv6Subnet": reserve_pool.get("ipv6_subnet"), + "ipv6DnsServers": reserve_pool.get("ipv6_dns_servers"), + "ipv4TotalHost": reserve_pool.get("ipv4_total_host"), + "ipv6TotalHost": reserve_pool.get("ipv6_total_host") + } + + # Check for missing mandatory parameters in the playbook + if not want_reserve.get("name"): + self.msg = "Missing mandatory parameter 'name' in reserve_pool_details" + self.status = "failed" + return self + + if want_reserve.get("ipv4Prefix") is True: + if want_reserve.get("ipv4Subnet") is None and \ + want_reserve.get("ipv4TotalHost") is None: + self.msg = "missing parameter 'ipv4_subnet' or 'ipv4TotalHost' \ + while adding the ipv4 in reserve_pool_details" + self.status = "failed" + return self + + if want_reserve.get("ipv6Prefix") is True: + if want_reserve.get("ipv6Subnet") is None and \ + want_reserve.get("ipv6TotalHost") is None: + self.msg = "missing parameter 'ipv6_subnet' or 'ipv6TotalHost' \ + while adding the ipv6 in reserve_pool_details" + self.status = "failed" + return self + + self.log("Reserved IP pool playbook details: {0}".format(want_reserve), "DEBUG") + + # If there are no existing Reserved Pool details, validate and set defaults + if not self.have.get("reservePool").get("details"): + if not want_reserve.get("ipv4GlobalPool"): + self.msg = "missing parameter 'ipv4GlobalPool' in reserve_pool_details" + self.status = "failed" + return self + + if not want_reserve.get("ipv4PrefixLength"): + self.msg = "missing parameter 'ipv4_prefix_length' in reserve_pool_details" + self.status = "failed" + return self + + if want_reserve.get("type") is None: + want_reserve.update({"type": "Generic"}) + if want_reserve.get("ipv4GateWay") is None: + want_reserve.update({"ipv4GateWay": ""}) + if want_reserve.get("ipv4DhcpServers") is None: + want_reserve.update({"ipv4DhcpServers": []}) + if want_reserve.get("ipv4DnsServers") is None: + want_reserve.update({"ipv4DnsServers": []}) + if want_reserve.get("ipv6AddressSpace") is None: + want_reserve.update({"ipv6AddressSpace": False}) + if want_reserve.get("slaacSupport") is None: + want_reserve.update({"slaacSupport": True}) + if want_reserve.get("ipv4TotalHost") is None: + del want_reserve['ipv4TotalHost'] + if want_reserve.get("ipv6AddressSpace") is True: + want_reserve.update({"ipv6Prefix": True}) + else: + del want_reserve['ipv6Prefix'] + + if not want_reserve.get("ipv6AddressSpace"): + keys_to_check = ['ipv6GlobalPool', 'ipv6PrefixLength', + 'ipv6GateWay', 'ipv6DhcpServers', + 'ipv6DnsServers', 'ipv6TotalHost'] + for key in keys_to_check: + if want_reserve.get(key) is None: + del want_reserve[key] + else: + keys_to_delete = ['type', 'ipv4GlobalPool', + 'ipv4Prefix', 'ipv4PrefixLength', + 'ipv4TotalHost', 'ipv4Subnet'] + for key in keys_to_delete: + if key in want_reserve: + del want_reserve[key] + + self.want.update({"wantReserve": want_reserve}) + self.log("Desired State (want): {0}".format(self.want), "INFO") + self.msg = "Collecting the reserve pool details from the playbook" + self.status = "success" + return self + + def get_want_network(self, network_management_details): + """ + Get all the Network related information from playbook + Set the status and the msg before returning from the API + Check the return value of the API with check_return_status() + + Parameters: + network_management_details (dict) - Playbook network + details containing various network settings. + + Returns: + self - The current object with updated desired Network-related information. + """ + + want_network = { + "settings": { + "dhcpServer": {}, + "dnsServer": {}, + "snmpServer": {}, + "syslogServer": {}, + "netflowcollector": {}, + "ntpServer": {}, + "timezone": "", + "messageOfTheday": {}, + "network_aaa": {}, + "clientAndEndpoint_aaa": {} + } + } + want_network_settings = want_network.get("settings") + self.log("Current state (have): {0}".format(self.have), "DEBUG") + if network_management_details.get("dhcp_server") is not None: + want_network_settings.update({ + "dhcpServer": network_management_details.get("dhcp_server") + }) + else: + del want_network_settings["dhcpServer"] + + if network_management_details.get("ntp_server") is not None: + want_network_settings.update({ + "ntpServer": network_management_details.get("ntp_server") + }) + else: + del want_network_settings["ntpServer"] + + if network_management_details.get("timezone") is not None: + want_network_settings["timezone"] = \ + network_management_details.get("timezone") + else: + self.msg = "missing parameter timezone in network" + self.status = "failed" + return self + + dnsServer = network_management_details.get("dns_server") + if dnsServer is not None: + if dnsServer.get("domain_name") is not None: + want_network_settings.get("dnsServer").update({ + "domainName": + dnsServer.get("domain_name") + }) + + if dnsServer.get("primary_ip_address") is not None: + want_network_settings.get("dnsServer").update({ + "primaryIpAddress": + dnsServer.get("primary_ip_address") + }) + + if dnsServer.get("secondary_ip_address") is not None: + want_network_settings.get("dnsServer").update({ + "secondaryIpAddress": + dnsServer.get("secondary_ip_address") + }) + else: + del want_network_settings["dnsServer"] + + snmpServer = network_management_details.get("snmp_server") + if snmpServer is not None: + if snmpServer.get("configure_dnac_ip") is not None: + want_network_settings.get("snmpServer").update({ + "configureDnacIP": snmpServer.get("configure_dnac_ip") + }) + if snmpServer.get("ip_addresses") is not None: + want_network_settings.get("snmpServer").update({ + "ipAddresses": snmpServer.get("ip_addresses") + }) + else: + del want_network_settings["snmpServer"] + + 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({ + "configureDnacIP": syslogServer.get("configure_dnac_ip") + }) + if syslogServer.get("ip_addresses") is not None: + want_network_settings.get("syslogServer").update({ + "ipAddresses": syslogServer.get("ip_addresses") + }) + else: + del want_network_settings["syslogServer"] + + netflowcollector = network_management_details.get("netflow_collector") + if netflowcollector is not None: + if netflowcollector.get("ip_address") is not None: + want_network_settings.get("netflowcollector").update({ + "ipAddress": + netflowcollector.get("ip_address") + }) + if netflowcollector.get("port") is not None: + want_network_settings.get("netflowcollector").update({ + "port": + netflowcollector.get("port") + }) + else: + del want_network_settings["netflowcollector"] + + messageOfTheday = network_management_details.get("message_of_the_day") + if messageOfTheday is not None: + if messageOfTheday.get("banner_message") is not None: + want_network_settings.get("messageOfTheday").update({ + "bannerMessage": + messageOfTheday.get("banner_message") + }) + if messageOfTheday.get("retain_existing_banner") is not None: + want_network_settings.get("messageOfTheday").update({ + "retainExistingBanner": + messageOfTheday.get("retain_existing_banner") + }) + else: + del want_network_settings["messageOfTheday"] + + network_aaa = network_management_details.get("network_aaa") + if network_aaa: + if network_aaa.get("ip_address"): + want_network_settings.get("network_aaa").update({ + "ipAddress": + network_aaa.get("ip_address") + }) + else: + if network_aaa.get("servers") == "ISE": + self.msg = "missing parameter ip_address in network_aaa, server ISE is set" + self.status = "failed" + return self + + if network_aaa.get("network"): + want_network_settings.get("network_aaa").update({ + "network": network_aaa.get("network") + }) + else: + self.msg = "missing parameter network in network_aaa" + self.status = "failed" + return self + + if network_aaa.get("protocol"): + want_network_settings.get("network_aaa").update({ + "protocol": + network_aaa.get("protocol") + }) + else: + self.msg = "missing parameter protocol in network_aaa" + self.status = "failed" + return self + + if network_aaa.get("servers"): + want_network_settings.get("network_aaa").update({ + "servers": + network_aaa.get("servers") + }) + else: + self.msg = "missing parameter servers in network_aaa" + self.status = "failed" + return self + + if network_aaa.get("shared_secret"): + want_network_settings.get("network_aaa").update({ + "sharedSecret": + network_aaa.get("shared_secret") + }) + else: + del want_network_settings["network_aaa"] + + clientAndEndpoint_aaa = network_management_details.get("client_and_endpoint_aaa") + if clientAndEndpoint_aaa: + if clientAndEndpoint_aaa.get("ip_address"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "ipAddress": + clientAndEndpoint_aaa.get("ip_address") + }) + else: + if clientAndEndpoint_aaa.get("servers") == "ISE": + self.msg = "missing parameter ip_address in clientAndEndpoint_aaa, \ + server ISE is set" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("network"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "network": + clientAndEndpoint_aaa.get("network") + }) + else: + self.msg = "missing parameter network in clientAndEndpoint_aaa" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("protocol"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "protocol": + clientAndEndpoint_aaa.get("protocol") + }) + else: + self.msg = "missing parameter protocol in clientAndEndpoint_aaa" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("servers"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "servers": + clientAndEndpoint_aaa.get("servers") + }) + else: + self.msg = "missing parameter servers in clientAndEndpoint_aaa" + self.status = "failed" + return self + + if clientAndEndpoint_aaa.get("shared_secret"): + want_network_settings.get("clientAndEndpoint_aaa").update({ + "sharedSecret": + clientAndEndpoint_aaa.get("shared_secret") + }) + else: + del want_network_settings["clientAndEndpoint_aaa"] + + self.log("Network playbook details: {0}".format(want_network), "DEBUG") + self.want.update({"wantNetwork": want_network}) + self.msg = "Collecting the network details from the playbook" + self.status = "success" + return self + + def get_want(self, config): + """ + Get all the Global Pool Reserved Pool and Network related information from playbook + + Parameters: + config (list of dict) - Playbook details + + Returns: + None + """ + + if config.get("global_pool_details"): + global_ippool = config.get("global_pool_details").get("settings").get("ip_pool")[0] + self.get_want_global_pool(global_ippool).check_return_status() + + if config.get("reserve_pool_details"): + reserve_pool = config.get("reserve_pool_details") + self.get_want_reserve_pool(reserve_pool).check_return_status() + + if config.get("network_management_details"): + network_management_details = config.get("network_management_details") \ + .get("settings") + self.get_want_network(network_management_details).check_return_status() + + self.log("Desired State (want): {0}".format(self.want), "INFO") + self.msg = "Successfully retrieved details from the playbook" + self.status = "success" + return self + + def update_global_pool(self, config): + """ + Update/Create Global Pool in Cisco Catalyst Center with fields provided in playbook + + Parameters: + config (list of dict) - Playbook details + + Returns: + None + """ + + name = config.get("global_pool_details") \ + .get("settings").get("ip_pool")[0].get("name") + result_global_pool = self.result.get("response")[0].get("globalPool") + result_global_pool.get("response").update({name: {}}) + + # Check pool exist, if not create and return + if not self.have.get("globalPool").get("exists"): + pool_params = self.want.get("wantGlobal") + self.log("Desired State for global pool (want): {0}".format(pool_params), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="create_global_pool", + params=pool_params, + ) + self.check_execution_response_status(response).check_return_status() + self.log("Successfully created global pool '{0}'.".format(name), "INFO") + result_global_pool.get("response").get(name) \ + .update({"globalPool Details": self.want.get("wantGlobal")}) + result_global_pool.get("msg").update({name: "Global Pool Created Successfully"}) + return + + # Pool exists, check update is required + if not self.requires_update(self.have.get("globalPool").get("details"), + self.want.get("wantGlobal"), self.global_pool_obj_params): + self.log("Global pool '{0}' doesn't require an update".format(name), "INFO") + result_global_pool.get("response").get(name).update({ + "Cisco Catalyst Center params": + self.have.get("globalPool").get("details").get("settings").get("ippool")[0] + }) + result_global_pool.get("response").get(name).update({ + "Id": self.have.get("globalPool").get("id") + }) + result_global_pool.get("msg").update({ + name: "Global pool doesn't require an update" + }) + return + + self.log("Global pool requires update", "DEBUG") + # Pool Exists + pool_params = copy.deepcopy(self.want.get("wantGlobal")) + pool_params_ippool = pool_params.get("settings").get("ippool")[0] + pool_params_ippool.update({"id": self.have.get("globalPool").get("id")}) + self.log("Desired State for global pool (want): {0}".format(pool_params), "DEBUG") + keys_to_remove = ["IpAddressSpace", "ipPoolCidr", "type"] + for key in keys_to_remove: + del pool_params["settings"]["ippool"][0][key] + + have_ippool = self.have.get("globalPool").get("details").get("settings").get("ippool")[0] + keys_to_update = ["dhcpServerIps", "dnsServerIps", "gateway"] + for key in keys_to_update: + if pool_params_ippool.get(key) is None: + pool_params_ippool[key] = have_ippool.get(key) + + self.log("Desired global pool details (want): {0}".format(pool_params), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="update_global_pool", + params=pool_params, + ) + + self.check_execution_response_status(response).check_return_status() + self.log("Global pool '{0}' updated successfully".format(name), "INFO") + result_global_pool.get("response").get(name) \ + .update({"Id": self.have.get("globalPool").get("details").get("id")}) + result_global_pool.get("msg").update({name: "Global Pool Updated Successfully"}) + return + + def update_reserve_pool(self, config): + """ + Update or Create a Reserve Pool in Cisco Catalyst Center based on the provided configuration. + This method checks if a reserve pool with the specified name exists in Cisco Catalyst Center. + If it exists and requires an update, it updates the pool. If not, it creates a new pool. + + Parameters: + config (list of dict) - Playbook details containing Reserve Pool information. + + Returns: + None + """ + + name = config.get("reserve_pool_details").get("name") + result_reserve_pool = self.result.get("response")[1].get("reservePool") + result_reserve_pool.get("response").update({name: {}}) + self.log("Current reserved pool details in Catalyst Center: {0}" + .format(self.have.get("reservePool").get("details")), "DEBUG") + self.log("Desired reserved pool details in Catalyst Center: {0}" + .format(self.want.get("wantReserve")), "DEBUG") + + # Check pool exist, if not create and return + self.log("IPv4 global pool: {0}" + .format(self.want.get("wantReserve").get("ipv4GlobalPool")), "DEBUG") + site_name = config.get("reserve_pool_details").get("site_name") + reserve_params = self.want.get("wantReserve") + site_id = self.get_site_id(site_name) + reserve_params.update({"site_id": site_id}) + if not self.have.get("reservePool").get("exists"): + self.log("Desired reserved pool details (want): {0}".format(reserve_params), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="reserve_ip_subpool", + params=reserve_params, + ) + self.check_execution_response_status(response).check_return_status() + self.log("Successfully created IP subpool reservation '{0}'.".format(name), "INFO") + result_reserve_pool.get("response").get(name) \ + .update({"reservePool Details": self.want.get("wantReserve")}) + result_reserve_pool.get("msg") \ + .update({name: "Ip Subpool Reservation Created Successfully"}) + return + + # Check update is required + if not self.requires_update(self.have.get("reservePool").get("details"), + self.want.get("wantReserve"), self.reserve_pool_obj_params): + self.log("Reserved ip subpool '{0}' doesn't require an update".format(name), "INFO") + result_reserve_pool.get("response").get(name) \ + .update({"Cisco Catalyst Center params": self.have.get("reservePool").get("details")}) + result_reserve_pool.get("response").get(name) \ + .update({"Id": self.have.get("reservePool").get("id")}) + result_reserve_pool.get("msg") \ + .update({name: "Reserve ip subpool doesn't require an update"}) + return + + self.log("Reserved ip pool '{0}' requires an update".format(name), "DEBUG") + # Pool Exists + self.log("Current reserved ip pool '{0}' details in Catalyst Center: {1}" + .format(name, self.have.get("reservePool")), "DEBUG") + self.log("Desired reserved ip pool '{0}' details: {1}" + .format(name, self.want.get("wantReserve")), "DEBUG") + reserve_params.update({"id": self.have.get("reservePool").get("id")}) + response = self.dnac._exec( + family="network_settings", + function="update_reserve_ip_subpool", + params=reserve_params, + ) + self.check_execution_response_status(response).check_return_status() + self.log("Reserved ip subpool '{0}' updated successfully.".format(name), "INFO") + result_reserve_pool['msg'] = "Reserved Ip Subpool Updated Successfully" + result_reserve_pool.get("response").get(name) \ + .update({"Reservation details": self.have.get("reservePool").get("details")}) + return + + def update_network(self, config): + """ + Update or create a network configuration in Cisco Catalyst + Center based on the provided playbook details. + + Parameters: + config (list of dict) - Playbook details containing Network Management information. + + Returns: + None + """ + + 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: {}}) + + # Check update is required or not + if not self.requires_update(self.have.get("network").get("net_details"), + self.want.get("wantNetwork"), self.network_obj_params): + + self.log("Network in site '{0}' doesn't require an update.".format(site_name), "INFO") + result_network.get("response").get(site_name).update({ + "Cisco Catalyst Center params": self.have.get("network") + .get("net_details").get("settings") + }) + result_network.get("msg").update({site_name: "Network doesn't require an update"}) + return + + self.log("Network in site '{0}' requires update.".format(site_name), "INFO") + self.log("Current State of network in Catalyst Center: {0}" + .format(self.have.get("network")), "DEBUG") + self.log("Desired State of network: {0}".format(self.want.get("wantNetwork")), "DEBUG") + + net_params = copy.deepcopy(self.want.get("wantNetwork")) + net_params.update({"site_id": self.have.get("network").get("site_id")}) + response = self.dnac._exec( + family="network_settings", + function='update_network_v2', + params=net_params, + ) + self.log("Received API response of 'update_network_v2': {0}".format(response), "DEBUG") + validation_string = "desired common settings operation successful" + self.check_task_response_status(response, validation_string).check_return_status() + self.log("Network has been changed successfully", "INFO") + result_network.get("msg") \ + .update({site_name: "Network Updated successfully"}) + result_network.get("response").get(site_name) \ + .update({"Network Details": self.want.get("wantNetwork").get("settings")}) + return + + def get_diff_merged(self, config): + """ + Update or create Global Pool, Reserve Pool, and + Network configurations in Cisco Catalyst Center based on the playbook details + + Parameters: + config (list of dict) - Playbook details containing + Global Pool, Reserve Pool, and Network Management information. + + Returns: + self + """ + + if config.get("global_pool_details") is not None: + self.update_global_pool(config) + + if config.get("reserve_pool_details") is not None: + self.update_reserve_pool(config) + + if config.get("network_management_details") is not None: + self.update_network(config) + + return self + + def delete_reserve_pool(self, name): + """ + Delete a Reserve Pool by name in Cisco Catalyst Center + + Parameters: + name (str) - The name of the Reserve Pool to be deleted. + + Returns: + self + """ + + reserve_pool_exists = self.have.get("reservePool").get("exists") + result_reserve_pool = self.result.get("response")[1].get("reservePool") + + if not reserve_pool_exists: + result_reserve_pool.get("response").update({name: "Reserve Pool not found"}) + self.msg = "Reserved Ip Subpool Not Found" + self.status = "success" + return self + + self.log("Reserved IP pool scheduled for deletion: {0}" + .format(self.have.get("reservePool").get("name")), "INFO") + _id = self.have.get("reservePool").get("id") + self.log("Reserved pool {0} id: {1}".format(name, _id), "DEBUG") + response = self.dnac._exec( + family="network_settings", + function="release_reserve_ip_subpool", + params={"id": _id}, + ) + self.check_execution_response_status(response).check_return_status() + executionid = response.get("executionId") + result_reserve_pool = self.result.get("response")[1].get("reservePool") + result_reserve_pool.get("response").update({name: {}}) + result_reserve_pool.get("response").get(name) \ + .update({"Execution Id": executionid}) + result_reserve_pool.get("msg") \ + .update({name: "Ip subpool reservation released successfully"}) + self.msg = "Reserved pool - {0} released successfully".format(name) + self.status = "success" + return self + + def delete_global_pool(self, name): + """ + Delete a Global Pool by name in Cisco Catalyst Center + + Parameters: + name (str) - The name of the Global Pool to be deleted. + + Returns: + self + """ + + global_pool_exists = self.have.get("globalPool").get("exists") + result_global_pool = self.result.get("response")[0].get("globalPool") + if not global_pool_exists: + result_global_pool.get("response").update({name: "Global Pool not found"}) + self.msg = "Global pool Not Found" + self.status = "success" + return self + + response = self.dnac._exec( + family="network_settings", + function="delete_global_ip_pool", + params={"id": self.have.get("globalPool").get("id")}, + ) + + # Check the execution status + self.check_execution_response_status(response).check_return_status() + executionid = response.get("executionId") + + # Update result information + result_global_pool = self.result.get("response")[0].get("globalPool") + result_global_pool.get("response").update({name: {}}) + result_global_pool.get("response").get(name).update({"Execution Id": executionid}) + result_global_pool.get("msg").update({name: "Pool deleted successfully"}) + self.msg = "Global pool - {0} deleted successfully".format(name) + self.status = "success" + return self + + def get_diff_deleted(self, config): + """ + Delete Reserve Pool and Global Pool in Cisco Catalyst Center based on playbook details. + + Parameters: + config (list of dict) - Playbook details + + Returns: + self + """ + + if config.get("reserve_pool_details") is not None: + name = config.get("reserve_pool_details").get("name") + self.delete_reserve_pool(name).check_return_status() + + if config.get("global_pool_details") is not None: + name = config.get("global_pool_details") \ + .get("settings").get("ip_pool")[0].get("name") + self.delete_global_pool(name).check_return_status() + + return self + + def verify_diff_merged(self, config): + """ + Validating the Cisco Catalyst Center 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Requested State (want): {0}".format(self.want), "INFO") + if config.get("global_pool_details") is not None: + self.log("Desired State of global pool (want): {0}" + .format(self.want.get("wantGlobal")), "DEBUG") + self.log("Current State of global pool (have): {0}" + .format(self.have.get("globalPool").get("details")), "DEBUG") + 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 Cisco Catalyst Center" + self.status = "failed" + return self + + self.log("Successfully validated global pool '{0}'.".format(self.want + .get("wantGlobal").get("settings").get("ippool")[0].get("ipPoolName")), "INFO") + 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("Desired State for reserve pool (want): {0}" + .format(self.want.get("wantReserve")), "DEBUG") + self.log("Current State for reserve pool (have): {0}" + .format(self.have.get("reservePool").get("details")), "DEBUG") + self.msg = "Reserved Pool Config is not applied to the Cisco Catalyst Center" + self.status = "failed" + return self + + self.log("Successfully validated the reserved pool '{0}'." + .format(self.want.get("wantReserve").get("name")), "INFO") + 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 Cisco Catalyst Center" + self.status = "failed" + return self + + self.log("Successfully validated the network functions '{0}'." + .format(config.get("network_management_details").get("site_name")), "INFO") + 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 Cisco Catalyst Center 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + 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 Cisco Catalyst Center" + self.status = "failed" + return self + + self.log("Successfully validated absence of Global Pool '{0}'." + .format(config.get("global_pool_details") + .get("settings").get("ip_pool")[0].get("name")), "INFO") + 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 = "Reserved Pool Config is not applied to the Catalyst Center" + self.status = "failed" + return self + + self.log("Successfully validated the absence of Reserve Pool '{0}'." + .format(config.get("reserve_pool_details").get("name")), "INFO") + 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 + + Parameters: + None + + Returns: + None + """ + + self.have.clear() + self.want.clear() + return + + +def main(): + """main entry point for module execution""" + + # Define the specification for module arguments + element_spec = { + "dnac_host": {"type": 'str', "required": True}, + "dnac_port": {"type": 'str', "default": '443'}, + "dnac_username": {"type": 'str', "default": 'admin', "aliases": ['user']}, + "dnac_password": {"type": 'str', "no_log": True}, + "dnac_verify": {"type": 'bool', "default": 'True'}, + "dnac_version": {"type": 'str', "default": '2.2.3.3'}, + "dnac_debug": {"type": 'bool', "default": False}, + "dnac_log": {"type": 'bool', "default": False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + "config_verify": {"type": 'bool', "default": False}, + "dnac_api_task_timeout": {"type": 'int', "default": 1200}, + "dnac_task_poll_interval": {"type": 'int', "default": 2}, + "config": {"type": 'list', "required": True, "elements": 'dict'}, + "state": {"default": 'merged', "choices": ['merged', 'deleted']}, + "validate_response_schema": {"type": 'bool', "default": True}, + } + + # Create an AnsibleModule object with argument specifications + module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) + ccc_network = NetworkSettings(module) + state = ccc_network.params.get("state") + config_verify = ccc_network.params.get("config_verify") + if state not in ccc_network.supported_states: + ccc_network.status = "invalid" + ccc_network.msg = "State {0} is invalid".format(state) + ccc_network.check_return_status() + + ccc_network.validate_input().check_return_status() + + for config in ccc_network.config: + ccc_network.reset_values() + ccc_network.get_have(config).check_return_status() + if state != "deleted": + ccc_network.get_want(config).check_return_status() + ccc_network.get_diff_state_apply[state](config).check_return_status() + if config_verify: + ccc_network.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_network.result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/pnp_intent.py b/ansible_collections/cisco/dnac/plugins/modules/pnp_intent.py index 02c89721a..3c71046a9 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/pnp_intent.py +++ b/ansible_collections/cisco/dnac/plugins/modules/pnp_intent.py @@ -1,439 +1,247 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright (c) 2022, Cisco Systems +# Copyright (c) 2024, Cisco Systems # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - from __future__ import absolute_import, division, print_function __metaclass__ = type -__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary") +__author__ = ("Abinash Mishra, Madhan Sankaranarayanan, Rishita Chowdhary") DOCUMENTATION = r""" --- module: pnp_intent short_description: Resource module for Site and PnP related functions description: -- 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. -version_added: '6.6.0' + - 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 -author: Madhan Sankaranarayanan (@madhansansel) - Rishita Chowdhary (@rishitachowdhary) +author: Abinash Mishra (@abimishr) Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after + applying the playbook config. + type: bool + default: false state: - description: The state of DNAC after module completion. + description: The state of Cisco Catalyst Center after module completion. type: str - choices: [ merged, deleted ] + choices: + - merged + - deleted default: merged config: description: - - List of details of device being managed. + - List of details of device being managed. type: list elements: dict required: true suboptions: - template_name: - description: Name of template to be configured on the device. - type: str - image_name: - description: Name of image to be configured on the device - type: str - golden_image: - description: Is the image to be condifgured tagged as golden image - type: bool - site_name: - description: Name of the site for which device will be claimed. - type: str - deviceInfo: - description: Pnp Device's deviceInfo. - type: dict + device_info: + description: + - Provides the device-specific information required for adding devices + to the PnP database that are not already present. + - For adding a single device, the list should contain exactly one set + of device information. If a site name is also provided, the device + can be claimed immediately after being added. + - For bulk import, the list must contain information for more than one + device. Bulk import is intended solely for adding devices; claiming + must be performed with separate tasks or configurations. + type: list + required: true + elements: dict suboptions: - aaaCredentials: - description: Pnp Device's aaaCredentials. - type: dict - suboptions: - password: - description: Pnp Device's password. - type: str - username: - description: Pnp Device's username. - type: str - addedOn: - description: Pnp Device's addedOn. - type: int - addnMacAddrs: - description: Pnp Device's addnMacAddrs. - elements: str - type: list - agentType: - description: Pnp Device's agentType. - type: str - authStatus: - description: Pnp Device's authStatus. - type: str - authenticatedSudiSerialNo: - description: Pnp Device's authenticatedSudiSerialNo. - type: str - capabilitiesSupported: - description: Pnp Device's capabilitiesSupported. - elements: str - type: list - cmState: - description: Pnp Device's cmState. - type: str - description: - description: Pnp Device's description. - type: str - deviceSudiSerialNos: - description: Pnp Device's deviceSudiSerialNos. - elements: str - type: list - deviceType: - description: Pnp Device's deviceType. - type: str - featuresSupported: - description: Pnp Device's featuresSupported. - elements: str - type: list - fileSystemList: - description: Pnp Device's fileSystemList. - type: list - elements: dict - suboptions: - freespace: - description: Pnp Device's freespace. - type: int - name: - description: Pnp Device's name. - type: str - readable: - description: Readable flag. - type: bool - size: - description: Pnp Device's size. - type: int - type: - description: Pnp Device's type. - type: str - writeable: - description: Writeable flag. - type: bool - firstContact: - description: Pnp Device's firstContact. - type: int hostname: - description: Pnp Device's hostname. + description: + - Defines the desired hostname for the PnP device after it has + been claimed. + - The hostname can only be assigned or changed during the claim + process, not during bulk or single device additions. type: str - httpHeaders: - description: Pnp Device's httpHeaders. - type: list - elements: dict - suboptions: - key: - description: Pnp Device's key. - type: str - value: - description: Pnp Device's value. - type: str - imageFile: - description: Pnp Device's imageFile. - type: str - imageVersion: - description: Pnp Device's imageVersion. - type: str - ipInterfaces: - description: Pnp Device's ipInterfaces. - elements: dict - type: list - suboptions: - ipv4Address: - description: Pnp Device's ipv4Address. - type: dict - ipv6AddressList: - description: Pnp Device's ipv6AddressList. - elements: dict - type: list - macAddress: - description: Pnp Device's macAddress. - type: str - name: - description: Pnp Device's name. - type: str - status: - description: Pnp Device's status. - type: str - lastContact: - description: Pnp Device's lastContact. - type: int - lastSyncTime: - description: Pnp Device's lastSyncTime. - type: int - lastUpdateOn: - description: Pnp Device's lastUpdateOn. - type: int - location: - description: Pnp Device's location. - type: dict - suboptions: - address: - description: Pnp Device's address. - type: str - altitude: - description: Pnp Device's altitude. - type: str - latitude: - description: Pnp Device's latitude. - type: str - longitude: - description: Pnp Device's longitude. - type: str - siteId: - description: Pnp Device's siteId. - type: str - macAddress: - description: Pnp Device's macAddress. - type: str - mode: - description: Pnp Device's mode. - type: str - name: - description: Pnp Device's name. - type: str - neighborLinks: - description: Pnp Device's neighborLinks. - type: list - elements: dict - suboptions: - localInterfaceName: - description: Pnp Device's localInterfaceName. - type: str - localMacAddress: - description: Pnp Device's localMacAddress. - type: str - localShortInterfaceName: - description: Pnp Device's localShortInterfaceName. - type: str - remoteDeviceName: - description: Pnp Device's remoteDeviceName. - type: str - remoteInterfaceName: - description: Pnp Device's remoteInterfaceName. - type: str - remoteMacAddress: - description: Pnp Device's remoteMacAddress. - type: str - remotePlatform: - description: Pnp Device's remotePlatform. - type: str - remoteShortInterfaceName: - description: Pnp Device's remoteShortInterfaceName. - type: str - remoteVersion: - description: Pnp Device's remoteVersion. - type: str - onbState: - description: Pnp Device's onbState. + state: + description: + - Represents the onboarding state of the PnP device. + - Possible values are 'Unclaimed', 'Claimed', or 'Provisioned'. type: str pid: description: Pnp Device's pid. type: str - pnpProfileList: - description: Pnp Device's pnpProfileList. - type: list - elements: dict - suboptions: - createdBy: - description: Pnp Device's createdBy. - type: str - discoveryCreated: - description: DiscoveryCreated flag. - type: bool - primaryEndpoint: - description: Pnp Device's primaryEndpoint. - type: dict - suboptions: - certificate: - description: Pnp Device's certificate. - type: str - fqdn: - description: Pnp Device's fqdn. - type: str - ipv4Address: - description: Pnp Device's ipv4Address. - type: dict - ipv6Address: - description: Pnp Device's ipv6Address. - type: dict - port: - description: Pnp Device's port. - type: int - protocol: - description: Pnp Device's protocol. - type: str - profileName: - description: Pnp Device's profileName. - type: str - secondaryEndpoint: - description: Pnp Device's secondaryEndpoint. - type: dict - suboptions: - certificate: - description: Pnp Device's certificate. - type: str - fqdn: - description: Pnp Device's fqdn. - type: str - ipv4Address: - description: Pnp Device's ipv4Address. - type: dict - ipv6Address: - description: Pnp Device's ipv6Address. - type: dict - port: - description: Pnp Device's port. - type: int - protocol: - description: Pnp Device's protocol. - type: str - populateInventory: - description: PopulateInventory flag. - type: bool - preWorkflowCliOuputs: - description: Pnp Device's preWorkflowCliOuputs. - type: list - elements: dict - suboptions: - cli: - description: Pnp Device's cli. - type: str - cliOutput: - description: Pnp Device's cliOutput. - type: str - projectId: - description: Pnp Device's projectId. - type: str - projectName: - description: Pnp Device's projectName. + serial_number: + description: Pnp Device's serial_number. type: str - reloadRequested: - description: ReloadRequested flag. + is_sudi_required: + description: Sudi Authentication requiremnet's flag. type: bool - serialNumber: - description: Pnp Device's serialNumber. - type: str - smartAccountId: - description: Pnp Device's smartAccountId. - type: str - source: - description: Pnp Device's source. - type: str - stack: - description: Stack flag. - type: bool - stackInfo: - description: Pnp Device's stackInfo. - type: dict - suboptions: - isFullRing: - description: IsFullRing flag. - type: bool - stackMemberList: - description: Pnp Device's stackMemberList. - type: list - elements: dict - suboptions: - hardwareVersion: - description: Pnp Device's hardwareVersion. - type: str - licenseLevel: - description: Pnp Device's licenseLevel. - type: str - licenseType: - description: Pnp Device's licenseType. - type: str - macAddress: - description: Pnp Device's macAddress. - type: str - pid: - description: Pnp Device's pid. - type: str - priority: - description: Pnp Device's priority. - type: int - role: - description: Pnp Device's role. - type: str - serialNumber: - description: Pnp Device's serialNumber. - type: str - softwareVersion: - description: Pnp Device's softwareVersion. - type: str - stackNumber: - description: Pnp Device's stackNumber. - type: int - state: - description: Pnp Device's state. - type: str - sudiSerialNumber: - description: Pnp Device's sudiSerialNumber. - type: str - stackRingProtocol: - description: Pnp Device's stackRingProtocol. - type: str - supportsStackWorkflows: - description: SupportsStackWorkflows flag. - type: bool - totalMemberCount: - description: Pnp Device's totalMemberCount. - type: int - validLicenseLevels: - description: Pnp Device's validLicenseLevels. - type: str - state: - description: Pnp Device's state. - type: str - sudiRequired: - description: SudiRequired flag. - type: bool - tags: - description: Pnp Device's tags. - type: dict - userSudiSerialNos: - description: Pnp Device's userSudiSerialNos. - elements: str - type: list - virtualAccountId: - description: Pnp Device's virtualAccountId. - type: str - workflowId: - description: Pnp Device's workflowId. - type: str - workflowName: - description: Pnp Device's workflowName. - type: str - + site_name: + description: Name of the site for which device will be claimed. + type: str + project_name: + description: Name of the project under which the template is present + type: str + default: Onboarding Configuration + template_name: + description: + - Name of template to be configured on the device. + - Supported for EWLC from Cisco Catalyst Center release version + 2.3.7.x onwards. + type: str + template_params: + description: + - Parameter values for the parameterised templates. + - Each varibale has a value that needs to be passed as key-value pair + in the dictionary. We can pass values as + variable_name:variable_value. + - Supported for EWLC from Cisco Catalyst Center release version + 2.3.7.x onwards. + type: dict + image_name: + description: Name of image to be configured on the device + type: str + golden_image: + description: Is the image to be condifgured tagged as golden image + type: bool + pnp_type: + description: Specifies the device type for the Plug and Play (PnP) device. - + Options include 'Default', 'CatalystWLC', 'AccessPoint', or + 'StackSwitch'. - 'Default' is applicable to switches and routers. - + 'CatalystWLC' should be selected for 9800 series wireless controllers. + - 'AccessPoint' is used when claiming an access point. - 'StackSwitch' + should be chosen for a group of switches that operate as a single + switch, typically used in the access layer. + type: str + choices: + - Default + - CatalystWLC + - AccessPoint + - StackSwitch + default: Default + static_ip: + description: Management IP address of the Wireless Controller + type: str + subnet_mask: + description: Subnet Mask of the Management IP address of the Wireless Controller + type: str + gateway: + description: Gateway IP address of the Wireless Controller for getting pinged + type: str + vlan_id: + description: Vlan Id allocated for claimimg of Wireless Controller + type: str + ip_interface_name: + description: Specifies the interface name utilized for Plug and Play (PnP) by + the Wireless Controller. Ensure this interface is pre-configured on + the Controller prior to device claiming. + type: str + rf_profile: + description: + - Radio Frequecy (RF) profile of the AP being claimed. + - RF Profiles allow you to tune groups of APs that share a common + coverage zone together. + - They selectively change how Radio Resource Management will operate + the APs within that coverage zone. + - HIGH RF profile allows you to use more power and allows to join AP + with the client in an easier fashion. + - TYPICAL RF profile is a blend of moderate power and moderate + visibility to the client. + - LOW RF profile allows you to consume lesser power and has least + visibility to the client. + type: str + choices: + - HIGH + - LOW + - TYPICAL requirements: -- dnacentersdk == 2.4.5 -- python >= 3.5 + - dnacentersdk == 2.6.10 + - python >= 3.5 notes: - - SDK Method used are - device_onboarding_pnp.DeviceOnboardingPnp.add_device, + - SDK Method used are device_onboarding_pnp.DeviceOnboardingPnp.add_device, + device_onboarding_pnp.DeviceOnboardingPnp.get_device_list, device_onboarding_pnp.DeviceOnboardingPnp.claim_a_device_to_a_site, device_onboarding_pnp.DeviceOnboardingPnp.delete_device_by_id_from_pnp, - - - Paths used are - post /dna/intent/api/v1/onboarding/pnp-device - post /dna/intent/api/v1/onboarding/pnp-device/site-claim - post /dna/intent/api/v1/onboarding/pnp-device/{id} + 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 + - Paths used are post /dna/intent/api/v1/onboarding/pnp-device post + /dna/intent/api/v1/onboarding/pnp-device/site-claim 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 """ EXAMPLES = r""" -- name: Add a new device and claim the device +- name: Import multiple switches in bulk only + cisco.dnac.pnp_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_level: "{{dnac_log_level}}" + dnac_log: True + state: merged + config_verify: True + config: + - device_info: + - serial_number: QD2425L8M7 + state: Unclaimed + pid: c9300-24P + is_sudi_required: False + - serial_number: QTC2320E0H9 + state: Unclaimed + pid: c9300-24P + hostname: Test-123 + - serial_number: ETC2320E0HB + state: Unclaimed + pid: c9300-24P + +- name: Add a new EWLC and claim it + cisco.dnac.pnp_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_level: "{{dnac_log_level}}" + dnac_log: True + state: merged + config_verify: True + config: + - device_info: + - serial_number: FOX2639PAY7 + hostname: New_WLC + state: Unclaimed + pid: C9800-CL-K9 + site_name: Global/USA/San Francisco/BGL_18 + template_name: Ansible_PNP_WLC + template_params: + hostname: IAC-EWLC-Claimed + project_name: Onboarding Configuration + image_name: C9800-40-universalk9_wlc.17.12.01.SPA.bin + golden_image: true + pnp_type: CatalystWLC + static_ip: 204.192.101.10 + subnet_mask: 255.255.255.0 + gateway: 204.192.101.1 + vlan_id: 1101 + ip_interface_name: TenGigabitEthernet0/0/0 + +- name: Claim a pre-added switch, apply a template, and perform an image upgrade for a specific site cisco.dnac.pnp_intent: dnac_host: "{{dnac_host}}" dnac_username: "{{dnac_username}}" @@ -442,138 +250,48 @@ EXAMPLES = r""" dnac_port: "{{dnac_port}}" dnac_version: "{{dnac_version}}" dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" dnac_log: True state: merged + config_verify: True config: - template_name: string - image_name: string - site_name: string - deviceInfo: - aaaCredentials: - password: string - username: string - addedOn: 0 - addnMacAddrs: - - string - agentType: string - authStatus: string - authenticatedSudiSerialNo: string - capabilitiesSupported: - - string - cmState: string - description: string - deviceSudiSerialNos: - - string - deviceType: string - featuresSupported: - - string - fileSystemList: - - freespace: 0 - name: string - readable: true - size: 0 - type: string - writeable: true - firstContact: 0 - hostname: string - httpHeaders: - - key: string - value: string - imageFile: string - imageVersion: string - ipInterfaces: - - ipv4Address: {} - ipv6AddressList: - - {} - macAddress: string - name: string - status: string - lastContact: 0 - lastSyncTime: 0 - lastUpdateOn: 0 - location: - address: string - altitude: string - latitude: string - longitude: string - siteId: string - macAddress: string - mode: string - name: string - neighborLinks: - - localInterfaceName: string - localMacAddress: string - localShortInterfaceName: string - remoteDeviceName: string - remoteInterfaceName: string - remoteMacAddress: string - remotePlatform: string - remoteShortInterfaceName: string - remoteVersion: string - onbState: string - pid: string - pnpProfileList: - - createdBy: string - discoveryCreated: true - primaryEndpoint: - certificate: string - fqdn: string - ipv4Address: {} - ipv6Address: {} - port: 0 - protocol: string - profileName: string - secondaryEndpoint: - certificate: string - fqdn: string - ipv4Address: {} - ipv6Address: {} - port: 0 - protocol: string - populateInventory: true - preWorkflowCliOuputs: - - cli: string - cliOutput: string - projectId: string - projectName: string - reloadRequested: true - serialNumber: string - smartAccountId: string - source: string - stack: true - stackInfo: - isFullRing: true - stackMemberList: - - hardwareVersion: string - licenseLevel: string - licenseType: string - macAddress: string - pid: string - priority: 0 - role: string - serialNumber: string - softwareVersion: string - stackNumber: 0 - state: string - sudiSerialNumber: string - stackRingProtocol: string - supportsStackWorkflows: true - totalMemberCount: 0 - validLicenseLevels: string - state: string - sudiRequired: true - tags: {} - userSudiSerialNos: - - string - virtualAccountId: string - workflowId: string - workflowName: string + - device_info: + - serial_number: FJC271924EQ + hostname: Switch + state: Unclaimed + pid: C9300-48UXM + site_name: Global/USA/San Francisco/BGL_18 + template_name: "Ansible_PNP_Switch" + image_name: cat9k_iosxe_npe.17.03.07.SPA.bin + project_name: Onboarding Configuration + template_params: + hostname: SJC-Switch-1 + interface: TwoGigabitEthernet1/0/2 + +- name: Remove multiple devices from the PnP dashboard safely (ignores non-existent devices) + cisco.dnac.pnp_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_level: "{{dnac_log_level}}" + dnac_log: True + state: deleted + config_verify: True + config: + - device_info: + - serial_number: QD2425L8M7 + - serial_number: FTC2320E0HA + - serial_number: FKC2310E0HB """ RETURN = r""" #Case_1: When the device is claimed successfully. response_1: - description: A dictionary with the response returned by the Cisco DNAC Python SDK + description: A dictionary with the response returned by the Cisco Catalyst Center Python SDK returned: always type: dict sample: > @@ -588,7 +306,7 @@ response_1: #Case_2: Given site/image/template/project not found or Device is not found for deletion response_2: - description: A list with the response returned by the Cisco DNAC Python SDK + description: A list with the response returned by the Cisco Catalyst Center Python SDK returned: always type: list sample: > @@ -599,7 +317,7 @@ response_2: #Case_3: Error while deleting/claiming a device response_3: - description: A string with the response returned by the Cisco DNAC Python SDK + description: A string with the response returned by the Cisco Catalyst Center Python SDK returned: always type: dict sample: > @@ -608,347 +326,975 @@ response_3: "msg": String } """ - -import copy from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( - DNACSDK, + DnacBase, validate_list_of_dicts, - log, - get_dict_result, + get_dict_result ) -class DnacPnp: +class PnP(DnacBase): def __init__(self, module): - self.module = module - self.params = module.params - self.config = copy.deepcopy(module.params.get("config")) - self.have = [] - self.want = [] - self.diff = [] - self.validated = [] - dnac_params = self.get_dnac_params(self.params) - log(str(dnac_params)) - self.dnac = DNACSDK(params=dnac_params) - self.log = dnac_params.get("dnac_log") - - self.result = dict(changed=False, diff=[], response=[], warnings=[]) - - def get_state(self): - return self.params.get("state") + super().__init__(module) def validate_input(self): - pnp_spec = dict( - template_name=dict(required=True, type='str'), - project_name=dict(required=False, type='str', default="Onboarding Configuration"), - site_name=dict(required=True, type='str'), - image_name=dict(required=True, type='str'), - golden_image=dict(required=False, type='bool'), - deviceInfo=dict(required=True, type='dict'), - pnp_type=dict(required=False, type=str, default="Default") - ) - - if self.config: - msg = None - - # Validate template params - if self.log: - log(str(self.config)) - valid_pnp, invalid_params = validate_list_of_dicts( - self.config, pnp_spec - ) - - if invalid_params: - msg = "Invalid parameters in playbook: {0}".format( - "\n".join(invalid_params) - ) - self.module.fail_json(msg=msg) - - self.validated = valid_pnp - - if self.log: - log(str(valid_pnp)) - log(str(self.validated)) - - def get_dnac_params(self, params): - dnac_params = dict( - dnac_host=params.get("dnac_host"), - dnac_port=params.get("dnac_port"), - dnac_username=params.get("dnac_username"), - dnac_password=params.get("dnac_password"), - dnac_verify=params.get("dnac_verify"), - dnac_debug=params.get("dnac_debug"), - dnac_log=params.get("dnac_log") + """ + Validate the fields provided in the playbook. Checks the + configuration provided in the playbook against a predefined + specification to ensure it adheres to the expected structure + and data types. + + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the + 'config' parameter. + Example: + To use this method, create an instance of the class and call + 'validate_input' on it.If the validation succeeds, 'self.status' + will be 'success'and 'self.validated_config' will contain the + validated configuration. If it fails, 'self.status' will be + 'failed', and 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + pnp_spec = { + 'template_name': {'type': 'str', 'required': False}, + 'template_params': {'type': 'dict', 'required': False}, + 'project_name': {'type': 'str', 'required': False, + 'default': 'Onboarding Configuration'}, + 'site_name': {'type': 'str', 'required': False}, + 'image_name': {'type': 'str', 'required': False}, + 'golden_image': {'type': 'bool', 'required': False}, + 'device_info': {'type': 'list', 'required': True, + 'elements': 'dict'}, + 'pnp_type': {'type': 'str', 'required': False, 'default': 'Default'}, + "rf_profile": {'type': 'str', 'required': False}, + "static_ip": {'type': 'str', 'required': False}, + "subnet_mask": {'type': 'str', 'required': False}, + "gateway": {'type': 'str', 'required': False}, + "vlan_id": {'type': 'str', 'required': False}, + "ip_interface_name": {'type': 'str', 'required': False}, + "sensorProfile": {'type': 'str', 'required': False} + } + + # Validate pnp params + valid_pnp, invalid_params = validate_list_of_dicts( + self.config, pnp_spec ) - return dnac_params + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format( + "\n".join(invalid_params)) + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + self.validated_config = valid_pnp + self.msg = "Successfully validated playbook config params: {0}".format(str(valid_pnp)) + self.log(str(self.msg), "INFO") + self.status = "success" + + return self + + def get_site_details(self): + """ + Check whether the site exists or not, along with side id + + 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: + - site_exits: A boolean value indicating the existence of the site. + - site_id: The Id of the site i.e. required to claim device to site. + Example: + Post creation of the validated input, we this method gets the + site_id and checks whether the site exists or not + """ - def site_exists(self): site_exists = False site_id = None response = None + try: - response = self.dnac._exec( + response = self.dnac_apply['exec']( family="sites", function='get_site', params={"name": self.want.get("site_name")}, ) - except Exception as e: + except Exception: + self.log("Exception occurred as site \ + '{0}' was not found".format(self.want.get("site_name")), "CRITICAL") self.module.fail_json(msg="Site not found", response=[]) if response: - if self.log: - log(str(response)) - + self.log("Received site details \ + for '{0}': {1}".format(self.want.get("site_name"), str(response)), "DEBUG") site = response.get("response") - site_id = site[0].get("id") - site_exists = True + if len(site) == 1: + site_id = site[0].get("id") + site_exists = True + self.log("Site Name: {1}, Site ID: {0}".format(site_id, self.want.get("site_name")), "INFO") return (site_exists, site_id) + def get_site_type(self): + """ + Fetches the type of site + + 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: + - site_type: A string indicating the type of the + site (area/building/floor). + Example: + Post creation of the validated input, we this method gets the + type of the site. + """ + + try: + response = self.dnac_apply['exec']( + family="sites", + function='get_site', + params={"name": self.want.get("site_name")}, + ) + except Exception: + self.log("Exception occurred as \ + site '{0}' was not found".format(self.want.get("site_name")), "CRITICAL") + self.module.fail_json(msg="Site not found", response=[]) + + if response: + self.log("Received site details\ + for '{0}': {1}".format(self.want.get("site_name"), str(response)), "DEBUG") + 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") + self.log("Site type for site name '{1}' : {0}".format(site_type, self.want.get("site_name")), "INFO") + + return site_type + def get_pnp_params(self, params): - pnp_params = {} - pnp_params['_id'] = params.get('_id') - pnp_params['deviceInfo'] = params.get('deviceInfo') - pnp_params['runSummaryList'] = params.get('runSummaryList') - pnp_params['systemResetWorkflow'] = params.get('systemResetWorkflow') - pnp_params['systemWorkflow'] = params.get('systemWorkflow') - pnp_params['tenantId'] = params.get('tenantId') - pnp_params['version'] = params.get('device_version') - pnp_params['workflow'] = params.get('workflow') - pnp_params['workflowParameters'] = params.get('workflowParameters') - - return pnp_params + """ + Store pnp parameters from the playbook for pnp processing in Cisco Catalyst Center. + + Parameters: + - self: The instance of the class containing the 'config' + attribute to be validated. + - params: The validated params passed from the playbook. + Returns: + The method returns an instance of the class with updated attributes: + - pnp_params: A dictionary containing all the values indicating + the type of the site (area/building/floor). + Example: + Post creation of the validated input, it fetches the required paramters + 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: + device_dict = {} + param["serialNumber"] = param.pop("serial_number") + if "is_sudi_required" in param: + param["isSudiRequired"] = param.pop("is_sudi_required") + device_dict["deviceInfo"] = param + device_info_list.append(device_dict) + + self.log("PnP paramters passed are {0}".format(str(params_list)), "INFO") + return device_info_list def get_image_params(self, params): - image_params = dict( - image_name=params.get("image_name"), - is_tagged_golden=params.get("golden_image"), - ) - + """ + Get image name and the confirmation whether it's tagged golden or not + + Parameters: + - self: The instance of the class containing the 'config' attribute + to be validated. + - params: The validated params passed from the playbook. + Returns: + The method returns an instance of the class with updated attributes: + - image_params: A dictionary containing all the values indicating + name of the image and its golden image status. + Example: + Post creation of the validated input, it fetches the required + paramters and stores it for further processing and calling the + parameters in other APIs. + """ + + image_params = { + 'image_name': params.get('image_name'), + 'is_tagged_golden': params.get('golden_image') + } + + self.log("Image details are {0}".format(str(image_params)), "INFO") return image_params - def get_claim_params(self): - imageinfo = dict( - imageId=self.have.get("image_id") - ) - configinfo = dict( - configId=self.have.get("template_id"), - configParameters=[dict( - key="", - value="" - )] - ) - claim_params = dict( - deviceId=self.have.get("device_id"), - siteId=self.have.get("site_id"), - type=self.want.get("pnp_type"), - hostname=self.want.get("hostname"), - imageInfo=imageinfo, - configInfo=configinfo, - ) + def pnp_cred_failure(self, msg=None): + """ + Method for failing discovery if there is any discrepancy in the PnP credentials + passed by the user + """ + + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + def get_claim_params(self): + """ + Get the paramters needed for claiming the device to site. + 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: + - claim_params: A dictionary needed for calling the POST call + for claim a device to a site API. + Example: + The stored dictionary can be used to call the API claim a device + to a site via SDK + """ + + imageinfo = { + 'imageId': self.have.get('image_id') + } + template_params = self.validated_config[0].get("template_params") + configinfo = { + 'configId': self.have.get('template_id'), + 'configParameters': [ + { + 'key': '', + 'value': '' + } + ] + } + + if configinfo.get("configId") and template_params: + if isinstance(template_params, dict): + if len(template_params) > 0: + configinfo["configParameters"] = [] + for key, value in template_params.items(): + config_dict = { + 'key': key, + 'value': value + } + configinfo["configParameters"].append(config_dict) + + claim_params = { + 'deviceId': self.have.get('device_id'), + 'siteId': self.have.get('site_id'), + 'type': self.want.get('pnp_type'), + 'hostname': self.want.get('hostname'), + 'imageInfo': imageinfo, + 'configInfo': configinfo, + } + + if claim_params["type"] == "CatalystWLC": + if not (self.validated_config[0].get('static_ip')): + msg = "A static IP address is required to claim a wireless controller. Please provide one." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('subnet_mask')): + msg = "Please provide a subnet mask to claim a wireless controller. "\ + "This information is mandatory for the configuration." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('gateway')): + msg = "A gateway IP is required to claim a wireless controller. Please ensure to provide it." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('ip_interface_name')): + msg = "Please provide the Interface Name to claim a wireless controller. This information is necessary"\ + " for making it a logical interface post claiming which can used to help manage the Wireless SSIDs "\ + "broadcasted by the access points, manage the controller, access point and user data, plus more." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('vlan_id')): + msg = "Please provide the Vlan ID to claim a wireless controller. This is a required field for the process"\ + " to create and set the specified port as trunk during PnP." + self.pnp_cred_failure(msg=msg) + claim_params["staticIP"] = self.validated_config[0]['static_ip'] + claim_params["subnetMask"] = self.validated_config[0]['subnet_mask'] + claim_params["gateway"] = self.validated_config[0]['gateway'] + claim_params["vlanId"] = str(self.validated_config[0].get('vlan_id')) + claim_params["ipInterfaceName"] = self.validated_config[0]['ip_interface_name'] + + if claim_params["type"] == "AccessPoint": + if not (self.validated_config[0].get("rf_profile")): + msg = "The RF Profile for claiming an AP must be passed" + self.pnp_cred_failure(msg=msg) + claim_params["rfProfile"] = self.validated_config[0]["rf_profile"] + + self.log("Paramters used for claiming are {0}".format(str(claim_params)), "INFO") 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": "" + } + ] + } + + self.log("Paramters used for resetting from errored state:{0}".format(str(reset_params)), "INFO") + return reset_params + def get_have(self): + """ + Get the current image, template and site details from the Cisco Catalyst Center. + + 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: + - self.image_response: A list of image passed by the user + - self.template_list: A list of template under project + - self.device_response: Gets the device_id and stores it + Example: + Stored paramters are used to call the APIs to get the current image, + template and site details to call the API for various types of devices + """ have = {} - if self.params.get("state") == "merged": - # check if given image exists, if exists store image_id - image_response = self.dnac._exec( - family="software_image_management_swim", - function='get_software_image_details', - params=self.want.get("image_params"), + # Claiming is only allowed for single addition of devices + if len(self.want.get('pnp_params')) == 1: + # check if given device exists in pnp inventory, store device Id + device_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": self.want.get("serial_number")} ) + self.log("Device details for the device with serial \ + number '{0}': {1}".format(self.want.get("serial_number"), str(device_response)), "DEBUG") - if self.log: - log(str(image_response)) + if not (device_response and (len(device_response) == 1)): + self.log("Device with serial number {0} is not found in the inventory".format(self.want.get("serial_number")), "WARNING") + self.msg = "Adding the device to database" + self.status = "success" + self.have = have + have["device_found"] = False + return self + + have["device_found"] = True + have["device_id"] = device_response[0].get("id") + self.log("Device Id: " + str(have["device_id"])) + + if self.params.get("state") == "merged": + # check if given image exists, if exists store image_id + image_response = self.dnac_apply['exec']( + family="software_image_management_swim", + function='get_software_image_details', + params=self.want.get("image_params"), + ) + image_list = image_response.get("response") + self.log("Image details obtained from the API 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") + + # check if project has templates or not + template_list = self.dnac_apply['exec']( + family="configuration_templates", + function='gets_the_templates_available', + params={"project_names": self.want.get("project_name")}, + ) + self.log("List of templates under the project '{0}': {1}".format(self.want.get("project_name"), str(template_list)), "DEBUG") - image_list = image_response.get("response") + dev_details_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="get_device_by_id", + params={"id": device_response[0].get("id")} + ) + self.log("Device details retrieved after calling the 'get_device_by_id' API: {0}".format(str(dev_details_response)), "DEBUG") + install_mode = dev_details_response.get("deviceInfo").get("mode") + self.log("Installation mode of the device with the serial no. '{0}':{1}".format(self.want.get("serial_number"), install_mode), "INFO") + + # 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')[0].get('deviceInfo'): + self.msg = "The site name must be a string" + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + + site_name = self.want.get("site_name") + (site_exists, site_id) = self.get_site_details() + + if site_exists: + have["site_id"] = site_id + self.log("Site Exists: {0}\nSite Name: {1}\nSite ID: {2}".format(site_exists, site_name, site_id), "INFO") + if self.want.get("pnp_type") == "AccessPoint": + if self.get_site_type() != "floor": + self.msg = "Please ensure that the site type is specified as 'floor' when claiming an AP."\ + " The site type is given as '{0}'. Please change the 'site_type' into 'floor' to "\ + "proceed.".format(self.get_site_type()) + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + + if len(image_list) == 0: + self.msg = "The image '{0}' is either not present or not tagged as 'Golden' in the Cisco Catalyst Center."\ + " Please verify its existence and its tag status.".format(self.validated_config[0].get("image_name")) + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self + + if len(image_list) == 1: + if install_mode != "INSTALL": + self.msg = "The system must be in INSTALL mode to upgrade the image. The current mode is '{0}'."\ + " Please switch to INSTALL mode to proceed.".format(install_mode) + self.log(str(self.msg), "CRITICAL") + self.status = "failed" + return self + + have["image_id"] = image_list[0].get("imageUuid") + self.log("Image ID for the image '{0}': {1}".format(self.want.get('image_params').get('image_name'), str(have["image_id"])), "INFO") + + template_name = self.want.get("template_name") + if template_name: + if not (template_list and isinstance(template_list, list)): + self.msg = "Either project not found"\ + " or it is Empty." + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self + + template_details = get_dict_result(template_list, 'name', template_name) + if template_details: + have["template_id"] = template_details.get("templateId") + else: + self.msg = "Template '{0}' is not found.".format(template_name) + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self - if len(image_list) == 1: - have["image_id"] = image_list[0].get("imageUuid") - if self.log: - log("Image Id: " + str(have["image_id"])) - else: - self.module.fail_json(msg="Image not found", response=[]) + else: + if not self.want.get('pnp_params')[0].get('deviceInfo'): + self.msg = "Either Site Name or Device details must be added." + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + self.msg = "Successfully collected all project and template \ + parameters from Cisco Catalyst Center for comparison" + self.log(self.msg, "INFO") + self.status = "success" + self.have = have + return self + + def get_want(self, config): + """ + Get all the image, template and site and pnp related + information from playbook that is needed to be created in Cisco Catalyst Center. + + Parameters: + - self: The instance of the class containing the 'config' + attribute to be validated. + - config: validated config passed from the playbook + Returns: + The method returns an instance of the class with updated attributes: + - self.want: A dictionary of paramters obtained from the playbook. + - self.msg: A message indicating all the paramters from the playbook + are collected. + - self.status: Success. + Example: + It stores all the paramters passed from the playbook for further + processing before calling the APIs + """ + + self.want = { + 'image_params': self.get_image_params(config), + 'pnp_params': self.get_pnp_params(config), + 'pnp_type': config.get('pnp_type'), + 'site_name': config.get('site_name'), + 'project_name': config.get('project_name'), + 'template_name': config.get('template_name') + } + if len(self.want.get('pnp_params')) == 1: + self.want["serial_number"] = ( + self.want['pnp_params'][0]["deviceInfo"]. + get("serialNumber") + ) + self.want["hostname"] = ( + self.want['pnp_params'][0]["deviceInfo"]. + get("hostname") + ) - # check if given template exists, if exists store template id - template_list = self.dnac._exec( - family="configuration_templates", - function='gets_the_templates_available', - params={"project_names": self.want.get("project_name")}, + if self.want["pnp_type"] == "CatalystWLC": + self.want["static_ip"] = config.get('static_ip') + self.want["subnet_mask"] = config.get('subnet_mask') + self.want["gateway"] = config.get('gateway') + self.want["vlan_id"] = config.get('vlan_id') + self.want["ip_interface_name"] = config.get('ip_interface_name') + + elif self.want["pnp_type"] == "AccessPoint": + self.want["rf_profile"] = config.get("rf_profile") + self.msg = "Successfully collected all parameters from playbook " + \ + "for comparison" + self.log(self.msg, "INFO") + self.status = "success" + + return self + + def get_diff_merged(self): + """ + If given device doesnot exist + then add it to pnp database and get the device id + Args: + self: An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + object: An instance of the class with updated results and status + based on the processing of differences. Based on the length of devices passed + it adds/claims or does both. + Description: + The function processes the differences and, depending on the + changes required, it may add, update,or resynchronize devices in + Cisco Catalyst Center. The updated results and status are stored in the + class instance for further use. + """ + + if not isinstance(self.want.get("pnp_params"), list): + self.msg = "Device Info must be passed as a list" + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + 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']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": device["deviceInfo"]["serialNumber"]} + ) + self.log("Device details for serial number {0} \ + obtained from the API 'get_device_list': {1}".format(device["deviceInfo"]["serialNumber"], str(multi_device_response)), "DEBUG") + if (multi_device_response and (len(multi_device_response) == 1)): + devices_added.append(device) + self.log("Details of the added device:{0}".format(str(device)), "INFO") + if (len(self.want.get("pnp_params")) - len(devices_added)) == 0: + self.result['response'] = [] + self.result['msg'] = "Devices are already added" + self.log(self.result['msg'], "WARNING") + return self + + bulk_list = [ + device + for device in self.want.get("pnp_params") + if device not in devices_added + ] + bulk_params = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="import_devices_in_bulk", + params={"payload": bulk_list}, + op_modifies=True, ) + self.log("Response from API 'import_devices_in_bulk' for imported devices: {0}".format(bulk_params), "DEBUG") + if len(bulk_params.get("successList")) > 0: + self.result['msg'] = "{0} device(s) imported successfully".format( + len(bulk_params.get("successList"))) + self.log(self.result['msg'], "INFO") + self.result['response'] = bulk_params + self.result['diff'] = self.validated_config + self.result['changed'] = True + return self + + self.msg = "Bulk import failed" + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self + + provisioned_count_params = { + "serial_number": self.want.get("serial_number"), + "state": "Provisioned" + } + + planned_count_params = { + "serial_number": self.want.get("serial_number"), + "state": "Planned" + } - if self.log: - log(str(template_list)) + if not self.have.get("device_found"): + if not self.want['pnp_params']: + self.msg = "Device needs to be added before claiming. Please add device_info" + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + if not self.want["site_name"]: + self.log("Adding device to pnp database", "INFO") + dev_add_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="add_device", + params=self.want.get('pnp_params')[0], + op_modifies=True, + ) - if template_list and isinstance(template_list, list): - # API execution error returns a dict - template_details = get_dict_result(template_list, 'name', self.want.get("template_name")) - if template_details: - have["template_id"] = template_details.get("templateId") + self.have["deviceInfo"] = dev_add_response.get("deviceInfo") + self.log("Response from API 'add device' for a single device addition: {0}".format(str(dev_add_response)), "DEBUG") + if self.have["deviceInfo"]: + self.result['msg'] = "Only Device Added Successfully" + self.log(self.result['msg'], "INFO") + self.result['response'] = dev_add_response + self.result['diff'] = self.validated_config + self.result['changed'] = True - if self.log: - log("Template Id: " + str(have["template_id"])) else: - self.module.fail_json(msg="Template not found", response=[]) + self.msg = "Device Addition Failed" + self.log(self.result['msg'], "CRITICAL") + self.status = "failed" + + return self + else: - self.module.fail_json(msg="Project Not Found", response=[]) + self.log("Adding device to pnp database") + dev_add_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="add_device", + params=self.want.get("pnp_params")[0], + op_modifies=True, + ) + self.get_have().check_return_status() + self.have["deviceInfo"] = dev_add_response.get("deviceInfo") + self.log("Response from API 'add device' for single device addition: {0}".format(str(dev_add_response)), "DEBUG") + claim_params = self.get_claim_params() + claim_params["deviceId"] = dev_add_response.get("id") + claim_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='claim_a_device_to_a_site', + op_modifies=True, + params=claim_params, + ) - # check if given site exits, if exists store current site info - site_name = self.want.get("site_name") + self.log("Response from API 'claim a device to a site' for a single claiming: {0}".format(str(dev_add_response)), "DEBUG") + if claim_response.get("response") == "Device Claimed" and self.have["deviceInfo"]: + self.result['msg'] = "Device Added and Claimed Successfully" + self.log(self.result['msg'], "INFO") + self.result['response'] = claim_response + self.result['diff'] = self.validated_config + self.result['changed'] = True - site_exists = False - (site_exists, site_id) = self.site_exists() + else: + self.msg = "Device Claim Failed" + self.log(self.result['msg'], "CRITICAL") + self.status = "failed" - if site_exists: - have["site_id"] = site_id - if self.log: - log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) - log("Site Name:" + str(site_name)) + return self - # check if given device exists in pnp inventory, store device Id - device_response = self.dnac._exec( + prov_dev_response = self.dnac_apply['exec']( family="device_onboarding_pnp", - function='get_device_list', - params={"serial_number": self.want.get("serial_number")} + function='get_device_count', + op_modifies=True, + params=provisioned_count_params, ) + self.log("Response from 'get device count' API for provisioned devices: {0}".format(str(prov_dev_response)), "DEBUG") - if self.log: - log(str(device_response)) + plan_dev_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_count', + op_modifies=True, + params=planned_count_params, + ) + self.log("Response from 'get_device_count' API for devices in planned state: {0}".format(str(plan_dev_response)), "DEBUG") - if device_response and (len(device_response) == 1): - have["device_id"] = device_response[0].get("id") - have["device_found"] = True + dev_details_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="get_device_by_id", + params={"id": self.have["device_id"]} + ) + self.log("Response from 'get_device_by_id' API for device details: {0}".format(str(dev_details_response)), "DEBUG") - if self.log: - log("Device Id: " + str(have["device_id"])) - else: - have["device_found"] = False + is_stack = False + if dev_details_response.get("deviceInfo").get("stack"): + is_stack = dev_details_response.get("deviceInfo").get("stack") + pnp_state = dev_details_response.get("deviceInfo").get("state") + self.log("PnP state of the device: {0}".format(pnp_state), "INFO") - self.have = have + if not self.want["site_name"]: + self.result['response'] = self.have.get("device_found") + self.result['msg'] = "Device is already added" + self.log(self.result['msg'], "WARNING") + return self - def get_want(self): - for params in self.validated: - want = dict( - image_params=self.get_image_params(params), - pnp_params=self.get_pnp_params(params), - pnp_type=params.get("pnp_type"), - site_name=params.get("site_name"), - serial_number=params.get("deviceInfo").get("serialNumber"), - hostname=params.get("deviceInfo").get("hostname"), - project_name=params.get("project_name"), - template_name=params.get("template_name") - ) - - self.want = want + update_payload = {"deviceInfo": self.want.get('pnp_params')[0].get("deviceInfo")} + update_payload["deviceInfo"]["stack"] = is_stack - def get_diff_merge(self): + self.log("The request sent for 'update_device' API for device's config update: {0}".format(update_payload), "DEBUG") + 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("Response from 'update_device' API for device's config update: {0}".format(str(update_response)), "DEBUG") - # if given device doesnot exist then add it to pnp database and get the device id - if not self.have.get("device_found"): - log("Adding device to pnp database") - response = self.dnac._exec( + if pnp_state == "Error": + reset_paramters = self.get_reset_params() + reset_response = self.dnac_apply['exec']( family="device_onboarding_pnp", - function="add_device", - params=self.want.get("pnp_params"), + function="reset_device", + params={"payload": reset_paramters}, op_modifies=True, ) - self.have["device_id"] = response.get("id") + self.log("Response from 'update_device' API for errored state resolution: {0}".format(str(reset_response)), "DEBUG") + self.result['msg'] = "Device reset done Successfully" + self.log(self.result['msg'], "INFO") + self.result['response'] = reset_response + self.result['diff'] = self.validated_config + self.result['changed'] = True + + return self - if self.log: - log(str(response)) - log(self.have.get("device_id")) + 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" + self.log(self.result['msg'], "WARNING") + if update_response.get("deviceInfo"): + self.result['changed'] = True + return self claim_params = self.get_claim_params() - claim_response = self.dnac._exec( + self.log("Parameters for claiming the device: {0}".format(str(claim_params)), "DEBUG") + + claim_response = self.dnac_apply['exec']( family="device_onboarding_pnp", function='claim_a_device_to_a_site', op_modifies=True, params=claim_params, ) - - if self.log: - log(str(claim_response)) - + self.log("Response from 'claim_a_device_to_a_site' API for claiming: {0}".format(str(claim_response)), "DEBUG") if claim_response.get("response") == "Device Claimed": - self.result['changed'] = True - self.result['msg'] = "Device Claimed Successfully" + self.result['msg'] = "Only Device Claimed Successfully" + self.log(self.result['msg'], "INFO") self.result['response'] = claim_response - self.result['diff'] = self.validated - else: - self.module.fail_json(msg="Device Claim Failed", response=claim_response) + self.result['diff'] = self.validated_config + self.result['changed'] = True - def get_diff_delete(self): - if self.have.get("device_found"): + return self + + def get_diff_deleted(self): + """ + If the given device is added to pnp database + and is in unclaimed or failed state delete the + given device + Args: + self: An instance of a class used for interacting with Cisco Catalyst Center. + Here we pass a list of device info to be deleted + Returns: + self: An instance of the class with updated results and status based on + the deletion operation. It tells us the number of devices deleted if any of the devices + get deleted + Description: + This function is responsible for removing devices from the Cisco Catalyst Center PnP GUI and + pass new changes if devices are already deleted. + """ + devices_deleted = [] + devices_to_delete = self.want.get("pnp_params")[:] + for device in devices_to_delete: + multi_device_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": device["deviceInfo"]["serialNumber"]} + ) + self.log("Response from 'get_device_list' API for claiming: {0}".format(str(multi_device_response)), "DEBUG") + if multi_device_response and len(multi_device_response) == 1: + device_id = multi_device_response[0].get("id") - try: - response = self.dnac._exec( + response = self.dnac_apply['exec']( family="device_onboarding_pnp", function="delete_device_by_id_from_pnp", op_modifies=True, - params={"id": self.have.get("device_id")}, + params={"id": device_id}, ) - - if self.log: - log(str(response)) - - if response.get("deviceInfo").get("state") == "Deleted": - self.result['changed'] = True - self.result['response'] = response - self.result['diff'] = self.validated - self.result['msg'] = "Device Deleted Successfully" + self.log("Device details for the deleted device with \ + serial number '{0}': {1}".format(device["deviceInfo"]["serialNumber"], str(response)), "DEBUG") + if response.get("deviceInfo", {}).get("state") == "Deleted": + devices_deleted.append(device["deviceInfo"]["serialNumber"]) + self.want.get("pnp_params").remove(device) else: self.result['response'] = response self.result['msg'] = "Error while deleting the device" + self.log(self.result['msg'], "CRITICAL") - except Exception as errorstr: - response = str(errorstr) - msg = "Device Deletion Failed" - self.module.fail_json(msg=msg, response=response) - + if len(devices_deleted) > 0: + self.result['changed'] = True + self.result['response'] = devices_deleted + self.result['diff'] = self.want.get("pnp_params") + self.result['msg'] = "{0} Device(s) Deleted Successfully".format(len(devices_deleted)) + self.log(self.result['msg'], "INFO") else: - self.module.fail_json(msg="Device Not Found", response=[]) + self.result['msg'] = "Device(s) Not Found" + self.log(self.result['msg'], "WARNING") + self.result['response'] = devices_deleted + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of PnP configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center 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 Catalyst Center and" + " addition verified.".format(device["deviceInfo"]["serialNumber"])) + self.log(msg, "INFO") + + else: + msg = ( + "Requested Device with Serial No. {0} is " + "not present in Cisco Catalyst Center" + "Center".format(device["deviceInfo"]["serialNumber"])) + self.log(msg, "WARNING") + + self.status = "success" + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of PnP configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified device(s) exists in the Cisco Catalyst Center configuration's + PnP Database. + """ + + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center 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, "INFO") + + else: + msg = ( + "Requested Device with Serial No. {0} is " + "present in Cisco Catalyst Center".format(device["deviceInfo"]["serialNumber"])) + self.log(msg, "WARNING") + + self.status = "success" + return self def main(): - """ main entry point for module execution + """ + main entry point for module execution """ - element_spec = dict( - dnac_host=dict(required=True, type='str'), - dnac_port=dict(type='str', default='443'), - dnac_username=dict(type='str', default='admin', aliases=["user"]), - dnac_password=dict(type='str', no_log=True), - dnac_verify=dict(type='bool', default='True'), - dnac_version=dict(type="str", default="2.2.3.3"), - dnac_debug=dict(type='bool', default=False), - dnac_log=dict(type='bool', default=False), - validate_response_schema=dict(type="bool", default=True), - config=dict(required=True, type='list', elements='dict'), - state=dict( - default='merged', - choices=['merged', 'deleted'] - ) - ) + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) - dnac_pnp = DnacPnp(module) - dnac_pnp.validate_input() - state = dnac_pnp.get_state() - - dnac_pnp.get_want() - dnac_pnp.get_have() - - if state == "merged": - dnac_pnp.get_diff_merge() - - elif state == "deleted": - dnac_pnp.get_diff_delete() - - module.exit_json(**dnac_pnp.result) + ccc_pnp = PnP(module) + + state = ccc_pnp.params.get("state") + if state not in ccc_pnp.supported_states: + ccc_pnp.status = "invalid" + ccc_pnp.msg = "State {0} is invalid".format(state) + ccc_pnp.check_return_status() + + ccc_pnp.validate_input().check_return_status() + config_verify = ccc_pnp.params.get("config_verify") + + for config in ccc_pnp.validated_config: + ccc_pnp.reset_values() + ccc_pnp.get_want(config).check_return_status() + ccc_pnp.get_have().check_return_status() + ccc_pnp.get_diff_state_apply[state]().check_return_status() + if config_verify: + ccc_pnp.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_pnp.result) if __name__ == '__main__': diff --git a/ansible_collections/cisco/dnac/plugins/modules/pnp_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/pnp_workflow_manager.py new file mode 100644 index 000000000..e1b334f71 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/pnp_workflow_manager.py @@ -0,0 +1,1301 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Abinash Mishra, Madhan Sankaranarayanan, Rishita Chowdhary") + +DOCUMENTATION = r""" +--- +module: pnp_workflow_manager +short_description: Resource module for Site and PnP related functions +description: + - 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.workflow_manager_params +author: Abinash Mishra (@abimishr) Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after + applying the playbook config. + type: bool + default: false + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: + - merged + - deleted + default: merged + config: + description: + - List of details of device being managed. + type: list + elements: dict + required: true + suboptions: + device_info: + description: + - Provides the device-specific information required for adding devices + to the PnP database that are not already present. + - For adding a single device, the list should contain exactly one set + of device information. If a site name is also provided, the device + can be claimed immediately after being added. + - For bulk import, the list must contain information for more than one + device. Bulk import is intended solely for adding devices; claiming + must be performed with separate tasks or configurations. + type: list + required: true + elements: dict + suboptions: + hostname: + description: + - Defines the desired hostname for the PnP device after it has + been claimed. + - The hostname can only be assigned or changed during the claim + process, not during bulk or single device additions. + type: str + state: + description: + - Represents the onboarding state of the PnP device. + - Possible values are 'Unclaimed', 'Claimed', or 'Provisioned'. + type: str + pid: + description: Pnp Device's pid. + type: str + serial_number: + description: Pnp Device's serial_number. + type: str + is_sudi_required: + description: Sudi Authentication requiremnet's flag. + type: bool + site_name: + description: Name of the site for which device will be claimed. + type: str + project_name: + description: Name of the project under which the template is present + type: str + default: Onboarding Configuration + template_name: + description: + - Name of template to be configured on the device. + - Supported for EWLC from Cisco Catalyst Center release version + 2.3.7.x onwards. + type: str + template_params: + description: + - Parameter values for the parameterised templates. + - Each varibale has a value that needs to be passed as key-value pair + in the dictionary. We can pass values as + variable_name:variable_value. + - Supported for EWLC from Cisco Catalyst Center release version + 2.3.7.x onwards. + type: dict + image_name: + description: Name of image to be configured on the device + type: str + golden_image: + description: Is the image to be condifgured tagged as golden image + type: bool + pnp_type: + description: Specifies the device type for the Plug and Play (PnP) device. - + Options include 'Default', 'CatalystWLC', 'AccessPoint', or + 'StackSwitch'. - 'Default' is applicable to switches and routers. - + 'CatalystWLC' should be selected for 9800 series wireless controllers. + - 'AccessPoint' is used when claiming an access point. - 'StackSwitch' + should be chosen for a group of switches that operate as a single + switch, typically used in the access layer. + type: str + choices: + - Default + - CatalystWLC + - AccessPoint + - StackSwitch + default: Default + static_ip: + description: Management IP address of the Wireless Controller + type: str + subnet_mask: + description: Subnet Mask of the Management IP address of the Wireless Controller + type: str + gateway: + description: Gateway IP address of the Wireless Controller for getting pinged + type: str + vlan_id: + description: Vlan Id allocated for claimimg of Wireless Controller + type: str + ip_interface_name: + description: Specifies the interface name utilized for Plug and Play (PnP) by + the Wireless Controller. Ensure this interface is pre-configured on + the Controller prior to device claiming. + type: str + rf_profile: + description: + - Radio Frequecy (RF) profile of the AP being claimed. + - RF Profiles allow you to tune groups of APs that share a common + coverage zone together. + - They selectively change how Radio Resource Management will operate + the APs within that coverage zone. + - HIGH RF profile allows you to use more power and allows to join AP + with the client in an easier fashion. + - TYPICAL RF profile is a blend of moderate power and moderate + visibility to the client. + - LOW RF profile allows you to consume lesser power and has least + visibility to the client. + type: str + choices: + - HIGH + - LOW + - TYPICAL +requirements: + - dnacentersdk == 2.6.10 + - python >= 3.5 +notes: + - SDK Method used are device_onboarding_pnp.DeviceOnboardingPnp.add_device, + device_onboarding_pnp.DeviceOnboardingPnp.get_device_list, + device_onboarding_pnp.DeviceOnboardingPnp.claim_a_device_to_a_site, + 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 + - Paths used are post /dna/intent/api/v1/onboarding/pnp-device post + /dna/intent/api/v1/onboarding/pnp-device/site-claim 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 + +""" + +EXAMPLES = r""" +- name: Import multiple switches in bulk only + cisco.dnac.pnp_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + state: merged + config_verify: True + config: + - device_info: + - serial_number: QD2425L8M7 + state: Unclaimed + pid: c9300-24P + is_sudi_required: False + - serial_number: QTC2320E0H9 + state: Unclaimed + pid: c9300-24P + hostname: Test-123 + - serial_number: ETC2320E0HB + state: Unclaimed + pid: c9300-24P + +- name: Add a new EWLC and claim it + cisco.dnac.pnp_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + state: merged + config_verify: True + config: + - device_info: + - serial_number: FOX2639PAY7 + hostname: New_WLC + state: Unclaimed + pid: C9800-CL-K9 + site_name: Global/USA/San Francisco/BGL_18 + template_name: Ansible_PNP_WLC + template_params: + hostname: IAC-EWLC-Claimed + project_name: Onboarding Configuration + image_name: C9800-40-universalk9_wlc.17.12.01.SPA.bin + golden_image: true + pnp_type: CatalystWLC + static_ip: 204.192.101.10 + subnet_mask: 255.255.255.0 + gateway: 204.192.101.1 + vlan_id: 1101 + ip_interface_name: TenGigabitEthernet0/0/0 + +- name: Claim a pre-added switch, apply a template, and perform an image upgrade for a specific site + cisco.dnac.pnp_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + state: merged + config_verify: True + config: + - device_info: + - serial_number: FJC271924EQ + hostname: Switch + state: Unclaimed + pid: C9300-48UXM + site_name: Global/USA/San Francisco/BGL_18 + template_name: "Ansible_PNP_Switch" + image_name: cat9k_iosxe_npe.17.03.07.SPA.bin + project_name: Onboarding Configuration + template_params: + hostname: SJC-Switch-1 + interface: TwoGigabitEthernet1/0/2 + +- name: Remove multiple devices from the PnP dashboard safely (ignores non-existent devices) + cisco.dnac.pnp_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + state: deleted + config_verify: True + config: + - device_info: + - serial_number: QD2425L8M7 + - serial_number: FTC2320E0HA + - serial_number: FKC2310E0HB +""" + +RETURN = r""" +#Case_1: When the device is claimed successfully. +response_1: + description: A dictionary with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": + { + "response": String, + "version": String + }, + "msg": String + } + +#Case_2: Given site/image/template/project not found or Device is not found for deletion +response_2: + description: A list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } + +#Case_3: Error while deleting/claiming a device +response_3: + description: A string with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": String, + "msg": String + } +""" +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result +) + + +class PnP(DnacBase): + def __init__(self, module): + super().__init__(module) + + def validate_input(self): + """ + Validate the fields provided in the playbook. Checks the + configuration provided in the playbook against a predefined + specification to ensure it adheres to the expected structure + and data types. + + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the + 'config' parameter. + Example: + To use this method, create an instance of the class and call + 'validate_input' on it.If the validation succeeds, 'self.status' + will be 'success'and 'self.validated_config' will contain the + validated configuration. If it fails, 'self.status' will be + 'failed', and 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.msg = "config not available in playbook for validation" + self.status = "success" + return self + + pnp_spec = { + 'template_name': {'type': 'str', 'required': False}, + 'template_params': {'type': 'dict', 'required': False}, + 'project_name': {'type': 'str', 'required': False, + 'default': 'Onboarding Configuration'}, + 'site_name': {'type': 'str', 'required': False}, + 'image_name': {'type': 'str', 'required': False}, + 'golden_image': {'type': 'bool', 'required': False}, + 'device_info': {'type': 'list', 'required': True, + 'elements': 'dict'}, + 'pnp_type': {'type': 'str', 'required': False, 'default': 'Default'}, + "rf_profile": {'type': 'str', 'required': False}, + "static_ip": {'type': 'str', 'required': False}, + "subnet_mask": {'type': 'str', 'required': False}, + "gateway": {'type': 'str', 'required': False}, + "vlan_id": {'type': 'str', 'required': False}, + "ip_interface_name": {'type': 'str', 'required': False}, + "sensorProfile": {'type': 'str', 'required': False} + } + + # Validate pnp params + valid_pnp, invalid_params = validate_list_of_dicts( + self.config, pnp_spec + ) + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format( + "\n".join(invalid_params)) + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + self.validated_config = valid_pnp + self.msg = "Successfully validated playbook config params: {0}".format(str(valid_pnp)) + self.log(str(self.msg), "INFO") + self.status = "success" + + return self + + def get_site_details(self): + """ + Check whether the site exists or not, along with side id + + 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: + - site_exits: A boolean value indicating the existence of the site. + - site_id: The Id of the site i.e. required to claim device to site. + Example: + Post creation of the validated input, we this method gets the + site_id and checks whether the site exists or not + """ + + site_exists = False + site_id = None + response = None + + try: + response = self.dnac_apply['exec']( + family="sites", + function='get_site', + params={"name": self.want.get("site_name")}, + ) + except Exception: + self.log("Exception occurred as site \ + '{0}' was not found".format(self.want.get("site_name")), "CRITICAL") + self.module.fail_json(msg="Site not found", response=[]) + + if response: + self.log("Received site details \ + for '{0}': {1}".format(self.want.get("site_name"), str(response)), "DEBUG") + site = response.get("response") + if len(site) == 1: + site_id = site[0].get("id") + site_exists = True + self.log("Site Name: {1}, Site ID: {0}".format(site_id, self.want.get("site_name")), "INFO") + + return (site_exists, site_id) + + def get_site_type(self): + """ + Fetches the type of site + + 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: + - site_type: A string indicating the type of the + site (area/building/floor). + Example: + Post creation of the validated input, we this method gets the + type of the site. + """ + + try: + response = self.dnac_apply['exec']( + family="sites", + function='get_site', + params={"name": self.want.get("site_name")}, + ) + except Exception: + self.log("Exception occurred as \ + site '{0}' was not found".format(self.want.get("site_name")), "CRITICAL") + self.module.fail_json(msg="Site not found", response=[]) + + if response: + self.log("Received site details\ + for '{0}': {1}".format(self.want.get("site_name"), str(response)), "DEBUG") + 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") + self.log("Site type for site name '{1}' : {0}".format(site_type, self.want.get("site_name")), "INFO") + + return site_type + + def get_pnp_params(self, params): + """ + Store pnp parameters from the playbook for pnp processing in Cisco Catalyst Center. + + Parameters: + - self: The instance of the class containing the 'config' + attribute to be validated. + - params: The validated params passed from the playbook. + Returns: + The method returns an instance of the class with updated attributes: + - pnp_params: A dictionary containing all the values indicating + the type of the site (area/building/floor). + Example: + Post creation of the validated input, it fetches the required paramters + 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: + device_dict = {} + param["serialNumber"] = param.pop("serial_number") + if "is_sudi_required" in param: + param["isSudiRequired"] = param.pop("is_sudi_required") + device_dict["deviceInfo"] = param + device_info_list.append(device_dict) + + self.log("PnP paramters passed are {0}".format(str(params_list)), "INFO") + return device_info_list + + def get_image_params(self, params): + """ + Get image name and the confirmation whether it's tagged golden or not + + Parameters: + - self: The instance of the class containing the 'config' attribute + to be validated. + - params: The validated params passed from the playbook. + Returns: + The method returns an instance of the class with updated attributes: + - image_params: A dictionary containing all the values indicating + name of the image and its golden image status. + Example: + Post creation of the validated input, it fetches the required + paramters and stores it for further processing and calling the + parameters in other APIs. + """ + + image_params = { + 'image_name': params.get('image_name'), + 'is_tagged_golden': params.get('golden_image') + } + + self.log("Image details are {0}".format(str(image_params)), "INFO") + return image_params + + def pnp_cred_failure(self, msg=None): + """ + Method for failing discovery if there is any discrepancy in the PnP credentials + passed by the user + """ + + self.log(msg, "CRITICAL") + self.module.fail_json(msg=msg) + + def get_claim_params(self): + """ + Get the paramters needed for claiming the device to site. + 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: + - claim_params: A dictionary needed for calling the POST call + for claim a device to a site API. + Example: + The stored dictionary can be used to call the API claim a device + to a site via SDK + """ + + imageinfo = { + 'imageId': self.have.get('image_id') + } + template_params = self.validated_config[0].get("template_params") + configinfo = { + 'configId': self.have.get('template_id'), + 'configParameters': [ + { + 'key': '', + 'value': '' + } + ] + } + + if configinfo.get("configId") and template_params: + if isinstance(template_params, dict): + if len(template_params) > 0: + configinfo["configParameters"] = [] + for key, value in template_params.items(): + config_dict = { + 'key': key, + 'value': value + } + configinfo["configParameters"].append(config_dict) + + claim_params = { + 'deviceId': self.have.get('device_id'), + 'siteId': self.have.get('site_id'), + 'type': self.want.get('pnp_type'), + 'hostname': self.want.get('hostname'), + 'imageInfo': imageinfo, + 'configInfo': configinfo, + } + + if claim_params["type"] == "CatalystWLC": + if not (self.validated_config[0].get('static_ip')): + msg = "A static IP address is required to claim a wireless controller. Please provide one." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('subnet_mask')): + msg = "Please provide a subnet mask to claim a wireless controller. "\ + "This information is mandatory for the configuration." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('gateway')): + msg = "A gateway IP is required to claim a wireless controller. Please ensure to provide it." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('ip_interface_name')): + msg = "Please provide the Interface Name to claim a wireless controller. This information is necessary"\ + " for making it a logical interface post claiming which can used to help manage the Wireless SSIDs "\ + "broadcasted by the access points, manage the controller, access point and user data, plus more." + self.pnp_cred_failure(msg=msg) + if not (self.validated_config[0].get('vlan_id')): + msg = "Please provide the Vlan ID to claim a wireless controller. This is a required field for the process"\ + " to create and set the specified port as trunk during PnP." + self.pnp_cred_failure(msg=msg) + claim_params["staticIP"] = self.validated_config[0]['static_ip'] + claim_params["subnetMask"] = self.validated_config[0]['subnet_mask'] + claim_params["gateway"] = self.validated_config[0]['gateway'] + claim_params["vlanId"] = str(self.validated_config[0].get('vlan_id')) + claim_params["ipInterfaceName"] = self.validated_config[0]['ip_interface_name'] + + if claim_params["type"] == "AccessPoint": + if not (self.validated_config[0].get("rf_profile")): + msg = "The RF Profile for claiming an AP must be passed" + self.pnp_cred_failure(msg=msg) + claim_params["rfProfile"] = self.validated_config[0]["rf_profile"] + + self.log("Paramters used for claiming are {0}".format(str(claim_params)), "INFO") + 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": "" + } + ] + } + + self.log("Paramters used for resetting from errored state:{0}".format(str(reset_params)), "INFO") + return reset_params + + def get_have(self): + """ + Get the current image, template and site details from the Cisco Catalyst Center. + + 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: + - self.image_response: A list of image passed by the user + - self.template_list: A list of template under project + - self.device_response: Gets the device_id and stores it + Example: + Stored paramters are used to call the APIs to get the current image, + template and site details to call the API for various types of devices + """ + have = {} + + # Claiming is only allowed for single addition of devices + if len(self.want.get('pnp_params')) == 1: + # check if given device exists in pnp inventory, store device Id + device_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": self.want.get("serial_number")} + ) + self.log("Device details for the device with serial \ + number '{0}': {1}".format(self.want.get("serial_number"), str(device_response)), "DEBUG") + + if not (device_response and (len(device_response) == 1)): + self.log("Device with serial number {0} is not found in the inventory".format(self.want.get("serial_number")), "WARNING") + self.msg = "Adding the device to database" + self.status = "success" + self.have = have + have["device_found"] = False + return self + + have["device_found"] = True + have["device_id"] = device_response[0].get("id") + self.log("Device Id: " + str(have["device_id"])) + + if self.params.get("state") == "merged": + # check if given image exists, if exists store image_id + image_response = self.dnac_apply['exec']( + family="software_image_management_swim", + function='get_software_image_details', + params=self.want.get("image_params"), + ) + image_list = image_response.get("response") + self.log("Image details obtained from the API 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") + + # check if project has templates or not + template_list = self.dnac_apply['exec']( + family="configuration_templates", + function='gets_the_templates_available', + params={"project_names": self.want.get("project_name")}, + ) + self.log("List of templates under the project '{0}': {1}".format(self.want.get("project_name"), str(template_list)), "DEBUG") + + dev_details_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="get_device_by_id", + params={"id": device_response[0].get("id")} + ) + self.log("Device details retrieved after calling the 'get_device_by_id' API: {0}".format(str(dev_details_response)), "DEBUG") + install_mode = dev_details_response.get("deviceInfo").get("mode") + self.log("Installation mode of the device with the serial no. '{0}':{1}".format(self.want.get("serial_number"), install_mode), "INFO") + + # 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')[0].get('deviceInfo'): + self.msg = "The site name must be a string" + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + + site_name = self.want.get("site_name") + (site_exists, site_id) = self.get_site_details() + + if site_exists: + have["site_id"] = site_id + self.log("Site Exists: {0}\nSite Name: {1}\nSite ID: {2}".format(site_exists, site_name, site_id), "INFO") + if self.want.get("pnp_type") == "AccessPoint": + if self.get_site_type() != "floor": + self.msg = "Please ensure that the site type is specified as 'floor' when claiming an AP."\ + " The site type is given as '{0}'. Please change the 'site_type' into 'floor' to "\ + "proceed.".format(self.get_site_type()) + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + + if len(image_list) == 0: + self.msg = "The image '{0}' is either not present or not tagged as 'Golden' in the Cisco Catalyst Center."\ + " Please verify its existence and its tag status.".format(self.validated_config[0].get("image_name")) + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self + + if len(image_list) == 1: + if install_mode != "INSTALL": + self.msg = "The system must be in INSTALL mode to upgrade the image. The current mode is '{0}'."\ + " Please switch to INSTALL mode to proceed.".format(install_mode) + self.log(str(self.msg), "CRITICAL") + self.status = "failed" + return self + + have["image_id"] = image_list[0].get("imageUuid") + self.log("Image ID for the image '{0}': {1}".format(self.want.get('image_params').get('image_name'), str(have["image_id"])), "INFO") + + template_name = self.want.get("template_name") + if template_name: + if not (template_list and isinstance(template_list, list)): + self.msg = "Either project not found"\ + " or it is Empty." + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self + + template_details = get_dict_result(template_list, 'name', template_name) + if template_details: + have["template_id"] = template_details.get("templateId") + else: + self.msg = "Template '{0}' is not found.".format(template_name) + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self + + else: + if not self.want.get('pnp_params')[0].get('deviceInfo'): + self.msg = "Either Site Name or Device details must be added." + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + self.msg = "Successfully collected all project and template \ + parameters from Cisco Catalyst Center for comparison" + self.log(self.msg, "INFO") + self.status = "success" + self.have = have + return self + + def get_want(self, config): + """ + Get all the image, template and site and pnp related + information from playbook that is needed to be created in Cisco Catalyst Center. + + Parameters: + - self: The instance of the class containing the 'config' + attribute to be validated. + - config: validated config passed from the playbook + Returns: + The method returns an instance of the class with updated attributes: + - self.want: A dictionary of paramters obtained from the playbook. + - self.msg: A message indicating all the paramters from the playbook + are collected. + - self.status: Success. + Example: + It stores all the paramters passed from the playbook for further + processing before calling the APIs + """ + + self.want = { + 'image_params': self.get_image_params(config), + 'pnp_params': self.get_pnp_params(config), + 'pnp_type': config.get('pnp_type'), + 'site_name': config.get('site_name'), + 'project_name': config.get('project_name'), + 'template_name': config.get('template_name') + } + if len(self.want.get('pnp_params')) == 1: + self.want["serial_number"] = ( + self.want['pnp_params'][0]["deviceInfo"]. + get("serialNumber") + ) + self.want["hostname"] = ( + self.want['pnp_params'][0]["deviceInfo"]. + get("hostname") + ) + + if self.want["pnp_type"] == "CatalystWLC": + self.want["static_ip"] = config.get('static_ip') + self.want["subnet_mask"] = config.get('subnet_mask') + self.want["gateway"] = config.get('gateway') + self.want["vlan_id"] = config.get('vlan_id') + self.want["ip_interface_name"] = config.get('ip_interface_name') + + elif self.want["pnp_type"] == "AccessPoint": + self.want["rf_profile"] = config.get("rf_profile") + self.msg = "Successfully collected all parameters from playbook " + \ + "for comparison" + self.log(self.msg, "INFO") + self.status = "success" + + return self + + def get_diff_merged(self): + """ + If given device doesnot exist + then add it to pnp database and get the device id + Args: + self: An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + object: An instance of the class with updated results and status + based on the processing of differences. Based on the length of devices passed + it adds/claims or does both. + Description: + The function processes the differences and, depending on the + changes required, it may add, update,or resynchronize devices in + Cisco Catalyst Center. The updated results and status are stored in the + class instance for further use. + """ + + if not isinstance(self.want.get("pnp_params"), list): + self.msg = "Device Info must be passed as a list" + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + 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']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": device["deviceInfo"]["serialNumber"]} + ) + self.log("Device details for serial number {0} \ + obtained from the API 'get_device_list': {1}".format(device["deviceInfo"]["serialNumber"], str(multi_device_response)), "DEBUG") + if (multi_device_response and (len(multi_device_response) == 1)): + devices_added.append(device) + self.log("Details of the added device:{0}".format(str(device)), "INFO") + if (len(self.want.get("pnp_params")) - len(devices_added)) == 0: + self.result['response'] = [] + self.result['msg'] = "Devices are already added" + self.log(self.result['msg'], "WARNING") + return self + + bulk_list = [ + device + for device in self.want.get("pnp_params") + if device not in devices_added + ] + bulk_params = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="import_devices_in_bulk", + params={"payload": bulk_list}, + op_modifies=True, + ) + self.log("Response from API 'import_devices_in_bulk' for imported devices: {0}".format(bulk_params), "DEBUG") + if len(bulk_params.get("successList")) > 0: + self.result['msg'] = "{0} device(s) imported successfully".format( + len(bulk_params.get("successList"))) + self.log(self.result['msg'], "INFO") + self.result['response'] = bulk_params + self.result['diff'] = self.validated_config + self.result['changed'] = True + return self + + self.msg = "Bulk import failed" + self.log(self.msg, "CRITICAL") + self.status = "failed" + return self + + provisioned_count_params = { + "serial_number": self.want.get("serial_number"), + "state": "Provisioned" + } + + planned_count_params = { + "serial_number": self.want.get("serial_number"), + "state": "Planned" + } + + if not self.have.get("device_found"): + if not self.want['pnp_params']: + self.msg = "Device needs to be added before claiming. Please add device_info" + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + if not self.want["site_name"]: + self.log("Adding device to pnp database", "INFO") + dev_add_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="add_device", + params=self.want.get('pnp_params')[0], + op_modifies=True, + ) + + self.have["deviceInfo"] = dev_add_response.get("deviceInfo") + self.log("Response from API 'add device' for a single device addition: {0}".format(str(dev_add_response)), "DEBUG") + if self.have["deviceInfo"]: + self.result['msg'] = "Only Device Added Successfully" + self.log(self.result['msg'], "INFO") + self.result['response'] = dev_add_response + self.result['diff'] = self.validated_config + self.result['changed'] = True + + else: + self.msg = "Device Addition Failed" + self.log(self.result['msg'], "CRITICAL") + self.status = "failed" + + return self + + else: + self.log("Adding device to pnp database") + dev_add_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="add_device", + params=self.want.get("pnp_params")[0], + op_modifies=True, + ) + self.get_have().check_return_status() + self.have["deviceInfo"] = dev_add_response.get("deviceInfo") + self.log("Response from API 'add device' for single device addition: {0}".format(str(dev_add_response)), "DEBUG") + claim_params = self.get_claim_params() + claim_params["deviceId"] = dev_add_response.get("id") + 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("Response from API 'claim a device to a site' for a single claiming: {0}".format(str(dev_add_response)), "DEBUG") + if claim_response.get("response") == "Device Claimed" and self.have["deviceInfo"]: + self.result['msg'] = "Device Added and Claimed Successfully" + self.log(self.result['msg'], "INFO") + self.result['response'] = claim_response + self.result['diff'] = self.validated_config + self.result['changed'] = True + + else: + self.msg = "Device Claim Failed" + self.log(self.result['msg'], "CRITICAL") + self.status = "failed" + + return self + + prov_dev_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_count', + op_modifies=True, + params=provisioned_count_params, + ) + self.log("Response from 'get device count' API for provisioned devices: {0}".format(str(prov_dev_response)), "DEBUG") + + plan_dev_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_count', + op_modifies=True, + params=planned_count_params, + ) + self.log("Response from 'get_device_count' API for devices in planned state: {0}".format(str(plan_dev_response)), "DEBUG") + + dev_details_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="get_device_by_id", + params={"id": self.have["device_id"]} + ) + self.log("Response from 'get_device_by_id' API for device details: {0}".format(str(dev_details_response)), "DEBUG") + + is_stack = False + if dev_details_response.get("deviceInfo").get("stack"): + is_stack = dev_details_response.get("deviceInfo").get("stack") + pnp_state = dev_details_response.get("deviceInfo").get("state") + self.log("PnP state of the device: {0}".format(pnp_state), "INFO") + + if not self.want["site_name"]: + self.result['response'] = self.have.get("device_found") + self.result['msg'] = "Device is already added" + self.log(self.result['msg'], "WARNING") + return self + + update_payload = {"deviceInfo": self.want.get('pnp_params')[0].get("deviceInfo")} + update_payload["deviceInfo"]["stack"] = is_stack + + self.log("The request sent for 'update_device' API for device's config update: {0}".format(update_payload), "DEBUG") + 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("Response from 'update_device' API for device's config update: {0}".format(str(update_response)), "DEBUG") + + if pnp_state == "Error": + reset_paramters = self.get_reset_params() + reset_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="reset_device", + params={"payload": reset_paramters}, + op_modifies=True, + ) + self.log("Response from 'update_device' API for errored state resolution: {0}".format(str(reset_response)), "DEBUG") + self.result['msg'] = "Device reset done Successfully" + self.log(self.result['msg'], "INFO") + self.result['response'] = reset_response + self.result['diff'] = self.validated_config + self.result['changed'] = True + + return self + + 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" + self.log(self.result['msg'], "WARNING") + if update_response.get("deviceInfo"): + self.result['changed'] = True + return self + + claim_params = self.get_claim_params() + self.log("Parameters for claiming the device: {0}".format(str(claim_params)), "DEBUG") + + 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("Response from 'claim_a_device_to_a_site' API for claiming: {0}".format(str(claim_response)), "DEBUG") + if claim_response.get("response") == "Device Claimed": + self.result['msg'] = "Only Device Claimed Successfully" + self.log(self.result['msg'], "INFO") + self.result['response'] = claim_response + self.result['diff'] = self.validated_config + self.result['changed'] = True + + return self + + def get_diff_deleted(self): + """ + If the given device is added to pnp database + and is in unclaimed or failed state delete the + given device + Args: + self: An instance of a class used for interacting with Cisco Catalyst Center. + Here we pass a list of device info to be deleted + Returns: + self: An instance of the class with updated results and status based on + the deletion operation. It tells us the number of devices deleted if any of the devices + get deleted + Description: + This function is responsible for removing devices from the Cisco Catalyst Center PnP GUI and + pass new changes if devices are already deleted. + """ + devices_deleted = [] + devices_to_delete = self.want.get("pnp_params")[:] + for device in devices_to_delete: + multi_device_response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": device["deviceInfo"]["serialNumber"]} + ) + self.log("Response from 'get_device_list' API for claiming: {0}".format(str(multi_device_response)), "DEBUG") + if multi_device_response and len(multi_device_response) == 1: + device_id = multi_device_response[0].get("id") + + response = self.dnac_apply['exec']( + family="device_onboarding_pnp", + function="delete_device_by_id_from_pnp", + op_modifies=True, + params={"id": device_id}, + ) + self.log("Device details for the deleted device with \ + serial number '{0}': {1}".format(device["deviceInfo"]["serialNumber"], str(response)), "DEBUG") + if response.get("deviceInfo", {}).get("state") == "Deleted": + devices_deleted.append(device["deviceInfo"]["serialNumber"]) + self.want.get("pnp_params").remove(device) + else: + self.result['response'] = response + self.result['msg'] = "Error while deleting the device" + self.log(self.result['msg'], "CRITICAL") + + if len(devices_deleted) > 0: + self.result['changed'] = True + self.result['response'] = devices_deleted + self.result['diff'] = self.want.get("pnp_params") + self.result['msg'] = "{0} Device(s) Deleted Successfully".format(len(devices_deleted)) + self.log(self.result['msg'], "INFO") + else: + self.result['msg'] = "Device(s) Not Found" + self.log(self.result['msg'], "WARNING") + self.result['response'] = devices_deleted + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of PnP configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center 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 Catalyst Center and" + " addition verified.".format(device["deviceInfo"]["serialNumber"])) + self.log(msg, "INFO") + + else: + msg = ( + "Requested Device with Serial No. {0} is " + "not present in Cisco Catalyst Center" + "Center".format(device["deviceInfo"]["serialNumber"])) + self.log(msg, "WARNING") + + self.status = "success" + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of PnP configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified device(s) exists in the Cisco Catalyst Center configuration's + PnP Database. + """ + + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(config)), "INFO") + # Code to validate Cisco Catalyst Center 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, "INFO") + + else: + msg = ( + "Requested Device with Serial No. {0} is " + "present in Cisco Catalyst Center".format(device["deviceInfo"]["serialNumber"])) + self.log(msg, "WARNING") + + self.status = "success" + return self + + +def main(): + """ + main entry point for module execution + """ + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + ccc_pnp = PnP(module) + + state = ccc_pnp.params.get("state") + if state not in ccc_pnp.supported_states: + ccc_pnp.status = "invalid" + ccc_pnp.msg = "State {0} is invalid".format(state) + ccc_pnp.check_return_status() + + ccc_pnp.validate_input().check_return_status() + config_verify = ccc_pnp.params.get("config_verify") + + for config in ccc_pnp.validated_config: + ccc_pnp.reset_values() + ccc_pnp.get_want(config).check_return_status() + ccc_pnp.get_have().check_return_status() + ccc_pnp.get_diff_state_apply[state]().check_return_status() + if config_verify: + ccc_pnp.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_pnp.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/provision_intent.py b/ansible_collections/cisco/dnac/plugins/modules/provision_intent.py new file mode 100644 index 000000000..4a3c8a228 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/provision_intent.py @@ -0,0 +1,620 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Abinash Mishra") + +DOCUMENTATION = r""" +--- +module: provision_intent +short_description: Resource module for provision functions +description: +- Manage operation related to wired and wireless provisioning +- API to re-provision provisioned devices +- API to un-provision provisioned devices +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.intent_params +author: Abinash Mishra (@abimishr) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of DNAC after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of device being managed. + type: list + elements: dict + required: true + suboptions: + management_ip_address: + description: Management Ip Address . + type: str + required: true + site_name: + description: Name of site where the device needs to be added. + 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 controller + type: list + elements: dict + suboptions: + interface_ip_address: + description: Ip Address allocated to the interface + type: str + interface_netmask_in_c_i_d_r: + 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.4.5 +- python >= 3.5 +notes: + - SDK Methods used are + sites.Sites.get_site, + devices.Devices.get_network_device_by_ip, + task.Task.get_task_by_id, + sda.Sda.get_provisioned_wired_device, + sda.Sda.re_provision_wired_device, + sda.Sda.provision_wired_device, + wireless.Wireless.provision + + - Paths used are + get /dna/intent/api/v1/site + get /dna/intent/api/v1/network-device/ip-address/{ipAddress} + get /dna/intent/api/v1/task/{taskId} + get /dna/intent/api/v1/business/sda/provision-device + put /dna/intent/api/v1/business/sda/provision-device + post /dna/intent/api/v1/business/sda/provision-device + post /dna/intent/api/v1/wireless/provision + +""" + +EXAMPLES = r""" +- name: Create/Modify a new provision + cisco.dnac.provision_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: merged + config: + - site_name: string + management_ip_address: string + managed_ap_locations: list + dynamic_interfaces: + - vlan_id: integer + interface_name: string + interface_ip_address: string + interface_gateway: string + interface_netmask_in_c_i_d_r: integer + lag_or_port_number: integer + +""" + +RETURN = r""" +# Case_1: Successful creation/updation/deletion of provision +response_1: + description: A dictionary with details of provision is returned + returned: always + type: dict + sample: > + { + "response": + { + "response": String, + "version": String + }, + "msg": String + } + +# Case_2: Error while creating a provision +response_2: + description: A list with the response returned by the Cisco DNAC Python SDK + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } + +# Case_3: Already exists and requires no update +response_3: + description: A dictionary with the exisiting details as returned by the Cisco DNAC Python SDK + returned: always + type: dict + sample: > + { + "response": String, + "msg": String + } +""" +import time +import re +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts +) + + +class Dnacprovision(DnacBase): + + """ + Class containing member attributes for provision intent module + """ + def __init__(self, module): + super().__init__(module) + + def validate_input(self): + + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + Args: + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the + 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and + 'self.validated_config' will contain the validated configuration. If it fails, + 'self.status' will be 'failed', and 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.msg = "config not available in playbook for validattion" + self.status = "success" + return self + + provision_spec = { + "management_ip_address": {'type': 'str', 'required': True}, + "site_name": {'type': 'str', 'required': True}, + "managed_ap_locations": {'type': 'list', 'required': False, + 'elements': 'str'}, + "dynamic_interfaces": {'type': 'list', 'required': False, + 'elements': 'dict'} + } + # Validate provision params + valid_provision, invalid_params = validate_list_of_dicts( + self.config, provision_spec + ) + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format( + "\n".join(invalid_params)) + self.status = "failed" + return self + + self.validated_config = valid_provision + self.log(str(valid_provision)) + self.msg = "Successfully validated input" + self.status = "success" + return self + + def get_dev_type(self): + """ + Fetches the type of device (wired/wireless) + + 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: + - device_type: A string indicating the type of the + device (wired/wireless). + Example: + Post creation of the validated input, we this method gets the + type of the device. + """ + + dev_response = self.dnac_apply['exec']( + family="devices", + function='get_network_device_by_ip', + params={"ip_address": self.validated_config[0]["management_ip_address"]} + ) + + dev_dict = dev_response.get("response") + device_family = dev_dict["family"] + + if device_family == "Wireless Controller": + device_type = "wireless" + elif device_family in ["Switches and Hubs", "Routers"]: + device_type = "wired" + else: + device_type = None + return device_type + + def get_task_status(self, task_id=None): + """ + Fetches the status of the task once any provision API is called + + 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: + - result: A dict indiacting wheter the task was succesful or not + Example: + Post creation of the provision task, this method fetheches the task + status. + + """ + result = False + params = {"task_id": task_id} + while True: + response = self.dnac_apply['exec']( + family="task", + function='get_task_by_id', + params=params, + ) + response = response.response + if response.get('isError') or re.search( + 'failed', response.get('progress'), flags=re.IGNORECASE + ): + msg = 'Discovery task with id {0} has not completed - Reason: {1}'.format( + task_id, response.get("failureReason")) + self.module.fail_json(msg=msg) + return False + + if response.get('progress') != 'In Progress': + result = True + break + + time.sleep(3) + self.result.update(dict(discovery_task=response)) + return result + + def get_site_type(self, site_name=None): + """ + Fetches the type of site + + 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: + - site_type: A string indicating the type of the + site (area/building/floor). + Example: + Post creation of the validated input, we this method gets the + type of the site. + """ + + try: + response = self.dnac_apply['exec']( + family="sites", + function='get_site', + params={"name": site_name}, + ) + except Exception: + self.module.fail_json(msg="Site not found", response=[]) + + if response: + 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") + + return site_type + + def get_wired_params(self): + """ + Prepares the payload for provisioning of the wired devices + + 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: + - wired_params: A dictionary containing all the values indicating + management IP address of the device and the hierarchy + of the site. + Example: + Post creation of the validated input, it fetches the required + 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") + } + + return wired_params + + def get_wireless_params(self): + """ + Prepares the payload for provisioning of the wireless devices + + 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: + - wireless_params: A list of dictionary containing all the values indicating + management IP address of the device, hierarchy + of the site, AP Location of the wireless controller and details + of the interface + Example: + Post creation of the validated input, it fetches the required + paramters and stores it for further processing and calling the + parameters in other APIs. + """ + + wireless_params = [ + { + "site": self.validated_config[0].get("site_name"), + "managedAPLocations": self.validated_config[0].get("managed_ap_locations"), + } + ] + for ap_loc in wireless_params[0]["managedAPLocations"]: + if self.get_site_type(site_name=ap_loc) != "floor": + self.module.fail_json(msg="Managed AP Location must be a floor", response=[]) + + wireless_params[0]["dynamicInterfaces"] = [] + for interface in self.validated_config[0].get("dynamic_interfaces"): + interface_dict = { + "interfaceIPAddress": interface.get("interface_ip_address"), + "interfaceNetmaskInCIDR": interface.get("interface_netmask_in_c_i_d_r"), + "interfaceGateway": interface.get("interface_gateway"), + "lagOrPortNumber": interface.get("lag_or_port_number"), + "vlanId": interface.get("vlan_id"), + "interfaceName": interface.get("interface_name") + } + wireless_params[0]["dynamicInterfaces"].append(interface_dict) + response = self.dnac_apply['exec']( + family="devices", + function='get_network_device_by_ip', + params={"management_ip_address": self.validated_config[0]["management_ip_address"]} + ) + + wireless_params[0]["deviceName"] = response.get("response")[0].get("hostname") + return wireless_params + + def get_want(self): + """ + Get all provision related informantion from the playbook + Args: + self: The instance of the class containing the 'config' attribute to be validated. + config: validated config passed from the playbook + Returns: + The method returns an instance of the class with updated attributes: + - self.want: A dictionary of paramters obtained from the playbook + - self.msg: A message indicating all the paramters from the playbook are + collected + - self.status: Success + Example: + 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": + self.want["prov_params"] = self.get_wired_params() + elif self.want["device_type"] == "wireless": + self.want["prov_params"] = self.get_wireless_params() + else: + self.log("Passed devices are neither wired or wireless devices") + + self.msg = "Successfully collected all parameters from playbook " + \ + "for comparison" + self.status = "success" + return self + + def get_diff_merged(self): + """ + Add to provision database + Args: + self: An instance of a class used for interacting with Cisco DNA Center. + Returns: + object: An instance of the class with updated results and status + based on the processing of differences. + Description: + The function processes the differences and, depending on the + changes required, it may add, update,or resynchronize devices in + 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: + 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": + response = self.dnac_apply['exec']( + family="sda", + function="re_provision_wired_device", + op_modifies=True, + params=self.want["prov_params"], + ) + else: + response = self.dnac_apply['exec']( + family="sda", + function="provision_wired_device", + op_modifies=True, + params=self.want["prov_params"], + ) + + elif device_type == "wireless": + response = self.dnac_apply['exec']( + family="wireless", + function="provision", + op_modifies=True, + params=self.want["prov_params"], + ) + + 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) + self.result["changed"] = True + self.result['msg'] = "Provision done Successfully" + self.result['diff'] = self.validated_config + self.result['response'] = task_id + + return self + + def get_diff_deleted(self): + """ + Delete from provision database + Args: + self: An instance of a class used for interacting with Cisco DNA Center + Returns: + self: An instance of the class with updated results and status based on + the deletion operation. + Description: + This function is responsible for removing devices from the Cisco DNA Center PnP GUI and + raise Exception if any error occured. + """ + + 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 + + +def main(): + + """ + main entry point for module execution + """ + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + "config_verify": {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + dnac_provision = Dnacprovision(module) + + state = dnac_provision.params.get("state") + if state not in dnac_provision.supported_states: + dnac_provision.status = "invalid" + dnac_provision.msg = "State {0} is invalid".format(state) + dnac_provision.check_return_status() + + dnac_provision.validate_input().check_return_status() + + for config in dnac_provision.validated_config: + dnac_provision.reset_values() + dnac_provision.get_want().check_return_status() + dnac_provision.get_diff_state_apply[state]().check_return_status() + + module.exit_json(**dnac_provision.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/provision_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/provision_workflow_manager.py new file mode 100644 index 000000000..27ae58141 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/provision_workflow_manager.py @@ -0,0 +1,737 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Abinash Mishra, Madhan Sankaranarayanan") + +DOCUMENTATION = r""" +--- +module: provision_workflow_manager +short_description: Resource module for provision related functions +description: +- Manage operations related to wired and wireless provisioning +- API to re-provision provisioned devices +- API to un-provision provisioned devices +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Abinash Mishra (@abimishr) + Madhan Sankaranarayanan (@madhansansel) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of device being managed. + type: list + elements: dict + required: true + suboptions: + management_ip_address: + description: Management Ip Address . + type: str + required: true + site_name_hierarchy: + description: Name of site where the device needs to be added. + 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 controller + type: list + elements: dict + suboptions: + interface_ip_address: + description: Ip Address allocated to the interface + type: str + interface_netmask_in_c_i_d_r: + 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.4.5 +- python >= 3.5 +notes: + - SDK Methods used are + sites.Sites.get_site, + devices.Devices.get_network_device_by_ip, + task.Task.get_task_by_id, + sda.Sda.get_provisioned_wired_device, + sda.Sda.re_provision_wired_device, + sda.Sda.provision_wired_device, + wireless.Wireless.provision + + - Paths used are + get /dna/intent/api/v1/site + get /dna/intent/api/v1/network-device/ip-address/{ipAddress} + get /dna/intent/api/v1/task/{taskId} + get /dna/intent/api/v1/business/sda/provision-device + put /dna/intent/api/v1/business/sda/provision-device + post /dna/intent/api/v1/business/sda/provision-device + post /dna/intent/api/v1/wireless/provision + +""" + +EXAMPLES = r""" +- name: Create/Modify a new provision + cisco.dnac.provision_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + state: merged + config: + - site_name_hierarchy: string + management_ip_address: string + managed_ap_locations: list + dynamic_interfaces: + - vlan_id: integer + interface_name: string + interface_ip_address: string + interface_gateway: string + interface_netmask_in_c_i_d_r: integer + lag_or_port_number: integer + +""" + +RETURN = r""" +# Case_1: Successful creation/updation/deletion of provision +response_1: + description: A dictionary with details of provision is returned + returned: always + type: dict + sample: > + { + "response": + { + "response": String, + "version": String + }, + "msg": String + } + +# Case_2: Error while creating a provision +response_2: + description: A list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } + +# Case_3: Already exists and requires no update +response_3: + description: A dictionary with the exisiting details as returned by the Cisco Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": String, + "msg": String + } +""" +import time +import re +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts +) + + +class Provision(DnacBase): + + """ + Class containing member attributes for provision workflow module + """ + def __init__(self, module): + super().__init__(module) + + def validate_input(self, state=None): + + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + Args: + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the + 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and + 'self.validated_config' will contain the validated configuration. If it fails, + 'self.status' will be 'failed', and 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.msg = "config not available in playbook for validattion" + self.status = "success" + return self + + provision_spec = { + "management_ip_address": {'type': 'str', 'required': True}, + "site_name_hierarchy": {'type': 'str', 'required': False}, + "managed_ap_locations": {'type': 'list', 'required': False, + 'elements': 'str'}, + "dynamic_interfaces": {'type': 'list', 'required': False, + 'elements': 'dict'} + } + if state == "merged": + provision_spec["site_name_hierarchy"] = {'type': 'str', 'required': True} + + # Validate provision params + valid_provision, invalid_params = validate_list_of_dicts( + self.config, provision_spec + ) + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format( + "\n".join(invalid_params)) + self.log(str(self.msg), "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_provision + self.msg = "Successfully validated playbook configuration parameters using 'validate_input': {0}".format(str(valid_provision)) + self.log(str(self.msg), "INFO") + self.status = "success" + return self + + def get_dev_type(self): + """ + Fetches the type of device (wired/wireless) + + 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: + - device_type: A string indicating the type of the + device (wired/wireless). + Example: + Post creation of the validated input, we this method gets the + type of the device. + """ + + dev_response = self.dnac_apply['exec']( + family="devices", + function='get_network_device_by_ip', + params={"ip_address": self.validated_config[0]["management_ip_address"]} + ) + + self.log("The device response from 'get_network_device_by_ip' API is {0}".format(str(dev_response)), "DEBUG") + dev_dict = dev_response.get("response") + device_family = dev_dict["family"] + + if device_family == "Wireless Controller": + device_type = "wireless" + elif device_family in ["Switches and Hubs", "Routers"]: + device_type = "wired" + else: + device_type = None + self.log("The device type is {0}".format(device_type), "INFO") + return device_type + + def get_task_status(self, task_id=None): + """ + Fetches the status of the task once any provision API is called + + 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: + - result: A dict indiacting wheter the task was succesful or not + Example: + Post creation of the provision task, this method fetheches the task + status. + + """ + result = False + params = {"task_id": task_id} + while True: + response = self.dnac_apply['exec']( + family="task", + function='get_task_by_id', + params=params, + ) + self.log("Response collected from 'get_task_by_id' API is {0}".format(str(response)), "DEBUG") + response = response.response + self.log("Task status for the task id {0} is {1}".format(str(task_id), str(response)), "INFO") + if response.get('isError') or re.search( + 'failed', response.get('progress'), flags=re.IGNORECASE + ): + msg = 'Provision task with id {0} has not completed - Reason: {1}'.format( + task_id, response.get("failureReason")) + self.module.fail_json(msg=msg) + return False + + if response.get('progress') != 'In Progress': + result = True + break + + time.sleep(3) + self.result.update(dict(provision_task=response)) + return result + + def get_site_type(self, site_name_hierarchy=None): + """ + Fetches the type of site + + 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: + - site_type: A string indicating the type of the + site (area/building/floor). + Example: + Post creation of the validated input, we this method gets the + type of the site. + """ + + try: + response = self.dnac_apply['exec']( + family="sites", + function='get_site', + params={"name": site_name_hierarchy}, + ) + except Exception: + self.log("Exception occurred as \ + site '{0}' was not found".format(self.want.get("site_name")), "CRITICAL") + self.module.fail_json(msg="Site not found", response=[]) + + if response: + self.log("Received site details\ + for '{0}': {1}".format(site_name_hierarchy, str(response)), "DEBUG") + 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") + self.log("Site type for site name '{1}' : {0}".format(site_type, site_name_hierarchy), "INFO") + + return site_type + + def get_wired_params(self): + """ + Prepares the payload for provisioning of the wired devices + + 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: + - wired_params: A dictionary containing all the values indicating + management IP address of the device and the hierarchy + of the site. + Example: + Post creation of the validated input, it fetches the required + 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_hierarchy") + } + + self.log("Parameters collected for the provisioning of wired device:{0}".format(wired_params), "INFO") + return wired_params + + def get_wireless_params(self): + """ + Prepares the payload for provisioning of the wireless devices + + 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: + - wireless_params: A list of dictionary containing all the values indicating + management IP address of the device, hierarchy + of the site, AP Location of the wireless controller and details + of the interface + Example: + Post creation of the validated input, it fetches the required + paramters and stores it for further processing and calling the + parameters in other APIs. + """ + + wireless_params = [ + { + "site": self.validated_config[0].get("site_name_hierarchy"), + "managedAPLocations": self.validated_config[0].get("managed_ap_locations"), + } + ] + for ap_loc in wireless_params[0]["managedAPLocations"]: + if self.get_site_type(site_name_hierarchy=ap_loc) != "floor": + self.log("Managed AP Location must be a floor", "CRITICAL") + self.module.fail_json(msg="Managed AP Location must be a floor", response=[]) + + wireless_params[0]["dynamicInterfaces"] = [] + for interface in self.validated_config[0].get("dynamic_interfaces"): + interface_dict = { + "interfaceIPAddress": interface.get("interface_ip_address"), + "interfaceNetmaskInCIDR": interface.get("interface_netmask_in_c_i_d_r"), + "interfaceGateway": interface.get("interface_gateway"), + "lagOrPortNumber": interface.get("lag_or_port_number"), + "vlanId": interface.get("vlan_id"), + "interfaceName": interface.get("interface_name") + } + wireless_params[0]["dynamicInterfaces"].append(interface_dict) + response = self.dnac_apply['exec']( + family="devices", + function='get_network_device_by_ip', + params={"management_ip_address": self.validated_config[0]["management_ip_address"]} + ) + + self.log("Response collected from 'get_network_device_by_ip' is:{0}".format(str(response)), "DEBUG") + wireless_params[0]["deviceName"] = response.get("response")[0].get("hostname") + self.log("Parameters collected for the provisioning of wireless device:{0}".format(wireless_params), "INFO") + return wireless_params + + def get_want(self): + """ + Get all provision related informantion from the playbook + Args: + self: The instance of the class containing the 'config' attribute to be validated. + config: validated config passed from the playbook + Returns: + The method returns an instance of the class with updated attributes: + - self.want: A dictionary of paramters obtained from the playbook + - self.msg: A message indicating all the paramters from the playbook are + collected + - self.status: Success + Example: + 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": + self.want["prov_params"] = self.get_wired_params() + elif self.want["device_type"] == "wireless": + self.want["prov_params"] = self.get_wireless_params() + else: + self.log("Passed devices are neither wired or wireless devices", "WARNING") + + self.msg = "Successfully collected all parameters from playbook " + \ + "for comparison" + self.log(self.msg, "INFO") + self.status = "success" + return self + + def get_diff_merged(self): + """ + Add to provision database + Args: + self: An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + object: An instance of the class with updated results and status + based on the processing of differences. + Description: + The function processes the differences and, depending on the + changes required, it may add, update,or resynchronize devices in + Cisco Catalyst 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: + 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 = {} + self.log("Wired device's status Response collected from 'get_provisioned_wired_device' API is:{0}".format(str(status_response)), "DEBUG") + status = status_response.get("status") + self.log("The provisioned status of the wired device is {0}".format(status), "INFO") + + if status == "success": + response = self.dnac_apply['exec']( + family="sda", + function="re_provision_wired_device", + op_modifies=True, + params=self.want["prov_params"], + ) + self.log("Reprovisioning response collected from 're_provision_wired_device' API is: {0}".format(response), "DEBUG") + else: + response = self.dnac_apply['exec']( + family="sda", + function="provision_wired_device", + op_modifies=True, + params=self.want["prov_params"], + ) + self.log("Provisioning response collected from 'provision_wired_device' API is: {0}".format(response), "DEBUG") + + elif device_type == "wireless": + response = self.dnac_apply['exec']( + family="wireless", + function="provision", + op_modifies=True, + params=self.want["prov_params"], + ) + self.log("Wireless provisioning response collected from 'provision' API is: {0}".format(response), "DEBUG") + + else: + self.result['msg'] = "Passed device is neither wired nor wireless" + self.log(self.result['msg'], "ERROR") + self.result['response'] = self.want["prov_params"] + return self + + task_id = response.get("taskId") + provision_info = self.get_task_status(task_id=task_id) + self.result["changed"] = True + self.result['msg'] = "Provision done Successfully" + self.result['diff'] = self.validated_config + self.result['response'] = task_id + self.log(self.result['msg'], "INFO") + return self + + def get_diff_deleted(self): + """ + Delete from provision database + Args: + self: An instance of a class used for interacting with Cisco Catalyst Center + Returns: + self: An instance of the class with updated results and status based on + the deletion operation. + Description: + This function is responsible for removing devices from the Cisco Catalyst Center PnP GUI and + raise Exception if any error occured. + """ + + device_type = self.want.get("device_type") + + if device_type != "wired": + self.result['msg'] = "APIs are not supported for the device" + self.log(self.result['msg'], "CRITICAL") + 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 = {} + self.log("Wired device's status Response collected from 'get_provisioned_wired_device' API is:{0}".format(str(status_response)), "DEBUG") + status = status_response.get("status") + self.log("The provisioned status of the wired device is {0}".format(status), "INFO") + + if status != "success": + self.result['msg'] = "Device associated with the passed IP address is not provisioned" + self.log(self.result['msg'], "CRITICAL") + 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"] + }, + ) + self.log("Response collected from the 'delete_provisioned_wired_device' API is : {0}".format(str(response)), "DEBUG") + + 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 + self.log(self.result['msg'], "INFO") + return self + + def verify_diff_merged(self): + """ + Verify the merged status(Creation/Updation) of Discovery in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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 Inventory Database in the provisioned state. + """ + + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + # Code to validate Cisco Catalyst Center config for merged state + + device_type = self.want.get("device_type") + if device_type == "wired": + 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 = {} + self.log("Wired device's status Response collected from 'get_provisioned_wired_device' API is:{0}".format(str(status_response)), "DEBUG") + status = status_response.get("status") + self.log("The provisioned status of the wired device is {0}".format(status), "INFO") + + if status == "success": + self.log("Requested wired device is alread provisioned", "INFO") + + else: + self.log("Requested wired device is not provisioned", "INFO") + + else: + self.log("Currently we don't have any API in the Cisco Catalyst Center to fetch the provisioning details of wired devices") + self.status = "success" + + return self + + def verify_diff_deleted(self): + """ + Verify the deletion status of Discovery in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified discovery(s) exists in the Cisco Catalyst Center configuration's + Inventory Database in the provisioned state. + """ + + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + # Code to validate Cisco Catalyst Center config for merged state + + device_type = self.want.get("device_type") + if device_type == "wired": + 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 = {} + self.log("Wired device's status Response collected from 'get_provisioned_wired_device' API is:{0}".format(str(status_response)), "DEBUG") + status = status_response.get("status") + self.log("The provisioned status of the wired device is {0}".format(status), "INFO") + + if status == "success": + self.log("Requested wired device is in provisioned state and is not unprovisioned", "INFO") + + else: + self.log("Requested wired device is unprovisioned", "INFO") + + else: + self.log("Currently we don't have any API in the Cisco Catalyst Center to fetch the provisioning details of wired devices") + self.status = "success" + + return self + + +def main(): + + """ + main entry point for module execution + """ + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + "config_verify": {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + ccc_provision = Provision(module) + config_verify = ccc_provision.params.get("config_verify") + + state = ccc_provision.params.get("state") + if state not in ccc_provision.supported_states: + ccc_provision.status = "invalid" + ccc_provision.msg = "State {0} is invalid".format(state) + ccc_provision.check_return_status() + + ccc_provision.validate_input(state=state).check_return_status() + + for config in ccc_provision.validated_config: + ccc_provision.reset_values() + ccc_provision.get_want().check_return_status() + ccc_provision.get_diff_state_apply[state]().check_return_status() + if config_verify: + ccc_provision.verify_diff_state_apply[state]().check_return_status() + + module.exit_json(**ccc_provision.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/role_permissions_info.py b/ansible_collections/cisco/dnac/plugins/modules/role_permissions_info.py index 53d7a9b3a..3f520a697 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/role_permissions_info.py +++ b/ansible_collections/cisco/dnac/plugins/modules/role_permissions_info.py @@ -28,7 +28,7 @@ seealso: link: https://developer.cisco.com/docs/dna-center/#!get-permissions-api notes: - SDK Method used are - userand_roles.UserandRoles.get_permissions_ap_i, + user_and_roles.UserandRoles.get_permissions_ap_i, - Paths used are get /dna/system/api/v1/role/permissions, diff --git a/ansible_collections/cisco/dnac/plugins/modules/roles_info.py b/ansible_collections/cisco/dnac/plugins/modules/roles_info.py index 279f9ed10..3e719be1a 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/roles_info.py +++ b/ansible_collections/cisco/dnac/plugins/modules/roles_info.py @@ -28,7 +28,7 @@ seealso: link: https://developer.cisco.com/docs/dna-center/#!get-roles-api notes: - SDK Method used are - userand_roles.UserandRoles.get_roles_ap_i, + user_and_roles.UserandRoles.get_roles_ap_i, - Paths used are get /dna/system/api/v1/roles, diff --git a/ansible_collections/cisco/dnac/plugins/modules/site_intent.py b/ansible_collections/cisco/dnac/plugins/modules/site_intent.py index 1ce744693..751d520be 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/site_intent.py +++ b/ansible_collections/cisco/dnac/plugins/modules/site_intent.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary") +__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary, Abhishek Maheshwari") DOCUMENTATION = r""" --- @@ -23,77 +23,99 @@ extends_documentation_fragment: - cisco.dnac.intent_params author: Madhan Sankaranarayanan (@madhansansel) Rishita Chowdhary (@rishitachowdhary) + Abhishek Maheshwari (@abhishekmaheshwari) options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False state: - description: The state of DNAC after module completion. + description: The state of Catalyst Center after module completion. type: str choices: [ merged, deleted ] default: merged config: - description: - - List of details of site being managed. + description: It represents a list of details for creating/managing/deleting sites, including areas, buildings, and floors. type: list elements: dict - required: true + required: True suboptions: - type: + site_type: description: Type of site to create/update/delete (eg area, building, floor). type: str site: - description: Site Details. + description: Contains details about the site being managed including areas, buildings and floors. type: dict suboptions: area: - description: Site Create's area. + description: Configuration details for creating or managing an area within a site. type: dict suboptions: name: - description: Name of the area (eg Area1). + description: Name of the area to be created or managed (e.g., "Area1"). type: str - parentName: - description: Parent name of the area to be created. + parent_name: + description: The full name of the parent under which the area will be created/managed/deleted (e.g., "Global/USA"). type: str building: - description: Building Details. + description: Configuration details required for creating or managing a building within a site. type: dict suboptions: address: - description: Address of the building to be created. + description: Physical address of the building that is to be created or managed. type: str latitude: - description: Latitude coordinate of the building (eg 37.338). - type: int + description: Geographical latitude coordinate of the building. For example, use 37.338 for a location in San Jose, California. + Valid values range from -90.0 to +90.0 degrees. + type: float longitude: - description: Longitude coordinate of the building (eg -121.832). - type: int + description: Geographical longitude coordinate of the building. For example, use -121.832 for a location in San Jose, California. + Valid values range from -180.0 to +180.0 degrees. + type: float name: - description: Name of the building (eg building1). + description: Name of the building (e.g., "Building1"). type: str - parentName: - description: Parent name of building to be created. + parent_name: + description: Hierarchical parent path of the building, indicating its location within the site (e.g., "Global/USA/San Francisco"). type: str floor: - description: Site Create's floor. + description: Configuration details required for creating or managing a floor within a site. type: dict suboptions: height: - description: Height of the floor (eg 15). - type: int + description: Height of the floor in feet (e.g., 15.23). + type: float length: - description: Length of the floor (eg 100). - type: int + description: Length of the floor in feet (e.g., 100.11). + type: float name: - description: Name of the floor (eg floor-1). + description: Name of the floor (e.g., "Floor-1"). type: str - parentName: - description: Parent name of the floor to be created. + parent_name: + description: Hierarchical parent path of the floor, indicating its location within the site (e.g., + "Global/USA/San Francisco/BGL_18"). type: str - rfModel: - description: Type of floor. Allowed values are 'Cubes And Walled Offices', - 'Drywall Office Only', 'Indoor High Ceiling', 'Outdoor Open Space'. + rf_model: + description: The RF (Radio Frequency) model type for the floor, which is essential for simulating and optimizing wireless + network coverage. Select from the following allowed values, which describe different environmental signal propagation + characteristics. + Type of floor (allowed values are 'Cubes And Walled Offices', 'Drywall Office Only', 'Indoor High Ceiling', + 'Outdoor Open Space'). + Cubes And Walled Offices - This RF model typically represents indoor areas with cubicles or walled offices, where + radio signals may experience attenuation due to walls and obstacles. + Drywall Office Only - This RF model indicates an environment with drywall partitions, commonly found in office spaces, + which may have moderate signal attenuation. + Indoor High Ceiling - This RF model is suitable for indoor spaces with high ceilings, such as auditoriums or atriums, + where signal propagation may differ due to the height of the ceiling. + Outdoor Open Space - This RF model is used for outdoor areas with open spaces, where signal propagation is less obstructed + and may follow different patterns compared to indoor environments. type: str width: - description: Width of the floor (eg 100). + description: Width of the floor in feet (e.g., 100.22). + type: float + floor_number: + description: Floor number within the building site (e.g., 5). This value can only be specified during the creation of the + floor and cannot be modified afterward. type: int requirements: @@ -112,6 +134,25 @@ notes: """ EXAMPLES = r""" +- name: Create a new area site + cisco.dnac.site_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_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: merged + config: + - site: + area: + name: Test + parent_name: Global/India + site_type: area + - name: Create a new building site cisco.dnac.site_intent: dnac_host: "{{dnac_host}}" @@ -121,22 +162,89 @@ EXAMPLES = r""" dnac_port: "{{dnac_port}}" dnac_version: "{{dnac_version}}" dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" dnac_log: "{{dnac_log}}" + state: merged config: - site: - building: - address: string - latitude: 0 - longitude: 0 - name: string - parentName: string - type: string + - site: + building: + name: Building_1 + parent_name: Global/India + address: Bengaluru, Karnataka, India + latitude: 24.12 + longitude: 23.45 + site_type: building + +- name: Create a Floor site under the building + cisco.dnac.site_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_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: merged + config: + - site: + floor: + name: Floor_1 + parent_name: Global/India/Building_1 + length: 75.76 + width: 35.54 + height: 30.12 + rf_model: Cubes And Walled Offices + floor_number: 2 + site_type: floor + +- name: Updating the Floor details under the building + cisco.dnac.site_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_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: merged + config: + - site: + floor: + name: Floor_1 + parent_name: Global/India/Building_1 + length: 75.76 + width: 35.54 + height: 30.12 + site_type: floor + +- name: Deleting any site you need site name and parent name + cisco.dnac.site_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_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: deleted + config: + - site: + floor: + name: Floor_1 + parent_name: Global/India/Building_1 + site_type: floor """ RETURN = r""" #Case_1: Site is successfully created/updated/deleted response_1: - description: A dictionary with API execution details as returned by the Cisco DNAC Python SDK + description: A dictionary with API execution details as returned by the Cisco Catalyst Center Python SDK returned: always type: dict sample: > @@ -177,7 +285,7 @@ response_2: #Case_3: Error while creating/updating/deleting site response_3: - description: A dictionary with API execution details as returned by the Cisco DNAC Python SDK + description: A dictionary with API execution details as returned by the Cisco Catalyst Center Python SDK returned: always type: dict sample: > @@ -202,7 +310,7 @@ response_3: #Case_4: Site not found when atempting to delete site response_4: - description: A list with the response returned by the Cisco DNAC Python + description: A list with the response returned by the Cisco Catalyst Center Python returned: always type: list sample: > @@ -214,81 +322,95 @@ response_4: from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( - DNACSDK, + DnacBase, validate_list_of_dicts, - log, get_dict_result, - dnac_compare_equality, ) -import copy floor_plan = { - '57057': 'CUBES AND WALLED OFFICES', - '57058': 'DRYWELL OFFICE ONLY', - '41541500': 'FREE SPACE', - '57060': 'INDOOR HIGH CEILING', - '57059': 'OUTDOOR OPEN SPACE' + '101101': 'Cubes And Walled Offices', + '101102': 'Drywall Office Only', + '101105': 'Free Space', + '101104': 'Indoor High Ceiling', + '101103': 'Outdoor Open Space' } -class DnacSite: +class DnacSite(DnacBase): + """Class containing member attributes for site intent module""" def __init__(self, module): - self.module = module - self.params = module.params - self.config = copy.deepcopy(module.params.get("config")) - self.have = {} - self.want_create = {} - self.diff_create = [] - self.validated = [] - dnac_params = self.get_dnac_params(self.params) - log(str(dnac_params)) - self.dnac = DNACSDK(params=dnac_params) - self.log = dnac_params.get("dnac_log") - - self.result = dict(changed=False, diff=[], response=[], warnings=[]) - - def get_state(self): - return self.params.get("state") + super().__init__(module) + self.supported_states = ["merged", "deleted"] def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + The method returns an instance of the class with updated attributes: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', and + 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.status = "success" + self.msg = "Configuration is not available in the playbook for validation" + self.log(self.msg, "ERROR") + return self + temp_spec = dict( type=dict(required=False, type='str'), site=dict(required=True, type='dict'), ) + self.config = self.camel_to_snake_case(self.config) + self.config = self.update_site_type_key(self.config) + + # Validate site params + valid_temp, invalid_params = validate_list_of_dicts( + self.config, temp_spec + ) - if self.config: - msg = None - # Validate site params - 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) ) + self.log(self.msg, "ERROR") + self.status = "failed" + return self - if invalid_params: - msg = "Invalid parameters in playbook: {0}".format( - "\n".join(invalid_params) - ) - self.module.fail_json(msg=msg) - - self.validated = valid_temp - - if self.log: - log(str(valid_temp)) - log(str(self.validated)) - - def get_dnac_params(self, params): - dnac_params = dict( - dnac_host=params.get("dnac_host"), - dnac_port=params.get("dnac_port"), - dnac_username=params.get("dnac_username"), - dnac_password=params.get("dnac_password"), - dnac_verify=params.get("dnac_verify"), - dnac_debug=params.get("dnac_debug"), - dnac_log=params.get("dnac_log") - ) - return dnac_params + self.validated_config = valid_temp + self.msg = "Successfully validated playbook config params: {0}".format(str(valid_temp)) + self.log(self.msg, "INFO") + self.status = "success" + + return self def get_current_site(self, site): + """ + Get the current site information. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - site (list): A list containing information about the site. + Returns: + - dict: A dictionary containing the extracted site information. + Description: + This method extracts information about the current site based on + the provided 'site' list. It determines the type of the site + (area, building, or floor) and retrieves specific details + accordingly. The resulting dictionary includes the type, site + details, and the site ID. + """ + site_info = {} location = get_dict_result(site[0].get("additionalInfo"), 'nameSpace', "Location") @@ -307,9 +429,10 @@ class DnacSite: building=dict( name=site[0].get("name"), parentName=site[0].get("siteNameHierarchy").split("/" + site[0].get("name"))[0], - address=location.get("attributes").get("address", ""), + address=location.get("attributes").get("address"), latitude=location.get("attributes").get("latitude"), longitude=location.get("attributes").get("longitude"), + country=location.get("attributes").get("country"), ) ) @@ -322,10 +445,11 @@ class DnacSite: floor=dict( name=site[0].get("name"), parentName=site[0].get("siteNameHierarchy").split("/" + site[0].get("name"))[0], - rfModel=floor_plan.get(rf_model), + rf_model=floor_plan.get(rf_model), width=map_geometry.get("attributes").get("width"), length=map_geometry.get("attributes").get("length"), - height=map_geometry.get("attributes").get("height") + height=map_geometry.get("attributes").get("height"), + floorNumber=map_geometry.get("attributes").get("floor_number", "") ) ) @@ -335,12 +459,29 @@ class DnacSite: siteId=site[0].get("id") ) - if self.log: - log(str(current_site)) + self.log("Current site details: {0}".format(str(current_site)), "INFO") return current_site def site_exists(self): + """ + Check if the site exists in Cisco Catalyst Center. + + Parameters: + - self (object): An instance of the class containing the method. + Returns: + - tuple: A tuple containing a boolean indicating whether the site exists and + a dictionary containing information about the existing site. + The returned tuple includes two elements: + - site_exists (bool): Indicates whether the site exists. + - dict: Contains information about the existing site. If the + site doesn't exist, this dictionary is empty. + Description: + Checks the existence of a site in Cisco Catalyst Center by querying the + 'get_site' function in the 'sites' family. It utilizes the + 'site_name' parameter from the 'want' attribute to identify the site. + """ + site_exists = False current_site = {} response = None @@ -352,79 +493,230 @@ class DnacSite: ) except Exception as e: - if self.log: - log("The input site is not valid or site is not present.") - + self.log("The provided site name '{0}' is either invalid or not present in the Cisco Catalyst Center." + .format(self.want.get("site_name")), "WARNING") if response: - if self.log: - log(str(response)) - response = response.get("response") + self.log("Received API response from 'get_site': {0}".format(str(response)), "DEBUG") current_site = self.get_current_site(response) site_exists = True - - if self.log: - log(str(self.validated)) + self.log("Site '{0}' exists in Cisco Catalyst Center".format(self.want.get("site_name")), "INFO") return (site_exists, current_site) def get_site_params(self, params): - site = params.get("site") + """ + Store the site-related parameters. + + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - params (dict): Dictionary containing site-related parameters. + Returns: + - dict: Dictionary containing the stored site-related parameters. + The returned dictionary includes the following keys: + - 'type' (str): The type of the site. + - 'site' (dict): Dictionary containing site-related info. + Description: + This method takes a dictionary 'params' containing site-related + information and stores the relevant parameters based on the site + type. If the site type is 'floor', it ensures that the 'rfModel' + parameter is stored in uppercase. + """ typeinfo = params.get("type") + site_info = {} - if typeinfo == "floor": - site["floor"]["rfModel"] = site.get("floor").get("rfModel").upper() + if typeinfo == 'area': + area_details = params.get('site').get('area') + site_info['area'] = { + 'name': area_details.get('name'), + 'parentName': area_details.get('parent_name') + } + elif typeinfo == 'building': + building_details = params.get('site').get('building') + site_info['building'] = { + 'name': building_details.get('name'), + 'address': building_details.get('address'), + 'parentName': building_details.get('parent_name'), + 'latitude': building_details.get('latitude'), + 'longitude': building_details.get('longitude'), + 'country': building_details.get('country') + } + else: + floor_details = params.get('site').get('floor') + site_info['floor'] = { + 'name': floor_details.get('name'), + 'parentName': floor_details.get('parent_name'), + 'length': floor_details.get('length'), + 'width': floor_details.get('width'), + 'height': floor_details.get('height'), + 'floorNumber': floor_details.get('floor_number', '') + } + try: + site_info["floor"]["rfModel"] = floor_details.get("rf_model") + except Exception as e: + self.log("The attribute 'rf_model' is missing in floor '{0}'.".format(floor_details.get('name')), "WARNING") site_params = dict( type=typeinfo, - site=site, + site=site_info, ) + self.log("Site parameters: {0}".format(str(site_params)), "DEBUG") return site_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 Catalyst Center. + - site (dict): A dictionary containing information about the site. + Returns: + - str: The constructed site name. + Description: + This method takes a dictionary 'site' containing information about + the site and constructs the site name by combining the parent name + and site name. + """ + site_type = site.get("type") - parent_name = site.get("site").get(site_type).get("parentName") + parent_name = site.get("site").get(site_type).get("parent_name") name = site.get("site").get(site_type).get("name") site_name = '/'.join([parent_name, name]) - - if self.log: - log(site_name) + self.log("Site name: {0}".format(site_name), "INFO") return site_name - def site_requires_update(self): - requested_site = self.want.get("site_params") - current_site = self.have.get("current_site") + 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 Catalyst 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 Catalyst 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 parent name) have been updated, False otherwise. + Description: + This method compares the area details (name and parent name) 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'] + ) - if self.log: - log("Current Site: " + str(current_site)) - log("Requested Site: " + str(requested_site)) + 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 Catalyst 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, parent_name, 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 + ('address' in requested_site and (requested_site['address'] is None or updated_site.get('address') == requested_site['address'])) + ) - obj_params = [ - ("type", "type"), - ("site", "site") - ] + def is_floor_updated(self, updated_site, requested_site): + """ + Check if the floor details in a site have been updated. - return any(not dnac_compare_equality(current_site.get(dnac_param), - requested_site.get(ansible_param)) - for (dnac_param, ansible_param) in obj_params) + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst 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. + """ - def get_execution_details(self, execid): - response = None - response = self.dnac._exec( - family="task", - function='get_business_api_execution_details', - params={"execution_id": execid} - ) + keys_to_compare = ['length', 'width', 'height'] + if updated_site['name'] != requested_site['name'] or updated_site['rf_model'] != requested_site['rfModel']: + return False - if self.log: - log(str(response)) + for key in keys_to_compare: + if not self.compare_float_values(updated_site[key], requested_site[key]): + return False - if response and isinstance(response, dict): - return response + 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 Catalyst Center. + Returns: + bool: True if the site requires updates, False otherwise. + Description: + This method compares the site parameters of the current site + ('current_site') and the requested site parameters ('requested_site') + stored in the 'want' attribute. It checks for differences in + specified parameters, such as the site type and site details. + """ + + 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 type: {0}".format(str(updated_site)), "INFO") + self.log("Requested Site type: {0}".format(str(requested_site)), "INFO") + + if type == "building": + return not self.is_building_updated(updated_site, requested_site) + + elif type == "floor": + return not self.is_floor_updated(updated_site, requested_site) + + return not self.is_area_updated(updated_site, requested_site) + + def get_have(self, config): + """ + Get the site details from Cisco Catalyst Center + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): A dictionary containing the configuration details. + Returns: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method queries Cisco Catalyst 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. + """ - def get_have(self): site_exists = False current_site = None have = {} @@ -432,8 +724,7 @@ class DnacSite: # check if given site exits, if exists store current site info (site_exists, current_site) = self.site_exists() - if self.log: - log("Site Exists: " + str(site_exists) + "\n Current Site:" + str(current_site)) + self.log("Current Site details (have): {0}".format(str(current_site)), "DEBUG") if site_exists: have["site_id"] = current_site.get("siteId") @@ -441,19 +732,52 @@ class DnacSite: have["current_site"] = current_site self.have = have + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + + return self + + def get_want(self, config): + """ + Get all site-related information from the playbook needed for creation/updation/deletion of site in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing configuration information. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + Retrieves all site-related information from playbook that is + required for creating a site in Cisco Catalyst Center. It includes + parameters such as 'site_params' and 'site_name.' The gathered + information is stored in the 'want' attribute for later reference. + """ - def get_want(self): want = {} - - for site in self.validated: - want = dict( - site_params=self.get_site_params(site), - site_name=self.get_site_name(site), - ) - + want = dict( + site_params=self.get_site_params(config), + site_name=self.get_site_name(config), + ) self.want = want + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + return self + + def get_diff_merged(self, config): + """ + Update/Create site information in Cisco Catalyst Center with fields + provided in the playbook. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing configuration information. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method determines whether to update or create a site in Cisco Catalyst Center based on the provided + configuration information. If the specified site exists, the method checks if it requires an update + by calling the 'site_requires_update' method. If an update is required, it calls the 'update_site' + function from the 'sites' family of the Cisco Catalyst Center API. If the site does not require an update, + the method exits, indicating that the site is up to date. + """ - def get_diff_merge(self): site_updated = False site_created = False @@ -463,6 +787,7 @@ class DnacSite: # Existing Site requires update site_params = self.want.get("site_params") site_params["site_id"] = self.have.get("site_id") + response = self.dnac._exec( family="sites", function='update_site', @@ -474,19 +799,28 @@ class DnacSite: else: # Site does not neet update self.result['response'] = self.have.get("current_site") - self.result['msg'] = "Site does not need update" - 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, "INFO") + self.result['msg'] = self.msg + return self else: # Creating New Site + site_params = self.want.get("site_params") + if site_params['site']['building']: + building_details = {} + for key, value in site_params['site']['building'].items(): + if value is not None: + building_details[key] = value + site_params['site']['building'] = building_details + response = self.dnac._exec( family="sites", function='create_site', op_modifies=True, - params=self.want.get("site_params"), + params=site_params, ) - - log(str(response)) + self.log("Received API response from 'create_site': {0}".format(str(response)), "DEBUG") site_created = True if site_created or site_updated: @@ -505,8 +839,9 @@ class DnacSite: break if site_updated: - log("Site Updated Successfully") - self.result['msg'] = "Site Updated Successfully" + self.msg = "Site - {0} Updated Successfully".format(self.want.get("site_name")) + self.log(self.msg, "INFO") + self.result['msg'] = self.msg self.result['response'].update({"siteId": self.have.get("site_id")}) else: @@ -514,76 +849,237 @@ class DnacSite: (site_exists, current_site) = self.site_exists() if site_exists: - log("Site Created Successfully") - log("Current site:" + str(current_site)) - self.result['msg'] = "Site Created Successfully" + self.msg = "Site '{0}' created successfully".format(self.want.get("site_name")) + self.log(self.msg, "INFO") + self.log("Current site (have): {0}".format(str(current_site)), "DEBUG") + self.result['msg'] = self.msg self.result['response'].update({"siteId": current_site.get('site_id')}) - def get_diff_delete(self): - site_exists = self.have.get("site_exists") + return self + + def delete_single_site(self, site_id, site_name): + """" + Delete a single site in the Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + site_id (str): The ID of the site to be deleted. + site_name (str): The name of the site to be deleted. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function initiates the deletion of a site in the Cisco Catalyst Center by calling the delete API. + If the deletion is successful, the result is marked as changed, and the status is set to "success." + If an error occurs during the deletion process, the status is set to "failed," and the log contains + details about the error. + """ - if site_exists: + try: response = self.dnac._exec( family="sites", function="delete_site", - params={"site_id": self.have.get("site_id")}, + params={"site_id": site_id}, ) if response and isinstance(response, dict): + self.log("Received API response from 'delete_site': {0}".format(str(response)), "DEBUG") executionid = response.get("executionId") + while True: execution_details = self.get_execution_details(executionid) if execution_details.get("status") == "SUCCESS": + self.msg = "Site '{0}' deleted successfully".format(site_name) self.result['changed'] = True - self.result['response'] = execution_details - self.result['response'].update({"siteId": self.have.get("site_id")}) - self.result['msg'] = "Site deleted successfully" + self.result['response'] = self.msg + self.status = "success" + self.log(self.msg, "INFO") break - elif execution_details.get("bapiError"): - self.module.fail_json(msg=execution_details.get("bapiError"), - response=execution_details) + self.log("Error response for 'delete_site' execution: {0}".format(execution_details.get("bapiError")), "ERROR") + self.module.fail_json(msg=execution_details.get("bapiError"), response=execution_details) break - else: - self.module.fail_json(msg="Site Not Found", response=[]) + except Exception as e: + self.status = "failed" + self.msg = "Exception occurred while deleting site '{0}' due to: {1}".format(site_name, str(e)) + self.log(self.msg, "ERROR") + + return self + + def get_diff_deleted(self, config): + """ + Call Cisco Catalyst Center API to delete sites with provided inputs. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): Dictionary containing information for site deletion. + Returns: + - self: The result dictionary includes the following keys: + - 'changed' (bool): Indicates whether changes were made + during the deletion process. + - 'response' (dict): Contains details about the execution + 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 Catalyst Center API. It uses the site ID obtained from the 'have' attribute. + """ + + site_exists = self.have.get("site_exists") + site_name = self.want.get("site_name") + if not site_exists: + self.status = "success" + self.msg = "Unable to delete site '{0}' as it's not found in Cisco Catalyst Center".format(site_name) + self.result.update({'changed': False, + 'response': self.msg, + 'msg': self.msg}) + self.log(self.msg, "INFO") + + return self + + # Check here if the site have the childs then fetch it using get membership API and then sort it + # in reverse order and start deleting from bottom to top + site_id = self.have.get("site_id") + mem_response = self.dnac._exec( + family="sites", + function="get_membership", + params={"site_id": site_id}, + ) + site_response = mem_response.get("site").get("response") + self.log("Site {0} response along with it's child sites: {1}".format(site_name, str(site_response)), "DEBUG") + + if len(site_response) == 0: + self.delete_single_site(site_id, site_name) + return self + + # Sorting the response in reverse order based on hierarchy levels + sorted_site_resp = sorted(site_response, key=lambda x: x.get("groupHierarchy"), reverse=True) + + # Deleting each level in reverse order till topmost parent site + for item in sorted_site_resp: + self.delete_single_site(item['id'], item['name']) + + # Delete the final parent site + self.delete_single_site(site_id, site_name) + self.msg = "The site '{0}' and its child sites have been deleted successfully".format(site_name) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of site configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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 Catalyst Center configuration. + """ + + self.get_have(config) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + # Code to validate dnac config for merged state + site_exist = self.have.get("site_exists") + site_name = self.want.get("site_name") + + if site_exist: + self.status = "success" + self.msg = "The requested site '{0}' is present in the Cisco Catalyst Center and its creation has been verified.".format(site_name) + self.log(self.msg, "INFO") + + require_update = self.site_requires_update() + + if not require_update: + self.log("The update for site '{0}' has been successfully verified.".format(site_name), "INFO") + self. status = "success" + return self + + self.log("""The playbook input for site '{0}' does not align with the Cisco Catalyst Center, indicating that the merge task + may not have executed successfully.""".format(site_name), "INFO") + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of site configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified site exists in the Catalyst Center configuration. + """ + + self.get_have(config) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + # Code to validate dnac config for delete state + site_exist = self.have.get("site_exists") + + if not site_exist: + self.status = "success" + msg = """The requested site '{0}' has already been deleted from the Cisco Catalyst Center and this has been + successfully verified.""".format(self.want.get("site_name")) + self.log(msg, "INFO") + return self + self.log("""Mismatch between the playbook input for site '{0}' and the Cisco Catalyst Center indicates that + the deletion was not executed successfully.""".format(self.want.get("site_name")), "INFO") + + return self def main(): """ main entry point for module execution """ - element_spec = dict( - dnac_host=dict(required=True, type='str'), - dnac_port=dict(type='str', default='443'), - dnac_username=dict(type='str', default='admin', aliases=["user"]), - dnac_password=dict(type='str', no_log=True), - dnac_verify=dict(type='bool', default='True'), - dnac_version=dict(type="str", default="2.2.3.3"), - dnac_debug=dict(type='bool', default=False), - dnac_log=dict(type='bool', default=False), - validate_response_schema=dict(type="bool", default=True), - config=dict(required=True, type='list', elements='dict'), - state=dict( - default='merged', - choices=['merged', 'deleted']), - ) + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) dnac_site = DnacSite(module) - dnac_site.validate_input() - state = dnac_site.get_state() - - dnac_site.get_want() - dnac_site.get_have() - - if state == "merged": - dnac_site.get_diff_merge() - - elif state == "deleted": - dnac_site.get_diff_delete() + state = dnac_site.params.get("state") + + if state not in dnac_site.supported_states: + dnac_site.status = "invalid" + dnac_site.msg = "State {0} is invalid".format(state) + 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/ansible_collections/cisco/dnac/plugins/modules/site_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/site_workflow_manager.py new file mode 100644 index 000000000..1ae28afd8 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/site_workflow_manager.py @@ -0,0 +1,1087 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary, Abhishek Maheshwari") + +DOCUMENTATION = r""" +--- +module: site_workflow_manager +short_description: Resource module for Site operations +description: +- Manage operation create, update and delete of the resource Sites. +- Creates site with area/building/floor with specified hierarchy. +- Updates site with area/building/floor with specified hierarchy. +- Deletes site with area/building/floor with specified hierarchy. +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) + Abhishek Maheshwari (@abhishekmaheshwari) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: It represents a list of details for creating/managing/deleting sites, including areas, buildings, and floors. + type: list + elements: dict + required: True + suboptions: + site_type: + description: Type of site to create/update/delete (eg area, building, floor). + type: str + site: + description: Contains details about the site being managed including areas, buildings and floors. + type: dict + suboptions: + area: + description: Configuration details for creating or managing an area within a site. + type: dict + suboptions: + name: + description: Name of the area to be created or managed (e.g., "Area1"). + type: str + parent_name: + description: The full name of the parent under which the area will be created/managed/deleted (e.g., "Global/USA"). + type: str + building: + description: Configuration details required for creating or managing a building within a site. + type: dict + suboptions: + address: + description: Physical address of the building that is to be created or managed. + type: str + latitude: + description: Geographical latitude coordinate of the building. For example, use 37.338 for a location in San Jose, California. + Valid values range from -90.0 to +90.0 degrees. + type: float + longitude: + description: Geographical longitude coordinate of the building. For example, use -121.832 for a location in San Jose, California. + Valid values range from -180.0 to +180.0 degrees. + type: float + name: + description: Name of the building (e.g., "Building1"). + type: str + parent_name: + description: Hierarchical parent path of the building, indicating its location within the site (e.g., "Global/USA/San Francisco"). + type: str + floor: + description: Configuration details required for creating or managing a floor within a site. + type: dict + suboptions: + height: + description: Height of the floor in feet (e.g., 15.23). + type: float + length: + description: Length of the floor in feet (e.g., 100.11). + type: float + name: + description: Name of the floor (e.g., "Floor-1"). + type: str + parent_name: + description: Hierarchical parent path of the floor, indicating its location within the site (e.g., + "Global/USA/San Francisco/BGL_18"). + type: str + rf_model: + description: The RF (Radio Frequency) model type for the floor, which is essential for simulating and optimizing wireless + network coverage. Select from the following allowed values, which describe different environmental signal propagation + characteristics. + Type of floor (allowed values are 'Cubes And Walled Offices', 'Drywall Office Only', 'Indoor High Ceiling', + 'Outdoor Open Space'). + Cubes And Walled Offices - This RF model typically represents indoor areas with cubicles or walled offices, where + radio signals may experience attenuation due to walls and obstacles. + Drywall Office Only - This RF model indicates an environment with drywall partitions, commonly found in office spaces, + which may have moderate signal attenuation. + Indoor High Ceiling - This RF model is suitable for indoor spaces with high ceilings, such as auditoriums or atriums, + where signal propagation may differ due to the height of the ceiling. + Outdoor Open Space - This RF model is used for outdoor areas with open spaces, where signal propagation is less obstructed + and may follow different patterns compared to indoor environments. + type: str + width: + description: Width of the floor in feet (e.g., 100.22). + type: float + floor_number: + description: Floor number within the building site (e.g., 5). This value can only be specified during the creation of the + floor and cannot be modified afterward. + type: int + +requirements: +- dnacentersdk == 2.4.5 +- python >= 3.5 +notes: + - SDK Method used are + sites.Sites.create_site, + sites.Sites.update_site, + sites.Sites.delete_site + + - Paths used are + post /dna/intent/api/v1/site, + put dna/intent/api/v1/site/{siteId}, + delete dna/intent/api/v1/site/{siteId} +""" + +EXAMPLES = r""" +- name: Create a new area site + cisco.dnac.site_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: merged + config: + - site: + area: + name: Test + parent_name: Global/India + site_type: area + +- name: Create a new building site + cisco.dnac.site_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: merged + config: + - site: + building: + name: Building_1 + parent_name: Global/India + address: Bengaluru, Karnataka, India + latitude: 24.12 + longitude: 23.45 + site_type: building + +- name: Create a Floor site under the building + cisco.dnac.site_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: merged + config: + - site: + floor: + name: Floor_1 + parent_name: Global/India/Building_1 + length: 75.76 + width: 35.54 + height: 30.12 + rf_model: Cubes And Walled Offices + floor_number: 2 + site_type: floor + +- name: Updating the Floor details under the building + cisco.dnac.site_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: merged + config: + - site: + floor: + name: Floor_1 + parent_name: Global/India/Building_1 + length: 75.76 + width: 35.54 + height: 30.12 + site_type: floor + +- name: Deleting any site you need site name and parent name + cisco.dnac.site_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: "{{dnac_log}}" + state: deleted + config: + - site: + floor: + name: Floor_1 + parent_name: Global/India/Building_1 + site_type: floor +""" + +RETURN = r""" +#Case_1: Site is successfully created/updated/deleted +response_1: + description: A dictionary with API execution details as returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": + { + "bapiExecutionId": String, + "bapiKey": String, + "bapiName": String, + "endTime": String, + "endTimeEpoch": 0, + "runtimeInstanceId": String, + "siteId": String, + "startTime": String, + "startTimeEpoch": 0, + "status": String, + "timeDuration": 0 + + }, + "msg": "string" + } + +#Case_2: Site exits and does not need an update +response_2: + description: A dictionary with existing site details. + returned: always + type: dict + sample: > + { + "response": + { + "site": {}, + "siteId": String, + "type": String + }, + "msg": String + } + +#Case_3: Error while creating/updating/deleting site +response_3: + description: A dictionary with API execution details as returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": + { + "bapiError": String, + "bapiExecutionId": String, + "bapiKey": String, + "bapiName": String, + "endTime": String, + "endTimeEpoch": 0, + "runtimeInstanceId": String, + "startTime": String, + "startTimeEpoch": 0, + "status": String, + "timeDuration": 0 + + }, + "msg": "string" + } + +#Case_4: Site not found when atempting to delete site +response_4: + description: A list with the response returned by the Cisco Catalyst Center Python + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result, +) + +floor_plan = { + '101101': 'Cubes And Walled Offices', + '101102': 'Drywall Office Only', + '101105': 'Free Space', + '101104': 'Indoor High Ceiling', + '101103': 'Outdoor Open Space' +} + + +class Site(DnacBase): + """Class containing member attributes for Site workflow_manager module""" + + def __init__(self, module): + super().__init__(module) + self.supported_states = ["merged", "deleted"] + + def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + The method returns an instance of the class with updated attributes: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of the 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', and + 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.status = "success" + self.msg = "Configuration is not available in the playbook for validation" + self.log(self.msg, "ERROR") + return self + + temp_spec = dict( + type=dict(required=False, type='str'), + site=dict(required=True, type='dict'), + ) + self.config = self.update_site_type_key(self.config) + + # Validate site params + 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) + ) + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_temp + self.msg = "Successfully validated playbook config params: {0}".format(str(valid_temp)) + self.log(self.msg, "INFO") + self.status = "success" + + return self + + def get_current_site(self, site): + """ + Get the current site information. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - site (list): A list containing information about the site. + Returns: + - dict: A dictionary containing the extracted site information. + Description: + This method extracts information about the current site based on + the provided 'site' list. It determines the type of the site + (area, building, or floor) and retrieves specific details + accordingly. The resulting dictionary includes the type, site + details, and the site ID. + """ + + site_info = {} + + location = get_dict_result(site[0].get("additionalInfo"), 'nameSpace', "Location") + typeinfo = location.get("attributes").get("type") + + if typeinfo == "area": + site_info = dict( + area=dict( + name=site[0].get("name"), + parentName=site[0].get("siteNameHierarchy").split("/" + site[0].get("name"))[0] + ) + ) + + elif typeinfo == "building": + site_info = dict( + building=dict( + name=site[0].get("name"), + parentName=site[0].get("siteNameHierarchy").split("/" + site[0].get("name"))[0], + address=location.get("attributes").get("address"), + latitude=location.get("attributes").get("latitude"), + longitude=location.get("attributes").get("longitude"), + country=location.get("attributes").get("country"), + ) + ) + + elif typeinfo == "floor": + map_geometry = get_dict_result(site[0].get("additionalInfo"), 'nameSpace', "mapGeometry") + map_summary = get_dict_result(site[0].get("additionalInfo"), 'nameSpace', "mapsSummary") + rf_model = map_summary.get("attributes").get("rfModel") + + site_info = dict( + floor=dict( + name=site[0].get("name"), + parentName=site[0].get("siteNameHierarchy").split("/" + site[0].get("name"))[0], + rf_model=floor_plan.get(rf_model), + width=map_geometry.get("attributes").get("width"), + length=map_geometry.get("attributes").get("length"), + height=map_geometry.get("attributes").get("height"), + floorNumber=map_geometry.get("attributes").get("floor_number", "") + ) + ) + + current_site = dict( + type=typeinfo, + site=site_info, + siteId=site[0].get("id") + ) + + self.log("Current site details: {0}".format(str(current_site)), "INFO") + + return current_site + + def site_exists(self): + """ + Check if the site exists in Cisco Catalyst Center. + + Parameters: + - self (object): An instance of the class containing the method. + Returns: + - tuple: A tuple containing a boolean indicating whether the site exists and + a dictionary containing information about the existing site. + The returned tuple includes two elements: + - site_exists (bool): Indicates whether the site exists. + - dict: Contains information about the existing site. If the + site doesn't exist, this dictionary is empty. + Description: + Checks the existence of a site in Cisco Catalyst Center by querying the + 'get_site' function in the 'sites' family. It utilizes the + 'site_name' parameter from the 'want' attribute to identify the site. + """ + + site_exists = False + current_site = {} + response = None + try: + response = self.dnac._exec( + family="sites", + function='get_site', + params={"name": self.want.get("site_name")}, + ) + + except Exception as e: + self.log("The provided site name '{0}' is either invalid or not present in the Cisco Catalyst Center." + .format(self.want.get("site_name")), "WARNING") + if response: + response = response.get("response") + self.log("Received API response from 'get_site': {0}".format(str(response)), "DEBUG") + current_site = self.get_current_site(response) + site_exists = True + self.log("Site '{0}' exists in Cisco Catalyst Center".format(self.want.get("site_name")), "INFO") + + return (site_exists, current_site) + + def get_site_params(self, params): + """ + Store the site-related parameters. + + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - params (dict): Dictionary containing site-related parameters. + Returns: + - dict: Dictionary containing the stored site-related parameters. + The returned dictionary includes the following keys: + - 'type' (str): The type of the site. + - 'site' (dict): Dictionary containing site-related info. + Description: + This method takes a dictionary 'params' containing site-related + information and stores the relevant parameters based on the site + type. If the site type is 'floor', it ensures that the 'rfModel' + parameter is stored in uppercase. + """ + typeinfo = params.get("type") + site_info = {} + + if typeinfo == 'area': + area_details = params.get('site').get('area') + site_info['area'] = { + 'name': area_details.get('name'), + 'parentName': area_details.get('parent_name') + } + elif typeinfo == 'building': + building_details = params.get('site').get('building') + site_info['building'] = { + 'name': building_details.get('name'), + 'address': building_details.get('address'), + 'parentName': building_details.get('parent_name'), + 'latitude': building_details.get('latitude'), + 'longitude': building_details.get('longitude'), + 'country': building_details.get('country') + } + else: + floor_details = params.get('site').get('floor') + site_info['floor'] = { + 'name': floor_details.get('name'), + 'parentName': floor_details.get('parent_name'), + 'length': floor_details.get('length'), + 'width': floor_details.get('width'), + 'height': floor_details.get('height'), + 'floorNumber': floor_details.get('floor_number', '') + } + try: + site_info["floor"]["rfModel"] = floor_details.get("rf_model") + except Exception as e: + self.log("The attribute 'rf_model' is missing in floor '{0}'.".format(floor_details.get('name')), "WARNING") + + site_params = dict( + type=typeinfo, + site=site_info, + ) + self.log("Site parameters: {0}".format(str(site_params)), "DEBUG") + + return site_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 Catalyst Center. + - site (dict): A dictionary containing information about the site. + Returns: + - str: The constructed site name. + Description: + This method takes a dictionary 'site' containing information about + the site and constructs the site name by combining the parent name + and site name. + """ + + site_type = site.get("type") + parent_name = site.get("site").get(site_type).get("parent_name") + name = site.get("site").get(site_type).get("name") + site_name = '/'.join([parent_name, name]) + self.log("Site name: {0}".format(site_name), "INFO") + + 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 Catalyst 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 Catalyst 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 parent name) have been updated, False otherwise. + Description: + This method compares the area details (name and parent name) 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 Catalyst 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, parent_name, 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 + ('address' in requested_site and (requested_site['address'] is None or updated_site.get('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 Catalyst 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 Catalyst Center. + Returns: + bool: True if the site requires updates, False otherwise. + Description: + This method compares the site parameters of the current site + ('current_site') and the requested site parameters ('requested_site') + stored in the 'want' attribute. It checks for differences in + specified parameters, such as the site type and site details. + """ + + 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 type: {0}".format(str(updated_site)), "INFO") + self.log("Requested Site type: {0}".format(str(requested_site)), "INFO") + + if type == "building": + return not self.is_building_updated(updated_site, requested_site) + + elif type == "floor": + return not self.is_floor_updated(updated_site, requested_site) + + return not self.is_area_updated(updated_site, requested_site) + + def get_have(self, config): + """ + Get the site details from Cisco Catalyst Center + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): A dictionary containing the configuration details. + Returns: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method queries Cisco Catalyst 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. + """ + + site_exists = False + current_site = None + have = {} + + # check if given site exits, if exists store current site info + (site_exists, current_site) = self.site_exists() + + self.log("Current Site details (have): {0}".format(str(current_site)), "DEBUG") + + if site_exists: + have["site_id"] = current_site.get("siteId") + have["site_exists"] = site_exists + have["current_site"] = current_site + + self.have = have + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + + return self + + def get_want(self, config): + """ + Get all site-related information from the playbook needed for creation/updation/deletion of site in Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing configuration information. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + Retrieves all site-related information from playbook that is + required for creating a site in Cisco Catalyst Center. It includes + parameters such as 'site_params' and 'site_name.' The gathered + information is stored in the 'want' attribute for later reference. + """ + + want = {} + want = dict( + site_params=self.get_site_params(config), + site_name=self.get_site_name(config), + ) + self.want = want + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + return self + + def get_diff_merged(self, config): + """ + Update/Create site information in Cisco Catalyst Center with fields + provided in the playbook. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): A dictionary containing configuration information. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method determines whether to update or create a site in Cisco Catalyst Center based on the provided + configuration information. If the specified site exists, the method checks if it requires an update + by calling the 'site_requires_update' method. If an update is required, it calls the 'update_site' + function from the 'sites' family of the Cisco Catalyst Center API. If the site does not require an update, + the method exits, indicating that the site is up to date. + """ + + site_updated = False + site_created = False + + # check if the given site exists and/or needs to be updated/created. + if self.have.get("site_exists"): + if self.site_requires_update(): + # Existing Site requires update + site_params = self.want.get("site_params") + site_params["site_id"] = self.have.get("site_id") + + response = self.dnac._exec( + family="sites", + function='update_site', + op_modifies=True, + params=site_params, + ) + site_updated = True + + else: + # Site does not neet update + self.result['response'] = self.have.get("current_site") + self.msg = "Site - {0} does not need any update".format(self.have.get("current_site")) + self.log(self.msg, "INFO") + self.result['msg'] = self.msg + return self + + else: + # Creating New Site + site_params = self.want.get("site_params") + if site_params['site']['building']: + building_details = {} + for key, value in site_params['site']['building'].items(): + if value is not None: + building_details[key] = value + site_params['site']['building'] = building_details + + response = self.dnac._exec( + family="sites", + function='create_site', + op_modifies=True, + params=site_params, + ) + self.log("Received API response from 'create_site': {0}".format(str(response)), "DEBUG") + site_created = True + + if site_created or site_updated: + if response and isinstance(response, dict): + executionid = response.get("executionId") + while True: + execution_details = self.get_execution_details(executionid) + if execution_details.get("status") == "SUCCESS": + self.result['changed'] = True + self.result['response'] = execution_details + break + + elif execution_details.get("bapiError"): + self.module.fail_json(msg=execution_details.get("bapiError"), + response=execution_details) + break + + if site_updated: + self.msg = "Site - {0} Updated Successfully".format(self.want.get("site_name")) + self.log(self.msg, "INFO") + self.result['msg'] = self.msg + self.result['response'].update({"siteId": self.have.get("site_id")}) + + else: + # Get the site id of the newly created site. + (site_exists, current_site) = self.site_exists() + + if site_exists: + self.msg = "Site '{0}' created successfully".format(self.want.get("site_name")) + self.log(self.msg, "INFO") + self.log("Current site (have): {0}".format(str(current_site)), "DEBUG") + self.result['msg'] = self.msg + self.result['response'].update({"siteId": current_site.get('site_id')}) + + return self + + def delete_single_site(self, site_id, site_name): + """" + Delete a single site in the Cisco Catalyst Center. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + site_id (str): The ID of the site to be deleted. + site_name (str): The name of the site to be deleted. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function initiates the deletion of a site in the Cisco Catalyst Center by calling the delete API. + If the deletion is successful, the result is marked as changed, and the status is set to "success." + If an error occurs during the deletion process, the status is set to "failed," and the log contains + details about the error. + """ + + try: + response = self.dnac._exec( + family="sites", + function="delete_site", + params={"site_id": site_id}, + ) + + if response and isinstance(response, dict): + self.log("Received API response from 'delete_site': {0}".format(str(response)), "DEBUG") + executionid = response.get("executionId") + + while True: + execution_details = self.get_execution_details(executionid) + if execution_details.get("status") == "SUCCESS": + self.msg = "Site '{0}' deleted successfully".format(site_name) + self.result['changed'] = True + self.result['response'] = self.msg + self.status = "success" + self.log(self.msg, "INFO") + break + elif execution_details.get("bapiError"): + self.log("Error response for 'delete_site' execution: {0}".format(execution_details.get("bapiError")), "ERROR") + self.module.fail_json(msg=execution_details.get("bapiError"), response=execution_details) + break + + except Exception as e: + self.status = "failed" + self.msg = "Exception occurred while deleting site '{0}' due to: {1}".format(site_name, str(e)) + self.log(self.msg, "ERROR") + + return self + + def get_diff_deleted(self, config): + """ + Call Cisco Catalyst Center API to delete sites with provided inputs. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): Dictionary containing information for site deletion. + Returns: + - self: The result dictionary includes the following keys: + - 'changed' (bool): Indicates whether changes were made + during the deletion process. + - 'response' (dict): Contains details about the execution + 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 Catalyst Center API. It uses the site ID obtained from the 'have' attribute. + """ + + site_exists = self.have.get("site_exists") + site_name = self.want.get("site_name") + if not site_exists: + self.status = "success" + self.msg = "Unable to delete site '{0}' as it's not found in Cisco Catalyst Center".format(site_name) + self.result.update({'changed': False, + 'response': self.msg, + 'msg': self.msg}) + self.log(self.msg, "INFO") + + return self + + # Check here if the site have the childs then fetch it using get membership API and then sort it + # in reverse order and start deleting from bottom to top + site_id = self.have.get("site_id") + mem_response = self.dnac._exec( + family="sites", + function="get_membership", + params={"site_id": site_id}, + ) + site_response = mem_response.get("site").get("response") + self.log("Site {0} response along with it's child sites: {1}".format(site_name, str(site_response)), "DEBUG") + + if len(site_response) == 0: + self.delete_single_site(site_id, site_name) + return self + + # Sorting the response in reverse order based on hierarchy levels + sorted_site_resp = sorted(site_response, key=lambda x: x.get("groupHierarchy"), reverse=True) + + # Deleting each level in reverse order till topmost parent site + for item in sorted_site_resp: + self.delete_single_site(item['id'], item['name']) + + # Delete the final parent site + self.delete_single_site(site_id, site_name) + self.msg = "The site '{0}' and its child sites have been deleted successfully".format(site_name) + self.result['response'] = self.msg + self.log(self.msg, "INFO") + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Creation/Updation) of site configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst 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 Catalyst Center configuration. + """ + + self.get_have(config) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + # Code to validate ccc config for merged state + site_exist = self.have.get("site_exists") + site_name = self.want.get("site_name") + + if site_exist: + self.status = "success" + self.msg = "The requested site '{0}' is present in the Cisco Catalyst Center and its creation has been verified.".format(site_name) + self.log(self.msg, "INFO") + + require_update = self.site_requires_update() + + if not require_update: + self.log("The update for site '{0}' has been successfully verified.".format(site_name), "INFO") + self. status = "success" + return self + + self.log("""The playbook input for site '{0}' does not align with the Cisco Catalyst Center, indicating that the merge task + may not have executed successfully.""".format(site_name), "INFO") + + return self + + def verify_diff_deleted(self, config): + """ + Verify the deletion status of site configuration in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the deletion status of a configuration in Cisco Catalyst Center. + It validates whether the specified site exists in the Catalyst Center configuration. + """ + + self.get_have(config) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + # Code to validate ccc config for delete state + site_exist = self.have.get("site_exists") + + if not site_exist: + self.status = "success" + msg = """The requested site '{0}' has already been deleted from the Cisco Catalyst Center and this has been + successfully verified.""".format(self.want.get("site_name")) + self.log(msg, "INFO") + return self + self.log("""Mismatch between the playbook input for site '{0}' and the Cisco Catalyst Center indicates that + the deletion was not executed successfully.""".format(self.want.get("site_name")), "INFO") + + return self + + +def main(): + """ main entry point for module execution + """ + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + + ccc_site = Site(module) + state = ccc_site.params.get("state") + + if state not in ccc_site.supported_states: + ccc_site.status = "invalid" + ccc_site.msg = "State {0} is invalid".format(state) + ccc_site.check_return_status() + + ccc_site.validate_input().check_return_status() + config_verify = ccc_site.params.get("config_verify") + + for config in ccc_site.validated_config: + ccc_site.reset_values() + ccc_site.get_want(config).check_return_status() + ccc_site.get_have(config).check_return_status() + ccc_site.get_diff_state_apply[state](config).check_return_status() + if config_verify: + ccc_site.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_site.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/swim_intent.py b/ansible_collections/cisco/dnac/plugins/modules/swim_intent.py index ca173fb44..08f78ac30 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/swim_intent.py +++ b/ansible_collections/cisco/dnac/plugins/modules/swim_intent.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary") +__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary, Abhishek Maheshwari") DOCUMENTATION = r""" --- @@ -15,53 +15,85 @@ module: swim_intent short_description: Intent module for SWIM related functions description: - Manage operation related to image importation, distribution, activation and tagging image as golden -- API to fetch a software image from remote file system using URL for HTTP/FTP and upload it to DNA Center. +- API to fetch a software image from remote file system using URL for HTTP/FTP and upload it to Catalyst Center. Supported image files extensions are bin, img, tar, smu, pie, aes, iso, ova, tar_gz and qcow2. -- API to fetch a software image from local file system and upload it to DNA Center +- API to fetch a software image from local file system and upload it to Catalyst Center Supported image files extensions are bin, img, tar, smu, pie, aes, iso, ova, tar_gz and qcow2. - API to tag/untag image as golen for a given family of devices - API to distribute a software image on a given device. Software image must be imported successfully into - DNA Center before it can be distributed. + Catalyst Center before it can be distributed. - API to activate a software image on a given device. Software image must be present in the device flash. version_added: '6.6.0' extends_documentation_fragment: - cisco.dnac.intent_params author: Madhan Sankaranarayanan (@madhansansel) Rishita Chowdhary (@rishitachowdhary) + Abhishek Maheshwari (@abmahesh) options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Catalyst Center after module completion. + type: str + choices: [ merged ] + default: merged config: description: List of details of SWIM image being managed type: list elements: dict required: True suboptions: - importImageDetails: + import_image_details: description: Details of image being imported type: dict suboptions: type: - description: The source of import, supports url import or local import. + description: Specifies the import source, supporting local file import (local) or remote url import (remote). type: str - localImageDetails: + local_image_details: description: Details of the local path of the image to be imported. type: dict suboptions: - filePath: - description: File absolute path. + file_path: + description: Provide the absolute file path needed to import an image from your local system (Eg "/path/to/your/file"). + Accepted files formats are - .gz,.bin,.img,.tar,.smu,.pie,.aes,.iso,.ova,.tar_gz,.qcow2,.nfvispkg,.zip,.spa,.rpm. type: str - isThirdParty: - description: IsThirdParty query parameter. Third party Image check. + is_third_party: + description: Query parameter to determine if the image is from a third party (optional). type: bool - thirdPartyApplicationType: - description: ThirdPartyApplicationType query parameter. Third Party Application Type. + third_party_application_type: + description: Specify the ThirdPartyApplicationType query parameter to indicate the type of third-party application. Allowed + values include WLC, LINUX, FIREWALL, WINDOWS, LOADBALANCER, THIRDPARTY, etc.(optional). + WLC (Wireless LAN Controller) - It's a network device that manages and controls multiple wireless access points (APs) in a + centralized manner. + LINUX - It's an open-source operating system that provides a complete set of software packages and utilities. + FIREWALL - It's a network security device that monitors and controls incoming and outgoing network traffic based on + predetermined security rules.It acts as a barrier between a trusted internal network and untrusted external networks + (such as the internet), preventing unauthorized access. + WINDOWS - It's an operating system known for its graphical user interface (GUI) support, extensive compatibility with hardware + and software, and widespread use across various applications. + LOADBALANCER - It's a network device or software application that distributes incoming network traffic across multiple servers + or resources. + THIRDPARTY - It refers to third-party images or applications that are not part of the core system. + NAM (Network Access Manager) - It's a network management tool or software application that provides centralized control and + monitoring of network access policies, user authentication, and device compliance. + WAN Optimization - It refers to techniques and technologies used to improve the performance and efficiency of WANs. It includes + various optimization techniques such as data compression, caching, protocol optimization, and traffic prioritization to reduce + latency, increase throughput, and improve user experience over WAN connections. + Unknown - It refers to an unspecified or unrecognized application type. + Router - It's a network device that forwards data packets between computer networks. They are essential for connecting multiple + networks together and directing traffic between them. type: str - thirdPartyImageFamily: - description: ThirdPartyImageFamily query parameter. Third Party image family. + third_party_image_family: + description: Provide the ThirdPartyImageFamily query parameter to identify the family of the third-party image. Image Family name + like PALOALTO, RIVERBED, FORTINET, CHECKPOINT, SILVERPEAK etc. (optional). type: str - thirdPartyVendor: - description: ThirdPartyVendor query parameter. Third Party Vendor. + third_party_vendor: + description: Include the ThirdPartyVendor query parameter to specify the vendor of the third party. type: str - urlDetails: + url_details: description: URL details for SWIM import type: dict suboptions: @@ -70,108 +102,210 @@ options: type: list elements: dict suboptions: - applicationType: - description: Swim Import Via Url's applicationType. + application_type: + description: An optional parameter that specifies the type of application. Allowed values include WLC, LINUX, FIREWALL, WINDOWS, + LOADBALANCER, THIRDPARTY, etc. This is only applicable for third-party image types(optional). + WLC (Wireless LAN Controller) - It's network device that manages and controls multiple wireless access points (APs) in a + centralized manner. + LINUX - It's an open source which provide complete operating system with a wide range of software packages and utilities. + FIREWALL - It's a network security device that monitors and controls incoming and outgoing network traffic based on + predetermined security rules.It acts as a barrier between a trusted internal network and untrusted external networks + (such as the internet), preventing unauthorized access. + WINDOWS - It's an OS which provides GUI support for various applications, and extensive compatibility with hardware + and software. + LOADBALANCER - It's a network device or software application that distributes incoming network traffic across multiple servers + or resources. + THIRDPARTY - It refers to third-party images or applications that are not part of the core system. + NAM (Network Access Manager) - It's a network management tool or software application that provides centralized control and + monitoring of network access policies, user authentication, and device compliance. + WAN Optimization - It refers to techniques and technologies used to improve the performance and efficiency of WANs. It includes + various optimization techniques such as data compression, caching, protocol optimization, and traffic prioritization to reduce + latency, increase throughput, and improve user experience over WAN connections. + Unknown - It refers to an unspecified or unrecognized application type. + Router - It's a network device that forwards data packets between computer networks. They are essential for connecting multiple + networks together and directing traffic between them. type: str - imageFamily: - description: Swim Import Via Url's imageFamily. + image_family: + description: Represents the name of the image family and is applicable only when uploading third-party images. Image Family name + like PALOALTO, RIVERBED, FORTINET, CHECKPOINT, SILVERPEAK etc. (optional). type: str - sourceURL: - description: Swim Import Via Url's sourceURL. + source_url: + description: A mandatory parameter for importing a SWIM image via a remote URL. This parameter is required when using a URL + to import an image.(For example, http://{host}/swim/cat9k_isoxe.16.12.10s.SPA.bin, + ftp://user:password@{host}/swim/cat9k_isoxe.16.12.10s.SPA.iso) type: str - thirdParty: - description: ThirdParty flag. + is_third_party: + description: Flag indicates whether the image is uploaded from a third party (optional). type: bool vendor: - description: Swim Import Via Url's vendor. + description: The name of the vendor, that applies only to third-party image types when importing via URL (optional). type: str - scheduleAt: + schedule_at: description: ScheduleAt query parameter. Epoch Time (The number of milli-seconds since - January 1 1970 UTC) at which the distribution should be scheduled (Optional). + January 1 1970 UTC) at which the distribution should be scheduled (optional). type: str - scheduleDesc: - description: ScheduleDesc query parameter. Custom Description (Optional). + schedule_desc: + description: ScheduleDesc query parameter. Custom Description (optional). type: str - scheduleOrigin: - description: ScheduleOrigin query parameter. Originator of this call (Optional). + schedule_origin: + description: ScheduleOrigin query parameter. Originator of this call (optional). type: str - taggingDetails: + tagging_details: description: Details for tagging or untagging an image as golden type: dict suboptions: - imageName: + image_name: description: SWIM image name which will be tagged or untagged as golden. type: str - deviceRole: - description: Device Role. Permissible Values ALL, UNKNOWN, ACCESS, BORDER ROUTER, - DISTRIBUTION and CORE. + device_role: + description: Defines the device role, with permissible values including ALL, UNKNOWN, ACCESS, BORDER ROUTER, + DISTRIBUTION, and CORE. + ALL - This role typically represents all devices within the network, regardless of their specific roles or functions. + UNKNOWN - This role is assigned to devices whose roles or functions have not been identified or classified within Cisco Catalsyt Center. + This could happen if the platform is unable to determine the device's role based on available information. + ACCESS - This role typically represents switches or access points that serve as access points for end-user devices to connect to the network. + These devices are often located at the edge of the network and provide connectivity to end-user devices. + BORDER ROUTER - These are devices that connect different network domains or segments together. They often serve as + gateways between different networks, such as connecting an enterprise network to the internet or connecting + multiple branch offices. + DISTRIBUTION - This role represents function as distribution switches or routers in hierarchical network designs. They aggregate traffic + from access switches and route it toward the core of the network or toward other distribution switches. + CORE - This role typically represents high-capacity switches or routers that form the backbone of the network. They handle large volumes + of traffic and provide connectivity between different parts of network, such as connecting distribution switches or + providing interconnection between different network segments. type: str - deviceFamilyName: - description: Device family name + device_image_family_name: + description: Device Image family name(Eg Cisco Catalyst 9300 Switch) type: str - siteName: + site_name: description: Site name for which SWIM image will be tagged/untagged as golden. If not provided, SWIM image will be mapped to global site. type: str + device_series_name: + description: This parameter specifies the name of the device series. It is used to identify a specific series of devices, + such as Cisco Catalyst 9300 Series Switches, within the Cisco Catalyst Center. + type: str + version_added: 6.12.0 tagging: description: Booelan value to tag/untag SWIM image as golden If True then the given image will be tagged as golden. If False then the given image will be un-tagged as golden. type: bool - imageDistributionDetails: + image_distribution_details: description: Details for SWIM image distribution. Device on which the image needs to distributed can be speciifed using any of the following parameters - deviceSerialNumber, deviceIPAddress, deviceHostname or deviceMacAddress. type: dict suboptions: - imageName: + device_role: + description: Defines the device role, with permissible values including ALL, UNKNOWN, ACCESS, BORDER ROUTER, + DISTRIBUTION, and CORE. + ALL - This role typically represents all devices within the network, regardless of their specific roles or functions. + UNKNOWN - This role is assigned to devices whose roles or functions have not been identified or classified within Cisco Catalsyt Center. + This could happen if the platform is unable to determine the device's role based on available information. + ACCESS - This role typically represents switches or access points that serve as access points for end-user devices to connect to the network. + These devices are often located at the edge of the network and provide connectivity to end-user devices. + BORDER ROUTER - These are devices that connect different network domains or segments together. They often serve as + gateways between different networks, such as connecting an enterprise network to the internet or connecting + multiple branch offices. + DISTRIBUTION - This role represents function as distribution switches or routers in hierarchical network designs. They aggregate traffic + from access switches and route it toward the core of the network or toward other distribution switches. + CORE - This role typically represents high-capacity switches or routers that form the backbone of the network. They handle large volumes + of traffic and provide connectivity between different parts of network, such as connecting distribution switches or + providing interconnection between different network segments. + type: str + device_family_name: + description: Specify the name of the device family such as Switches and Hubs, etc. + type: str + site_name: + description: Used to get device details associated to this site. + type: str + device_series_name: + description: This parameter specifies the name of the device series. It is used to identify a specific series of devices, + such as Cisco Catalyst 9300 Series Switches, within the Cisco Catalyst Center. + type: str + version_added: 6.12.0 + image_name: description: SWIM image's name type: str - deviceSerialNumber: + device_serial_number: description: Device serial number where the image needs to be distributed type: str - deviceIPAddress: + device_ip_address: description: Device IP address where the image needs to be distributed type: str - deviceHostname: + device_hostname: description: Device hostname where the image needs to be distributed type: str - deviceMacAddress: + device_mac_address: description: Device MAC address where the image needs to be distributed type: str - imageActivationDetails: + image_activation_details: description: Details for SWIM image activation. Device on which the image needs to activated can be speciifed using any of the following parameters - deviceSerialNumber, deviceIPAddress, deviceHostname or deviceMacAddress. type: dict suboptions: - activateLowerImageVersion: + device_role: + description: Defines the device role, with permissible values including ALL, UNKNOWN, ACCESS, BORDER ROUTER, + DISTRIBUTION, and CORE. + ALL - This role typically represents all devices within the network, regardless of their specific roles or functions. + UNKNOWN - This role is assigned to devices whose roles or functions have not been identified or classified within Cisco Catalsyt Center. + This could happen if the platform is unable to determine the device's role based on available information. + ACCESS - This role typically represents switches or access points that serve as access points for end-user devices to connect to the network. + These devices are often located at the edge of the network and provide connectivity to end-user devices. + BORDER ROUTER - These are devices that connect different network domains or segments together. They often serve as + gateways between different networks, such as connecting an enterprise network to the internet or connecting + multiple branch offices. + DISTRIBUTION - This role represents function as distribution switches or routers in hierarchical network designs. They aggregate traffic + from access switches and route it toward the core of the network or toward other distribution switches. + CORE - This role typically represents high-capacity switches or routers that form the backbone of the network. They handle large volumes + of traffic and provide connectivity between different parts of network, such as connecting distribution switches or + providing interconnection between different network segments. + type: str + device_family_name: + description: Specify the name of the device family such as Switches and Hubs, etc. + type: str + site_name: + description: Used to get device details associated to this site. + type: str + activate_lower_image_version: description: ActivateLowerImageVersion flag. type: bool - deviceUpgradeMode: - description: Swim Trigger Activation's deviceUpgradeMode. + device_upgrade_mode: + description: It specifies the mode of upgrade to be applied to the devices having the following values - 'install', 'bundle', and 'currentlyExists'. + install - This mode instructs Cisco Catalyst Center to perform a clean installation of the new image on the target devices. + When this mode is selected, the existing image on the device is completely replaced with the new image during the upgrade process. + This ensures that the device runs only the new image version after the upgrade is completed. + bundle - This mode instructs Cisco Catalyst Center bundles the new image with the existing image on the device before initiating + the upgrade process. This mode allows for a more efficient upgrade process by preserving the existing image on the device while + adding the new image as an additional bundle. After the upgrade, the device can run either the existing image or the new bundled + image, depending on the configuration. + currentlyExists - This mode instructs Cisco Catalyst Center to checks if the target devices already have the desired image version + installed. If image already present on devices, no action is taken and upgrade process is skipped for those devices. This mode + is useful for avoiding unnecessary upgrades on devices that already have the correct image version installed, thereby saving time. type: str - distributeIfNeeded: - description: DistributeIfNeeded flag. + distribute_if_needed: + description: Enable the distribute_if_needed option when activating the SWIM image. type: bool - imageName: + image_name: description: SWIM image's name type: str - deviceSerialNumber: + device_serial_number: description: Device serial number where the image needs to be activated type: str - deviceIPAddress: + device_ip_address: description: Device IP address where the image needs to be activated type: str - deviceHostname: + device_hostname: description: Device hostname where the image needs to be activated type: str - deviceMacAddress: + device_mac_address: description: Device MAC address where the image needs to be activated type: str - scheduleValidate: + schedule_validate: description: ScheduleValidate query parameter. ScheduleValidate, validates data - before schedule (Optional). + before schedule (optional). type: bool requirements: - dnacentersdk == 2.4.5 @@ -189,6 +323,8 @@ notes: post /dna/intent/api/v1/image/distribution, post /dna/intent/api/v1/image/activation/device, + - Added the parameter 'dnac_api_task_timeout', 'dnac_task_poll_interval' options in v6.13.2. + """ EXAMPLES = r""" @@ -201,41 +337,140 @@ EXAMPLES = r""" dnac_port: "{{dnac_port}}" dnac_version: "{{dnac_version}}" dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" dnac_log: True config: - - importImageDetails: - type: string - urlDetails: + - import_image_details: + type: remote + url_details: payload: - - sourceURL: string - isThirdParty: bool - imageFamily: string - vendor: string - applicationType: string - scheduleAt: string - scheduleDesc: string - scheduleOrigin: string - taggingDetails: - imageName: string - deviceRole: string - deviceFamilyName: string - siteName: string - tagging: bool - imageDistributionDetails: - imageName: string - deviceSerialNumber: string - imageActivationDetails: - scheduleValidate: bool - activateLowerImageVersion: bool - distributeIfNeeded: bool - deviceSerialNumber: string - imageName: string + - source_url: "http://10.10.10.10/stda/cat9k_iosxe.17.12.01.SPA.bin" + is_third_party: False + tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: True + image_distribution_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_serial_number: FJC2327U0S2 + image_activation_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + schedule_validate: False + activate_lower_image_version: False + distribute_if_needed: True + device_serial_number: FJC2327U0S2 + +- 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_level: "{{dnac_log_level}}" + dnac_log: True + config: + - import_image_details: + type: local + local_image_details: + file_path: /Users/Downloads/cat9k_iosxe.17.12.01.SPA.bin + is_third_party: False + tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: True + +- name: Tag the given image as golden and load it on device + 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_level: "{{dnac_log_level}}" + dnac_log: True + config: + - tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: True + +- name: Un-tagged the given image as golden and load it on device + 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_level: "{{dnac_log_level}}" + dnac_log: True + config: + - tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: False + +- name: Distribute the given image on devices associated to that site with specified role. + 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_level: "{{dnac_log_level}}" + dnac_log: True + config: + - image_distribution_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + site_name: Global/USA/San Francisco/BGL_18 + device_role: ALL + device_family_name: Switches and Hubs + device_series_name: Cisco Catalyst 9300 Series Switches + +- name: Activate the given image on devices associated to that site with specified role. + 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_level: "{{dnac_log_level}}" + dnac_log: True + config: + - image_activation_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + site_name: Global/USA/San Francisco/BGL_18 + device_role: ALL + device_family_name: Switches and Hubs + device_series_name: Cisco Catalyst 9300 Series Switches + scehdule_validate: False + activate_lower_image_version: True + distribute_if_needed: True + """ RETURN = r""" #Case: SWIM image is successfully imported, tagged as golden, distributed and activated on a device response: - description: A dictionary with activation details as returned by the DNAC Python SDK + description: A dictionary with activation details as returned by the Catalyst Center Python SDK returned: always type: dict sample: > @@ -259,85 +494,88 @@ response: """ -import copy from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( - DNACSDK, + DnacBase, validate_list_of_dicts, - log, get_dict_result, ) from ansible.module_utils.basic import AnsibleModule +import os +import time -class DnacSwims: +class DnacSwims(DnacBase): + """Class containing member attributes for Swim intent module""" def __init__(self, module): - self.module = module - self.params = module.params - self.config = copy.deepcopy(module.params.get("config")) - self.have = {} - self.want_create = {} - self.diff_create = [] - self.validated = [] - dnac_params = self.get_dnac_params(self.params) - log(str(dnac_params)) - self.dnac = DNACSDK(params=dnac_params) - self.log = dnac_params.get("dnac_log") - - self.result = dict(changed=False, diff=[], response=[], warnings=[]) + super().__init__(module) + self.supported_states = ["merged"] def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', + 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.status = "success" + self.msg = "Configuration is not available in the playbook for validation" + self.log(self.msg, "ERROR") + return self + temp_spec = dict( - importImageDetails=dict(type='dict'), - taggingDetails=dict(type='dict'), - imageDistributionDetails=dict(type='dict'), - imageActivationDetails=dict(type='dict'), + import_image_details=dict(type='dict'), + tagging_details=dict(type='dict'), + image_distribution_details=dict(type='dict'), + image_activation_details=dict(type='dict'), ) - if self.config: - msg = None - # Validate site params - valid_temp, invalid_params = validate_list_of_dicts( - self.config, temp_spec - ) - if invalid_params: - msg = "Invalid parameters in playbook: {0}".format( - "\n".join(invalid_params) - ) - self.module.fail_json(msg=msg) - - self.validated = valid_temp - if self.log: - log(str(valid_temp)) - log(str(self.validated)) - - def get_dnac_params(self, params): - dnac_params = dict( - dnac_host=params.get("dnac_host"), - dnac_port=params.get("dnac_port"), - dnac_username=params.get("dnac_username"), - dnac_password=params.get("dnac_password"), - dnac_verify=params.get("dnac_verify"), - dnac_debug=params.get("dnac_debug"), - dnac_log=params.get("dnac_log") - ) - return dnac_params + self.config = self.camel_to_snake_case(self.config) - def get_task_details(self, id): - result = None - response = self.dnac._exec( - family="task", - function='get_task_by_id', - params={"task_id": id}, + # Validate swim params + valid_temp, invalid_params = validate_list_of_dicts( + self.config, temp_spec ) - if self.log: - log(str(response)) - if isinstance(response, dict): - result = response.get("response") + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format(invalid_params) + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_temp + self.msg = "Successfully validated playbook config params: {0}".format(str(valid_temp)) + self.log(self.msg, "INFO") + self.status = "success" + + return self + + def site_exists(self, site_name): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + tuple: A tuple containing two values: + - site_exists (bool): A boolean indicating whether the site exists (True) or not (False). + - site_id (str or None): The ID of the site if it exists, or None if the site is not found. + Description: + This method checks the existence of a site in the Catalyst Center. If the site is found,it sets 'site_exists' to True, + retrieves the site's ID, and returns both values in a tuple. If the site does not exist, 'site_exists' is set + to False, and 'site_id' is None. If an exception occurs during the site lookup, an exception is raised. + """ - return result - - def site_exists(self): site_exists = False site_id = None response = None @@ -345,15 +583,15 @@ class DnacSwims: response = self.dnac._exec( family="sites", function='get_site', - params={"name": self.want.get("site_name")}, + params={"name": site_name}, ) except Exception as e: - self.module.fail_json(msg="Site not found") + self.msg = "An exception occurred: Site '{0}' does not exist in the Cisco Catalyst Center".format(site_name) + self.log(self.msg, "ERROR") + self.module.fail_json(msg=self.msg) if response: - if self.log: - log(str(response)) - + self.log("Received API response from 'get_site': {0}".format(str(response)), "DEBUG") site = response.get("response") site_id = site[0].get("id") site_exists = True @@ -361,247 +599,629 @@ class DnacSwims: return (site_exists, site_id) def get_image_id(self, name): - # check if given image exists, if exists store image_id + """ + Retrieve the unique image ID based on the provided image name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + name (str): The name of the software image to search for. + Returns: + str: The unique image ID (UUID) corresponding to the given image name. + Raises: + AnsibleFailJson: If the image is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve details about a software image based on its name. + It extracts and returns the image ID if a single matching image is found. If no image or multiple + images are found with the same name, it raises an exception. + """ + image_response = self.dnac._exec( family="software_image_management_swim", function='get_software_image_details', params={"image_name": name}, ) - - if self.log: - log(str(image_response)) - + self.log("Received API response from 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") image_list = image_response.get("response") + if (len(image_list) == 1): image_id = image_list[0].get("imageUuid") - if self.log: - log("Image Id: " + str(image_id)) + self.log("SWIM image '{0}' has the ID: {1}".format(name, image_id), "INFO") else: - self.module.fail_json(msg="Image not found", response=image_response) + error_message = "SWIM image '{0}' could not be found".format(name) + self.log(error_message, "ERROR") + self.module.fail_json(msg=error_message, response=image_response) return image_id + def get_image_name_from_id(self, image_id): + """ + Retrieve the unique image name based on the provided image id. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + id (str): The unique image ID (UUID) of the software image to search for. + Returns: + str: The image name corresponding to the given unique image ID (UUID) + Raises: + AnsibleFailJson: If the image is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve details about a software image based on its id. + It extracts and returns the image name if a single matching image is found. If no image or multiple + images are found with the same name, it raises an exception. + """ + + image_response = self.dnac._exec( + family="software_image_management_swim", + function='get_software_image_details', + params={"image_uuid": image_id}, + ) + self.log("Received API response from 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") + image_list = image_response.get("response") + + if (len(image_list) == 1): + image_name = image_list[0].get("name") + self.log("SWIM image '{0}' has been fetched successfully from Cisco Catalyst Center".format(image_name), "INFO") + else: + error_message = "SWIM image with Id '{0}' could not be found in Cisco Catalyst Center".format(image_id) + self.log(error_message, "ERROR") + self.module.fail_json(msg=error_message, response=image_response) + + return image_name + + def is_image_exist(self, name): + """ + Retrieve the unique image ID based on the provided image name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + name (str): The name of the software image to search for. + Returns: + str: The unique image ID (UUID) corresponding to the given image name. + Raises: + AnsibleFailJson: If the image is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve details about a software image based on its name. + It extracts and returns the image ID if a single matching image is found. If no image or multiple + images are found with the same name, it raises an exception. + """ + + image_exist = False + image_response = self.dnac._exec( + family="software_image_management_swim", + function='get_software_image_details', + params={"image_name": name}, + ) + self.log("Received API response from 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") + image_list = image_response.get("response") + + if (len(image_list) == 1): + image_exist = True + + return image_exist + def get_device_id(self, params): + """ + Retrieve the unique device ID based on the provided parameters. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + params (dict): A dictionary containing parameters to filter devices. + Returns: + str: The unique device ID corresponding to the filtered device. + Description: + This function sends a request to Cisco Catalyst Center to retrieve a list of devices based on the provided + filtering parameters. If a single matching device is found, it extracts and returns the device ID. If + no device or multiple devices match the criteria, it raises an exception. + """ + device_id = None response = self.dnac._exec( family="devices", function='get_device_list', params=params, ) - if self.log: - log(str(response)) + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") device_list = response.get("response") if (len(device_list) == 1): device_id = device_list[0].get("id") - if self.log: - log("Device Id: " + str(device_id)) + self.log("Device Id: {0}".format(str(device_id)), "INFO") else: - self.module.fail_json(msg="Device not found", response=response) + self.msg = "Device with params: '{0}' not found in Cisco Catalyst Center so can't fetch the device id".format(str(params)) + self.log(self.msg, "WARNING") return device_id + def get_device_uuids(self, site_name, device_family, device_role, device_series_name=None): + """ + Retrieve a list of device UUIDs based on the specified criteria. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + site_name (str): The name of the site for which device UUIDs are requested. + device_family (str): The family/type of devices to filter on. + device_role (str): The role of devices to filter on. If None, 'ALL' roles are considered. + device_series_name(str): Specifies the name of the device series. + Returns: + list: A list of device UUIDs that match the specified criteria. + Description: + The function checks the reachability status and role of devices in the given site. + Only devices with "Reachable" status are considered, and filtering is based on the specified + device family and role (if provided). + """ + + device_uuid_list = [] + if not site_name: + site_name = "Global" + self.log("Since site name is not given so it will be fetch all the devices under Global and mark site name as 'Global'", "INFO") + + (site_exists, site_id) = self.site_exists(site_name) + if not site_exists: + self.log("""Site '{0}' is not found in the Cisco Catalyst Center, hence unable to fetch associated + devices.""".format(site_name), "INFO") + return device_uuid_list + + if device_series_name: + if device_series_name.startswith(".*") and device_series_name.endswith(".*"): + self.log("Device series name '{0}' is already in the regex format".format(device_series_name), "INFO") + else: + device_series_name = ".*" + device_series_name + ".*" + + site_params = { + "site_id": site_id, + "device_family": device_family + } + response = self.dnac._exec( + family="sites", + function='get_membership', + op_modifies=True, + params=site_params, + ) + self.log("Received API response from 'get_membership': {0}".format(str(response)), "DEBUG") + response = response['device'] + + site_response_list = [] + for item in response: + if item['response']: + for item_dict in item['response']: + site_response_list.append(item_dict) + + if device_role.upper() == 'ALL': + device_role = None + + device_params = { + 'series': device_series_name, + 'family': device_family, + 'role': device_role + } + device_list_response = self.dnac._exec( + family="devices", + function='get_device_list', + op_modifies=True, + params=device_params, + ) + + device_response = device_list_response.get('response') + if not response or not device_response: + self.log("Failed to retrieve devices associated with the site '{0}' due to empty API response.".format(site_name), "INFO") + return device_uuid_list + + site_memberships_ids, device_response_ids = [], [] + + for item in site_response_list: + if item["reachabilityStatus"] != "Reachable": + self.log("""Device '{0}' is currently '{1}' and cannot be included in the SWIM distribution/activation + process.""".format(item["managementIpAddress"], item["reachabilityStatus"]), "INFO") + continue + self.log("""Device '{0}' from site '{1}' is ready for the SWIM distribution/activation + process.""".format(item["managementIpAddress"], site_name), "INFO") + site_memberships_ids.append(item["instanceUuid"]) + + for item in device_response: + if item["reachabilityStatus"] != "Reachable": + self.log("""Unable to proceed with the device '{0}' for SWIM distribution/activation as its status is + '{1}'.""".format(item["managementIpAddress"], item["reachabilityStatus"]), "INFO") + continue + self.log("""Device '{0}' matches to the specified filter requirements and is set for SWIM + distribution/activation.""".format(item["managementIpAddress"]), "INFO") + device_response_ids.append(item["instanceUuid"]) + + # Find the intersection of device IDs with the response get from get_membership api and get_device_list api with provided filters + device_uuid_list = set(site_memberships_ids).intersection(set(device_response_ids)) + + return device_uuid_list + def get_device_family_identifier(self, family_name): + """ + Retrieve and store the device family identifier based on the provided family name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + family_name (str): The name of the device family for which to retrieve the identifier. + Returns: + None + Raises: + AnsibleFailJson: If the family name is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve a list of device family identifiers.It then + searches for a specific family name within the response and stores its associated identifier. If the family + name is found, the identifier is stored; otherwise, an exception is raised. + """ + have = {} response = self.dnac._exec( family="software_image_management_swim", function='get_device_family_identifiers', ) - if self.log: - log(str(response)) + self.log("Received API response from 'get_device_family_identifiers': {0}".format(str(response)), "DEBUG") 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 - if self.log: - log("Family device indentifier:" + str(device_family_identifier)) + self.log("Family device indentifier: {0}".format(str(device_family_identifier)), "INFO") else: - self.module.fail_json(msg="Family Device Name not found", response=[]) + self.msg = "Device Family: {0} not found".format(str(family_name)) + self.log(self.msg, "ERROR") + self.module.fail_json(msg=self.msg, response=[self.msg]) self.have.update(have) def get_have(self): + """ + Retrieve and store various software image and device details based on user-provided information. + Returns: + self: The current instance of the class with updated 'have' attributes. + Raises: + AnsibleFailJson: If required image or device details are not provided. + Description: + This function populates the 'have' dictionary with details related to software images, site information, + device families, distribution devices, and activation devices based on user-provided data in the 'want' dictionary. + It validates and retrieves the necessary information from Cisco Catalyst Center to support later actions. + """ + if self.want.get("tagging_details"): have = {} tagging_details = self.want.get("tagging_details") - if tagging_details.get("imageName"): - image_id = self.get_image_id(tagging_details.get("imageName")) + if tagging_details.get("image_name"): + name = tagging_details.get("image_name").split("/")[-1] + image_id = self.get_image_id(name) have["tagging_image_id"] = image_id elif self.have.get("imported_image_id"): have["tagging_image_id"] = self.have.get("imported_image_id") else: + self.log("Image details for tagging not provided", "CRITICAL") self.module.fail_json(msg="Image details for tagging not provided", response=[]) # check if given site exists, store siteid # if not then use global site - site_name = tagging_details.get("siteName") + site_name = tagging_details.get("site_name") if site_name: site_exists = False - (site_exists, site_id) = self.site_exists() + (site_exists, site_id) = self.site_exists(site_name) if site_exists: have["site_id"] = site_id - if self.log: - log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) + self.log("Site {0} exists having the site id: {1}".format(site_name, str(site_id)), "DEBUG") else: # For global site, use -1 as siteId have["site_id"] = "-1" - if self.log: - log("Site Name not given by user. Using global site.") + self.log("Site Name not given by user. Using global site.", "WARNING") self.have.update(have) # check if given device family name exists, store indentifier value - family_name = tagging_details.get("deviceFamilyName") + family_name = tagging_details.get("device_image_family_name") self.get_device_family_identifier(family_name) 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 '{0}' exists and has the site ID: {1}".format(site_name, str(site_id)), "DEBUG") + # check if image for distributon is available - if distribution_details.get("imageName"): - image_id = self.get_image_id(distribution_details.get("imageName")) + if distribution_details.get("image_name"): + name = distribution_details.get("image_name").split("/")[-1] + image_id = self.get_image_id(name) have["distribution_image_id"] = image_id elif self.have.get("imported_image_id"): have["distribution_image_id"] = self.have.get("imported_image_id") else: - self.module.fail_json(msg="Image details for distribution not provided", response=[]) + self.log("Image details required for distribution have not been provided", "ERROR") + self.module.fail_json(msg="Image details required for distribution have not been provided", response=[]) device_params = dict( - hostname=distribution_details.get("deviceHostname"), - serial_number=distribution_details.get("deviceSerialNumber"), - management_ip_address=distribution_details.get("deviceIpAddress"), - mac_address=distribution_details.get("deviceMacAddress"), + hostname=distribution_details.get("device_hostname"), + 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) - have["distribution_device_id"] = device_id + + if device_id is not None: + have["distribution_device_id"] = device_id + self.have.update(have) if self.want.get("activation_details"): have = {} activation_details = self.want.get("activation_details") # check if image for activation is available - if activation_details.get("imageName"): - image_id = self.get_image_id(activation_details.get("imageName")) + if activation_details.get("image_name"): + name = activation_details.get("image_name").split("/")[-1] + image_id = self.get_image_id(name) have["activation_image_id"] = image_id elif self.have.get("imported_image_id"): have["activation_image_id"] = self.have.get("imported_image_id") - else: - self.module.fail_json(msg="Image details for activation not provided", response=[]) + self.log("Image details required for activation have not been provided", "ERROR") + self.module.fail_json(msg="Image details required for activation have not been provided", response=[]) + + site_name = activation_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("The site '{0}' exists and has the site ID '{1}'".format(site_name, str(site_id)), "INFO") device_params = dict( - hostname=activation_details.get("deviceHostname"), - serial_number=activation_details.get("deviceSerialNumber"), - management_ip_address=activation_details.get("deviceIpAddress"), - mac_address=activation_details.get("deviceMacAddress"), + hostname=activation_details.get("device_hostname"), + 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) - have["activation_device_id"] = device_id + + if device_id is not None: + have["activation_device_id"] = device_id self.have.update(have) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + + return self + + def get_want(self, config): + """ + Retrieve and store import, tagging, distribution, and activation details from playbook configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): The configuration dictionary containing image import and other details. + Returns: + self: The current instance of the class with updated 'want' attributes. + Raises: + AnsibleFailJson: If an incorrect import type is specified. + Description: + This function parses the playbook configuration to extract information related to image + import, tagging, distribution, and activation. It stores these details in the 'want' dictionary + for later use in the Ansible module. + """ - def get_want(self): want = {} - for image in self.validated: - if image.get("importImageDetails"): - want["import_image"] = True - want["import_type"] = image.get("importImageDetails").get("type").lower() - if want["import_type"] == "url": - want["url_import_details"] = image.get("importImageDetails").get("urlDetails") - elif want["import_type"] == "local": - want["local_import_details"] = image.get("importImageDetails").get("localImageDetails") - else: - self.module.fail_json(msg="Incorrect import type. Supported Values: local or url") + if config.get("import_image_details"): + want["import_image"] = True + want["import_type"] = config.get("import_image_details").get("type").lower() + if want["import_type"] == "remote": + want["url_import_details"] = config.get("import_image_details").get("url_details") + elif want["import_type"] == "local": + want["local_import_details"] = config.get("import_image_details").get("local_image_details") + else: + self.log("The import type '{0}' provided is incorrect. Only 'local' or 'remote' are supported.".format(want["import_type"]), "CRITICAL") + self.module.fail_json(msg="Incorrect import type. Supported Values: local or remote") - want["tagging_details"] = image.get("taggingDetails") - want["distribution_details"] = image.get("imageDistributionDetails") - want["activation_details"] = image.get("imageActivationDetails") + want["tagging_details"] = config.get("tagging_details") + want["distribution_details"] = config.get("image_distribution_details") + want["activation_details"] = config.get("image_activation_details") self.want = want - if self.log: - log(str(self.want)) + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + return self def get_diff_import(self): - if not self.want.get("import_image"): - return - - if self.want.get("import_type") == "url": - image_name = self.want.get("url_import_details").get("payload")[0].get("sourceURL") - url_import_params = dict( - payload=self.want.get("url_import_details").get("payload"), - schedule_at=self.want.get("url_import_details").get("scheduleAt"), - schedule_desc=self.want.get("url_import_details").get("scheduleDesc"), - schedule_origin=self.want.get("url_import_details").get("scheduleOrigin"), - ) - response = self.dnac._exec( - family="software_image_management_swim", - function='import_software_image_via_url', - op_modifies=True, - params=url_import_params, - ) - else: - image_name = self.want.get("local_import_details").get("filePath") - local_import_params = dict( - is_third_party=self.want.get("local_import_details").get("isThirdParty"), - third_party_vendor=self.want.get("local_import_details").get("thirdPartyVendor"), - third_party_image_family=self.want.get("local_import_details").get("thirdPartyImageFamily"), - third_party_application_type=self.want.get("local_import_details").get("thirdPartyApplicationType"), - file_path=self.want.get("local_import_details").get("filePath"), - ) + """ + Check the image import type and fetch the image ID for the imported image for further use. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function checks the type of image import (URL or local) and proceeds with the import operation accordingly. + It then monitors the import task's progress and updates the 'result' dictionary. If the operation is successful, + 'changed' is set to True. + Additionally, if tagging, distribution, or activation details are provided, it fetches the image ID for the + imported image and stores it in the 'have' dictionary for later use. + """ + + try: + import_type = self.want.get("import_type") + + if not import_type: + self.status = "success" + self.msg = "Error: Details required for importing SWIM image. Please provide the necessary information." + self.result['msg'] = self.msg + self.log(self.msg, "WARNING") + self.result['changed'] = False + return self + + if import_type == "remote": + image_name = self.want.get("url_import_details").get("payload")[0].get("source_url") + else: + image_name = self.want.get("local_import_details").get("file_path") + + # Code to check if the image already exists in Catalyst Center + name = image_name.split('/')[-1] + image_exist = self.is_image_exist(name) + + import_key_mapping = { + 'source_url': 'sourceURL', + 'image_family': 'imageFamily', + 'application_type': 'applicationType', + 'is_third_party': 'thirdParty', + } + + if image_exist: + image_id = self.get_image_id(name) + self.have["imported_image_id"] = image_id + self.msg = "Image '{0}' already exists in the Cisco Catalyst Center".format(name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + self.status = "success" + self.result['changed'] = False + return self + + if self.want.get("import_type") == "remote": + 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=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: + file_path = self.want.get("local_import_details").get("file_path") + 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"), + multipart_fields={'file': (os.path.basename(file_path), open(file_path, 'rb'), 'application/octet-stream')}, + multipart_monitor_callback=None + ) + import_function = 'import_local_software_image' + response = self.dnac._exec( family="software_image_management_swim", - function='import_local_software_image', + function=import_function, op_modifies=True, - params=local_import_params, - file_paths=[('file_path', 'file')], + params=import_params, ) + self.log("Received API response from {0}: {1}".format(import_function, str(response)), "DEBUG") - if self.log: - log(str(response)) + task_details = {} + task_id = response.get("response").get("taskId") - task_details = {} - task_id = response.get("response").get("taskId") - while (True): - task_details = self.get_task_details(task_id) - if task_details and \ - ("completed successfully" in task_details.get("progress").lower()): - self.result['changed'] = True - self.result['msg'] = "Image imported successfully" - break + while (True): + task_details = self.get_task_details(task_id) + name = image_name.split('/')[-1] - if task_details and task_details.get("isError"): - if "Image already exists" in task_details.get("failureReason"): - self.result['msg'] = "Image already exists." + if task_details and \ + ("completed successfully" in task_details.get("progress").lower()): + self.result['changed'] = True + self.status = "success" + self.msg = "Swim Image {0} imported successfully".format(name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") break - else: - self.module.fail_json(msg=task_details.get("failureReason"), - response=task_details) - - self.result['response'] = task_details if task_details else response - if not (self.want.get("tagging_details") or self.want.get("distribution_details") - or self.want.get("activation_details")): - return - # Fetch image_id for the imported image for further use - image_name = image_name.split('/')[-1] - image_id = self.get_image_id(image_name) - self.have["imported_image_id"] = image_id + + if task_details and task_details.get("isError"): + if "already exists" in task_details.get("failureReason", ""): + self.msg = "SWIM Image {0} already exists in the Cisco Catalyst Center".format(name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + self.status = "success" + self.result['changed'] = False + break + else: + self.status = "failed" + self.msg = task_details.get("failureReason", "SWIM Image {0} seems to be invalid".format(image_name)) + self.log(self.msg, "WARNING") + self.result['response'] = self.msg + return self + + self.result['response'] = task_details if task_details else response + + # Fetch image_id for the imported image for further use + image_name = image_name.split('/')[-1] + image_id = self.get_image_id(image_name) + self.have["imported_image_id"] = image_id + + return self + + except Exception as e: + self.status = "failed" + self.msg = """Error: Import image details are not provided in the playbook, or the Import Image API was not + triggered successfully. Please ensure the necessary details are provided and verify the status of the Import Image process.""" + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + + return self def get_diff_tagging(self): + """ + Tag or untag a software image as golden based on provided tagging details. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function tags or untags a software image as a golden image in Cisco Catalyst Center based on the provided + tagging details. The tagging action is determined by the value of the 'tagging' attribute + in the 'tagging_details' dictionary.If 'tagging' is True, the image is tagged as golden, and if 'tagging' + is False, the golden tag is removed. The function sends the appropriate request to Cisco Catalyst Center and updates the + task details in the 'result' dictionary. If the operation is successful, 'changed' is set to True. + """ + tagging_details = self.want.get("tagging_details") tag_image_golden = tagging_details.get("tagging") + image_name = self.get_image_name_from_id(self.have.get("tagging_image_id")) + + 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("device_role", "ALL").upper() + ) + + response = self.dnac._exec( + family="software_image_management_swim", + function='get_golden_tag_status_of_an_image', + op_modifies=True, + params=image_params + ) + self.log("Received API response from 'get_golden_tag_status_of_an_image': {0}".format(str(response)), "DEBUG") + + response = response.get('response') + if response: + image_status = response['taggedGolden'] + if image_status and image_status == tag_image_golden: + self.status = "success" + self.result['changed'] = False + self.msg = "SWIM Image '{0}' already tagged as Golden image in Cisco Catalyst Center".format(image_name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + return self + + if not image_status and image_status == tag_image_golden: + self.status = "success" + self.result['changed'] = False + self.msg = "SWIM Image '{0}' already un-tagged from Golden image in Cisco Catalyst Center".format(image_name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + return self if tag_image_golden: image_params = dict( imageId=self.have.get("tagging_image_id"), siteId=self.have.get("site_id"), deviceFamilyIdentifier=self.have.get("device_family_identifier"), - deviceRole=tagging_details.get("deviceRole") + deviceRole=tagging_details.get("device_role", "ALL").upper() ) - if self.log: - log("Image params for tagging image as golden:" + str(image_params)) + self.log("Parameters for tagging the image as golden: {0}".format(str(image_params)), "INFO") response = self.dnac._exec( family="software_image_management_swim", @@ -609,16 +1229,10 @@ class DnacSwims: op_modifies=True, params=image_params ) + self.log("Received API response from 'tag_as_golden_image': {0}".format(str(response)), "DEBUG") 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") - ) - if self.log: - log("Image params for un-tagging image as golden:" + str(image_params)) + self.log("Parameters for un-tagging the image as golden: {0}".format(str(image_params)), "INFO") response = self.dnac._exec( family="software_image_management_swim", @@ -626,126 +1240,669 @@ class DnacSwims: op_modifies=True, params=image_params ) + self.log("Received API response from 'remove_golden_tag_for_image': {0}".format(str(response)), "DEBUG") - if response: - task_details = {} - task_id = response.get("response").get("taskId") + if not response: + self.status = "failed" + self.msg = "Did not get the response of API so cannot check the Golden tagging status of image - {0}".format(image_name) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + return self + + task_details = {} + task_id = response.get("response").get("taskId") + + while True: task_details = self.get_task_details(task_id) - if not task_details.get("isError"): + + if not task_details.get("isError") and 'successful' in task_details.get("progress"): + self.status = "success" self.result['changed'] = True - self.result['msg'] = task_details.get("progress") + self.msg = task_details.get("progress") + self.result['msg'] = self.msg + self.result['response'] = self.msg + self.log(self.msg, "INFO") + break + elif task_details.get("isError"): + failure_reason = task_details.get("failureReason", "") + if failure_reason and "An inheritted tag cannot be un-tagged" in failure_reason: + self.status = "failed" + self.result['changed'] = False + self.msg = failure_reason + self.result['msg'] = failure_reason + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + break + else: + error_message = task_details.get("failureReason", "Error: while tagging/un-tagging the golden swim image.") + self.status = "failed" + self.msg = error_message + self.result['msg'] = error_message + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + break - self.result['response'] = task_details if task_details else response + return self + + def get_device_ip_from_id(self, device_id): + """ + Retrieve the management IP address of a device from Cisco Catalyst Center using its ID. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - device_id (str): The unique identifier of the device in Cisco Catalyst Center. + Returns: + str: The management IP address of the specified device. + Raises: + Exception: If there is an error while retrieving the response from Cisco Catalyst Center. + Description: + This method queries Cisco Catalyst Center for the device details based on its unique identifier (ID). + It uses the 'get_device_list' function in the 'devices' family, extracts the management IP address + from the response, and returns it. If any error occurs during the process, an exception is raised + with an appropriate error message logged. + """ + + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"id": device_id} + ) + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get('response')[0] + device_ip = response.get("managementIpAddress") + + return device_ip + except Exception as e: + error_message = "Error occurred while getting the response of device from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + def check_swim_task_status(self, swim_task_dict, swim_task_name): + """ + Check the status of the SWIM (Software Image Management) task for each device. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + swim_task_dict (dict): A dictionary containing the mapping of device IP address to the respective task ID. + swim_task_name (str): The name of the SWIM task being checked which is either Distribution or Activation. + Returns: + tuple: A tuple containing two elements: + - device_ips_list (list): A list of device IP addresses for which the SWIM task failed. + - device_count (int): The count of devices for which the SWIM task was successful. + Description: + This function iterates through the distribution_task_dict, which contains the mapping of + device IP address to their respective task ID. It checks the status of the SWIM task for each device by + repeatedly querying for task details until the task is either completed successfully or fails. If the task + is successful, the device count is incremented. If the task fails, an error message is logged, and the device + IP is appended to the device_ips_list and return a tuple containing the device_ips_list and device_count. + """ + + device_ips_list = [] + device_count = 0 + + for device_ip, task_id in swim_task_dict.items(): + start_time = time.time() + max_timeout = self.params.get('dnac_api_task_timeout') + + while (True): + end_time = time.time() + if (end_time - start_time) >= max_timeout: + self.log("""Max timeout of {0} sec has reached for the task id '{1}' for the device '{2}' and unexpected + task status so moving out to next task id""".format(max_timeout, task_id, device_ip), "WARNING") + device_ips_list.append(device_ip) + break + + task_details = self.get_task_details(task_id) + + if not task_details.get("isError") and \ + ("completed successfully" in task_details.get("progress")): + self.result['changed'] = True + self.status = "success" + self.log("Image {0} successfully for the device '{1}".format(swim_task_name, device_ip), "INFO") + device_count += 1 + break + + if task_details.get("isError"): + error_msg = "Image {0} gets failed for the device '{1}'".format(swim_task_name, device_ip) + self.log(error_msg, "ERROR") + self.result['response'] = task_details + device_ips_list.append(device_ip) + break + time.sleep(self.params.get('dnac_task_poll_interval')) + + return device_ips_list, device_count def get_diff_distribution(self): + """ + Get image distribution parameters from the playbook and trigger image distribution. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function retrieves image distribution parameters from the playbook's 'distribution_details' and triggers + the distribution of the specified software image to the specified device. It monitors the distribution task's + progress and updates the 'result' dictionary. If the operation is successful, 'changed' is set to True. + """ + distribution_details = self.want.get("distribution_details") - distribution_params = dict( - payload=[dict( - deviceUuid=self.have.get("distribution_device_id"), - imageUuid=self.have.get("distribution_image_id") + site_name = distribution_details.get("site_name") + device_family = distribution_details.get("device_family_name") + device_role = distribution_details.get("device_role", "ALL") + device_series_name = distribution_details.get("device_series_name") + device_uuid_list = self.get_device_uuids(site_name, device_family, device_role, device_series_name) + image_id = self.have.get("distribution_image_id") + self.complete_successful_distribution = False + self.partial_successful_distribution = False + self.single_device_distribution = False + + if self.have.get("distribution_device_id"): + + distribution_params = dict( + payload=[dict( + deviceUuid=self.have.get("distribution_device_id"), + imageUuid=image_id + )] + ) + self.log("Distribution Params: {0}".format(str(distribution_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_distribution', + op_modifies=True, + params=distribution_params, + ) + self.log("Received API response from 'trigger_software_image_distribution': {0}".format(str(response)), "DEBUG") + + if response: + task_details = {} + task_id = response.get("response").get("taskId") + + while (True): + task_details = self.get_task_details(task_id) + + if not task_details.get("isError") and \ + ("completed successfully" in task_details.get("progress")): + self.result['changed'] = True + self.status = "success" + self.single_device_distribution = True + self.result['msg'] = "Image with Id {0} Distributed Successfully".format(image_id) + break + + if task_details.get("isError"): + self.status = "failed" + self.msg = "Image with Id {0} Distribution Failed".format(image_id) + self.log(self.msg, "ERROR") + self.result['response'] = task_details + break + + self.result['response'] = task_details if task_details else response + + return self + + if len(device_uuid_list) == 0: + self.status = "success" + self.msg = "The SWIM image distribution task could not proceed because no eligible devices were found." + self.result['msg'] = self.msg + self.log(self.msg, "WARNING") + return self + + self.log("Device UUIDs involved in Image Distribution: {0}".format(str(device_uuid_list)), "INFO") + distribution_task_dict = {} + + for device_uuid in device_uuid_list: + device_management_ip = self.get_device_ip_from_id(device_uuid) + distribution_params = dict( + payload=[dict( + deviceUuid=device_uuid, + imageUuid=image_id + )] + ) + self.log("Distribution Params: {0}".format(str(distribution_params)), "INFO") + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_distribution', + op_modifies=True, + params=distribution_params, + ) + self.log("Received API response from 'trigger_software_image_distribution': {0}".format(str(response)), "DEBUG") + + if response: + task_details = {} + task_id = response.get("response").get("taskId") + distribution_task_dict[device_management_ip] = task_id + + device_ips_list, device_distribution_count = self.check_swim_task_status(distribution_task_dict, 'Distribution') + + if device_distribution_count == 0: + self.status = "failed" + 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" + self.complete_successful_distribution = True + self.msg = "Image with Id {0} Distributed Successfully for all devices".format(image_id) + else: + self.result['changed'] = True + self.status = "success" + self.partial_successful_distribution = False + self.msg = "Image with Id '{0}' Distributed and partially successfull".format(image_id) + self.log("For device(s) {0} image Distribution gets failed".format(str(device_ips_list)), "CRITICAL") + + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + + return self + + def get_diff_activation(self): + """ + Get image activation parameters from the playbook and trigger image activation. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function retrieves image activation parameters from the playbook's 'activation_details' and triggers the + activation of the specified software image on the specified device. It monitors the activation task's progress and + updates the 'result' dictionary. If the operation is successful, 'changed' is set to True. + """ + + activation_details = self.want.get("activation_details") + site_name = activation_details.get("site_name") + device_family = activation_details.get("device_family_name") + device_role = activation_details.get("device_role", "ALL") + device_series_name = activation_details.get("device_series_name") + device_uuid_list = self.get_device_uuids(site_name, device_family, device_role, device_series_name) + image_id = self.have.get("activation_image_id") + self.complete_successful_activation = False + self.partial_successful_activation = False + self.single_device_activation = False + + if self.have.get("activation_device_id"): + payload = [dict( + activateLowerImageVersion=activation_details.get("activate_lower_image_version"), + deviceUpgradeMode=activation_details.get("device_upgrade_mode"), + distributeIfNeeded=activation_details.get("distribute_if_needed"), + deviceUuid=self.have.get("activation_device_id"), + imageUuidList=[image_id] )] - ) - if self.log: - log("Distribution Params: " + str(distribution_params)) - response = self.dnac._exec( - family="software_image_management_swim", - function='trigger_software_image_distribution', - op_modifies=True, - params=distribution_params, - ) - if response: + activation_params = dict( + schedule_validate=activation_details.get("scehdule_validate"), + payload=payload + ) + self.log("Activation Params: {0}".format(str(activation_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_activation', + op_modifies=True, + params=activation_params, + ) + self.log("Received API response from 'trigger_software_image_activation': {0}".format(str(response)), "DEBUG") + task_details = {} task_id = response.get("response").get("taskId") + while (True): task_details = self.get_task_details(task_id) + if not task_details.get("isError") and \ ("completed successfully" in task_details.get("progress")): self.result['changed'] = True - self.result['msg'] = "Image Distributed Successfully" + self.result['msg'] = "Image Activated successfully" + self.status = "success" + self.single_device_activation = True break if task_details.get("isError"): - self.module.fail_json(msg="Image Distribution Failed", - response=task_details) + self.msg = "Activation for Image with Id '{0}' gets failed".format(image_id) + self.status = "failed" + self.result['response'] = task_details + self.log(self.msg, "ERROR") + return self self.result['response'] = task_details if task_details else response - def get_diff_activation(self): - activation_details = self.want.get("activation_details") - payload = [dict( - activateLowerImageVersion=activation_details.get("activateLowerImageVersion"), - deviceUpgradeMode=activation_details.get("deviceUpgradeMode"), - distributeIfNeeded=activation_details.get("distributeIfNeeded"), - deviceUuid=self.have.get("activation_device_id"), - imageUuidList=[self.have.get("activation_image_id")] - )] - activation_params = dict( - schedule_validate=activation_details.get("scehduleValidate"), - payload=payload + return self + + if len(device_uuid_list) == 0: + self.status = "success" + self.msg = "The SWIM image activation task could not proceed because no eligible devices were found." + self.result['msg'] = self.msg + self.log(self.msg, "WARNING") + return self + + self.log("Device UUIDs involved in Image Activation: {0}".format(str(device_uuid_list)), "INFO") + activation_task_dict = {} + + for device_uuid in device_uuid_list: + device_management_ip = self.get_device_ip_from_id(device_uuid) + payload = [dict( + activateLowerImageVersion=activation_details.get("activate_lower_image_version"), + deviceUpgradeMode=activation_details.get("device_upgrade_mode"), + distributeIfNeeded=activation_details.get("distribute_if_needed"), + deviceUuid=device_uuid, + imageUuidList=[image_id] + )] + + activation_params = dict( + schedule_validate=activation_details.get("scehdule_validate"), + payload=payload + ) + self.log("Activation Params: {0}".format(str(activation_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_activation', + op_modifies=True, + params=activation_params, + ) + self.log("Received API response from 'trigger_software_image_activation': {0}".format(str(response)), "DEBUG") + + if response: + task_details = {} + task_id = response.get("response").get("taskId") + activation_task_dict[device_management_ip] = task_id + + device_ips_list, device_activation_count = self.check_swim_task_status(activation_task_dict, 'Activation') + + if device_activation_count == 0: + self.status = "failed" + self.msg = "Image with Id '{0}' activation failed for all devices".format(image_id) + elif device_activation_count == len(device_uuid_list): + self.result['changed'] = True + self.status = "success" + self.complete_successful_activation = True + self.msg = "Image with Id '{0}' activated successfully for all devices".format(image_id) + else: + self.result['changed'] = True + self.status = "success" + self.partial_successful_activation = True + self.msg = "Image with Id '{0}' activated and partially successfull".format(image_id) + self.log("For Device(s) {0} Image activation gets Failed".format(str(device_ips_list)), "CRITICAL") + + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + + return self + + def get_diff_merged(self, config): + """ + Get tagging details and then trigger distribution followed by activation if specified in the playbook. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): The configuration dictionary containing tagging, distribution, and activation details. + Returns: + self: The current instance of the class with updated 'result' and 'have' attributes. + Description: + This function checks the provided playbook configuration for tagging, distribution, and activation details. It + then triggers these operations in sequence if the corresponding details are found in the configuration.The + function monitors the progress of each task and updates the 'result' dictionary accordingly. If any of the + operations are successful, 'changed' is set to True. + """ + + if config.get("tagging_details"): + self.get_diff_tagging().check_return_status() + + if config.get("image_distribution_details"): + self.get_diff_distribution().check_return_status() + + if config.get("image_activation_details"): + self.get_diff_activation().check_return_status() + + return self + + def verify_diff_imported(self, import_type): + """ + Verify the successful import of a software image into Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + import_type (str): The type of import, either 'remote' or 'local'. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the successful import of a software image into Cisco Catalyst Center. + It checks whether the image exists in Catalyst Center based on the provided import type. + If the image exists, the status is set to 'success', and a success message is logged. + If the image does not exist, a warning message is logged indicating a potential import failure. + """ + + if import_type == "remote": + image_name = self.want.get("url_import_details").get("payload")[0].get("source_url") + else: + image_name = self.want.get("local_import_details").get("file_path") + + # Code to check if the image already exists in Catalyst Center + name = image_name.split('/')[-1] + image_exist = self.is_image_exist(name) + if image_exist: + self.status = "success" + self.msg = "The requested Image '{0}' imported in the Cisco Catalyst Center and Image presence has been verified.".format(name) + self.log(self.msg, "INFO") + else: + self.log("""The playbook input for SWIM Image '{0}' does not align with the Cisco Catalyst Center, indicating that image + may not have imported successfully.""".format(name), "INFO") + + return self + + def verify_diff_tagged(self): + """ + Verify the Golden tagging status of a software image in Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the tagging status of a software image in Cisco Catalyst Center. + It retrieves tagging details from the input, including the desired tagging status and image ID. + Using the provided image ID, it obtains image parameters required for checking the image status. + The method then queries Catalyst Center to get the golden tag status of the image. + If the image status matches the desired tagging status, a success message is logged. + If there is a mismatch between the playbook input and the Catalyst Center, a warning message is logged. + """ + + tagging_details = self.want.get("tagging_details") + tag_image_golden = tagging_details.get("tagging") + image_id = self.have.get("tagging_image_id") + image_name = self.get_image_name_from_id(image_id) + + 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("device_role", "ALL").upper() ) - if self.log: - log("Activation Params: " + str(activation_params)) + self.log("Parameters for checking the status of image: {0}".format(str(image_params)), "INFO") response = self.dnac._exec( family="software_image_management_swim", - function='trigger_software_image_activation', + function='get_golden_tag_status_of_an_image', op_modifies=True, - params=activation_params, + params=image_params ) - task_details = {} - task_id = response.get("response").get("taskId") - while (True): - task_details = self.get_task_details(task_id) - if not task_details.get("isError") and \ - ("completed successfully" in task_details.get("progress")): - self.result['changed'] = True - self.result['msg'] = "Image activated successfully" - break - - if task_details.get("isError"): - self.module.fail_json(msg="Image Activation Failed", - response=task_details) + self.log("Received API response from 'get_golden_tag_status_of_an_image': {0}".format(str(response)), "DEBUG") - self.result['response'] = task_details if task_details else response + response = response.get('response') + if response: + image_status = response['taggedGolden'] + if image_status == tag_image_golden: + if tag_image_golden: + self.msg = """The requested image '{0}' has been tagged as golden in the Cisco Catalyst Center and + its status has been successfully verified.""".format(image_name) + self.log(self.msg, "INFO") + else: + self.msg = """The requested image '{0}' has been un-tagged as golden in the Cisco Catalyst Center and + image status has been verified.""".format(image_name) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between the playbook input for tagging/un-tagging image as golden and the Cisco Catalyst Center indicates that + the tagging/un-tagging task was not executed successfully.""", "INFO") + + return self + + def verify_diff_distributed(self): + """ + Verify the distribution status of a software image in Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + import_type (str): The type of import, either 'url' or 'local'. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the distribution status of a software image in Cisco Catalyst Center. + It retrieves the image ID and name from the input and if distribution device ID is provided, it checks the distribution status for that + list of specific device and logs the info message based on distribution status. + """ + + image_id = self.have.get("distribution_image_id") + image_name = self.get_image_name_from_id(image_id) + + if self.have.get("distribution_device_id"): + if self.single_device_distribution: + self.msg = """The requested image '{0}', associated with the device ID '{1}', has been successfully distributed in the Cisco Catalyst Center + and its status has been verified.""".format(image_name, self.have.get("distribution_device_id")) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between the playbook input for distributing the image to the device with ID '{0}' and the actual state in the + Cisco Catalyst Center suggests that the distribution task might not have been executed + successfully.""".format(self.have.get("distribution_device_id")), "INFO") + elif self.complete_successful_distribution: + self.msg = """The requested image '{0}', with ID '{1}', has been successfully distributed to all devices within the specified + site in the Cisco Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + elif self.partial_successful_distribution: + self.msg = """T"The requested image '{0}', with ID '{1}', has been partially distributed across some devices in the Cisco Catalyst + Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + else: + self.msg = """The requested image '{0}', with ID '{1}', failed to be distributed across devices in the Cisco Catalyst + Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + + return self + + def verify_diff_activated(self): + """ + Verify the activation status of a software image in Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the activation status of a software image in Cisco Catalyst Center and retrieves the image ID and name from + the input. If activation device ID is provided, it checks the activation status for that specific device. Based on activation status + a corresponding message is logged. + """ + + image_id = self.have.get("activation_image_id") + image_name = self.get_image_name_from_id(image_id) + + if self.have.get("activation_device_id"): + if self.single_device_activation: + self.msg = """The requested image '{0}', associated with the device ID '{1}', has been successfully activated in the Cisco Catalyst + Center and its status has been verified.""".format(image_name, self.have.get("activation_device_id")) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between the playbook's input for activating the image '{0}' on the device with ID '{1}' and the actual state in + the Cisco Catalyst Center suggests that the activation task might not have been executed + successfully.""".format(image_name, self.have.get("activation_device_id")), "INFO") + elif self.complete_successful_activation: + self.msg = """The requested image '{0}', with ID '{1}', has been successfully activated on all devices within the specified site in the + Cisco Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + elif self.partial_successful_activation: + self.msg = """"The requested image '{0}', with ID '{1}', has been partially activated on some devices in the Cisco + Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + else: + self.msg = """The activation of the requested image '{0}', with ID '{1}', failed on devices in the Cisco + Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Importing/Tagging/Distributing/Actiavting) the SWIM Image in devices in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst Center by retrieving the current state + (have) and desired state (want) of the configuration, logs the states, and validates whether the specified + SWIM operation performed or not. + """ + + self.get_have() + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + import_type = self.want.get("import_type") + if import_type: + self.verify_diff_imported(import_type).check_return_status() + + tagged = self.want.get("tagging_details") + if tagged: + self.verify_diff_tagged().check_return_status() - def get_diff(self): - if self.want.get("tagging_details"): - self.get_diff_tagging() + distribution_details = self.want.get("distribution_details") + if distribution_details: + self.verify_diff_distributed().check_return_status() - if self.want.get("distribution_details"): - self.get_diff_distribution() + activation_details = self.want.get("activation_details") + if activation_details: + self.verify_diff_activated().check_return_status() - if self.want.get("activation_details"): - self.get_diff_activation() + return self def main(): """ main entry point for module execution """ - element_spec = dict( - dnac_host=dict(required=True, type='str'), - dnac_port=dict(type='str', default='443'), - dnac_username=dict(type='str', default='admin', aliases=["user"]), - dnac_password=dict(type='str', no_log=True), - dnac_verify=dict(type='bool', default='True'), - dnac_version=dict(type="str", default="2.2.3.3"), - dnac_debug=dict(type='bool', default=False), - dnac_log=dict(type='bool', default=False), - config=dict(required=True, type='list', elements='dict'), - validate_response_schema=dict(type="bool", default=True), - ) + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged']} + } module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) dnac_swims = DnacSwims(module) - dnac_swims.validate_input() - dnac_swims.get_want() - dnac_swims.get_diff_import() - dnac_swims.get_have() - dnac_swims.get_diff() + state = dnac_swims.params.get("state") + + if state not in dnac_swims.supported_states: + dnac_swims.status = "invalid" + dnac_swims.msg = "State {0} is invalid".format(state) + dnac_swims.check_return_status() + + dnac_swims.validate_input().check_return_status() + config_verify = dnac_swims.params.get("config_verify") + + for config in dnac_swims.validated_config: + dnac_swims.reset_values() + dnac_swims.get_want(config).check_return_status() + dnac_swims.get_diff_import().check_return_status() + dnac_swims.get_have().check_return_status() + dnac_swims.get_diff_state_apply[state](config).check_return_status() + if config_verify: + dnac_swims.verify_diff_state_apply[state](config).check_return_status() module.exit_json(**dnac_swims.result) diff --git a/ansible_collections/cisco/dnac/plugins/modules/swim_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/swim_workflow_manager.py new file mode 100644 index 000000000..a147b4055 --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/swim_workflow_manager.py @@ -0,0 +1,1896 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary, Abhishek Maheshwari") + +DOCUMENTATION = r""" +--- +module: swim_workflow_manager +short_description: workflow_manager module for SWIM related functions +description: +- Manage operation related to image importation, distribution, activation and tagging image as golden +- API to fetch a software image from remote file system using URL for HTTP/FTP and upload it to Catalyst Center. + Supported image files extensions are bin, img, tar, smu, pie, aes, iso, ova, tar_gz and qcow2. +- API to fetch a software image from local file system and upload it to Catalyst Center + Supported image files extensions are bin, img, tar, smu, pie, aes, iso, ova, tar_gz and qcow2. +- API to tag/untag image as golen for a given family of devices +- API to distribute a software image on a given device. Software image must be imported successfully into + Catalyst Center before it can be distributed. +- API to activate a software image on a given device. Software image must be present in the device flash. +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) + Abhishek Maheshwari (@abmahesh) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center config after applying the playbook config. + type: bool + default: False + state: + description: The state of Catalyst Center after module completion. + type: str + choices: [ merged ] + default: merged + config: + description: List of details of SWIM image being managed + type: list + elements: dict + required: True + suboptions: + import_image_details: + description: Details of image being imported + type: dict + suboptions: + type: + description: Specifies the import source, supporting local file import (local) or remote url import (remote). + type: str + local_image_details: + description: Details of the local path of the image to be imported. + type: dict + suboptions: + file_path: + description: Provide the absolute file path needed to import an image from your local system (Eg "/path/to/your/file"). + Accepted files formats are - .gz,.bin,.img,.tar,.smu,.pie,.aes,.iso,.ova,.tar_gz,.qcow2,.nfvispkg,.zip,.spa,.rpm. + type: str + is_third_party: + description: Query parameter to determine if the image is from a third party (optional). + type: bool + third_party_application_type: + description: Specify the ThirdPartyApplicationType query parameter to indicate the type of third-party application. Allowed + values include WLC, LINUX, FIREWALL, WINDOWS, LOADBALANCER, THIRDPARTY, etc.(optional). + WLC (Wireless LAN Controller) - It's a network device that manages and controls multiple wireless access points (APs) in a + centralized manner. + LINUX - It's an open-source operating system that provides a complete set of software packages and utilities. + FIREWALL - It's a network security device that monitors and controls incoming and outgoing network traffic based on + predetermined security rules.It acts as a barrier between a trusted internal network and untrusted external networks + (such as the internet), preventing unauthorized access. + WINDOWS - It's an operating system known for its graphical user interface (GUI) support, extensive compatibility with hardware + and software, and widespread use across various applications. + LOADBALANCER - It's a network device or software application that distributes incoming network traffic across multiple servers + or resources. + THIRDPARTY - It refers to third-party images or applications that are not part of the core system. + NAM (Network Access Manager) - It's a network management tool or software application that provides centralized control and + monitoring of network access policies, user authentication, and device compliance. + WAN Optimization - It refers to techniques and technologies used to improve the performance and efficiency of WANs. It includes + various optimization techniques such as data compression, caching, protocol optimization, and traffic prioritization to reduce + latency, increase throughput, and improve user experience over WAN connections. + Unknown - It refers to an unspecified or unrecognized application type. + Router - It's a network device that forwards data packets between computer networks. They are essential for connecting multiple + networks together and directing traffic between them. + type: str + third_party_image_family: + description: Provide the ThirdPartyImageFamily query parameter to identify the family of the third-party image. Image Family name + like PALOALTO, RIVERBED, FORTINET, CHECKPOINT, SILVERPEAK etc. (optional). + type: str + third_party_vendor: + description: Include the ThirdPartyVendor query parameter to specify the vendor of the third party. + type: str + url_details: + description: URL details for SWIM import + type: dict + suboptions: + payload: + description: Swim Import Via Url's payload. + type: list + elements: dict + suboptions: + application_type: + description: An optional parameter that specifies the type of application. Allowed values include WLC, LINUX, FIREWALL, WINDOWS, + LOADBALANCER, THIRDPARTY, etc. This is only applicable for third-party image types(optional). + WLC (Wireless LAN Controller) - It's network device that manages and controls multiple wireless access points (APs) in a + centralized manner. + LINUX - It's an open source which provide complete operating system with a wide range of software packages and utilities. + FIREWALL - It's a network security device that monitors and controls incoming and outgoing network traffic based on + predetermined security rules.It acts as a barrier between a trusted internal network and untrusted external networks + (such as the internet), preventing unauthorized access. + WINDOWS - It's an OS which provides GUI support for various applications, and extensive compatibility with hardware + and software. + LOADBALANCER - It's a network device or software application that distributes incoming network traffic across multiple servers + or resources. + THIRDPARTY - It refers to third-party images or applications that are not part of the core system. + NAM (Network Access Manager) - It's a network management tool or software application that provides centralized control and + monitoring of network access policies, user authentication, and device compliance. + WAN Optimization - It refers to techniques and technologies used to improve the performance and efficiency of WANs. It includes + various optimization techniques such as data compression, caching, protocol optimization, and traffic prioritization to reduce + latency, increase throughput, and improve user experience over WAN connections. + Unknown - It refers to an unspecified or unrecognized application type. + Router - It's a network device that forwards data packets between computer networks. They are essential for connecting multiple + networks together and directing traffic between them. + type: str + image_family: + description: Represents the name of the image family and is applicable only when uploading third-party images. Image Family name + like PALOALTO, RIVERBED, FORTINET, CHECKPOINT, SILVERPEAK etc. (optional). + type: str + source_url: + description: A mandatory parameter for importing a SWIM image via a remote URL. This parameter is required when using a URL + to import an image..(For example, http://{host}/swim/cat9k_isoxe.16.12.10s.SPA.bin, + ftp://user:password@{host}/swim/cat9k_isoxe.16.12.10s.SPA.iso) + type: str + is_third_party: + description: Flag indicates whether the image is uploaded from a third party (optional). + type: bool + vendor: + description: The name of the vendor, that applies only to third-party image types when importing via URL (optional). + type: str + schedule_at: + description: ScheduleAt query parameter. Epoch Time (The number of milli-seconds since + January 1 1970 UTC) at which the distribution should be scheduled (optional). + type: str + schedule_desc: + description: ScheduleDesc query parameter. Custom Description (optional). + type: str + schedule_origin: + description: ScheduleOrigin query parameter. Originator of this call (optional). + type: str + tagging_details: + description: Details for tagging or untagging an image as golden + type: dict + suboptions: + image_name: + description: SWIM image name which will be tagged or untagged as golden. + type: str + device_role: + description: Defines the device role, with permissible values including ALL, UNKNOWN, ACCESS, BORDER ROUTER, + DISTRIBUTION, and CORE. + ALL - This role typically represents all devices within the network, regardless of their specific roles or functions. + UNKNOWN - This role is assigned to devices whose roles or functions have not been identified or classified within Cisco Catalsyt Center. + This could happen if the platform is unable to determine the device's role based on available information. + ACCESS - This role typically represents switches or access points that serve as access points for end-user devices to connect to the network. + These devices are often located at the edge of the network and provide connectivity to end-user devices. + BORDER ROUTER - These are devices that connect different network domains or segments together. They often serve as + gateways between different networks, such as connecting an enterprise network to the internet or connecting + multiple branch offices. + DISTRIBUTION - This role represents function as distribution switches or routers in hierarchical network designs. They aggregate traffic + from access switches and route it toward the core of the network or toward other distribution switches. + CORE - This role typically represents high-capacity switches or routers that form the backbone of the network. They handle large volumes + of traffic and provide connectivity between different parts of network, such as connecting distribution switches or + providing interconnection between different network segments. + type: str + device_image_family_name: + description: Device Image family name(Eg Cisco Catalyst 9300 Switch) + type: str + site_name: + description: Site name for which SWIM image will be tagged/untagged as golden. + If not provided, SWIM image will be mapped to global site. + type: str + tagging: + description: Booelan value to tag/untag SWIM image as golden + If True then the given image will be tagged as golden. + If False then the given image will be un-tagged as golden. + type: bool + image_distribution_details: + description: Details for SWIM image distribution. Device on which the image needs to distributed + can be speciifed using any of the following parameters - deviceSerialNumber, + deviceIPAddress, deviceHostname or deviceMacAddress. + type: dict + suboptions: + device_role: + description: Device Role and permissible Values are ALL, UNKNOWN, ACCESS, BORDER ROUTER, + DISTRIBUTION and CORE. + ALL - This role typically represents all devices within the network, regardless of their specific roles or functions. + UNKNOWN - This role is assigned to devices whose roles or functions have not been identified or classified within Cisco Catalsyt Center. + This could happen if the platform is unable to determine the device's role based on available information. + ACCESS - This role typically represents switches or access points that serve as access points for end-user devices to connect to the network. + These devices are often located at the edge of the network and provide connectivity to end-user devices. + BORDER ROUTER - These are devices that connect different network domains or segments together. They often serve as + gateways between different networks, such as connecting an enterprise network to the internet or connecting + multiple branch offices. + DISTRIBUTION - This role represents function as distribution switches or routers in hierarchical network designs. They aggregate traffic + from access switches and route it toward the core of the network or toward other distribution switches. + CORE - This role typically represents high-capacity switches or routers that form the backbone of the network. They handle large volumes + of traffic and provide connectivity between different parts of network, such as connecting distribution switches or + providing interconnection between different network segments. + type: str + device_family_name: + description: Specify the name of the device family such as Switches and Hubs, etc. + type: str + site_name: + description: Used to get device details associated to this site. + type: str + device_series_name: + description: This parameter specifies the name of the device series. It is used to identify a specific series of devices, + such as Cisco Catalyst 9300 Series Switches, within the Cisco Catalyst Center. + type: str + version_added: 6.12.0 + image_name: + description: SWIM image's name + type: str + device_serial_number: + description: Device serial number where the image needs to be distributed + type: str + device_ip_address: + description: Device IP address where the image needs to be distributed + type: str + device_hostname: + description: Device hostname where the image needs to be distributed + type: str + device_mac_address: + description: Device MAC address where the image needs to be distributed + type: str + image_activation_details: + description: Details for SWIM image activation. Device on which the image needs to activated + can be speciifed using any of the following parameters - deviceSerialNumber, + deviceIPAddress, deviceHostname or deviceMacAddress. + type: dict + suboptions: + device_role: + description: Defines the device role, with permissible values including ALL, UNKNOWN, ACCESS, BORDER ROUTER, + DISTRIBUTION, and CORE. + type: str + device_family_name: + description: Specify the name of the device family such as Switches and Hubs, etc. + type: str + site_name: + description: Used to get device details associated to this site. + type: str + device_series_name: + description: This parameter specifies the name of the device series. It is used to identify a specific series of devices, + such as Cisco Catalyst 9300 Series Switches, within the Cisco Catalyst Center. + type: str + version_added: 6.12.0 + activate_lower_image_version: + description: ActivateLowerImageVersion flag. + type: bool + device_upgrade_mode: + description: It specifies the mode of upgrade to be applied to the devices having the following values - 'install', 'bundle', and 'currentlyExists'. + install - This mode instructs Cisco Catalyst Center to perform a clean installation of the new image on the target devices. + When this mode is selected, the existing image on the device is completely replaced with the new image during the upgrade process. + This ensures that the device runs only the new image version after the upgrade is completed. + bundle - This mode instructs Cisco Catalyst Center bundles the new image with the existing image on the device before initiating + the upgrade process. This mode allows for a more efficient upgrade process by preserving the existing image on the device while + adding the new image as an additional bundle. After the upgrade, the device can run either the existing image or the new bundled + image, depending on the configuration. + currentlyExists - This mode instructs Cisco Catalyst Center to checks if the target devices already have the desired image version + installed. If image already present on devices, no action is taken and upgrade process is skipped for those devices. This mode + is useful for avoiding unnecessary upgrades on devices that already have the correct image version installed, thereby saving time. + type: str + distribute_if_needed: + description: Enable the distribute_if_needed option when activating the SWIM image. + type: bool + image_name: + description: SWIM image's name + type: str + device_serial_number: + description: Device serial number where the image needs to be activated + type: str + device_ip_address: + description: Device IP address where the image needs to be activated + type: str + device_hostname: + description: Device hostname where the image needs to be activated + type: str + device_mac_address: + description: Device MAC address where the image needs to be activated + type: str + schedule_validate: + description: ScheduleValidate query parameter. ScheduleValidate, validates data + before schedule (optional). + type: bool +requirements: +- dnacentersdk == 2.4.5 +- python >= 3.5 +notes: + - SDK Method used are + software_image_management_swim.SoftwareImageManagementSwim.import_software_image_via_url, + software_image_management_swim.SoftwareImageManagementSwim.tag_as_golden_image, + software_image_management_swim.SoftwareImageManagementSwim.trigger_software_image_distribution, + software_image_management_swim.SoftwareImageManagementSwim.trigger_software_image_activation, + + - Paths used are + post /dna/intent/api/v1/image/importation/source/url, + post /dna/intent/api/v1/image/importation/golden, + post /dna/intent/api/v1/image/distribution, + post /dna/intent/api/v1/image/activation/device, + + - Added the parameter 'dnac_api_task_timeout', 'dnac_task_poll_interval' options in v6.13.2. + +""" + +EXAMPLES = r""" +- name: Import an image from a URL, tag it as golden and load it on device + cisco.dnac.swim_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + config: + - import_image_details: + type: remote + url_details: + payload: + - source_url: "http://10.10.10.10/stda/cat9k_iosxe.17.12.01.SPA.bin" + is_third_party: False + tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: True + image_distribution_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_serial_number: FJC2327U0S2 + image_activation_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + schedule_validate: False + activate_lower_image_version: False + distribute_if_needed: True + device_serial_number: FJC2327U0S2 + +- name: Import an image from local, tag it as golden. + cisco.dnac.swim_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + config: + - import_image_details: + type: local + local_image_details: + file_path: /Users/Downloads/cat9k_iosxe.17.12.01.SPA.bin + is_third_party: False + tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: True + +- name: Tag the given image as golden and load it on device + cisco.dnac.swim_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + config: + - tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: True + +- name: Un-tagged the given image as golden and load it on device + cisco.dnac.swim_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + config: + - tagging_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + device_role: ACCESS + device_image_family_name: Cisco Catalyst 9300 Switch + site_name: Global/USA/San Francisco/BGL_18 + tagging: False + +- name: Distribute the given image on devices associated to that site with specified role. + cisco.dnac.swim_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + config: + - image_distribution_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + site_name: Global/USA/San Francisco/BGL_18 + device_role: ALL + device_family_name: Switches and Hubs + device_series_name: Cisco Catalyst 9300 Series Switches + +- name: Activate the given image on devices associated to that site with specified role. + cisco.dnac.swim_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log_level: "{{dnac_log_level}}" + dnac_log: True + config: + - image_activation_details: + image_name: cat9k_iosxe.17.12.01.SPA.bin + site_name: Global/USA/San Francisco/BGL_18 + device_role: ALL + device_family_name: Switches and Hubs + device_series_name: Cisco Catalyst 9300 Series Switches + scehdule_validate: False + activate_lower_image_version: True + distribute_if_needed: True + +""" + +RETURN = r""" +#Case: SWIM image is successfully imported, tagged as golden, distributed and activated on a device +response: + description: A dictionary with activation details as returned by the Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": { + "additionalStatusURL": String, + "data": String, + "endTime": 0, + "id": String, + "instanceTenantId": String, + "isError": bool, + "lastUpdate": 0, + "progress": String, + "rootId": String, + "serviceType": String, + "startTime": 0, + "version": 0 + }, + "msg": String + } + +""" + +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result, +) +from ansible.module_utils.basic import AnsibleModule +import os +import time + + +class Swim(DnacBase): + """Class containing member attributes for Swim workflow_manager module""" + + def __init__(self, module): + super().__init__(module) + self.supported_states = ["merged"] + + def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', + 'self.msg' will describe the validation issues. + """ + + if not self.config: + self.status = "success" + self.msg = "Configuration is not available in the playbook for validation" + self.log(self.msg, "ERROR") + return self + + temp_spec = dict( + import_image_details=dict(type='dict'), + tagging_details=dict(type='dict'), + image_distribution_details=dict(type='dict'), + image_activation_details=dict(type='dict'), + ) + + # Validate swim params + valid_temp, invalid_params = validate_list_of_dicts( + self.config, temp_spec + ) + + if invalid_params: + self.msg = "Invalid parameters in playbook: {0}".format(invalid_params) + self.log(self.msg, "ERROR") + self.status = "failed" + return self + + self.validated_config = valid_temp + self.msg = "Successfully validated playbook config params: {0}".format(str(valid_temp)) + self.log(self.msg, "INFO") + self.status = "success" + + return self + + def site_exists(self, site_name): + """ + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + tuple: A tuple containing two values: + - site_exists (bool): A boolean indicating whether the site exists (True) or not (False). + - site_id (str or None): The ID of the site if it exists, or None if the site is not found. + Description: + This method checks the existence of a site in the Catalyst Center. If the site is found,it sets 'site_exists' to True, + retrieves the site's ID, and returns both values in a tuple. If the site does not exist, 'site_exists' is set + to False, and 'site_id' is None. If an exception occurs during the site lookup, an exception is raised. + """ + + site_exists = False + site_id = None + response = None + try: + response = self.dnac._exec( + family="sites", + function='get_site', + params={"name": site_name}, + ) + except Exception as e: + self.msg = "An exception occurred: Site '{0}' does not exist in the Cisco Catalyst Center".format(site_name) + self.log(self.msg, "ERROR") + self.module.fail_json(msg=self.msg) + + if response: + self.log("Received API response from 'get_site': {0}".format(str(response)), "DEBUG") + site = response.get("response") + site_id = site[0].get("id") + site_exists = True + + return (site_exists, site_id) + + def get_image_id(self, name): + """ + Retrieve the unique image ID based on the provided image name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + name (str): The name of the software image to search for. + Returns: + str: The unique image ID (UUID) corresponding to the given image name. + Raises: + AnsibleFailJson: If the image is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve details about a software image based on its name. + It extracts and returns the image ID if a single matching image is found. If no image or multiple + images are found with the same name, it raises an exception. + """ + + image_response = self.dnac._exec( + family="software_image_management_swim", + function='get_software_image_details', + params={"image_name": name}, + ) + self.log("Received API response from 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") + image_list = image_response.get("response") + + if (len(image_list) == 1): + image_id = image_list[0].get("imageUuid") + self.log("SWIM image '{0}' has the ID: {1}".format(name, image_id), "INFO") + else: + error_message = "SWIM image '{0}' could not be found".format(name) + self.log(error_message, "ERROR") + self.module.fail_json(msg=error_message, response=image_response) + + return image_id + + def get_image_name_from_id(self, image_id): + """ + Retrieve the unique image name based on the provided image id. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + id (str): The unique image ID (UUID) of the software image to search for. + Returns: + str: The image name corresponding to the given unique image ID (UUID) + Raises: + AnsibleFailJson: If the image is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve details about a software image based on its id. + It extracts and returns the image name if a single matching image is found. If no image or multiple + images are found with the same name, it raises an exception. + """ + + image_response = self.dnac._exec( + family="software_image_management_swim", + function='get_software_image_details', + params={"image_uuid": image_id}, + ) + self.log("Received API response from 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") + image_list = image_response.get("response") + + if (len(image_list) == 1): + image_name = image_list[0].get("name") + self.log("SWIM image '{0}' has been fetched successfully from Cisco Catalyst Center".format(image_name), "INFO") + else: + error_message = "SWIM image with Id '{0}' could not be found in Cisco Catalyst Center".format(image_id) + self.log(error_message, "ERROR") + self.module.fail_json(msg=error_message, response=image_response) + + return image_name + + def is_image_exist(self, name): + """ + Retrieve the unique image ID based on the provided image name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + name (str): The name of the software image to search for. + Returns: + str: The unique image ID (UUID) corresponding to the given image name. + Raises: + AnsibleFailJson: If the image is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve details about a software image based on its name. + It extracts and returns the image ID if a single matching image is found. If no image or multiple + images are found with the same name, it raises an exception. + """ + + image_exist = False + image_response = self.dnac._exec( + family="software_image_management_swim", + function='get_software_image_details', + params={"image_name": name}, + ) + self.log("Received API response from 'get_software_image_details': {0}".format(str(image_response)), "DEBUG") + image_list = image_response.get("response") + + if (len(image_list) == 1): + image_exist = True + + return image_exist + + def get_device_id(self, params): + """ + Retrieve the unique device ID based on the provided parameters. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + params (dict): A dictionary containing parameters to filter devices. + Returns: + str: The unique device ID corresponding to the filtered device. + Description: + This function sends a request to Cisco Catalyst Center to retrieve a list of devices based on the provided + filtering parameters. If a single matching device is found, it extracts and returns the device ID. If + no device or multiple devices match the criteria, it raises an exception. + """ + device_id = None + response = self.dnac._exec( + family="devices", + function='get_device_list', + params=params, + ) + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + + device_list = response.get("response") + if (len(device_list) == 1): + device_id = device_list[0].get("id") + self.log("Device Id: {0}".format(str(device_id)), "INFO") + else: + self.msg = "Device with params: '{0}' not found in Cisco Catalyst Center so can't fetch the device id".format(str(params)) + self.log(self.msg, "WARNING") + + return device_id + + def get_device_uuids(self, site_name, device_family, device_role, device_series_name=None): + """ + Retrieve a list of device UUIDs based on the specified criteria. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + site_name (str): The name of the site for which device UUIDs are requested. + device_family (str): The family/type of devices to filter on. + device_role (str): The role of devices to filter on. If None, 'ALL' roles are considered. + device_series_name(str): Specifies the name of the device series. + Returns: + list: A list of device UUIDs that match the specified criteria. + Description: + The function checks the reachability status and role of devices in the given site. + Only devices with "Reachable" status are considered, and filtering is based on the specified + device family and role (if provided). + """ + + device_uuid_list = [] + if not site_name: + site_name = "Global" + self.log("Site name not specified; defaulting to 'Global' to fetch all devices under this category", "INFO") + + (site_exists, site_id) = self.site_exists(site_name) + if not site_exists: + self.log("""Site '{0}' is not found in the Cisco Catalyst Center, hence unable to fetch associated + devices.""".format(site_name), "INFO") + return device_uuid_list + + if device_series_name: + if device_series_name.startswith(".*") and device_series_name.endswith(".*"): + self.log("Device series name '{0}' is already in the regex format".format(device_series_name), "INFO") + else: + device_series_name = ".*" + device_series_name + ".*" + + site_params = { + "site_id": site_id, + "device_family": device_family + } + response = self.dnac._exec( + family="sites", + function='get_membership', + op_modifies=True, + params=site_params, + ) + self.log("Received API response from 'get_membership': {0}".format(str(response)), "DEBUG") + response = response['device'] + + site_response_list = [] + for item in response: + if item['response']: + for item_dict in item['response']: + site_response_list.append(item_dict) + + if device_role.upper() == 'ALL': + device_role = None + + device_params = { + 'series': device_series_name, + 'family': device_family, + 'role': device_role + } + device_list_response = self.dnac._exec( + family="devices", + function='get_device_list', + op_modifies=True, + params=device_params, + ) + + device_response = device_list_response.get('response') + if not response or not device_response: + self.log("Failed to retrieve devices associated with the site '{0}' due to empty API response.".format(site_name), "INFO") + return device_uuid_list + + site_memberships_ids, device_response_ids = [], [] + + for item in site_response_list: + if item["reachabilityStatus"] != "Reachable": + self.log("""Device '{0}' is currently '{1}' and cannot be included in the SWIM distribution/activation + process.""".format(item["managementIpAddress"], item["reachabilityStatus"]), "INFO") + continue + self.log("""Device '{0}' from site '{1}' is ready for the SWIM distribution/activation + process.""".format(item["managementIpAddress"], site_name), "INFO") + site_memberships_ids.append(item["instanceUuid"]) + + for item in device_response: + if item["reachabilityStatus"] != "Reachable": + self.log("""Unable to proceed with the device '{0}' for SWIM distribution/activation as its status is + '{1}'.""".format(item["managementIpAddress"], item["reachabilityStatus"]), "INFO") + continue + self.log("""Device '{0}' matches to the specified filter requirements and is set for SWIM + distribution/activation.""".format(item["managementIpAddress"]), "INFO") + device_response_ids.append(item["instanceUuid"]) + + # Find the intersection of device IDs with the response get from get_membership api and get_device_list api with provided filters + device_uuid_list = set(site_memberships_ids).intersection(set(device_response_ids)) + + return device_uuid_list + + def get_device_family_identifier(self, family_name): + """ + Retrieve and store the device family identifier based on the provided family name. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + family_name (str): The name of the device family for which to retrieve the identifier. + Returns: + None + Raises: + AnsibleFailJson: If the family name is not found in the response. + Description: + This function sends a request to Cisco Catalyst Center to retrieve a list of device family identifiers.It then + searches for a specific family name within the response and stores its associated identifier. If the family + name is found, the identifier is stored; otherwise, an exception is raised. + """ + + have = {} + response = self.dnac._exec( + family="software_image_management_swim", + function='get_device_family_identifiers', + ) + self.log("Received API response from 'get_device_family_identifiers': {0}".format(str(response)), "DEBUG") + 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 + self.log("Family device indentifier: {0}".format(str(device_family_identifier)), "INFO") + else: + self.msg = "Device Family: {0} not found".format(str(family_name)) + self.log(self.msg, "ERROR") + self.module.fail_json(msg=self.msg, response=self.msg) + self.have.update(have) + + def get_have(self): + """ + Retrieve and store various software image and device details based on user-provided information. + Returns: + self: The current instance of the class with updated 'have' attributes. + Raises: + AnsibleFailJson: If required image or device details are not provided. + Description: + This function populates the 'have' dictionary with details related to software images, site information, + device families, distribution devices, and activation devices based on user-provided data in the 'want' dictionary. + It validates and retrieves the necessary information from Cisco Catalyst Center to support later actions. + """ + + if self.want.get("tagging_details"): + have = {} + tagging_details = self.want.get("tagging_details") + if tagging_details.get("image_name"): + name = tagging_details.get("image_name").split("/")[-1] + image_id = self.get_image_id(name) + have["tagging_image_id"] = image_id + + elif self.have.get("imported_image_id"): + have["tagging_image_id"] = self.have.get("imported_image_id") + + else: + self.log("Image details for tagging not provided", "CRITICAL") + self.module.fail_json(msg="Image details for tagging not provided", response=[]) + + # check if given site exists, store siteid + # if not then use global site + site_name = tagging_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 {0} exists having the site id: {1}".format(site_name, str(site_id)), "DEBUG") + else: + # For global site, use -1 as siteId + have["site_id"] = "-1" + self.log("Site Name not given by user. Using global site.", "WARNING") + + self.have.update(have) + # check if given device family name exists, store indentifier value + family_name = tagging_details.get("device_image_family_name") + self.get_device_family_identifier(family_name) + + 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 '{0}' exists and has the site ID: {1}".format(site_name, str(site_id)), "DEBUG") + + # check if image for distributon is available + if distribution_details.get("image_name"): + name = distribution_details.get("image_name").split("/")[-1] + image_id = self.get_image_id(name) + have["distribution_image_id"] = image_id + + elif self.have.get("imported_image_id"): + have["distribution_image_id"] = self.have.get("imported_image_id") + + else: + self.log("Image details required for distribution have not been provided", "ERROR") + self.module.fail_json(msg="Image details required for distribution have not been provided", response=[]) + + device_params = dict( + hostname=distribution_details.get("device_hostname"), + 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: + have["distribution_device_id"] = device_id + + self.have.update(have) + + if self.want.get("activation_details"): + have = {} + activation_details = self.want.get("activation_details") + # check if image for activation is available + if activation_details.get("image_name"): + name = activation_details.get("image_name").split("/")[-1] + image_id = self.get_image_id(name) + have["activation_image_id"] = image_id + + elif self.have.get("imported_image_id"): + have["activation_image_id"] = self.have.get("imported_image_id") + else: + self.log("Image details required for activation have not been provided", "ERROR") + self.module.fail_json(msg="Image details required for activation have not been provided", response=[]) + + site_name = activation_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("The site '{0}' exists and has the site ID '{1}'".format(site_name, str(site_id)), "INFO") + + device_params = dict( + hostname=activation_details.get("device_hostname"), + 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: + have["activation_device_id"] = device_id + self.have.update(have) + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + + return self + + def get_want(self, config): + """ + Retrieve and store import, tagging, distribution, and activation details from playbook configuration. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): The configuration dictionary containing image import and other details. + Returns: + self: The current instance of the class with updated 'want' attributes. + Raises: + AnsibleFailJson: If an incorrect import type is specified. + Description: + This function parses the playbook configuration to extract information related to image + import, tagging, distribution, and activation. It stores these details in the 'want' dictionary + for later use in the Ansible module. + """ + + want = {} + if config.get("import_image_details"): + want["import_image"] = True + want["import_type"] = config.get("import_image_details").get("type").lower() + if want["import_type"] == "remote": + want["url_import_details"] = config.get("import_image_details").get("url_details") + elif want["import_type"] == "local": + want["local_import_details"] = config.get("import_image_details").get("local_image_details") + else: + self.log("The import type '{0}' provided is incorrect. Only 'local' or 'remote' are supported.".format(want["import_type"]), "CRITICAL") + self.module.fail_json(msg="Incorrect import type. Supported Values: local or remote") + + want["tagging_details"] = config.get("tagging_details") + want["distribution_details"] = config.get("image_distribution_details") + want["activation_details"] = config.get("image_activation_details") + + self.want = want + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + return self + + def get_diff_import(self): + """ + Check the image import type and fetch the image ID for the imported image for further use. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function checks the type of image import (URL or local) and proceeds with the import operation accordingly. + It then monitors the import task's progress and updates the 'result' dictionary. If the operation is successful, + 'changed' is set to True. + Additionally, if tagging, distribution, or activation details are provided, it fetches the image ID for the + imported image and stores it in the 'have' dictionary for later use. + """ + + try: + import_type = self.want.get("import_type") + + if not import_type: + self.status = "success" + self.msg = "Error: Details required for importing SWIM image. Please provide the necessary information." + self.result['msg'] = self.msg + self.log(self.msg, "WARNING") + self.result['changed'] = False + return self + + if import_type == "remote": + image_name = self.want.get("url_import_details").get("payload")[0].get("source_url") + else: + image_name = self.want.get("local_import_details").get("file_path") + + # Code to check if the image already exists in Catalyst Center + name = image_name.split('/')[-1] + image_exist = self.is_image_exist(name) + + import_key_mapping = { + 'source_url': 'sourceURL', + 'image_family': 'imageFamily', + 'application_type': 'applicationType', + 'is_third_party': 'thirdParty', + } + + if image_exist: + image_id = self.get_image_id(name) + self.have["imported_image_id"] = image_id + self.msg = "Image '{0}' already exists in the Cisco Catalyst Center".format(name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + self.status = "success" + self.result['changed'] = False + return self + + if self.want.get("import_type") == "remote": + 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=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: + file_path = self.want.get("local_import_details").get("file_path") + 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"), + multipart_fields={'file': (os.path.basename(file_path), open(file_path, 'rb'), 'application/octet-stream')}, + multipart_monitor_callback=None + ) + import_function = 'import_local_software_image' + + response = self.dnac._exec( + family="software_image_management_swim", + function=import_function, + op_modifies=True, + params=import_params, + ) + self.log("Received API response from {0}: {1}".format(import_function, str(response)), "DEBUG") + + task_details = {} + task_id = response.get("response").get("taskId") + + while (True): + task_details = self.get_task_details(task_id) + name = image_name.split('/')[-1] + + if task_details and \ + ("completed successfully" in task_details.get("progress").lower()): + self.result['changed'] = True + self.status = "success" + self.msg = "Swim Image {0} imported successfully".format(name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + break + + if task_details and task_details.get("isError"): + if "already exists" in task_details.get("failureReason", ""): + self.msg = "SWIM Image {0} already exists in the Cisco Catalyst Center".format(name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + self.status = "success" + self.result['changed'] = False + break + else: + self.status = "failed" + self.msg = task_details.get("failureReason", "SWIM Image {0} seems to be invalid".format(image_name)) + self.log(self.msg, "WARNING") + self.result['response'] = self.msg + return self + + self.result['response'] = task_details if task_details else response + + # Fetch image_id for the imported image for further use + image_name = image_name.split('/')[-1] + image_id = self.get_image_id(image_name) + self.have["imported_image_id"] = image_id + + return self + + except Exception as e: + self.status = "failed" + self.msg = """Error: Import image details are not provided in the playbook, or the Import Image API was not + triggered successfully. Please ensure the necessary details are provided and verify the status of the Import Image process.""" + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + + return self + + def get_diff_tagging(self): + """ + Tag or untag a software image as golden based on provided tagging details. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function tags or untags a software image as a golden image in Cisco Catalyst Center based on the provided + tagging details. The tagging action is determined by the value of the 'tagging' attribute + in the 'tagging_details' dictionary.If 'tagging' is True, the image is tagged as golden, and if 'tagging' + is False, the golden tag is removed. The function sends the appropriate request to Cisco Catalyst Center and updates the + task details in the 'result' dictionary. If the operation is successful, 'changed' is set to True. + """ + + tagging_details = self.want.get("tagging_details") + tag_image_golden = tagging_details.get("tagging") + image_name = self.get_image_name_from_id(self.have.get("tagging_image_id")) + + 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("device_role", "ALL").upper() + ) + + response = self.dnac._exec( + family="software_image_management_swim", + function='get_golden_tag_status_of_an_image', + op_modifies=True, + params=image_params + ) + self.log("Received API response from 'get_golden_tag_status_of_an_image': {0}".format(str(response)), "DEBUG") + + response = response.get('response') + if response: + image_status = response['taggedGolden'] + if image_status and image_status == tag_image_golden: + self.status = "success" + self.result['changed'] = False + self.msg = "SWIM Image '{0}' already tagged as Golden image in Cisco Catalyst Center".format(image_name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + return self + + if not image_status and image_status == tag_image_golden: + self.status = "success" + self.result['changed'] = False + self.msg = "SWIM Image '{0}' already un-tagged from Golden image in Cisco Catalyst Center".format(image_name) + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + return self + + if tag_image_golden: + image_params = dict( + imageId=self.have.get("tagging_image_id"), + siteId=self.have.get("site_id"), + deviceFamilyIdentifier=self.have.get("device_family_identifier"), + deviceRole=tagging_details.get("device_role", "ALL").upper() + ) + self.log("Parameters for tagging the image as golden: {0}".format(str(image_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='tag_as_golden_image', + op_modifies=True, + params=image_params + ) + self.log("Received API response from 'tag_as_golden_image': {0}".format(str(response)), "DEBUG") + + else: + self.log("Parameters for un-tagging the image as golden: {0}".format(str(image_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='remove_golden_tag_for_image', + op_modifies=True, + params=image_params + ) + self.log("Received API response from 'remove_golden_tag_for_image': {0}".format(str(response)), "DEBUG") + + if not response: + self.status = "failed" + self.msg = "Did not get the response of API so cannot check the Golden tagging status of image - {0}".format(image_name) + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + return self + + task_details = {} + task_id = response.get("response").get("taskId") + + while True: + task_details = self.get_task_details(task_id) + + if not task_details.get("isError") and 'successful' in task_details.get("progress"): + self.status = "success" + self.result['changed'] = True + self.msg = task_details.get("progress") + self.result['msg'] = self.msg + self.result['response'] = self.msg + self.log(self.msg, "INFO") + break + elif task_details.get("isError"): + failure_reason = task_details.get("failureReason", "") + if failure_reason and "An inheritted tag cannot be un-tagged" in failure_reason: + self.status = "failed" + self.result['changed'] = False + self.msg = failure_reason + self.result['msg'] = failure_reason + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + break + else: + error_message = task_details.get("failureReason", "Error: while tagging/un-tagging the golden swim image.") + self.status = "failed" + self.msg = error_message + self.result['msg'] = error_message + self.log(self.msg, "ERROR") + self.result['response'] = self.msg + break + + return self + + def get_device_ip_from_id(self, device_id): + """ + Retrieve the management IP address of a device from Cisco Catalyst Center using its ID. + Parameters: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - device_id (str): The unique identifier of the device in Cisco Catalyst Center. + Returns: + str: The management IP address of the specified device. + Raises: + Exception: If there is an error while retrieving the response from Cisco Catalyst Center. + Description: + This method queries Cisco Catalyst Center for the device details based on its unique identifier (ID). + It uses the 'get_device_list' function in the 'devices' family, extracts the management IP address + from the response, and returns it. If any error occurs during the process, an exception is raised + with an appropriate error message logged. + """ + + try: + response = self.dnac._exec( + family="devices", + function='get_device_list', + params={"id": device_id} + ) + self.log("Received API response from 'get_device_list': {0}".format(str(response)), "DEBUG") + response = response.get('response')[0] + device_ip = response.get("managementIpAddress") + + return device_ip + except Exception as e: + error_message = "Error occurred while getting the response of device from Cisco Catalyst Center: {0}".format(str(e)) + self.log(error_message, "ERROR") + raise Exception(error_message) + + def check_swim_task_status(self, swim_task_dict, swim_task_name): + """ + Check the status of the SWIM (Software Image Management) task for each device. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + swim_task_dict (dict): A dictionary containing the mapping of device IP address to the respective task ID. + swim_task_name (str): The name of the SWIM task being checked which is either Distribution or Activation. + Returns: + tuple: A tuple containing two elements: + - device_ips_list (list): A list of device IP addresses for which the SWIM task failed. + - device_count (int): The count of devices for which the SWIM task was successful. + Description: + This function iterates through the distribution_task_dict, which contains the mapping of + device IP address to their respective task ID. It checks the status of the SWIM task for each device by + repeatedly querying for task details until the task is either completed successfully or fails. If the task + is successful, the device count is incremented. If the task fails, an error message is logged, and the device + IP is appended to the device_ips_list and return a tuple containing the device_ips_list and device_count. + """ + + device_ips_list = [] + device_count = 0 + + for device_ip, task_id in swim_task_dict.items(): + start_time = time.time() + + while (True): + end_time = time.time() + max_timeout = self.params.get('dnac_api_task_timeout') + + if (end_time - start_time) >= max_timeout: + self.log("""Max timeout of {0} has reached for the task id '{1}' for the device '{2}' and unexpected + task status so moving out to next task id""".format(max_timeout, task_id, device_ip), "WARNING") + device_ips_list.append(device_ip) + break + + task_details = self.get_task_details(task_id) + + if not task_details.get("isError") and \ + ("completed successfully" in task_details.get("progress")): + self.result['changed'] = True + self.status = "success" + self.log("Image {0} successfully for the device '{1}".format(swim_task_name, device_ip), "INFO") + device_count += 1 + break + + if task_details.get("isError"): + error_msg = "Image {0} gets failed for the device '{1}'".format(swim_task_name, device_ip) + self.log(error_msg, "ERROR") + self.result['response'] = task_details + device_ips_list.append(device_ip) + break + time.sleep(self.params.get('dnac_task_poll_interval')) + + return device_ips_list, device_count + + def get_diff_distribution(self): + """ + Get image distribution parameters from the playbook and trigger image distribution. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function retrieves image distribution parameters from the playbook's 'distribution_details' and triggers + the distribution of the specified software image to the specified device. It monitors the distribution task's + progress and updates the 'result' dictionary. If the operation is successful, 'changed' is set to True. + """ + + distribution_details = self.want.get("distribution_details") + site_name = distribution_details.get("site_name") + device_family = distribution_details.get("device_family_name") + device_role = distribution_details.get("device_role", "ALL") + device_series_name = distribution_details.get("device_series_name") + device_uuid_list = self.get_device_uuids(site_name, device_family, device_role, device_series_name) + image_id = self.have.get("distribution_image_id") + self.complete_successful_distribution = False + self.partial_successful_distribution = False + self.single_device_distribution = False + + if self.have.get("distribution_device_id"): + distribution_params = dict( + payload=[dict( + deviceUuid=self.have.get("distribution_device_id"), + imageUuid=image_id + )] + ) + self.log("Distribution Params: {0}".format(str(distribution_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_distribution', + op_modifies=True, + params=distribution_params, + ) + self.log("Received API response from 'trigger_software_image_distribution': {0}".format(str(response)), "DEBUG") + + if response: + task_details = {} + task_id = response.get("response").get("taskId") + + while (True): + task_details = self.get_task_details(task_id) + + if not task_details.get("isError") and \ + ("completed successfully" in task_details.get("progress")): + self.result['changed'] = True + self.status = "success" + self.single_device_distribution = True + self.result['msg'] = "Image with Id {0} Distributed Successfully".format(image_id) + break + + if task_details.get("isError"): + self.status = "failed" + self.msg = "Image with Id {0} Distribution Failed".format(image_id) + self.log(self.msg, "ERROR") + self.result['response'] = task_details + break + + self.result['response'] = task_details if task_details else response + + return self + + if len(device_uuid_list) == 0: + self.status = "success" + self.msg = "The SWIM image distribution task could not proceed because no eligible devices were found" + self.result['msg'] = self.msg + self.log(self.msg, "WARNING") + return self + + self.log("Device UUIDs involved in Image Distribution: {0}".format(str(device_uuid_list)), "INFO") + distribution_task_dict = {} + + for device_uuid in device_uuid_list: + device_management_ip = self.get_device_ip_from_id(device_uuid) + distribution_params = dict( + payload=[dict( + deviceUuid=device_uuid, + imageUuid=image_id + )] + ) + self.log("Distribution Params: {0}".format(str(distribution_params)), "INFO") + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_distribution', + op_modifies=True, + params=distribution_params, + ) + self.log("Received API response from 'trigger_software_image_distribution': {0}".format(str(response)), "DEBUG") + + if response: + task_details = {} + task_id = response.get("response").get("taskId") + distribution_task_dict[device_management_ip] = task_id + + device_ips_list, device_distribution_count = self.check_swim_task_status(distribution_task_dict, 'Distribution') + + if device_distribution_count == 0: + self.status = "failed" + 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" + self.complete_successful_distribution = True + self.msg = "Image with Id {0} Distributed Successfully for all devices".format(image_id) + else: + self.result['changed'] = True + self.status = "success" + self.partial_successful_distribution = False + self.msg = "Image with Id '{0}' Distributed and partially successfull".format(image_id) + self.log("For device(s) {0} image Distribution gets failed".format(str(device_ips_list)), "CRITICAL") + + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + + return self + + def get_diff_activation(self): + """ + Get image activation parameters from the playbook and trigger image activation. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This function retrieves image activation parameters from the playbook's 'activation_details' and triggers the + activation of the specified software image on the specified device. It monitors the activation task's progress and + updates the 'result' dictionary. If the operation is successful, 'changed' is set to True. + """ + + activation_details = self.want.get("activation_details") + site_name = activation_details.get("site_name") + device_family = activation_details.get("device_family_name") + device_role = activation_details.get("device_role", "ALL") + device_series_name = activation_details.get("device_series_name") + device_uuid_list = self.get_device_uuids(site_name, device_family, device_role, device_series_name) + image_id = self.have.get("activation_image_id") + self.complete_successful_activation = False + self.partial_successful_activation = False + self.single_device_activation = False + + if self.have.get("activation_device_id"): + payload = [dict( + activateLowerImageVersion=activation_details.get("activate_lower_image_version"), + deviceUpgradeMode=activation_details.get("device_upgrade_mode"), + distributeIfNeeded=activation_details.get("distribute_if_needed"), + deviceUuid=self.have.get("activation_device_id"), + imageUuidList=[image_id] + )] + + activation_params = dict( + schedule_validate=activation_details.get("scehdule_validate"), + payload=payload + ) + self.log("Activation Params: {0}".format(str(activation_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_activation', + op_modifies=True, + params=activation_params, + ) + self.log("Received API response from 'trigger_software_image_activation': {0}".format(str(response)), "DEBUG") + + task_details = {} + task_id = response.get("response").get("taskId") + + while (True): + task_details = self.get_task_details(task_id) + + if not task_details.get("isError") and \ + ("completed successfully" in task_details.get("progress")): + self.result['changed'] = True + self.result['msg'] = "Image Activated successfully" + self.status = "success" + self.single_device_activation = True + break + + if task_details.get("isError"): + self.msg = "Activation for Image with Id '{0}' gets failed".format(image_id) + self.status = "failed" + self.result['response'] = task_details + self.log(self.msg, "ERROR") + return self + + self.result['response'] = task_details if task_details else response + + return self + + if len(device_uuid_list) == 0: + self.status = "success" + self.msg = "The SWIM image activation task could not proceed because no eligible devices were found." + self.result['msg'] = self.msg + self.log(self.msg, "WARNING") + return self + + self.log("Device UUIDs involved in Image Activation: {0}".format(str(device_uuid_list)), "INFO") + activation_task_dict = {} + + for device_uuid in device_uuid_list: + device_management_ip = self.get_device_ip_from_id(device_uuid) + payload = [dict( + activateLowerImageVersion=activation_details.get("activate_lower_image_version"), + deviceUpgradeMode=activation_details.get("device_upgrade_mode"), + distributeIfNeeded=activation_details.get("distribute_if_needed"), + deviceUuid=device_uuid, + imageUuidList=[image_id] + )] + + activation_params = dict( + schedule_validate=activation_details.get("scehdule_validate"), + payload=payload + ) + self.log("Activation Params: {0}".format(str(activation_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='trigger_software_image_activation', + op_modifies=True, + params=activation_params, + ) + self.log("Received API response from 'trigger_software_image_activation': {0}".format(str(response)), "DEBUG") + + if response: + task_details = {} + task_id = response.get("response").get("taskId") + activation_task_dict[device_management_ip] = task_id + + device_ips_list, device_activation_count = self.check_swim_task_status(activation_task_dict, 'Activation') + + if device_activation_count == 0: + self.status = "failed" + self.msg = "Image with Id '{0}' activation failed for all devices".format(image_id) + elif device_activation_count == len(device_uuid_list): + self.result['changed'] = True + self.status = "success" + self.complete_successful_activation = True + self.msg = "Image with Id '{0}' activated successfully for all devices".format(image_id) + else: + self.result['changed'] = True + self.status = "success" + self.partial_successful_activation = True + self.msg = "Image with Id '{0}' activated and partially successful.".format(image_id) + self.log("For Device(s) {0} Image activation gets Failed".format(str(device_ips_list)), "CRITICAL") + + self.result['msg'] = self.msg + self.log(self.msg, "INFO") + + return self + + def get_diff_merged(self, config): + """ + Get tagging details and then trigger distribution followed by activation if specified in the playbook. + Parameters: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + config (dict): The configuration dictionary containing tagging, distribution, and activation details. + Returns: + self: The current instance of the class with updated 'result' and 'have' attributes. + Description: + This function checks the provided playbook configuration for tagging, distribution, and activation details. It + then triggers these operations in sequence if the corresponding details are found in the configuration.The + function monitors the progress of each task and updates the 'result' dictionary accordingly. If any of the + operations are successful, 'changed' is set to True. + """ + + if config.get("tagging_details"): + self.get_diff_tagging().check_return_status() + + if config.get("image_distribution_details"): + self.get_diff_distribution().check_return_status() + + if config.get("image_activation_details"): + self.get_diff_activation().check_return_status() + + return self + + def verify_diff_imported(self, import_type): + """ + Verify the successful import of a software image into Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + import_type (str): The type of import, either 'remote' or 'local'. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the successful import of a software image into Cisco Catalyst Center. + It checks whether the image exists in Catalyst Center based on the provided import type. + If the image exists, the status is set to 'success', and a success message is logged. + If the image does not exist, a warning message is logged indicating a potential import failure. + """ + + if import_type == "remote": + image_name = self.want.get("url_import_details").get("payload")[0].get("source_url") + else: + image_name = self.want.get("local_import_details").get("file_path") + + # Code to check if the image already exists in Catalyst Center + name = image_name.split('/')[-1] + image_exist = self.is_image_exist(name) + if image_exist: + self.status = "success" + self.msg = "The requested Image '{0}' imported in the Cisco Catalyst Center and Image presence has been verified.".format(name) + self.log(self.msg, "INFO") + else: + self.log("""The playbook input for SWIM Image '{0}' does not align with the Cisco Catalyst Center, indicating that image + may not have imported successfully.""".format(name), "INFO") + + return self + + def verify_diff_tagged(self): + """ + Verify the Golden tagging status of a software image in Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the tagging status of a software image in Cisco Catalyst Center. + It retrieves tagging details from the input, including the desired tagging status and image ID. + Using the provided image ID, it obtains image parameters required for checking the image status. + The method then queries Catalyst Center to get the golden tag status of the image. + If the image status matches the desired tagging status, a success message is logged. + If there is a mismatch between the playbook input and the Catalyst Center, a warning message is logged. + """ + + tagging_details = self.want.get("tagging_details") + tag_image_golden = tagging_details.get("tagging") + image_id = self.have.get("tagging_image_id") + image_name = self.get_image_name_from_id(image_id) + + 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("device_role", "ALL").upper() + ) + self.log("Parameters for checking the status of image: {0}".format(str(image_params)), "INFO") + + response = self.dnac._exec( + family="software_image_management_swim", + function='get_golden_tag_status_of_an_image', + op_modifies=True, + params=image_params + ) + self.log("Received API response from 'get_golden_tag_status_of_an_image': {0}".format(str(response)), "DEBUG") + + response = response.get('response') + if response: + image_status = response['taggedGolden'] + if image_status == tag_image_golden: + if tag_image_golden: + self.msg = """The requested image '{0}' has been tagged as golden in the Cisco Catalyst Center and + its status has been successfully verified.""".format(image_name) + self.log(self.msg, "INFO") + else: + self.msg = """The requested image '{0}' has been un-tagged as golden in the Cisco Catalyst Center and + image status has been verified.""".format(image_name) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between the playbook input for tagging/un-tagging image as golden and the Cisco Catalyst Center indicates that + the tagging/un-tagging task was not executed successfully.""", "INFO") + + return self + + def verify_diff_distributed(self): + """ + Verify the distribution status of a software image in Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the distribution status of a software image in Cisco Catalyst Center. + It retrieves the image ID and name from the input and if distribution device ID is provided, it checks the distribution status for that + list of specific device and logs the info message based on distribution status. + """ + + image_id = self.have.get("distribution_image_id") + image_name = self.get_image_name_from_id(image_id) + + if self.have.get("distribution_device_id"): + if self.single_device_distribution: + self.msg = """The requested image '{0}', associated with the device ID '{1}', has been successfully distributed in the Cisco Catalyst Center + and its status has been verified.""".format(image_name, self.have.get("distribution_device_id")) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between the playbook input for distributing the image to the device with ID '{0}' and the actual state in the + Cisco Catalyst Center suggests that the distribution task might not have been executed + successfully.""".format(self.have.get("distribution_device_id")), "INFO") + elif self.complete_successful_distribution: + self.msg = """The requested image '{0}', with ID '{1}', has been successfully distributed to all devices within the specified + site in the Cisco Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + elif self.partial_successful_distribution: + self.msg = """T"The requested image '{0}', with ID '{1}', has been partially distributed across some devices in the Cisco Catalyst + Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + else: + self.msg = """The requested image '{0}', with ID '{1}', failed to be distributed across devices in the Cisco Catalyst + Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + + return self + + def verify_diff_activated(self): + """ + Verify the activation status of a software image in Cisco Catalyst Center. + Args: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Returns: + self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method verifies the activation status of a software image in Cisco Catalyst Center and retrieves the image ID and name from + the input. If activation device ID is provided, it checks the activation status for that specific device. Based on activation status + a corresponding message is logged. + """ + + image_id = self.have.get("activation_image_id") + image_name = self.get_image_name_from_id(image_id) + + if self.have.get("activation_device_id"): + if self.single_device_activation: + self.msg = """The requested image '{0}', associated with the device ID '{1}', has been successfully activated in the Cisco Catalyst + Center and its status has been verified.""".format(image_name, self.have.get("activation_device_id")) + self.log(self.msg, "INFO") + else: + self.log("""Mismatch between the playbook's input for activating the image '{0}' on the device with ID '{1}' and the actual state in + the Cisco Catalyst Center suggests that the activation task might not have been executed + successfully.""".format(image_name, self.have.get("activation_device_id")), "INFO") + elif self.complete_successful_activation: + self.msg = """The requested image '{0}', with ID '{1}', has been successfully activated on all devices within the specified site in the + Cisco Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + elif self.partial_successful_activation: + self.msg = """"The requested image '{0}', with ID '{1}', has been partially activated on some devices in the Cisco + Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + else: + self.msg = """The activation of the requested image '{0}', with ID '{1}', failed on devices in the Cisco + Catalyst Center.""".format(image_name, image_id) + self.log(self.msg, "INFO") + + return self + + def verify_diff_merged(self, config): + """ + Verify the merged status(Importing/Tagging/Distributing/Actiavting) the SWIM Image in devices in Cisco Catalyst Center. + Args: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + - config (dict): The configuration details to be verified. + Return: + - self (object): An instance of a class used for interacting with Cisco Catalyst Center. + Description: + This method checks the merged status of a configuration in Cisco Catalyst Center by retrieving the current state + (have) and desired state (want) of the configuration, logs the states, and validates whether the specified + SWIM operation performed or not. + """ + + self.get_have() + self.log("Current State (have): {0}".format(str(self.have)), "INFO") + self.log("Desired State (want): {0}".format(str(self.want)), "INFO") + + import_type = self.want.get("import_type") + if import_type: + self.verify_diff_imported(import_type).check_return_status() + + tagged = self.want.get("tagging_details") + if tagged: + self.verify_diff_tagged().check_return_status() + + distribution_details = self.want.get("distribution_details") + if distribution_details: + self.verify_diff_distributed().check_return_status() + + activation_details = self.want.get("activation_details") + if activation_details: + self.verify_diff_activated().check_return_status() + + return self + + +def main(): + """ main entry point for module execution + """ + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log_level': {'type': 'str', 'default': 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'dnac_log': {'type': 'bool', 'default': False}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + 'config_verify': {'type': 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged']} + } + + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + + ccc_swims = Swim(module) + state = ccc_swims.params.get("state") + + if state not in ccc_swims.supported_states: + ccc_swims.status = "invalid" + ccc_swims.msg = "State {0} is invalid".format(state) + ccc_swims.check_return_status() + + ccc_swims.validate_input().check_return_status() + config_verify = ccc_swims.params.get("config_verify") + + for config in ccc_swims.validated_config: + ccc_swims.reset_values() + ccc_swims.get_want(config).check_return_status() + ccc_swims.get_diff_import().check_return_status() + ccc_swims.get_have().check_return_status() + ccc_swims.get_diff_state_apply[state](config).check_return_status() + if config_verify: + ccc_swims.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_swims.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/tag_member.py b/ansible_collections/cisco/dnac/plugins/modules/tag_member.py index 528caf301..017fd1c23 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/tag_member.py +++ b/ansible_collections/cisco/dnac/plugins/modules/tag_member.py @@ -79,6 +79,7 @@ EXAMPLES = r""" state: absent id: string memberId: string + memberType: string """ diff --git a/ansible_collections/cisco/dnac/plugins/modules/template_intent.py b/ansible_collections/cisco/dnac/plugins/modules/template_intent.py index 8a42f9271..c6e3042de 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/template_intent.py +++ b/ansible_collections/cisco/dnac/plugins/modules/template_intent.py @@ -1,13 +1,13 @@ #!/usr/bin/python # -*- coding: utf-8 -*- - # Copyright (c) 2022, Cisco Systems # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +"""Ansible module to perform operations on project and templates in DNAC.""" from __future__ import absolute_import, division, print_function __metaclass__ = type -__author__ = ("Madhan Sankaranarayanan, Rishita Chowdhary") +__author__ = ['Madhan Sankaranarayanan, Rishita Chowdhary, Akash Bhaskaran, Muthu Rakesh'] DOCUMENTATION = r""" --- @@ -18,12 +18,22 @@ description: - API to create a template by project name and template name. - API to update a template by template name and project name. - API to delete a template by template name and project name. +- API to export the projects for given projectNames. +- API to export the templates for given templateIds. +- API to manage operation create of the resource Configuration Template Import Project. +- API to manage operation create of the resource Configuration Template Import Template. version_added: '6.6.0' extends_documentation_fragment: - cisco.dnac.intent_params author: Madhan Sankaranarayanan (@madhansansel) Rishita Chowdhary (@rishitachowdhary) + 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 @@ -36,35 +46,270 @@ options: elements: dict required: true suboptions: - author: - description: Author of template. - type: str - composite: - description: Is it composite template. - type: bool - containingTemplates: - description: Configuration Template Create's containingTemplates. + configuration_templates: + description: Create/Update/Delete template. + type: dict suboptions: + author: + description: Author of template. + type: str composite: description: Is it composite template. type: bool - description: - description: Description of template. - type: str - deviceTypes: - description: Configuration Template Create's deviceTypes. + containing_templates: + description: Configuration Template Create's containingTemplates. + suboptions: + composite: + description: Is it composite template. + type: bool + description: + description: Description of template. + type: str + device_types: + description: deviceTypes on which templates would be applied. + type: list + elements: dict + suboptions: + product_family: + description: Device family. + type: str + product_series: + description: Device series. + type: str + product_type: + description: Device type. + type: str + id: + description: UUID of template. + type: str + language: + description: Template language + choices: + - JINJA + - VELOCITY + type: str + name: + description: Name of template. + type: str + project_name: + description: Name of the project under which templates are managed. + type: str + project_description: + description: Description of the project created. + type: str + rollback_template_params: + description: Params required for template rollback. + type: list + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Create's range. + type: list + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Create's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + tags: + description: Configuration Template Create's tags. + suboptions: + id: + description: UUID of tag. + type: str + name: + description: Name of tag. + type: str + type: list + elements: dict + template_content: + description: Template content. + type: str + template_params: + description: Configuration Template Create's templateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Create's range. + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + elements: dict + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Create's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + version: + description: Current version of template. + type: str type: list elements: dict + create_time: + description: Create time of template. + type: int + custom_params_order: + description: Custom Params Order. + type: bool + template_description: + description: Description of template. + type: str + device_types: + description: Configuration Template Create's deviceTypes. This field is mandatory to create a new template. suboptions: - productFamily: + product_family: description: Device family. type: str - productSeries: + product_series: description: Device series. type: str - productType: + product_type: description: Device type. type: str + type: list + elements: dict + failure_policy: + description: Define failure policy if template provisioning fails. + type: str id: description: UUID of template. type: str @@ -74,33 +319,49 @@ options: - JINJA - VELOCITY type: str - name: - description: Name of template. + last_update_time: + description: Update time of template. + type: int + latest_version_time: + description: Latest versioned template time. + type: int + template_name: + description: Name of template. This field is mandatory to create a new template. + type: str + parent_template_id: + description: Parent templateID. + type: str + project_id: + description: Project UUID. type: str - projectName: + project_name: description: Project name. type: str - rollbackTemplateParams: + project_description: + description: Project Description. + type: str + rollback_template_content: + description: Rollback template content. + type: str + rollback_template_params: description: Configuration Template Create's rollbackTemplateParams. - type: list - elements: dict suboptions: binding: description: Bind to source. type: str - customOrder: + custom_order: description: CustomOrder of template param. type: int - dataType: + data_type: description: Datatype of template param. type: str - defaultValue: + default_value: description: Default value of template param. type: str description: description: Description of template param. type: str - displayName: + display_name: description: Display name of param. type: str group: @@ -109,22 +370,22 @@ options: id: description: UUID of template param. type: str - instructionText: + instruction_text: description: Instruction text for param. type: str key: description: Key. type: str - notParam: + not_param: description: Is it not a variable. type: bool order: description: Order of template param. type: int - paramArray: + param_array: description: Is it an array. type: bool - parameterName: + parameter_name: description: Name of template param. type: str provider: @@ -132,39 +393,50 @@ options: type: str range: description: Configuration Template Create's range. - type: list - elements: dict suboptions: id: description: UUID of range. type: str - maxValue: + max_value: description: Max value of range. type: int - minValue: + min_value: description: Min value of range. type: int + type: list + elements: dict required: description: Is param required. type: bool selection: description: Configuration Template Create's selection. suboptions: - defaultSelectedValues: + default_selected_values: description: Default selection values. elements: str type: list id: description: UUID of selection. type: str - selectionType: + selection_type: description: Type of selection(SINGLE_SELECT or MULTI_SELECT). type: str - selectionValues: + selection_values: description: Selection values. type: dict type: dict - tags: + type: list + elements: dict + software_type: + description: Applicable device software type. This field is mandatory to create a new template. + type: str + software_variant: + description: Applicable device software variant. + type: str + software_version: + description: Applicable device software version. + type: str + template_tag: description: Configuration Template Create's tags. suboptions: id: @@ -175,29 +447,28 @@ options: type: str type: list elements: dict - templateContent: + template_content: description: Template content. type: str - templateParams: + template_params: description: Configuration Template Create's templateParams. - elements: dict suboptions: binding: description: Bind to source. type: str - customOrder: + custom_order: description: CustomOrder of template param. type: int - dataType: + data_type: description: Datatype of template param. type: str - defaultValue: + default_value: description: Default value of template param. type: str description: description: Description of template param. type: str - displayName: + display_name: description: Display name of param. type: str group: @@ -206,22 +477,22 @@ options: id: description: UUID of template param. type: str - instructionText: + instruction_text: description: Instruction text for param. type: str key: description: Key. type: str - notParam: + not_param: description: Is it not a variable. type: bool order: description: Order of template param. type: int - paramArray: + param_array: description: Is it an array. type: bool - parameterName: + parameter_name: description: Name of template param. type: str provider: @@ -233,10 +504,10 @@ options: id: description: UUID of range. type: str - maxValue: + max_value: description: Max value of range. type: int - minValue: + min_value: description: Min value of range. type: int type: list @@ -247,294 +518,590 @@ options: selection: description: Configuration Template Create's selection. suboptions: - defaultSelectedValues: + default_selected_values: description: Default selection values. elements: str type: list id: description: UUID of selection. type: str - selectionType: + selection_type: description: Type of selection(SINGLE_SELECT or MULTI_SELECT). type: str - selectionValues: + selection_values: description: Selection values. type: dict type: dict type: list - version: - description: Current version of template. - type: str - type: list - elements: dict - createTime: - description: Create time of template. - type: int - customParamsOrder: - description: Custom Params Order. - type: bool - template_description: - description: Description of template. - type: str - deviceTypes: - description: Configuration Template Create's deviceTypes. - suboptions: - productFamily: - description: Device family. - type: str - productSeries: - description: Device series. - type: str - productType: - description: Device type. - type: str - type: list - elements: dict - failurePolicy: - description: Define failure policy if template provisioning fails. - type: str - language: - description: Template language - choices: - - JINJA - - VELOCITY - type: str - lastUpdateTime: - description: Update time of template. - type: int - latestVersionTime: - description: Latest versioned template time. - type: int - templateName: - description: Name of template. - type: str - parentTemplateId: - description: Parent templateID. - type: str - projectId: - description: Project UUID. - type: str - projectName: - description: Project name. - type: str - rollbackTemplateContent: - description: Rollback template content. - type: str - rollbackTemplateParams: - description: Configuration Template Create's rollbackTemplateParams. - suboptions: - binding: - description: Bind to source. - type: str - customOrder: - description: CustomOrder of template param. - type: int - dataType: - description: Datatype of template param. - type: str - defaultValue: - description: Default value of template param. - type: str - description: - description: Description of template param. - type: str - displayName: - description: Display name of param. - type: str - group: - description: Group. - type: str - id: - description: UUID of template param. - type: str - instructionText: - description: Instruction text for param. - type: str - key: - description: Key. - type: str - notParam: - description: Is it not a variable. - type: bool - order: - description: Order of template param. - type: int - paramArray: - description: Is it an array. - type: bool - parameterName: - description: Name of template param. - type: str - provider: - description: Provider. - type: str - range: - description: Configuration Template Create's range. - suboptions: - id: - description: UUID of range. - type: str - maxValue: - description: Max value of range. - type: int - minValue: - description: Min value of range. - type: int - type: list elements: dict - required: - description: Is param required. - type: bool - selection: - description: Configuration Template Create's selection. + validation_errors: + description: Configuration Template Create's validationErrors. suboptions: - defaultSelectedValues: - description: Default selection values. - elements: str + rollback_template_errors: + description: Validation or design conflicts errors of rollback template. + elements: dict type: list - id: - description: UUID of selection. + template_errors: + description: Validation or design conflicts errors. + elements: dict + type: list + template_id: + description: UUID of template. type: str - selectionType: - description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + template_version: + description: Current version of template. type: str - selectionValues: - description: Selection values. - type: dict type: dict - type: list - elements: dict - softwareType: - description: Applicable device software type. - type: str - softwareVariant: - description: Applicable device software variant. - type: str - softwareVersion: - description: Applicable device software version. - type: str - template_tag: - description: Configuration Template Create's tags. - suboptions: - id: - description: UUID of tag. + version: + description: Current version of template. type: str - name: - description: Name of tag. + version_description: + description: Template version comments. type: str - type: list - elements: dict - templateContent: - description: Template content. - type: str - templateParams: - description: Configuration Template Create's templateParams. + export: + description: Export the project/template details. + type: dict suboptions: - binding: - description: Bind to source. - type: str - customOrder: - description: CustomOrder of template param. - type: int - dataType: - description: Datatype of template param. - type: str - defaultValue: - description: Default value of template param. - type: str - description: - description: Description of template param. - type: str - displayName: - description: Display name of param. - type: str - group: - description: Group. - type: str - id: - description: UUID of template param. - type: str - instructionText: - description: Instruction text for param. - type: str - key: - description: Key. - type: str - notParam: - description: Is it not a variable. - type: bool - order: - description: Order of template param. - type: int - paramArray: - description: Is it an array. - type: bool - parameterName: - description: Name of template param. - type: str - provider: - description: Provider. - type: str - range: - description: Configuration Template Create's range. - suboptions: - id: - description: UUID of range. - type: str - maxValue: - description: Max value of range. - type: int - minValue: - description: Min value of range. - type: int + project: + description: Export the project. + type: list + elements: str + template: + description: Export the template. type: list elements: dict - required: - description: Is param required. - type: bool - selection: - description: Configuration Template Create's selection. suboptions: - defaultSelectedValues: - description: Default selection values. - elements: str - type: list - id: - description: UUID of selection. + project_name: + description: Name of the project under the template available. type: str - selectionType: - description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + template_name: + description: Name of the template which we need to export type: str - selectionValues: - description: Selection values. - type: dict - type: dict - type: list - elements: dict - validationErrors: - description: Configuration Template Create's validationErrors. - suboptions: - rollbackTemplateErrors: - description: Validation or design conflicts errors of rollback template. - elements: dict - type: list - templateErrors: - description: Validation or design conflicts errors. - elements: dict - type: list - templateId: - description: UUID of template. - type: str - templateVersion: - description: Current version of template. - type: str + import: + description: Import the project/template details. type: dict - version: - description: Current version of template. - type: str - versionDescription: - description: Template version comments. - type: str + suboptions: + project: + description: Import the project details. + type: dict + suboptions: + do_version: + description: DoVersion query parameter. If this flag is true, creates a new + version of the template with the imported contents, if the templates already + exists. " If false and if template already exists, then operation + fails with 'Template already exists' error. + type: bool + template: + description: Import the template details. + type: dict + suboptions: + do_version: + description: DoVersion query parameter. If this flag is true, creates a new + version of the template with the imported contents, if the templates already + exists. " If false and if template already exists, then operation + fails with 'Template already exists' error. + type: bool + payload: + description: Configuration Template Import Template's payload. + elements: dict + suboptions: + author: + description: Author of template. + type: str + composite: + description: Is it composite template. + type: bool + containing_templates: + description: Configuration Template Import Template's containingTemplates. + elements: dict + suboptions: + composite: + description: Is it composite template. + type: bool + description: + description: Description of template. + type: str + device_types: + description: Configuration Template Import Template's deviceTypes. + elements: dict + suboptions: + product_family: + description: Device family. + type: str + product_series: + description: Device series. + type: str + product_type: + description: Device type. + type: str + type: list + id: + description: UUID of template. + type: str + language: + description: Template language (JINJA or VELOCITY). + type: str + name: + description: Name of template. + type: str + project_name: + description: Project name. + type: str + rollback_template_params: + description: Configuration Template Import Template's rollbackTemplateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + tags: + description: Configuration Template Import Template's tags. + elements: dict + suboptions: + id: + description: UUID of tag. + type: str + name: + description: Name of tag. + type: str + type: list + template_content: + description: Template content. + type: str + template_params: + description: Configuration Template Import Template's templateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + version: + description: Current version of template. + type: str + type: list + create_time: + description: Create time of template. + type: int + custom_params_order: + description: Custom Params Order. + type: bool + description: + description: Description of template. + type: str + device_types: + description: Configuration Template Import Template's deviceTypes. + elements: dict + suboptions: + product_family: + description: Device family. + type: str + product_series: + description: Device series. + type: str + product_type: + description: Device type. + type: str + type: list + failure_policy: + description: Define failure policy if template provisioning fails. + type: str + id: + description: UUID of template. + type: str + language: + description: Template language (JINJA or VELOCITY). + type: str + last_update_time: + description: Update time of template. + type: int + latest_version_time: + description: Latest versioned template time. + type: int + name: + description: Name of template. + type: str + parent_template_id: + description: Parent templateID. + type: str + project_id: + description: Project UUID. + type: str + project_name: + description: Project name. + type: str + rollback_template_content: + description: Rollback template content. + type: str + rollback_template_params: + description: Configuration Template Import Template's rollbackTemplateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + software_type: + description: Applicable device software type. + type: str + software_variant: + description: Applicable device software variant. + type: str + software_version: + description: Applicable device software version. + type: str + tags: + description: Configuration Template Import Template's tags. + elements: dict + suboptions: + id: + description: UUID of tag. + type: str + name: + description: Name of tag. + type: str + type: list + template_content: + description: Template content. + type: str + template_params: + description: Configuration Template Import Template's templateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + validation_errors: + description: Configuration Template Import Template's validationErrors. + suboptions: + rollback_template_errors: + description: Validation or design conflicts errors of rollback template. + type: dict + template_errors: + description: Validation or design conflicts errors. + type: dict + template_id: + description: UUID of template. + type: str + template_version: + description: Current version of template. + type: str + type: dict + version: + description: Current version of template. + type: str + type: list + project_name: + description: ProjectName path parameter. Project name to create template under the + project. + type: str + requirements: - dnacentersdk == 2.4.5 - python >= 3.5 @@ -543,16 +1110,24 @@ notes: configuration_templates.ConfigurationTemplates.create_template, configuration_templates.ConfigurationTemplates.deletes_the_template, configuration_templates.ConfigurationTemplates.update_template, + configuration_templates.ConfigurationTemplates.export_projects, + configuration_templates.ConfigurationTemplates.export_templates, + configuration_templates.ConfigurationTemplates.imports_the_projects_provided, + configuration_templates.ConfigurationTemplates.imports_the_templates_provided, - Paths used are post /dna/intent/api/v1/template-programmer/project/{projectId}/template, delete /dna/intent/api/v1/template-programmer/template/{templateId}, put /dna/intent/api/v1/template-programmer/template, + post /dna/intent/api/v1/template-programmer/project/name/exportprojects, + post /dna/intent/api/v1/template-programmer/template/exporttemplates, + post /dna/intent/api/v1/template-programmer/project/importprojects, + post /dna/intent/api/v1/template-programmer/project/name/{projectName}/template/importtemplates, """ EXAMPLES = r""" -- name: Create a new template +- name: Create a new template, export and import the project and template. cisco.dnac.template_intent: dnac_host: "{{dnac_host}}" dnac_username: "{{dnac_username}}" @@ -562,47 +1137,96 @@ EXAMPLES = r""" dnac_version: "{{dnac_version}}" dnac_debug: "{{dnac_debug}}" dnac_log: True + dnac_log_level: "{{dnac_log_level}}" state: merged + config_verify: True config: + - configuration_templates: author: string composite: true - createTime: 0 - customParamsOrder: true + create_time: 0 + custom_params_order: true description: string - deviceTypes: - - productFamily: string - productSeries: string - productType: string - failurePolicy: string + device_types: + - product_family: string + product_series: string + product_type: string + failure_policy: string id: string language: string - lastUpdateTime: 0 - latestVersionTime: 0 + last_update_time: 0 + latest_version_time: 0 name: string - parentTemplateId: string - projectId: string - projectName: string - rollbackTemplateContent: string - softwareType: string - softwareVariant: string - softwareVersion: string + parent_template_id: string + project_id: string + project_name: string + project_description: string + rollback_template_content: string + software_type: string + software_variant: string + software_version: string tags: - id: string name: string - templateContent: string - validationErrors: - rollbackTemplateErrors: + template_content: string + validation_errors: + rollback_template_errors: - {} - templateErrors: + template_errors: - {} - templateId: string - templateVersion: string + template_id: string + template_version: string version: string + export: + project: + - string + template: + - project_name : string + template_name: string + import: + project: + do_version: true + export: + do_version: true + payload: + - author: string + composite: true + containing_templates: + - composite: true + description: string + device_types: + - product_family: string + product_series: string + product_type: string + id: string + language: string + name: string + project_name: string + rollback_template_params: + - binding: string + custom_order: 0 + data_type: string + default_value: string + description: string + display_name: string + group: string + id: string + instruction_text: string + key: string + not_param: true + order: 0 + param_array: true + parameter_name: string + provider: string + range: + - id: string + project_name: string + """ RETURN = r""" -#Case_1: Successful creation/updation/deletion of template +# Case_1: Successful creation/updation/deletion of template/project response_1: description: A dictionary with versioning details of the template as returned by the DNAC Python SDK returned: always @@ -625,7 +1249,7 @@ response_1: "msg": String } -#Case_2: Error while deleting a template or when given project is not found +# Case_2: Error while deleting a template or when given project is not found response_2: description: A list with the response returned by the Cisco DNAC Python SDK returned: always @@ -636,7 +1260,7 @@ response_2: "msg": String } -#Case_3: Given template already exists and requires no udpate +# Case_3: Given template already exists and requires no update response_3: description: A dictionary with the exisiting template deatails as returned by the Cisco DNAC Python SDK returned: always @@ -646,223 +1270,926 @@ response_3: "response": {}, "msg": String } + +# Case_4: Given template list that needs to be exported +response_4: + description: Details of the templates in the list as returned by the Cisco DNAC Python SDK + returned: always + type: dict + sample: > + { + "response": {}, + "msg": String + } + +# Case_5: Given project list that needs to be exported +response_5: + description: Details of the projects in the list as returned by the Cisco DNAC Python SDK + returned: always + type: dict + sample: > + { + "response": {}, + "msg": String + } + """ import copy +from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( - DNACSDK, + DnacBase, validate_list_of_dicts, - log, get_dict_result, dnac_compare_equality, ) -from ansible.module_utils.basic import AnsibleModule -class DnacTemplate: +class DnacTemplate(DnacBase): + """Class containing member attributes for template intent module""" def __init__(self, module): - self.module = module - self.params = module.params - self.config = copy.deepcopy(module.params.get("config")) - self.have_create = {} - self.want_create = {} - self.validated = [] - dnac_params = self.get_dnac_params(self.params) - log(str(dnac_params)) - self.dnac = DNACSDK(params=dnac_params) - self.log = dnac_params.get("dnac_log") - - self.result = dict(changed=False, diff=[], response=[], warnings=[]) - - def get_state(self): - return self.params.get("state") + super().__init__(module) + self.have_project = {} + self.have_template = {} + self.supported_states = ["merged", "deleted"] + self.accepted_languages = ["JINJA", "VELOCITY"] + self.export_template = [] + self.result['response'].append({}) def validate_input(self): - temp_spec = dict( - tags=dict(type="list"), - author=dict(type="str"), - composite=dict(type="bool"), - containingTemplates=dict(type="list"), - createTime=dict(type="int"), - customParamsOrder=dict(type="bool"), - description=dict(type="str"), - deviceTypes=dict(type="list", elements='dict'), - failurePolicy=dict(type="str"), - id=dict(type="str"), - language=dict(type="str"), - lastUpdateTime=dict(type="int"), - latestVersionTime=dict(type="int"), - name=dict(type="str"), - parentTemplateId=dict(type="str"), - projectId=dict(type="str"), - projectName=dict(required=True, type="str"), - rollbackTemplateContent=dict(type="str"), - rollbackTemplateParams=dict(type="list"), - softwareType=dict(type="str"), - softwareVariant=dict(type="str"), - softwareVersion=dict(type="str"), - templateContent=dict(type="str"), - templateParams=dict(type="list"), - templateName=dict(required=True, type='str'), - validationErrors=dict(type="dict"), - version=dict(type="str"), - versionDescription=dict(type='str'), + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', + 'self.msg' will describe the validation issues. + + """ + + if not self.config: + self.msg = "config not available in playbook for validattion" + self.status = "success" + return self + + temp_spec = { + "configuration_templates": { + 'type': 'dict', + 'tags': {'type': 'list'}, + 'author': {'type': 'str'}, + 'composite': {'type': 'bool'}, + 'containing_templates': {'type': 'list'}, + 'create_time': {'type': 'int'}, + 'custom_params_order': {'type': 'bool'}, + 'description': {'type': 'str'}, + 'device_types': { + 'type': 'list', + 'elements': 'dict', + 'product_family': {'type': 'str'}, + 'product_series': {'type': 'str'}, + 'product_type': {'type': 'str'}, + }, + 'failure_policy': {'type': 'str'}, + 'id': {'type': 'str'}, + 'language': {'type': 'str'}, + 'last_update_time': {'type': 'int'}, + 'latest_version_time': {'type': 'int'}, + 'name': {'type': 'str'}, + 'parent_template_id': {'type': 'str'}, + 'project_id': {'type': 'str'}, + 'project_name': {'type': 'str'}, + 'project_description': {'type': 'str'}, + 'rollback_template_content': {'type': 'str'}, + 'rollback_template_params': {'type': 'list'}, + 'software_type': {'type': 'str'}, + 'software_variant': {'type': 'str'}, + 'software_version': {'type': 'str'}, + 'template_content': {'type': 'str'}, + 'template_params': {'type': 'list'}, + 'template_name': {'type': 'str'}, + 'validation_errors': {'type': 'dict'}, + 'version': {'type': 'str'}, + 'version_description': {'type': 'str'} + }, + 'export': { + 'type': 'dict', + 'project': {'type': 'list', 'elements': 'str'}, + 'template': { + 'type': 'list', + 'elements': 'dict', + 'project_name': {'type': 'str'}, + 'template_name': {'type': 'str'} + } + }, + 'import': { + 'type': 'dict', + 'project': { + 'type': 'dict', + 'do_version': {'type': 'str', 'default': 'False'}, + }, + 'template': { + 'type': 'dict', + 'do_version': {'type': 'str', 'default': 'False'}, + 'payload': { + 'type': 'list', + 'elements': 'dict', + 'tags': {'type': 'list'}, + 'author': {'type': 'str'}, + 'composite': {'type': 'bool'}, + 'containing_templates': {'type': 'list'}, + 'create_time': {'type': 'int'}, + 'custom_params_order': {'type': 'bool'}, + 'description': {'type': 'str'}, + 'device_types': { + 'type': 'list', + 'elements': 'dict', + 'product_family': {'type': 'str'}, + 'product_series': {'type': 'str'}, + 'product_type': {'type': 'str'}, + }, + 'failure_policy': {'type': 'str'}, + 'id': {'type': 'str'}, + 'language': {'type': 'str'}, + 'last_update_time': {'type': 'int'}, + 'latest_version_time': {'type': 'int'}, + 'name': {'type': 'str'}, + 'parent_template_id': {'type': 'str'}, + 'project_id': {'type': 'str'}, + 'project_name': {'type': 'str'}, + 'project_description': {'type': 'str'}, + 'rollback_template_content': {'type': 'str'}, + 'rollback_template_params': {'type': 'list'}, + 'software_type': {'type': 'str'}, + 'software_variant': {'type': 'str'}, + 'software_version': {'type': 'str'}, + 'template_content': {'type': 'str'}, + 'template_params': {'type': 'list'}, + 'template_name': {'type': 'str'}, + 'validation_errors': {'type': 'dict'}, + 'version': {'type': 'str'} + } + } + } + } + # Validate template params + 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)) + self.status = "failed" + return self + + self.validated_config = valid_temp + self.log("Successfully validated playbook config params: {0}".format(valid_temp), "INFO") + self.msg = "Successfully validated input" + self.status = "success" + return self + + def get_project_params(self, params): + """ + Store project parameters from the playbook for template processing in DNAC. + + Parameters: + params (dict) - Playbook details containing Project information. + + Returns: + project_params (dict) - Organized Project parameters. + """ + + project_params = {"name": params.get("project_name"), + "description": params.get("project_description") + } + return project_params + + def get_tags(self, _tags): + """ + Store tags from the playbook for template processing in DNAC. + Check using check_return_status() + + Parameters: + tags (dict) - Tags details containing Template information. + + Returns: + tags (dict) - Organized tags parameters. + """ + + if _tags is None: + return None + + tags = [] + i = 0 + for item in _tags: + tags.append({}) + id = item.get("id") + if id is not None: + tags[i].update({"id": id}) + + name = item.get("name") + if name is not None: + tags[i].update({"name": name}) + else: + self.msg = "name is mandatory in tags in location " + str(i) + self.status = "failed" + return self.check_return_status() + + return tags + + def get_device_types(self, device_types): + """ + Store device types parameters from the playbook for template processing in DNAC. + Check using check_return_status() + + Parameters: + device_types (dict) - Device types details containing Template information. + + Returns: + deviceTypes (dict) - Organized device types parameters. + """ + + if device_types is None: + return None + + deviceTypes = [] + i = 0 + for item in device_types: + deviceTypes.append({}) + product_family = item.get("product_family") + if product_family is not None: + deviceTypes[i].update({"productFamily": product_family}) + else: + self.msg = "product_family is mandatory for deviceTypes" + self.status = "failed" + return self.check_return_status() + + product_series = item.get("product_series") + if product_series is not None: + deviceTypes[i].update({"productSeries": product_series}) + product_type = item.get("product_type") + if product_type is not None: + deviceTypes[i].update({"productType": product_type}) + i = i + 1 + + return deviceTypes + + def get_validation_errors(self, validation_errors): + """ + Store template parameters from the playbook for template processing in DNAC. + + Parameters: + validation_errors (dict) - Playbook details containing validation errors information. + + Returns: + validationErrors (dict) - Organized validation errors parameters. + """ + + if validation_errors is None: + return None + + validationErrors = {} + rollback_template_errors = validation_errors.get("rollback_template_errors") + if rollback_template_errors is not None: + validationErrors.update({ + "rollbackTemplateErrors": rollback_template_errors + }) + + template_errors = validation_errors.get("template_errors") + if template_errors is not None: + validationErrors.update({ + "templateErrors": template_errors + }) + + template_id = validation_errors.get("template_id") + if template_id is not None: + validationErrors.update({ + "templateId": template_id + }) + + template_version = validation_errors.get("template_version") + if template_version is not None: + validationErrors.update({ + "templateVersion": template_version + }) + + return validationErrors + + def get_template_info(self, template_params): + """ + Store template params from the playbook for template processing in DNAC. + Check using check_return_status() + + Parameters: + template_params (dict) - Playbook details containing template params information. + + Returns: + templateParams (dict) - Organized template params parameters. + """ + + if template_params is None: + return None + + templateParams = [] + i = 0 + self.log("Template params details: {0}".format(template_params), "DEBUG") + for item in template_params: + self.log("Template params items: {0}".format(item), "DEBUG") + templateParams.append({}) + binding = item.get("binding") + if binding is not None: + templateParams[i].update({"binding": binding}) + + custom_order = item.get("custom_order") + if custom_order is not None: + templateParams[i].update({"customOrder": custom_order}) + + default_value = item.get("default_value") + if default_value is not None: + templateParams[i].update({"defaultValue": default_value}) + + description = item.get("description") + if description is not None: + templateParams[i].update({"description": description}) + + display_name = item.get("display_name") + if display_name is not None: + templateParams[i].update({"displayName": display_name}) + + group = item.get("group") + if group is not None: + templateParams[i].update({"group": group}) + + id = item.get("id") + if id is not None: + templateParams[i].update({"id": id}) + + instruction_text = item.get("instruction_text") + if instruction_text is not None: + templateParams[i].update({"instructionText": instruction_text}) + + key = item.get("key") + if key is not None: + templateParams[i].update({"key": key}) + + not_param = item.get("not_param") + if not_param is not None: + templateParams[i].update({"notParam": not_param}) + + order = item.get("order") + if order is not None: + templateParams[i].update({"order": order}) + + param_array = item.get("param_array") + if param_array is not None: + templateParams[i].update({"paramArray": param_array}) + + provider = item.get("provider") + if provider is not None: + templateParams[i].update({"provider": provider}) + + parameter_name = item.get("parameter_name") + if parameter_name is not None: + templateParams[i].update({"parameterName": parameter_name}) + else: + self.msg = "parameter_name is mandatory for the template_params." + self.status = "failed" + return self.check_return_status() + + data_type = item.get("data_type") + datatypes = ["STRING", "INTEGER", "IPADDRESS", "MACADDRESS", "SECTIONDIVIDER"] + if data_type is not None: + templateParams[i].update({"dataType": data_type}) + else: + self.msg = "dataType is mandatory for the template_params." + self.status = "failed" + return self.check_return_status() + if data_type not in datatypes: + self.msg = "data_type under template_params should be in " + str(datatypes) + self.status = "failed" + return self.check_return_status() + + required = item.get("required") + if required is not None: + templateParams[i].update({"required": required}) + + range = item.get("range") + self.log("Template params range list: {0}".format(range), "DEBUG") + if range is not None: + templateParams[i].update({"range": []}) + _range = templateParams[i].get("range") + self.log("Template params range: {0}".format(_range), "DEBUG") + j = 0 + for value in range: + _range.append({}) + id = value.get("id") + if id is not None: + _range[j].update({"id": id}) + max_value = value.get("max_value") + if max_value is not None: + _range[j].update({"maxValue": max_value}) + else: + self.msg = "max_value is mandatory for range under template_params" + self.status = "failed" + return self.check_return_status() + min_value = value.get("min_value") + if min_value is not None: + _range[j].update({"maxValue": min_value}) + else: + self.msg = "min_value is mandatory for range under template_params" + self.status = "failed" + return self.check_return_status() + j = j + 1 + + self.log("Template params details: {0}".format(templateParams), "DEBUG") + selection = item.get("selection") + self.log("Template params selection: {0}".format(selection), "DEBUG") + if selection is not None: + templateParams[i].update({"selection": {}}) + _selection = templateParams[i].get("selection") + id = selection.get("id") + if id is not None: + _selection.update({"id": id}) + default_selected_values = selection.get("default_selected_values") + if default_selected_values is not None: + _selection.update({"defaultSelectedValues": default_selected_values}) + selection_values = selection.get("selection_values") + if selection_values is not None: + _selection.update({"selectionValues": selection_values}) + selection_type = selection.get("selection_type") + if selection_type is not None: + _selection.update({"selectionType": selection_type}) + i = i + 1 + + return templateParams + + def get_containing_templates(self, containing_templates): + """ + Store tags from the playbook for template processing in DNAC. + Check using check_return_status() + + Parameters: + containing_templates (dict) - Containing tempaltes details + containing Template information. + + Returns: + containingTemplates (dict) - Organized containing templates parameters. + """ + + if containing_templates is None: + return None + + containingTemplates = [] + i = 0 + for item in containing_templates: + containingTemplates.append({}) + _tags = item.get("tags") + if _tags is not None: + containingTemplates[i].update({"tags": self.get_tags(_tags)}) + + composite = item.get("composite") + if composite is not None: + containingTemplates[i].update({"composite": composite}) + + description = item.get("description") + if description is not None: + containingTemplates[i].update({"description": description}) + + device_types = item.get("device_types") + if device_types is not None: + containingTemplates[i].update({ + "deviceTypes": self.get_device_types(device_types) + }) + + id = item.get("id") + if id is not None: + containingTemplates[i].update({"id": id}) + + name = item.get("name") + if name is None: + self.msg = "name is mandatory under containing templates" + self.status = "failed" + return self.check_return_status() + + containingTemplates[i].update({"name": name}) + + language = item.get("language") + if language is None: + self.msg = "language is mandatory under containing templates" + self.status = "failed" + return self.check_return_status() + + language_list = ["JINJA", "VELOCITY"] + if language not in language_list: + self.msg = "language under containing templates should be in " + str(language_list) + self.status = "failed" + return self.check_return_status() + + containingTemplates[i].update({"language": language}) + + project_name = item.get("project_name") + if project_name is not None: + containingTemplates[i].update({"projectName": project_name}) + else: + self.msg = "project_name is mandatory under containing templates" + self.status = "failed" + return self.check_return_status() - if self.config: - msg = None - # Validate template params - valid_temp, invalid_params = validate_list_of_dicts( - self.config, temp_spec - ) + rollback_template_params = item.get("rollback_template_params") + if rollback_template_params is not None: + containingTemplates[i].update({ + "rollbackTemplateParams": self.get_template_info(rollback_template_params) + }) - if invalid_params: - msg = "Invalid parameters in playbook: {0}".format( - "\n".join(invalid_params) - ) - self.module.fail_json(msg=msg) + template_content = item.get("template_content") + if template_content is not None: + containingTemplates[i].update({"templateContent": template_content}) - self.validated = valid_temp + template_params = item.get("template_params") + if template_params is not None: + containingTemplates[i].update({ + "templateParams": self.get_template_info(template_params) + }) - if self.log: - log(str(valid_temp)) - log(str(self.validated)) + version = item.get("version") + if version is not None: + containingTemplates[i].update({"version": version}) - if self.params.get("state") == "merged": - for temp in self.validated: - if not temp.get("language") or not temp.get("deviceTypes") \ - or not temp.get("softwareType"): - msg = "missing required arguments: language or deviceTypes or softwareType" - self.module.fail_json(msg=msg) - if not (temp.get("language").lower() == "velocity" or - temp.get("language").lower() == "jinja"): - msg = "Invalid parameters in playbook: {0} : Invalid choice provided".format( - "".join(temp.get("language"))) - self.module.fail_json(msg=msg) - - def get_dnac_params(self, params): - dnac_params = dict( - dnac_host=params.get("dnac_host"), - dnac_port=params.get("dnac_port"), - dnac_username=params.get("dnac_username"), - dnac_password=params.get("dnac_password"), - dnac_verify=params.get("dnac_verify"), - dnac_debug=params.get("dnac_debug"), - dnac_log=params.get("dnac_log") - ) - return dnac_params + return containingTemplates def get_template_params(self, params): - temp_params = dict( - tags=params.get("template_tag"), - author=params.get("author"), - composite=params.get("composite"), - containingTemplates=params.get("containingTemplates"), - createTime=params.get("createTime"), - customParamsOrder=params.get("customParamsOrder"), - description=params.get("template_description"), - deviceTypes=params.get("deviceTypes"), - failurePolicy=params.get("failurePolicy"), - id=params.get("templateId"), - language=params.get("language").upper(), - lastUpdateTime=params.get("lastUpdateTime"), - latestVersionTime=params.get("latestVersionTime"), - name=params.get("templateName"), - parentTemplateId=params.get("parentTemplateId"), - projectId=params.get("projectId"), - projectName=params.get("projectName"), - rollbackTemplateContent=params.get("rollbackTemplateContent"), - rollbackTemplateParams=params.get("rollbackTemplateParams"), - softwareType=params.get("softwareType"), - softwareVariant=params.get("softwareVariant"), - softwareVersion=params.get("softwareVersion"), - templateContent=params.get("templateContent"), - templateParams=params.get("templateParams"), - validationErrors=params.get("validationErrors"), - version=params.get("version"), - project_id=params.get("projectId"), - ) + """ + Store template parameters from the playbook for template processing in DNAC. + + Parameters: + params (dict) - Playbook details containing Template information. + + Returns: + temp_params (dict) - Organized template parameters. + """ + + self.log("Template params playbook details: {0}".format(params), "DEBUG") + temp_params = { + "tags": self.get_tags(params.get("template_tag")), + "author": params.get("author"), + "composite": params.get("composite"), + "containingTemplates": + self.get_containing_templates(params.get("containing_templates")), + "createTime": params.get("create_time"), + "customParamsOrder": params.get("custom_params_order"), + "description": params.get("template_description"), + "deviceTypes": + self.get_device_types(params.get("device_types")), + "failurePolicy": params.get("failure_policy"), + "id": params.get("id"), + "language": params.get("language").upper(), + "lastUpdateTime": params.get("last_update_time"), + "latestVersionTime": params.get("latest_version_time"), + "name": params.get("template_name"), + "parentTemplateId": params.get("parent_template_id"), + "projectId": params.get("project_id"), + "projectName": params.get("project_name"), + "rollbackTemplateContent": params.get("rollback_template_content"), + "rollbackTemplateParams": + self.get_template_info(params.get("rollback_template_params")), + "softwareType": params.get("software_type"), + "softwareVariant": params.get("software_variant"), + "softwareVersion": params.get("software_version"), + "templateContent": params.get("template_content"), + "templateParams": + self.get_template_info(params.get("template_params")), + "validationErrors": + self.get_validation_errors(params.get("validation_errors")), + "version": params.get("version"), + "project_id": params.get("project_id") + } + self.log("Formatted template params details: {0}".format(temp_params), "DEBUG") + copy_temp_params = copy.deepcopy(temp_params) + for item in copy_temp_params: + if temp_params[item] is None: + del temp_params[item] + self.log("Formatted template params details: {0}".format(temp_params), "DEBUG") return temp_params - def get_template(self): - result = None + def get_template(self, config): + """ + Get the template needed for updation or creation. - for temp in self.validated: - items = self.dnac._exec( - family="configuration_templates", - function="get_template_details", - params={"template_id": temp.get("templateId")} - ) + Parameters: + config (dict) - Playbook details containing Template information. - if items: - result = items + Returns: + result (dict) - Template details for the given template ID. + """ - if self.log: - log(str(items)) + result = None + items = self.dnac_apply['exec']( + family="configuration_templates", + function="get_template_details", + params={"template_id": config.get("templateId")} + ) + if items: + result = items + self.log("Received API response from 'get_template_details': {0}".format(items), "DEBUG") self.result['response'] = items return result - def get_have(self): - prev_template = None - template_exists = False - have_create = {} - - # Get available templates. Filter templates based on provided projectName - for temp in self.validated: - template_list = self.dnac._exec( - family="configuration_templates", - function='gets_the_templates_available', - params={"project_names": temp.get("projectName")}, - ) - # API execution error returns a dict - if template_list and isinstance(template_list, list): - template_details = get_dict_result(template_list, 'name', temp.get("templateName")) - - if template_details: - temp["templateId"] = template_details.get("templateId") - have_create["templateId"] = template_details.get("templateId") - prev_template = self.get_template() - - if self.log: - log(str(prev_template)) + def get_have_project(self, config): + """ + Get the current project related information from DNAC. + + Parameters: + config (dict) - Playbook details containing Project information. + + Returns: + template_available (list) - Current project information. + """ + + have_project = {} + given_projectName = config.get("configuration_templates").get("project_name") + template_available = None + + # Check if project exists. + project_details = self.get_project_details(given_projectName) + # DNAC returns project details even if the substring matches. + # Hence check the projectName retrieved from DNAC. + if not (project_details and isinstance(project_details, list)): + self.log("Project: {0} not found, need to create new project in DNAC" + .format(given_projectName), "INFO") + return None + + fetched_projectName = project_details[0].get('name') + if fetched_projectName != given_projectName: + self.log("Project {0} provided is not exact match in DNAC DB" + .format(given_projectName), "INFO") + return None + + template_available = project_details[0].get('templates') + have_project["project_found"] = True + have_project["id"] = project_details[0].get("id") + have_project["isDeletable"] = project_details[0].get("isDeletable") + + self.have_project = have_project + return template_available + + def get_have_template(self, config, template_available): + """ + Get the current template related information from DNAC. + + Parameters: + config (dict) - Playbook details containing Template information. + template_available (list) - Current project information. + + Returns: + self + """ + + projectName = config.get("configuration_templates").get("project_name") + templateName = config.get("configuration_templates").get("template_name") + template = None + have_template = {} + + have_template["isCommitPending"] = False + have_template["template_found"] = False + + template_details = get_dict_result(template_available, + "name", + templateName) + # Check if specified template in playbook is available + if not template_details: + self.log("Template {0} not found in project {1}" + .format(templateName, projectName), "INFO") + self.msg = "Template : {0} missing, new template to be created".format(templateName) + self.status = "success" + return self + + config["templateId"] = template_details.get("id") + have_template["id"] = template_details.get("id") + # Get available templates which are committed under the project + template_list = self.dnac_apply['exec']( + family="configuration_templates", + function="gets_the_templates_available", + params={"projectNames": config.get("projectName")}, + ) + have_template["isCommitPending"] = True + # This check will fail if specified template is there not committed in dnac + if template_list and isinstance(template_list, list): + template_info = get_dict_result(template_list, + "name", + templateName) + if template_info: + template = self.get_template(config) + have_template["template"] = template + have_template["isCommitPending"] = False + have_template["template_found"] = template is not None \ + and isinstance(template, dict) + self.log("Template {0} is found and template " + "details are :{1}".format(templateName, str(template)), "INFO") + + # There are committed templates in the project but the + # one specified in the playbook may not be committed + self.log("Commit pending for template name {0}" + " is {1}".format(templateName, have_template.get('isCommitPending')), "INFO") + + self.have_template = have_template + self.msg = "Successfully collected all template parameters from dnac for comparison" + self.status = "success" + return self + + def get_have(self, config): + """ + Get the current project and template details from DNAC. + + Parameters: + config (dict) - Playbook details containing Project/Template information. + + Returns: + self + """ + configuration_templates = config.get("configuration_templates") + if configuration_templates: + if not configuration_templates.get("project_name"): + self.msg = "Mandatory Parameter project_name not available" + self.status = "failed" + return self + template_available = self.get_have_project(config) + if template_available: + self.get_have_template(config, template_available) + + self.msg = "Successfully collected all project and template \ + parameters from dnac for comparison" + self.status = "success" + return self + + def get_project_details(self, projectName): + """ + Get the details of specific project name provided. + + Parameters: + projectName (str) - Project Name + + Returns: + items (dict) - Project details with given project name. + """ + + items = self.dnac_apply['exec']( + family="configuration_templates", + function='get_projects', + op_modifies=True, + params={"name": projectName}, + ) + return items - template_exists = prev_template is not None and isinstance(prev_template, dict) - else: - self.module.fail_json(msg="Project Not Found", response=[]) + def get_want(self, config): + """ + Get all the template and project related information from playbook + that is needed to be created in DNAC. - have_create['template'] = prev_template - have_create['template_found'] = template_exists - self.have_create = have_create + Parameters: + config (dict) - Playbook details. - def get_want(self): - want_create = {} + Returns: + self + """ - for temp in self.validated: - template_params = self.get_template_params(temp) - version_comments = temp.get("versionDescription") + want = {} + configuration_templates = config.get("configuration_templates") + self.log("Playbook details: {0}".format(config), "INFO") + if configuration_templates: + template_params = self.get_template_params(configuration_templates) + project_params = self.get_project_params(configuration_templates) + version_comments = configuration_templates.get("version_description") - if self.params.get("state") == "merged" and \ - not self.have_create.get("template_found"): + if self.params.get("state") == "merged": + self.update_mandatory_parameters(template_params) + + want["template_params"] = template_params + want["project_params"] = project_params + want["comments"] = version_comments + + self.want = want + self.msg = "Successfully collected all parameters from playbook " + \ + "for comparison" + self.status = "success" + return self + + def create_project_or_template(self, is_create_project=False): + """ + Call DNAC API to create project or template based on the input provided. + + Parameters: + is_create_project (bool) - Default value is False. + + Returns: + creation_id (str) - Project Id. + created (str) - True if Project created, else False. + """ + + creation_id = None + created = False + self.log("Desired State (want): {0}".format(self.want), "INFO") + template_params = self.want.get("template_params") + project_params = self.want.get("project_params") + + if is_create_project: + params_key = project_params + name = "project: {0}".format(project_params.get('name')) + validation_string = "Successfully created project" + creation_value = "create_project" + else: + params_key = template_params + name = "template: {0}".format(template_params.get('name')) + validation_string = "Successfully created template" + creation_value = "create_template" + + response = self.dnac_apply['exec']( + family="configuration_templates", + function=creation_value, + op_modifies=True, + params=params_key, + ) + if not isinstance(response, dict): + self.log("Response of '{0}' is not in dictionary format." + .format(creation_value), "CRITICAL") + return creation_id, created + + task_id = response.get("response").get("taskId") + if not task_id: + self.log("Task id {0} not found for '{1}'.".format(task_id, creation_value), "CRITICAL") + return creation_id, created + + while not created: + task_details = self.get_task_details(task_id) + if not task_details: + self.log("Failed to get task details of '{0}' for taskid: {1}" + .format(creation_value, task_id), "CRITICAL") + return creation_id, created + + self.log("Task details for {0}: {1}".format(creation_value, task_details), "DEBUG") + if task_details.get("isError"): + self.log("Error occurred for '{0}' with taskid: {1}" + .format(creation_value, task_id), "ERROR") + return creation_id, created + + if validation_string not in task_details.get("progress"): + self.log("'{0}' progress set to {1} for taskid: {2}" + .format(creation_value, task_details.get('progress'), task_id), "DEBUG") + continue + + task_details_data = task_details.get("data") + value = self.check_string_dictionary(task_details_data) + if value is None: + creation_id = task_details.get("data") + else: + creation_id = value.get("templateId") + if not creation_id: + self.log("Export data is not found for '{0}' with taskid : {1}" + .format(creation_value, task_id), "DEBUG") + continue + + created = True + if is_create_project: # ProjectId is required for creating a new template. # Store it with other template parameters. - items = self.dnac._exec( - family="configuration_templates", - function='get_projects', - params={"name": temp.get("projectName")}, - ) - template_params["projectId"] = items[0].get("id") - template_params["project_id"] = items[0].get("id") - - want_create["template_params"] = template_params - want_create["comments"] = version_comments + template_params["projectId"] = creation_id + template_params["project_id"] = creation_id - self.want_create = want_create + self.log("New {0} created with id {1}".format(name, creation_id), "DEBUG") + return creation_id, created def requires_update(self): - current_obj = self.have_create.get("template") - requested_obj = self.want_create.get("template_params") + """ + Check if the template config given requires update. + + Parameters: + self - Current object. + + Returns: + bool - True if any parameter specified in obj_params differs between + current_obj and requested_obj, indicating that an update is required. + False if all specified parameters are equal. + """ + + if self.have_template.get("isCommitPending"): + self.log("Template '{0}' is in saved state and needs to be updated and committed." + .format(self.have_template.get("template").get("name")), "DEBUG") + return True + + current_obj = self.have_template.get("template") + requested_obj = self.want.get("template_params") + self.log("Current State (have): {0}".format(current_obj), "INFO") + self.log("Desired State (want): {0}".format(requested_obj), "INFO") obj_params = [ ("tags", "tags", ""), ("author", "author", ""), @@ -896,163 +2223,573 @@ class DnacTemplate: requested_obj.get(ansible_param)) for (dnac_param, ansible_param, default) in obj_params) - def get_task_details(self, id): - result = None - response = self.dnac._exec( - family="task", - function='get_task_by_id', - params={"task_id": id}, - ) + def update_mandatory_parameters(self, template_params): + """ + Update parameters which are mandatory for creating a template. + + Parameters: + template_params (dict) - Template information. + + Returns: + None + """ + + # Mandate fields required for creating a new template. + # Store it with other template parameters. + template_params["projectId"] = self.have_project.get("id") + template_params["project_id"] = self.have_project.get("id") + # Update language,deviceTypes and softwareType if not provided for existing template. + if not template_params.get("language"): + template_params["language"] = self.have_template.get('template') \ + .get('language') + if not template_params.get("deviceTypes"): + template_params["deviceTypes"] = self.have_template.get('template') \ + .get('deviceTypes') + if not template_params.get("softwareType"): + template_params["softwareType"] = self.have_template.get('template') \ + .get('softwareType') + + def validate_input_merge(self, template_exists): + """ + Validate input after getting all the parameters from DNAC. + "If mandate like deviceTypes, softwareType and language " + "already present in DNAC for a template." + "It is not required to be provided in playbook, " + "but if it is new creation error will be thrown to provide these fields. + + Parameters: + template_exists (bool) - True if template exists, else False. + + Returns: + None + """ + + template_params = self.want.get("template_params") + language = template_params.get("language").upper() + if language: + if language not in self.accepted_languages: + self.msg = "Invalid value language {0} ." \ + "Accepted language values are {1}" \ + .format(self.accepted_languages, language) + self.status = "failed" + return self + else: + template_params["language"] = "JINJA" - if self.log: - log(str(response)) + if not template_exists: + if not template_params.get("deviceTypes") \ + or not template_params.get("softwareType"): + self.msg = "DeviceTypes and SoftwareType are required arguments to create Templates" + self.status = "failed" + return self - if isinstance(response, dict): - result = response.get("response") + self.msg = "Input validated for merging" + self.status = "success" + return self - return result + def get_export_template_values(self, export_values): + """ + Get the export template values from the details provided by the playbook. - def get_diff_merge(self): - template_id = None - template_ceated = False - template_updated = False - template_exists = self.have_create.get("template_found") + Parameters: + export_values (bool) - All the template available under the project. - if template_exists: - if self.requires_update(): - response = self.dnac._exec( + Returns: + self + """ + + template_details = self.dnac._exec( + family="configuration_templates", + function='get_projects_details' + ) + for values in export_values: + project_name = values.get("project_name") + self.log("Project name for export template: {0}".format(project_name), "DEBUG") + template_details = template_details.get("response") + self.log("Template details: {0}".format(template_details), "DEBUG") + all_template_details = get_dict_result(template_details, + "name", + project_name) + self.log("Template details under the project name {0}: {1}" + .format(project_name, all_template_details), "DEBUG") + all_template_details = all_template_details.get("templates") + self.log("Template details under the project name {0}: {1}" + .format(project_name, all_template_details), "DEBUG") + template_name = values.get("template_name") + template_detail = get_dict_result(all_template_details, + "name", + template_name) + self.log("Template details with template name {0}: {1}" + .format(template_name, template_detail), "DEBUG") + if template_detail is None: + self.msg = "Invalid project_name and template_name in export" + self.status = "failed" + return self + self.export_template.append(template_detail.get("id")) + + self.msg = "Successfully collected the export template IDs" + self.status = "success" + return self + + def update_configuration_templates(self, config): + """ + Update/Create templates and projects in DNAC with fields provided in DNAC. + + Parameters: + config (dict) - Playbook details containing template information. + + Returns: + self + """ + + configuration_templates = config.get("configuration_templates") + if configuration_templates: + is_project_found = self.have_project.get("project_found") + if not is_project_found: + project_id, project_created = \ + self.create_project_or_template(is_create_project=True) + if project_created: + self.log("project created with projectId: {0}".format(project_id), "DEBUG") + else: + self.status = "failed" + self.msg = "Project creation failed" + return self + + is_template_found = self.have_template.get("template_found") + template_params = self.want.get("template_params") + self.log("Desired template details: {0}".format(template_params), "DEBUG") + self.log("Current template details: {0}".format(self.have_template), "DEBUG") + template_id = None + template_updated = False + self.validate_input_merge(is_template_found).check_return_status() + if is_template_found: + if self.requires_update(): + template_id = self.have_template.get("id") + template_params.update({"id": template_id}) + self.log("Current State (have): {0}".format(self.have_template), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + response = self.dnac_apply['exec']( + family="configuration_templates", + function="update_template", + params=template_params, + op_modifies=True, + ) + template_updated = True + self.log("Updating existing template '{0}'." + .format(self.have_template.get("template").get("name")), "INFO") + else: + # Template does not need update + self.result.update({ + 'response': self.have_template.get("template"), + 'msg': "Template does not need update" + }) + self.status = "exited" + return self + else: + if template_params.get("name"): + template_id, template_updated = self.create_project_or_template() + else: + self.msg = "missing required arguments: template_name" + self.status = "failed" + return self + + if template_updated: + # Template needs to be versioned + version_params = { + "comments": self.want.get("comments"), + "templateId": template_id + } + response = self.dnac_apply['exec']( family="configuration_templates", - function="update_template", - params=self.want_create.get("template_params"), + function="version_template", op_modifies=True, + params=version_params ) - template_updated = True - template_id = self.have_create.get("templateId") - - if self.log: - log("Updating Existing Template") - else: - # Template does not need update - self.result['response'] = self.have_create.get("template") - self.result['msg'] = "Template does not need update" - self.module.exit_json(**self.result) - else: - response = self.dnac._exec( - family="configuration_templates", - function='create_template', - op_modifies=True, - params=self.want_create.get("template_params"), - ) - - if self.log: - log("Template created. Get template_id for versioning") - if isinstance(response, dict): - create_error = False - task_details = {} task_id = response.get("response").get("taskId") - - if task_id: - while (True): - task_details = self.get_task_details(task_id) - if task_details and task_details.get("isError"): - create_error = True - break - - if task_details and ("Successfully created template" in task_details.get("progress")): - break - if not create_error: - template_id = task_details.get("data") - if template_id: - template_created = True - - if template_updated or template_created: - # Template needs to be versioned - version_params = dict( - comments=self.want_create.get("comments"), - templateId=template_id - ) - response = self.dnac._exec( - family="configuration_templates", - function='version_template', - op_modifies=True, - params=version_params - ) - task_details = {} - task_id = response.get("response").get("taskId") - - if task_id: + if not task_id: + self.msg = "Task id: {0} not found".format(task_id) + self.status = "failed" + return self task_details = self.get_task_details(task_id) self.result['changed'] = True self.result['msg'] = task_details.get('progress') - self.result['diff'] = self.validated - if self.log: - log(str(task_details)) + self.result['diff'] = config.get("configuration_templates") + self.log("Task details for 'version_template': {0}".format(task_details), "DEBUG") + self.result['response'] = task_details if task_details else response + + if not self.result.get('msg'): + self.msg = "Error while versioning the template" + self.status = "failed" + return self + + def handle_export(self, config): + """ + Export templates and projects in DNAC with fields provided in DNAC. + + Parameters: + config (dict) - Playbook details containing template information. + + Returns: + self + """ + + export = config.get("export") + if export: + export_project = export.get("project") + self.log("Export project playbook details: {0}" + .format(export_project), "DEBUG") + if export_project: + response = self.dnac._exec( + family="configuration_templates", + function='export_projects', + params={"payload": export_project}, + ) + validation_string = "successfully exported project" + self.check_task_response_status(response, + validation_string, + True).check_return_status() + self.result['response'][0].update({"exportProject": self.msg}) + + export_values = export.get("template") + if export_values: + self.get_export_template_values(export_values).check_return_status() + self.log("Exporting template playbook details: {0}" + .format(self.export_template), "DEBUG") + response = self.dnac._exec( + family="configuration_templates", + function='export_templates', + params={"payload": self.export_template}, + ) + validation_string = "successfully exported template" + self.check_task_response_status(response, + validation_string, + True).check_return_status() + self.result['response'][0].update({"exportTemplate": self.msg}) + + return self + + def handle_import(self, config): + """ + Import templates and projects in DNAC with fields provided in DNAC. + + Parameters: + config (dict) - Playbook details containing template information. + + Returns: + self + """ + + _import = config.get("import") + if _import: + # _import_project = _import.get("project") + do_version = _import.get("project").get("do_version") + payload = None + if _import.get("project").get("payload"): + payload = _import.get("project").get("payload") + else: + self.msg = "Mandatory parameter payload is not found under import project" + self.status = "failed" + return self + _import_project = { + "doVersion": do_version, + # "payload": "{0}".format(payload) + "payload": payload + } + self.log("Importing project details from the playbook: {0}" + .format(_import_project), "DEBUG") + if _import_project: + response = self.dnac._exec( + family="configuration_templates", + function='imports_the_projects_provided', + params=_import_project, + ) + validation_string = "successfully imported project" + self.check_task_response_status(response, validation_string).check_return_status() + self.result['response'][0].update({"importProject": validation_string}) + + _import_template = _import.get("template") + if _import_template.get("project_name"): + self.msg = "Mandatory paramter project_name is not found under import template" + self.status = "failed" + return self + if _import_template.get("payload"): + self.msg = "Mandatory paramter payload is not found under import template" + self.status = "failed" + return self + + payload = _import_template.get("project_name") + import_template = { + "doVersion": _import_template.get("do_version"), + "projectName": _import_template.get("project_name"), + "payload": self.get_template_params(payload) + } + self.log("Import template details from the playbook: {0}" + .format(_import_template), "DEBUG") + if _import_template: + response = self.dnac._exec( + family="configuration_templates", + function='imports_the_templates_provided', + params=import_template, + ) + validation_string = "successfully imported template" + self.check_task_response_status(response, validation_string).check_return_status() + self.result['response'][0].update({"importTemplate": validation_string}) + + return self + + def get_diff_merged(self, config): + """ + Update/Create templates and projects in DNAC with fields provided in DNAC. + Export the tempaltes and projects. + Import the templates and projects. + Check using check_return_status(). + + Parameters: + config (dict) - Playbook details containing template information. + + Returns: + self + """ + + self.update_configuration_templates(config) + if self.status == "failed": + return self + + self.handle_export(config) + if self.status == "failed": + return self + + self.handle_import(config) + if self.status == "failed": + return self + + self.msg = "Successfully completed merged state execution" + self.status = "success" + return self + + def delete_project_or_template(self, config, is_delete_project=False): + """ + Call DNAC API to delete project or template with provided inputs. + + Parameters: + config (dict) - Playbook details containing template information. + is_delete_project (bool) - True if we need to delete project, else False. + + Returns: + self + """ + + if is_delete_project: + params_key = {"project_id": self.have_project.get("id")} + deletion_value = "deletes_the_project" + name = "project: {0}".format(config.get("configuration_templates").get('project_name')) + else: + template_params = self.want.get("template_params") + params_key = {"template_id": self.have_template.get("id")} + deletion_value = "deletes_the_template" + name = "templateName: {0}".format(template_params.get('templateName')) + + response = self.dnac_apply['exec']( + family="configuration_templates", + function=deletion_value, + params=params_key, + ) + task_id = response.get("response").get("taskId") + if task_id: + task_details = self.get_task_details(task_id) + self.result['changed'] = True + self.result['msg'] = task_details.get('progress') + self.result['diff'] = config.get("configuration_templates") + + self.log("Task details for '{0}': {1}".format(deletion_value, task_details), "DEBUG") self.result['response'] = task_details if task_details else response - - if not self.result.get('msg'): - self.result['msg'] = "Error while versioning the template" - - def get_diff_delete(self): - template_exists = self.have_create.get("template_found") - - if template_exists: - response = self.dnac._exec( + if not self.result['msg']: + self.result['msg'] = "Error while deleting {name} : " + self.status = "failed" + return self + + self.msg = "Successfully deleted {0} ".format(name) + self.status = "success" + return self + + def get_diff_deleted(self, config): + """ + Delete projects or templates in DNAC with fields provided in playbook. + + Parameters: + config (dict) - Playbook details containing template information. + + Returns: + self + """ + + configuration_templates = config.get("configuration_templates") + if configuration_templates: + is_project_found = self.have_project.get("project_found") + projectName = config.get("configuration_templates").get("project_name") + + if not is_project_found: + self.msg = "Project {0} is not found".format(projectName) + self.status = "failed" + return self + + is_template_found = self.have_template.get("template_found") + template_params = self.want.get("template_params") + templateName = config.get("configuration_templates").get("template_name") + if template_params.get("name"): + if is_template_found: + self.delete_project_or_template(config) + else: + self.msg = "Invalid template {0} under project".format(templateName) + self.status = "failed" + return self + else: + self.log("Template name is empty, deleting the project '{0}' and " + "associated templates" + .format(config.get("configuration_templates").get("project_name")), "INFO") + is_project_deletable = self.have_project.get("isDeletable") + if is_project_deletable: + self.delete_project_or_template(config, is_delete_project=True) + else: + self.msg = "Project is not deletable" + self.status = "failed" + return self + + self.msg = "Successfully completed delete state execution" + 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("Template availability: {0}".format(is_template_available), "INFO") + 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("Current State (have): {0}".format(self.want.get("template_params")), "INFO") + self.log("Desired State (want): {0}".format(self.have_template.get("template")), "INFO") + 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.log("Successfully validated the Template in the Catalyst Center.", "INFO") + 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + template_list = self.dnac_apply['exec']( family="configuration_templates", - function="deletes_the_template", - params={"template_id": self.have_create.get("templateId")}, + function="gets_the_templates_available", + params={"projectNames": config.get("projectName")}, ) - task_details = {} - task_id = response.get("response").get("taskId") + 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 - if task_id: - task_details = self.get_task_details(task_id) - self.result['changed'] = True - self.result['msg'] = task_details.get('progress') - self.result['diff'] = self.validated + self.log("Successfully validated absence of template in the Catalyst Center.", "INFO") + self.result.get("response").update({"Validation": "Success"}) - if self.log: - log(str(task_details)) + self.msg = "Successfully validated the absence of Template in the DNAC." + self.status = "success" + return self - self.result['response'] = task_details if task_details else response + def reset_values(self): + """ + Reset all neccessary attributes to default values. - if not self.result['msg']: - self.result['msg'] = "Error while deleting template" - else: - self.module.fail_json(msg="Template not found", response=[]) + Parameters: + self - The current object. + + Returns: + None + """ + + self.have_project.clear() + self.have_template.clear() + self.want.clear() def main(): - """ main entry point for module execution - """ - - element_spec = dict( - dnac_host=dict(required=True, type='str'), - dnac_port=dict(type='str', default='443'), - dnac_username=dict(type='str', default='admin', aliases=["user"]), - dnac_password=dict(type='str', no_log=True), - dnac_verify=dict(type='bool', default='True'), - dnac_version=dict(type="str", default="2.2.3.3"), - dnac_debug=dict(type='bool', default=False), - dnac_log=dict(type='bool', default=False), - validate_response_schema=dict(type="bool", default=True), - config=dict(required=True, type='list', elements='dict'), - state=dict( - default='merged', - choices=['merged', 'deleted']), - ) + """ main entry point for module execution""" + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + "config_verify": {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } module = AnsibleModule(argument_spec=element_spec, supports_check_mode=False) dnac_template = DnacTemplate(module) - dnac_template.validate_input() - state = dnac_template.get_state() - dnac_template.get_have() - dnac_template.get_want() - - if state == "merged": - dnac_template.get_diff_merge() - - elif state == "deleted": - dnac_template.get_diff_delete() + 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) + dnac_template.check_return_status() + + for config in dnac_template.validated_config: + dnac_template.reset_values() + 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) diff --git a/ansible_collections/cisco/dnac/plugins/modules/template_workflow_manager.py b/ansible_collections/cisco/dnac/plugins/modules/template_workflow_manager.py new file mode 100644 index 000000000..13e81da9a --- /dev/null +++ b/ansible_collections/cisco/dnac/plugins/modules/template_workflow_manager.py @@ -0,0 +1,2885 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +"""Ansible module to perform operations on project and templates in Cisco Catalyst Center.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = ['Madhan Sankaranarayanan, Rishita Chowdhary, Akash Bhaskaran, Muthu Rakesh'] + +DOCUMENTATION = r""" +--- +module: template_workflow_manager +short_description: Resource module for Template functions +description: +- Manage operations create, update and delete of the resource Configuration Template. +- API to create a template by project name and template name. +- API to update a template by template name and project name. +- API to delete a template by template name and project name. +- API to export the projects for given projectNames. +- API to export the templates for given templateIds. +- API to manage operation create of the resource Configuration Template Import Project. +- API to manage operation create of the resource Configuration Template Import Template. +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.workflow_manager_params +author: Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) + Akash Bhaskaran (@akabhask) + Muthu Rakesh (@MUTHU-RAKESH-27) +options: + config_verify: + description: Set to True to verify the Cisco Catalyst Center after applying the playbook config. + type: bool + default: False + state: + description: The state of Cisco Catalyst Center after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of templates being managed. + type: list + elements: dict + required: true + suboptions: + configuration_templates: + description: Create/Update/Delete template. + type: dict + suboptions: + author: + description: Author of template. + type: str + composite: + description: Is it composite template. + type: bool + containing_templates: + description: Configuration Template Create's containingTemplates. + suboptions: + composite: + description: Is it composite template. + type: bool + description: + description: Description of template. + type: str + device_types: + description: deviceTypes on which templates would be applied. + type: list + elements: dict + suboptions: + product_family: + description: Device family. + type: str + product_series: + description: Device series. + type: str + product_type: + description: Device type. + type: str + id: + description: UUID of template. + type: str + language: + description: Template language + choices: + - JINJA + - VELOCITY + type: str + name: + description: Name of template. + type: str + project_name: + description: Name of the project under which templates are managed. + type: str + project_description: + description: Description of the project created. + type: str + rollback_template_params: + description: Params required for template rollback. + type: list + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Create's range. + type: list + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Create's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + tags: + description: Configuration Template Create's tags. + suboptions: + id: + description: UUID of tag. + type: str + name: + description: Name of tag. + type: str + type: list + elements: dict + template_content: + description: Template content. + type: str + template_params: + description: Configuration Template Create's templateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Create's range. + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + elements: dict + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Create's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + version: + description: Current version of template. + type: str + type: list + elements: dict + create_time: + description: Create time of template. + type: int + custom_params_order: + description: Custom Params Order. + type: bool + template_description: + description: Description of template. + type: str + device_types: + description: Configuration Template Create's deviceTypes. This field is mandatory to create a new template. + suboptions: + product_family: + description: Device family. + type: str + product_series: + description: Device series. + type: str + product_type: + description: Device type. + type: str + type: list + elements: dict + failure_policy: + description: Define failure policy if template provisioning fails. + type: str + id: + description: UUID of template. + type: str + language: + description: Template language + choices: + - JINJA + - VELOCITY + type: str + last_update_time: + description: Update time of template. + type: int + latest_version_time: + description: Latest versioned template time. + type: int + template_name: + description: Name of template. This field is mandatory to create a new template. + type: str + parent_template_id: + description: Parent templateID. + type: str + project_id: + description: Project UUID. + type: str + project_name: + description: Project name. + type: str + project_description: + description: Project Description. + type: str + rollback_template_content: + description: Rollback template content. + type: str + rollback_template_params: + description: Configuration Template Create's rollbackTemplateParams. + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Create's range. + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + elements: dict + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Create's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + elements: dict + software_type: + description: Applicable device software type. This field is mandatory to create a new template. + type: str + software_variant: + description: Applicable device software variant. + type: str + software_version: + description: Applicable device software version. + type: str + template_tag: + description: Configuration Template Create's tags. + suboptions: + id: + description: UUID of tag. + type: str + name: + description: Name of tag. + type: str + type: list + elements: dict + template_content: + description: Template content. + type: str + template_params: + description: Configuration Template Create's templateParams. + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Create's range. + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + elements: dict + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Create's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + elements: dict + validation_errors: + description: Configuration Template Create's validationErrors. + suboptions: + rollback_template_errors: + description: Validation or design conflicts errors of rollback template. + elements: dict + type: list + template_errors: + description: Validation or design conflicts errors. + elements: dict + type: list + template_id: + description: UUID of template. + type: str + template_version: + description: Current version of template. + type: str + type: dict + version: + description: Current version of template. + type: str + version_description: + description: Template version comments. + type: str + export: + description: Export the project/template details. + type: dict + suboptions: + project: + description: Export the project. + type: list + elements: str + template: + description: Export the template. + type: list + elements: dict + suboptions: + project_name: + description: Name of the project under the template available. + type: str + template_name: + description: Name of the template which we need to export + type: str + import: + description: Import the project/template details. + type: dict + suboptions: + project: + description: Import the project details. + type: dict + suboptions: + do_version: + description: DoVersion query parameter. If this flag is true, creates a new + version of the template with the imported contents, if the templates already + exists. " If false and if template already exists, then operation + fails with 'Template already exists' error. + type: bool + template: + description: Import the template details. + type: dict + suboptions: + do_version: + description: DoVersion query parameter. If this flag is true, creates a new + version of the template with the imported contents, if the templates already + exists. " If false and if template already exists, then operation + fails with 'Template already exists' error. + type: bool + payload: + description: Configuration Template Import Template's payload. + elements: dict + suboptions: + author: + description: Author of template. + type: str + composite: + description: Is it composite template. + type: bool + containing_templates: + description: Configuration Template Import Template's containingTemplates. + elements: dict + suboptions: + composite: + description: Is it composite template. + type: bool + description: + description: Description of template. + type: str + device_types: + description: Configuration Template Import Template's deviceTypes. + elements: dict + suboptions: + product_family: + description: Device family. + type: str + product_series: + description: Device series. + type: str + product_type: + description: Device type. + type: str + type: list + id: + description: UUID of template. + type: str + language: + description: Template language (JINJA or VELOCITY). + type: str + name: + description: Name of template. + type: str + project_name: + description: Project name. + type: str + rollback_template_params: + description: Configuration Template Import Template's rollbackTemplateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + tags: + description: Configuration Template Import Template's tags. + elements: dict + suboptions: + id: + description: UUID of tag. + type: str + name: + description: Name of tag. + type: str + type: list + template_content: + description: Template content. + type: str + template_params: + description: Configuration Template Import Template's templateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + version: + description: Current version of template. + type: str + type: list + create_time: + description: Create time of template. + type: int + custom_params_order: + description: Custom Params Order. + type: bool + description: + description: Description of template. + type: str + device_types: + description: Configuration Template Import Template's deviceTypes. + elements: dict + suboptions: + product_family: + description: Device family. + type: str + product_series: + description: Device series. + type: str + product_type: + description: Device type. + type: str + type: list + failure_policy: + description: Define failure policy if template provisioning fails. + type: str + id: + description: UUID of template. + type: str + language: + description: Template language (JINJA or VELOCITY). + type: str + last_update_time: + description: Update time of template. + type: int + latest_version_time: + description: Latest versioned template time. + type: int + name: + description: Name of template. + type: str + parent_template_id: + description: Parent templateID. + type: str + project_id: + description: Project UUID. + type: str + project_name: + description: Project name. + type: str + rollback_template_content: + description: Rollback template content. + type: str + rollback_template_params: + description: Configuration Template Import Template's rollbackTemplateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + software_type: + description: Applicable device software type. + type: str + software_variant: + description: Applicable device software variant. + type: str + software_version: + description: Applicable device software version. + type: str + tags: + description: Configuration Template Import Template's tags. + elements: dict + suboptions: + id: + description: UUID of tag. + type: str + name: + description: Name of tag. + type: str + type: list + template_content: + description: Template content. + type: str + template_params: + description: Configuration Template Import Template's templateParams. + elements: dict + suboptions: + binding: + description: Bind to source. + type: str + custom_order: + description: CustomOrder of template param. + type: int + data_type: + description: Datatype of template param. + type: str + default_value: + description: Default value of template param. + type: str + description: + description: Description of template param. + type: str + display_name: + description: Display name of param. + type: str + group: + description: Group. + type: str + id: + description: UUID of template param. + type: str + instruction_text: + description: Instruction text for param. + type: str + key: + description: Key. + type: str + not_param: + description: Is it not a variable. + type: bool + order: + description: Order of template param. + type: int + param_array: + description: Is it an array. + type: bool + parameter_name: + description: Name of template param. + type: str + provider: + description: Provider. + type: str + range: + description: Configuration Template Import Template's range. + elements: dict + suboptions: + id: + description: UUID of range. + type: str + max_value: + description: Max value of range. + type: int + min_value: + description: Min value of range. + type: int + type: list + required: + description: Is param required. + type: bool + selection: + description: Configuration Template Import Template's selection. + suboptions: + default_selected_values: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selection_type: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selection_values: + description: Selection values. + type: dict + type: dict + type: list + validation_errors: + description: Configuration Template Import Template's validationErrors. + suboptions: + rollback_template_errors: + description: Validation or design conflicts errors of rollback template. + type: dict + template_errors: + description: Validation or design conflicts errors. + type: dict + template_id: + description: UUID of template. + type: str + template_version: + description: Current version of template. + type: str + type: dict + version: + description: Current version of template. + type: str + type: list + project_name: + description: ProjectName path parameter. Project name to create template under the + project. + type: str + +requirements: +- dnacentersdk == 2.4.5 +- python >= 3.5 +notes: + - SDK Method used are + configuration_templates.ConfigurationTemplates.create_template, + configuration_templates.ConfigurationTemplates.deletes_the_template, + configuration_templates.ConfigurationTemplates.update_template, + configuration_templates.ConfigurationTemplates.export_projects, + configuration_templates.ConfigurationTemplates.export_templates, + configuration_templates.ConfigurationTemplates.imports_the_projects_provided, + configuration_templates.ConfigurationTemplates.imports_the_templates_provided, + + - Paths used are + post /dna/intent/api/v1/template-programmer/project/{projectId}/template, + delete /dna/intent/api/v1/template-programmer/template/{templateId}, + put /dna/intent/api/v1/template-programmer/template, + post /dna/intent/api/v1/template-programmer/project/name/exportprojects, + post /dna/intent/api/v1/template-programmer/template/exporttemplates, + post /dna/intent/api/v1/template-programmer/project/importprojects, + post /dna/intent/api/v1/template-programmer/project/name/{projectName}/template/importtemplates, + +""" + +EXAMPLES = r""" +- name: Create a new template. + cisco.dnac.template_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + - configuration_templates: + author: string + composite: true + create_time: 0 + custom_params_order: true + description: string + device_types: + - product_family: string + product_series: string + product_type: string + failure_policy: string + id: string + language: string + last_update_time: 0 + latest_version_time: 0 + name: string + parent_template_id: string + project_id: string + project_name: string + project_description: string + rollback_template_content: string + software_type: string + software_variant: string + software_version: string + tags: + - id: string + name: string + template_content: string + validation_errors: + rollback_template_errors: + - {} + template_errors: + - {} + template_id: string + template_version: string + version: string + +- name: Export the projects. + cisco.dnac.template_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + export: + project: + - string + - string + +- name: Export the templates. + cisco.dnac.template_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + export: + template: + - project_name : string + template_name: string + - project_name: string + template_name: string + +- name: Import the Projects. + cisco.dnac.template_workflow_manager: + dnac_host: "{{dnac_host}}" + dnac_username: "{{dnac_username}}" + dnac_password: "{{dnac_password}}" + dnac_verify: "{{dnac_verify}}" + dnac_port: "{{dnac_port}}" + dnac_version: "{{dnac_version}}" + dnac_debug: "{{dnac_debug}}" + dnac_log: True + dnac_log_level: "{{dnac_log_level}}" + state: merged + config_verify: True + config: + import: + project: + do_version: false + payload: + - name: string + - name: string + +""" + +RETURN = r""" +# Case_1: Successful creation/updation/deletion of template/project +response_1: + description: A dictionary with versioning details of the template as returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": { + "endTime": 0, + "version": 0, + "data": String, + "startTime": 0, + "username": String, + "progress": String, + "serviceType": String, "rootId": String, + "isError": bool, + "instanceTenantId": String, + "id": String + "version": 0 + }, + "msg": String + } + +# Case_2: Error while deleting a template or when given project is not found +response_2: + description: A list with the response returned by the Cisco Catalyst Center Python SDK + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } + +# Case_3: Given template already exists and requires no update +response_3: + description: A dictionary with the exisiting template deatails as returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": {}, + "msg": String + } + +# Case_4: Given template list that needs to be exported +response_4: + description: Details of the templates in the list as returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": {}, + "msg": String + } + +# Case_5: Given project list that needs to be exported +response_5: + description: Details of the projects in the list as returned by the Cisco Catalyst Center Python SDK + returned: always + type: dict + sample: > + { + "response": {}, + "msg": String + } + +""" + +import copy +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DnacBase, + validate_list_of_dicts, + get_dict_result, + dnac_compare_equality, +) + + +class Template(DnacBase): + """Class containing member attributes for template_workflow_manager module""" + + def __init__(self, module): + super().__init__(module) + self.have_project = {} + self.have_template = {} + self.supported_states = ["merged", "deleted"] + self.accepted_languages = ["JINJA", "VELOCITY"] + self.export_template = [] + self.result['response'].append({}) + + def validate_input(self): + """ + Validate the fields provided in the playbook. + Checks the configuration provided in the playbook against a predefined specification + to ensure it adheres to the expected structure and data types. + 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: + - self.msg: A message describing the validation result. + - self.status: The status of the validation (either 'success' or 'failed'). + - self.validated_config: If successful, a validated version of 'config' parameter. + Example: + To use this method, create an instance of the class and call 'validate_input' on it. + If the validation succeeds, 'self.status' will be 'success' and 'self.validated_config' + will contain the validated configuration. If it fails, 'self.status' will be 'failed', + 'self.msg' will describe the validation issues. + + """ + + if not self.config: + self.msg = "config not available in playbook for validattion" + self.status = "success" + return self + + temp_spec = { + "configuration_templates": { + 'type': 'dict', + 'tags': {'type': 'list'}, + 'author': {'type': 'str'}, + 'composite': {'type': 'bool'}, + 'containing_templates': {'type': 'list'}, + 'create_time': {'type': 'int'}, + 'custom_params_order': {'type': 'bool'}, + 'description': {'type': 'str'}, + 'device_types': { + 'type': 'list', + 'elements': 'dict', + 'product_family': {'type': 'str'}, + 'product_series': {'type': 'str'}, + 'product_type': {'type': 'str'}, + }, + 'failure_policy': {'type': 'str'}, + 'id': {'type': 'str'}, + 'language': {'type': 'str'}, + 'last_update_time': {'type': 'int'}, + 'latest_version_time': {'type': 'int'}, + 'name': {'type': 'str'}, + 'parent_template_id': {'type': 'str'}, + 'project_id': {'type': 'str'}, + 'project_name': {'type': 'str'}, + 'project_description': {'type': 'str'}, + 'rollback_template_content': {'type': 'str'}, + 'rollback_template_params': {'type': 'list'}, + 'software_type': {'type': 'str'}, + 'software_variant': {'type': 'str'}, + 'software_version': {'type': 'str'}, + 'template_content': {'type': 'str'}, + 'template_params': {'type': 'list'}, + 'template_name': {'type': 'str'}, + 'validation_errors': {'type': 'dict'}, + 'version': {'type': 'str'}, + 'version_description': {'type': 'str'} + }, + 'export': { + 'type': 'dict', + 'project': {'type': 'list', 'elements': 'str'}, + 'template': { + 'type': 'list', + 'elements': 'dict', + 'project_name': {'type': 'str'}, + 'template_name': {'type': 'str'} + } + }, + 'import': { + 'type': 'dict', + 'project': { + 'type': 'dict', + 'do_version': {'type': 'str', 'default': 'False'}, + }, + 'template': { + 'type': 'dict', + 'do_version': {'type': 'str', 'default': 'False'}, + 'payload': { + 'type': 'list', + 'elements': 'dict', + 'tags': {'type': 'list'}, + 'author': {'type': 'str'}, + 'composite': {'type': 'bool'}, + 'containing_templates': {'type': 'list'}, + 'create_time': {'type': 'int'}, + 'custom_params_order': {'type': 'bool'}, + 'description': {'type': 'str'}, + 'device_types': { + 'type': 'list', + 'elements': 'dict', + 'product_family': {'type': 'str'}, + 'product_series': {'type': 'str'}, + 'product_type': {'type': 'str'}, + }, + 'failure_policy': {'type': 'str'}, + 'id': {'type': 'str'}, + 'language': {'type': 'str'}, + 'last_update_time': {'type': 'int'}, + 'latest_version_time': {'type': 'int'}, + 'name': {'type': 'str'}, + 'parent_template_id': {'type': 'str'}, + 'project_id': {'type': 'str'}, + 'project_name': {'type': 'str'}, + 'project_description': {'type': 'str'}, + 'rollback_template_content': {'type': 'str'}, + 'rollback_template_params': {'type': 'list'}, + 'software_type': {'type': 'str'}, + 'software_variant': {'type': 'str'}, + 'software_version': {'type': 'str'}, + 'template_content': {'type': 'str'}, + 'template_params': {'type': 'list'}, + 'template_name': {'type': 'str'}, + 'validation_errors': {'type': 'dict'}, + 'version': {'type': 'str'} + } + } + } + } + # Validate template params + 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)) + self.status = "failed" + return self + + self.validated_config = valid_temp + self.log("Successfully validated playbook config params: {0}".format(valid_temp), "INFO") + self.msg = "Successfully validated input" + self.status = "success" + return self + + def get_project_params(self, params): + """ + Store project parameters from the playbook for template processing in Cisco Catalyst Center. + + Parameters: + params (dict) - Playbook details containing Project information. + + Returns: + project_params (dict) - Organized Project parameters. + """ + + project_params = {"name": params.get("project_name"), + "description": params.get("project_description") + } + return project_params + + def get_tags(self, _tags): + """ + Store tags from the playbook for template processing in Cisco Catalyst Center. + Check using check_return_status() + + Parameters: + tags (dict) - Tags details containing Template information. + + Returns: + tags (dict) - Organized tags parameters. + """ + + if _tags is None: + return None + + tags = [] + i = 0 + for item in _tags: + tags.append({}) + id = item.get("id") + if id is not None: + tags[i].update({"id": id}) + + name = item.get("name") + if name is not None: + tags[i].update({"name": name}) + else: + self.msg = "name is mandatory in tags in location " + str(i) + self.status = "failed" + return self.check_return_status() + + return tags + + def get_device_types(self, device_types): + """ + Store device types parameters from the playbook for template processing in Cisco Catalyst Center. + Check using check_return_status() + + Parameters: + device_types (dict) - Device types details containing Template information. + + Returns: + deviceTypes (dict) - Organized device types parameters. + """ + + if device_types is None: + self.msg = "Mandatory parameter 'device_types' is required." + self.status = "failed" + return self.check_return_status() + + deviceTypes = [] + i = 0 + for item in device_types: + deviceTypes.append({}) + product_family = item.get("product_family") + if product_family is not None: + deviceTypes[i].update({"productFamily": product_family}) + else: + self.msg = "product_family is mandatory for deviceTypes" + self.status = "failed" + return self.check_return_status() + + product_series = item.get("product_series") + if product_series is not None: + deviceTypes[i].update({"productSeries": product_series}) + product_type = item.get("product_type") + if product_type is not None: + deviceTypes[i].update({"productType": product_type}) + i = i + 1 + + return deviceTypes + + def get_validation_errors(self, validation_errors): + """ + Store template parameters from the playbook for template processing in Cisco Catalyst Center. + + Parameters: + validation_errors (dict) - Playbook details containing validation errors information. + + Returns: + validationErrors (dict) - Organized validation errors parameters. + """ + + if validation_errors is None: + return None + + validationErrors = {} + rollback_template_errors = validation_errors.get("rollback_template_errors") + if rollback_template_errors is not None: + validationErrors.update({ + "rollbackTemplateErrors": rollback_template_errors + }) + + template_errors = validation_errors.get("template_errors") + if template_errors is not None: + validationErrors.update({ + "templateErrors": template_errors + }) + + template_id = validation_errors.get("template_id") + if template_id is not None: + validationErrors.update({ + "templateId": template_id + }) + + template_version = validation_errors.get("template_version") + if template_version is not None: + validationErrors.update({ + "templateVersion": template_version + }) + + return validationErrors + + def get_template_info(self, template_params): + """ + Store template params from the playbook for template processing in Cisco Catalyst Center. + Check using check_return_status() + + Parameters: + template_params (dict) - Playbook details containing template params information. + + Returns: + templateParams (dict) - Organized template params parameters. + """ + + if template_params is None: + return None + + templateParams = [] + i = 0 + self.log("Template params details: {0}".format(template_params), "DEBUG") + for item in template_params: + self.log("Template params items: {0}".format(item), "DEBUG") + templateParams.append({}) + binding = item.get("binding") + if binding is not None: + templateParams[i].update({"binding": binding}) + + custom_order = item.get("custom_order") + if custom_order is not None: + templateParams[i].update({"customOrder": custom_order}) + + default_value = item.get("default_value") + if default_value is not None: + templateParams[i].update({"defaultValue": default_value}) + + description = item.get("description") + if description is not None: + templateParams[i].update({"description": description}) + + display_name = item.get("display_name") + if display_name is not None: + templateParams[i].update({"displayName": display_name}) + + group = item.get("group") + if group is not None: + templateParams[i].update({"group": group}) + + id = item.get("id") + if id is not None: + templateParams[i].update({"id": id}) + + instruction_text = item.get("instruction_text") + if instruction_text is not None: + templateParams[i].update({"instructionText": instruction_text}) + + key = item.get("key") + if key is not None: + templateParams[i].update({"key": key}) + + not_param = item.get("not_param") + if not_param is not None: + templateParams[i].update({"notParam": not_param}) + + order = item.get("order") + if order is not None: + templateParams[i].update({"order": order}) + + param_array = item.get("param_array") + if param_array is not None: + templateParams[i].update({"paramArray": param_array}) + + provider = item.get("provider") + if provider is not None: + templateParams[i].update({"provider": provider}) + + parameter_name = item.get("parameter_name") + if parameter_name is not None: + templateParams[i].update({"parameterName": parameter_name}) + else: + self.msg = "parameter_name is mandatory for the template_params." + self.status = "failed" + return self.check_return_status() + + data_type = item.get("data_type") + datatypes = ["STRING", "INTEGER", "IPADDRESS", "MACADDRESS", "SECTIONDIVIDER"] + if data_type is not None: + templateParams[i].update({"dataType": data_type}) + else: + self.msg = "dataType is mandatory for the template_params." + self.status = "failed" + return self.check_return_status() + if data_type not in datatypes: + self.msg = "data_type under template_params should be in " + str(datatypes) + self.status = "failed" + return self.check_return_status() + + required = item.get("required") + if required is not None: + templateParams[i].update({"required": required}) + + range = item.get("range") + self.log("Template params range list: {0}".format(range), "DEBUG") + if range is not None: + templateParams[i].update({"range": []}) + _range = templateParams[i].get("range") + self.log("Template params range: {0}".format(_range), "DEBUG") + j = 0 + for value in range: + _range.append({}) + id = value.get("id") + if id is not None: + _range[j].update({"id": id}) + max_value = value.get("max_value") + if max_value is not None: + _range[j].update({"maxValue": max_value}) + else: + self.msg = "max_value is mandatory for range under template_params" + self.status = "failed" + return self.check_return_status() + min_value = value.get("min_value") + if min_value is not None: + _range[j].update({"maxValue": min_value}) + else: + self.msg = "min_value is mandatory for range under template_params" + self.status = "failed" + return self.check_return_status() + j = j + 1 + + self.log("Template params details: {0}".format(templateParams), "DEBUG") + selection = item.get("selection") + self.log("Template params selection: {0}".format(selection), "DEBUG") + if selection is not None: + templateParams[i].update({"selection": {}}) + _selection = templateParams[i].get("selection") + id = selection.get("id") + if id is not None: + _selection.update({"id": id}) + default_selected_values = selection.get("default_selected_values") + if default_selected_values is not None: + _selection.update({"defaultSelectedValues": default_selected_values}) + selection_values = selection.get("selection_values") + if selection_values is not None: + _selection.update({"selectionValues": selection_values}) + selection_type = selection.get("selection_type") + if selection_type is not None: + _selection.update({"selectionType": selection_type}) + i = i + 1 + + return templateParams + + def get_containing_templates(self, containing_templates): + """ + Store tags from the playbook for template processing in Cisco Catalyst Center. + Check using check_return_status() + + Parameters: + containing_templates (dict) - Containing tempaltes details + containing Template information. + + Returns: + containingTemplates (dict) - Organized containing templates parameters. + """ + + if containing_templates is None: + return None + + containingTemplates = [] + i = 0 + for item in containing_templates: + containingTemplates.append({}) + _tags = item.get("tags") + if _tags is not None: + containingTemplates[i].update({"tags": self.get_tags(_tags)}) + + composite = item.get("composite") + if composite is not None: + containingTemplates[i].update({"composite": composite}) + + description = item.get("description") + if description is not None: + containingTemplates[i].update({"description": description}) + + device_types = item.get("device_types") + if device_types is not None: + containingTemplates[i].update({ + "deviceTypes": self.get_device_types(device_types) + }) + + id = item.get("id") + if id is not None: + containingTemplates[i].update({"id": id}) + + name = item.get("name") + if name is None: + self.msg = "name is mandatory under containing templates" + self.status = "failed" + return self.check_return_status() + + containingTemplates[i].update({"name": name}) + + language = item.get("language") + if language is None: + self.msg = "language is mandatory under containing templates" + self.status = "failed" + return self.check_return_status() + + language_list = ["JINJA", "VELOCITY"] + if language not in language_list: + self.msg = "language under containing templates should be in " + str(language_list) + self.status = "failed" + return self.check_return_status() + + containingTemplates[i].update({"language": language}) + + project_name = item.get("project_name") + if project_name is not None: + containingTemplates[i].update({"projectName": project_name}) + else: + self.msg = "project_name is mandatory under containing templates" + self.status = "failed" + return self.check_return_status() + + rollback_template_params = item.get("rollback_template_params") + if rollback_template_params is not None: + containingTemplates[i].update({ + "rollbackTemplateParams": self.get_template_info(rollback_template_params) + }) + + template_content = item.get("template_content") + if template_content is not None: + containingTemplates[i].update({"templateContent": template_content}) + + template_params = item.get("template_params") + if template_params is not None: + containingTemplates[i].update({ + "templateParams": self.get_template_info(template_params) + }) + + version = item.get("version") + if version is not None: + containingTemplates[i].update({"version": version}) + + return containingTemplates + + def get_template_params(self, params): + """ + Store template parameters from the playbook for template processing in Cisco Catalyst Center. + + Parameters: + params (dict) - Playbook details containing Template information. + + Returns: + temp_params (dict) - Organized template parameters. + """ + + self.log("Template params playbook details: {0}".format(params), "DEBUG") + self.log(str(params)) + temp_params = { + "tags": self.get_tags(params.get("template_tag")), + "author": params.get("author"), + "composite": params.get("composite"), + "containingTemplates": + self.get_containing_templates(params.get("containing_templates")), + "createTime": params.get("create_time"), + "customParamsOrder": params.get("custom_params_order"), + "description": params.get("template_description"), + "deviceTypes": + self.get_device_types(params.get("device_types")), + "failurePolicy": params.get("failure_policy"), + "id": params.get("id"), + "lastUpdateTime": params.get("last_update_time"), + "latestVersionTime": params.get("latest_version_time"), + "parentTemplateId": params.get("parent_template_id"), + "projectId": params.get("project_id"), + "rollbackTemplateContent": params.get("rollback_template_content"), + "rollbackTemplateParams": + self.get_template_info(params.get("rollback_template_params")), + "softwareVariant": params.get("software_variant"), + "softwareVersion": params.get("software_version"), + "templateContent": params.get("template_content"), + "templateParams": + self.get_template_info(params.get("template_params")), + "validationErrors": + self.get_validation_errors(params.get("validation_errors")), + "version": params.get("version"), + "project_id": params.get("project_id") + } + language = params.get("language") + if not language: + self.msg = "Mandatory parameter 'language' is required." + self.status = "failed" + return self.check_return_status() + + language = language.upper() + language_list = ["JINJA", "VELOCITY"] + if language not in language_list: + self.msg = "language should be in '{0}'".format(language_list) + self.status = "failed" + return self.check_return_status() + + temp_params.update({"language": language}) + + name = params.get("template_name") + if not name: + self.msg = "Mandatory parameter 'template_name' is required." + self.status = "failed" + return self.check_return_status() + + temp_params.update({"name": name}) + + projectName = params.get("project_name") + if not projectName: + self.msg = "Mandatory parameter 'project_name' is required." + self.status = "failed" + return self.check_return_status() + + temp_params.update({"project_name": projectName}) + + softwareType = params.get("software_type") + if not softwareType: + self.msg = "Mandatory parameter 'software_type' is required." + self.status = "failed" + return self.check_return_status() + + temp_params.update({"softwareType": softwareType}) + + self.log("Formatted template params details: {0}".format(temp_params), "DEBUG") + copy_temp_params = copy.deepcopy(temp_params) + for item in copy_temp_params: + if temp_params[item] is None: + del temp_params[item] + self.log(str(temp_params)) + return temp_params + + def get_template(self, config): + """ + Get the template needed for updation or creation. + + Parameters: + config (dict) - Playbook details containing Template information. + + Returns: + result (dict) - Template details for the given template ID. + """ + + result = None + items = self.dnac_apply['exec']( + family="configuration_templates", + function="get_template_details", + params={"template_id": config.get("templateId")} + ) + if items: + result = items + + self.log("Received API response from 'get_template_details': {0}".format(items), "DEBUG") + self.result['response'] = items + return result + + def get_have_project(self, config): + """ + Get the current project related information from Cisco Catalyst Center. + + Parameters: + config (dict) - Playbook details containing Project information. + + Returns: + template_available (list) - Current project information. + """ + + have_project = {} + given_projectName = config.get("configuration_templates").get("project_name") + template_available = None + + # Check if project exists. + project_details = self.get_project_details(given_projectName) + # Cisco Catalyst Center returns project details even if the substring matches. + # Hence check the projectName retrieved from Cisco Catalyst Center. + if not (project_details and isinstance(project_details, list)): + self.log("Project: {0} not found, need to create new project in Cisco Catalyst Center" + .format(given_projectName), "INFO") + return None + + fetched_projectName = project_details[0].get('name') + if fetched_projectName != given_projectName: + self.log("Project {0} provided is not exact match in Cisco Catalyst Center DB" + .format(given_projectName), "INFO") + return None + + template_available = project_details[0].get('templates') + have_project["project_found"] = True + have_project["id"] = project_details[0].get("id") + have_project["isDeletable"] = project_details[0].get("isDeletable") + + self.have_project = have_project + return template_available + + def get_have_template(self, config, template_available): + """ + Get the current template related information from Cisco Catalyst Center. + + Parameters: + config (dict) - Playbook details containing Template information. + template_available (list) - Current project information. + + Returns: + self + """ + + projectName = config.get("configuration_templates").get("project_name") + templateName = config.get("configuration_templates").get("template_name") + template = None + have_template = {} + + have_template["isCommitPending"] = False + have_template["template_found"] = False + + template_details = get_dict_result(template_available, + "name", + templateName) + # Check if specified template in playbook is available + if not template_details: + self.log("Template {0} not found in project {1}" + .format(templateName, projectName), "INFO") + self.msg = "Template : {0} missing, new template to be created".format(templateName) + self.status = "success" + return self + + config["templateId"] = template_details.get("id") + have_template["id"] = template_details.get("id") + # Get available templates which are committed under the project + template_list = self.dnac_apply['exec']( + family="configuration_templates", + function="gets_the_templates_available", + params={"projectNames": config.get("projectName")}, + ) + have_template["isCommitPending"] = True + # This check will fail if specified template is there not committed in Cisco Catalyst Center + if template_list and isinstance(template_list, list): + template_info = get_dict_result(template_list, + "name", + templateName) + if template_info: + template = self.get_template(config) + have_template["template"] = template + have_template["isCommitPending"] = False + have_template["template_found"] = template is not None \ + and isinstance(template, dict) + self.log("Template {0} is found and template " + "details are :{1}".format(templateName, str(template)), "INFO") + + # There are committed templates in the project but the + # one specified in the playbook may not be committed + self.log("Commit pending for template name {0}" + " is {1}".format(templateName, have_template.get('isCommitPending')), "INFO") + + self.have_template = have_template + self.msg = "Successfully collected all template parameters from Cisco Catalyst Center for comparison" + self.status = "success" + return self + + def get_have(self, config): + """ + Get the current project and template details from Cisco Catalyst Center. + + Parameters: + config (dict) - Playbook details containing Project/Template information. + + Returns: + self + """ + configuration_templates = config.get("configuration_templates") + if configuration_templates: + if not configuration_templates.get("project_name"): + self.msg = "Mandatory Parameter project_name not available" + self.status = "failed" + return self + template_available = self.get_have_project(config) + if template_available: + self.get_have_template(config, template_available) + + self.msg = "Successfully collected all project and template \ + parameters from Cisco Catalyst Center for comparison" + self.status = "success" + return self + + def get_project_details(self, projectName): + """ + Get the details of specific project name provided. + + Parameters: + projectName (str) - Project Name + + Returns: + items (dict) - Project details with given project name. + """ + + items = self.dnac_apply['exec']( + family="configuration_templates", + function='get_projects', + op_modifies=True, + params={"name": projectName}, + ) + return items + + def get_want(self, config): + """ + Get all the template and project related information from playbook + that is needed to be created in Cisco Catalyst Center. + + Parameters: + config (dict) - Playbook details. + + Returns: + self + """ + + want = {} + configuration_templates = config.get("configuration_templates") + self.log("Playbook details: {0}".format(config), "INFO") + if configuration_templates: + template_params = self.get_template_params(configuration_templates) + project_params = self.get_project_params(configuration_templates) + version_comments = configuration_templates.get("version_description") + + if self.params.get("state") == "merged": + self.update_mandatory_parameters(template_params) + + want["template_params"] = template_params + want["project_params"] = project_params + want["comments"] = version_comments + + self.want = want + self.msg = "Successfully collected all parameters from playbook " + \ + "for comparison" + self.status = "success" + return self + + def create_project_or_template(self, is_create_project=False): + """ + Call Cisco Catalyst Center API to create project or template based on the input provided. + + Parameters: + is_create_project (bool) - Default value is False. + + Returns: + creation_id (str) - Project Id. + created (str) - True if Project created, else False. + """ + + creation_id = None + created = False + self.log("Desired State (want): {0}".format(self.want), "INFO") + template_params = self.want.get("template_params") + project_params = self.want.get("project_params") + + if is_create_project: + params_key = project_params + name = "project: {0}".format(project_params.get('name')) + validation_string = "Successfully created project" + creation_value = "create_project" + else: + params_key = template_params + name = "template: {0}".format(template_params.get('name')) + validation_string = "Successfully created template" + creation_value = "create_template" + + response = self.dnac_apply['exec']( + family="configuration_templates", + function=creation_value, + op_modifies=True, + params=params_key, + ) + if not isinstance(response, dict): + self.log("Response of '{0}' is not in dictionary format." + .format(creation_value), "CRITICAL") + return creation_id, created + + task_id = response.get("response").get("taskId") + if not task_id: + self.log("Task id {0} not found for '{1}'.".format(task_id, creation_value), "CRITICAL") + return creation_id, created + + while not created: + task_details = self.get_task_details(task_id) + if not task_details: + self.log("Failed to get task details of '{0}' for taskid: {1}" + .format(creation_value, task_id), "CRITICAL") + return creation_id, created + + self.log("Task details for {0}: {1}".format(creation_value, task_details), "DEBUG") + if task_details.get("isError"): + self.log("Error occurred for '{0}' with taskid: {1}" + .format(creation_value, task_id), "ERROR") + return creation_id, created + + if validation_string not in task_details.get("progress"): + self.log("'{0}' progress set to {1} for taskid: {2}" + .format(creation_value, task_details.get('progress'), task_id), "DEBUG") + continue + + task_details_data = task_details.get("data") + value = self.check_string_dictionary(task_details_data) + if value is None: + creation_id = task_details.get("data") + else: + creation_id = value.get("templateId") + if not creation_id: + self.log("Export data is not found for '{0}' with taskid : {1}" + .format(creation_value, task_id), "DEBUG") + continue + + created = True + if is_create_project: + # ProjectId is required for creating a new template. + # Store it with other template parameters. + template_params["projectId"] = creation_id + template_params["project_id"] = creation_id + + self.log("New {0} created with id {1}".format(name, creation_id), "DEBUG") + return creation_id, created + + def requires_update(self): + """ + Check if the template config given requires update. + + Parameters: + self - Current object. + + Returns: + bool - True if any parameter specified in obj_params differs between + current_obj and requested_obj, indicating that an update is required. + False if all specified parameters are equal. + """ + + if self.have_template.get("isCommitPending"): + self.log("Template '{0}' is in saved state and needs to be updated and committed." + .format(self.have_template.get("template").get("name")), "DEBUG") + return True + + current_obj = self.have_template.get("template") + requested_obj = self.want.get("template_params") + self.log("Current State (have): {0}".format(current_obj), "INFO") + self.log("Desired State (want): {0}".format(requested_obj), "INFO") + obj_params = [ + ("tags", "tags", ""), + ("author", "author", ""), + ("composite", "composite", False), + ("containingTemplates", "containingTemplates", []), + ("createTime", "createTime", ""), + ("customParamsOrder", "customParamsOrder", False), + ("description", "description", ""), + ("deviceTypes", "deviceTypes", []), + ("failurePolicy", "failurePolicy", ""), + ("id", "id", ""), + ("language", "language", "VELOCITY"), + ("lastUpdateTime", "lastUpdateTime", ""), + ("latestVersionTime", "latestVersionTime", ""), + ("name", "name", ""), + ("parentTemplateId", "parentTemplateId", ""), + ("projectId", "projectId", ""), + ("projectName", "projectName", ""), + ("rollbackTemplateContent", "rollbackTemplateContent", ""), + ("rollbackTemplateParams", "rollbackTemplateParams", []), + ("softwareType", "softwareType", ""), + ("softwareVariant", "softwareVariant", ""), + ("softwareVersion", "softwareVersion", ""), + ("templateContent", "templateContent", ""), + ("templateParams", "templateParams", []), + ("validationErrors", "validationErrors", {}), + ("version", "version", ""), + ] + + return any(not dnac_compare_equality(current_obj.get(dnac_param, default), + requested_obj.get(ansible_param)) + for (dnac_param, ansible_param, default) in obj_params) + + def update_mandatory_parameters(self, template_params): + """ + Update parameters which are mandatory for creating a template. + + Parameters: + template_params (dict) - Template information. + + Returns: + None + """ + + # Mandate fields required for creating a new template. + # Store it with other template parameters. + self.log(str(template_params)) + self.log(str(self.have_project)) + template_params["projectId"] = self.have_project.get("id") + template_params["project_id"] = self.have_project.get("id") + # Update language,deviceTypes and softwareType if not provided for existing template. + if not template_params.get("language"): + template_params["language"] = self.have_template.get('template') \ + .get('language') + if not template_params.get("deviceTypes"): + template_params["deviceTypes"] = self.have_template.get('template') \ + .get('deviceTypes') + if not template_params.get("softwareType"): + template_params["softwareType"] = self.have_template.get('template') \ + .get('softwareType') + + def validate_input_merge(self, template_exists): + """ + Validate input after getting all the parameters from Cisco Catalyst Center. + "If mandate like deviceTypes, softwareType and language " + "already present in Cisco Catalyst Center for a template." + "It is not required to be provided in playbook, " + "but if it is new creation error will be thrown to provide these fields. + + Parameters: + template_exists (bool) - True if template exists, else False. + + Returns: + None + """ + + template_params = self.want.get("template_params") + language = template_params.get("language").upper() + if language: + if language not in self.accepted_languages: + self.msg = "Invalid value language {0} ." \ + "Accepted language values are {1}" \ + .format(self.accepted_languages, language) + self.status = "failed" + return self + else: + template_params["language"] = "JINJA" + + if not template_exists: + if not template_params.get("deviceTypes") \ + or not template_params.get("softwareType"): + self.msg = "DeviceTypes and SoftwareType are required arguments to create Templates" + self.status = "failed" + return self + + self.msg = "Input validated for merging" + self.status = "success" + return self + + def get_export_template_values(self, export_values): + """ + Get the export template values from the details provided by the playbook. + + Parameters: + export_values (bool) - All the template available under the project. + + Returns: + self + """ + + template_details = self.dnac._exec( + family="configuration_templates", + function='get_projects_details' + ) + for values in export_values: + project_name = values.get("project_name") + self.log("Project name for export template: {0}".format(project_name), "DEBUG") + template_details = template_details.get("response") + self.log("Template details: {0}".format(template_details), "DEBUG") + all_template_details = get_dict_result(template_details, + "name", + project_name) + self.log("Template details under the project name {0}: {1}" + .format(project_name, all_template_details), "DEBUG") + all_template_details = all_template_details.get("templates") + self.log("Template details under the project name {0}: {1}" + .format(project_name, all_template_details), "DEBUG") + template_name = values.get("template_name") + template_detail = get_dict_result(all_template_details, + "name", + template_name) + self.log("Template details with template name {0}: {1}" + .format(template_name, template_detail), "DEBUG") + if template_detail is None: + self.msg = "Invalid project_name and template_name in export" + self.status = "failed" + return self + self.export_template.append(template_detail.get("id")) + + self.msg = "Successfully collected the export template IDs" + self.status = "success" + return self + + def update_configuration_templates(self, configuration_templates): + """ + Update/Create templates and projects in CCC with fields provided in Cisco Catalyst Center. + + Parameters: + configuration_templates (dict) - Playbook details containing template information. + + Returns: + self + """ + + is_project_found = self.have_project.get("project_found") + if not is_project_found: + project_id, project_created = \ + self.create_project_or_template(is_create_project=True) + if not project_created: + self.status = "failed" + self.msg = "Project creation failed" + return self + + self.log("project created with projectId: {0}".format(project_id), "DEBUG") + + is_template_found = self.have_template.get("template_found") + template_params = self.want.get("template_params") + self.log("Desired template details: {0}".format(template_params), "DEBUG") + self.log("Current template details: {0}".format(self.have_template), "DEBUG") + template_id = None + template_updated = False + self.validate_input_merge(is_template_found).check_return_status() + if is_template_found: + if not self.requires_update(): + # Template does not need update + self.result.update({ + 'response': self.have_template.get("template"), + 'msg': "Template does not need update" + }) + self.status = "exited" + return self + + template_id = self.have_template.get("id") + template_params.update({"id": template_id}) + self.log("Current State (have): {0}".format(self.have_template), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + response = self.dnac_apply['exec']( + family="configuration_templates", + function="update_template", + params=template_params, + op_modifies=True, + ) + template_updated = True + self.log("Updating existing template '{0}'." + .format(self.have_template.get("template").get("name")), "INFO") + + else: + if not template_params.get("name"): + self.msg = "missing required arguments: template_name" + self.status = "failed" + return self + template_id, template_updated = self.create_project_or_template() + + if template_updated: + # Template needs to be versioned + version_params = { + "comments": self.want.get("comments"), + "templateId": template_id + } + response = self.dnac_apply['exec']( + family="configuration_templates", + function="version_template", + op_modifies=True, + params=version_params + ) + task_id = response.get("response").get("taskId") + if not task_id: + self.msg = "Task id: {0} not found".format(task_id) + self.status = "failed" + return self + task_details = self.get_task_details(task_id) + self.result['changed'] = True + self.result['msg'] = task_details.get('progress') + self.result['diff'] = configuration_templates + self.log("Task details for 'version_template': {0}".format(task_details), "DEBUG") + self.result['response'] = task_details if task_details else response + + if not self.result.get('msg'): + self.msg = "Error while versioning the template" + self.status = "failed" + return self + + def handle_export(self, export): + """ + Export templates and projects in CCC with fields provided in Cisco Catalyst Center. + + Parameters: + export (dict) - Playbook details containing export project/template information. + + Returns: + self + """ + + export_project = export.get("project") + self.log("Export project playbook details: {0}" + .format(export_project), "DEBUG") + if export_project: + response = self.dnac._exec( + family="configuration_templates", + function='export_projects', + params={ + "payload": export_project, + "active_validation": False, + }, + ) + validation_string = "successfully exported project" + self.check_task_response_status(response, + validation_string, + True).check_return_status() + self.result['response'][0].update({"exportProject": self.msg}) + + export_values = export.get("template") + if export_values: + self.get_export_template_values(export_values).check_return_status() + self.log("Exporting template playbook details: {0}" + .format(self.export_template), "DEBUG") + response = self.dnac._exec( + family="configuration_templates", + function='export_templates', + params={ + "payload": self.export_template, + "active_validation": False, + }, + ) + validation_string = "successfully exported template" + self.check_task_response_status(response, + validation_string, + True).check_return_status() + self.result['response'][0].update({"exportTemplate": self.msg}) + + return self + + def handle_import(self, _import): + """ + Import templates and projects in CCC with fields provided in Cisco Catalyst Center. + + Parameters: + _import (dict) - Playbook details containing import project/template information. + + Returns: + self + """ + + _import_project = _import.get("project") + if _import_project: + do_version = _import_project.get("do_version") + if not do_version: + do_version = False + payload = None + if _import.get("project").get("payload"): + payload = _import.get("project").get("payload") + else: + self.msg = "Mandatory parameter payload is not found under import project" + self.status = "failed" + return self + final_payload = [] + for item in payload: + response = self.get_project_details(item.get("name")) + if response == []: + final_payload.append(item) + if final_payload != []: + _import_project = { + "do_version": do_version, + "payload": final_payload, + "active_validation": False, + } + self.log("Importing project details from the playbook: {0}" + .format(_import_project), "DEBUG") + if _import_project: + response = self.dnac._exec( + family="configuration_templates", + function='imports_the_projects_provided', + params=_import_project, + ) + validation_string = "successfully imported project" + self.check_task_response_status(response, validation_string).check_return_status() + self.result['response'][0].update({"importProject": validation_string}) + else: + self.msg = "Projects '{0}' already available.".format(payload) + self.result['response'][0].update({ + "importProject": "Projects '{0}' already available.".format(payload) + }) + + _import_template = _import.get("template") + if _import_template: + do_version = _import_template.get("do_version") + if not do_version: + do_version = False + if not _import_template.get("project_name"): + self.msg = "Mandatory parameter project_name is not found under import template" + self.status = "failed" + return self + + if not _import_template.get("payload"): + self.msg = "Mandatory parameter payload is not found under import template" + self.status = "failed" + return self + + payload = _import_template.get("payload") + final_payload = [] + for item in payload: + self.log(str(item)) + final_payload.append(self.get_template_params(item)) + self.log(str(final_payload)) + import_template = { + "do_version": _import_template.get("do_version"), + "project_name": _import_template.get("project_name"), + "payload": final_payload, + "active_validation": False, + } + self.log("Import template details from the playbook: {0}" + .format(_import_template), "DEBUG") + if _import_template: + response = self.dnac._exec( + family="configuration_templates", + function='imports_the_templates_provided', + params=import_template + ) + validation_string = "successfully imported template" + self.check_task_response_status(response, validation_string).check_return_status() + self.result['response'][0].update({"importTemplate": validation_string}) + + return self + + def get_diff_merged(self, config): + """ + Update/Create templates and projects in CCC with fields provided in Cisco Catalyst Center. + Export the tempaltes and projects. + Import the templates and projects. + Check using check_return_status(). + + Parameters: + config (dict) - Playbook details containing template information. + + Returns: + self + """ + + configuration_templates = config.get("configuration_templates") + if configuration_templates: + self.update_configuration_templates(configuration_templates) + if self.status == "failed": + return self + + export = config.get("export") + if export: + self.handle_export(export) + if self.status == "failed": + return self + + _import = config.get("import") + if _import: + self.handle_import(_import) + if self.status == "failed": + return self + + self.msg = "Successfully completed merged state execution" + self.status = "success" + return self + + def delete_project_or_template(self, config, is_delete_project=False): + """ + Call Cisco Catalyst Center API to delete project or template with provided inputs. + + Parameters: + config (dict) - Playbook details containing template information. + is_delete_project (bool) - True if we need to delete project, else False. + + Returns: + self + """ + + if is_delete_project: + params_key = {"project_id": self.have_project.get("id")} + deletion_value = "deletes_the_project" + name = "project: {0}".format(config.get("configuration_templates").get('project_name')) + else: + template_params = self.want.get("template_params") + params_key = {"template_id": self.have_template.get("id")} + deletion_value = "deletes_the_template" + name = "templateName: {0}".format(template_params.get('templateName')) + + response = self.dnac_apply['exec']( + family="configuration_templates", + function=deletion_value, + params=params_key, + ) + task_id = response.get("response").get("taskId") + if task_id: + task_details = self.get_task_details(task_id) + self.result['changed'] = True + self.result['msg'] = task_details.get('progress') + self.result['diff'] = config.get("configuration_templates") + + self.log("Task details for '{0}': {1}".format(deletion_value, task_details), "DEBUG") + self.result['response'] = task_details if task_details else response + if not self.result['msg']: + self.result['msg'] = "Error while deleting {name} : " + self.status = "failed" + return self + + self.msg = "Successfully deleted {0} ".format(name) + self.status = "success" + return self + + def get_diff_deleted(self, config): + """ + Delete projects or templates in Cisco Catalyst Center with fields provided in playbook. + + Parameters: + config (dict) - Playbook details containing template information. + + Returns: + self + """ + + configuration_templates = config.get("configuration_templates") + if configuration_templates: + is_project_found = self.have_project.get("project_found") + projectName = config.get("configuration_templates").get("project_name") + + if not is_project_found: + self.msg = "Project {0} is not found".format(projectName) + self.status = "failed" + return self + + is_template_found = self.have_template.get("template_found") + template_params = self.want.get("template_params") + templateName = config.get("configuration_templates").get("template_name") + if template_params.get("name"): + if is_template_found: + self.delete_project_or_template(config) + else: + self.msg = "Invalid template {0} under project".format(templateName) + self.status = "failed" + return self + else: + self.log("Template name is empty, deleting the project '{0}' and " + "associated templates" + .format(config.get("configuration_templates").get("project_name")), "INFO") + is_project_deletable = self.have_project.get("isDeletable") + if is_project_deletable: + self.delete_project_or_template(config, is_delete_project=True) + else: + self.msg = "Project is not deletable" + self.status = "failed" + return self + + self.msg = "Successfully completed delete state execution" + self.status = "success" + return self + + def verify_diff_merged(self, config): + """ + Validating the Cisco Catalyst Center 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("Template availability: {0}".format(is_template_available), "INFO") + if not is_template_available: + self.msg = "Configuration Template config is not applied to the Cisco Catalyst Center." + self.status = "failed" + return self + + self.get_have_template(config, is_template_available) + self.log("Current State (have): {0}".format(self.want.get("template_params")), "INFO") + self.log("Desired State (want): {0}".format(self.have_template.get("template")), "INFO") + 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 Cisco Catalyst Center." + self.status = "failed" + return self + self.log("Successfully validated the Template in the Catalyst Center.", "INFO") + 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 Cisco Catalyst Center 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("Current State (have): {0}".format(self.have), "INFO") + self.log("Desired State (want): {0}".format(self.want), "INFO") + 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 Cisco Catalyst Center." + self.status = "failed" + return self + + self.log("Successfully validated absence of template in the Catalyst Center.", "INFO") + self.result.get("response").update({"Validation": "Success"}) + + self.msg = "Successfully validated the absence of Template in the Cisco Catalyst Center." + self.status = "success" + return self + + def reset_values(self): + """ + Reset all neccessary attributes to default values. + + Parameters: + self - The current object. + + Returns: + None + """ + + self.have_project.clear() + self.have_template.clear() + self.want.clear() + + +def main(): + """ main entry point for module execution""" + + element_spec = {'dnac_host': {'required': True, 'type': 'str'}, + 'dnac_port': {'type': 'str', 'default': '443'}, + 'dnac_username': {'type': 'str', 'default': 'admin', 'aliases': ['user']}, + 'dnac_password': {'type': 'str', 'no_log': True}, + 'dnac_verify': {'type': 'bool', 'default': 'True'}, + 'dnac_version': {'type': 'str', 'default': '2.2.3.3'}, + 'dnac_debug': {'type': 'bool', 'default': False}, + 'dnac_log': {'type': 'bool', 'default': False}, + "dnac_log_level": {"type": 'str', "default": 'WARNING'}, + "dnac_log_file_path": {"type": 'str', "default": 'dnac.log'}, + "dnac_log_append": {"type": 'bool', "default": True}, + 'validate_response_schema': {'type': 'bool', 'default': True}, + "config_verify": {"type": 'bool', "default": False}, + 'dnac_api_task_timeout': {'type': 'int', "default": 1200}, + 'dnac_task_poll_interval': {'type': 'int', "default": 2}, + 'config': {'required': True, 'type': 'list', 'elements': 'dict'}, + 'state': {'default': 'merged', 'choices': ['merged', 'deleted']} + } + module = AnsibleModule(argument_spec=element_spec, + supports_check_mode=False) + ccc_template = Template(module) + ccc_template.validate_input().check_return_status() + state = ccc_template.params.get("state") + config_verify = ccc_template.params.get("config_verify") + if state not in ccc_template.supported_states: + ccc_template.status = "invalid" + ccc_template.msg = "State {0} is invalid".format(state) + ccc_template.check_return_status() + + for config in ccc_template.validated_config: + ccc_template.reset_values() + ccc_template.get_have(config).check_return_status() + ccc_template.get_want(config).check_return_status() + ccc_template.get_diff_state_apply[state](config).check_return_status() + if config_verify: + ccc_template.verify_diff_state_apply[state](config).check_return_status() + + module.exit_json(**ccc_template.result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/cisco/dnac/plugins/modules/user.py b/ansible_collections/cisco/dnac/plugins/modules/user.py index 5bfdc9ced..21769bb83 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/user.py +++ b/ansible_collections/cisco/dnac/plugins/modules/user.py @@ -51,8 +51,8 @@ seealso: link: https://developer.cisco.com/docs/dna-center/#!update-user-api notes: - SDK Method used are - userand_roles.UserandRoles.add_user_ap_i, - userand_roles.UserandRoles.update_user_ap_i, + user_and_roles.UserandRoles.add_user_ap_i, + user_and_roles.UserandRoles.update_user_ap_i, - Paths used are post /dna/system/api/v1/user, diff --git a/ansible_collections/cisco/dnac/plugins/modules/user_info.py b/ansible_collections/cisco/dnac/plugins/modules/user_info.py index bb25c6659..f00e1d66e 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/user_info.py +++ b/ansible_collections/cisco/dnac/plugins/modules/user_info.py @@ -32,7 +32,7 @@ seealso: link: https://developer.cisco.com/docs/dna-center/#!get-users-api notes: - SDK Method used are - userand_roles.UserandRoles.get_users_ap_i, + user_and_roles.UserandRoles.get_users_api, - Paths used are get /dna/system/api/v1/user, diff --git a/ansible_collections/cisco/dnac/plugins/modules/users_external_servers_info.py b/ansible_collections/cisco/dnac/plugins/modules/users_external_servers_info.py index 2360060ad..536f958c8 100644 --- a/ansible_collections/cisco/dnac/plugins/modules/users_external_servers_info.py +++ b/ansible_collections/cisco/dnac/plugins/modules/users_external_servers_info.py @@ -32,7 +32,7 @@ seealso: link: https://developer.cisco.com/docs/dna-center/#!get-external-authentication-servers-api notes: - SDK Method used are - userand_roles.UserandRoles.get_external_authentication_servers_ap_i, + user_and_roles.UserandRoles.get_external_authentication_servers_ap_i, - Paths used are get /dna/system/api/v1/users/external-servers, diff --git a/ansible_collections/cisco/dnac/plugins/plugin_utils/dnac.py b/ansible_collections/cisco/dnac/plugins/plugin_utils/dnac.py index 80afbe39a..7448c4c50 100644 --- a/ansible_collections/cisco/dnac/plugins/plugin_utils/dnac.py +++ b/ansible_collections/cisco/dnac/plugins/plugin_utils/dnac.py @@ -248,7 +248,7 @@ class DNACSDK(object): return self.result def verify_array(self, verify_interface, **kwargs): - if type(verify_interface) is None: + if verify_interface is None: return list() if isinstance(verify_interface, list): |