diff --git a/README.md b/README.md index 16bbe40a9b..5450294370 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Other versions of this collection have support for previous Cisco DNA Center ver | 2.1.1 | 3.0.0 | 2.2.5 | | 2.2.2.3 | 3.3.1 | 2.3.3 | | 2.2.3.3 | 6.4.0 | 2.4.11 | -| 2.3.3.0 | 6.5.3 | 2.5.4 | +| 2.3.3.0 | 6.6.0 | 2.5.4 | *Notes*: diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 0ec344bd89..1926c34a68 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -591,4 +591,13 @@ releases: bugfixes: - Update dnacentersdk requirement from 2.5.0 to 2.5.4 - business_sda_hostonboarding_ssid_ippool - create function added. - - wireless_profile - create function fixed. \ No newline at end of file + - wireless_profile - create function fixed. + 6.6.0: + release_date: "2022-09-13" + changes: + release_summary: Added new intent modules + minor_changes: + - pnp_intent - new module. + - site_intent - new module. + - swim_intent - new module. + - template_intent - new module. \ No newline at end of file diff --git a/galaxy.yml b/galaxy.yml index 8dafa03fd9..c484e6c70c 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,12 +1,16 @@ --- namespace: cisco name: dnac -version: 6.5.3 +version: 6.6.0 readme: README.md authors: - Rafael Campos - William Astorga - Jose Bogarin + - Bryan Vargas + - Francisco Muñoz + - Madhan Sankaranarayanan (@madhansansel) + - Rishita Chowdhary (@rishitachowdhary) description: Ansible Modules for Cisco DNA Center license_file: "LICENSE" tags: diff --git a/playbooks/device_details.yml b/playbooks/device_details.yml new file mode 100644 index 0000000000..0c9dfdfef4 --- /dev/null +++ b/playbooks/device_details.yml @@ -0,0 +1,54 @@ +template_details: + - proj_name: "Onboarding Configuration" + device_config: "hostname cat9k-1\n" + language: "velocity" + family: "Switches and Hubs" + type: "IOS-XE" + variant: "XE" + temp_name: "temp_cat9k-1" + description: "Test Template 1" + - proj_name: "Onboarding Configuration" + device_config: "hostname cat9k-2\n" + language: "velocity" + family: "Switches and Hubs" + type: "IOS-XE" + variant: "XE" + temp_name: "temp_cat9k-2" + description: "Test Template 2" + - proj_name: "Onboarding Configuration" + device_config: "hostname cat9k-3\n" + language: "velocity" + family: "Switches and Hubs" + type: "IOS-XE" + variant: "XE" + temp_name: "temp_cat9k-3" + description: "Test Template 3" + +device_details: + - site_name: "Global/Chennai/Trill" + image_name: "cat9k_iosxe.17.04.01.SPA.bin" + proj_name: "Onboarding Configuration" + temp_name: "temp_cat9k-1" + device_version: "2" + device_number: "AB2425L8M7" + device_name: "Cat9k-1" + device_state: "Unclaimed" + device_id: "C9300-25UX" + - site_name: "Global/Chennai/Trill" + image_name: "cat9k_iosxe.17.04.01.SPA.bin" + proj_name: "Onboarding Configuration" + temp_name: "temp_cat9k-2" + device_version: "2" + device_number: "CD2425L8M7" + device_name: "Cat9k-2" + device_state: "Unclaimed" + device_id: "C9300-25UX" + - site_name: "Global/Chennai/Trill" + image_name: "cat9k_iosxe.17.04.01.SPA.bin" + proj_name: "Onboarding Configuration" + temp_name: "temp_cat9k-3" + device_version: "2" + device_number: "EF2425L8M7" + device_name: "Cat9k-3" + device_state: "Unclaimed" + device_id: "C9300-25UX" diff --git a/playbooks/image_details.yml b/playbooks/image_details.yml new file mode 100644 index 0000000000..4d787adede --- /dev/null +++ b/playbooks/image_details.yml @@ -0,0 +1,11 @@ +image_details: + - import_type: "url" + url_source: "http://10.104.118.10/swim/17.9/cat9k_iosxe.BLD_V179_THROTTLE_LATEST_20220429_033422.SSA.bin" + device_role: "ALL" + device_family_name: "Cisco Catalyst 9606R Switch-Cisco Catalyst 9600 Series Supervisor Engine 1" + device_serial_number: "FXS2325Q01C" + - import_type: "url" + url_source: "http://10.104.118.10/swim/17.9/cat9k_iosxe.BLD_V179_THROTTLE_LATEST_20220429_033422.SSA.bin" + device_role: "ALL" + device_family_name: "Cisco Catalyst 9606R Switch-Cisco Catalyst 9600 Series Supervisor Engine 1" + device_serial_number: "FXS5632Q01C" diff --git a/playbooks/template_pnp_intent.yml b/playbooks/template_pnp_intent.yml new file mode 100644 index 0000000000..b6894d670d --- /dev/null +++ b/playbooks/template_pnp_intent.yml @@ -0,0 +1,61 @@ +- hosts: dnac_servers + vars_files: + - credentials.yml + - device_details.yml + gather_facts: no + connection: local + tasks: +# +# Project Info Section +# + - name: Test project template + cisco.dnac.template_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 + state: "merged" + #ignore_errors: true #Enable this to continue execution even the task fails + config: + - projectName: "{{ item.proj_name }}" + templateContent: "{{ item.device_config }}" + language: "{{ item.language }}" + deviceTypes: + - productFamily: "{{ item.family }}" + softwareType: "{{ item.type }}" + softwareVariant: "{{ item.variant }}" + templateName: "{{ item.temp_name }}" + versionDescription: "{{ item.description }}" + register: template_result + with_items: '{{ template_details }}' + tags: + - template + + + - name: Create pnp + 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_debug: "{{dnac_debug}}" + dnac_log: True + config: + - site_name: "{{ item.site_name }}" + project_name: "{{ item.proj_name }}" + template_name: "{{ item.temp_name }}" + image_name: "{{ item.image_name }}" + device_version: "{{ item.device_version }}" + deviceInfo: + serialNumber: "{{ item.device_number }}" + hostname: "{{ item.device_name}}" + state: "{{ item.device_state }}" + pid: "{{ item.device_id }}" + register: pnp_result + with_items: '{{ device_details }}' + tags: + - pnp diff --git a/playbooks/test_swim_module.yml b/playbooks/test_swim_module.yml new file mode 100644 index 0000000000..7861e637a7 --- /dev/null +++ b/playbooks/test_swim_module.yml @@ -0,0 +1,41 @@ +- hosts: dnac_servers + vars_files: + - credentials_245.yml + - image_details.yml #Contains image and device details + gather_facts: no + connection: local + tasks: +# +# Project Info Section +# + + - name: Import an image, tag it as golden and load it on device + cisco.dnac.swim_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 + config: + - importImageDetails: + type: "{{ item.import_type }}" + urlDetails: + payload: + - sourceURL: "{{ item.url_source }}" + isThirdParty: false + taggingDetails: + deviceRole: "{{ item.device_role }}" + deviceFamilyName: "{{ item.device_family_name }}" + tagging: true + imageDistributionDetails: + deviceSerialNumber: "{{ item.device_serial_number }}" + imageActivationDetails: + scehduleValidate: false + activateLowerImageVersion: true + deviceSerialNumber: "{{ item.device_serial_number }}" + distributeIfNeeded: true + with_items: '{{ image_details }}' + tags: + - swim diff --git a/plugins/doc_fragments/intent_params.py b/plugins/doc_fragments/intent_params.py new file mode 100644 index 0000000000..fe95d684da --- /dev/null +++ b/plugins/doc_fragments/intent_params.py @@ -0,0 +1,66 @@ +#!/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 + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +options: + dnac_host: + description: + - The Cisco DNA Center hostname. + type: str + required: true + dnac_port: + description: + - The Cisco DNA Center port. + type: str + default: '443' + dnac_username: + description: + - The Cisco DNA Center username to authenticate. + type: str + default: admin + aliases: [ user ] + dnac_password: + description: + - The Cisco DNA Center password to authenticate. + type: str + dnac_verify: + description: + - Flag to enable or disable SSL certificate verification. + type: bool + default: true + dnac_version: + description: + - Informs the SDK which version of Cisco DNA Center to use. + type: str + default: 2.2.3.3 + dnac_debug: + description: + - Flag for Cisco DNA Center SDK to enable debugging. + type: bool + 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 + type: bool + default: false + validate_response_schema: + description: + - Flag for Cisco DNA Center SDK to enable the validation of request bodies against a JSON schema. + type: bool + default: true +notes: + - "Does not support C(check_mode)" + - "The plugin runs on the control node and does not use any ansible connection plugins, but instead the embedded connection manager from Cisco DNAC SDK" + - "The parameters starting with dnac_ are used by the Cisco DNAC Python SDK to establish the connection" +''' diff --git a/plugins/module_utils/__init__.py b/plugins/module_utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/module_utils/dnac.py b/plugins/module_utils/dnac.py new file mode 100644 index 0000000000..132d5cb7c2 --- /dev/null +++ b/plugins/module_utils/dnac.py @@ -0,0 +1,319 @@ +#!/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 +try: + from dnacentersdk import api, exceptions +except ImportError: + DNAC_SDK_IS_INSTALLED = False +else: + DNAC_SDK_IS_INSTALLED = True +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible.module_utils.common import validation +try: + import logging +except ImportError: + LOGGING_IN_STANDARD = False +else: + LOGGING_IN_STANDARD = True +import os.path +import datetime +import inspect + + +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 is_list_complex(x): + return isinstance(x[0], dict) or isinstance(x[0], list) + + +def has_diff_elem(ls1, ls2): + return any((elem not in ls1 for elem in ls2)) + + +def compare_list(list1, list2): + len_list1 = len(list1) + len_list2 = len(list2) + if len_list1 != len_list2: + return False + + if len_list1 == 0: + return True + + attempt_std_cmp = list1 == list2 + if attempt_std_cmp: + return True + + if not is_list_complex(list1) and not is_list_complex(list2): + return set(list1) == set(list2) + + # Compare normally if it exceeds expected size * 2 (len_list1==len_list2) + MAX_SIZE_CMP = 100 + # Fail fast if elem not in list, thanks to any and generators + if len_list1 > MAX_SIZE_CMP: + return attempt_std_cmp + else: + # not changes 'has diff elem' to list1 != list2 ':lists are not equal' + return not (has_diff_elem(list1, list2)) or not (has_diff_elem(list2, list1)) + + +def fn_comp_key(k, dict1, dict2): + return dnac_compare_equality(dict1.get(k), dict2.get(k)) + + +def dnac_compare_equality(current_value, requested_value): + # print("dnac_compare_equality", current_value, requested_value) + if requested_value is None: + return True + if current_value is None: + return True + if isinstance(current_value, dict) and isinstance(requested_value, dict): + all_dict_params = list(current_value.keys()) + list(requested_value.keys()) + return not any((not fn_comp_key(param, current_value, requested_value) for param in all_dict_params)) + elif isinstance(current_value, list) and isinstance(requested_value, list): + return compare_list(current_value, requested_value) + else: + return current_value == requested_value + + +def simple_cmp(obj1, obj2): + return obj1 == obj2 + + +def get_dict_result(result, key, value, cmp_fn=simple_cmp): + if isinstance(result, list): + if len(result) == 1: + if isinstance(result[0], dict): + result = result[0] + if result.get(key) is not None and result.get(key) != value: + result = None + else: + result = None + else: + for item in result: + if isinstance(item, dict) and (item.get(key) is None or item.get(key) == value): + result = item + return result + result = None + elif not isinstance(result, dict): + result = None + elif result.get(key) is not None and result.get(key) != value: + result = None + return result + + +def dnac_argument_spec(): + argument_spec = dict( + dnac_host=dict(type="str", required=True), + dnac_port=dict(type="int", required=False, 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), + validate_response_schema=dict(type="bool", default=True), + ) + return argument_spec + + +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 + spec: an argument spec dict + e.g. spec = dict(ip=dict(required=True, type='bool'), + 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 = {} + for param in spec: + item = list_entry.get(param) + log(str(item)) + if item is None: + if spec[param].get("required"): + invalid_params.append( + "{0} : Required parameter not found".format(param) + ) + else: + item = spec[param].get("default") + 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) + ) + + 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) + + return normalized, invalid_params + + +class DNACSDK(object): + def __init__(self, params): + self.result = dict(changed=False, result="") + self.validate_response_schema = params.get("validate_response_schema") + if DNAC_SDK_IS_INSTALLED: + self.api = api.DNACenterAPI( + username=params.get("dnac_username"), + password=params.get("dnac_password"), + base_url="https://{dnac_host}:{dnac_port}".format( + dnac_host=params.get("dnac_host"), dnac_port=params.get("dnac_port") + ), + version=params.get("dnac_version"), + verify=params.get("dnac_verify"), + debug=params.get("dnac_debug"), + ) + if params.get("dnac_debug") and LOGGING_IN_STANDARD: + logging.getLogger('dnacentersdk').addHandler(logging.StreamHandler()) + else: + self.fail_json(msg="DNA Center Python SDK is not installed. Execute 'pip install dnacentersdk'") + + def changed(self): + self.result["changed"] = True + + def object_created(self): + self.changed() + self.result["result"] = "Object created" + + def object_updated(self): + self.changed() + self.result["result"] = "Object updated" + + def object_deleted(self): + self.changed() + self.result["result"] = "Object deleted" + + def object_already_absent(self): + self.result["result"] = "Object already absent" + + def object_already_present(self): + self.result["result"] = "Object already present" + + def object_present_and_different(self): + self.result["result"] = "Object already present, but it has different values to the requested" + + def object_modify_result(self, changed=None, result=None): + if result is not None: + self.result["result"] = result + if changed: + self.changed() + + def is_file(self, file_path): + return os.path.isfile(file_path) + + def extract_file_name(self, file_path): + return os.path.basename(file_path) + + def exec(self, family, function, params=None, op_modifies=False, **kwargs): + try: + family = getattr(self.api, family) + func = getattr(family, function) + except Exception as e: + self.fail_json(msg=e) + + try: + if params: + file_paths_params = kwargs.get('file_paths', []) + # This substitution is for the import file operation + if file_paths_params and isinstance(file_paths_params, list): + multipart_fields = {} + for (key, value) in file_paths_params: + if isinstance(params.get(key), str) and self.is_file(params[key]): + file_name = self.extract_file_name(params[key]) + file_path = params[key] + multipart_fields[value] = (file_name, open(file_path, 'rb')) + + params.setdefault("multipart_fields", multipart_fields) + params.setdefault("multipart_monitor_callback", None) + + if not self.validate_response_schema and op_modifies: + params["active_validation"] = False + + response = func(**params) + else: + response = func() + except exceptions.dnacentersdkException as e: + self.fail_json( + msg=( + "An error occured when executing operation." + " The error was: {error}" + ).format(error=to_native(e)) + ) + return response + + def fail_json(self, msg, **kwargs): + self.result.update(**kwargs) + raise Exception(msg) + + def exit_json(self): + return self.result + + +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/plugins/module_utils/exceptions.py b/plugins/module_utils/exceptions.py new file mode 100644 index 0000000000..007f8e1f45 --- /dev/null +++ b/plugins/module_utils/exceptions.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class AnsibleDNACException(Exception): + """Base class for all Ansible DNAC package exceptions.""" + pass + + +class InconsistentParameters(AnsibleDNACException): + """Provided parameters are not consistent.""" + pass diff --git a/plugins/modules/pnp_intent.py b/plugins/modules/pnp_intent.py new file mode 100644 index 0000000000..998496642b --- /dev/null +++ b/plugins/modules/pnp_intent.py @@ -0,0 +1,963 @@ +#!/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, 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' +extends_documentation_fragment: + - cisco.dnac.intent_params +author: Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) +options: + 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: + 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 + 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. + 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. + 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. + type: str + reloadRequested: + description: ReloadRequested 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 + +requirements: +- dnacentersdk == 2.4.5 +- python >= 3.5 +notes: + - SDK Method used are + device_onboarding_pnp.DeviceOnboardingPnp.add_device, + 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} + +""" + +EXAMPLES = r""" +- name: Add a new device and claim the device + 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: True + state: merged + 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 +""" + +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 + 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 DNAC 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 DNAC Python SDK + returned: always + type: dict + sample: > + { + "response": String, + "msg": String + } +""" + +import copy +try: + from ansible_collections.ansible.utils.plugins.module_utils.common.argspec_validate import ( + AnsibleArgSpecValidator, + ) +except ImportError: + ANSIBLE_UTILS_IS_INSTALLED = False +else: + ANSIBLE_UTILS_IS_INSTALLED = True +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DNACSDK, + validate_list_of_dicts, + log, + get_dict_result, +) + + +class DnacPnp: + 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") + + 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") + ) + return dnac_params + + def site_exists(self): + site_exists = False + site_id = None + response = None + try: + response = self.dnac.exec( + family="sites", + function='get_site', + params={"name": self.want.get("site_name")}, + ) + except Exception as e: + self.module.fail_json(msg="Site not found", response=[]) + + if response: + if self.log: + log(str(response)) + + site = response.get("response") + site_id = site[0].get("id") + site_exists = True + + return (site_exists, site_id) + + 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 + + def get_image_params(self, params): + image_params = dict( + image_name=params.get("image_name"), + is_tagged_golden=params.get("golden_image"), + ) + + 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, + ) + + return claim_params + + def get_have(self): + 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"), + ) + + if self.log: + log(str(image_response)) + + image_list = image_response.get("response") + + 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=[]) + + # 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.log: + log(str(template_list)) + + 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") + + if self.log: + log("Template Id: " + str(have["template_id"])) + else: + self.module.fail_json(msg="Template not found", response=[]) + else: + self.module.fail_json(msg="Project Not Found", response=[]) + + # check if given site exits, if exists store current site info + site_name = self.want.get("site_name") + + site_exists = False + (site_exists, site_id) = self.site_exists() + + 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)) + + # check if given device exists in pnp inventory, store device Id + device_response = self.dnac.exec( + family="device_onboarding_pnp", + function='get_device_list', + params={"serial_number": self.want.get("serial_number")} + ) + + if self.log: + log(str(device_response)) + + if device_response and (len(device_response) == 1): + have["device_id"] = device_response[0].get("id") + have["device_found"] = True + + if self.log: + log("Device Id: " + str(have["device_id"])) + else: + have["device_found"] = False + + self.have = have + + 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 + + def get_diff_merge(self): + + # 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( + family="device_onboarding_pnp", + function="add_device", + params=self.want.get("pnp_params"), + op_modifies=True, + ) + self.have["device_id"] = response.get("id") + + if self.log: + log(str(response)) + log(self.have.get("device_id")) + + claim_params = self.get_claim_params() + claim_response = self.dnac.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)) + + if claim_response.get("response") == "Device Claimed": + self.result['changed'] = True + self.result['msg'] = "Device Claimed Successfully" + self.result['response'] = claim_response + self.result['diff'] = self.validated + else: + self.module.fail_json(msg="Device Claim Failed", response=claim_response) + + def get_diff_delete(self): + if self.have.get("device_found"): + + try: + response = self.dnac.exec( + family="device_onboarding_pnp", + function="delete_device_by_id_from_pnp", + op_modifies=True, + params={"id": self.have.get("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" + else: + self.result['response'] = response + self.result['msg'] = "Error while deleting the device" + + except Exception as errorstr: + response = str(errorstr) + msg = "Device Deletion Failed" + self.module.fail_json(msg=msg, response=response) + + else: + self.module.fail_json(msg="Device Not Found", response=[]) + + +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'] + ) + ) + + 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) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/site_intent.py b/plugins/modules/site_intent.py new file mode 100644 index 0000000000..eab7335702 --- /dev/null +++ b/plugins/modules/site_intent.py @@ -0,0 +1,583 @@ +#!/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, Rishita Chowdhary") + +DOCUMENTATION = r""" +--- +module: site_intent +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.intent_params +author: Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) +options: + state: + description: The state of DNAC after module completion. + type: str + choices: [ merged, deleted ] + default: merged + config: + description: + - List of details of site being managed. + type: list + elements: dict + required: true + suboptions: + type: + description: Type of site to create/update/delete (eg area, building, floor). + type: str + site: + description: Site Details. + type: dict + suboptions: + area: + description: Site Create's area. + type: dict + suboptions: + name: + description: Name of the area (eg Area1). + type: str + parentName: + description: Parent name of the area to be created. + type: str + building: + description: Building Details. + type: dict + suboptions: + address: + description: Address of the building to be created. + type: str + latitude: + description: Latitude coordinate of the building (eg 37.338). + type: int + longitude: + description: Longitude coordinate of the building (eg -121.832). + type: int + name: + description: Name of the building (eg building1). + type: str + parentName: + description: Parent name of building to be created. + type: str + floor: + description: Site Create's floor. + type: dict + suboptions: + height: + description: Height of the floor (eg 15). + type: int + length: + description: Length of the floor (eg 100). + type: int + name: + description: Name of the floor (eg floor-1). + type: str + parentName: + description: Parent name of the floor to be created. + type: str + rfModel: + description: Type of floor. Allowed values are 'Cubes And Walled Offices', + 'Drywall Office Only', 'Indoor High Ceiling', 'Outdoor Open Space'. + type: str + width: + description: Width of the floor (eg 100). + 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 building 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: "{{dnac_log}}" + config: + site: + building: + address: string + latitude: 0 + longitude: 0 + name: string + parentName: string + type: string +""" + +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 + returned: always + type: dict + sample: > + { + "response": + { + "bapiExecutionId": String, + "bapiKey": String, + "bapiName": String, + "endTime": String, + "endTimeEpoch": 0, + "runtimeInstanceId": 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": {}, + "msg": String + } + +#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 + 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 DNAC 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 ( + DNACSDK, + 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' +} + + +class DnacSite: + + 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") + + def validate_input(self): + temp_spec = dict( + type=dict(required=False, type='str'), + site=dict(required=True, 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 + + def get_current_site(self, site): + 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"), + ) + ) + + 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], + rfModel=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") + ) + ) + + current_site = dict( + type=typeinfo, + site=site_info, + site_id=site[0].get("id") + ) + + if self.log: + log(str(current_site)) + + return current_site + + def site_exists(self): + 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: + if self.log: + log("The input site is not valid or site is not present.") + + if response: + if self.log: + log(str(response)) + + response = response.get("response") + current_site = self.get_current_site(response) + site_exists = True + + if self.log: + log(str(self.validated)) + + return (site_exists, current_site) + + def get_site_params(self, params): + site = params.get("site") + typeinfo = params.get("type") + + if typeinfo == "floor": + site["floor"]["rfModel"] = site.get("floor").get("rfModel").upper() + + site_params = dict( + type=typeinfo, + site=site, + ) + + return site_params + + def get_site_name(self, site): + site_type = site.get("type") + parent_name = site.get("site").get(site_type).get("parentName") + name = site.get("site").get(site_type).get("name") + site_name = '/'.join([parent_name, name]) + + if self.log: + log(site_name) + + return site_name + + def site_requires_update(self): + requested_site = self.want.get("site_params") + current_site = self.have.get("current_site") + + if self.log: + log("Current Site: " + str(current_site)) + log("Requested Site: " + str(requested_site)) + + obj_params = [ + ("type", "type"), + ("site", "site") + ] + + return any(not dnac_compare_equality(current_site.get(dnac_param), + requested_site.get(ansible_param)) + for (dnac_param, ansible_param) in obj_params) + + def get_execution_details(self, execid): + response = None + response = self.dnac.exec( + family="task", + function='get_business_api_execution_details', + params={"execution_id": execid} + ) + + if self.log: + log(str(response)) + + if response and isinstance(response, dict): + return response + + def get_have(self): + 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() + + if self.log: + log("Site Exists: " + str(site_exists) + "\n Current Site:" + str(current_site)) + + if site_exists: + have["site_id"] = current_site.get("site_id") + have["site_exists"] = site_exists + have["current_site"] = current_site + + self.have = have + + 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), + ) + + self.want = want + + def get_diff_merge(self): + 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.result['msg'] = "Site does not need update" + self.module.exit_json(**self.result) + + else: + # Creating New Site + response = self.dnac.exec( + family="sites", + function='create_site', + op_modifies=True, + params=self.want.get("site_params"), + ) + + log(str(response)) + 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: + log("Site Updated Successfully") + self.result['msg'] = "Site Updated Successfully" + + else: + # Get the site id of the newly created site. + (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" + + def get_diff_delete(self): + site_exists = self.have.get("site_exists") + + if site_exists: + response = self.dnac.exec( + family="sites", + function="delete_site", + params={"site_id": self.have.get("site_id")}, + ) + + 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 + self.result['msg'] = "Site deleted successfully" + break + + elif execution_details.get("bapiError"): + self.module.fail_json(msg=execution_details.get("bapiError"), + response=execution_details) + break + + else: + self.module.fail_json(msg="Site Not Found", response=[]) + + +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']), + ) + + 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() + + module.exit_json(**dnac_site.result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/swim_intent.py b/plugins/modules/swim_intent.py new file mode 100644 index 0000000000..e518d1460d --- /dev/null +++ b/plugins/modules/swim_intent.py @@ -0,0 +1,712 @@ +#!/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, Rishita Chowdhary") + +DOCUMENTATION = r""" +--- +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 uploads to DNA 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. +- 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) +options: + config: + description: List of details of SWIM image being managed + type: list + elements: dict + required: True + suboptions: + importImageDetails: + description: Details of image being imported + type: dict + suboptions: + type: + description: The source of import (Currently support vis URL). + type: str + urlDetails: + description: URL details for SWIM import + type: dict + suboptions: + payload: + description: Swim Import Via Url's payload. + type: list + elements: dict + suboptions: + applicationType: + description: Swim Import Via Url's applicationType. + type: str + imageFamily: + description: Swim Import Via Url's imageFamily. + type: str + sourceURL: + description: Swim Import Via Url's sourceURL. + type: str + thirdParty: + description: ThirdParty flag. + type: bool + vendor: + description: Swim Import Via Url's vendor. + type: str + scheduleAt: + 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 + scheduleDesc: + description: ScheduleDesc query parameter. Custom Description (Optional). + type: str + scheduleOrigin: + description: ScheduleOrigin query parameter. Originator of this call (Optional). + type: str + taggingDetails: + description: Details for tagging or untagging an image as golden + type: dict + suboptions: + imageName: + 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. + type: str + deviceFamilyName: + description: Device family name + type: str + siteName: + 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 + imageDistributionDetails: + 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: + description: SWIM image's name + type: str + deviceSerialNumber: + description: Device serial number where the image needs to be distributed + type: str + deviceIPAddress: + description: Device IP address where the image needs to be distributed + type: str + deviceHostname: + description: Device hostname where the image needs to be distributed + type: str + deviceMacAddress: + description: Device MAC address where the image needs to be distributed + type: str + imageActivationDetails: + 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: + description: ActivateLowerImageVersion flag. + type: bool + deviceUpgradeMode: + description: Swim Trigger Activation's deviceUpgradeMode. + type: str + distributeIfNeeded: + description: DistributeIfNeeded flag. + type: bool + imageName: + description: SWIM image's name + type: str + deviceSerialNumber: + description: Device serial number where the image needs to be activated + type: str + deviceIPAddress: + description: Device IP address where the image needs to be activated + type: str + deviceHostname: + description: Device hostname where the image needs to be activated + type: str + deviceMacAddress: + description: Device MAC address where the image needs to be activated + type: str + scheduleValidate: + 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, + +""" + +EXAMPLES = r""" +- name: Import an image, tag it 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: True + config: + - importImageDetails: + type: string + urlDetails: + 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 +""" + +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 + 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 + } + +""" + +import copy +try: + from ansible_collections.ansible.utils.plugins.module_utils.common.argspec_validate import ( + AnsibleArgSpecValidator, + ) +except ImportError: + ANSIBLE_UTILS_IS_INSTALLED = False +else: + ANSIBLE_UTILS_IS_INSTALLED = True +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DNACSDK, + validate_list_of_dicts, + log, + get_dict_result, +) +from ansible.module_utils.basic import AnsibleModule + + +class DnacSwims: + + 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 validate_input(self): + temp_spec = dict( + importImageDetails=dict(type='dict'), + taggingDetails=dict(type='dict'), + imageDistributionDetails=dict(type='dict'), + imageActivationDetails=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 + + def get_task_details(self, id): + result = None + response = self.dnac.exec( + family="task", + function='get_task_by_id', + params={"task_id": id}, + ) + if self.log: + log(str(response)) + + if isinstance(response, dict): + result = response.get("response") + + return result + + def site_exists(self): + site_exists = False + site_id = None + response = None + try: + response = self.dnac.exec( + family="sites", + function='get_site', + params={"name": self.want.get("site_name")}, + ) + except Exception as e: + self.module.fail_json(msg="Site not found") + + if response: + if self.log: + log(str(response)) + + site = response.get("response") + site_id = site[0].get("id") + site_exists = True + + return (site_exists, site_id) + + def get_image_id(self, name): + # 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={"image_name": name}, + ) + + if self.log: + log(str(image_response)) + + 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)) + else: + self.module.fail_json(msg="Image not found", response=image_response) + + return image_id + + def get_device_id(self, params): + response = self.dnac.exec( + family="devices", + function='get_device_list', + params=params, + ) + if self.log: + log(str(response)) + + 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)) + else: + self.module.fail_json(msg="Device not found", response=response) + + return device_id + + def get_device_family_identifier(self, family_name): + have = {} + response = self.dnac.exec( + family="software_image_management_swim", + function='get_device_family_identifiers', + ) + 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)) + else: + self.module.fail_json(msg="Family Device Name not found") + self.have.update(have) + + def get_have(self): + 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")) + 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.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") + if site_name: + site_exists = False + (site_exists, site_id) = self.site_exists() + if site_exists: + have["site_id"] = site_id + if self.log: + log("Site Exists: " + str(site_exists) + "\n Site_id:" + str(site_id)) + 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.have.update(have) + # check if given device family name exists, store indentifier value + family_name = tagging_details.get("deviceFamilyName") + self.get_device_family_identifier(family_name) + + if self.want.get("distribution_details"): + have = {} + distribution_details = self.want.get("distribution_details") + # check if image for distributon is available + if distribution_details.get("imageName"): + image_id = self.get_image_id(distribution_details.get("imageName")) + 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=[]) + + 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"), + ) + device_id = self.get_device_id(device_params) + 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")) + 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=[]) + + 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"), + ) + device_id = self.get_device_id(device_params) + have["activation_device_id"] = device_id + self.have.update(have) + + def get_want(self): + want = {} + for image in self.validated: + if image.get("importImageDetails"): + want["import_type"] = image.get("importImageDetails").get("type") + if want["import_type"].lower() == "url": + want["url_import_details"] = image.get("importImageDetails").get("urlDetails") + + want["tagging_details"] = image.get("taggingDetails") + want["distribution_details"] = image.get("imageDistributionDetails") + want["activation_details"] = image.get("imageActivationDetails") + + self.want = want + if self.log: + log(str(self.want)) + + def get_diff_import(self): + if not self.want.get("url_import_details"): + return + + 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, + ) + if self.log: + log(str(response)) + + 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")): + self.result['changed'] = True + self.result['msg'] = "Image imported successfully" + break + + if task_details and task_details.get("isError"): + if "Image already exists" in task_details.get("failureReason"): + break + else: + self.module.fail_json(msg=task_details.get("failureReason"), + response=task_details) + + self.result['response'] = task_details if task_details else response + # Fetch image_id if the imported image for further use + image_name = self.want.get("url_import_details").get("payload")[0].get("sourceURL") + image_name = image_name.split('/')[-1] + image_id = self.get_image_id(image_name) + self.have["imported_image_id"] = image_id + + def get_diff_tagging(self): + tagging_details = self.want.get("tagging_details") + tag_image_golden = tagging_details.get("tagging") + + 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") + ) + if self.log: + log("Image params for tagging image as golden:" + str(image_params)) + + response = self.dnac.exec( + family="software_image_management_swim", + function='tag_as_golden_image', + op_modifies=True, + params=image_params + ) + + else: + image_params = dict( + image_id=self.have.get("tagging_image_id"), + ite_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)) + + response = self.dnac.exec( + family="software_image_management_swim", + function='remove_golden_tag_for_image', + op_modifies=True, + params=image_params + ) + + if response: + task_details = {} + task_id = response.get("response").get("taskId") + task_details = self.get_task_details(task_id) + if not task_details.get("isError"): + self.result['changed'] = True + self.result['msg'] = task_details.get("progress") + + self.result['response'] = task_details if task_details else response + + def get_diff_distribution(self): + 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") + )] + ) + 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: + 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" + break + + if task_details.get("isError"): + self.module.fail_json(msg="Image Distribution Failed", + response=task_details) + + 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 + ) + if self.log: + log("Activation Params: " + str(activation_params)) + + response = self.dnac.exec( + family="software_image_management_swim", + function='trigger_software_image_activation', + op_modifies=True, + params=activation_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.result['response'] = task_details if task_details else response + + def get_diff(self): + if self.want.get("tagging_details"): + self.get_diff_tagging() + + if self.want.get("distribution_details"): + self.get_diff_distribution() + + if self.want.get("activation_details"): + self.get_diff_activation() + + +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), + ) + + 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() + + module.exit_json(**dnac_swims.result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/template_intent.py b/plugins/modules/template_intent.py new file mode 100644 index 0000000000..14f5b646a4 --- /dev/null +++ b/plugins/modules/template_intent.py @@ -0,0 +1,1065 @@ +#!/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, Rishita Chowdhary") + +DOCUMENTATION = r""" +--- +module: template_intent +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. +version_added: '6.6.0' +extends_documentation_fragment: + - cisco.dnac.intent_params +author: Madhan Sankaranarayanan (@madhansansel) + Rishita Chowdhary (@rishitachowdhary) +options: + state: + description: The state of DNAC 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: + author: + description: Author of template. + type: str + composite: + description: Is it composite template. + type: bool + containingTemplates: + description: Configuration Template Create's containingTemplates. + suboptions: + composite: + description: Is it composite template. + type: bool + description: + description: Description of template. + type: str + deviceTypes: + description: Configuration Template Create's deviceTypes. + type: list + elements: dict + suboptions: + productFamily: + description: Device family. + type: str + productSeries: + description: Device series. + type: str + productType: + 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 + projectName: + description: Project name. + type: str + rollbackTemplateParams: + description: Configuration Template Create's rollbackTemplateParams. + type: list + elements: 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. + type: list + elements: dict + suboptions: + id: + description: UUID of range. + type: str + maxValue: + description: Max value of range. + type: int + minValue: + description: Min value of range. + type: int + 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. + type: str + selectionType: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selectionValues: + 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 + templateContent: + description: Template content. + type: str + templateParams: + description: Configuration Template Create's templateParams. + elements: 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 + 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. + type: str + selectionType: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + type: str + selectionValues: + 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. + suboptions: + defaultSelectedValues: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selectionType: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + 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. + type: str + name: + description: Name of tag. + type: str + type: list + elements: dict + templateContent: + description: Template content. + type: str + templateParams: + description: Configuration Template Create's templateParams. + 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. + suboptions: + defaultSelectedValues: + description: Default selection values. + elements: str + type: list + id: + description: UUID of selection. + type: str + selectionType: + description: Type of selection(SINGLE_SELECT or MULTI_SELECT). + 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 + type: dict + version: + description: Current version of template. + type: str + versionDescription: + description: Template version comments. + 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, + + - 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, + +""" + +EXAMPLES = r""" +- name: Create a new template + cisco.dnac.template_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: + author: string + composite: true + createTime: 0 + customParamsOrder: true + description: string + deviceTypes: + - productFamily: string + productSeries: string + productType: string + failurePolicy: string + id: string + language: string + lastUpdateTime: 0 + latestVersionTime: 0 + name: string + parentTemplateId: string + projectId: string + projectName: string + rollbackTemplateContent: string + softwareType: string + softwareVariant: string + softwareVersion: string + tags: + - id: string + name: string + templateContent: string + validationErrors: + rollbackTemplateErrors: + - {} + templateErrors: + - {} + templateId: string + templateVersion: string + version: string + +""" + +RETURN = r""" +#Case_1: Successful creation/updation/deletion of template +response_1: + description: A dictionary with versioning details of the template as returned by the DNAC 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 DNAC Python SDK + returned: always + type: list + sample: > + { + "response": [], + "msg": String + } + +#Case_3: Given template already exists and requires no udpate +response_3: + description: A dictionary with the exisiting template deatails as returned by the Cisco DNAC Python SDK + returned: always + type: dict + sample: > + { + "response": {}, + "msg": String + } +""" + +import copy +try: + from ansible_collections.ansible.utils.plugins.module_utils.common.argspec_validate import ( + AnsibleArgSpecValidator, + ) +except ImportError: + ANSIBLE_UTILS_IS_INSTALLED = False +else: + ANSIBLE_UTILS_IS_INSTALLED = True +from ansible_collections.cisco.dnac.plugins.module_utils.dnac import ( + DNACSDK, + dnac_argument_spec, + validate_list_of_dicts, + log, + get_dict_result, + dnac_compare_equality, +) +from ansible.module_utils.basic import AnsibleModule + + +class DnacTemplate: + + 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") + + 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(required=False, choices=['velocity', 'jinja']), + 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'), + ) + + if self.config: + msg = None + # Validate template 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)) + + 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) + + 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 + + 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"), + ) + return temp_params + + def get_template(self): + result = None + + for temp in self.validated: + items = self.dnac.exec( + family="configuration_templates", + function="get_template_details", + params={"template_id": temp.get("templateId")} + ) + + if items: + result = items + + if self.log: + log(str(items)) + + 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)) + + template_exists = prev_template is not None and isinstance(prev_template, dict) + else: + self.module.fail_json(msg="Project Not Found", response=[]) + + have_create['template'] = prev_template + have_create['template_found'] = template_exists + self.have_create = have_create + + def get_want(self): + want_create = {} + + for temp in self.validated: + template_params = self.get_template_params(temp) + version_comments = temp.get("versionDescription") + + if self.params.get("state") == "merged" and \ + not self.have_create.get("template_found"): + # 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 + + self.want_create = want_create + + def requires_update(self): + current_obj = self.have_create.get("template") + requested_obj = self.want_create.get("template_params") + 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 get_task_details(self, id): + result = None + response = self.dnac.exec( + family="task", + function='get_task_by_id', + params={"task_id": id}, + ) + + if self.log: + log(str(response)) + + if isinstance(response, dict): + result = response.get("response") + + return result + + def get_diff_merge(self): + template_id = None + template_ceated = False + template_updated = False + template_exists = self.have_create.get("template_found") + + if template_exists: + if self.requires_update(): + response = self.dnac.exec( + family="configuration_templates", + function="update_template", + params=self.want_create.get("template_params"), + op_modifies=True, + ) + 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: + 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['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( + family="configuration_templates", + function="deletes_the_template", + params={"template_id": self.have_create.get("templateId")}, + ) + task_details = {} + 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'] = self.validated + + if self.log: + log(str(task_details)) + + self.result['response'] = task_details if task_details else response + + if not self.result['msg']: + self.result['msg'] = "Error while deleting template" + else: + self.module.fail_json(msg="Template not found", response=[]) + + +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']), + ) + 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() + + module.exit_json(**dnac_template.result) + + +if __name__ == '__main__': + main() diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 9670594862..4cd6816af6 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -1,5 +1,7 @@ plugins/plugin_utils/dnac.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/plugin_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets_count_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK @@ -350,6 +352,10 @@ plugins/action/syslog_config_create.py compile-2.6!skip # Python 2.6 is not supp plugins/action/syslog_config_update.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/transit_peer_network.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/transit_peer_network_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_count_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK @@ -699,4 +705,18 @@ plugins/action/pnp_device_authorize.py compile-2.7!skip # Python 2.7 is not supp plugins/action/syslog_config_create.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/syslog_config_update.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/transit_peer_network.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK -plugins/action/transit_peer_network_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK \ No newline at end of file +plugins/action/transit_peer_network_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index 8826a4a10f..c18cef2393 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -1,6 +1,9 @@ plugins/plugin_utils/dnac.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/plugin_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/plugin_utils/dnac.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets_count_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK @@ -351,6 +354,10 @@ plugins/action/syslog_config_create.py compile-2.6!skip # Python 2.6 is not supp plugins/action/syslog_config_update.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/transit_peer_network.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/transit_peer_network_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_count_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK @@ -701,6 +708,10 @@ plugins/action/syslog_config_create.py compile-2.7!skip # Python 2.7 is not supp plugins/action/syslog_config_update.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/transit_peer_network.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/transit_peer_network_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_count_info.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_info.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK @@ -1050,4 +1061,13 @@ plugins/action/pnp_device_authorize.py import-2.7 # Python 2.7 is not supported plugins/action/syslog_config_create.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK plugins/action/syslog_config_update.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK plugins/action/transit_peer_network.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK -plugins/action/transit_peer_network_info.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK \ No newline at end of file +plugins/action/transit_peer_network_info.py import-2.7 # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK diff --git a/tests/sanity/ignore-2.12.txt b/tests/sanity/ignore-2.12.txt index e69de29bb2..498c84007b 100644 --- a/tests/sanity/ignore-2.12.txt +++ b/tests/sanity/ignore-2.12.txt @@ -0,0 +1,20 @@ +plugins/module_utils/dnac.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt new file mode 100644 index 0000000000..1ef6913d16 --- /dev/null +++ b/tests/sanity/ignore-2.13.txt @@ -0,0 +1,10 @@ +plugins/module_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK \ No newline at end of file diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt new file mode 100644 index 0000000000..1ef6913d16 --- /dev/null +++ b/tests/sanity/ignore-2.14.txt @@ -0,0 +1,10 @@ +plugins/module_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK \ No newline at end of file diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index 9670594862..4cd6816af6 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -1,5 +1,7 @@ plugins/plugin_utils/dnac.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/plugin_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets_count_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK @@ -350,6 +352,10 @@ plugins/action/syslog_config_create.py compile-2.6!skip # Python 2.6 is not supp plugins/action/syslog_config_update.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/transit_peer_network.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/transit_peer_network_info.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.6!skip # Python 2.6 is not supported by the DNA Center SDK plugins/action/application_sets.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_count_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/application_sets_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK @@ -699,4 +705,18 @@ plugins/action/pnp_device_authorize.py compile-2.7!skip # Python 2.7 is not supp plugins/action/syslog_config_create.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/syslog_config_update.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK plugins/action/transit_peer_network.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK -plugins/action/transit_peer_network_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK \ No newline at end of file +plugins/action/transit_peer_network_info.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py compile-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.6!skip # Python 2.6 is not supported by the DNA Center SDK +plugins/module_utils/dnac.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/pnp_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/template_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/site_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK +plugins/modules/swim_intent.py import-2.7!skip # Python 2.7 is not supported by the DNA Center SDK diff --git a/tests/unit/modules/dnac/__init__.py b/tests/unit/modules/dnac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/modules/dnac/dnac_module.py b/tests/unit/modules/dnac/dnac_module.py new file mode 100644 index 0000000000..c05b5a6eed --- /dev/null +++ b/tests/unit/modules/dnac/dnac_module.py @@ -0,0 +1,140 @@ +# Copyright (c) 2022 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import os +import json + +from ansible_collections.ansible.netcommon.tests.unit.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleTestCase, +) +from ansible_collections.ansible.netcommon.tests.unit.modules.utils import ( + set_module_args as _set_module_args, +) + + +def set_module_args(args): + return _set_module_args(args) + + +fixture_path = os.path.join(os.path.dirname(__file__), "fixtures") +fixture_data = {} + + +def loadPlaybookData(module_name): + path = os.path.join(fixture_path, "{0}.json".format(module_name)) + print(path) + + with open(path) as f: + data = f.read() + + try: + j_data = json.loads(data) + except Exception as e: + print(e) + pass + + return j_data + + +def load_fixture(module_name, name, device=""): + path = os.path.join(fixture_path, module_name, device, name) + if not os.path.exists(path): + path = os.path.join(fixture_path, module_name, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestDnacModule(ModuleTestCase): + def execute_module_devices( + self, failed=False, changed=False, response=None, sort=True, defaults=False + ): + module_name = self.module.__name__.rsplit(".", 1)[1] + local_fixture_path = os.path.join(fixture_path, module_name) + + models = [] + for path in os.listdir(local_fixture_path): + path = os.path.join(local_fixture_path, path) + if os.path.isdir(path): + models.append(os.path.basename(path)) + if not models: + models = [""] + + retvals = {} + for model in models: + retvals[model] = self.execute_module( + failed, changed, response, sort, device=model + ) + + return retvals + + def execute_module( + self, failed=False, changed=False, response=None, sort=True, device="" + ): + + self.load_fixtures(response, device=device) + + if failed: + result = self.failed() + self.assertTrue(result["failed"], result) + else: + result = self.changed(changed) + self.assertEqual(result["changed"], changed, result) + + if response is not None: + if sort: + self.assertEqual( + sorted(response), sorted(result["response"]), result["response"] + ) + else: + self.assertEqual(response, result["response"], result["response"]) + + return result + + def failed(self): + with self.assertRaises(AnsibleFailJson) as exc: + self.module.main() + + result = exc.exception.args[0] + self.assertTrue(result["failed"], result) + return result + + def changed(self, changed=False): + with self.assertRaises(AnsibleExitJson) as exc: + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result["changed"], changed, result) + return result + + def load_fixtures(self, response=None, device=""): + pass diff --git a/tests/unit/modules/dnac/fixtures/pnp_intent.json b/tests/unit/modules/dnac/fixtures/pnp_intent.json new file mode 100644 index 0000000000..449ed54e73 --- /dev/null +++ b/tests/unit/modules/dnac/fixtures/pnp_intent.json @@ -0,0 +1,166 @@ +{ + "playbook_config": [ + { + "image_name": "cat9k_iosxe.17.04.01.SPA.bin", + "site_name": "Global/Chennai/MS_Test_1", + "device_version": 2, + "template_name": "ANSIBLE-TEST", + "deviceInfo": { + "serialNumber": "PRATEST4", + "hostname": "PRATEST4", + "state": "Unclaimed", + "pid": "C9300-25UX" + } + } + ], + + "playbook_config_missing_parameter": [ + { + "site_name": "Global/Chennai/MS_Test_1", + "device_version": 2, + "template_name": "ANSIBLE-TEST", + "deviceInfo": { + "serialNumber": "PRATEST4", + "hostname": "PRATEST4", + "state": "Unclaimed", + "pid": "C9300-25UX" + } + } + ], + + "image_exists_response": { + "response": + [{ + "imageUuid": "2f566ca4-cca8-4b4f-8bc9-917ef813bfb3", + "name": "cat9k_iosxe.17.04.01.SPA.bin", + "family": "CAT9K", + "version": "17.04.01.0.173" + }], + "version": "1.0" + }, + + "image_doesnot_exist_response": { + "response": [], + "version": "1.0" + }, + + "template_exists_response": + [{ + "name": "ANSIBLE-TEST", + "projectName": "Onboarding Configuration", + "projectId": "1d0e4377-a0f7-4146-9ee7-60b2e23f1343", + "templateId": "77bf8489-b14b-47c0-9d87-12a26e89796a", + "softwareType": "IOS-XE", + "deviceTypes": + [{ + "productFamily": "Switches and Hubs" + }] + }], + + "template_doesnot_exist_response": + [{ + "name": "DMVPN Hub for Cloud Router", + "projectName": "Onboarding Configuration", + "projectId": "1d0e4377-a0f7-4146-9ee7-60b2e23f1343", + "templateId": "94b4b0ff-25dd-4c05-9cee-1229a86f8a2b", + "softwareType": "IOS-XE", + "deviceTypes": + [{ + "productFamily": "Routers" + }] + }], + + "create_site_response":{ + "executionId": "e40e3f70-9b09-4a32-90b6-8d1c72d81cf5", + "executionStatusUrl": "/dna/platform/management/business-api/v1/execution-status/e40e3f70-9b09-4a32-90b6-8d1c72d81cf5", + "message": "The request has been accepted for execution" + }, + + "execution_details_create_success":{ + "bapiKey": "50b5-89fd-4c7a-930a", + "bapiName": "Create Site", + "bapiExecutionId": "e40e3f70-9b09-4a32-90b6-8d1c72d81cf5", + "status": "SUCCESS" + }, + + "site_exists_response": { + "response": + [{ + "parentId": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "additionalInfo": + [{ + "nameSpace": "Location", + "attributes": + { + "country": "India", + "latitude": "12.93570674358", + "type": "building", + "longitude": "80.24982195822832" + } + }], + "name": "MS_Test_1", + "id": "63b99771-bae4-43da-bf75-bce03cdaf7ee", + "siteNameHierarchy": "Global/Chennai/MS_Test_1" + }] + }, + + "site_needs_update_response": { + "response": + [{ + "parentId": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "additionalInfo": + [{ + "nameSpace": "Location", + "attributes": + { + "country": "India", + "latitude": "13.02372563782", + "type": "building", + "longitude": "80.2541235068557" + } + }], + "name": "MS_Test_1", + "id": "63b99771-bae4-43da-bf75-bce03cdaf7ee", + "siteNameHierarchy": "Global/Chennai/MS_Test_1" + }] + }, + + "device_exists_response":[{ + "version": 2, + "deviceInfo": { + "serialNumber": "PRATEST4", + "name": "PRATEST4", + "pid": "C9300-25UX", + "state": "Planned", + "siteId": "a5aaf1be-5257-4445-b84f-d0d6d65b645d", + "siteName": "Global/Chennai/MS_Test_1", + "hostname": "PRATEST4" + } + }], + + "add_device_response":{ + "version": 2, + "deviceInfo": + { + "serialNumber": "PRATEST4", + "name": "PRATEST4", + "pid": "C9300-25UX", + "state": "Unclaimed", + "hostname": "PRATEST4" + }, + "id": "627daabc938cba2eb64042d2" + + }, + + "claim_response": { + "response": "Device Claimed", + "version": "1.0" + }, + + "delete_device_response":{ + "deviceInfo": + { + "state": "Deleted" + } + } +} diff --git a/tests/unit/modules/dnac/fixtures/site_intent.json b/tests/unit/modules/dnac/fixtures/site_intent.json new file mode 100644 index 0000000000..703de3cb69 --- /dev/null +++ b/tests/unit/modules/dnac/fixtures/site_intent.json @@ -0,0 +1,186 @@ +{ + "playbook_config": [{ + "type": "floor", + "site": { + "floor": { + "name": "Floor 1", + "parentName": "Global/Chennai/Trill", + "rfModel": "Free Space", + "width": "100.0", + "length": "100.0", + "height": "100.0" + } + } + }], + "playbook_config_invalid_param": [{ + "type": "floor" + }], + "create_site_response":{ + "executionId": "0ac985a4-b1bb-442d-9fa3-2032cb9e9ba6", + "executionStatusUrl": "/dna/platform/management/business-api/v1/execution-status/0ac985a4-b1bb-442d-9fa3-2032cb9e9ba6", + "message": "The request has been accepted for execution" + }, + "get_business_api_execution_details_response":{ + "status": "SUCCESS" + }, + "get_site_response":{ + "response": [{ + "parentId": "1e3868a6-666d-47a3-813c-6a2aa9bce46c", + "additionalInfo": [ + { + "nameSpace": "mapsSummary", + "attributes": { + "rfModel": "14144130", + "imageURL": "", + "isCadFile": "false", + "floorIndex": "1" + } + }, + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "type": "floor" + } + }, + { + "nameSpace": "mapGeometry", + "attributes": { + "offsetX": "0.0", + "offsetY": "0.0", + "width": "90.0", + "length": "100.0", + "height": "100.0" + } + } + ], + "name": "Floor 2", + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "ea3848d7-5337-4e48-aec3-605beaee55b4", + "siteHierarchy": "c6622c8a-ae49-47db-bc7c-5984fa61ca28/597dba2d-c09f-4bae-ae61-8b0d9c8cd268/1e3868a6-666d-47a3-813c-6a2aa9bce46c/ea3848d7-5337-4e48-aec3-605beaee55b4", + "siteNameHierarchy": "Global/Chennai/Trill/Floor 2" + } + ] + }, + "update_not_needed_get_site_response":{ + "response": [ + { + "parentId": "1e3868a6-666d-47a3-813c-6a2aa9bce46c", + "additionalInfo": [ + { + "nameSpace": "mapsSummary", + "attributes": { + "rfModel": "14144130", + "imageURL": "", + "isCadFile": "false", + "floorIndex": "1" + } + }, + { + "nameSpace": "mapGeometry", + "attributes": { + "offsetX": "0.0", + "offsetY": "0.0", + "length": "100.0", + "width": "100.0", + "height": "100.0" + } + }, + { + "nameSpace": "Location", + "attributes": { + "latitude": "0.0", + "addressInheritedFrom": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "type": "floor", "longitude": "0.0" + } + } + ], + "name": "Floor 1", + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "16d77f9d-e1ae-4556-8e80-615651ee52ca", + "siteHierarchy": "c6622c8a-ae49-47db-bc7c-5984fa61ca28/597dba2d-c09f-4bae-ae61-8b0d9c8cd268/1e3868a6-666d-47a3-813c-6a2aa9bce46c/16d77f9d-e1ae-4556-8e80-615651ee52ca", + "siteNameHierarchy": "Global/Chennai/Trill/Floor 1" + } + ] + }, + "update_needed_get_site_response": { + "response": [ + { + "parentId": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "India", + "latitude": "12.933971097851867", + "addressInheritedFrom": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "type": "building", + "longitude": "80.2467681804189" + } + } + ], + "name": "Triill", + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "87169daa-7de2-4f9d-814a-d8746de41be6", + "siteHierarchy": "c6622c8a-ae49-47db-bc7c-5984fa61ca28/597dba2d-c09f-4bae-ae61-8b0d9c8cd268/87169daa-7de2-4f9d-814a-d8746de41be6", + "siteNameHierarchy": "Global/Chennai/Triill" + } + ] + }, + "update_needed_update_site_response":{ + "executionId": "67f206ab-5377-46d4-99bf-dadf1543f84e", + "executionStatusUrl": "/dna/platform/management/business-api/v1/execution-status/67f206ab-5377-46d4-99bf-dadf1543f84e", + "message": "The request has been accepted for execution" + }, + "delete_get_site_response":{ + "response": [ + { + "parentId": "4ee5983e-4e4b-4a91-96fa-3d526d593cb0", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "addressInheritedFrom": "4ee5983e-4e4b-4a91-96fa-3d526d593cb0", + "type": "area" + } + } + ], + "name": "Hebbal", + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "e9b94916-e5b0-422c-9dac-16618ebb5f73", + "siteHierarchy": "c6622c8a-ae49-47db-bc7c-5984fa61ca28/4ee5983e-4e4b-4a91-96fa-3d526d593cb0/e9b94916-e5b0-422c-9dac-16618ebb5f73", + "siteNameHierarchy": "Global/Bangalore/Hebbal" + } + ] + }, + "delete_delete_site_response":{ + "executionId": "820b2ad9-4c31-48c1-b729-e029ad00ce95" + }, + "delete_execution_details_error":{ + "bapiError":"True" + }, + "delete_error_get_site_response":{ + "response": [ + { + "parentId": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "additionalInfo": [ + { + "nameSpace": "Location", + "attributes": { + "country": "India", + "latitude": "12.933971097851867", + "addressInheritedFrom": "597dba2d-c09f-4bae-ae61-8b0d9c8cd268", + "type": "building", + "longitude": "80.2467681804189" + } + } + ], + "name": "Triill", + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "87169daa-7de2-4f9d-814a-d8746de41be6", + "siteHierarchy": "c6622c8a-ae49-47db-bc7c-5984fa61ca28/597dba2d-c09f-4bae-ae61-8b0d9c8cd268/87169daa-7de2-4f9d-814a-d8746de41be6", + "siteNameHierarchy": "Global/Chennai/Triill" + } + ] + } +} diff --git a/tests/unit/modules/dnac/fixtures/template_intent.json b/tests/unit/modules/dnac/fixtures/template_intent.json new file mode 100644 index 0000000000..68c16646f3 --- /dev/null +++ b/tests/unit/modules/dnac/fixtures/template_intent.json @@ -0,0 +1,244 @@ +{ + "playbook_config": [ + { + "projectName": "Onboarding Configuration", + "templateContent": "hostname Test-2", + "language": "velocity", + "deviceTypes": [{ + "productFamily": "Switches and Hubs" + }], + "softwareType": "IOS-XE", + "sofwtareVariant": "XE", + "templateName": "ANSIBLE-TEST", + "versionDescription": "MS Test Template" + }], + + "playbook_config_missing_param": [ + { + "projectName": "Onboarding Configuration", + "templateContent": "hostname Test-2", + "deviceTypes": [{ + "productFamily": "Switches and Hubs" + }], + "softwareType": "IOS-XE", + "sofwtareVariant": "XE", + "templateName": "ANSIBLE-TEST", + "versionDescription": "MS Test Template" + }], + + "playbook_config_invalid_param": [ + { + "projectName": "Onboarding Configuration", + "templateContent": "hostname Test-2", + "language": "velocty", + "deviceTypes": [{ + "productFamily": "Switches and Hubs" + }], + "softwareType": "IOS-XE", + "sofwtareVariant": "XE", + "templateName": "ANSIBLE-TEST", + "versionDescription": "MS Test Template" + }], + + "create_template_list_response":[ + { + "name": "DMVPN Hub for Cloud Router- System Default", + "projectName": "Onboarding Configuration", + "projectId": "1d0e4377-a0f7-4146-9ee7-60b2e23f1343", + "templateId": "94b4b0ff-25dd-4c05-9cee-1229a86f8a2b" + } + ], + + "create_template_get_project_response": [ + { + "name": "Onboarding Configuration", + "id": "1d0e4377-a0f7-4146-9ee7-60b2e23f1343", + "isDeletable": false + } + ], + + "create_template_response":{ + "response": { + "taskId": "f79b5b3e-09b0-4ddf-9039-7172313f4ac0", + "url": "/api/v1/task/f79b5b3e-09b0-4ddf-9039-7172313f4ac0" + }, + "version": "1.0" + }, + + "create_template_task_details_for_create": { + "response": { + "data": "b750cf21-54a0-4ce7-93d1-8d14de24b86f", + "progress": "Successfully created template with name ANSIBLE-TEST", + "isError": false, + "id": "f79b5b3e-09b0-4ddf-9039-7172313f4ac0" + }, + "version": "1.0"}, + + "create_template_version_template_response": { + "response": { + "taskId": "91baaf05-c9cd-46ed-b180-4a2ff8aabb18", + "url": "/api/v1/task/91baaf05-c9cd-46ed-b180-4a2ff8aabb18" + }, + "version": "1.0" + }, + + "create_template_task_details_for_versioning": { + "response": { + "endTime": 1652055868465, + "version": 1652055868449, + "data": "1, 1652055868457", + "startTime": 1652055868449, + "username": "admin", + "progress": "Successfully committed template ANSIBLE-TEST to version 1", + "serviceType": "NCTP", "rootId": "91baaf05-c9cd-46ed-b180-4a2ff8aabb18", + "isError": false, + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "91baaf05-c9cd-46ed-b180-4a2ff8aabb18" + }, + "version": "1.0" + }, + + "update_template_list": [ + { + "name": "ANSIBLE-TEST", + "projectName": "Onboarding Configuration", + "projectId": "1d0e4377-a0f7-4146-9ee7-60b2e23f1343", + "templateId": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15" + } + ], + + "update_template_existing_template": { + "name": "ANSIBLE-TEST", + "tags": [], + "deviceTypes": + [ + { + "productFamily": "Switches and Hubs" + } + ], + "softwareType": "IOS-XE", + "softwareVariant": "XE", + "templateContent": "hostname Test-2", + "templateParams": [], + "rollbackTemplateParams": [], + "composite": false, + "containingTemplates": [], + "language": "VELOCITY", + "id": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "customParamsOrder": false, + "createTime": 1652056127518, + "lastUpdateTime": 1652056127518, + "latestVersionTime": 1652056127638, + "projectName": "Onboarding Configuration", + "projectId": "1d0e4377-a0f7-4146-9ee7-60b2e23f1343", + "parentTemplateId": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "validationErrors": + { + "templateErrors": [], + "rollbackTemplateErrors": [], + "templateId": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "templateVersion": null + }, + "projectAssociated": true, + "documentDatabase": false + }, + + "update_template_existing_template_needs_update": { + "name": "ANSIBLE-TEST", + "tags": [], + "deviceTypes": + [ + { + "productFamily": "Switches and Hubs" + } + ], + "softwareType": "IOS-XE", + "softwareVariant": "XE", + "templateContent": "hostname TEST-RC\n", + "templateParams": [], + "rollbackTemplateParams": [], + "composite": false, + "containingTemplates": [], + "language": "VELOCITY", + "id": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "customParamsOrder": false, + "createTime": 1652056127518, + "lastUpdateTime": 1652056127518, + "latestVersionTime": 1652056127638, + "projectName": "Onboarding Configuration", + "projectId": "1d0e4377-a0f7-4146-9ee7-60b2e23f1343", + "parentTemplateId": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "validationErrors": + { + "templateErrors": [], + "rollbackTemplateErrors": [], + "templateId": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "templateVersion": null + }, + "projectAssociated": true, + "documentDatabase": false + }, + + "update_template_response":{ + "response": + { + "taskId": "31f072a0-7d85-4102-8d24-3ddb40034dff", + "url": "/api/v1/task/31f072a0-7d85-4102-8d24-3ddb40034dff" + }, + "version": "1.0" + }, + + "update_template_version_template_response":{ + "response": + { + "taskId": "addf4d04-7a9b-439c-915c-12618d5e5411", + "url": "/api/v1/task/addf4d04-7a9b-439c-915c-12618d5e5411" + }, + "version": "1.0" + }, + + "update_template_task_details_for_versioning":{ + "response": + { + "endTime": 1652085898143, + "version": 1652085898124, + "data": "2, 1652085898134", + "startTime": 1652085898124, + "username": "admin", + "progress": "Successfully committed template ANSIBLE-TEST to version 2", + "serviceType": "NCTP", + "rootId": "addf4d04-7a9b-439c-915c-12618d5e5411", + "isError": false, + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "addf4d04-7a9b-439c-915c-12618d5e5411" + }, + "version": "1.0" + }, + + "delete_template_response":{ + "response": + { + "taskId": "d13f4df1-39b0-452f-bbc1-5e170e6c302f", + "url": "/api/v1/task/d13f4df1-39b0-452f-bbc1-5e170e6c302f" + }, + "version": "1.0" + }, + + "delete_template_task_details":{ + "response": + { + "endTime": 1652086415305, + "version": 1652086415256, + "data": "fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "startTime": 1652086415256, + "username": "admin", + "progress": "Successfully deleted template with name fd74ab6c-fdda-465e-9f59-fb7eac7d6b15", + "serviceType": "NCTP", + "rootId": "d13f4df1-39b0-452f-bbc1-5e170e6c302f", + "isError": false, + "instanceTenantId": "5fa5559c4238fb00c6cc6801", + "id": "d13f4df1-39b0-452f-bbc1-5e170e6c302f" + }, + "version": "1.0" + } +} diff --git a/tests/unit/modules/dnac/test_pnp_intent.py b/tests/unit/modules/dnac/test_pnp_intent.py new file mode 100644 index 0000000000..a2805e49e0 --- /dev/null +++ b/tests/unit/modules/dnac/test_pnp_intent.py @@ -0,0 +1,303 @@ +# Copyright (c) 2020-2022 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +import pdb + +from dnacentersdk import exceptions +from unittest.mock import patch +from ansible.errors import AnsibleActionFail +from ansible_collections.cisco.dnac.plugins.modules import pnp_intent +from .dnac_module import TestDnacModule, set_module_args, loadPlaybookData + +import json +import copy + + +class TestDnacPnPIntent(TestDnacModule): + + module = pnp_intent + + test_data = loadPlaybookData("pnp_intent") + + playbook_config = test_data.get("playbook_config") + playbook_config_missing_param = test_data.get("playbook_config_missing_param") + + def setUp(self): + super(TestDnacPnPIntent, self).setUp() + + self.mock_dnac_init = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.__init__") + self.run_dnac_init = self.mock_dnac_init.start() + self.run_dnac_init.side_effect = [None] + self.mock_dnac_exec = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.exec" + ) + self.run_dnac_exec = self.mock_dnac_exec.start() + + def tearDown(self): + super(TestDnacPnPIntent, self).tearDown() + self.mock_dnac_exec.stop() + self.mock_dnac_init.stop() + + def load_fixtures(self, response=None, device=""): + if "site_not_found" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("image_exists_response"), + self.test_data.get("template_exists_response"), + Exception(), + ] + + elif "add_new_device" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("image_exists_response"), + self.test_data.get("template_exists_response"), + self.test_data.get("site_exists_response"), + [], + self.test_data.get("add_device_response"), + self.test_data.get("claim_response") + ] + + elif "device_exists" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("image_exists_response"), + self.test_data.get("template_exists_response"), + self.test_data.get("site_exists_response"), + self.test_data.get("device_exists_response"), + self.test_data.get("claim_response") + ] + + elif "delete_device" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("device_exists_response"), + self.test_data.get("delete_device_response") + ] + + elif "deletion_error" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("device_exists_response"), + AnsibleActionFail("An error occured when executing operation." + + "The error was: [400] Bad Request - NCOB01313: Delete device(FJC2416U047) from Inventory"), + ] + + elif "image_doesnot_exist" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("image_doesnot_exist_response") + ] + + elif "template_doesnot_exist" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("image_exists_response"), + self.test_data.get("template_doesnot_exist_response") + ] + + elif "project_not_found" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("image_exists_response"), + [] + ] + elif "delete_nonexisting_device" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + [] + ] + + def test_pnp_intent_site_not_found(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Site not found" + ) + + def test_pnp_intent_add_new_device(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('response').get('response'), + "Device Claimed" + ) + + def test_pnp_intent_device_exists(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('response').get('response'), + "Device Claimed" + ) + + def test_pnp_intent_image_doesnot_exist(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Image not found" + ) + + def test_pnp_intent_template_doesnot_exist(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Template not found" + ) + + def test_pnp_intent_project_not_found(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Project Not Found" + ) + + def test_pnp_intent_missing_param(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.test_data.get("playbook_config_missing_parameter") + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Invalid parameters in playbook: image_name : Required parameter not found" + ) + + def test_pnp_intent_delete_device(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('msg'), + "Device Deleted Successfully" + ) + + def test_pnp_intent_deletion_error(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Device Deletion Failed" + ) + + def test_pnp_intent_delete_nonexisting_device(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Device Not Found" + ) + + def test_pnp_intent_invalid_state(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merge", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "value of state must be one of: merged, deleted, got: merge" + ) diff --git a/tests/unit/modules/dnac/test_site_intent.py b/tests/unit/modules/dnac/test_site_intent.py new file mode 100644 index 0000000000..e92dc773a4 --- /dev/null +++ b/tests/unit/modules/dnac/test_site_intent.py @@ -0,0 +1,251 @@ +# Copyright (c) 2020 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +import pdb + +from dnacentersdk import exceptions +from unittest.mock import patch + +from ansible_collections.cisco.dnac.plugins.modules import site_intent +from .dnac_module import TestDnacModule, set_module_args, loadPlaybookData + +import json +import copy + + +class TestDnacSiteIntent(TestDnacModule): + + module = site_intent + + test_data = loadPlaybookData("site_intent") + + playbook_config = test_data.get("playbook_config") + playbook_config_missing_param = test_data.get("playbook_config_missing_param") + + def setUp(self): + super(TestDnacSiteIntent, self).setUp() + + self.mock_dnac_init = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.__init__") + self.run_dnac_init = self.mock_dnac_init.start() + self.run_dnac_init.side_effect = [None] + self.mock_dnac_exec = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.exec" + ) + self.run_dnac_exec = self.mock_dnac_exec.start() + + def tearDown(self): + super(TestDnacSiteIntent, self).tearDown() + self.mock_dnac_exec.stop() + self.mock_dnac_init.stop() + + def load_fixtures(self, response=None, device=""): + if "create_site" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + Exception(), + self.test_data.get("create_site_response"), + self.test_data.get("get_business_api_execution_details_response"), + self.test_data.get("get_site_response") + ] + + elif "update_not_needed" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("update_not_needed_get_site_response"), + ] + + elif "update_needed" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("update_needed_get_site_response"), + self.test_data.get("update_needed_update_site_response"), + self.test_data.get("get_business_api_execution_details_response") + ] + elif "delete_existing_site" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("delete_get_site_response"), + self.test_data.get("delete_delete_site_response"), + self.test_data.get("get_business_api_execution_details_response") + ] + elif "delete_non_existing_site" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + Exception() + ] + elif "error_delete" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("delete_error_get_site_response"), + self.test_data.get("delete_delete_site_response"), + self.test_data.get("delete_execution_details_error") + ] + elif "error_create" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + Exception(), + self.test_data.get("create_site_response"), + self.test_data.get("delete_execution_details_error") + ] + + def test_site_intent_create_site(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('msg'), + "Site Created Successfully" + ) + + def test_site_intent_update_not_needed(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=False) + self.assertEqual( + result.get('msg'), + "Site does not need update" + ) + + def test_site_intent_update_needed(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('msg'), + "Site Updated Successfully" + ) + + def test_site_intent_delete_existing_site(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('response').get('status'), + "SUCCESS" + ) + + def test_site_intent_delete_non_existing_site(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Site Not Found" + ) + + def test_site_intent_invalid_param(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.test_data.get("playbook_config_invalid_param") + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertTrue( + "Invalid parameters in playbook:" in result.get('msg') + ) + + def test_site_intent_error_delete(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "True" + ) + + def test_site_intent_error_create(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "True" + ) + + def test_site_intent_invalid_state(self): + + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merge", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "value of state must be one of: merged, deleted, got: merge" + ) diff --git a/tests/unit/modules/dnac/test_template_intent.py b/tests/unit/modules/dnac/test_template_intent.py new file mode 100644 index 0000000000..1cde7f7d41 --- /dev/null +++ b/tests/unit/modules/dnac/test_template_intent.py @@ -0,0 +1,244 @@ +# Copyright (c) 2020-2022 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +import pdb + +from unittest.mock import patch +from ansible_collections.cisco.dnac.plugins.modules import template_intent +from .dnac_module import TestDnacModule, set_module_args, loadPlaybookData + +import json +import copy + + +class TestDnacTemplateIntent(TestDnacModule): + + module = template_intent + + test_data = loadPlaybookData("template_intent") + + playbook_config = test_data.get("playbook_config") + playbook_config_missing_param = test_data.get("playbook_config_missing_param") + + def setUp(self): + super(TestDnacTemplateIntent, self).setUp() + self.mock_dnac_init = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.__init__") + self.run_dnac_init = self.mock_dnac_init.start() + self.run_dnac_init.side_effect = [None] + self.mock_dnac_exec = patch( + "ansible_collections.cisco.dnac.plugins.module_utils.dnac.DNACSDK.exec" + ) + self.run_dnac_exec = self.mock_dnac_exec.start() + + def tearDown(self): + super(TestDnacTemplateIntent, self).tearDown() + self.mock_dnac_exec.stop() + self.mock_dnac_init.stop() + + def load_fixtures(self, response=None, device=""): + if "create_template" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("create_template_list_response"), + self.test_data.get("create_template_get_project_response"), + self.test_data.get("create_template_response"), + self.test_data.get("create_template_task_details_for_create"), + self.test_data.get("create_template_version_template_response"), + self.test_data.get("create_template_task_details_for_versioning") + ] + elif "update_not_needed" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("update_template_list"), + self.test_data.get("update_template_existing_template"), + ] + elif "update_needed" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("update_template_list"), + self.test_data.get("update_template_existing_template_needs_update"), + self.test_data.get("update_template_response"), + self.test_data.get("update_template_version_template_response"), + self.test_data.get("update_template_task_details_for_versioning") + ] + elif "project_not_found" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + [], + ] + elif "delete_non_existing_template" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("create_template_list_response") + ] + elif "delete_template" in self._testMethodName: + self.run_dnac_exec.side_effect = [ + self.test_data.get("update_template_list"), + self.test_data.get("update_template_existing_template_needs_update"), + self.test_data.get("delete_template_response"), + self.test_data.get("delete_template_task_details"), + ] + + def test_template_intent_create_template(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('response').get('progress'), + "Successfully committed template ANSIBLE-TEST to version 1" + ) + + def test_template_intent_update_not_needed(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=False) + self.assertEqual( + result.get('msg'), + "Template does not need update" + ) + + def test_template_intent_update_needed(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('response').get('progress'), + "Successfully committed template ANSIBLE-TEST to version 2" + ) + + def test_template_intent_project_not_found(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Project Not Found" + ) + + def test_template_intent_delete_non_existing_template(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Template not found" + ) + + def test_template_intent_delete_template(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="deleted", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=True, failed=False) + self.assertEqual( + result.get('response').get('progress'), + "Successfully deleted template with name fd74ab6c-fdda-465e-9f59-fb7eac7d6b15" + ) + + def test_template_intent_missing_param(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.playbook_config_missing_param + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "missing required arguments: language or deviceTypes or softwareType" + ) + + def test_template_intent_invalid_state(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merge", + config=self.playbook_config + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "value of state must be one of: merged, deleted, got: merge" + ) + + def test_template_intent_invalid_param(self): + set_module_args( + dict( + dnac_host="1.1.1.1", + dnac_username="dummy", + dnac_password="dummy", + dnac_log=True, + state="merged", + config=self.test_data.get("playbook_config_invalid_param") + ) + ) + result = self.execute_module(changed=False, failed=True) + self.assertEqual( + result.get('msg'), + "Invalid parameters in playbook: velocty : Invalid choice provided" + )