diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 5d09e74..2fafa00 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -157,6 +157,11 @@ "restart", "delete", "update", + "merged", + "replaced", + "overridden", + "deleted", + "gathered", ) INTERFACE_FLOW_RULES_TYPES_MAPPING = {"port_channel": "PORTCHANNEL", "physical": "PHYSICAL", "l3out_sub_interface": "L3_SUBIF", "l3out_svi": "SVI"} @@ -168,3 +173,12 @@ ND_REST_KEYS_TO_SANITIZE = ["metadata"] ND_SETUP_NODE_DEPLOYMENT_TYPE = {"physical": "cimc", "virtual": "vnode"} + +USER_ROLES_MAPPING = { + "fabric_admin": "fabric-admin", + "observer": "observer", + "super_admin": "super-admin", + "support_engineer": "support-engineer", + "approver": "approver", + "designer": "designer", +} diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index cca3ed4..0a33be8 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -18,7 +18,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 +72,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 +225,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 +283,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) @@ -506,30 +481,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/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py new file mode 100644 index 0000000..c60f800 --- /dev/null +++ b/plugins/module_utils/nd_config_collection.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Gaspard Micol (@gmicol) + +# 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 + +from copy import deepcopy + + +# TODO: move it to utils.py +def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remove_none_values=True): + if keys is None: + keys = [] + if values is None: + values = [] + + result = deepcopy(dict_to_sanitize) + for k, v in dict_to_sanitize.items(): + if k in keys: + del result[k] + elif v in values or (v is None and remove_none_values): + del result[k] + elif isinstance(v, dict) and recursive: + result[k] = sanitize_dict(v, keys, values) + elif isinstance(v, list) and recursive: + for index, item in enumerate(v): + if isinstance(item, dict): + result[k][index] = sanitize_dict(item, keys, values) + return result + + +# Custom NDConfigCollection Exceptions +class NDConfigCollectionError(Exception): + """Base exception for NDConfigCollection errors.""" + pass + + +class NDConfigNotFoundError(NDConfigCollectionError, KeyError): + """Raised when a configuration is not found by its identifier.""" + pass + + +class NDIdentifierMismatchError(NDConfigCollectionError, ValueError): + """Raised when an identifier in a config does not match the expected key.""" + pass + + +class InvalidNDConfigError(NDConfigCollectionError, TypeError): + """Raised when a provided config is not a dictionary or is missing the identifier key.""" + pass + + +# TODO: Add a get_diff_config function +# TODO: Handle multiple identifiers +# TODO: Add descriptions +# TODO: Maybe leverage MutableMapping, MutableSequence from collections.abc +# NOTE: New data structure for ND Network Resource Module +class NDConfigCollection: + def __init__(self, identifier_key, data=None): + if not isinstance(identifier_key, str): + raise TypeError("identifier_key must be a string.") + self.identifier_key = identifier_key + self.config_collection = {} + + if data is not None: + if isinstance(data, list): + self.list_view = data + elif isinstance(data, dict): + self.config_collection = data + else: + raise TypeError("data must be a list of dicts or dict of configs.") + + @property + def list_view(self): + return [v.copy() for v in self.config_collection.values()] + + @list_view.setter + def list_view(self, new_list): + if not isinstance(new_list, list): + raise TypeError("list_view must be set to a list.") + + new_dict = {} + for item in new_list: + if not isinstance(item, dict): + raise TypeError("All items in list_view must be dicts.") + if self.identifier_key not in item: + raise InvalidNDConfigError("Missing '{0}' in item: {1}".format(self.identifier_key, item)) + + key = item[self.identifier_key] + new_dict[key] = item.copy() + self.config_collection = new_dict + + # Basic Operations + def replace(self, config): + if not isinstance(config, dict): + raise InvalidNDConfigError("Config must be a dict.") + if self.identifier_key not in config: + raise InvalidNDConfigError("Missing '{0}' in config: {1}".format(self.identifier_key, config)) + + key = config[self.identifier_key] + self.config_collection[key] = config.copy() + + def merge(self, config): + if not isinstance(config, dict): + raise InvalidNDConfigError("Config must be a dict.") + if self.identifier_key not in config: + raise InvalidNDConfigError("Missing '{0}' in config: {1}".format(self.identifier_key, config)) + + key = config[self.identifier_key] + if key in self.config_collection: + self.config_collection[key].update(config.copy()) + else: + self.config_collection[key] = config.copy() + + def remove(self, identifier): + if identifier not in self.config_collection: + raise NDConfigNotFoundError("Configuration with identifier '{0}' not found.".format(identifier)) + del self.config_collection[identifier] + + def get(self, identifier): + config = self.config_collection.get(identifier) + if config is None: + raise NDConfigNotFoundError("Configuration with identifier '{0}' not found.".format(identifier)) + return config.copy() + + # Magic Methods + def __len__(self): + return len(self.config_collection) + + def __contains__(self, identifier): + return identifier in self.config_collection + + def __iter__(self): + for config in self.config_collection.values(): + yield config.copy() + + def __getitem__(self, identifier): + return self.get(identifier) + + def __setitem__(self, identifier, config): + if not isinstance(config, dict): + raise InvalidNDConfigError("Config must be a dict when setting via __setitem__.") + if self.identifier_key not in config: + raise InvalidNDConfigError("Config must contain '{0}' when setting via __setitem__.".format(self.identifier_key)) + if config[self.identifier_key] != identifier: + raise NDIdentifierMismatchError( + "Identifier '{0}' in key does not match '{1}' value '{2}' in config.".format(identifier, self.identifier_key, config[self.identifier_key]) + ) + self.replace(config) + + def __delitem__(self, identifier): + self.remove(identifier) + + def __eq__(self, other): + if not isinstance(other, NDConfigCollection): + # TODO: Make it works for list and dict as well. For now just raise an error + raise InvalidNDConfigError("Can only do __eq__ with another NDConfigCollection instance.") + + if self.identifier_key != other.identifier_key: + return False + + return self.config_collection == other.config_collection + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "NDConfigCollection(identifier_key='{0}', count={1})".format(self.identifier_key, len(self)) + + # Standard Dictionary-like Views + def keys(self): + return self.config_collection.keys() + + def values(self): + for v in self.config_collection.values(): + yield v.copy() + + def items(self): + for k, v in self.config_collection.items(): + yield k, v.copy() + + # Utility/Convenience Functions + def clear(self): + self.config_collection.clear() + + def find_by_attribute(self, attribute_name, attribute_value): + matching_configs = [] + for config in self.values(): + if config.get(attribute_name) == attribute_value: + matching_configs.append(config.copy()) + return matching_configs + + def copy(self): + return NDConfigCollection(self.identifier_key, data=deepcopy(self.config_collection)) + + def sanitize(self, keys_to_remove=None, values_to_remove=None, recursive=True, remove_none_values=True): + sanitized_config_collection = sanitize_dict(self.config_collection, keys_to_remove, values_to_remove, recursive, remove_none_values) + return NDConfigCollection(self.identifier_key, data=sanitized_config_collection) + + def get_diff_identifiers(self, other_collection): + if not isinstance(other_collection, NDConfigCollection): + raise InvalidNDConfigError("Can only do get_removed_identifiers with another NDConfigCollection instance.") + + if self.identifier_key != other_collection.identifier_key: + raise NDIdentifierMismatchError( + "Cannot do get_removed_identifiers with another NDConfigCollection with different identifier_key. " + "Expected '{0}', got '{1}'.".format(self.identifier_key, other_collection.identifier_key) + ) + current_identifiers = set(self.config_collection.keys()) + other_identifiers = set(other_collection.config_collection.keys()) + + return list(current_identifiers - other_identifiers) diff --git a/plugins/module_utils/nd_network_resources.py b/plugins/module_utils/nd_network_resources.py new file mode 100644 index 0000000..6d6feb8 --- /dev/null +++ b/plugins/module_utils/nd_network_resources.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Gaspard Micol (@gmicol) + +# 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 + +from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection +from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED + + +# TODO: Make it an all new module class with request, etc. +# TODO: Find a more flexible way to query all objects when init NDNetworkResourceModule +# TODO: Enable check mode +# TODO: Add descriptions +# TODO: Combine it with Shreyas proposal to tackle unique requests +# NOTE: ONLY works for new API endpoints introduced in ND v4.1.0 and later +class NDNetworkResourceModule(NDModule): + def __init__(self, module, path, identifier_key, init_all_data): + super().__init__(module) + self.path = path + self.identifier_key = identifier_key + # normal output + self.existing = NDConfigCollection(identifier_key) + + # info output + self.previous = NDConfigCollection(identifier_key) + self.proposed = NDConfigCollection(identifier_key) + self.sent = NDConfigCollection(identifier_key) + self.init_all_existing = NDConfigCollection(identifier_key, data=init_all_data) + + def preporcessing_configs(self, new_configs): + self.proposed = NDConfigCollection(self.identifier_key, data=new_configs).sanitize() + self.existing = self.init_all_existing.copy() + self.previous = self.init_all_existing.copy() + + def merge_configs(self, new_configs): + self.preporcessing_configs(new_configs) + + for identifier, config in self.proposed.items(): + if identifier in self.existing.keys(): + self.existing.merge(config) + # TODO: leverage a get_diff_config instead + if self.existing.get(identifier) != self.previous.get(identifier): + object_path = "{0}/{1}".format(self.path, identifier) + self.request(path=object_path, method="PUT", data=self.existing[identifier]) + self.sent[identifier] = config + else: + self.request(path=self.path, method="POST", data=config) + self.existing[identifier] = config + self.sent[identifier] = config + + def replace_configs(self, new_configs): + self.preporcessing_configs(new_configs) + + for identifier, config in self.proposed.items(): + # TODO: leverage a get_diff_config instead + if identifier in self.existing.keys() and config != self.existing.get(identifier): + object_path = "{0}/{1}".format(self.path, identifier) + self.request(path=object_path, method="PUT", data=config) + self.sent[identifier] = config + else: + self.request(path=self.path, method="POST", data=config) + self.sent[identifier] = config + self.existing[identifier] = config + + def override_configs(self, new_configs, unwanted=None): + if unwanted is None: + unwanted = [] + + self.replace_configs(new_configs) + + diff_identifiers = self.previous.get_diff_identifiers(self.proposed) + for identifier in diff_identifiers: + if identifier not in unwanted: + object_path = "{0}/{1}".format(self.path, identifier) + self.request(path=object_path, method="DELETE") + del self.existing[identifier] + + def delete_configs(self, new_configs): + self.preporcessing_configs(new_configs) + + for identifier in self.proposed.keys(): + if identifier in self.existing.keys(): + object_path = "{0}/{1}".format(self.path, identifier) + self.request(path=object_path, method="DELETE") + del self.existing[identifier] + + # self.proposed.clear() + + # TODO: Make further modifications to fail_json and exit_json func + def nrm_fail_json(self, msg, **kwargs): + if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: + if self.params.get("output_level") in ("debug", "info"): + self.result["previous"] = self.previous.list_view + if not self.has_modified and self.previous != self.existing: + self.result["changed"] = True + if self.stdout: + self.result["stdout"] = self.stdout + + if self.params.get("output_level") == "debug": + if self.url is not None: + self.result["method"] = self.method + self.result["response"] = self.response + self.result["status"] = self.status + self.result["url"] = self.url + self.result["httpapi_logs"] = self.httpapi_logs + + if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: + self.result["sent"] = self.sent.list_view + self.result["proposed"] = self.proposed.list_view + + self.result["current"] = self.existing.list_view + + self.result.update(**kwargs) + self.module.fail_json(msg=msg, **self.result) + + def nrm_exit_json(self, **kwargs): + if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: + if self.params.get("output_level") in ("debug", "info"): + self.result["previous"] = self.previous.list_view + if not self.has_modified and self.previous != self.existing: + self.result["changed"] = True + if self.stdout: + self.result["stdout"] = self.stdout + + if self.params.get("output_level") == "debug": + self.result["method"] = self.method + self.result["response"] = self.response + self.result["status"] = self.status + self.result["url"] = self.url + self.result["httpapi_logs"] = self.httpapi_logs + + if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: + self.result["sent"] = self.sent.list_view + self.result["proposed"] = self.proposed.list_view + + self.result["current"] = self.existing.list_view + + if self.module._diff and self.result.get("changed") is True: + self.result["diff"] = dict( + before=self.previous.list_view, + after=self.existing.list_view, + ) + + self.result.update(**kwargs) + self.module.exit_json(**self.result) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py new file mode 100644 index 0000000..6b633a5 --- /dev/null +++ b/plugins/modules/nd_local_user.py @@ -0,0 +1,276 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Gaspard Micol (@gmicol) + +# 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_local_user +version_added: "1.4.0" +short_description: Manage local users on Cisco Nexus Dashboard +description: +- Manage local users on Cisco Nexus Dashboard (ND). +- It supports creating, updating, querying, and deleting local users. +author: +- Gaspard Micol (@gmicol) +options: + config: + description: + - The list of the local users to configure. + type: list + elements: dict + suboptions: + email: + description: + - The email address of the local user. + type: str + login_id: + description: + - The login ID of the local user. + - The O(config.login_id) must be defined when creating, updating or deleting a local user. + type: str + required: true + first_name: + description: + - The first name of the local user. + type: str + last_name: + description: + - The last name of the local user. + type: str + user_password: + description: + - The password of the local user. + - Password must have a minimum of 8 characters to a maximum of 64 characters. + - Password must have three of the following; one number, one lower case character, one upper case character, one special character. + - The O(config.user_password) must be defined when creating a new local_user. + type: str + reuse_limitation: + description: + - The number of different passwords a user must use before they can reuse a previous one. + - It defaults to C(0) when unset during creation. + type: int + time_interval_limitation: + description: + - The minimum time period that must pass before a previous password can be reused. + - It defaults to C(0) when unset during creation. + type: int + security_domains: + description: + - The list of Security Domains and Roles for the local user. + - At least, one Security Domain must be defined when creating a new local user. + type: list + elements: dict + suboptions: + name: + description: + - The name of the Security Domain to which the local user is given access. + type: str + required: true + aliases: [ security_domain_name, domain_name ] + roles: + description: + - The Permission Roles of the local user within the Security Domain. + type: list + elements: str + choices: [ fabric_admin, observer, super_admin, support_engineer, approver, designer ] + aliases: [ domains ] + remote_id_claim: + description: + - The remote ID claim of the local user. + type: str + remote_user_authorization: + description: + - To enable/disable the Remote User Authorization of the local user. + - Remote User Authorization is used for signing into Nexus Dashboard when using identity providers that cannot provide authorization claims. + Once this attribute is enabled, the local user ID cannot be used to directly login to Nexus Dashboard. + - It defaults to C(false) when unset during creation. + type: bool + state: + description: + - The desired state of the network resources on the Cisco Nexus Dashboard. + - Use O(state=merged) to create new resources and updates existing ones as defined in your configuration. + Resources on ND that are not specified in the configuration will be left unchanged. + - Use O(state=replaced) to replace the resources specified in the configuration. + - Use O(state=overridden) to enforce the configuration as the single source of truth. + The resources on ND will be modified to exactly match the configuration. + Any resource existing on ND but not present in the configuration will be deleted. Use with extra caution. + - Use O(state=deleted) to remove the resources specified in the configuration from the Cisco Nexus Dashboard. + type: str + default: merged + choices: [ merged, replaced, overridden, deleted ] +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +notes: +- This module is only supported on Nexus Dashboard having version 4.1.0 or higher. +- This module is not idempotent when creating or updating a local user object when O(config.user_password) is used. +""" + +EXAMPLES = r""" +- name: Create a new local user + cisco.nd.nd_local_user: + config: + - email: user@example.com + login_id: local_user + first_name: User first name + last_name: User last name + user_password: localUserPassword1% + reuse_limitation: 20 + time_interval_limitation: 10 + security_domains: + name: all + roles: + - observer + - support_engineer + remote_id_claim: remote_user + remote_user_authorization: true + state: merged + register: result + +- name: Create local user with minimal configuration + cisco.nd.nd_local_user: + config: + - login_id: local_user_min + user_password: localUserMinuser_password + security_domain: all + state: merged + +- name: Update local user + cisco.nd.nd_local_user: + config: + - email: udpateduser@example.com + login_id: local_user + first_name: Updated user first name + last_name: Updated user last name + user_password: updatedLocalUserPassword1% + reuse_limitation: 25 + time_interval_limitation: 15 + security_domains: + - name: all + roles: super_admin + - name: ansible_domain + roles: observer + roles: super_admin + remote_id_claim: "" + remote_user_authorization: false + state: replaced + +- name: Delete a local user + cisco.nd.nd_local_user: + config: + - login_id: local_user + state: deleted +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec, NDModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_network_resources import NDNetworkResourceModule +from ansible_collections.cisco.nd.plugins.module_utils.constants import USER_ROLES_MAPPING + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update( + config=dict( + type="list", + elements="dict", + options=dict( + email=dict(type="str"), + login_id=dict(type="str", required=True), + first_name=dict(type="str"), + last_name=dict(type="str"), + user_password=dict(type="str", no_log=True), + reuse_limitation=dict(type="int"), + time_interval_limitation=dict(type="int"), + security_domains=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True, aliases=["security_domain_name", "domain_name"]), + roles=dict(type="list", elements="str", choices=list(USER_ROLES_MAPPING)), + ), + aliases=["domains"], + ), + remote_id_claim=dict(type="str"), + remote_user_authorization=dict(type="bool"), + ), + ), + state=dict(type="str", default="merged", choices=["merged", "replaced", "overridden", "deleted"]), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + path = "/api/v1/infra/aaa/localUsers" + identifier_key = "loginID" + + nd_init = NDModule(module) + # TODO: find a more flexible way to query all objects when init NDNetworkResourceModule + init_all_data = nd_init.query_obj(path).get("localusers") + nd = NDNetworkResourceModule(module, path, identifier_key, init_all_data) + + state = nd.params.get("state") + config = nd.params.get("config") + + if state == "deleted": + new_config = [{identifier_key: object.get("login_id")} for object in config] + nd.delete_configs(new_configs=new_config) + + else: + new_config = [] + for object in config: + payload = { + "email": object.get("email"), + "firstName": object.get("first_name"), + "lastName": object.get("last_name"), + identifier_key: object.get("login_id"), + "password": object.get("user_password"), + "remoteIDClaim": object.get("remote_id_claim"), + "xLaunch": object.get("remote_user_authorization"), + } + + if object.get("security_domains"): + payload["rbac"] = { + "domains": { + security_domain.get("name"): { + "roles": ( + [USER_ROLES_MAPPING.get(role) for role in security_domain["roles"]] if isinstance(security_domain.get("roles"), list) else [] + ) + } + for security_domain in object["security_domains"] + }, + } + if object.get("reuse_limitation") or object.get("time_interval_limitation"): + payload["passwordPolicy"] = { + "reuseLimitation": object.get("reuse_limitation"), + "timeIntervalLimitation": object.get("time_interval_limitation"), + } + new_config.append(payload) + + if state == "merged": + nd.merge_configs(new_configs=new_config) + elif state == "replaced": + nd.replace_configs(new_configs=new_config) + else: + # TODO: Introduce an attribute to avoid deleting certains critical objects + nd.override_configs(new_configs=new_config, unwanted=["admin", "github_ci"]) + + nd.nrm_exit_json() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml new file mode 100644 index 0000000..072926e --- /dev/null +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -0,0 +1,309 @@ +# Test code for the ND modules +# Copyright: (c) 2025, Gaspard Micol (@gmicol) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Test that we have a Nexus Dashboard host, username and password + ansible.builtin.fail: + msg: 'Please define the following variables: ansible_host, ansible_user and ansible_password.' + when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + +- name: Set vars + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + +- name: Ensure local users do not exist before test starts + cisco.nd.nd_local_user: + <<: *nd_info + config: + - login_id: ansible_local_user + - login_id: ansible_local_user_2 + state: deleted + +# CREATE +- name: Create local users with full and minimum configuration (check mode) + cisco.nd.nd_local_user: &create_local_user + <<: *nd_info + config: + - email: ansibleuser@example.com + login_id: ansible_local_user + first_name: Ansible first name + last_name: Ansible last name + user_password: ansibleLocalUserPassword1% + reuse_limitation: 20 + time_interval_limitation: 10 + security_domains: + - name: all + roles: + - observer + - support_engineer + remote_id_claim: ansible_remote_user + remote_user_authorization: true + - login_id: ansible_local_user_2 + user_password: ansibleLocalUser2Password1% + security_domains: + - name: all + state: merged + check_mode: true + register: cm_create_local_user + +- name: Create local users with full and minimum configuration (normal mode) + cisco.nd.nd_local_user: + <<: *create_local_user + register: nm_create_local_user + +# - name: Verify local user creation tasks +# ansible.builtin.assert: +# that: +# - cm_create_local_user is changed +# - cm_create_local_user.previous == {} +# - cm_create_local_user.current == cm_create_local_user.proposed +# - cm_create_local_user.current.email == "ansibleuser@example.com" +# - cm_create_local_user.current.firstName == "Ansible first name" +# - cm_create_local_user.current.lastName == "Ansible last name" +# - cm_create_local_user.current.loginID == "ansible_local_user" +# - cm_create_local_user.current.passwordPolicy.reuseLimitation == 20 +# - cm_create_local_user.current.passwordPolicy.timeIntervalLimitation == 10 +# - cm_create_local_user.current.rbac.domains.all.roles.0 == "observer" +# - cm_create_local_user.current.rbac.domains.all.roles.1 == "support-engineer" +# - cm_create_local_user.current.remoteIDClaim == "ansible_remote_user" +# - cm_create_local_user.current.xLaunch == true +# - nm_create_local_user is changed +# - nm_create_local_user.previous == {} +# - nm_create_local_user.proposed.email == "ansibleuser@example.com" +# - nm_create_local_user.proposed.firstName == "Ansible first name" +# - nm_create_local_user.proposed.lastName == "Ansible last name" +# - nm_create_local_user.proposed.loginID == "ansible_local_user" +# - nm_create_local_user.proposed.passwordPolicy.reuseLimitation == 20 +# - nm_create_local_user.proposed.passwordPolicy.timeIntervalLimitation == 10 +# - nm_create_local_user.proposed.rbac.domains.all.roles.0 == "observer" +# - nm_create_local_user.proposed.rbac.domains.all.roles.1 == "support-engineer" +# - nm_create_local_user.proposed.remoteIDClaim == "ansible_remote_user" +# - nm_create_local_user.proposed.xLaunch == true +# - nm_create_local_user.current.email == "ansibleuser@example.com" +# - nm_create_local_user.current.firstName == "Ansible first name" +# - nm_create_local_user.current.lastName == "Ansible last name" +# - nm_create_local_user.current.loginID == "ansible_local_user" +# - nm_create_local_user.current.passwordPolicy.reuseLimitation == 20 +# - nm_create_local_user.current.passwordPolicy.timeIntervalLimitation == 10 +# - nm_create_local_user.current.rbac.domains.all.roles.0 == "observer" +# - nm_create_local_user.current.rbac.domains.all.roles.1 == "support-engineer" +# - nm_create_local_user.current.remoteIDClaim == "ansible_remote_user" +# - nm_create_local_user.current.xLaunch == true +# - nm_create_local_user_2 is changed +# - nm_create_local_user_2.previous == {} +# - nm_create_local_user_2.proposed.loginID == "ansible_local_user_2" +# - nm_create_local_user_2.proposed.rbac.domains.all.roles == [] +# - nm_create_local_user_2.current.loginID == "ansible_local_user_2" +# - nm_create_local_user_2.current.rbac.domains.all.roles == [] + +# UPDATE +# TODO: Add overidden operations +- name: Update all ansible_local_user's attributes (check mode) + cisco.nd.nd_local_user: &update_first_local_user + <<: *create_local_user + config: + - email: updatedansibleuser@example.com + first_name: Updated Ansible first name + last_name: Updated Ansible last name + user_password: updatedAnsibleLocalUserPassword1% + reuse_limitation: 25 + time_interval_limitation: 15 + security_domains: + - name: all + roles: super_admin + remote_id_claim: "" + remote_user_authorization: false + state: replaced + check_mode: true + register: cm_update_local_user + +- name: Update local user (normal mode) + cisco.nd.nd_local_user: + <<: *update_first_local_user + register: nm_update_local_user + +- name: Update all ansible_local_user_2's attributes except password + cisco.nd.nd_local_user: &update_second_local_user + <<: *nd_info + config: + - email: secondansibleuser@example.com + login_id: ansible_local_user_2 + first_name: Second Ansible first name + last_name: Second Ansible last name + reuse_limitation: 20 + time_interval_limitation: 10 + security_domains: + - name: all + roles: fabric_admin + remote_id_claim: ansible_remote_user_2 + remote_user_authorization: true + state: merged + register: nm_update_local_user_2 + +- name: Update all ansible_local_user_2's attributes except password again (idempotency) + cisco.nd.nd_local_user: + <<: *update_second_local_user + register: nm_update_local_user_2_again + +# - name: Verify local user update tasks +# ansible.builtin.assert: +# that: +# - cm_update_local_user is changed +# - cm_update_local_user.previous.email == "ansibleuser@example.com" +# - cm_update_local_user.previous.firstName == "Ansible first name" +# - cm_update_local_user.previous.lastName == "Ansible last name" +# - cm_update_local_user.previous.loginID == "ansible_local_user" +# - cm_update_local_user.previous.passwordPolicy.reuseLimitation == 20 +# - cm_update_local_user.previous.passwordPolicy.timeIntervalLimitation == 10 +# - cm_update_local_user.previous.rbac.domains.all.roles.0 == "observer" +# - cm_update_local_user.previous.rbac.domains.all.roles.1 == "support-engineer" +# - cm_update_local_user.previous.remoteIDClaim == "ansible_remote_user" +# - cm_update_local_user.previous.xLaunch == true +# - cm_update_local_user.current == cm_update_local_user.proposed +# - cm_update_local_user.current.email == "updatedansibleuser@example.com" +# - cm_update_local_user.current.firstName == "Updated Ansible first name" +# - cm_update_local_user.current.lastName == "Updated Ansible last name" +# - cm_update_local_user.current.loginID == "ansible_local_user" +# - cm_update_local_user.current.passwordPolicy.reuseLimitation == 25 +# - cm_update_local_user.current.passwordPolicy.timeIntervalLimitation == 15 +# - cm_update_local_user.current.rbac.domains.all.roles.0 == "super-admin" +# - cm_update_local_user.current.remoteIDClaim == "" +# - cm_update_local_user.current.xLaunch == false +# - nm_update_local_user is changed +# - nm_update_local_user.previous.email == "ansibleuser@example.com" +# - nm_update_local_user.previous.firstName == "Ansible first name" +# - nm_update_local_user.previous.lastName == "Ansible last name" +# - nm_update_local_user.previous.loginID == "ansible_local_user" +# - nm_update_local_user.previous.passwordPolicy.reuseLimitation == 20 +# - nm_update_local_user.previous.passwordPolicy.timeIntervalLimitation == 10 +# - nm_update_local_user.previous.rbac.domains.all.roles.0 == "observer" +# - nm_update_local_user.previous.rbac.domains.all.roles.1 == "support-engineer" +# - nm_update_local_user.previous.remoteIDClaim == "ansible_remote_user" +# - nm_update_local_user.previous.xLaunch == true +# - nm_update_local_user.proposed.email == "updatedansibleuser@example.com" +# - nm_update_local_user.proposed.firstName == "Updated Ansible first name" +# - nm_update_local_user.proposed.lastName == "Updated Ansible last name" +# - nm_update_local_user.proposed.loginID == "ansible_local_user" +# - nm_update_local_user.proposed.passwordPolicy.reuseLimitation == 25 +# - nm_update_local_user.proposed.passwordPolicy.timeIntervalLimitation == 15 +# - nm_update_local_user.proposed.rbac.domains.all.roles.0 == "super-admin" +# - nm_update_local_user.proposed.remoteIDClaim == "" +# - nm_update_local_user.proposed.xLaunch == false +# - nm_update_local_user.current.email == "updatedansibleuser@example.com" +# - nm_update_local_user.current.firstName == "Updated Ansible first name" +# - nm_update_local_user.current.lastName == "Updated Ansible last name" +# - nm_update_local_user.current.loginID == "ansible_local_user" +# - nm_update_local_user.current.passwordPolicy.reuseLimitation == 25 +# - nm_update_local_user.current.passwordPolicy.timeIntervalLimitation == 15 +# - nm_update_local_user.current.rbac.domains.all.roles.0 == "super-admin" +# - nm_update_local_user.current.remoteIDClaim == "" +# - nm_update_local_user.current.xLaunch == false +# - nm_update_local_user_2 is changed +# - nm_update_local_user_2.previous.loginID == "ansible_local_user_2" +# - nm_update_local_user_2.previous.passwordPolicy.reuseLimitation == 0 +# - nm_update_local_user_2.previous.passwordPolicy.timeIntervalLimitation == 0 +# - nm_update_local_user_2.previous.rbac.domains.all.roles == [] +# - nm_update_local_user_2.previous.xLaunch == false +# - nm_update_local_user_2.proposed.email == "secondansibleuser@example.com" +# - nm_update_local_user_2.proposed.firstName == "Second Ansible first name" +# - nm_update_local_user_2.proposed.lastName == "Second Ansible last name" +# - nm_update_local_user_2.proposed.loginID == "ansible_local_user_2" +# - nm_update_local_user_2.proposed.passwordPolicy.reuseLimitation == 20 +# - nm_update_local_user_2.proposed.passwordPolicy.timeIntervalLimitation == 10 +# - nm_update_local_user_2.proposed.rbac.domains.all.roles.0 == "fabric-admin" +# - nm_update_local_user_2.proposed.remoteIDClaim == "ansible_remote_user_2" +# - nm_update_local_user_2.proposed.xLaunch == true +# - nm_update_local_user_2.current.email == "secondansibleuser@example.com" +# - nm_update_local_user_2.current.firstName == "Second Ansible first name" +# - nm_update_local_user_2.current.lastName == "Second Ansible last name" +# - nm_update_local_user_2.current.loginID == "ansible_local_user_2" +# - nm_update_local_user_2.current.passwordPolicy.reuseLimitation == 20 +# - nm_update_local_user_2.current.passwordPolicy.timeIntervalLimitation == 10 +# - nm_update_local_user_2.current.rbac.domains.all.roles.0 == "fabric-admin" +# - nm_update_local_user_2.current.remoteIDClaim == "ansible_remote_user_2" +# - nm_update_local_user_2.current.xLaunch == true +# - nm_update_local_user_2_again.previous.email == "secondansibleuser@example.com" +# - nm_update_local_user_2_again.previous.firstName == "Second Ansible first name" +# - nm_update_local_user_2_again.previous.lastName == "Second Ansible last name" +# - nm_update_local_user_2_again.previous.loginID == "ansible_local_user_2" +# - nm_update_local_user_2_again.previous.passwordPolicy.reuseLimitation == 20 +# - nm_update_local_user_2_again.previous.passwordPolicy.timeIntervalLimitation == 10 +# - nm_update_local_user_2_again.previous.rbac.domains.all.roles.0 == "fabric-admin" +# - nm_update_local_user_2_again.previous.remoteIDClaim == "ansible_remote_user_2" +# - nm_update_local_user_2_again.previous.xLaunch == true +# - nm_update_local_user_2_again.proposed.email == "secondansibleuser@example.com" +# - nm_update_local_user_2_again.proposed.firstName == "Second Ansible first name" +# - nm_update_local_user_2_again.proposed.lastName == "Second Ansible last name" +# - nm_update_local_user_2_again.proposed.loginID == "ansible_local_user_2" +# - nm_update_local_user_2_again.proposed.passwordPolicy.reuseLimitation == 20 +# - nm_update_local_user_2_again.proposed.passwordPolicy.timeIntervalLimitation == 10 +# - nm_update_local_user_2_again.proposed.rbac.domains.all.roles.0 == "fabric-admin" +# - nm_update_local_user_2_again.proposed.remoteIDClaim == "ansible_remote_user_2" +# - nm_update_local_user_2_again.proposed.xLaunch == true +# - nm_update_local_user_2_again.current.email == "secondansibleuser@example.com" +# - nm_update_local_user_2_again.current.firstName == "Second Ansible first name" +# - nm_update_local_user_2_again.current.lastName == "Second Ansible last name" +# - nm_update_local_user_2_again.current.loginID == "ansible_local_user_2" +# - nm_update_local_user_2_again.current.passwordPolicy.reuseLimitation == 20 +# - nm_update_local_user_2_again.current.passwordPolicy.timeIntervalLimitation == 10 +# - nm_update_local_user_2_again.current.rbac.domains.all.roles.0 == "fabric-admin" +# - nm_update_local_user_2_again.current.remoteIDClaim == "ansible_remote_user_2" +# - nm_update_local_user_2_again.current.xLaunch == true + + +# DELETE +- name: Delete local user by name (check mode) + cisco.nd.nd_local_user: &delete_local_user + <<: *nd_info + config: + - login_id: ansible_local_user + state: deleted + check_mode: true + register: cm_delete_local_user + +- name: Delete local user by name (normal mode) + cisco.nd.nd_local_user: + <<: *delete_local_user + register: nm_delete_local_user + +- name: Delete local user again (idempotency test) + cisco.nd.nd_local_user: + <<: *delete_local_user + register: nm_delete_local_user_again + +# - name: Verify local user deletion tasks +# ansible.builtin.assert: +# that: +# - cm_delete_local_user is changed +# - cm_delete_local_user.previous.email == "updatedansibleuser@example.com" +# - cm_delete_local_user.previous.firstName == "Updated Ansible first name" +# - cm_delete_local_user.previous.lastName == "Updated Ansible last name" +# - cm_delete_local_user.previous.loginID == "ansible_local_user" +# - cm_delete_local_user.previous.passwordPolicy.reuseLimitation == 25 +# - cm_delete_local_user.previous.passwordPolicy.timeIntervalLimitation == 15 +# - cm_delete_local_user.previous.rbac.domains.all.roles.0 == "super-admin" +# - cm_delete_local_user.previous.xLaunch == false +# - cm_delete_local_user.current == cm_delete_local_user.proposed == {} +# - nm_delete_local_user is changed +# - nm_delete_local_user.previous.email == "updatedansibleuser@example.com" +# - nm_delete_local_user.previous.firstName == "Updated Ansible first name" +# - nm_delete_local_user.previous.lastName == "Updated Ansible last name" +# - nm_delete_local_user.previous.loginID == "ansible_local_user" +# - nm_delete_local_user.previous.passwordPolicy.reuseLimitation == 25 +# - nm_delete_local_user.previous.passwordPolicy.timeIntervalLimitation == 15 +# - nm_delete_local_user.previous.rbac.domains.all.roles.0 == "super-admin" +# - nm_delete_local_user.previous.xLaunch == false +# - nm_delete_local_user.current == nm_delete_local_user.proposed == {} +# - nm_delete_local_user_again is not changed +# - nm_delete_local_user_again.current == nm_delete_local_user_again.proposed == nm_delete_local_user_again.previous == {} + +# CLEAN UP +- name: Ensure local users do not exist + cisco.nd.nd_local_user: + <<: *nd_info + config: + - login_id: ansible_local_user + - login_id: ansible_local_user_2 + state: deleted