diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index cca3ed4..2476b17 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -3,6 +3,7 @@ # Copyright: (c) 2021, Lionel Hercot (@lhercot) # Copyright: (c) 2022, Cindy Zhao (@cizhao) # Copyright: (c) 2022, Akini Ross (@akinross) +# Copyright: (c) 2025, Shreyas Srish (@shrsr) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -18,7 +19,6 @@ from ansible.module_utils.basic import json from ansible.module_utils.basic import env_fallback from ansible.module_utils.six import PY3 -from ansible.module_utils.six.moves import filterfalse from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.module_utils._text import to_native, to_text from ansible.module_utils.connection import Connection @@ -73,53 +73,27 @@ def cmp(a, b): def issubset(subset, superset): - """Recurse through nested dictionary and compare entries""" + """Recurse through a nested dictionary and check if it is a subset of another.""" - # Both objects are the same object - if subset is superset: - return True - - # Both objects are identical - if subset == superset: - return True - - # Both objects have a different type - if isinstance(subset) is not isinstance(superset): + if type(subset) is not type(superset): return False + if not isinstance(subset, dict): + if isinstance(subset, list): + return all(item in superset for item in subset) + return subset == superset + for key, value in subset.items(): - # Ignore empty values if value is None: - return True + continue - # Item from subset is missing from superset if key not in superset: return False - # Item has different types in subset and superset - if isinstance(superset.get(key)) is not isinstance(value): - return False + superset_value = superset.get(key) - # Compare if item values are subset - if isinstance(value, dict): - if not issubset(superset.get(key), value): - return False - elif isinstance(value, list): - try: - # NOTE: Fails for lists of dicts - if not set(value) <= set(superset.get(key)): - return False - except TypeError: - # Fall back to exact comparison for lists of dicts - diff = list(filterfalse(lambda i: i in value, superset.get(key))) + list(filterfalse(lambda j: j in superset.get(key), value)) - if diff: - return False - elif isinstance(value, set): - if not value <= superset.get(key): - return False - else: - if not value == superset.get(key): - return False + if not issubset(value, superset_value): + return False return True @@ -252,7 +226,7 @@ def request( if file is not None: info = conn.send_file_request(method, uri, file, data, None, file_key, file_ext) else: - if data: + if data is not None: info = conn.send_request(method, uri, json.dumps(data)) else: info = conn.send_request(method, uri) @@ -310,6 +284,8 @@ def request( self.fail_json(msg="ND Error: {0}".format(self.error.get("message")), data=data, info=info) self.error = payload if "code" in payload: + if self.status == 404 and ignore_not_found_error: + return {} self.fail_json(msg="ND Error {code}: {message}".format(**payload), data=data, info=info, payload=payload) elif "messages" in payload and len(payload.get("messages")) > 0: self.fail_json(msg="ND Error {code} ({severity}): {message}".format(**payload["messages"][0]), data=data, info=info, payload=payload) @@ -375,6 +351,37 @@ def get_obj(self, path, **kwargs): self.fail_json(msg="More than one object matches unique filter: {0}".format(kwargs)) return objs[0] + def get_object_by_nested_key_value(self, path, nested_key_path, value, data_key=None): + + response_data = self.request(path, method="GET") + + if not response_data: + return None + + object_list = [] + if isinstance(response_data, list): + object_list = response_data + elif data_key and data_key in response_data: + object_list = response_data.get(data_key) + else: + return None + + keys = nested_key_path.split(".") + + for obj in object_list: + current_level = obj + for key in keys: + if isinstance(current_level, dict): + current_level = current_level.get(key) + else: + current_level = None + break + + if current_level == value: + return obj + + return None + def sanitize(self, updates, collate=False, required=None, unwanted=None): """Clean up unset keys from a request payload""" if required is None: @@ -506,30 +513,27 @@ def get_diff(self, unwanted=None): if not self.existing and self.sent: return True - existing = self.existing - sent = self.sent + existing = deepcopy(self.existing) + sent = deepcopy(self.sent) for key in unwanted: if isinstance(key, str): if key in existing: - try: - del existing[key] - except KeyError: - pass - try: - del sent[key] - except KeyError: - pass + del existing[key] + if key in sent: + del sent[key] elif isinstance(key, list): key_path, last = key[:-1], key[-1] try: existing_parent = reduce(dict.get, key_path, existing) - del existing_parent[last] + if existing_parent is not None: + del existing_parent[last] except KeyError: pass try: sent_parent = reduce(dict.get, key_path, sent) - del sent_parent[last] + if sent_parent is not None: + del sent_parent[last] except KeyError: pass return not issubset(sent, existing) diff --git a/plugins/modules/nd_multi_cluster_connectivity.py b/plugins/modules/nd_multi_cluster_connectivity.py new file mode 100644 index 0000000..0419ade --- /dev/null +++ b/plugins/modules/nd_multi_cluster_connectivity.py @@ -0,0 +1,285 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Shreyas Srish (@shrsr) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_multi_cluster_connectivity +version_added: "1.0.0" +short_description: Manages cluster configurations on Cisco Nexus Dashboard. +description: +- This module allows for the management of clusters on Cisco Nexus Dashboard. +- This module is only supported on ND v4.1 and later. +author: +- Shreyas Srish (@shrsr) +options: + cluster_type: + description: + - The type of the cluster. + type: str + choices: [ nd, apic ] + cluster_hostname: + description: + - The hostname or IP address of the cluster. + type: str + cluster_username: + description: + - The username for authenticating with the cluster. + type: str + cluster_password: + description: + - The password for authenticating with the cluster. + - This value is not logged in the output. + type: str + cluster_login_domain: + description: + - The login domain for the cluster. + type: str + multi_cluster_login_domain: + description: + - The multi-cluster login domain. + type: str + fabric_name: + description: + - The name of the fabric to which the cluster belongs. + type: str + license_tier: + description: + - The license tier for the cluster. + type: str + choices: [ advantage, essentials, premier ] + features: + description: + - A list of features to enable on the cluster. + type: list + elements: str + choices: [ telemetry, orchestration ] + inband_epg: + description: + - The in-band EPG (Endpoint Group) for the cluster. + - This option is only used when C(telemetry) is enabled in O(features). + type: str + security_domain: + description: + - The security domain for the cluster. + type: str + validate_peer_certificate: + description: + - Whether to validate the peer's SSL/TLS certificate. + type: bool + state: + description: + - Use C(present) to create or update a cluster configuration. + - Use C(absent) to delete a cluster configuration. + - Use C(query) to retrieve cluster configurations. + type: str + choices: [ absent, present, query ] + default: present +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +""" + +EXAMPLES = r""" +- name: Connect ND cluster + cisco.nd.nd_multi_cluster_connectivity: + cluster_type: nd + cluster_hostname: cluster-IP + cluster_username: admin + cluster_password: cluster-password + state: present + +- name: Connect an ACI cluster with features + cisco.nd.nd_multi_cluster_connectivity: + cluster_type: apic + fabric_name: test_aci + cluster_hostname: cluster-IP + cluster_username: admin + cluster_password: cluster-password + license_tier: premier + features: + - orchestration + inband_epg: ansible-inband + state: present + +- name: Query ND cluster + cisco.nd.nd_multi_cluster_connectivity: + cluster_hostname: cluster-IP + state: query + register: query_result + +- name: Query an ACI cluster + cisco.nd.nd_multi_cluster_connectivity: + fabric_name: test_aci + state: query + register: query_result + +- name: Query all the clusters + cisco.nd.nd_multi_cluster_connectivity: + state: query + register: query_result + +- name: Delete ND cluster + cisco.nd.nd_multi_cluster_connectivity: + cluster_hostname: cluster-IP + state: absent + +- name: Delete an ACI cluster + cisco.nd.nd_multi_cluster_connectivity: + fabric_name: test_aci + cluster_username: admin + cluster_password: cluster-password + state: absent +""" + +RETURN = r""" +""" + +from copy import deepcopy +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule, nd_argument_spec + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update( + cluster_type=dict(type="str", choices=["nd", "apic"]), + cluster_hostname=dict(type="str"), + cluster_username=dict(type="str"), + cluster_password=dict(type="str", no_log=True), + cluster_login_domain=dict(type="str"), + multi_cluster_login_domain=dict(type="str"), + fabric_name=dict(type="str"), + license_tier=dict(type="str", choices=["advantage", "essentials", "premier"]), + features=dict(type="list", elements="str", choices=["telemetry", "orchestration"]), + inband_epg=dict(type="str"), + security_domain=dict(type="str"), + validate_peer_certificate=dict(type="bool"), + state=dict(type="str", default="present", choices=["absent", "present", "query"]), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ["state", "present", ["cluster_hostname", "cluster_username", "cluster_password", "cluster_type"]], + ], + ) + + nd = NDModule(module) + + cluster_type = nd.params.get("cluster_type").upper() if nd.params.get("cluster_type") else nd.params.get("cluster_type") + cluster_hostname = nd.params.get("cluster_hostname") + cluster_username = nd.params.get("cluster_username") + cluster_password = nd.params.get("cluster_password") + cluster_login_domain = nd.params.get("cluster_login_domain") + multi_cluster_login_domain = nd.params.get("multi_cluster_login_domain") + license_tier = nd.params.get("license_tier") + features = nd.params.get("features") + inband_epg = nd.params.get("inband_epg") + fabric_name = nd.params.get("fabric_name") + telemetry = {} + if features and "telemetry" in features: + telemetry["status"] = "enabled" + if inband_epg: + telemetry["network"] = "inband" + telemetry["epg"] = "uni/tn-mgmt/mgmtp-default/inb-{0}".format(inband_epg) + else: + telemetry["network"] = "outband" + else: + telemetry["status"] = "disabled" + orchestration = {} + if features and "orchestration" in features: + orchestration["status"] = "enabled" + else: + orchestration["status"] = "disabled" + security_domain = nd.params.get("security_domain") + validate_peer_certificate = nd.params.get("validate_peer_certificate") + state = nd.params.get("state") + + path = "/api/v1/infra/clusters" + if fabric_name: + nd.existing = nd.previous = deepcopy(nd.query_obj("{0}/{1}".format(path, fabric_name), ignore_not_found_error=True)) or {} + elif cluster_hostname: + nd.existing = nd.previous = ( + deepcopy(nd.get_object_by_nested_key_value(path, nested_key_path="spec.onboardUrl", value=cluster_hostname, data_key="clusters")) or {} + ) + if nd.existing: + fabric_name = nd.existing.get("spec").get("name") + else: + nd.existing = nd.previous = nd.query_objs(path, key="clusters") + + if state == "present": + payload = { + "spec": { + "clusterType": cluster_type, + "onboardUrl": cluster_hostname, + "credentials": { + "user": cluster_username, + "password": cluster_password, + "logindomain": cluster_login_domain, + }, + } + } + + if cluster_type == "APIC": + payload["spec"]["aci"] = { + "licenseTier": license_tier, + "name": fabric_name, + "securityDomain": security_domain, + "verifyCA": validate_peer_certificate, + "telemetry": telemetry, + "orchestration": orchestration, + } + elif cluster_type == "ND" and multi_cluster_login_domain: + payload["spec"]["nd"] = {"multiClusterLoginDomainName": multi_cluster_login_domain} + + nd.sanitize(payload) + + if module.check_mode: + nd.existing = nd.proposed + else: + if not nd.existing: + nd.existing = nd.request(path, method="POST", data=payload) + elif nd.get_diff( + unwanted=[ + ["spec", "credentials"], + ["spec", "aci", "name"], + ["spec", "aci", "telemetry"], + ["spec", "aci", "orchestration"], + ["spec", "aci", "licenseTier"], + ] + ): + payload["spec"]["name"] = fabric_name + update_path = "{0}/{1}".format(path, fabric_name) + nd.request(update_path, method="PUT", data=payload) + nd.existing = nd.query_obj(update_path) + + elif state == "absent": + if nd.existing: + if not module.check_mode: + payload = {} + if cluster_type == "APIC": + payload = { + "credentials": { + "user": cluster_username, + "password": cluster_password, + } + } + nd.request("{0}/{1}/remove".format(path, fabric_name), method="POST", data=payload) + nd.existing = {} + + nd.exit_json() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/inventory.networking b/tests/integration/inventory.networking index 6b37d8f..2f4cd39 100644 --- a/tests/integration/inventory.networking +++ b/tests/integration/inventory.networking @@ -29,3 +29,9 @@ external_data_service_ip= data_ip= data_gateway= service_package_host=173.36.219.254 +nd_cluster_host= +nd_cluster_username= +nd_cluster_password= +aci_cluster_host= +aci_cluster_username= +aci_cluster_password= diff --git a/tests/integration/targets/nd_multi_cluster_connectivity/tasks/main.yml b/tests/integration/targets/nd_multi_cluster_connectivity/tasks/main.yml new file mode 100644 index 0000000..ee0b528 --- /dev/null +++ b/tests/integration/targets/nd_multi_cluster_connectivity/tasks/main.yml @@ -0,0 +1,159 @@ +# Test code for the ND modules +# Copyright: (c) 2025, Shreyas Srish (@shrsr) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Connect ND cluster in check mode + cisco.nd.nd_multi_cluster_connectivity: &cm_add_nd_cluster + output_level: debug + cluster_type: nd + cluster_hostname: "{{ nd_cluster_host }}" + cluster_username: "{{ nd_cluster_username }}" + cluster_password: "{{ nd_cluster_password }}" + state: present + check_mode: true + register: cm_add_nd_cluster + +- name: Connect ND cluster in normal mode + cisco.nd.nd_multi_cluster_connectivity: + <<: *cm_add_nd_cluster + register: nm_add_nd_cluster + +- name: Connect ND cluster again + cisco.nd.nd_multi_cluster_connectivity: + <<: *cm_add_nd_cluster + register: nm_add_nd_cluster_again + +- name: Connect an ACI cluster in check mode + cisco.nd.nd_multi_cluster_connectivity: &cm_add_aci_cluster + output_level: debug + cluster_type: apic + fabric_name: test_aci + cluster_hostname: "{{ aci_cluster_host }}" + cluster_username: "{{ aci_cluster_username }}" + cluster_password: "{{ aci_cluster_password }}" + license_tier: premier + #features: + #- telemetry + #- orchestration + inband_epg: ansible-inband + state: present + check_mode: true + register: cm_add_aci_cluster + +- name: Connect an ACI cluster in normal mode + cisco.nd.nd_multi_cluster_connectivity: + <<: *cm_add_aci_cluster + register: nm_add_aci_cluster + +- name: Connect an ACI cluster again + cisco.nd.nd_multi_cluster_connectivity: + <<: *cm_add_aci_cluster + register: nm_add_aci_cluster_again + +- name: Update an ACI cluster by adding validate_peer_certificate + cisco.nd.nd_multi_cluster_connectivity: + <<: *cm_add_aci_cluster + validate_peer_certificate: false + state: present + register: nm_update_aci_cluster + +- name: Assertion check for create + ansible.builtin.assert: + that: + - cm_add_nd_cluster is changed + - cm_add_nd_cluster.proposed.spec.clusterType == "ND" + - cm_add_nd_cluster.proposed.spec.onboardUrl == "173.36.219.50" + - nm_add_nd_cluster is changed + - nm_add_nd_cluster.previous == {} + - nm_add_nd_cluster.current.spec.clusterType == "ND" + - nm_add_nd_cluster.current.spec.onboardUrl == "173.36.219.50" + - nm_add_nd_cluster_again is not changed + - nm_add_nd_cluster_again.current.spec.clusterType == "ND" + - nm_add_nd_cluster_again.current.spec.onboardUrl == "173.36.219.50" + - nm_add_nd_cluster_again.previous == nm_add_nd_cluster_again.current + - cm_add_aci_cluster is changed + - cm_add_aci_cluster.proposed.spec.clusterType == "APIC" + - cm_add_aci_cluster.proposed.spec.onboardUrl == "173.36.219.69" + # - cm_add_aci_cluster.proposed.spec.licenseTier == "premier" + - cm_add_aci_cluster.proposed.spec.aci.name == "test_aci" + - nm_add_aci_cluster is changed + - nm_add_aci_cluster.current.spec.clusterType == "APIC" + - nm_add_aci_cluster.current.spec.onboardUrl == "173.36.219.69" + # - nm_add_aci_cluster.current.spec.licenseTier == "premier" + - nm_add_aci_cluster.current.spec.aci.name == "test_aci" + - nm_add_aci_cluster.previous == {} + - nm_add_aci_cluster_again is not changed + - nm_add_aci_cluster_again.current.spec.clusterType == "APIC" + - nm_add_aci_cluster_again.current.spec.onboardUrl == "173.36.219.69" + # - nm_add_aci_cluster_again.current.spec.licenseTier == "premier" + - nm_add_aci_cluster_again.current.spec.name == "test_aci" + - nm_add_aci_cluster_again.previous == nm_add_aci_cluster_again.current + - nm_update_aci_cluster is changed + - nm_update_aci_cluster.current.spec.clusterType == "APIC" + - nm_update_aci_cluster.current.spec.onboardUrl == "173.36.219.69" + # - nm_update_aci_cluster.current.spec.licenseTier == "premier" + - nm_update_aci_cluster.previous.spec.aci.verifyCA is not defined + - nm_update_aci_cluster.sent.spec.aci.verifyCA == false + +- name: Query ND cluster + cisco.nd.nd_multi_cluster_connectivity: + cluster_type: nd + cluster_hostname: "{{ nd_cluster_host }}" + state: query + register: query_nd_cluster + +- name: Query all + cisco.nd.nd_multi_cluster_connectivity: + output_level: debug + state: query + register: query_all + +- name: Assertion check for query + ansible.builtin.assert: + that: + - query_nd_cluster is not changed + - query_nd_cluster.current.spec.clusterType == "ND" + - query_nd_cluster.current.spec.onboardUrl == "173.36.219.50" + - query_all is not changed + - query_all.current | length >= 2 + +- name: Delete ND cluster in check mode + cisco.nd.nd_multi_cluster_connectivity: &remove_nd_cluster + output_level: info + cluster_type: nd + cluster_hostname: "{{ nd_cluster_host }}" + state: absent + check_mode: true + register: cm_remove_nd_cluster + +- name: Delete ND cluster in normal mode + cisco.nd.nd_multi_cluster_connectivity: + <<: *remove_nd_cluster + register: nm_remove_nd_cluster + +- name: Delete ND cluster again + cisco.nd.nd_multi_cluster_connectivity: + <<: *remove_nd_cluster + register: nm_remove_nd_cluster_again + +- name: Delete aci cluster + cisco.nd.nd_multi_cluster_connectivity: + fabric_name: test_aci + cluster_type: apic + cluster_username: "{{ aci_cluster_username }}" + cluster_password: "{{ aci_cluster_password }}" + state: absent + register: nm_remove_aci_cluster + +- name: Assertion check for delete + ansible.builtin.assert: + that: + - cm_remove_nd_cluster is changed + - nm_remove_nd_cluster is changed + - nm_remove_nd_cluster.current == {} + - nm_remove_nd_cluster.previous == query_nd_cluster.current + - nm_remove_nd_cluster_again is not changed + - nm_remove_nd_cluster_again.current == nm_remove_nd_cluster_again.previous == {} + - nm_remove_aci_cluster.current == {} +