From 5c8b04d12a157430186d70ade94b0ca2959e5299 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 2 Mar 2026 13:53:49 -1000 Subject: [PATCH 01/51] [ignore] Add generic logger facility through the Log class and logging config --- plugins/module_utils/common/__init__.py | 0 plugins/module_utils/common/log.py | 465 +++++++++++ plugins/module_utils/logging_config.json | 36 + tests/__init__.py | 0 tests/config.yml | 3 + tests/unit/__init__.py | 0 tests/unit/module_utils/__init__.py | 0 tests/unit/test_log.py | 931 +++++++++++++++++++++++ 8 files changed, 1435 insertions(+) create mode 100644 plugins/module_utils/common/__init__.py create mode 100644 plugins/module_utils/common/log.py create mode 100644 plugins/module_utils/logging_config.json create mode 100644 tests/__init__.py create mode 100644 tests/config.yml create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/module_utils/__init__.py create mode 100644 tests/unit/test_log.py diff --git a/plugins/module_utils/common/__init__.py b/plugins/module_utils/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/common/log.py b/plugins/module_utils/common/log.py new file mode 100644 index 00000000..29182539 --- /dev/null +++ b/plugins/module_utils/common/log.py @@ -0,0 +1,465 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# 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 + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import json +import logging +from enum import Enum +from logging.config import dictConfig +from os import environ +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from ansible.module_utils.basic import AnsibleModule + + +class ValidLogHandlers(str, Enum): + """Valid logging handler classes (must not log to console).""" + + FILE_HANDLER = "logging.FileHandler" + ROTATING_FILE_HANDLER = "logging.handlers.RotatingFileHandler" + TIMED_ROTATING_FILE_HANDLER = "logging.handlers.TimedRotatingFileHandler" + WATCHED_FILE_HANDLER = "logging.handlers.WatchedFileHandler" + + +class Log: + """ + # Summary + + Create the base nd logging object. + + ## Raises + + - `ValueError` if: + - An error is encountered reading the logging config file. + - An error is encountered parsing the logging config file. + - An invalid handler is found in the logging config file. + - Valid handlers are defined in `ValidLogHandlers`. + - No formatters are found in the logging config file that + are associated with the configured handlers. + - `TypeError` if: + - `develop` is not a boolean. + + ## Usage + + By default, Log() does the following: + + 1. Reads the environment variable `ND_LOGGING_CONFIG` to determine + the path to the logging config file. If the environment variable is + not set, then logging is disabled. + 2. Sets `develop` to False. This disables exceptions raised by the + logging module itself. + + Hence, the simplest usage for Log() is: + + - Set the environment variable `ND_LOGGING_CONFIG` to the + path of the logging config file. `bash` shell is used in the + example below. + + ```bash + export ND_LOGGING_CONFIG="/path/to/logging_config.json" + ``` + + - Instantiate a Log() object instance and call `commit()` on the instance: + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log + try: + log = Log() + log.commit() + except ValueError as error: + # handle error + ``` + + To later disable logging, unset the environment variable. + `bash` shell is used in the example below. + + ```bash + unset ND_LOGGING_CONFIG + ``` + + To enable exceptions from the logging module (not recommended, unless needed for + development), set `develop` to True: + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log + try: + log = Log() + log.develop = True + log.commit() + except ValueError as error: + # handle error + ``` + + To directly set the path to the logging config file, overriding the + `ND_LOGGING_CONFIG` environment variable, set the `config` + property prior to calling `commit()`: + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log + try: + log = Log() + log.config = "/path/to/logging_config.json" + log.commit() + except ValueError as error: + # handle error + ``` + + At this point, a base/parent logger is created for which all other + loggers throughout the nd collection will be children. + This allows for a single logging config to be used for all modules in the + collection, and allows for the logging config to be specified in a + single place external to the code. + + ## Example module code using the Log() object + + The `setup_logging()` helper is the recommended way to configure logging in module `main()` functions. + It handles exceptions internally by calling `module.fail_json()`. + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging + + def main(): + module = AnsibleModule(...) + log = setup_logging(module) + + task = AnsibleTask() + ``` + + To enable logging exceptions during development, pass `develop=True`: + + ```python + log = setup_logging(module, develop=True) + ``` + + Alternatively, `Log()` can be used directly when finer control is needed: + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log + + def main(): + try: + log = Log() + log.commit() + except ValueError as error: + ansible_module.fail_json(msg=str(error)) + + task = AnsibleTask() + ``` + + In the AnsibleTask() class (or any other classes running in the + main() function's call stack i.e. classes instantiated in either + main() or in AnsibleTask()). + + ```python + class AnsibleTask: + def __init__(self): + self.class_name = self.__class__.__name__ + self.log = logging.getLogger(f"nd.{self.class_name}") + def some_method(self): + self.log.debug("This is a debug message.") + ``` + + ## Logging Config File + + The logging config file MUST conform to `logging.config.dictConfig` + from Python's standard library and MUST NOT contain any handlers or + that log to stdout or stderr. The logging config file MUST only + contain handlers that log to files. + + An example logging config file is shown below: + + ```json + { + "version": 1, + "formatters": { + "standard": { + "class": "logging.Formatter", + "format": "%(asctime)s - %(levelname)s - [%(name)s.%(funcName)s.%(lineno)d] %(message)s" + } + }, + "handlers": { + "file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "standard", + "level": "DEBUG", + "filename": "/tmp/nd.log", + "mode": "a", + "encoding": "utf-8", + "maxBytes": 50000000, + "backupCount": 4 + } + }, + "loggers": { + "nd": { + "handlers": [ + "file" + ], + "level": "DEBUG", + "propagate": false + } + }, + "root": { + "level": "INFO", + "handlers": [ + "file" + ] + } + } + ``` + """ + + def __init__(self, config: Optional[str] = None, develop: bool = False): + self.class_name = self.__class__.__name__ + # Disable exceptions raised by the logging module. + # Set this to True during development to catch logging errors. + logging.raiseExceptions = False + + self._config: Optional[str] = environ.get("ND_LOGGING_CONFIG", None) + self._develop: bool = False + if config is not None: + self.config = config + self.develop = develop + + def disable_logging(self) -> None: + """ + # Summary + + Disable logging by removing all handlers from the base logger. + + ## Raises + + None + """ + logger = logging.getLogger() + for handler in logger.handlers.copy(): + try: + logger.removeHandler(handler) + except ValueError: # if handler already removed + pass + logger.addHandler(logging.NullHandler()) + logger.propagate = False + + def enable_logging(self) -> None: + """ + # Summary + + Enable logging by reading the logging config file and configuring + the base logger instance. + + ## Raises + - `ValueError` if: + - An error is encountered reading the logging config file. + """ + if self.config is None or self.config.strip() == "": + return + + try: + with open(self.config, "r", encoding="utf-8") as file: + try: + logging_config = json.load(file) + except json.JSONDecodeError as error: + msg = f"error parsing logging config from {self.config}. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + except IOError as error: + msg = f"error reading logging config from {self.config}. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + try: + self._validate_logging_config(logging_config) + except ValueError as error: + raise ValueError(str(error)) from error + + try: + dictConfig(logging_config) + except (RuntimeError, TypeError, ValueError) as error: + msg = "logging.config.dictConfig: " + msg += f"Unable to configure logging from {self.config}. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + def _validate_logging_config(self, logging_config: dict) -> None: + """ + # Summary + + - Validate the logging config file. + - Ensure that the logging config file does not contain any handlers + that log to console, stdout, or stderr. + + ## Raises + + - `ValueError` if: + - The logging config file contains no handlers. + - Any handler's `class` property is not one of the classes + defined in `ValidLogHandlers`. + + ## Usage + + ```python + log = Log() + log.config = "/path/to/logging_config.json" + log.commit() + ``` + """ + msg = "" + if len(logging_config.get("handlers", {})) == 0: + msg = "logging.config.dictConfig: " + msg += "No file handlers found. " + msg += "Add a file handler to the logging config file " + msg += f"and try again: {self.config}" + raise ValueError(msg) + bad_handlers = [] + for handler_name, handler_config in logging_config.get("handlers", {}).items(): + handler_class = handler_config.get("class", "") + if handler_class not in set(ValidLogHandlers): + msg = "logging.config.dictConfig: " + msg += "handlers found that may interrupt Ansible module " + msg += "execution. " + msg += "Remove these handlers from the logging config file " + msg += "and try again. " + bad_handlers.append(handler_name) + if len(bad_handlers) > 0: + msg += f"Handlers: {','.join(bad_handlers)}. " + msg += f"Logging config file: {self.config}." + raise ValueError(msg) + + def commit(self) -> None: + """ + # Summary + + - If `config` is None, disable logging. + - If `config` is a JSON file conformant with + `logging.config.dictConfig` from Python's standard library, read the file and configure the + base logger instance from the file's contents. + + ## Raises + + - `ValueError` if: + - An error is encountered reading the logging config file. + + ## Notes + + 1. If self.config is None, then logging is disabled. + 2. If self.config is a path to a JSON file, then the file is read + and logging is configured from the file. + + ## Usage + + ```python + log = Log() + log.config = "/path/to/logging_config.json" + log.commit() + ``` + """ + if self.config is None: + self.disable_logging() + else: + self.enable_logging() + + @property + def config(self) -> Optional[str]: + """ + ## Summary + + Path to a JSON file from which logging config is read. + JSON file must conform to `logging.config.dictConfig` from Python's + standard library. + + ## Default + + If the environment variable `ND_LOGGING_CONFIG` is set, then + the value of that variable is used. Otherwise, None. + + The environment variable can be overridden by directly setting + `config` to one of the following prior to calling `commit()`: + + 1. None. Logging will be disabled. + 2. Path to a JSON file from which logging config is read. + Must conform to `logging.config.dictConfig` from Python's + standard library. + """ + return self._config + + @config.setter + def config(self, value: Optional[str]) -> None: + self._config = value + + @property + def develop(self) -> bool: + """ + # Summary + + Disable or enable exceptions raised by the logging module. + + ## Default + + `False` + + ## Valid Values + + - `True`: Exceptions will be raised by the logging module. + - `False`: Exceptions will not be raised by the logging module. + """ + return self._develop + + @develop.setter + def develop(self, value: bool) -> None: + method_name = "develop" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: Expected boolean for develop. " + msg += f"Got: type {type(value).__name__} for value {value}." + raise TypeError(msg) + self._develop = value + logging.raiseExceptions = value + + +def setup_logging(module: "AnsibleModule", develop: bool = False) -> Log: + """ + # Summary + + Configure nd collection logging and return the `Log` instance. + + Intended for use in each Ansible module's `main()` function after + `AnsibleModule` is instantiated. + + ## Raises + + None + + ## Notes + + - Calls `module.fail_json()` if logging configuration fails, which + exits the module with an error message rather than raising an exception. + + ## Usage + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging + + def main(): + module = AnsibleModule(...) + log = setup_logging(module) + ``` + + To enable logging exceptions during development, pass `develop=True`: + + ```python + log = setup_logging(module, develop=True) + ``` + """ + try: + log = Log(develop=develop) + log.commit() + except ValueError as error: + module.fail_json(msg=str(error)) + return log diff --git a/plugins/module_utils/logging_config.json b/plugins/module_utils/logging_config.json new file mode 100644 index 00000000..e87ddf05 --- /dev/null +++ b/plugins/module_utils/logging_config.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "formatters": { + "standard": { + "class": "logging.Formatter", + "format": "%(asctime)s - %(levelname)s - [%(name)s.%(funcName)s.%(lineno)d] %(message)s" + } + }, + "handlers": { + "file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "standard", + "level": "DEBUG", + "filename": "/tmp/nd.log", + "mode": "a", + "encoding": "utf-8", + "maxBytes": 50000000, + "backupCount": 4 + } + }, + "loggers": { + "nd": { + "handlers": [ + "file" + ], + "level": "DEBUG", + "propagate": false + } + }, + "root": { + "level": "INFO", + "handlers": [ + "file" + ] + } +} \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/config.yml b/tests/config.yml new file mode 100644 index 00000000..7cf024ab --- /dev/null +++ b/tests/config.yml @@ -0,0 +1,3 @@ +modules: + # Limit Python version to control node Python versions + python_requires: controller diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/module_utils/__init__.py b/tests/unit/module_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py new file mode 100644 index 00000000..96c11346 --- /dev/null +++ b/tests/unit/test_log.py @@ -0,0 +1,931 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for plugins/module_utils/log.py +""" + +# See the following regarding *_fixture imports +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# Due to the above, we also need to disable unused-import +# pylint: disable=unused-import +# Some fixtures need to use *args to match the signature of the function they are mocking +# pylint: disable=unused-argument +# Some tests require calling protected methods +# pylint: disable=protected-access +# pylint: disable=unused-variable +# pylint: disable=line-too-long +# pylint: disable=too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import inspect +import json +import logging +from unittest.mock import MagicMock + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log, setup_logging +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + + +def logging_config(logging_config_file) -> dict: + """ + ### Summary + Return a logging configuration conformant with logging.config.dictConfig. + """ + return { + "version": 1, + "formatters": { + "standard": { + "class": "logging.Formatter", + "format": "%(asctime)s - %(levelname)s - [%(name)s.%(funcName)s.%(lineno)d] %(message)s", + } + }, + "handlers": { + "file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "standard", + "level": "DEBUG", + "filename": logging_config_file, + "mode": "a", + "encoding": "utf-8", + "maxBytes": 500000, + "backupCount": 4, + } + }, + "loggers": {"nd": {"handlers": ["file"], "level": "DEBUG", "propagate": False}}, + "root": {"level": "INFO", "handlers": ["file"]}, + } + + +def test_log_00000(monkeypatch) -> None: + """ + # Summary + + Verify default state of `Log()` when `ND_LOGGING_CONFIG` is not set. + + ## Test + + - `ND_LOGGING_CONFIG` is not set. + - `instance.config` is `None`. + - `instance.develop` is `False`. + - `logging.raiseExceptions` is `False`. + + ## Classes and Methods + + - `Log.__init__()` + """ + monkeypatch.delenv("ND_LOGGING_CONFIG", raising=False) + + with does_not_raise(): + instance = Log() + + assert instance.config is None + assert instance.develop is False + assert logging.raiseExceptions is False + + +def test_log_00010(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify Log().commit() happy path. log. logs to the logfile and the log message contains the calling method's name. + + ## Test + + - Log().commit() is called with a valid logging config. + - log.info(), log.debug(), log.warning(), log.critical() all write to the logfile. + - The log message contains the calling method's name. + + ## Classes and Methods + + - Log().commit() + """ + method_name = inspect.stack()[0][3] + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + instance.commit() + + info_msg = "foo" + debug_msg = "bing" + warning_msg = "bar" + critical_msg = "baz" + log = logging.getLogger("nd.test_logger") + log.info(info_msg) + log.debug(debug_msg) + log.warning(warning_msg) + log.critical(critical_msg) + assert logging.getLevelName(log.getEffectiveLevel()) == "DEBUG" + assert info_msg in log_file.read_text(encoding="UTF-8") + assert debug_msg in log_file.read_text(encoding="UTF-8") + assert warning_msg in log_file.read_text(encoding="UTF-8") + assert critical_msg in log_file.read_text(encoding="UTF-8") + # test that the log message includes the method name + assert method_name in log_file.read_text(encoding="UTF-8") + + +def test_log_00020(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `Log(config=...)` constructor parameter enables logging without setting `ND_LOGGING_CONFIG`. + + ## Test + + - `ND_LOGGING_CONFIG` is not set. + - A valid config path is passed directly to `Log(config=...)`. + - `commit()` succeeds and messages appear in the log file. + + ## Classes and Methods + + - `Log.__init__()` + - `Log.commit()` + """ + monkeypatch.delenv("ND_LOGGING_CONFIG", raising=False) + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + with does_not_raise(): + instance = Log(config=str(config_file)) + instance.commit() + + msg = "hello_from_test_log_00020" + log = logging.getLogger("nd.test_log_00020") + log.info(msg) + assert msg in log_file.read_text(encoding="UTF-8") + + +def test_log_00030(monkeypatch) -> None: + """ + # Summary + + Verify `Log(develop=True)` constructor parameter sets `develop` and `logging.raiseExceptions`. + + ## Test + + - `Log(develop=True)` is instantiated. + - `instance.develop` is `True`. + - `logging.raiseExceptions` is `True`. + + ## Classes and Methods + + - `Log.__init__()` + """ + monkeypatch.delenv("ND_LOGGING_CONFIG", raising=False) + + with does_not_raise(): + instance = Log(develop=True) + + assert instance.develop is True + assert logging.raiseExceptions is True + + +def test_log_00100(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify nothing is logged when ND_LOGGING_CONFIG is not set. + + ## Test + + - ND_LOGGING_CONFIG is not set. + - Log().commit() succeeds. + - No logfile is created. + + ## Classes and Methods + + - Log().commit() + """ + monkeypatch.delenv("ND_LOGGING_CONFIG", raising=False) + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + with does_not_raise(): + instance = Log() + instance.commit() + + info_msg = "foo" + debug_msg = "bing" + warning_msg = "bar" + critical_msg = "baz" + log = logging.getLogger("nd.test_logger") + log.info(info_msg) + log.debug(debug_msg) + log.warning(warning_msg) + log.critical(critical_msg) + # test that nothing was logged (file was not created) + with pytest.raises(FileNotFoundError): + log_file.read_text(encoding="UTF-8") + + +@pytest.mark.parametrize("env_var", [(""), (" ")]) +def test_log_00110(tmp_path, monkeypatch, env_var) -> None: + """ + # Summary + + Verify nothing is logged when ND_LOGGING_CONFIG is set to an empty string or whitespace. + + ## Test + + - ND_LOGGING_CONFIG is set to an empty string or whitespace. + - Log().commit() succeeds. + - No logfile is created. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", env_var) + + with does_not_raise(): + instance = Log() + instance.commit() + + info_msg = "foo" + debug_msg = "bing" + warning_msg = "bar" + critical_msg = "baz" + log = logging.getLogger("nd.test_logger") + log.info(info_msg) + log.debug(debug_msg) + log.warning(warning_msg) + log.critical(critical_msg) + # test that nothing was logged (file was not created) + with pytest.raises(FileNotFoundError): + log_file.read_text(encoding="UTF-8") + + +def test_log_00120(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify nothing is logged when Log().config is set to None, overriding ND_LOGGING_CONFIG. + + ## Test Setup + + - ND_LOGGING_CONFIG is set to a file that exists, which would normally enable logging. + - Log().config is set to None, which overrides ND_LOGGING_CONFIG. + + ## Test + + - Nothing is logged because Log().config overrides ND_LOGGING_CONFIG. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + instance.config = None + instance.commit() + + info_msg = "foo" + debug_msg = "bing" + warning_msg = "bar" + critical_msg = "baz" + log = logging.getLogger("nd.test_logger") + log.info(info_msg) + log.debug(debug_msg) + log.warning(warning_msg) + log.critical(critical_msg) + # test that nothing was logged (file was not created) + with pytest.raises(FileNotFoundError): + log_file.read_text(encoding="UTF-8") + + +def test_log_00130(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `instance.config` set to a file path overrides `ND_LOGGING_CONFIG`, logging to the new file. + + ## Test Setup + + - `ND_LOGGING_CONFIG` points to config A (log file A). + - `instance.config` is set to config B (log file B) after instantiation. + + ## Test + + - Messages appear in log file B, not log file A. + + ## Classes and Methods + + - `Log.config` (setter) + - `Log.commit()` + """ + log_dir_a = tmp_path / "log_dir_a" + log_dir_a.mkdir() + config_file_a = log_dir_a / "logging_config_a.json" + log_file_a = log_dir_a / "nd_a.log" + config_a = logging_config(str(log_file_a)) + with open(config_file_a, "w", encoding="UTF-8") as fp: + json.dump(config_a, fp) + + log_dir_b = tmp_path / "log_dir_b" + log_dir_b.mkdir() + config_file_b = log_dir_b / "logging_config_b.json" + log_file_b = log_dir_b / "nd_b.log" + config_b = logging_config(str(log_file_b)) + with open(config_file_b, "w", encoding="UTF-8") as fp: + json.dump(config_b, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file_a)) + + with does_not_raise(): + instance = Log() + instance.config = str(config_file_b) + instance.commit() + + msg = "hello_from_test_log_00130" + log = logging.getLogger("nd.test_log_00130") + log.info(msg) + assert msg in log_file_b.read_text(encoding="UTF-8") + assert not log_file_a.exists() + + +def test_log_00200(monkeypatch) -> None: + """ + # Summary + + Verify `ValueError` is raised if logging config file does not exist. + + ## Classes and Methods + + - Log().commit() + """ + config_file = "DOES_NOT_EXIST.json" + monkeypatch.setenv("ND_LOGGING_CONFIG", config_file) + + with does_not_raise(): + instance = Log() + + match = rf"error reading logging config from {config_file}\.\s+" + match += r"Error detail:\s+\[Errno 2\]\s+No such file or directory:\s+" + match += rf"\'{config_file}\'" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_log_00210(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `ValueError` is raised if logging config file contains invalid JSON. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump({"BAD": "JSON"}, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + + match = r"logging.config.dictConfig:\s+" + match += r"No file handlers found\.\s+" + match += r"Add a file handler to the logging config file\s+" + match += rf"and try again: {config_file}" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_log_00220(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `ValueError` is raised if logging config file does not contain JSON. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + with open(config_file, "w", encoding="UTF-8") as fp: + fp.write("NOT JSON") + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + + match = rf"error parsing logging config from {config_file}\.\s+" + match += r"Error detail: Expecting value: line 1 column 1 \(char 0\)" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_log_00230(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `ValueError` is raised if logging config file contains handler(s) that emit to non-file destinations. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + config["handlers"]["console"] = { + "class": "logging.StreamHandler", + "formatter": "standard", + "level": "DEBUG", + "stream": "ext://sys.stdout", + } + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + + match = r"logging.config.dictConfig:\s+" + match += r"handlers found that may interrupt Ansible module\s+" + match += r"execution\.\s+" + match += r"Remove these handlers from the logging config file and\s+" + match += r"try again\.\s+" + match += r"Handlers:\s+.*\.\s+" + match += r"Logging config file:\s+.*\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_log_00231(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify no `ValueError` is raised when a handler uses a non-standard name but a valid handler class (e.g. `logging.handlers.RotatingFileHandler`). + + ## Test + + - Previously, validation checked the handler key name rather than the class, so `"my_file_handler"` would have been incorrectly rejected. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + # Rename the handler key from "file" to a non-standard name. + config["handlers"]["my_file_handler"] = config["handlers"].pop("file") + config["loggers"]["nd"]["handlers"] = ["my_file_handler"] + config["root"]["handlers"] = ["my_file_handler"] + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + instance.commit() + + +def test_log_00232(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `ValueError` is raised when a handler is named `"file"` but its `class` property is `logging.StreamHandler`. + + ## Test + + - Previously, validation checked the handler key name rather than the class, so a `StreamHandler` named `"file"` would have been incorrectly accepted. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + # Keep the key name "file" but switch to a disallowed handler class. + config["handlers"]["file"]["class"] = "logging.StreamHandler" + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + + match = r"logging.config.dictConfig:\s+" + match += r"handlers found that may interrupt Ansible module\s+" + match += r"execution\.\s+" + match += r"Remove these handlers from the logging config file and\s+" + match += r"try again\.\s+" + match += r"Handlers:\s+.*\.\s+" + match += r"Logging config file:\s+.*\." + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_log_00233(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `commit()` does not raise when the handler class is `logging.FileHandler`. + + ## Test + + - Config uses `logging.FileHandler` (a valid handler class per `ValidLogHandlers`). + - `commit()` succeeds without raising. + + ## Classes and Methods + + - `Log.commit()` + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + config["handlers"]["file"]["class"] = "logging.FileHandler" + del config["handlers"]["file"]["maxBytes"] + del config["handlers"]["file"]["backupCount"] + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + instance.commit() + + +def test_log_00234(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `commit()` does not raise when the handler class is `logging.handlers.TimedRotatingFileHandler`. + + ## Test + + - Config uses `logging.handlers.TimedRotatingFileHandler` (a valid handler class per `ValidLogHandlers`). + - `commit()` succeeds without raising. + + ## Classes and Methods + + - `Log.commit()` + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + config["handlers"]["file"]["class"] = "logging.handlers.TimedRotatingFileHandler" + config["handlers"]["file"]["when"] = "midnight" + del config["handlers"]["file"]["maxBytes"] + del config["handlers"]["file"]["mode"] + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + instance.commit() + + +def test_log_00235(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `commit()` does not raise when the handler class is `logging.handlers.WatchedFileHandler`. + + ## Test + + - Config uses `logging.handlers.WatchedFileHandler` (a valid handler class per `ValidLogHandlers`). + - `commit()` succeeds without raising. + + ## Classes and Methods + + - `Log.commit()` + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + config["handlers"]["file"]["class"] = "logging.handlers.WatchedFileHandler" + del config["handlers"]["file"]["maxBytes"] + del config["handlers"]["file"]["backupCount"] + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + instance.commit() + + +def test_log_00240(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `ValueError` is raised if logging config file does not contain any handlers. + + ## Notes + + - `test_log_00210` raises the same error message in the case where the logging config file contains JSON that is not conformant with dictConfig. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + del config["handlers"] + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + + match = r"logging.config.dictConfig:\s+" + match += r"No file handlers found\.\s+" + match += r"Add a file handler to the logging config file\s+" + match += rf"and try again: {config_file}" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_log_00250(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `ValueError` is raised if logging config file does not contain any formatters or contains formatters that are not associated with handlers. + + ## Classes and Methods + + - Log().commit() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + del config["formatters"] + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + with does_not_raise(): + instance = Log() + + match = r"logging.config.dictConfig:\s+" + match += r"Unable to configure logging from\s+.*\.\s+" + match += r"Error detail: Unable to configure handler.*" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_log_00300() -> None: + """ + # Summary + + Verify `TypeError` is raised if develop is set to a non-bool. + + ## Classes and Methods + + - Log().develop (setter) + """ + with does_not_raise(): + instance = Log() + + match = r"Log\.develop:\s+" + match += r"Expected boolean for develop\.\s+" + match += r"Got: type str for value FOO\." + with pytest.raises(TypeError, match=match): + instance.develop = "FOO" # type: ignore[assignment] + + +@pytest.mark.parametrize("develop", [(True), (False)]) +def test_log_00310(develop) -> None: + """ + # Summary + + Verify develop is set correctly if passed a bool and no exceptions are raised. + + ## Classes and Methods + + - Log().develop (setter) + """ + with does_not_raise(): + instance = Log() + instance.develop = develop + assert instance.develop == develop + + +@pytest.mark.parametrize("develop", [(True), (False)]) +def test_log_00320(develop) -> None: + """ + # Summary + + Verify `Log.develop` setter side effect: `logging.raiseExceptions` is updated to match `develop`. + + ## Test + + - `instance.develop` is set to `develop`. + - `instance.develop == develop`. + - `logging.raiseExceptions == develop`. + + ## Classes and Methods + + - `Log.develop` (setter) + """ + with does_not_raise(): + instance = Log() + instance.develop = develop + assert instance.develop == develop + assert logging.raiseExceptions == develop + + +def test_setup_logging_00010(tmp_path, monkeypatch) -> None: + """ + # Summary + + Verify `setup_logging()` returns a `Log` instance when the config is valid. + + ## Test + + - `ND_LOGGING_CONFIG` points to a valid logging config file. + - `setup_logging()` returns a `Log` instance. + - `module.fail_json()` is not called. + + ## Classes and Methods + + - setup_logging() + """ + log_dir = tmp_path / "log_dir" + log_dir.mkdir() + config_file = log_dir / "logging_config.json" + log_file = log_dir / "nd.log" + config = logging_config(str(log_file)) + with open(config_file, "w", encoding="UTF-8") as fp: + json.dump(config, fp) + + monkeypatch.setenv("ND_LOGGING_CONFIG", str(config_file)) + + mock_module = MagicMock() + + with does_not_raise(): + result = setup_logging(mock_module) + + assert isinstance(result, Log) + mock_module.fail_json.assert_not_called() + + +@pytest.mark.parametrize("develop", [(True), (False)]) +def test_setup_logging_00040(monkeypatch, develop) -> None: + """ + # Summary + + Verify `setup_logging()` passes `develop` through to the `Log` instance. + + ## Test + + - `ND_LOGGING_CONFIG` is not set. + - `setup_logging()` is called with `develop` set to `True` or `False`. + - `result.develop` matches the value passed. + - `logging.raiseExceptions` matches the value passed. + + ## Classes and Methods + + - `setup_logging()` + """ + monkeypatch.delenv("ND_LOGGING_CONFIG", raising=False) + + mock_module = MagicMock() + + with does_not_raise(): + result = setup_logging(mock_module, develop=develop) + + assert isinstance(result, Log) + assert result.develop is develop + assert logging.raiseExceptions is develop + mock_module.fail_json.assert_not_called() + + +def test_setup_logging_00020(monkeypatch) -> None: + """ + # Summary + + Verify `setup_logging()` calls `module.fail_json()` when the config file does not exist. + + ## Test + + - `ND_LOGGING_CONFIG` points to a nonexistent file. + - `setup_logging()` calls `module.fail_json()` with an error message describing the failure. + + ## Classes and Methods + + - setup_logging() + """ + config_file = "DOES_NOT_EXIST.json" + monkeypatch.setenv("ND_LOGGING_CONFIG", config_file) + + mock_module = MagicMock() + mock_module.fail_json.side_effect = SystemExit + + with pytest.raises(SystemExit): + setup_logging(mock_module) + + mock_module.fail_json.assert_called_once() + call_kwargs = mock_module.fail_json.call_args.kwargs + assert "error reading logging config" in call_kwargs["msg"] + + +def test_setup_logging_00030(monkeypatch) -> None: + """ + # Summary + + Verify `setup_logging()` returns a `Log` instance with logging disabled when `ND_LOGGING_CONFIG` is not set. + + ## Test + + - `ND_LOGGING_CONFIG` is not set. + - `setup_logging()` returns a `Log` instance. + - `module.fail_json()` is not called. + + ## Classes and Methods + + - `setup_logging()` + """ + monkeypatch.delenv("ND_LOGGING_CONFIG", raising=False) + + mock_module = MagicMock() + + with does_not_raise(): + result = setup_logging(mock_module) + + assert isinstance(result, Log) + mock_module.fail_json.assert_not_called() From 756bdccf7c0efbad86dfc77886368f94acec490b Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Wed, 11 Mar 2026 08:46:19 -1000 Subject: [PATCH 02/51] [ignore] Add Endpoints framework for ND API v1 (#186) --- .../module_utils/common/pydantic_compat.py | 243 +++++ plugins/module_utils/endpoints/__init__.py | 0 plugins/module_utils/endpoints/base.py | 134 +++ plugins/module_utils/endpoints/mixins.py | 86 ++ .../module_utils/endpoints/query_params.py | 324 +++++++ plugins/module_utils/endpoints/v1/__init__.py | 0 .../endpoints/v1/infra/__init__.py | 0 .../endpoints/v1/infra/base_path.py | 80 ++ .../v1/infra/clusterhealth_config.py | 120 +++ .../v1/infra/clusterhealth_status.py | 139 +++ .../module_utils/endpoints/v1/infra/login.py | 85 ++ .../endpoints/v1/manage/__init__.py | 0 .../endpoints/v1/manage/base_path.py | 80 ++ plugins/module_utils/enums.py | 158 ++++ tests/unit/module_utils/common_utils.py | 75 ++ .../module_utils/endpoints/test_base_model.py | 245 +++++ .../endpoints/test_base_paths_infra.py | 267 ++++++ .../endpoints/test_base_paths_manage.py | 191 ++++ .../endpoints/test_endpoint_mixins.py | 82 ++ ...st_endpoints_api_v1_infra_clusterhealth.py | 485 ++++++++++ .../test_endpoints_api_v1_infra_login.py | 68 ++ .../endpoints/test_query_params.py | 845 ++++++++++++++++++ 22 files changed, 3707 insertions(+) create mode 100644 plugins/module_utils/common/pydantic_compat.py create mode 100644 plugins/module_utils/endpoints/__init__.py create mode 100644 plugins/module_utils/endpoints/base.py create mode 100644 plugins/module_utils/endpoints/mixins.py create mode 100644 plugins/module_utils/endpoints/query_params.py create mode 100644 plugins/module_utils/endpoints/v1/__init__.py create mode 100644 plugins/module_utils/endpoints/v1/infra/__init__.py create mode 100644 plugins/module_utils/endpoints/v1/infra/base_path.py create mode 100644 plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py create mode 100644 plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py create mode 100644 plugins/module_utils/endpoints/v1/infra/login.py create mode 100644 plugins/module_utils/endpoints/v1/manage/__init__.py create mode 100644 plugins/module_utils/endpoints/v1/manage/base_path.py create mode 100644 plugins/module_utils/enums.py create mode 100644 tests/unit/module_utils/common_utils.py create mode 100644 tests/unit/module_utils/endpoints/test_base_model.py create mode 100644 tests/unit/module_utils/endpoints/test_base_paths_infra.py create mode 100644 tests/unit/module_utils/endpoints/test_base_paths_manage.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoint_mixins.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py create mode 100644 tests/unit/module_utils/endpoints/test_query_params.py diff --git a/plugins/module_utils/common/pydantic_compat.py b/plugins/module_utils/common/pydantic_compat.py new file mode 100644 index 00000000..e1550a18 --- /dev/null +++ b/plugins/module_utils/common/pydantic_compat.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=too-few-public-methods +""" +# Summary + +Pydantic compatibility layer. + +This module provides a single location for Pydantic imports with fallback +implementations when Pydantic is not available. This ensures consistent +behavior across all modules and follows the DRY principle. + +## Usage + +### Importing + +Rather than importing directly from pydantic, import from this module: + +```python +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel +``` + +This ensure that Ansible sanity tests will not fail due to missing Pydantic dependencies. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import traceback +from typing import TYPE_CHECKING, Any, Callable, Union + +if TYPE_CHECKING: + # Type checkers always see the real Pydantic types + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + field_validator, + model_validator, + validator, + ) + + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name +else: + # Runtime: try to import, with fallback + try: + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + field_validator, + model_validator, + validator, + ) + except ImportError: + HAS_PYDANTIC = False # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name + + # Fallback: Minimal BaseModel replacement + class BaseModel: + """Fallback BaseModel when pydantic is not available.""" + + model_config = {"validate_assignment": False, "use_enum_values": False} + + def __init__(self, **kwargs): + """Accept keyword arguments and set them as attributes.""" + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self, exclude_none: bool = False, exclude_defaults: bool = False) -> dict: # pylint: disable=unused-argument + """Return a dictionary of field names and values. + + Args: + exclude_none: If True, exclude fields with None values + exclude_defaults: Accepted for API compatibility but not implemented in fallback + """ + result = {} + for key, value in self.__dict__.items(): + if exclude_none and value is None: + continue + result[key] = value + return result + + # Fallback: ConfigDict that does nothing + def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-name + """Pydantic ConfigDict fallback when pydantic is not available.""" + return kwargs + + # Fallback: Field that does nothing + def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name + """Pydantic Field fallback when pydantic is not available.""" + if "default_factory" in kwargs: + return kwargs["default_factory"]() + return kwargs.get("default") + + # Fallback: field_serializer decorator that does nothing + def field_serializer(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic field_serializer fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: field_validator decorator that does nothing + def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name + """Pydantic field_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: AfterValidator that returns the function unchanged + def AfterValidator(func): # pylint: disable=invalid-name + """Pydantic AfterValidator fallback when pydantic is not available.""" + return func + + # Fallback: BeforeValidator that returns the function unchanged + def BeforeValidator(func): # pylint: disable=invalid-name + """Pydantic BeforeValidator fallback when pydantic is not available.""" + return func + + # Fallback: PydanticExperimentalWarning + PydanticExperimentalWarning = Warning + + # Fallback: StrictBool + StrictBool = bool + + # Fallback: ValidationError + class ValidationError(Exception): + """ + Pydantic ValidationError fallback when pydantic is not available. + """ + + def __init__(self, message="A custom error occurred."): + self.message = message + super().__init__(self.message) + + def __str__(self): + return f"ValidationError: {self.message}" + + # Fallback: model_validator decorator that does nothing + def model_validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic model_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: validator decorator that does nothing + def validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + else: + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name + + +def require_pydantic(module) -> None: + """ + # Summary + + Call `module.fail_json` if pydantic is not installed. + + Intended to be called once at the top of a module's `main()` function, + immediately after `AnsibleModule` is instantiated, to provide a clear + error message when pydantic is a required dependency. + + ## Example + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic + + def main(): + module = AnsibleModule(argument_spec=...) + require_pydantic(module) + ``` + + ## Raises + + None + + ## Notes + + - Does nothing if pydantic is installed. + - Uses Ansible's `missing_required_lib` to produce a standardized error + message that includes installation instructions. + """ + if not HAS_PYDANTIC: + from ansible.module_utils.basic import missing_required_lib # pylint: disable=import-outside-toplevel + + module.fail_json(msg=missing_required_lib("pydantic"), exception=PYDANTIC_IMPORT_ERROR) + + +__all__ = [ + "AfterValidator", + "BaseModel", + "BeforeValidator", + "ConfigDict", + "Field", + "HAS_PYDANTIC", + "PYDANTIC_IMPORT_ERROR", + "PydanticExperimentalWarning", + "StrictBool", + "ValidationError", + "field_serializer", + "field_validator", + "model_validator", + "require_pydantic", + "validator", +] diff --git a/plugins/module_utils/endpoints/__init__.py b/plugins/module_utils/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/endpoints/base.py b/plugins/module_utils/endpoints/base.py new file mode 100644 index 00000000..9da9620e --- /dev/null +++ b/plugins/module_utils/endpoints/base.py @@ -0,0 +1,134 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Base endpoint model for all ND API endpoints. + +Provides ``NDEndpointBaseModel``, the required base class for every +concrete endpoint definition. It centralizes ``model_config``, +version metadata, and enforces that subclasses define ``path``, +``verb``, and ``class_name``. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class NDEndpointBaseModel(BaseModel, ABC): + """ + # Summary + + Abstract base model for all ND API endpoint definitions. + + ## Description + + Centralizes common configuration and version metadata that every endpoint shares. Subclasses **must** define `path`, `verb`, and `class_name`. + + ## Fields (inherited by all endpoints) + + - `api_version` — API version string (default `"v1"`) + - `min_controller_version` — minimum ND controller version (default `"3.0.0"`) + + ## Abstract members (must be defined by subclasses) + + - `path` — `@property` returning the endpoint URL path + - `verb` — `@property` returning the `HttpVerbEnum` for this endpoint + - `class_name` — Pydantic field (typically a `Literal` type) identifying the concrete class + + ## Usage + + ```python + class EpInfraLoginPost(NDEndpointBaseModel): + class_name: Literal["EpInfraLoginPost"] = Field( + default="EpInfraLoginPost", + description="Class name for backward compatibility", + ) + + @property + def path(self) -> str: + return BasePath.path("login") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + ``` + """ + + model_config = ConfigDict(validate_assignment=True) + + def __init_subclass__(cls, **kwargs: object) -> None: + """ + # Summary + + Enforce that concrete subclasses define a `class_name` field. + + ## Description + + Fires at class definition time. Skips abstract subclasses (those with remaining abstract methods) and only checks concrete endpoint classes. + + ## Raises + + ### TypeError + + - If a concrete subclass does not define a `class_name` field in its annotations + """ + super().__init_subclass__(**kwargs) + # Compute abstract methods manually because __abstractmethods__ + # is not yet set on cls when __init_subclass__ fires (ABCMeta + # sets it after type.__new__ returns). + abstracts = {name for name, value in vars(cls).items() if getattr(value, "__isabstractmethod__", False)} + for base in cls.__bases__: + for name in getattr(base, "__abstractmethods__", set()): + if getattr(getattr(cls, name, None), "__isabstractmethod__", False): + abstracts.add(name) + if abstracts: + return + if "class_name" not in getattr(cls, "__annotations__", {}): + raise TypeError( + f"{cls.__name__} must define a 'class_name' field. " + f'Example: class_name: Literal["{cls.__name__}"] = ' + f'Field(default="{cls.__name__}", frozen=True, description="...")' + ) + + # Version metadata — shared by all endpoints + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + @property + @abstractmethod + def path(self) -> str: + """ + # Summary + + Return the endpoint URL path. + + ## Raises + + None + """ + + @property + @abstractmethod + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Return the HTTP verb for this endpoint. + + ## Raises + + None + """ diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py new file mode 100644 index 00000000..47695611 --- /dev/null +++ b/plugins/module_utils/endpoints/mixins.py @@ -0,0 +1,86 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Reusable mixin classes for endpoint models. + +This module provides mixin classes that can be composed to add common +fields to endpoint models without duplication. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + Field, +) + + +class ClusterNameMixin(BaseModel): + """Mixin for endpoints that require cluster_name parameter.""" + + cluster_name: Optional[str] = Field(default=None, min_length=1, description="Cluster name") + + +class FabricNameMixin(BaseModel): + """Mixin for endpoints that require fabric_name parameter.""" + + fabric_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Fabric name") + + +class ForceShowRunMixin(BaseModel): + """Mixin for endpoints that require force_show_run parameter.""" + + force_show_run: BooleanStringEnum = Field(default=BooleanStringEnum.FALSE, description="Force show running config") + + +class HealthCategoryMixin(BaseModel): + """Mixin for endpoints that require health_category parameter.""" + + health_category: Optional[str] = Field(default=None, min_length=1, description="Health category") + + +class InclAllMsdSwitchesMixin(BaseModel): + """Mixin for endpoints that require incl_all_msd_switches parameter.""" + + incl_all_msd_switches: BooleanStringEnum = Field(default=BooleanStringEnum.FALSE, description="Include all MSD switches") + + +class LinkUuidMixin(BaseModel): + """Mixin for endpoints that require link_uuid parameter.""" + + link_uuid: Optional[str] = Field(default=None, min_length=1, description="Link UUID") + + +class LoginIdMixin(BaseModel): + """Mixin for endpoints that require login_id parameter.""" + + login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") + + +class NetworkNameMixin(BaseModel): + """Mixin for endpoints that require network_name parameter.""" + + network_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Network name") + + +class NodeNameMixin(BaseModel): + """Mixin for endpoints that require node_name parameter.""" + + node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") + + +class SwitchSerialNumberMixin(BaseModel): + """Mixin for endpoints that require switch_sn parameter.""" + + switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") + + +class VrfNameMixin(BaseModel): + """Mixin for endpoints that require vrf_name parameter.""" + + vrf_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="VRF name") diff --git a/plugins/module_utils/endpoints/query_params.py b/plugins/module_utils/endpoints/query_params.py new file mode 100644 index 00000000..5bf8ff08 --- /dev/null +++ b/plugins/module_utils/endpoints/query_params.py @@ -0,0 +1,324 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Query parameter classes for API endpoints. + +This module provides composable query parameter classes for building +URL query strings. Supports endpoint-specific parameters and Lucene-style +filtering with type safety via Pydantic. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from enum import Enum +from typing import Optional, Protocol +from urllib.parse import quote + + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + Field, + field_validator, +) + + +class QueryParams(Protocol): + """ + # Summary + + Protocol for Query Parameters + + ## Description + + Structural type for all query parameter types. Any class implementing `to_query_string()` and `is_empty()` satisfies this protocol without explicit + inheritance. + + ## Design + + This allows composition of different query parameter types: + + - Endpoint-specific parameters (e.g., forceShowRun, ticketId) + - Generic Lucene-style filtering (e.g., filter, max, sort) + - Future parameter types can be added without changing existing code + """ + + def to_query_string(self) -> str: + """ + # Summary + + Convert parameters to URL query string format. + + ## Returns + + - Query string (without leading '?') + - Empty string if no parameters are set + + ### Example return value + + ```python + "forceShowRun=true&ticketId=12345" + ``` + """ + # pylint: disable=unnecessary-ellipsis + ... + + def is_empty(self) -> bool: + """ + # Summary + + Check if any parameters are set. + + ## Returns + + - True if no parameters are set + - False if at least one parameter is set + """ + # pylint: disable=unnecessary-ellipsis + ... + + +class EndpointQueryParams(BaseModel): + """ + # Summary + + Endpoint-Specific Query Parameters + + ## Description + + Query parameters specific to a particular endpoint. + These are typed and validated by Pydantic. + + ## Usage + + Subclass this for each endpoint that needs custom query parameters: + + ```python + class ConfigDeployQueryParams(EndpointQueryParams): + force_show_run: bool = False + include_all_msd_switches: bool = False + + def to_query_string(self) -> str: + params = [f"forceShowRun={str(self.force_show_run).lower()}"] + params.append(f"inclAllMSDSwitches={str(self.include_all_msd_switches).lower()}") + return "&".join(params) + ``` + """ + + def to_query_string(self) -> str: + """ + # Summary + + - Default implementation: convert all fields to key=value pairs. + - Override this method for custom formatting. + """ + params = [] + for field_name, field_value in self.model_dump(exclude_none=True).items(): + # Convert snake_case to camelCase for API compatibility + api_key = self._to_camel_case(field_name) + + # Handle different value types + if isinstance(field_value, bool): + api_value = str(field_value).lower() + elif isinstance(field_value, Enum): + # Get the enum's value (e.g., "true" or "false") + api_value = field_value.value + else: + api_value = str(field_value) + + params.append(f"{api_key}={api_value}") + return "&".join(params) + + @staticmethod + def _to_camel_case(snake_str: str) -> str: + """Convert snake_case to camelCase.""" + components = snake_str.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + def is_empty(self) -> bool: + """Check if any parameters are set.""" + return len(self.model_dump(exclude_none=True, exclude_defaults=True)) == 0 + + +class LuceneQueryParams(BaseModel): + """ + # Summary + + Lucene-Style Query Parameters + + ## Description + + Generic Lucene-style filtering query parameters for ND API. + Supports filtering, pagination, and sorting. + + ## Parameters + + - filter: Lucene filter expression (e.g., "name:MyFabric AND state:deployed") + - max: Maximum number of results to return + - offset: Offset for pagination + - sort: Sort field and direction (e.g., "name:asc", "created:desc") + - fields: Comma-separated list of fields to return + + ## Usage + + ```python + lucene = LuceneQueryParams( + filter="name:Fabric*", + max=100, + sort="name:asc" + ) + query_string = lucene.to_query_string() + # Returns: "filter=name:Fabric*&max=100&sort=name:asc" + ``` + + ## Lucene Filter Examples + + - Single field: `name:MyFabric` + - Wildcard: `name:Fabric*` + - Multiple conditions: `name:MyFabric AND state:deployed` + - Range: `created:[2024-01-01 TO 2024-12-31]` + - OR conditions: `state:deployed OR state:pending` + - NOT conditions: `NOT state:deleted` + """ + + filter: Optional[str] = Field(default=None, description="Lucene filter expression") + max: Optional[int] = Field(default=None, ge=1, le=10000, description="Maximum results") + offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") + sort: Optional[str] = Field(default=None, description="Sort field and direction (e.g., 'name:asc')") + fields: Optional[str] = Field(default=None, description="Comma-separated list of fields to return") + + @field_validator("sort") + @classmethod + def _validate_sort(cls, value): + """Validate sort format: field:direction.""" + if value is not None and ":" in value: + parts = value.split(":") + if len(parts) == 2 and parts[1].lower() not in ["asc", "desc"]: + raise ValueError("Sort direction must be 'asc' or 'desc'") + return value + + def to_query_string(self, url_encode: bool = True) -> str: + """ + Convert to URL query string format. + + ### Parameters + - url_encode: If True, URL-encode parameter values (default: True) + + ### Returns + - URL query string with encoded values + """ + params = [] + for field_name, field_value in self.model_dump(exclude_none=True).items(): + if field_value is not None: + # URL-encode the value if requested + encoded_value = quote(str(field_value), safe="") if url_encode else str(field_value) + params.append(f"{field_name}={encoded_value}") + return "&".join(params) + + def is_empty(self) -> bool: + """Check if any filter parameters are set.""" + return not self.model_dump(exclude_none=True) + + +class CompositeQueryParams: + """ + # Summary + + Composite Query Parameters + + ## Description + + Composes multiple query parameter types into a single query string. + This allows combining endpoint-specific parameters with Lucene filtering. + + ## Design Pattern + + Uses composition to combine different query parameter types without + inheritance. Each parameter type can be independently configured and tested. + + ## Usage + + ```python + # Endpoint-specific params + endpoint_params = ConfigDeployQueryParams( + force_show_run=True, + include_all_msd_switches=False + ) + + # Lucene filtering params + lucene_params = LuceneQueryParams( + filter="name:MySwitch*", + max=50, + sort="name:asc" + ) + + # Compose them together + composite = CompositeQueryParams() + composite.add(endpoint_params) + composite.add(lucene_params) + + query_string = composite.to_query_string() + # Returns: "forceShowRun=true&inclAllMSDSwitches=false&filter=name:MySwitch*&max=50&sort=name:asc" + ``` + """ + + def __init__(self) -> None: + self._param_groups: list[QueryParams] = [] + + def add(self, params: QueryParams) -> "CompositeQueryParams": + """ + # Summary + + Add a query parameter group to the composite. + + ## Parameters + + - params: Any object satisfying the `QueryParams` protocol + + ## Returns + + - Self (for method chaining) + + ## Example + + ```python + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + ``` + """ + self._param_groups.append(params) + return self + + def to_query_string(self, url_encode: bool = True) -> str: + """ + # Summary + + Build complete query string from all parameter groups. + + ## Parameters + + - url_encode: If True, URL-encode parameter values (default: True) + + ## Returns + + - Complete query string (without leading '?') + - Empty string if no parameters are set + """ + parts = [] + for param_group in self._param_groups: + if not param_group.is_empty(): + # LuceneQueryParams supports url_encode parameter, EndpointQueryParams doesn't + if isinstance(param_group, LuceneQueryParams): + parts.append(param_group.to_query_string(url_encode=url_encode)) + else: + parts.append(param_group.to_query_string()) + return "&".join(parts) + + def is_empty(self) -> bool: + """Check if any parameters are set across all groups.""" + return all(param_group.is_empty() for param_group in self._param_groups) + + def clear(self) -> None: + """Remove all parameter groups.""" + self._param_groups.clear() diff --git a/plugins/module_utils/endpoints/v1/__init__.py b/plugins/module_utils/endpoints/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/endpoints/v1/infra/__init__.py b/plugins/module_utils/endpoints/v1/infra/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/endpoints/v1/infra/base_path.py b/plugins/module_utils/endpoints/v1/infra/base_path.py new file mode 100644 index 00000000..f0612025 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/base_path.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Centralized base paths for ND Infra API endpoints. + +/api/v1/infra + +This module provides a single location to manage all API Infra base paths, +allowing easy modification when API paths change. All endpoint classes +should use these path builders for consistency. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + + +class BasePath: + """ + # Summary + + API Endpoints for ND Infra + + ## Description + + Provides centralized endpoint definitions for all ND Infra API endpoints. + This allows API path changes to be managed in a single location. + + ## Usage + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.base_path import BasePath + + # Get a complete base path for ND Infra + path = BasePath.path("aaa", "localUsers") + # Returns: /api/v1/infra/aaa/localUsers + ``` + + ## Design Notes + + - All base paths are defined as class constants for easy modification + - Helper methods compose paths from base constants + - Use these methods in Pydantic endpoint models to ensure consistency + - If ND Infra changes base API paths, only this class needs updating + """ + + API: Final = "/api/v1/infra" + + @classmethod + def path(cls, *segments: str) -> str: + """ + # Summary + + Build ND infra API path. + + ## Parameters + + - segments: Path segments to append after /api/v1/infra + + ## Returns + + - Complete ND infra API path + + ## Example + + ```python + path = BasePath.path("aaa", "localUsers") + # Returns: /api/v1/infra/aaa/localUsers + ``` + """ + if not segments: + return cls.API + return f"{cls.API}/{'/'.join(segments)}" diff --git a/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py b/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py new file mode 100644 index 00000000..607cea39 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py @@ -0,0 +1,120 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra ClusterHealth endpoint models. + +This module contains endpoint definitions for clusterhealth-related operations +in the ND Infra API. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ClusterNameMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class ClusterHealthConfigEndpointParams(ClusterNameMixin, EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for cluster health config endpoint. + + ## Parameters + + - cluster_name: Cluster name (optional, from `ClusterNameMixin`) + + ## Usage + + ```python + params = ClusterHealthConfigEndpointParams(cluster_name="my-cluster") + query_string = params.to_query_string() + # Returns: "clusterName=my-cluster" + ``` + """ + + +class EpInfraClusterhealthConfigGet(NDEndpointBaseModel): + """ + # Summary + + ND Infra ClusterHealth Config GET Endpoint + + ## Description + + Endpoint to retrieve cluster health configuration from the ND Infra service. + Optionally filter by cluster name using the clusterName query parameter. + + ## Path + + - /api/v1/infra/clusterhealth/config + - /api/v1/infra/clusterhealth/config?clusterName=foo + + ## Verb + + - GET + + ## Usage + + ```python + # Get cluster health config for all clusters + request = EpApiV1InfraClusterhealthConfigGet() + path = request.path + verb = request.verb + + # Get cluster health config for specific cluster + request = EpApiV1InfraClusterhealthConfigGet() + request.endpoint_params.cluster_name = "foo" + path = request.path + verb = request.verb + # Path will be: /api/v1/infra/clusterhealth/config?clusterName=foo + ``` + """ + + class_name: Literal["EpInfraClusterhealthConfigGet"] = Field( + default="EpInfraClusterhealthConfigGet", frozen=True, description="Class name for backward compatibility" + ) + + endpoint_params: ClusterHealthConfigEndpointParams = Field( + default_factory=ClusterHealthConfigEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with optional query string. + + ## Returns + + - Complete endpoint path string, optionally including query parameters + """ + base_path = BasePath.path("clusterhealth", "config") + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py b/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py new file mode 100644 index 00000000..52e6cc14 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py @@ -0,0 +1,139 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra ClusterHealth endpoint models. + +This module contains endpoint definitions for clusterhealth-related operations +in the ND Infra API. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ClusterNameMixin, + HealthCategoryMixin, + NodeNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class ClusterHealthStatusEndpointParams(ClusterNameMixin, HealthCategoryMixin, NodeNameMixin, EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for cluster health status endpoint. + + ## Parameters + + - cluster_name: Cluster name (optional, from `ClusterNameMixin`) + - health_category: Health category (optional, from `HealthCategoryMixin`) + - node_name: Node name (optional, from `NodeNameMixin`) + + ## Usage + + ```python + params = ClusterHealthStatusEndpointParams( + cluster_name="my-cluster", + health_category="cpu", + node_name="node1" + ) + query_string = params.to_query_string() + # Returns: "clusterName=my-cluster&healthCategory=cpu&nodeName=node1" + ``` + """ + + +class EpInfraClusterhealthStatusGet(NDEndpointBaseModel): + """ + # Summary + + ND Infra ClusterHealth Status GET Endpoint + + ## Description + + Endpoint to retrieve cluster health status from the ND Infra service. + Optionally filter by cluster name, health category, and/or node name using query parameters. + + ## Path + + - /api/v1/infra/clusterhealth/status + - /api/v1/infra/clusterhealth/status?clusterName=foo + - /api/v1/infra/clusterhealth/status?clusterName=foo&healthCategory=bar&nodeName=baz + + ## Verb + + - GET + + ## Usage + + ```python + # Get cluster health status for all clusters + request = EpApiV1InfraClusterhealthStatusGet() + path = request.path + verb = request.verb + + # Get cluster health status for specific cluster + request = EpApiV1InfraClusterhealthStatusGet() + request.endpoint_params.cluster_name = "foo" + path = request.path + verb = request.verb + + # Get cluster health status with all filters + request = EpApiV1InfraClusterhealthStatusGet() + request.endpoint_params.cluster_name = "foo" + request.endpoint_params.health_category = "bar" + request.endpoint_params.node_name = "baz" + path = request.path + verb = request.verb + # Path will be: /api/v1/infra/clusterhealth/status?clusterName=foo&healthCategory=bar&nodeName=baz + ``` + """ + + class_name: Literal["EpInfraClusterhealthStatusGet"] = Field( + default="EpInfraClusterhealthStatusGet", frozen=True, description="Class name for backward compatibility" + ) + + endpoint_params: ClusterHealthStatusEndpointParams = Field( + default_factory=ClusterHealthStatusEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path with optional query string. + + ## Returns + + - Complete endpoint path string, optionally including query parameters + """ + base_path = BasePath.path("clusterhealth", "status") + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/infra/login.py b/plugins/module_utils/endpoints/v1/infra/login.py new file mode 100644 index 00000000..70d894d4 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/login.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra Login endpoint model. + +This module contains the endpoint definition for the ND Infra login operation. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class EpInfraLoginPost(NDEndpointBaseModel): + """ + # Summary + + ND Infra Login POST Endpoint + + ## Description + + Endpoint to authenticate against the ND Infra login service. + + ## Path + + - /api/v1/infra/login + + ## Verb + + - POST + + ## Usage + + ```python + request = EpInfraLoginPost() + path = request.path + verb = request.verb + ``` + + ## Raises + + None + """ + + class_name: Literal["EpInfraLoginPost"] = Field(default="EpInfraLoginPost", frozen=True, description="Class name for backward compatibility") + + @property + def path(self) -> str: + """ + # Summary + + Return the endpoint path. + + ## Returns + + - Complete endpoint path string + + ## Raises + + None + """ + return BasePath.path("login") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/manage/__init__.py b/plugins/module_utils/endpoints/v1/manage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/endpoints/v1/manage/base_path.py b/plugins/module_utils/endpoints/v1/manage/base_path.py new file mode 100644 index 00000000..5f043ced --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/base_path.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Centralized base paths for ND Manage API endpoints. + +/api/v1/manage + +This module provides a single location to manage all API Manage base paths, +allowing easy modification when API paths change. All endpoint classes +should use these path builders for consistency. +""" + +from __future__ import absolute_import, annotations, division, print_function + + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + + +class BasePath: + """ + # Summary + + API Endpoints for ND Manage + + ## Description + + Provides centralized endpoint definitions for all ND Manage API endpoints. + This allows API path changes to be managed in a single location. + + ## Usage + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath + + # Get a complete base path for ND Manage + path = BasePath.path("inventory", "switches") + # Returns: /api/v1/manage/inventory/switches + ``` + + ## Design Notes + + - All base paths are defined as class constants for easy modification + - Helper methods compose paths from base constants + - Use these methods in Pydantic endpoint models to ensure consistency + - If ND Manage changes base API paths, only this class needs updating + """ + + API: Final = "/api/v1/manage" + + @classmethod + def path(cls, *segments: str) -> str: + """ + # Summary + + Build ND manage API path. + + ## Parameters + + - segments: Path segments to append after /api/v1/manage + + ## Returns + + - Complete ND manage API path + + ## Example + + ```python + path = BasePath.path("inventory", "switches") + # Returns: /api/v1/manage/inventory/switches + ``` + """ + if not segments: + return cls.API + return f"{cls.API}/{'/'.join(segments)}" diff --git a/plugins/module_utils/enums.py b/plugins/module_utils/enums.py new file mode 100644 index 00000000..55d1f1ac --- /dev/null +++ b/plugins/module_utils/enums.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +Enum definitions for Nexus Dashboard Ansible modules. + +## Enums + +- HttpVerbEnum: Enum for HTTP verb values used in endpoints. +- OperationType: Enum for operation types used by Results to determine if changes have occurred. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from enum import Enum + + +class BooleanStringEnum(str, Enum): + """ + # Summary + + Enum for boolean string values used in query parameters. + + ## Members + + - TRUE: Represents the string "true". + - FALSE: Represents the string "false". + """ + + TRUE = "true" + FALSE = "false" + + +class HttpVerbEnum(str, Enum): + """ + # Summary + + Enum for HTTP verb values used in endpoints. + + ## Members + + - GET: Represents the HTTP GET method. + - POST: Represents the HTTP POST method. + - PUT: Represents the HTTP PUT method. + - DELETE: Represents the HTTP DELETE method. + - PATCH: Represents the HTTP PATCH method. + """ + + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + PATCH = "PATCH" + + @classmethod + def values(cls) -> list[str]: + """ + # Summary + + Returns a list of all enum values. + + ## Returns + + - A list of string values representing the enum members. + """ + return sorted([member.value for member in cls]) + + +class OperationType(Enum): + """ + # Summary + + Enumeration for operation types. + + Used by Results to determine if changes have occurred based on the operation type. + + - QUERY: Represents a query operation which does not change state. + - CREATE: Represents a create operation which adds new resources. + - UPDATE: Represents an update operation which modifies existing resources. + - DELETE: Represents a delete operation which removes resources. + + # Usage + + ```python + from plugins.module_utils.enums import OperationType + class MyModule: + def __init__(self): + self.operation_type = OperationType.QUERY + ``` + + The above informs the Results class that the current operation is a query, and thus + no changes should be expected. + + Specifically, Results._determine_if_changed() will return False for QUERY operations, + while it will evaluate CREATE, UPDATE, and DELETE operations in more detail to + determine if any changes have occurred. + """ + + QUERY = "query" + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + def changes_state(self) -> bool: + """ + # Summary + + Return True if this operation type can change controller state. + + ## Returns + + - `bool`: True if operation can change state, False otherwise + + ## Examples + + ```python + OperationType.QUERY.changes_state() # Returns False + OperationType.CREATE.changes_state() # Returns True + OperationType.DELETE.changes_state() # Returns True + ``` + """ + return self in ( + OperationType.CREATE, + OperationType.UPDATE, + OperationType.DELETE, + ) + + def is_read_only(self) -> bool: + """ + # Summary + + Return True if this operation type is read-only. + + ## Returns + + - `bool`: True if operation is read-only, False otherwise + + ## Examples + + ```python + OperationType.QUERY.is_read_only() # Returns True + OperationType.CREATE.is_read_only() # Returns False + ``` + """ + return self == OperationType.QUERY diff --git a/tests/unit/module_utils/common_utils.py b/tests/unit/module_utils/common_utils.py new file mode 100644 index 00000000..bc64b0d6 --- /dev/null +++ b/tests/unit/module_utils/common_utils.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Common utilities used by unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from contextlib import contextmanager + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.log import Log +from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator +from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender as SenderFile + +params = { + "state": "merged", + "config": {"switches": [{"ip_address": "172.22.150.105"}]}, + "check_mode": False, +} + + +# See the following for explanation of why fixtures are explicitely named +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# @pytest.fixture(name="controller_version") +# def controller_version_fixture(): +# """ +# return ControllerVersion instance. +# """ +# return ControllerVersion() +@pytest.fixture(name="sender_file") +def sender_file_fixture(): + """ + return Send() imported from sender_file.py + """ + + def responses(): + yield {} + + instance = SenderFile() + instance.gen = ResponseGenerator(responses()) + return instance + + +@pytest.fixture(name="log") +def log_fixture(): + """ + return Log instance + """ + return Log() + + +@contextmanager +def does_not_raise(): + """ + A context manager that does not raise an exception. + """ + yield + + +def responses_sender_file(key: str) -> dict[str, str]: + """ + Return data in responses_SenderFile.json + """ + response_file = "responses_SenderFile" + response = load_fixture(response_file).get(key) + print(f"responses_sender_file: {key} : {response}") + return response diff --git a/tests/unit/module_utils/endpoints/test_base_model.py b/tests/unit/module_utils/endpoints/test_base_model.py new file mode 100644 index 00000000..e2db13be --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_model.py @@ -0,0 +1,245 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for NDEndpointBaseModel.__init_subclass__() + +Tests the class_name enforcement logic that ensures concrete +subclasses of NDEndpointBaseModel define a class_name field. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +# pylint: disable=unused-import +# pylint: disable=unused-variable +# pylint: disable=missing-function-docstring +# pylint: disable=missing-class-docstring +# pylint: disable=too-few-public-methods + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: __init_subclass__ — concrete subclass with class_name +# ============================================================================= + + +def test_base_model_00100(): + """ + # Summary + + Verify a concrete subclass with `class_name` defined is accepted. + + ## Test + + - Concrete subclass defines `class_name`, `path`, and `verb` + - Class definition succeeds without error + - Instance can be created and `class_name` is correct + + ## Classes and Methods + + - NDEndpointBaseModel.__init_subclass__() + """ + + class _GoodEndpoint(NDEndpointBaseModel): + class_name: Literal["_GoodEndpoint"] = Field(default="_GoodEndpoint", frozen=True, description="Class name") + + @property + def path(self) -> str: + return "/api/v1/test/good" + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + with does_not_raise(): + instance = _GoodEndpoint() + assert instance.class_name == "_GoodEndpoint" + + +# ============================================================================= +# Test: __init_subclass__ — concrete subclass missing class_name +# ============================================================================= + + +def test_base_model_00200(): + """ + # Summary + + Verify a concrete subclass without `class_name` raises `TypeError` at class definition time. + + ## Test + + - Concrete subclass defines `path` and `verb` but omits `class_name` + - `TypeError` is raised when the class is defined (not when instantiated) + + ## Classes and Methods + + - NDEndpointBaseModel.__init_subclass__() + """ + match = r"_BadEndpoint must define a 'class_name' field" + with pytest.raises(TypeError, match=match): + + class _BadEndpoint(NDEndpointBaseModel): + + @property + def path(self) -> str: + return "/api/v1/test/bad" + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +# ============================================================================= +# Test: __init_subclass__ — intermediate abstract subclass skipped +# ============================================================================= + + +def test_base_model_00300(): + """ + # Summary + + Verify an intermediate abstract subclass without `class_name` is allowed. + + ## Test + + - Intermediate ABC adds a new abstract method but does not define `class_name` + - No `TypeError` is raised at class definition time + - A concrete subclass of the intermediate ABC with `class_name` can be instantiated + + ## Classes and Methods + + - NDEndpointBaseModel.__init_subclass__() + """ + + class _MiddleABC(NDEndpointBaseModel, ABC): + + @property + @abstractmethod + def extra(self) -> str: + """Return extra info.""" + + class _ConcreteFromMiddle(_MiddleABC): + class_name: Literal["_ConcreteFromMiddle"] = Field(default="_ConcreteFromMiddle", frozen=True, description="Class name") + + @property + def path(self) -> str: + return "/api/v1/test/middle" + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + @property + def extra(self) -> str: + return "extra" + + with does_not_raise(): + instance = _ConcreteFromMiddle() + assert instance.class_name == "_ConcreteFromMiddle" + assert instance.extra == "extra" + + +# ============================================================================= +# Test: __init_subclass__ — concrete subclass of intermediate ABC missing class_name +# ============================================================================= + + +def test_base_model_00310(): + """ + # Summary + + Verify a concrete subclass of an intermediate ABC without `class_name` raises `TypeError`. + + ## Test + + - Intermediate ABC adds a new abstract method + - Concrete subclass implements all abstract methods but omits `class_name` + - `TypeError` is raised at class definition time + + ## Classes and Methods + + - NDEndpointBaseModel.__init_subclass__() + """ + + class _MiddleABC2(NDEndpointBaseModel, ABC): + + @property + @abstractmethod + def extra(self) -> str: + """Return extra info.""" + + match = r"_BadConcreteFromMiddle must define a 'class_name' field" + with pytest.raises(TypeError, match=match): + + class _BadConcreteFromMiddle(_MiddleABC2): + + @property + def path(self) -> str: + return "/api/v1/test/bad-middle" + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + @property + def extra(self) -> str: + return "extra" + + +# ============================================================================= +# Test: __init_subclass__ — error message includes example +# ============================================================================= + + +def test_base_model_00400(): + """ + # Summary + + Verify the `TypeError` message includes a helpful example with the class name. + + ## Test + + - Concrete subclass omits `class_name` + - Error message contains the class name in the `Literal` and `Field` example + + ## Classes and Methods + + - NDEndpointBaseModel.__init_subclass__() + """ + with pytest.raises(TypeError, match=r'Literal\["_ExampleEndpoint"\]') as exc_info: + + class _ExampleEndpoint(NDEndpointBaseModel): + + @property + def path(self) -> str: + return "/api/v1/test/example" + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + assert "_ExampleEndpoint" in str(exc_info.value) + assert "frozen=True" in str(exc_info.value) diff --git a/tests/unit/module_utils/endpoints/test_base_paths_infra.py b/tests/unit/module_utils/endpoints/test_base_paths_infra.py new file mode 100644 index 00000000..e25c4a4a --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_paths_infra.py @@ -0,0 +1,267 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for base_paths_infra.py + +Tests the BasePath class methods for building ND Infra API paths +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: BasePath.API constant +# ============================================================================= + + +def test_base_paths_infra_00010(): + """ + # Summary + + Verify API constant equals "/api/v1/infra" + + ## Test + + - BasePath.API equals "/api/v1/infra" + + ## Classes and Methods + + - BasePath.API + """ + with does_not_raise(): + result = BasePath.API + assert result == "/api/v1/infra" + + +# ============================================================================= +# Test: path() method +# ============================================================================= + + +def test_base_paths_infra_00100(): + """ + # Summary + + Verify path() with no segments returns API root + + ## Test + + - path() returns "/api/v1/infra" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path() + assert result == "/api/v1/infra" + + +def test_base_paths_infra_00110(): + """ + # Summary + + Verify path() with single segment + + ## Test + + - path("aaa") returns "/api/v1/infra/aaa" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("aaa") + assert result == "/api/v1/infra/aaa" + + +def test_base_paths_infra_00120(): + """ + # Summary + + Verify path() with multiple segments + + ## Test + + - path("aaa", "localUsers") returns "/api/v1/infra/aaa/localUsers" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("aaa", "localUsers") + assert result == "/api/v1/infra/aaa/localUsers" + + +def test_base_paths_infra_00130(): + """ + # Summary + + Verify path() with three segments + + ## Test + + - path("aaa", "localUsers", "user1") returns correct path + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("aaa", "localUsers", "user1") + assert result == "/api/v1/infra/aaa/localUsers/user1" + + +def test_base_paths_infra_00140(): + """ + # Summary + + Verify path() builds clusterhealth paths + + ## Test + + - path("clusterhealth") returns "/api/v1/infra/clusterhealth" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("clusterhealth") + assert result == "/api/v1/infra/clusterhealth" + + +def test_base_paths_infra_00150(): + """ + # Summary + + Verify path() builds clusterhealth config path + + ## Test + + - path("clusterhealth", "config") returns "/api/v1/infra/clusterhealth/config" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("clusterhealth", "config") + assert result == "/api/v1/infra/clusterhealth/config" + + +def test_base_paths_infra_00160(): + """ + # Summary + + Verify path() builds clusterhealth status path + + ## Test + + - path("clusterhealth", "status") returns "/api/v1/infra/clusterhealth/status" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("clusterhealth", "status") + assert result == "/api/v1/infra/clusterhealth/status" + + +def test_base_paths_infra_00170(): + """ + # Summary + + Verify path() builds clusterhealth path with multiple segments + + ## Test + + - path("clusterhealth", "config", "cluster1") returns correct path + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("clusterhealth", "config", "cluster1") + assert result == "/api/v1/infra/clusterhealth/config/cluster1" + + +# ============================================================================= +# Test: Edge cases +# ============================================================================= + + +def test_base_paths_infra_00500(): + """ + # Summary + + Verify empty string segment is handled + + ## Test + + - path("aaa", "", "localUsers") creates path with empty segment + - This creates double slashes (expected behavior) + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("aaa", "", "localUsers") + assert result == "/api/v1/infra/aaa//localUsers" + + +def test_base_paths_infra_00510(): + """ + # Summary + + Verify segments with special characters + + ## Test + + - path("aaa", "user-name_123") handles hyphens and underscores + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("aaa", "user-name_123") + assert result == "/api/v1/infra/aaa/user-name_123" + + +def test_base_paths_infra_00520(): + """ + # Summary + + Verify segments with spaces (no URL encoding) + + ## Test + + - BasePath does not URL-encode spaces + - URL encoding is caller's responsibility + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("my path") + assert result == "/api/v1/infra/my path" diff --git a/tests/unit/module_utils/endpoints/test_base_paths_manage.py b/tests/unit/module_utils/endpoints/test_base_paths_manage.py new file mode 100644 index 00000000..07fdd892 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_paths_manage.py @@ -0,0 +1,191 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for base_paths_manage.py + +Tests the BasePath class methods for building ND Manage API paths +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: BasePath.API constant +# ============================================================================= + + +def test_base_paths_manage_00010(): + """ + # Summary + + Verify API constant equals "/api/v1/manage" + + ## Test + + - BasePath.API equals "/api/v1/manage" + + ## Classes and Methods + + - BasePath.API + """ + with does_not_raise(): + result = BasePath.API + assert result == "/api/v1/manage" + + +# ============================================================================= +# Test: path() method +# ============================================================================= + + +def test_base_paths_manage_00100(): + """ + # Summary + + Verify path() with no segments returns API root + + ## Test + + - path() returns "/api/v1/manage" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path() + assert result == "/api/v1/manage" + + +def test_base_paths_manage_00110(): + """ + # Summary + + Verify path() with single segment + + ## Test + + - path("inventory") returns "/api/v1/manage/inventory" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("inventory") + assert result == "/api/v1/manage/inventory" + + +def test_base_paths_manage_00120(): + """ + # Summary + + Verify path() with multiple segments + + ## Test + + - path("inventory", "switches") returns "/api/v1/manage/inventory/switches" + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("inventory", "switches") + assert result == "/api/v1/manage/inventory/switches" + + +def test_base_paths_manage_00130(): + """ + # Summary + + Verify path() with three segments + + ## Test + + - path("inventory", "switches", "fabric1") returns correct path + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("inventory", "switches", "fabric1") + assert result == "/api/v1/manage/inventory/switches/fabric1" + + +# ============================================================================= +# Test: Edge cases +# ============================================================================= + + +def test_base_paths_manage_00400(): + """ + # Summary + + Verify empty string segment is handled + + ## Test + + - path("inventory", "", "switches") creates path with empty segment + - This creates double slashes (expected behavior) + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("inventory", "", "switches") + assert result == "/api/v1/manage/inventory//switches" + + +def test_base_paths_manage_00410(): + """ + # Summary + + Verify segments with special characters + + ## Test + + - path("inventory", "fabric-name_123") handles hyphens and underscores + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("inventory", "fabric-name_123") + assert result == "/api/v1/manage/inventory/fabric-name_123" + + +def test_base_paths_manage_00420(): + """ + # Summary + + Verify segments with spaces (no URL encoding) + + ## Test + + - BasePath does not URL-encode spaces + - URL encoding is caller's responsibility + + ## Classes and Methods + + - BasePath.path() + """ + with does_not_raise(): + result = BasePath.path("my path") + assert result == "/api/v1/manage/my path" diff --git a/tests/unit/module_utils/endpoints/test_endpoint_mixins.py b/tests/unit/module_utils/endpoints/test_endpoint_mixins.py new file mode 100644 index 00000000..f122d29a --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoint_mixins.py @@ -0,0 +1,82 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for endpoint_mixins.py + +Tests the mixin classes for endpoint models. +Only tests that verify our configuration constraints or our design +patterns (composition) are included. Simple default/getter/setter tests +are omitted as they test Pydantic itself, not our code. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + ForceShowRunMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: Validation constraints +# ============================================================================= + + +def test_endpoint_mixins_00220(): + """ + # Summary + + Verify FabricNameMixin validates max length + + ## Test + + - fabric_name rejects strings longer than 64 characters + + ## Classes and Methods + + - FabricNameMixin.fabric_name + """ + long_name = "a" * 65 # 65 characters + with pytest.raises(ValueError): + FabricNameMixin(fabric_name=long_name) + + +# ============================================================================= +# Test: Mixin composition +# ============================================================================= + + +def test_endpoint_mixins_01100(): + """ + # Summary + + Verify mixins can be composed together + + ## Test + + - Multiple mixins can be combined in a single class + + ## Classes and Methods + + - FabricNameMixin + - ForceShowRunMixin + """ + + # Create a composite class using multiple mixins + class CompositeParams(FabricNameMixin, ForceShowRunMixin): + pass + + with does_not_raise(): + instance = CompositeParams(fabric_name="MyFabric", force_show_run=BooleanStringEnum.TRUE) + assert instance.fabric_name == "MyFabric" + assert instance.force_show_run == BooleanStringEnum.TRUE diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py new file mode 100644 index 00000000..e4a3be8e --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py @@ -0,0 +1,485 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for ep_api_v1_infra_clusterhealth.py + +Tests the ND Infra ClusterHealth endpoint classes +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.clusterhealth_config import ( + ClusterHealthConfigEndpointParams, + EpInfraClusterhealthConfigGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.clusterhealth_status import ( + ClusterHealthStatusEndpointParams, + EpInfraClusterhealthStatusGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: ClusterHealthConfigEndpointParams +# ============================================================================= + + +def test_endpoints_clusterhealth_00010(): + """ + # Summary + + Verify ClusterHealthConfigEndpointParams default values + + ## Test + + - cluster_name defaults to None + + ## Classes and Methods + + - ClusterHealthConfigEndpointParams.__init__() + """ + with does_not_raise(): + params = ClusterHealthConfigEndpointParams() + assert params.cluster_name is None + + +def test_endpoints_clusterhealth_00020(): + """ + # Summary + + Verify ClusterHealthConfigEndpointParams cluster_name can be set + + ## Test + + - cluster_name can be set to a string value + + ## Classes and Methods + + - ClusterHealthConfigEndpointParams.__init__() + """ + with does_not_raise(): + params = ClusterHealthConfigEndpointParams(cluster_name="my-cluster") + assert params.cluster_name == "my-cluster" + + +def test_endpoints_clusterhealth_00030(): + """ + # Summary + + Verify ClusterHealthConfigEndpointParams generates correct query string + + ## Test + + - to_query_string() returns correct format with cluster_name + + ## Classes and Methods + + - ClusterHealthConfigEndpointParams.to_query_string() + """ + with does_not_raise(): + params = ClusterHealthConfigEndpointParams(cluster_name="test-cluster") + result = params.to_query_string() + assert result == "clusterName=test-cluster" + + +def test_endpoints_clusterhealth_00040(): + """ + # Summary + + Verify ClusterHealthConfigEndpointParams empty query string + + ## Test + + - to_query_string() returns empty string when no params set + + ## Classes and Methods + + - ClusterHealthConfigEndpointParams.to_query_string() + """ + with does_not_raise(): + params = ClusterHealthConfigEndpointParams() + result = params.to_query_string() + assert result == "" + + +# ============================================================================= +# Test: ClusterHealthStatusEndpointParams +# ============================================================================= + + +def test_endpoints_clusterhealth_00100(): + """ + # Summary + + Verify ClusterHealthStatusEndpointParams default values + + ## Test + + - All parameters default to None + + ## Classes and Methods + + - ClusterHealthStatusEndpointParams.__init__() + """ + with does_not_raise(): + params = ClusterHealthStatusEndpointParams() + assert params.cluster_name is None + assert params.health_category is None + assert params.node_name is None + + +def test_endpoints_clusterhealth_00110(): + """ + # Summary + + Verify ClusterHealthStatusEndpointParams all params can be set + + ## Test + + - All three parameters can be set + + ## Classes and Methods + + - ClusterHealthStatusEndpointParams.__init__() + """ + with does_not_raise(): + params = ClusterHealthStatusEndpointParams(cluster_name="cluster1", health_category="cpu", node_name="node1") + assert params.cluster_name == "cluster1" + assert params.health_category == "cpu" + assert params.node_name == "node1" + + +def test_endpoints_clusterhealth_00120(): + """ + # Summary + + Verify ClusterHealthStatusEndpointParams query string with all params + + ## Test + + - to_query_string() returns correct format with all parameters + + ## Classes and Methods + + - ClusterHealthStatusEndpointParams.to_query_string() + """ + with does_not_raise(): + params = ClusterHealthStatusEndpointParams(cluster_name="foo", health_category="bar", node_name="baz") + result = params.to_query_string() + assert set(result.split("&")) == {"clusterName=foo", "healthCategory=bar", "nodeName=baz"} + + +def test_endpoints_clusterhealth_00130(): + """ + # Summary + + Verify ClusterHealthStatusEndpointParams query string with partial params + + ## Test + + - to_query_string() only includes set parameters + + ## Classes and Methods + + - ClusterHealthStatusEndpointParams.to_query_string() + """ + with does_not_raise(): + params = ClusterHealthStatusEndpointParams(cluster_name="foo", node_name="baz") + result = params.to_query_string() + assert set(result.split("&")) == {"clusterName=foo", "nodeName=baz"} + + +# ============================================================================= +# Test: EpInfraClusterhealthConfigGet +# ============================================================================= + + +def test_endpoints_clusterhealth_00200(): + """ + # Summary + + Verify EpInfraClusterhealthConfigGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpInfraClusterhealthConfigGet.__init__() + - EpInfraClusterhealthConfigGet.verb + - EpInfraClusterhealthConfigGet.class_name + """ + with does_not_raise(): + instance = EpInfraClusterhealthConfigGet() + assert instance.class_name == "EpInfraClusterhealthConfigGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_clusterhealth_00210(): + """ + # Summary + + Verify EpInfraClusterhealthConfigGet path without params + + ## Test + + - path returns base path when no query params are set + + ## Classes and Methods + + - EpInfraClusterhealthConfigGet.path + """ + with does_not_raise(): + instance = EpInfraClusterhealthConfigGet() + result = instance.path + assert result == "/api/v1/infra/clusterhealth/config" + + +def test_endpoints_clusterhealth_00220(): + """ + # Summary + + Verify EpInfraClusterhealthConfigGet path with cluster_name + + ## Test + + - path includes query string when cluster_name is set + + ## Classes and Methods + + - EpInfraClusterhealthConfigGet.path + - EpInfraClusterhealthConfigGet.endpoint_params + """ + with does_not_raise(): + instance = EpInfraClusterhealthConfigGet() + instance.endpoint_params.cluster_name = "my-cluster" + result = instance.path + assert result == "/api/v1/infra/clusterhealth/config?clusterName=my-cluster" + + +def test_endpoints_clusterhealth_00230(): + """ + # Summary + + Verify EpInfraClusterhealthConfigGet params at instantiation + + ## Test + + - endpoint_params can be provided during instantiation + + ## Classes and Methods + + - EpInfraClusterhealthConfigGet.__init__() + """ + with does_not_raise(): + params = ClusterHealthConfigEndpointParams(cluster_name="test-cluster") + instance = EpInfraClusterhealthConfigGet(endpoint_params=params) + assert instance.endpoint_params.cluster_name == "test-cluster" + assert instance.path == "/api/v1/infra/clusterhealth/config?clusterName=test-cluster" + + +# ============================================================================= +# Test: EpInfraClusterhealthStatusGet +# ============================================================================= + + +def test_endpoints_clusterhealth_00300(): + """ + # Summary + + Verify EpInfraClusterhealthStatusGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpInfraClusterhealthStatusGet.__init__() + - EpInfraClusterhealthStatusGet.verb + - EpInfraClusterhealthStatusGet.class_name + """ + with does_not_raise(): + instance = EpInfraClusterhealthStatusGet() + assert instance.class_name == "EpInfraClusterhealthStatusGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_clusterhealth_00310(): + """ + # Summary + + Verify EpInfraClusterhealthStatusGet path without params + + ## Test + + - path returns base path when no query params are set + + ## Classes and Methods + + - EpInfraClusterhealthStatusGet.path + """ + with does_not_raise(): + instance = EpInfraClusterhealthStatusGet() + result = instance.path + assert result == "/api/v1/infra/clusterhealth/status" + + +def test_endpoints_clusterhealth_00320(): + """ + # Summary + + Verify EpInfraClusterhealthStatusGet path with single param + + ## Test + + - path includes query string with cluster_name + + ## Classes and Methods + + - EpInfraClusterhealthStatusGet.path + - EpInfraClusterhealthStatusGet.endpoint_params + """ + with does_not_raise(): + instance = EpInfraClusterhealthStatusGet() + instance.endpoint_params.cluster_name = "foo" + result = instance.path + assert result == "/api/v1/infra/clusterhealth/status?clusterName=foo" + + +def test_endpoints_clusterhealth_00330(): + """ + # Summary + + Verify EpInfraClusterhealthStatusGet path with all params + + ## Test + + - path includes query string with all parameters + + ## Classes and Methods + + - EpInfraClusterhealthStatusGet.path + - EpInfraClusterhealthStatusGet.endpoint_params + """ + with does_not_raise(): + instance = EpInfraClusterhealthStatusGet() + instance.endpoint_params.cluster_name = "foo" + instance.endpoint_params.health_category = "bar" + instance.endpoint_params.node_name = "baz" + result = instance.path + base, query = result.split("?", 1) + assert base == "/api/v1/infra/clusterhealth/status" + assert set(query.split("&")) == {"clusterName=foo", "healthCategory=bar", "nodeName=baz"} + + +def test_endpoints_clusterhealth_00340(): + """ + # Summary + + Verify EpInfraClusterhealthStatusGet with partial params + + ## Test + + - path only includes set parameters in query string + + ## Classes and Methods + + - EpInfraClusterhealthStatusGet.path + """ + with does_not_raise(): + instance = EpInfraClusterhealthStatusGet() + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.node_name = "node1" + result = instance.path + base, query = result.split("?", 1) + assert base == "/api/v1/infra/clusterhealth/status" + assert set(query.split("&")) == {"clusterName=cluster1", "nodeName=node1"} + + +# ============================================================================= +# Test: Pydantic validation +# ============================================================================= + + +def test_endpoints_clusterhealth_00400(): + """ + # Summary + + Verify Pydantic validation for empty string + + ## Test + + - Empty string is rejected for cluster_name (min_length=1) + + ## Classes and Methods + + - ClusterHealthConfigEndpointParams.__init__() + """ + with pytest.raises(ValueError): + ClusterHealthConfigEndpointParams(cluster_name="") + + +def test_endpoints_clusterhealth_00410(): + """ + # Summary + + Verify parameters can be modified after instantiation + + ## Test + + - endpoint_params can be changed after object creation + + ## Classes and Methods + + - EpInfraClusterhealthConfigGet.endpoint_params + """ + with does_not_raise(): + instance = EpInfraClusterhealthConfigGet() + assert instance.path == "/api/v1/infra/clusterhealth/config" + + instance.endpoint_params.cluster_name = "new-cluster" + assert instance.path == "/api/v1/infra/clusterhealth/config?clusterName=new-cluster" + + +def test_endpoints_clusterhealth_00420(): + """ + # Summary + + Verify snake_case to camelCase conversion + + ## Test + + - cluster_name converts to clusterName in query string + - health_category converts to healthCategory + - node_name converts to nodeName + + ## Classes and Methods + + - ClusterHealthStatusEndpointParams.to_query_string() + """ + with does_not_raise(): + params = ClusterHealthStatusEndpointParams(cluster_name="test", health_category="cpu", node_name="node1") + result = params.to_query_string() + # Verify camelCase conversion + assert "clusterName=" in result + assert "healthCategory=" in result + assert "nodeName=" in result + # Verify no snake_case + assert "cluster_name" not in result + assert "health_category" not in result + assert "node_name" not in result diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py new file mode 100644 index 00000000..b3b88a1b --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py @@ -0,0 +1,68 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for infra_login.py + +Tests the ND Infra Login endpoint class +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.login import ( + EpInfraLoginPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + + +def test_endpoints_api_v1_infra_login_00010(): + """ + # Summary + + Verify EpInfraLoginPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpInfraLoginPost.__init__() + - EpInfraLoginPost.class_name + - EpInfraLoginPost.verb + """ + with does_not_raise(): + instance = EpInfraLoginPost() + assert instance.class_name == "EpInfraLoginPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_infra_login_00020(): + """ + # Summary + + Verify EpInfraLoginPost path + + ## Test + + - path returns /api/v1/infra/login + + ## Classes and Methods + + - EpInfraLoginPost.path + """ + with does_not_raise(): + instance = EpInfraLoginPost() + result = instance.path + assert result == "/api/v1/infra/login" diff --git a/tests/unit/module_utils/endpoints/test_query_params.py b/tests/unit/module_utils/endpoints/test_query_params.py new file mode 100644 index 00000000..03500336 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_query_params.py @@ -0,0 +1,845 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for query_params.py + +Tests the query parameter composition classes +""" + +# pylint: disable=protected-access + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + CompositeQueryParams, + EndpointQueryParams, + LuceneQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Helper test class for EndpointQueryParams +# ============================================================================= + + +class SampleEndpointParams(EndpointQueryParams): + """Sample implementation of EndpointQueryParams for testing.""" + + force_show_run: BooleanStringEnum | None = Field(default=None) + fabric_name: str | None = Field(default=None) + switch_count: int | None = Field(default=None) + + +# ============================================================================= +# Test: EndpointQueryParams +# ============================================================================= + + +def test_query_params_00010(): + """ + # Summary + + Verify EndpointQueryParams default implementation + + ## Test + + - to_query_string() returns empty string when no params set + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams() + result = params.to_query_string() + # Only non-None, non-default values are included + assert result == "" + + +def test_query_params_00020(): + """ + # Summary + + Verify EndpointQueryParams snake_case to camelCase conversion + + ## Test + + - force_show_run converts to forceShowRun + - fabric_name converts to fabricName + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + - EndpointQueryParams._to_camel_case() + """ + with does_not_raise(): + params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Fabric1") + result = params.to_query_string() + assert "forceShowRun=" in result + assert "fabricName=" in result + # Verify no snake_case + assert "force_show_run" not in result + assert "fabric_name" not in result + + +def test_query_params_00030(): + """ + # Summary + + Verify EndpointQueryParams handles Enum values + + ## Test + + - BooleanStringEnum.TRUE converts to "true" + - BooleanStringEnum.FALSE converts to "false" + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE) + result = params.to_query_string() + assert "forceShowRun=true" in result + + +def test_query_params_00040(): + """ + # Summary + + Verify EndpointQueryParams handles integer values + + ## Test + + - Integer values are converted to strings + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(switch_count=42) + result = params.to_query_string() + assert result == "switchCount=42" + + +def test_query_params_00050(): + """ + # Summary + + Verify EndpointQueryParams handles string values + + ## Test + + - String values are included as-is + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(fabric_name="MyFabric") + result = params.to_query_string() + assert result == "fabricName=MyFabric" + + +def test_query_params_00060(): + """ + # Summary + + Verify EndpointQueryParams handles multiple params + + ## Test + + - Multiple parameters are joined with '&' + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Fabric1", switch_count=10) + result = params.to_query_string() + assert "forceShowRun=true" in result + assert "fabricName=Fabric1" in result + assert "switchCount=10" in result + assert result.count("&") == 2 + + +def test_query_params_00070(): + """ + # Summary + + Verify EndpointQueryParams is_empty() method + + ## Test + + - is_empty() returns True when no params set + - is_empty() returns False when params are set + + ## Classes and Methods + + - EndpointQueryParams.is_empty() + """ + with does_not_raise(): + params = SampleEndpointParams() + assert params.is_empty() is True + + params.fabric_name = "Fabric1" + assert params.is_empty() is False + + +def test_query_params_00080(): + """ + # Summary + + Verify EndpointQueryParams _to_camel_case() static method + + ## Test + + - Correctly converts various snake_case strings to camelCase + + ## Classes and Methods + + - EndpointQueryParams._to_camel_case() + """ + with does_not_raise(): + assert EndpointQueryParams._to_camel_case("simple") == "simple" + assert EndpointQueryParams._to_camel_case("snake_case") == "snakeCase" + assert EndpointQueryParams._to_camel_case("long_snake_case_name") == "longSnakeCaseName" + assert EndpointQueryParams._to_camel_case("single") == "single" + + +# ============================================================================= +# Test: LuceneQueryParams +# ============================================================================= + + +def test_query_params_00100(): + """ + # Summary + + Verify LuceneQueryParams default values + + ## Test + + - All parameters default to None + + ## Classes and Methods + + - LuceneQueryParams.__init__() + """ + with does_not_raise(): + params = LuceneQueryParams() + assert params.filter is None + assert params.max is None + assert params.offset is None + assert params.sort is None + assert params.fields is None + + +def test_query_params_00110(): + """ + # Summary + + Verify LuceneQueryParams filter parameter + + ## Test + + - filter can be set to a string value + - to_query_string() includes filter parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:MyFabric") + result = params.to_query_string() + assert "filter=" in result + assert "name" in result + assert "MyFabric" in result + + +def test_query_params_00120(): + """ + # Summary + + Verify LuceneQueryParams max parameter + + ## Test + + - max can be set to an integer value + - to_query_string() includes max parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(max=100) + result = params.to_query_string() + assert result == "max=100" + + +def test_query_params_00130(): + """ + # Summary + + Verify LuceneQueryParams offset parameter + + ## Test + + - offset can be set to an integer value + - to_query_string() includes offset parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(offset=20) + result = params.to_query_string() + assert result == "offset=20" + + +def test_query_params_00140(): + """ + # Summary + + Verify LuceneQueryParams sort parameter + + ## Test + + - sort can be set to a valid string + - to_query_string() includes sort parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(sort="name:asc") + result = params.to_query_string() + assert "sort=" in result + assert "name" in result + + +def test_query_params_00150(): + """ + # Summary + + Verify LuceneQueryParams fields parameter + + ## Test + + - fields can be set to a comma-separated string + - to_query_string() includes fields parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(fields="name,id,status") + result = params.to_query_string() + assert "fields=" in result + + +def test_query_params_00160(): + """ + # Summary + + Verify LuceneQueryParams URL encoding + + ## Test + + - Special characters in filter are URL-encoded by default + + ## Classes and Methods + + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:Fabric* AND status:active") + result = params.to_query_string(url_encode=True) + # Check for URL-encoded characters + assert "filter=" in result + # Space should be encoded + assert "%20" in result or "+" in result + + +def test_query_params_00170(): + """ + # Summary + + Verify LuceneQueryParams URL encoding can be disabled + + ## Test + + - url_encode=False preserves special characters + + ## Classes and Methods + + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:Fabric* AND status:active") + result = params.to_query_string(url_encode=False) + assert result == "filter=name:Fabric* AND status:active" + + +def test_query_params_00180(): + """ + # Summary + + Verify LuceneQueryParams is_empty() method + + ## Test + + - is_empty() returns True when no params set + - is_empty() returns False when params are set + + ## Classes and Methods + + - LuceneQueryParams.is_empty() + """ + with does_not_raise(): + params = LuceneQueryParams() + assert params.is_empty() is True + + params.max = 100 + assert params.is_empty() is False + + +def test_query_params_00190(): + """ + # Summary + + Verify LuceneQueryParams multiple parameters + + ## Test + + - Multiple parameters are joined with '&' + - Parameters appear in expected order + + ## Classes and Methods + + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:*", max=50, offset=10, sort="name:asc") + result = params.to_query_string(url_encode=False) + assert "filter=name:*" in result + assert "max=50" in result + assert "offset=10" in result + assert "sort=name:asc" in result + + +# ============================================================================= +# Test: LuceneQueryParams validation +# ============================================================================= + + +def test_query_params_00200(): + """ + # Summary + + Verify LuceneQueryParams validates max range + + ## Test + + - max must be >= 1 + - max must be <= 10000 + + ## Classes and Methods + + - LuceneQueryParams.__init__() + """ + # Valid values + with does_not_raise(): + LuceneQueryParams(max=1) + LuceneQueryParams(max=10000) + LuceneQueryParams(max=500) + + # Invalid values + with pytest.raises(ValueError): + LuceneQueryParams(max=0) + + with pytest.raises(ValueError): + LuceneQueryParams(max=10001) + + +def test_query_params_00210(): + """ + # Summary + + Verify LuceneQueryParams validates offset range + + ## Test + + - offset must be >= 0 + + ## Classes and Methods + + - LuceneQueryParams.__init__() + """ + # Valid values + with does_not_raise(): + LuceneQueryParams(offset=0) + LuceneQueryParams(offset=100) + + # Invalid values + with pytest.raises(ValueError): + LuceneQueryParams(offset=-1) + + +def test_query_params_00220(): + """ + # Summary + + Verify LuceneQueryParams validates sort format + + ## Test + + - sort direction must be 'asc' or 'desc' + - Invalid directions are rejected + + ## Classes and Methods + + - LuceneQueryParams.validate_sort() + """ + # Valid values + with does_not_raise(): + LuceneQueryParams(sort="name:asc") + LuceneQueryParams(sort="name:desc") + LuceneQueryParams(sort="name:ASC") + LuceneQueryParams(sort="name:DESC") + + # Invalid direction + with pytest.raises(ValueError, match="Sort direction must be"): + LuceneQueryParams(sort="name:invalid") + + +def test_query_params_00230(): + """ + # Summary + + Verify LuceneQueryParams allows sort without direction + + ## Test + + - sort can be set without ':' separator + - Validation only applies when ':' is present + + ## Classes and Methods + + - LuceneQueryParams.validate_sort() + """ + with does_not_raise(): + params = LuceneQueryParams(sort="name") + result = params.to_query_string(url_encode=False) + assert result == "sort=name" + + +# ============================================================================= +# Test: CompositeQueryParams +# ============================================================================= + + +def test_query_params_00300(): + """ + # Summary + + Verify CompositeQueryParams basic instantiation + + ## Test + + - Instance can be created + - Starts with empty parameter groups + + ## Classes and Methods + + - CompositeQueryParams.__init__() + """ + with does_not_raise(): + composite = CompositeQueryParams() + assert composite.is_empty() is True + + +def test_query_params_00310(): + """ + # Summary + + Verify CompositeQueryParams add() method + + ## Test + + - Can add EndpointQueryParams + - Returns self for method chaining + + ## Classes and Methods + + - CompositeQueryParams.add() + """ + with does_not_raise(): + composite = CompositeQueryParams() + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + result = composite.add(endpoint_params) + assert result is composite + assert composite.is_empty() is False + + +def test_query_params_00320(): + """ + # Summary + + Verify CompositeQueryParams add() with LuceneQueryParams + + ## Test + + - Can add LuceneQueryParams + - Parameters are combined correctly + + ## Classes and Methods + + - CompositeQueryParams.add() + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + composite = CompositeQueryParams() + lucene_params = LuceneQueryParams(max=100) + composite.add(lucene_params) + result = composite.to_query_string() + assert result == "max=100" + + +def test_query_params_00330(): + """ + # Summary + + Verify CompositeQueryParams method chaining + + ## Test + + - Multiple add() calls can be chained + - All parameters are included in final query string + + ## Classes and Methods + + - CompositeQueryParams.add() + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + lucene_params = LuceneQueryParams(max=50) + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + result = composite.to_query_string() + assert "fabricName=Fabric1" in result + assert "max=50" in result + + +def test_query_params_00340(): + """ + # Summary + + Verify CompositeQueryParams parameter ordering + + ## Test + + - Parameters appear in order they were added + - EndpointQueryParams before LuceneQueryParams + + ## Classes and Methods + + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + lucene_params = LuceneQueryParams(max=50) + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + result = composite.to_query_string() + + # fabricName should appear before max + fabric_pos = result.index("fabricName") + max_pos = result.index("max") + assert fabric_pos < max_pos + + +def test_query_params_00350(): + """ + # Summary + + Verify CompositeQueryParams is_empty() method + + ## Test + + - is_empty() returns True when all groups are empty + - is_empty() returns False when any group has params + + ## Classes and Methods + + - CompositeQueryParams.is_empty() + """ + with does_not_raise(): + composite = CompositeQueryParams() + assert composite.is_empty() is True + + # Add empty parameter group + empty_params = SampleEndpointParams() + composite.add(empty_params) + assert composite.is_empty() is True + + # Add non-empty parameter group + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + composite.add(endpoint_params) + assert composite.is_empty() is False + + +def test_query_params_00360(): + """ + # Summary + + Verify CompositeQueryParams clear() method + + ## Test + + - clear() removes all parameter groups + - is_empty() returns True after clear() + + ## Classes and Methods + + - CompositeQueryParams.clear() + - CompositeQueryParams.is_empty() + """ + with does_not_raise(): + composite = CompositeQueryParams() + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + composite.add(endpoint_params) + + assert composite.is_empty() is False + + composite.clear() + assert composite.is_empty() is True + + +def test_query_params_00370(): + """ + # Summary + + Verify CompositeQueryParams URL encoding propagation + + ## Test + + - url_encode parameter is passed to LuceneQueryParams + - EndpointQueryParams not affected (no url_encode parameter) + + ## Classes and Methods + + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(fabric_name="My Fabric") + lucene_params = LuceneQueryParams(filter="name:Test Value") + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + # With URL encoding + result_encoded = composite.to_query_string(url_encode=True) + assert "filter=" in result_encoded + + # Without URL encoding + result_plain = composite.to_query_string(url_encode=False) + assert "filter=name:Test Value" in result_plain + + +def test_query_params_00380(): + """ + # Summary + + Verify CompositeQueryParams with empty groups + + ## Test + + - Empty parameter groups are skipped in query string + - Only non-empty groups contribute to query string + + ## Classes and Methods + + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + empty_endpoint = SampleEndpointParams() + non_empty_lucene = LuceneQueryParams(max=100) + + composite = CompositeQueryParams() + composite.add(empty_endpoint).add(non_empty_lucene) + + result = composite.to_query_string() + + # Should only contain the Lucene params + assert result == "max=100" + + +# ============================================================================= +# Test: Integration scenarios +# ============================================================================= + + +def test_query_params_00400(): + """ + # Summary + + Verify complex query string composition + + ## Test + + - Combine multiple EndpointQueryParams with LuceneQueryParams + - All parameters are correctly formatted and encoded + + ## Classes and Methods + + - CompositeQueryParams.add() + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Production", switch_count=5) + + lucene_params = LuceneQueryParams(filter="status:active AND role:leaf", max=100, offset=0, sort="name:asc") + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + result = composite.to_query_string(url_encode=False) + + # Verify all parameters present + assert "forceShowRun=true" in result + assert "fabricName=Production" in result + assert "switchCount=5" in result + assert "filter=status:active AND role:leaf" in result + assert "max=100" in result + assert "offset=0" in result + assert "sort=name:asc" in result From f5b16557001ed0b7f35ef09a2e9b610a6491a07b Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 12 Mar 2026 13:30:35 -1000 Subject: [PATCH 03/51] [ignore] Add RestSend framework, enums, and shared unit test infrastructure (#185) --- plugins/module_utils/__init__.py | 0 plugins/module_utils/common/exceptions.py | 146 ++ plugins/module_utils/nd_v2.py | 317 ++++ plugins/module_utils/rest/__init__.py | 0 .../module_utils/rest/protocols/__init__.py | 0 .../rest/protocols/response_handler.py | 138 ++ .../rest/protocols/response_validation.py | 197 +++ plugins/module_utils/rest/protocols/sender.py | 103 ++ .../module_utils/rest/response_handler_nd.py | 401 +++++ .../rest/response_strategies/__init__.py | 0 .../response_strategies/nd_v1_strategy.py | 267 +++ plugins/module_utils/rest/rest_send.py | 819 +++++++++ plugins/module_utils/rest/results.py | 1178 +++++++++++++ plugins/module_utils/rest/sender_nd.py | 322 ++++ tests/sanity/requirements.txt | 6 +- .../fixtures/fixture_data/test_rest_send.json | 244 +++ .../module_utils/fixtures/load_fixture.py | 46 + .../unit/module_utils/mock_ansible_module.py | 95 + tests/unit/module_utils/response_generator.py | 60 + tests/unit/module_utils/sender_file.py | 293 ++++ .../module_utils/test_response_handler_nd.py | 1496 ++++++++++++++++ tests/unit/module_utils/test_rest_send.py | 1551 +++++++++++++++++ tests/unit/module_utils/test_results.py | 362 ++++ tests/unit/module_utils/test_sender_nd.py | 906 ++++++++++ 24 files changed, 8944 insertions(+), 3 deletions(-) create mode 100644 plugins/module_utils/__init__.py create mode 100644 plugins/module_utils/common/exceptions.py create mode 100644 plugins/module_utils/nd_v2.py create mode 100644 plugins/module_utils/rest/__init__.py create mode 100644 plugins/module_utils/rest/protocols/__init__.py create mode 100644 plugins/module_utils/rest/protocols/response_handler.py create mode 100644 plugins/module_utils/rest/protocols/response_validation.py create mode 100644 plugins/module_utils/rest/protocols/sender.py create mode 100644 plugins/module_utils/rest/response_handler_nd.py create mode 100644 plugins/module_utils/rest/response_strategies/__init__.py create mode 100644 plugins/module_utils/rest/response_strategies/nd_v1_strategy.py create mode 100644 plugins/module_utils/rest/rest_send.py create mode 100644 plugins/module_utils/rest/results.py create mode 100644 plugins/module_utils/rest/sender_nd.py create mode 100644 tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json create mode 100644 tests/unit/module_utils/fixtures/load_fixture.py create mode 100644 tests/unit/module_utils/mock_ansible_module.py create mode 100644 tests/unit/module_utils/response_generator.py create mode 100644 tests/unit/module_utils/sender_file.py create mode 100644 tests/unit/module_utils/test_response_handler_nd.py create mode 100644 tests/unit/module_utils/test_rest_send.py create mode 100644 tests/unit/module_utils/test_results.py create mode 100644 tests/unit/module_utils/test_sender_nd.py diff --git a/plugins/module_utils/__init__.py b/plugins/module_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py new file mode 100644 index 00000000..16e31ac6 --- /dev/null +++ b/plugins/module_utils/common/exceptions.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# exceptions.py + +Exception classes for the cisco.nd Ansible collection. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, +) + + +class NDErrorData(BaseModel): + """ + # Summary + + Pydantic model for structured error data from NDModule requests. + + This model provides type-safe error information that can be serialized + to a dict for use with Ansible's fail_json. + + ## Attributes + + - msg: Human-readable error message (required) + - status: HTTP status code as integer (optional) + - request_payload: Request payload that was sent (optional) + - response_payload: Response payload from controller (optional) + - raw: Raw response content for non-JSON responses (optional) + + ## Raises + + - None + """ + + model_config = ConfigDict(extra="forbid") + + msg: str + status: Optional[int] = None + request_payload: Optional[dict[str, Any]] = None + response_payload: Optional[dict[str, Any]] = None + raw: Optional[Any] = None + + +class NDModuleError(Exception): + """ + # Summary + + Exception raised by NDModule when a request fails. + + This exception wraps an NDErrorData Pydantic model, providing structured + error information that can be used by callers to build appropriate error + responses (e.g., Ansible fail_json). + + ## Usage Example + + ```python + try: + data = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, payload) + except NDModuleError as e: + print(f"Error: {e.msg}") + print(f"Status: {e.status}") + if e.response_payload: + print(f"Response: {e.response_payload}") + # Use to_dict() for fail_json + module.fail_json(**e.to_dict()) + ``` + + ## Raises + + - None + """ + + # pylint: disable=too-many-arguments + def __init__( + self, + msg: str, + status: Optional[int] = None, + request_payload: Optional[dict[str, Any]] = None, + response_payload: Optional[dict[str, Any]] = None, + raw: Optional[Any] = None, + ) -> None: + self.error_data = NDErrorData( + msg=msg, + status=status, + request_payload=request_payload, + response_payload=response_payload, + raw=raw, + ) + super().__init__(msg) + + @property + def msg(self) -> str: + """Human-readable error message.""" + return self.error_data.msg + + @property + def status(self) -> Optional[int]: + """HTTP status code.""" + return self.error_data.status + + @property + def request_payload(self) -> Optional[dict[str, Any]]: + """Request payload that was sent.""" + return self.error_data.request_payload + + @property + def response_payload(self) -> Optional[dict[str, Any]]: + """Response payload from controller.""" + return self.error_data.response_payload + + @property + def raw(self) -> Optional[Any]: + """Raw response content for non-JSON responses.""" + return self.error_data.raw + + def to_dict(self) -> dict[str, Any]: + """ + # Summary + + Convert exception attributes to a dict for use with fail_json. + + Returns a dict containing only non-None attributes. + + ## Raises + + - None + """ + return self.error_data.model_dump(exclude_none=True) diff --git a/plugins/module_utils/nd_v2.py b/plugins/module_utils/nd_v2.py new file mode 100644 index 00000000..0a3fe61a --- /dev/null +++ b/plugins/module_utils/nd_v2.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# nd_v2.py + +Simplified NDModule using RestSend infrastructure with exception-based error handling. + +This module provides a streamlined interface for interacting with Nexus Dashboard +controllers. Unlike the original nd.py which uses Ansible's fail_json/exit_json, +this module raises Python exceptions, making it: + +- Easier to unit test +- Reusable with non-Ansible code (e.g., raw Python Requests) +- More Pythonic in error handling + +## Usage Example + +```python +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule, + NDModuleError, + nd_argument_spec, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +def main(): + argument_spec = nd_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + nd = NDModule(module) + + try: + data = nd.request("/api/v1/some/endpoint", HttpVerbEnum.GET) + module.exit_json(changed=False, data=data) + except NDModuleError as e: + module.fail_json(msg=e.msg, status=e.status, response_payload=e.response_payload) +``` +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import logging +from typing import Any, Optional + +from ansible.module_utils.basic import env_fallback +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_handler import ResponseHandlerProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.sender import SenderProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender + + +def nd_argument_spec() -> dict[str, Any]: + """ + Return the common argument spec for ND modules. + + This function provides the standard arguments that all ND modules + should accept for connection and authentication. + """ + return dict( + host=dict(type="str", required=False, aliases=["hostname"], fallback=(env_fallback, ["ND_HOST"])), + port=dict(type="int", required=False, fallback=(env_fallback, ["ND_PORT"])), + username=dict(type="str", fallback=(env_fallback, ["ND_USERNAME", "ANSIBLE_NET_USERNAME"])), + password=dict(type="str", required=False, no_log=True, fallback=(env_fallback, ["ND_PASSWORD", "ANSIBLE_NET_PASSWORD"])), + output_level=dict(type="str", default="normal", choices=["debug", "info", "normal"], fallback=(env_fallback, ["ND_OUTPUT_LEVEL"])), + timeout=dict(type="int", default=30, fallback=(env_fallback, ["ND_TIMEOUT"])), + use_proxy=dict(type="bool", fallback=(env_fallback, ["ND_USE_PROXY"])), + use_ssl=dict(type="bool", fallback=(env_fallback, ["ND_USE_SSL"])), + validate_certs=dict(type="bool", fallback=(env_fallback, ["ND_VALIDATE_CERTS"])), + login_domain=dict(type="str", fallback=(env_fallback, ["ND_LOGIN_DOMAIN"])), + ) + + +class NDModule: + """ + # Summary + + Simplified NDModule using RestSend infrastructure with exception-based error handling. + + This class provides a clean interface for making REST API requests to Nexus Dashboard + controllers. It uses the RestSend/Sender/ResponseHandler infrastructure for + separation of concerns and testability. + + ## Key Differences from nd.py NDModule + + 1. Uses exceptions (NDModuleError) instead of fail_json/exit_json + 2. No Connection class dependency - uses Sender for HTTP operations + 3. Minimal state - only tracks request/response metadata + 4. request() leverages RestSend -> Sender -> ResponseHandler + + ## Usage Example + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule, NDModuleError + from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + nd = NDModule(module) + + try: + # GET request + data = nd.request("/api/v1/endpoint") + + # POST request with payload + result = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, {"key": "value"}) + except NDModuleError as e: + module.fail_json(**e.to_dict()) + ``` + + ## Raises + + - NDModuleError: When a request fails (replaces fail_json) + - ValueError: When RestSend encounters configuration errors + - TypeError: When invalid types are passed to RestSend + """ + + def __init__(self, module) -> None: + """ + Initialize NDModule with an AnsibleModule instance. + + Args: + module: AnsibleModule instance (or compatible mock for testing) + """ + self.class_name = self.__class__.__name__ + self.module = module + self.params: dict[str, Any] = module.params + + self.log = logging.getLogger(f"nd.{self.class_name}") + + # Request/response state (for debugging and error reporting) + self.method: Optional[str] = None + self.path: Optional[str] = None + self.response: Optional[str] = None + self.status: Optional[int] = None + self.url: Optional[str] = None + + # RestSend infrastructure (lazy initialized) + self._rest_send: Optional[RestSend] = None + self._sender: Optional[SenderProtocol] = None + self._response_handler: Optional[ResponseHandlerProtocol] = None + + if self.module._debug: + self.module.warn("Enable debug output because ANSIBLE_DEBUG was set.") + self.params["output_level"] = "debug" + + def _get_rest_send(self) -> RestSend: + """ + # Summary + + Lazy initialization of RestSend and its dependencies. + + ## Returns + + - RestSend: Configured RestSend instance ready for use. + """ + method_name = "_get_rest_send" + params = {} + if self._rest_send is None: + params = { + "check_mode": self.module.check_mode, + "state": self.params.get("state"), + } + self._sender = Sender() + self._sender.ansible_module = self.module + self._response_handler = ResponseHandler() + self._rest_send = RestSend(params) + self._rest_send.sender = self._sender + self._rest_send.response_handler = self._response_handler + + msg = f"{self.class_name}.{method_name}: " + msg += "Initialized RestSend instance with params: " + msg += f"{params}" + self.log.debug(msg) + return self._rest_send + + @property + def rest_send(self) -> RestSend: + """ + # Summary + + Access to the RestSend instance used by this NDModule. + + ## Returns + + - RestSend: The RestSend instance. + + ## Raises + + - `ValueError`: If accessed before `request()` has been called. + + ## Usage + + ```python + nd = NDModule(module) + data = nd.request("/api/v1/endpoint") + + # Access RestSend response/result + response = nd.rest_send.response_current + result = nd.rest_send.result_current + ``` + """ + if self._rest_send is None: + msg = f"{self.class_name}.rest_send: " + msg += "rest_send must be initialized before accessing. " + msg += "Call request() first." + raise ValueError(msg) + return self._rest_send + + def request( + self, + path: str, + verb: HttpVerbEnum = HttpVerbEnum.GET, + data: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """ + # Summary + + Make a REST API request to the Nexus Dashboard controller. + + This method uses the RestSend infrastructure for improved separation + of concerns and testability. + + ## Args + + - path: The fully-formed API endpoint path including query string + (e.g., "/appcenter/cisco/ndfc/api/v1/endpoint?param=value") + - verb: HTTP verb as HttpVerbEnum (default: HttpVerbEnum.GET) + - data: Optional request payload as a dict + + ## Returns + + The response DATA from the controller (parsed JSON body). + + For full response metadata (status, message, etc.), access + `rest_send.response_current` and `rest_send.result_current` + after calling this method. + + ## Raises + + - `NDModuleError`: If the request fails (with status, payload, etc.) + - `ValueError`: If RestSend encounters configuration errors + - `TypeError`: If invalid types are passed + """ + method_name = "request" + # If PATCH with empty data, return early (existing behavior) + if verb == HttpVerbEnum.PATCH and not data: + return {} + + rest_send = self._get_rest_send() + + # Send the request + try: + rest_send.path = path + rest_send.verb = verb # type: ignore[assignment] + msg = f"{self.class_name}.{method_name}: " + msg += "Sending request " + msg += f"verb: {verb}, " + msg += f"path: {path}" + if data: + rest_send.payload = data + msg += f", data: {data}" + self.log.debug(msg) + rest_send.commit() + except (TypeError, ValueError) as error: + raise ValueError(f"Error in request: {error}") from error + + # Get response and result from RestSend + response = rest_send.response_current + result = rest_send.result_current + + # Update state for debugging/error reporting + self.method = verb.value + self.path = path + self.response = response.get("MESSAGE") + self.status = response.get("RETURN_CODE", -1) + self.url = response.get("REQUEST_PATH") + + # Handle errors based on result + if not result.get("success", False): + response_data = response.get("DATA") + + # Get error message from ResponseHandler + error_msg = self._response_handler.error_message if self._response_handler else "Unknown error" + + # Build exception with available context + raw = None + payload = None + + if isinstance(response_data, dict): + if "raw_response" in response_data: + raw = response_data["raw_response"] + else: + payload = response_data + + raise NDModuleError( + msg=error_msg if error_msg else "Unknown error", + status=self.status, + request_payload=data, + response_payload=payload, + raw=raw, + ) + + # Return the response data on success + return response.get("DATA", {}) diff --git a/plugins/module_utils/rest/__init__.py b/plugins/module_utils/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/protocols/__init__.py b/plugins/module_utils/rest/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/protocols/response_handler.py b/plugins/module_utils/rest/protocols/response_handler.py new file mode 100644 index 00000000..487e12cf --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_handler.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# pylint: disable=missing-module-docstring +# pylint: disable=unnecessary-ellipsis +# pylint: disable=wrong-import-position +# Copyright: (c) 2026, Allen Robel (@arobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +""" +Protocol definition for ResponseHandler classes. +""" + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@runtime_checkable +class ResponseHandlerProtocol(Protocol): + """ + # Summary + + Protocol defining the interface for response handlers in RestSend. + + Any class implementing this protocol must provide: + + - `response` property (getter/setter): The controller response dict. + - `result` property (getter): The calculated result based on response and verb. + - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). + - `commit()` method: Parses response and sets result. + + ## Notes + + - Getters for `response`, `result`, and `verb` should raise `ValueError` if + accessed before being set. + + ## Example Implementations + + - `ResponseHandler` in `response_handler_nd.py`: Handles Nexus Dashboard responses. + - Future: `ResponseHandlerApic` for APIC controller responses. + """ + + @property + def response(self) -> dict: + """ + # Summary + + The controller response. + + ## Raises + + - ValueError: If accessed before being set. + """ + ... + + @response.setter + def response(self, value: dict) -> None: + pass + + @property + def result(self) -> dict: + """ + # Summary + + The calculated result based on response and verb. + + ## Raises + + - ValueError: If accessed before commit() is called. + """ + ... + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the request. + + ## Raises + + - ValueError: If accessed before being set. + """ + ... + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + pass + + def commit(self) -> None: + """ + # Summary + + Parse the response and set the result. + + ## Raises + + - ValueError: If response or verb is not set. + """ + ... + + @property + def error_message(self) -> Optional[str]: + """ + # Summary + + Human-readable error message extracted from response. + + ## Returns + + - str: Error message if an error occurred. + - None: If the request was successful or commit() not called. + """ + ... diff --git a/plugins/module_utils/rest/protocols/response_validation.py b/plugins/module_utils/rest/protocols/response_validation.py new file mode 100644 index 00000000..d1ec5ef0 --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_validation.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +Protocol definition for version-specific response validation strategies. + +## Description + +This module defines the ResponseValidationStrategy protocol which specifies +the interface for handling version-specific differences in ND API responses, +including status code validation and error message extraction. + +When ND API v2 is released with different status codes or response formats, +implementing a new strategy class allows clean separation of v1 and v2 logic. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +# pylint: disable=unnecessary-ellipsis + + +@runtime_checkable +class ResponseValidationStrategy(Protocol): + """ + # Summary + + Protocol for version-specific response validation. + + ## Description + + This protocol defines the interface for handling version-specific + differences in ND API responses, including status code validation + and error message extraction. + + Implementations of this protocol enable injecting version-specific + behavior into ResponseHandler without modifying the handler itself. + + ## Methods + + See property and method definitions below. + + ## Raises + + None - implementations may raise exceptions per their logic + """ + + @property + def success_codes(self) -> set[int]: + """ + # Summary + + Return set of HTTP status codes considered successful. + + ## Returns + + - Set of integers representing success status codes + """ + ... + + @property + def not_found_code(self) -> int: + """ + # Summary + + Return HTTP status code for resource not found. + + ## Returns + + - Integer representing not-found status code (typically 404) + """ + ... + + def is_success(self, response: dict) -> bool: + """ + # Summary + + Check if the full response indicates success. + + ## Description + + Implementations must check both the HTTP status code and any embedded error + indicators in the response body, since some ND API endpoints return a + successful status code (e.g. 200) while embedding an error in the payload. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, etc. + + ## Returns + + - True if the response is fully successful (good status code and no embedded error), False otherwise + + ## Raises + + None + """ + ... + + def is_not_found(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates not found. + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code matches not_found_code, False otherwise + + ## Raises + + None + """ + ... + + def is_changed(self, response: dict) -> bool: + """ + # Summary + + Check if a successful mutation request actually changed state. + + ## Description + + Some ND API endpoints include a `modified` response header (string `"true"` or + `"false"`) that explicitly signals whether the operation mutated any state. + Implementations should honour this header when present and default to `True` + when it is absent (matching the historical behaviour for PUT/POST/DELETE). + + This method should only be called after `is_success` has returned `True`. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, and any HTTP + response headers (lowercased) forwarded by the HttpAPI plugin. + + ## Returns + + - True if the operation changed state (or if the `modified` header is absent) + - False if the `modified` header is explicitly `"false"` + + ## Raises + + None + """ + ... + + def extract_error_message(self, response: dict) -> Optional[str]: + """ + # Summary + + Extract error message from response DATA. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, etc. + + ## Returns + + - Error message string if found, None otherwise + + ## Raises + + None - should return None gracefully if error message cannot be extracted + """ + ... diff --git a/plugins/module_utils/rest/protocols/sender.py b/plugins/module_utils/rest/protocols/sender.py new file mode 100644 index 00000000..5e55047c --- /dev/null +++ b/plugins/module_utils/rest/protocols/sender.py @@ -0,0 +1,103 @@ +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# pylint: disable=unnecessary-ellipsis +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@runtime_checkable +class SenderProtocol(Protocol): + """ + # Summary + + Protocol defining the sender interface for RestSend. + + Any class implementing this protocol must provide: + + - `path` property (getter/setter): The endpoint path for the REST request. + - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). + - `payload` property (getter/setter): Optional request payload as a dict. + - `response` property (getter): The response from the controller. + - `commit()` method: Sends the request to the controller. + + ## Example Implementations + + - `Sender` in `sender_nd.py`: Uses Ansible HttpApi plugin. + - `Sender` in `sender_file.py`: Reads responses from files (for testing). + """ + + @property + def path(self) -> str: + """Endpoint path for the REST request.""" + ... + + @path.setter + def path(self, value: str) -> None: + """Set the endpoint path for the REST request.""" + ... + + @property + def verb(self) -> HttpVerbEnum: + """HTTP method for the REST request.""" + ... + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + """Set the HTTP method for the REST request.""" + ... + + @property + def payload(self) -> Optional[dict]: + """Optional payload to send to the controller.""" + ... + + @payload.setter + def payload(self, value: dict) -> None: + """Set the optional payload for the REST request.""" + ... + + @property + def response(self) -> dict: + """The response from the controller.""" + ... + + def commit(self) -> None: + """ + Send the request to the controller. + + Raises: + ConnectionError: If there is an error with the connection. + """ + ... diff --git a/plugins/module_utils/rest/response_handler_nd.py b/plugins/module_utils/rest/response_handler_nd.py new file mode 100644 index 00000000..e7026d30 --- /dev/null +++ b/plugins/module_utils/rest/response_handler_nd.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# response_handler_nd.py + +Implements the ResponseHandler interface for handling Nexus Dashboard controller responses. + +## Version Compatibility + +This handler is designed for ND API v1 responses (ND 4.2+). + +### Status Code Assumptions + +Status codes are defined by the injected `ResponseValidationStrategy`, defaulting +to `NdV1Strategy` (ND 4.2+): + +- Success: 200, 201, 202, 204, 207 +- Not Found: 404 (treated as success for GET) +- Error: 405, 409 + +If ND API v2 uses different codes, inject a new strategy via the +`validation_strategy` property rather than modifying this class. + +### Response Format + +Expects ND HttpApi plugin to provide responses with these keys: + +- RETURN_CODE (int): HTTP status code (e.g., 200, 404, 500) +- MESSAGE (str): HTTP reason phrase (e.g., "OK", "Not Found") +- DATA (dict): Parsed JSON body or dict with raw_response if parsing failed +- REQUEST_PATH (str): The request URL path +- METHOD (str): The HTTP method used (GET, POST, PUT, DELETE, PATCH) + +### Supported Error Formats + +The error_message property handles multiple ND API v1 error response formats: + +1. code/message dict: {"code": , "message": } +2. messages array: {"messages": [{"code": , "severity": , "message": }]} +3. errors array: {"errors": [, ...]} +4. raw_response: {"raw_response": } for non-JSON responses + +If ND API v2 changes error response structures, error extraction logic will need updates. + +## Future v2 Considerations + +If ND API v2 changes response format or status codes, implement a new strategy +class (e.g. `NdV2Strategy`) conforming to `ResponseValidationStrategy` and inject +it via `response_handler.validation_strategy = NdV2Strategy()`. + +TODO: Should response be converted to a Pydantic model by this class? +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_validation import ResponseValidationStrategy +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_strategies.nd_v1_strategy import NdV1Strategy + + +class ResponseHandler: + """ + # Summary + + Implement the response handler interface for injection into RestSend(). + + ## Raises + + - `TypeError` if: + - `response` is not a dict. + - `ValueError` if: + - `response` is missing any fields required by the handler + to calculate the result. + - Required fields: + - `RETURN_CODE` + - `MESSAGE` + - `response` is not set prior to calling `commit()`. + - `verb` is not set prior to calling `commit()`. + + ## Interface specification + + - `response` setter property + - Accepts a dict containing the controller response. + - Raises `TypeError` if: + - `response` is not a dict. + - Raises `ValueError` if: + - `response` is missing any fields required by the handler + to calculate the result, for example `RETURN_CODE` and + `MESSAGE`. + - `result` getter property + - Returns a dict containing the calculated result based on the + controller response and the request verb. + - Raises `ValueError` if `result` is accessed before calling + `commit()`. + - `result` setter property + - Set internally by the handler based on the response and verb. + - `verb` setter property + - Accepts an HttpVerbEnum enum defining the request verb. + - Valid verb: One of "DELETE", "GET", "POST", "PUT". + - e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + - Raises `ValueError` if verb is not set prior to calling `commit()`. + - `commit()` method + - Parse `response` and set `result`. + - Raise `ValueError` if: + - `response` is not set. + - `verb` is not set. + + ## Usage example + + ```python + # import and instantiate the class + from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import \ + ResponseHandler + response_handler = ResponseHandler() + + try: + # Set the response from the controller + response_handler.response = controller_response + + # Set the request verb + response_handler.verb = HttpVerbEnum.GET + + # Call commit to parse the response + response_handler.commit() + + # Access the result + result = response_handler.result + except (TypeError, ValueError) as error: + handle_error(error) + ``` + + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + method_name = "__init__" + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._response: Optional[dict[str, Any]] = None + self._result: Optional[dict[str, Any]] = None + self._strategy: ResponseValidationStrategy = NdV1Strategy() + self._verb: Optional[HttpVerbEnum] = None + + msg = f"ENTERED {self.class_name}.{method_name}" + self.log.debug(msg) + + def _handle_response(self) -> None: + """ + # Summary + + Call the appropriate handler for response based on the value of self.verb + """ + if self.verb == HttpVerbEnum.GET: + self._handle_get_response() + else: + self._handle_post_put_delete_response() + + def _handle_get_response(self) -> None: + """ + # Summary + + Handle GET responses from the controller and set self.result. + + - self.result is a dict containing: + - found: + - False if RETURN_CODE == 404 + - True otherwise (when successful) + - success: + - True if RETURN_CODE in (200, 201, 202, 204, 207, 404) + - False otherwise (error status codes) + """ + result = {} + return_code = self.response.get("RETURN_CODE") + + # 404 Not Found - resource doesn't exist, but request was successful + if self._strategy.is_not_found(return_code): + result["found"] = False + result["success"] = True + # Success codes with no embedded error - resource found + elif self._strategy.is_success(self.response): + result["found"] = True + result["success"] = True + # Error codes - request failed + else: + result["found"] = False + result["success"] = False + + self.result = copy.copy(result) + + def _handle_post_put_delete_response(self) -> None: + """ + # Summary + + Handle POST, PUT, DELETE responses from the controller and set + self.result. + + - self.result is a dict containing: + - changed: + - True if RETURN_CODE in (200, 201, 202, 204, 207) and no ERROR + - False otherwise + - success: + - True if RETURN_CODE in (200, 201, 202, 204, 207) and no ERROR + - False otherwise + """ + result = {} + + # Success codes with no embedded error indicate the operation completed. + # Use the modified header (when present) as the authoritative signal for + # whether state was actually changed, falling back to True when absent. + if self._strategy.is_success(self.response): + result["success"] = True + result["changed"] = self._strategy.is_changed(self.response) + else: + result["success"] = False + result["changed"] = False + + self.result = copy.copy(result) + + def commit(self) -> None: + """ + # Summary + + Parse the response from the controller and set self.result + based on the response. + + ## Raises + + - ``ValueError`` if: + - ``response`` is not set. + - ``verb`` is not set. + """ + method_name = "commit" + msg = f"{self.class_name}.{method_name}: " + msg += f"response {self.response}, verb {self.verb}" + self.log.debug(msg) + self._handle_response() + + @property + def response(self) -> dict[str, Any]: + """ + # Summary + + The controller response. + + ## Raises + + - getter: ``ValueError`` if response is not set. + - setter: ``TypeError`` if ``response`` is not a dict. + - setter: ``ValueError`` if ``response`` is missing required fields + (``RETURN_CODE``, ``MESSAGE``). + """ + if self._response is None: + msg = f"{self.class_name}.response: " + msg += "response must be set before accessing." + raise ValueError(msg) + return self._response + + @response.setter + def response(self, value: dict[str, Any]) -> None: + method_name = "response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + if value.get("MESSAGE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a MESSAGE key. " + msg += f"Got: {value}." + raise ValueError(msg) + if value.get("RETURN_CODE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a RETURN_CODE key. " + msg += f"Got: {value}." + raise ValueError(msg) + self._response = value + + @property + def result(self) -> dict[str, Any]: + """ + # Summary + + The result calculated by the handler based on the controller response. + + ## Raises + + - getter: ``ValueError`` if result is not set (commit() not called). + - setter: ``TypeError`` if result is not a dict. + """ + if self._result is None: + msg = f"{self.class_name}.result: " + msg += "result must be set before accessing. Call commit() first." + raise ValueError(msg) + return self._result + + @result.setter + def result(self, value: dict[str, Any]) -> None: + method_name = "result" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._result = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + + ## Raises + + - ``ValueError`` if value is not set. + """ + if self._verb is None: + raise ValueError(f"{self.class_name}.verb is not set.") + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + self._verb = value + + @property + def error_message(self) -> Optional[str]: + """ + # Summary + + Extract a human-readable error message from the response DATA. + + Delegates to the injected `ResponseValidationStrategy`. Returns None if + result indicates success or if `commit()` has not been called. + + ## Returns + + - str: Human-readable error message if an error occurred. + - None: If the request was successful or `commit()` not called. + + ## Raises + + None + """ + if self._result is not None and not self._result.get("success", True): + return self._strategy.extract_error_message(self._response) + return None + + @property + def validation_strategy(self) -> ResponseValidationStrategy: + """ + # Summary + + The response validation strategy used to check status codes and extract + error messages. + + ## Returns + + - `ResponseValidationStrategy`: The current strategy instance. + + ## Raises + + None + """ + return self._strategy + + @validation_strategy.setter + def validation_strategy(self, value: ResponseValidationStrategy) -> None: + """ + # Summary + + Set the response validation strategy. + + ## Raises + + ### TypeError + + - If `value` does not implement `ResponseValidationStrategy`. + """ + method_name = "validation_strategy" + if not isinstance(value, ResponseValidationStrategy): + msg = f"{self.class_name}.{method_name}: " + msg += f"Expected ResponseValidationStrategy. Got {type(value)}." + raise TypeError(msg) + self._strategy = value diff --git a/plugins/module_utils/rest/response_strategies/__init__.py b/plugins/module_utils/rest/response_strategies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py new file mode 100644 index 00000000..58c7784f --- /dev/null +++ b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +ND API v1 response validation strategy. + +## Description + +Implements status code validation and error message extraction for ND API v1 +responses (ND 4.2). + +This strategy encapsulates the response handling logic previously hardcoded +in ResponseHandler, enabling version-specific behavior to be injected. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import Any, Optional + + +class NdV1Strategy: + """ + # Summary + + Response validation strategy for ND API v1. + + ## Description + + Implements status code validation and error message extraction + for ND API v1 (ND 4.2+). + + ## Status Codes + + - Success: 200, 201, 202, 204, 207 + - Not Found: 404 (treated as success for GET) + - Error: anything not in success codes and not 404 + + ## Error Formats Supported + + 1. raw_response: Non-JSON response stored in DATA.raw_response + 2. code/message: DATA.code and DATA.message + 3. messages array: all DATA.messages[].{code, severity, message} joined with "; " + 4. errors array: all DATA.errors[] joined with "; " + 5. Connection failure: No DATA with REQUEST_PATH and MESSAGE + 6. Non-dict DATA: Stringified DATA value + 7. Unknown: Fallback with RETURN_CODE + + ## Raises + + None + """ + + @property + def success_codes(self) -> set[int]: + """ + # Summary + + Return v1 success codes. + + ## Returns + + - Set of integers: {200, 201, 202, 204, 207} + + ## Raises + + None + """ + return {200, 201, 202, 204, 207} + + @property + def not_found_code(self) -> int: + """ + # Summary + + Return v1 not found code. + + ## Returns + + - Integer: 404 + + ## Raises + + None + """ + return 404 + + def is_success(self, response: dict) -> bool: + """ + # Summary + + Check if the full response indicates success (v1). + + ## Description + + Returns True only when both conditions hold: + + 1. `RETURN_CODE` is in `success_codes` + 2. The response body contains no embedded error indicators + + Embedded error indicators checked: + + - Top-level `ERROR` key is present + - `DATA.error` key is present + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, etc. + + ## Returns + + - True if the response is fully successful, False otherwise + + ## Raises + + None + """ + return_code = response.get("RETURN_CODE", -1) + if return_code not in self.success_codes: + return False + if response.get("ERROR") is not None: + return False + data = response.get("DATA") + if isinstance(data, dict) and data.get("error") is not None: + return False + return True + + def is_not_found(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates not found (v1). + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code matches not_found_code, False otherwise + + ## Raises + + None + """ + return return_code == self.not_found_code + + def is_changed(self, response: dict) -> bool: + """ + # Summary + + Check if a successful mutation request actually changed state (v1). + + ## Description + + ND API v1 may include a `modified` response header (forwarded by the HttpAPI + plugin as a lowercase key in the response dict) with string values `"true"` or + `"false"`. When present, this header is the authoritative signal for whether + the operation mutated any state on the controller. + + When the header is absent the method defaults to `True`, preserving the + historical behaviour for verbs (DELETE, POST, PUT) where ND does not send it. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, and any HTTP + response headers (lowercased) forwarded by the HttpAPI plugin. + + ## Returns + + - False if the `modified` header is present and equals `"false"` (case-insensitive) + - True otherwise + + ## Raises + + None + """ + modified = response.get("modified") + if modified is None: + return True + return str(modified).lower() != "false" + + def extract_error_message(self, response: dict) -> Optional[str]: + """ + # Summary + + Extract error message from v1 response DATA. + + ## Description + + Handles multiple ND API v1 error formats in priority order: + + 1. Connection failure (no DATA) + 2. Non-JSON response (raw_response in DATA) + 3. code/message dict + 4. messages array with code/severity/message (all items joined) + 5. errors array (all items joined) + 6. Unknown dict format + 7. Non-dict DATA + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, REQUEST_PATH + + ## Returns + + - Error message string if found, None otherwise + + ## Raises + + None - returns None gracefully if error message cannot be extracted + """ + msg: Optional[str] = None + + response_data = response.get("DATA") if response else None + return_code = response.get("RETURN_CODE", -1) if response else -1 + + # No response data - connection failure + if response_data is None: + request_path = response.get("REQUEST_PATH", "unknown") if response else "unknown" + message = response.get("MESSAGE", "Unknown error") if response else "Unknown error" + msg = f"Connection failed for {request_path}. {message}" + # Dict response data - check various ND error formats + elif isinstance(response_data, dict): + # Type-narrow response_data to dict[str, Any] for pylint + # pylint: disable=unsupported-membership-test,unsubscriptable-object + data_dict: dict[str, Any] = response_data + # Raw response (non-JSON) + if "raw_response" in data_dict: + msg = "ND Error: Response could not be parsed as JSON" + # code/message format + elif "code" in data_dict and "message" in data_dict: + msg = f"ND Error {data_dict['code']}: {data_dict['message']}" + + # messages array format + if msg is None and "messages" in data_dict and len(data_dict.get("messages", [])) > 0: + parts = [] + for m in data_dict["messages"]: + if all(k in m for k in ("code", "severity", "message")): + parts.append(f"ND Error {m['code']} ({m['severity']}): {m['message']}") + if parts: + msg = "; ".join(parts) + + # errors array format + if msg is None and "errors" in data_dict and len(data_dict.get("errors", [])) > 0: + msg = f"ND Error: {'; '.join(str(e) for e in data_dict['errors'])}" + + # Unknown dict format - fallback + if msg is None: + msg = f"ND Error: Request failed with status {return_code}" + # Non-dict response data + else: + msg = f"ND Error: {response_data}" + + return msg diff --git a/plugins/module_utils/rest/rest_send.py b/plugins/module_utils/rest/rest_send.py new file mode 100644 index 00000000..c87009a5 --- /dev/null +++ b/plugins/module_utils/rest/rest_send.py @@ -0,0 +1,819 @@ +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# Copyright: (c) 2026, Allen Robel (@arobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + + +import copy +import inspect +import json +import logging +from time import sleep +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_handler import ResponseHandlerProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.sender import SenderProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + + +class RestSend: + """ + # Summary + + - Send REST requests to the controller with retries. + - Accepts a `Sender()` class that implements SenderProtocol. + - The sender interface is defined in + `module_utils/rest/protocols/sender.py` + - Accepts a `ResponseHandler()` class that implements the response + handler interface. + - The response handler interface is defined in + `module_utils/rest/protocols/response_handler.py` + + ## Raises + + - `ValueError` if: + - ResponseHandler() raises `TypeError` or `ValueError` + - Sender().commit() raises `ValueError` + - `verb` is not a valid verb (GET, POST, PUT, DELETE) + - `TypeError` if: + - `check_mode` is not a `bool` + - `path` is not a `str` + - `payload` is not a `dict` + - `add_response()` value is not a `dict` + - `response_current` is not a `dict` + - `response_handler` is not an instance of + `ResponseHandler()` + - `add_result()` value is not a `dict` + - `result_current` is not a `dict` + - `send_interval` is not an `int` + - `sender` is not an instance of `SenderProtocol` + - `timeout` is not an `int` + - `unit_test` is not a `bool` + + ## Usage discussion + + - A Sender() class is used in the usage example below that requires an + instance of `AnsibleModule`, and uses the connection plugin (plugins/httpapi.nd.py) + to send requests to the controller. + - See ``module_utils/rest/protocols/sender.py`` for details about + implementing `Sender()` classes. + - A `ResponseHandler()` class is used in the usage example below that + abstracts controller response handling. It accepts a controller + response dict and returns a result dict. + - See `module_utils/rest/protocols/response_handler.py` for details + about implementing `ResponseHandler()` classes. + + ## Usage example + + ```python + params = {"check_mode": False, "state": "merged"} + sender = Sender() # class that implements SenderProtocol + sender.ansible_module = ansible_module + + try: + rest_send = RestSend(params) + rest_send.sender = sender + rest_send.response_handler = ResponseHandler() + rest_send.unit_test = True # optional, use in unit tests for speed + rest_send.path = "/rest/top-down/fabrics" + rest_send.verb = HttpVerbEnum.GET + rest_send.payload = my_payload # optional + rest_send.save_settings() # save current check_mode and timeout + rest_send.timeout = 300 # optional + rest_send.check_mode = True + # Do things with rest_send... + rest_send.commit() + rest_send.restore_settings() # restore check_mode and timeout + except (TypeError, ValueError) as error: + # Handle error + + # list of responses from the controller for this session + responses = rest_send.responses + # dict containing the current controller response + response_current = rest_send.response_current + # list of results from the controller for this session + results = rest_send.results + # dict containing the current controller result + result_current = rest_send.result_current + ``` + """ + + def __init__(self, params) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self.params = params + msg = "ENTERED RestSend(): " + msg += f"params: {self.params}" + self.log.debug(msg) + + self._check_mode: bool = False + self._committed_payload: Optional[dict] = None + self._path: Optional[str] = None + self._payload: Optional[dict] = None + self._response: list[dict[str, Any]] = [] + self._response_current: dict[str, Any] = {} + self._response_handler: Optional[ResponseHandlerProtocol] = None + self._result: list[dict] = [] + self._result_current: dict = {} + self._send_interval: int = 5 + self._sender: Optional[SenderProtocol] = None + self._timeout: int = 300 + self._unit_test: bool = False + self._verb: HttpVerbEnum = HttpVerbEnum.GET + + # See save_settings() and restore_settings() + self.saved_timeout: Optional[int] = None + self.saved_check_mode: Optional[bool] = None + + self.check_mode = self.params.get("check_mode", False) + + msg = "ENTERED RestSend(): " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + def restore_settings(self) -> None: + """ + # Summary + + Restore `check_mode` and `timeout` to their saved values. + + ## Raises + + None + + ## See also + + - `save_settings()` + + ## Discussion + + This is useful when a task needs to temporarily set `check_mode` + to False, (or change the timeout value) and then restore them to + their original values. + + - `check_mode` is not restored if `save_settings()` has not + previously been called. + - `timeout` is not restored if `save_settings()` has not + previously been called. + """ + if self.saved_check_mode is not None: + self.check_mode = self.saved_check_mode + if self.saved_timeout is not None: + self.timeout = self.saved_timeout + + def save_settings(self) -> None: + """ + # Summary + + Save the current values of `check_mode` and `timeout` for later + restoration. + + ## Raises + + None + + ## See also + + - `restore_settings()` + + ## NOTES + + - `check_mode` is not saved if it has not yet been initialized. + - `timeout` is not saved if it has not yet been initialized. + """ + if self.check_mode is not None: + self.saved_check_mode = self.check_mode + if self.timeout is not None: + self.saved_timeout = self.timeout + + def commit(self) -> None: + """ + # Summary + + Send the REST request to the controller + + ## Raises + + - `ValueError` if: + - RestSend()._commit_normal_mode() raises + `ValueError` + - ResponseHandler() raises `TypeError` or `ValueError` + - Sender().commit() raises `ValueError` + - `verb` is not a valid verb (GET, POST, PUT, DELETE) + - `TypeError` if: + - `check_mode` is not a `bool` + - `path` is not a `str` + - `payload` is not a `dict` + - `response` is not a `dict` + - `response_current` is not a `dict` + - `response_handler` is not an instance of + `ResponseHandler()` + - `result` is not a `dict` + - `result_current` is not a `dict` + - `send_interval` is not an `int` + - `sender` is not an instance of `Sender()` + - `timeout` is not an `int` + - `unit_test` is not a `bool` + + """ + method_name = "commit" + msg = f"{self.class_name}.{method_name}: " + msg += f"check_mode: {self.check_mode}, " + msg += f"verb: {self.verb}, " + msg += f"path: {self.path}." + self.log.debug(msg) + + try: + if self.check_mode is True: + self._commit_check_mode() + else: + self._commit_normal_mode() + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error during commit. " + msg += f"Error details: {error}" + raise ValueError(msg) from error + + def _commit_check_mode(self) -> None: + """ + # Summary + + Simulate a controller request for check_mode. + + ## Raises + + - `ValueError` if: + - ResponseHandler() raises `TypeError` or `ValueError` + - self.response_current raises `TypeError` + - self.result_current raises `TypeError` + + + ## Properties read: + + - `verb`: HttpVerbEnum e.g. HttpVerb.DELETE, HttpVerb.GET, etc. + - `path`: HTTP path e.g. http://controller_ip/path/to/endpoint + - `payload`: Optional HTTP payload + + ## Properties written: + + - `response_current`: raw simulated response + - `result_current`: result from self._handle_response() method + """ + method_name = "_commit_check_mode" + + msg = f"{self.class_name}.{method_name}: " + msg += f"verb {self.verb}, path {self.path}." + self.log.debug(msg) + + response_current: dict = {} + response_current["RETURN_CODE"] = 200 + response_current["METHOD"] = self.verb + response_current["REQUEST_PATH"] = self.path + response_current["MESSAGE"] = "OK" + response_current["CHECK_MODE"] = True + response_current["DATA"] = {"simulated": "check-mode-response", "status": "Success"} + + try: + self.response_current = response_current + self.response_handler.response = self.response_current + self.response_handler.verb = self.verb + self.response_handler.commit() + self.result_current = self.response_handler.result + self._response.append(self.response_current) + self._result.append(self.result_current) + self._committed_payload = copy.deepcopy(self._payload) + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error building response/result. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + def _commit_normal_mode(self) -> None: + """ + # Summary + + Call sender.commit() with retries until successful response or timeout is exceeded. + + ## Raises + + - `ValueError` if: + - HandleResponse() raises `ValueError` + - Sender().commit() raises `ValueError` + - `verb` is not a valid verb (GET, POST, PUT, DELETE)""" + method_name = "_commit_normal_mode" + timeout = copy.copy(self.timeout) + + msg = "Entering commit loop. " + msg += f"timeout: {timeout}, unit_test: {self.unit_test}." + self.log.debug(msg) + + self.sender.path = self.path + self.sender.verb = self.verb + if self.payload is not None: + self.sender.payload = self.payload + success = False + while timeout > 0 and success is False: + msg = f"{self.class_name}.{method_name}: " + msg += "Calling sender.commit(): " + msg += f"timeout {timeout}, success {success}, verb {self.verb}, path {self.path}." + self.log.debug(msg) + + try: + self.sender.commit() + except ValueError as error: + raise ValueError(error) from error + + self.response_current = self.sender.response + # Handle controller response and derive result + try: + self.response_handler.response = self.response_current + self.response_handler.verb = self.verb + self.response_handler.commit() + self.result_current = self.response_handler.result + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error building response/result. " + msg += f"Error detail: {error}" + self.log.debug(msg) + raise ValueError(msg) from error + + msg = f"{self.class_name}.{method_name}: " + msg += f"timeout: {timeout}. " + msg += f"result_current: {json.dumps(self.result_current, indent=4, sort_keys=True)}." + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"timeout: {timeout}. " + msg += "response_current: " + msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}." + self.log.debug(msg) + + success = self.result_current["success"] + if success is False: + if self.unit_test is False: + sleep(self.send_interval) + timeout -= self.send_interval + msg = f"{self.class_name}.{method_name}: " + msg += f"Subtracted {self.send_interval} from timeout. " + msg += f"timeout: {timeout}." + self.log.debug(msg) + + self._response.append(self.response_current) + self._result.append(self.result_current) + self._committed_payload = copy.deepcopy(self._payload) + self._payload = None + + @property + def check_mode(self) -> bool: + """ + # Summary + + Determines if changes should be made on the controller. + + ## Raises + + - `TypeError` if value is not a `bool` + + ## Default + + `False` + + - If `False`, write operations, if any, are made on the controller. + - If `True`, write operations are not made on the controller. + Instead, controller responses for write operations are simulated + to be successful (200 response code) and these simulated responses + are returned by RestSend(). Read operations are not affected + and are sent to the controller and real responses are returned. + + ## Discussion + + We want to be able to read data from the controller for read-only + operations (i.e. to set check_mode to False temporarily, even when + the user has set check_mode to True). For example, SwitchDetails + is a read-only operation, and we want to be able to read this data to + provide a real controller response to the user. + """ + return self._check_mode + + @check_mode.setter + def check_mode(self, value: bool) -> None: + method_name = "check_mode" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a boolean. Got {value}." + raise TypeError(msg) + self._check_mode = value + + @property + def committed_payload(self) -> Optional[dict]: + """ + # Summary + + Return the payload that was sent in the most recent commit, or None. + + ## Raises + + None + + ## Description + + After `commit()`, `self.payload` is reset to None. This property + preserves the payload that was actually sent, so consumers can + read it for registration in Results. + """ + return self._committed_payload + + @property + def failed_result(self) -> dict: + """ + Return a result for a failed task with no changes + """ + return Results().failed_result + + @property + def path(self) -> str: + """ + # Summary + + Endpoint path for the REST request. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + `/appcenter/cisco/ndfc/api/v1/...etc...` + """ + if self._path is None: + msg = f"{self.class_name}.path: path must be set before accessing." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str) -> None: + self._path = value + + @property + def payload(self) -> Optional[dict]: + """ + # Summary + + Return the payload to send to the controller, or None. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + """ + return self._payload + + @payload.setter + def payload(self, value: dict): + method_name = "payload" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. Got {value}." + raise TypeError(msg) + self._payload = value + + @property + def response_current(self) -> dict: + """ + # Summary + + Return the current response from the controller as a `dict`. + `commit()` must be called first. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + """ + return copy.deepcopy(self._response_current) + + @response_current.setter + def response_current(self, value): + method_name = "response_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"Value: {value}." + raise TypeError(msg) + self._response_current = value + + @property + def responses(self) -> list[dict]: + """ + # Summary + + The aggregated list of responses from the controller. + + `commit()` must be called first. + """ + return copy.deepcopy(self._response) + + def add_response(self, value: dict) -> None: + """ + # Summary + + Append a response dict to the response list. + + ## Raises + + - `TypeError` if value is not a `dict` + """ + method_name = "add_response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "value must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"Value: {value}." + raise TypeError(msg) + self._response.append(value) + + @property + def response_handler(self) -> ResponseHandlerProtocol: + """ + # Summary + + A class that implements ResponseHandlerProtocol. + + ## Raises + + - getter: `ValueError` if `response_handler` is not set before accessing. + - setter: `TypeError` if `value` does not implement `ResponseHandlerProtocol`. + + ## NOTES + + - See module_utils/rest/protocols/response_handler.py for the protocol definition. + """ + if self._response_handler is None: + msg = f"{self.class_name}.response_handler: " + msg += "response_handler must be set before accessing." + raise ValueError(msg) + return self._response_handler + + @staticmethod + def _has_member_static(obj: Any, member: str) -> bool: + """ + Check whether an object has a member without triggering descriptors. + + This avoids invoking property getters during dependency validation. + """ + try: + inspect.getattr_static(obj, member) + return True + except AttributeError: + return False + + @response_handler.setter + def response_handler(self, value: ResponseHandlerProtocol): + required_members = ( + "response", + "result", + "verb", + "commit", + "error_message", + ) + missing_members = [member for member in required_members if not self._has_member_static(value, member)] + if missing_members: + msg = f"{self.class_name}.response_handler: " + msg += "value must implement ResponseHandlerProtocol. " + msg += f"Missing members: {missing_members}. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + if not callable(getattr(value, "commit", None)): + msg = f"{self.class_name}.response_handler: " + msg += "value.commit must be callable. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._response_handler = value + + @property + def results(self) -> list[dict]: + """ + # Summary + + The aggregated list of results from the controller. + + `commit()` must be called first. + """ + return copy.deepcopy(self._result) + + def add_result(self, value: dict) -> None: + """ + # Summary + + Append a result dict to the result list. + + ## Raises + + - `TypeError` if value is not a `dict` + """ + method_name = "add_result" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += "value must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"Value: {value}." + raise TypeError(msg) + self._result.append(value) + + @property + def result_current(self) -> dict: + """ + # Summary + + The current result from the controller + + `commit()` must be called first. + + This is a dict containing the current result. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + + """ + return copy.deepcopy(self._result_current) + + @result_current.setter + def result_current(self, value: dict): + method_name = "result_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._result_current = value + + @property + def send_interval(self) -> int: + """ + # Summary + + Send interval, in seconds, for retrying responses from the controller. + + ## Raises + + - setter: ``TypeError`` if value is not an `int` + + ## Default + + `5` + """ + return self._send_interval + + @send_interval.setter + def send_interval(self, value: int) -> None: + method_name = "send_interval" + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an integer. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + # Check explicit boolean first since isinstance(True, int) is True + if isinstance(value, bool): + raise TypeError(msg) + if not isinstance(value, int): + raise TypeError(msg) + self._send_interval = value + + @property + def sender(self) -> SenderProtocol: + """ + # Summary + + A class implementing the SenderProtocol. + + See module_utils/rest/protocols/sender.py for SenderProtocol definition. + + ## Raises + + - getter: ``ValueError`` if sender is not set before accessing. + - setter: ``TypeError`` if value does not implement SenderProtocol. + """ + if self._sender is None: + msg = f"{self.class_name}.sender: " + msg += "sender must be set before accessing." + raise ValueError(msg) + return self._sender + + @sender.setter + def sender(self, value: SenderProtocol): + required_members = ( + "path", + "verb", + "payload", + "response", + "commit", + ) + missing_members = [member for member in required_members if not self._has_member_static(value, member)] + if missing_members: + msg = f"{self.class_name}.sender: " + msg += "value must implement SenderProtocol. " + msg += f"Missing members: {missing_members}. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + if not callable(getattr(value, "commit", None)): + msg = f"{self.class_name}.sender: " + msg += "value.commit must be callable. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._sender = value + + @property + def timeout(self) -> int: + """ + # Summary + + Timeout, in seconds, for retrieving responses from the controller. + + ## Raises + + - setter: ``TypeError`` if value is not an ``int`` + + ## Default + + `300` + """ + return self._timeout + + @timeout.setter + def timeout(self, value: int) -> None: + method_name = "timeout" + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an integer. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + if isinstance(value, bool): + raise TypeError(msg) + if not isinstance(value, int): + raise TypeError(msg) + self._timeout = value + + @property + def unit_test(self) -> bool: + """ + # Summary + + Is RestSend being called from a unit test. + Set this to True in unit tests to speed the test up. + + ## Raises + + - setter: `TypeError` if value is not a `bool` + + ## Default + + `False` + """ + return self._unit_test + + @unit_test.setter + def unit_test(self, value: bool) -> None: + method_name = "unit_test" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a boolean. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._unit_test = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + + ## Raises + + - setter: `TypeError` if value is not an instance of HttpVerbEnum + - getter: `ValueError` if verb is not set before accessing. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: " + msg += "verb must be set before accessing." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum): + if not isinstance(value, HttpVerbEnum): + msg = f"{self.class_name}.verb: " + msg += "verb must be an instance of HttpVerbEnum. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._verb = value diff --git a/plugins/module_utils/rest/results.py b/plugins/module_utils/rest/results.py new file mode 100644 index 00000000..59281683 --- /dev/null +++ b/plugins/module_utils/rest/results.py @@ -0,0 +1,1178 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=too-many-instance-attributes,too-many-public-methods,line-too-long,too-many-lines +""" +Exposes public class Results to collect results across Ansible tasks. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + + +import copy +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum, OperationType + + +class ApiCallResult(BaseModel): + """ + # Summary + + Pydantic model for a single task result. + + Represents all data for one API call including its response, result, diff, + and metadata. Immutable after creation to prevent accidental modification + of registered results. + + ## Raises + + - `ValidationError`: if field validation fails during instantiation + + ## Attributes + + - `sequence_number`: Unique sequence number for this task (required, >= 1) + - `path`: API endpoint path (required) + - `verb`: HTTP verb as string (required) + - `payload`: Request payload dict, or None for GET requests + - `verbosity_level`: Verbosity level for output filtering (required, 1-6) + - `response`: Controller response dict (required) + - `result`: Handler result dict (required) + - `diff`: Changes dict (required, can be empty) + - `metadata`: Task metadata dict (required) + - `changed`: Whether this task resulted in changes (required) + - `failed`: Whether this task failed (required) + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + sequence_number: int = Field(ge=1) + path: str + verb: str + payload: Optional[dict[str, Any]] = None + verbosity_level: int = Field(ge=1, le=6) + response: dict[str, Any] + result: dict[str, Any] + diff: dict[str, Any] + metadata: dict[str, Any] + changed: bool + failed: bool + + @field_validator("verb", mode="before") + @classmethod + def _coerce_verb_to_str(cls, value: Any) -> str: + """Coerce HttpVerbEnum to string.""" + if isinstance(value, HttpVerbEnum): + return value.value + return value + + +class FinalResultData(BaseModel): + """ + # Summary + + Pydantic model for the final aggregated result. + + This is the structure returned to Ansible's `exit_json`/`fail_json`. + Contains aggregated data from all registered tasks. + + ## Raises + + - `ValidationError`: if field validation fails during instantiation + + ## Attributes + + - `changed`: Overall changed status across all tasks (required) + - `failed`: Overall failed status across all tasks (required) + - `diff`: List of all diff dicts (default empty list) + - `metadata`: List of all metadata dicts (default empty list) + - `path`: List of API endpoint paths per API call (default empty list) + - `payload`: List of request payloads per API call (default empty list) + - `response`: List of all response dicts (default empty list) + - `result`: List of all result dicts (default empty list) + - `verb`: List of HTTP verbs per API call (default empty list) + - `verbosity_level`: List of verbosity levels per API call (default empty list) + """ + + model_config = ConfigDict(extra="forbid") + + changed: bool + failed: bool + diff: list[dict[str, Any]] = Field(default_factory=list) + metadata: list[dict[str, Any]] = Field(default_factory=list) + path: list[str] = Field(default_factory=list) + payload: list[Optional[dict[str, Any]]] = Field(default_factory=list) + response: list[dict[str, Any]] = Field(default_factory=list) + result: list[dict[str, Any]] = Field(default_factory=list) + verb: list[str] = Field(default_factory=list) + verbosity_level: list[int] = Field(default_factory=list) + + +class PendingApiCall(BaseModel): + """ + # Summary + + Pydantic model for the current task data being built. + + Mutable model used to stage data for the current task before + it's registered and converted to an immutable `ApiCallResult`. + Provides validation while allowing flexibility during the build phase. + + ## Raises + + - `ValidationError`: if field validation fails during instantiation or assignment + + ## Attributes + + - `response`: Controller response dict (default empty dict) + - `result`: Handler result dict (default empty dict) + - `diff`: Changes dict (default empty dict) + - `action`: Action name for metadata (default empty string) + - `state`: Ansible state for metadata (default empty string) + - `check_mode`: Check mode flag for metadata (default False) + - `operation_type`: Operation type determining if changes might occur (default QUERY) + - `path`: API endpoint path (default empty string) + - `verb`: HTTP verb (default GET) + - `payload`: Request payload dict, or None for GET requests + - `verbosity_level`: Verbosity level for output filtering (default 3, range 1-6) + """ + + model_config = ConfigDict(extra="allow", validate_assignment=True) + + response: dict[str, Any] = Field(default_factory=dict) + result: dict[str, Any] = Field(default_factory=dict) + diff: dict[str, Any] = Field(default_factory=dict) + action: str = "" + state: str = "" + check_mode: bool = False + operation_type: OperationType = OperationType.QUERY + path: str = "" + verb: HttpVerbEnum = HttpVerbEnum.GET + payload: Optional[dict[str, Any]] = None + verbosity_level: int = Field(default=3, ge=1, le=6) + + +class Results: + """ + # Summary + + Collect and aggregate results across tasks using Pydantic data models. + + ## Raises + + - `TypeError`: if properties are not of the correct type + - `ValueError`: if Pydantic validation fails or required data is missing + + ## Architecture + + This class uses a three-model Pydantic architecture for data validation: + + 1. `PendingApiCall` - Mutable staging area for building the current task + 2. `ApiCallResult` - Immutable registered API call result with validation (frozen=True) + 3. `FinalResultData` - Aggregated result for Ansible output + + The lifecycle is: **Build (Current) → Register (Task) → Aggregate (Final)** + + ## Description + + Provides a mechanism to collect results across tasks. The task classes + must support this Results class. Specifically, they must implement the + following: + + 1. Accept an instantiation of `Results()` + - Typically a class property is used for this + 2. Populate the `Results` instance with the current task data + - Set properties: `response_current`, `result_current`, `diff_current` + - Set metadata properties: `action`, `state`, `check_mode`, `operation_type` + 3. Optional. Register the task result with `Results.register_api_call()` + - Converts current task to immutable `ApiCallResult` + - Validates data with Pydantic + - Resets current task for next registration + - Tasks are NOT required to be registered. There are cases where + a task's information would not be useful to an end-user. If this + is the case, the task can simply not be registered. + + `Results` should be instantiated in the main Ansible Task class and + passed to all other task classes for which results are to be collected. + The task classes should populate the `Results` instance with the results + of the task and then register the results with `Results.register_api_call()`. + + This may be done within a separate class (as in the example below, where + the `FabricDelete()` class is called from the `TaskDelete()` class. + The `Results` instance can then be used to build the final result, by + calling `Results.build_final_result()`. + + ## Example Usage + + We assume an Ansible module structure as follows: + + - `TaskCommon()`: Common methods used by the various ansible + state classes. + - `TaskDelete(TaskCommon)`: Implements the delete state + - `TaskMerge(TaskCommon)`: Implements the merge state + - `TaskQuery(TaskCommon)`: Implements the query state + - etc... + + In TaskCommon, `Results` is instantiated and, hence, is inherited by all + state classes.: + + ```python + class TaskCommon: + def __init__(self): + self._results = Results() + + @property + def results(self) -> Results: + ''' + An instance of the Results class. + ''' + return self._results + + @results.setter + def results(self, value: Results) -> None: + self._results = value + ``` + + In each of the state classes (TaskDelete, TaskMerge, TaskQuery, etc...) + a class is instantiated (in the example below, FabricDelete) that + supports collecting results for the Results instance: + + ```python + class TaskDelete(TaskCommon): + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.fabric_delete = FabricDelete(self.ansible_module) + + def commit(self): + ''' + delete the fabric + ''' + ... + self.fabric_delete.fabric_names = ["FABRIC_1", "FABRIC_2"] + self.fabric_delete.results = self.results + # results.register_api_call() is optionally called within the + # commit() method of the FabricDelete class. + self.fabric_delete.commit() + ``` + + Finally, within the main() method of the Ansible module, the final result + is built by calling Results.build_final_result(): + + ```python + if ansible_module.params["state"] == "deleted": + task = TaskDelete(ansible_module) + task.commit() + elif ansible_module.params["state"] == "merged": + task = TaskDelete(ansible_module) + task.commit() + # etc, for other states... + + # Build the final result + task.results.build_final_result() + + # Call fail_json() or exit_json() based on the final result + if True in task.results.failed: + ansible_module.fail_json(**task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + ``` + + results.final_result will be a dict with the following structure + + ```json + { + "changed": True, # or False + "failed": True, # or False + "diff": { + [{"diff1": "diff"}, {"diff2": "diff"}, {"etc...": "diff"}], + } + "response": { + [{"response1": "response"}, {"response2": "response"}, {"etc...": "response"}], + } + "result": { + [{"result1": "result"}, {"result2": "result"}, {"etc...": "result"}], + } + "metadata": { + [{"metadata1": "metadata"}, {"metadata2": "metadata"}, {"etc...": "metadata"}], + } + } + ``` + + diff, response, and result dicts are per the Ansible ND Collection standard output. + + An example of a result dict would be (sequence_number is added by Results): + + ```json + { + "found": true, + "sequence_number": 1, + "success": true + } + ``` + + An example of a metadata dict would be (sequence_number is added by Results): + + + ```json + { + "action": "merge", + "check_mode": false, + "state": "merged", + "sequence_number": 1 + } + ``` + + `sequence_number` indicates the order in which the task was registered + with `Results`. It provides a way to correlate the diff, response, + result, and metadata across all tasks. + + ## Typical usage within a task class such as FabricDelete + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType + from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend + ... + class FabricDelete: + def __init__(self, ansible_module): + ... + self.action: str = "fabric_delete" + self.operation_type: OperationType = OperationType.DELETE # Determines if changes might occur + self._rest_send: RestSend = RestSend(params) + self._results: Results = Results() + ... + + def commit(self): + ... + # Set current task data (no need to manually track changed/failed) + self._results.response_current = self._rest_send.response_current + self._results.result_current = self._rest_send.result_current + self._results.diff_current = {} # or actual diff if available + # register_api_call() determines changed/failed automatically + self._results.register_api_call() + ... + + @property + def results(self) -> Results: + ''' + An instance of the Results class. + ''' + return self._results + @results.setter + def results(self, value: Results) -> None: + self._results = value + self._results.action = self.action + self._results.operation_type = self.operation_type + """ + + def __init__(self) -> None: + self.class_name: str = self.__class__.__name__ + + self.log: logging.Logger = logging.getLogger(f"nd.{self.class_name}") + + # Task sequence tracking + self.task_sequence_number: int = 0 + + # Registered tasks (immutable after registration) + self._tasks: list[ApiCallResult] = [] + + # Current task being built (mutable) + self._current: PendingApiCall = PendingApiCall() + + # Final result (built on demand) + self._final_result: Optional[FinalResultData] = None + + msg = f"ENTERED {self.class_name}():" + self.log.debug(msg) + + def _increment_task_sequence_number(self) -> None: + """ + # Summary + + Increment a unique task sequence number. + + ## Raises + + None + """ + self.task_sequence_number += 1 + msg = f"self.task_sequence_number: {self.task_sequence_number}" + self.log.debug(msg) + + def _determine_if_changed(self) -> bool: + """ + # Summary + + Determine if the current task resulted in changes. + + This is a private helper method used during task registration. + Checks operation type, check mode, explicit changed flag, + and diff content to determine if changes occurred. + + ## Raises + + None + + ## Returns + + - `bool`: True if changes occurred, False otherwise + """ + method_name: str = "_determine_if_changed" + + msg = f"{self.class_name}.{method_name}: ENTERED: " + msg += f"action={self._current.action}, " + msg += f"operation_type={self._current.operation_type}, " + msg += f"state={self._current.state}, " + msg += f"check_mode={self._current.check_mode}" + self.log.debug(msg) + + # Early exit for read-only operations + if self._current.check_mode or self._current.operation_type.is_read_only(): + msg = f"{self.class_name}.{method_name}: No changes (read-only operation)" + self.log.debug(msg) + return False + + # Check explicit changed flag in result + changed_flag = self._current.result.get("changed") + if changed_flag is not None: + msg = f"{self.class_name}.{method_name}: changed={changed_flag} (from result)" + self.log.debug(msg) + return changed_flag + + # Check if diff has content (besides sequence_number) + has_diff_content = any(key != "sequence_number" for key in self._current.diff) + + msg = f"{self.class_name}.{method_name}: changed={has_diff_content} (from diff)" + self.log.debug(msg) + return has_diff_content + + def register_api_call(self) -> None: + """ + # Summary + + Register the current task result. + + Converts `PendingApiCall` to immutable `ApiCallResult`, increments + sequence number, and aggregates changed/failed status. The current task + is then reset for the next task. + + ## Raises + + - `ValueError`: if Pydantic validation fails for task result data + - `ValueError`: if required fields are missing + + ## Description + + 1. Increment the task sequence number + 2. Build metadata from current task properties + 3. Determine if anything changed using `_determine_if_changed()` + 4. Determine if task failed based on `result["success"]` flag + 5. Add sequence_number to response, result, and diff + 6. Create immutable `ApiCallResult` with validation + 7. Register the task and update aggregated changed/failed sets + 8. Reset current task for next registration + """ + method_name: str = "register_api_call" + + msg = f"{self.class_name}.{method_name}: " + msg += f"ENTERED: action={self._current.action}, " + msg += f"result_current={self._current.result}" + self.log.debug(msg) + + # Increment sequence number + self._increment_task_sequence_number() + + # Build metadata from current task + metadata = { + "action": self._current.action, + "check_mode": self._current.check_mode, + "sequence_number": self.task_sequence_number, + "state": self._current.state, + } + + # Determine changed status + changed = self._determine_if_changed() + + # Determine failed status from result + success = self._current.result.get("success") + if success is True: + failed = False + elif success is False: + failed = True + else: + msg = f"{self.class_name}.{method_name}: " + msg += "result['success'] is not a boolean. " + msg += f"result={self._current.result}. " + msg += "Setting failed=False." + self.log.debug(msg) + failed = False + + # Add sequence_number to response, result, diff + response = copy.deepcopy(self._current.response) + response["sequence_number"] = self.task_sequence_number + + result = copy.deepcopy(self._current.result) + result["sequence_number"] = self.task_sequence_number + + diff = copy.deepcopy(self._current.diff) + diff["sequence_number"] = self.task_sequence_number + + # Create immutable ApiCallResult with validation + try: + task_data = ApiCallResult( + sequence_number=self.task_sequence_number, + path=self._current.path, + verb=self._current.verb, + payload=copy.deepcopy(self._current.payload) if self._current.payload is not None else None, + verbosity_level=self._current.verbosity_level, + response=response, + result=result, + diff=diff, + metadata=metadata, + changed=changed, + failed=failed, + ) + except ValidationError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Validation failed for task result: {error}" + raise ValueError(msg) from error + + # Register the task + self._tasks.append(task_data) + + # Reset current task for next task + self._current = PendingApiCall() + + # Log registration + if self.log.isEnabledFor(logging.DEBUG): + msg = f"{self.class_name}.{method_name}: " + msg += f"Registered task {self.task_sequence_number}: " + msg += f"changed={changed}, failed={failed}" + self.log.debug(msg) + + def build_final_result(self) -> None: + """ + # Summary + + Build the final result from all registered tasks. + + Creates a `FinalResultData` Pydantic model with aggregated + changed/failed status and all task data. The model is stored + internally and can be accessed via the `final_result` property. + + ## Raises + + - `ValueError`: if Pydantic validation fails for final result + + ## Description + + The final result consists of the following: + + ```json + { + "changed": True, # or False + "failed": True, + "diff": { + [], + }, + "response": { + [], + }, + "result": { + [], + }, + "metadata": { + [], + } + ``` + """ + method_name: str = "build_final_result" + + msg = f"{self.class_name}.{method_name}: " + msg += f"changed={self.changed}, failed={self.failed}" + self.log.debug(msg) + + # Aggregate data from all tasks + diff_list = [task.diff for task in self._tasks] + metadata_list = [task.metadata for task in self._tasks] + path_list = [task.path for task in self._tasks] + payload_list = [task.payload for task in self._tasks] + response_list = [task.response for task in self._tasks] + result_list = [task.result for task in self._tasks] + verb_list = [task.verb for task in self._tasks] + verbosity_level_list = [task.verbosity_level for task in self._tasks] + + # Create FinalResultData with validation + try: + self._final_result = FinalResultData( + changed=True in self.changed, + failed=True in self.failed, + diff=diff_list, + metadata=metadata_list, + path=path_list, + payload=payload_list, + response=response_list, + result=result_list, + verb=verb_list, + verbosity_level=verbosity_level_list, + ) + except ValidationError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Validation failed for final result: {error}" + raise ValueError(msg) from error + + msg = f"{self.class_name}.{method_name}: " + msg += f"Built final result: changed={self._final_result.changed}, " + msg += f"failed={self._final_result.failed}, " + msg += f"tasks={len(self._tasks)}" + self.log.debug(msg) + + @property + def final_result(self) -> dict[str, Any]: + """ + # Summary + + Return the final result as a dict for Ansible `exit_json`/`fail_json`. + + ## Raises + + - `ValueError`: if `build_final_result()` hasn't been called + + ## Returns + + - `dict[str, Any]`: The final result dictionary with all aggregated data + """ + if self._final_result is None: + msg = f"{self.class_name}.final_result: " + msg += "build_final_result() must be called before accessing final_result" + raise ValueError(msg) + return self._final_result.model_dump() + + @property + def failed_result(self) -> dict[str, Any]: + """ + # Summary + + Return a result for a failed task with no changes + + ## Raises + + None + """ + result: dict = {} + result["changed"] = False + result["failed"] = True + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] + return result + + @property + def ok_result(self) -> dict[str, Any]: + """ + # Summary + + Return a result for a successful task with no changes + + ## Raises + + None + """ + result: dict = {} + result["changed"] = False + result["failed"] = False + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] + return result + + @property + def action(self) -> str: + """ + # Summary + + Action name for the current task. + + Used in metadata to indicate the action that was taken. + + ## Raises + + None + """ + return self._current.action + + @action.setter + def action(self, value: str) -> None: + method_name: str = "action" + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a string. Got {type(value).__name__}." + raise TypeError(msg) + self._current.action = value + + @property + def operation_type(self) -> OperationType: + """ + # Summary + + The operation type for the current operation. + + Used to determine if the operation might change controller state. + + ## Raises + + None + + ## Returns + + The current operation type (`OperationType` enum value) + """ + return self._current.operation_type + + @operation_type.setter + def operation_type(self, value: OperationType) -> None: + """ + # Summary + + Set the operation type for the current task. + + ## Raises + + - `TypeError`: if value is not an `OperationType` instance + + ## Parameters + + - value: The operation type to set (must be an `OperationType` enum value) + """ + method_name: str = "operation_type" + if not isinstance(value, OperationType): + msg = f"{self.class_name}.{method_name}: " + msg += "value must be an OperationType instance. " + msg += f"Got type {type(value).__name__}, value {value}." + raise TypeError(msg) + self._current.operation_type = value + + @property + def changed(self) -> set[bool]: + """ + # Summary + + Returns a set() containing boolean values indicating whether anything changed. + + Derived from the `changed` attribute of all registered `ApiCallResult` tasks. + + ## Raises + + None + + ## Returns + + - A set() of boolean values indicating whether any tasks changed + + ## See also + + - `register_api_call()` method to register tasks. + """ + return {task.changed for task in self._tasks} + + @property + def check_mode(self) -> bool: + """ + # Summary + + Ansible check_mode flag for the current task. + + - `True` if check_mode is enabled, `False` otherwise. + + ## Raises + + None + """ + return self._current.check_mode + + @check_mode.setter + def check_mode(self, value: bool) -> None: + method_name: str = "check_mode" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a bool. Got {type(value).__name__}." + raise TypeError(msg) + self._current.check_mode = value + + @property + def diffs(self) -> list[dict[str, Any]]: + """ + # Summary + + A list of dicts representing the changes made across all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of diff dictionaries from all registered tasks + """ + return [task.diff for task in self._tasks] + + @property + def diff_current(self) -> dict[str, Any]: + """ + # Summary + + A dict representing the current diff for the current task. + + ## Raises + + - setter: `TypeError` if value is not a dict + """ + return self._current.diff + + @diff_current.setter + def diff_current(self, value: dict[str, Any]) -> None: + method_name: str = "diff_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict. Got {type(value).__name__}." + raise TypeError(msg) + self._current.diff = value + + @property + def failed(self) -> set[bool]: + """ + # Summary + + A set() of boolean values indicating whether any tasks failed. + + Derived from the `failed` attribute of all registered `ApiCallResult` tasks. + + - If the set contains True, at least one task failed. + - If the set contains only False all tasks succeeded. + + ## Raises + + None + + ## See also + + - `register_api_call()` method to register tasks. + """ + return {task.failed for task in self._tasks} + + @property + def metadata(self) -> list[dict[str, Any]]: + """ + # Summary + + A list of dicts representing the metadata for all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of metadata dictionaries from all registered tasks + """ + return [task.metadata for task in self._tasks] + + @property + def metadata_current(self) -> dict[str, Any]: + """ + # Summary + + Return the current metadata which is comprised of the following properties: + + - action + - check_mode + - sequence_number + - state + + ## Raises + + None + """ + value: dict[str, Any] = {} + value["action"] = self.action + value["check_mode"] = self.check_mode + value["sequence_number"] = self.task_sequence_number + value["state"] = self.state + return value + + @property + def response_current(self) -> dict[str, Any]: + """ + # Summary + + Return a `dict` containing the current response from the controller for the current task. + + ## Raises + + - setter: `TypeError` if value is not a dict + """ + return self._current.response + + @response_current.setter + def response_current(self, value: dict[str, Any]) -> None: + method_name: str = "response_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict. Got {type(value).__name__}." + raise TypeError(msg) + self._current.response = value + + @property + def responses(self) -> list[dict[str, Any]]: + """ + # Summary + + Return the response list; `list` of `dict`, where each `dict` contains a + response from the controller across all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of response dictionaries from all registered tasks + """ + return [task.response for task in self._tasks] + + @property + def results(self) -> list[dict[str, Any]]: + """ + # Summary + + A `list` of `dict`, where each `dict` contains a result across all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of result dictionaries from all registered tasks + """ + return [task.result for task in self._tasks] + + @property + def result_current(self) -> dict[str, Any]: + """ + # Summary + + A `dict` representing the current result for the current task. + + ## Raises + + - setter: `TypeError` if value is not a dict + """ + return self._current.result + + @result_current.setter + def result_current(self, value: dict[str, Any]) -> None: + method_name: str = "result_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict. Got {type(value).__name__}." + raise TypeError(msg) + self._current.result = value + + @property + def state(self) -> str: + """ + # Summary + + The Ansible state for the current task. + + ## Raises + + - setter: `TypeError` if value is not a string + """ + return self._current.state + + @state.setter + def state(self, value: str) -> None: + method_name: str = "state" + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a string. Got {type(value).__name__}." + raise TypeError(msg) + self._current.state = value + + @property + def path(self) -> list[str]: + """ + # Summary + + A list of API endpoint paths across all registered API calls. + + ## Raises + + None + + ## Returns + + - `list[str]`: List of path strings from all registered API calls + """ + return [task.path for task in self._tasks] + + @property + def path_current(self) -> str: + """ + # Summary + + The API endpoint path for the current task. + + ## Raises + + - setter: `TypeError` if value is not a string + """ + return self._current.path + + @path_current.setter + def path_current(self, value: str) -> None: + method_name: str = "path_current" + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a string. Got {type(value).__name__}." + raise TypeError(msg) + self._current.path = value + + @property + def verb(self) -> list[str]: + """ + # Summary + + A list of HTTP verbs across all registered API calls. + + ## Raises + + None + + ## Returns + + - `list[str]`: List of verb strings from all registered API calls + """ + return [task.verb for task in self._tasks] + + @property + def verb_current(self) -> HttpVerbEnum: + """ + # Summary + + The HTTP verb for the current task. + + ## Raises + + - setter: `TypeError` if value is not an `HttpVerbEnum` instance + """ + return self._current.verb + + @verb_current.setter + def verb_current(self, value: HttpVerbEnum) -> None: + method_name: str = "verb_current" + if not isinstance(value, HttpVerbEnum): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be an HttpVerbEnum instance. Got {type(value).__name__}." + raise TypeError(msg) + self._current.verb = value + + @property + def payload(self) -> list[Optional[dict[str, Any]]]: + """ + # Summary + + A list of request payloads across all registered API calls. + + ## Raises + + None + + ## Returns + + - `list[Optional[dict[str, Any]]]`: List of payload dicts (or None) from all registered API calls + """ + return [task.payload for task in self._tasks] + + @property + def payload_current(self) -> Optional[dict[str, Any]]: + """ + # Summary + + The request payload for the current task. + + ## Raises + + - setter: `TypeError` if value is not a dict or None + """ + return self._current.payload + + @payload_current.setter + def payload_current(self, value: Optional[dict[str, Any]]) -> None: + method_name: str = "payload_current" + if value is not None and not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict or None. Got {type(value).__name__}." + raise TypeError(msg) + self._current.payload = value + + @property + def verbosity_level(self) -> list[int]: + """ + # Summary + + A list of verbosity levels across all registered API calls. + + ## Raises + + None + + ## Returns + + - `list[int]`: List of verbosity levels from all registered API calls + """ + return [task.verbosity_level for task in self._tasks] + + @property + def verbosity_level_current(self) -> int: + """ + # Summary + + The verbosity level for the current task. + + ## Raises + + - setter: `TypeError` if value is not an int + - setter: `ValueError` if value is not in range 1-6 + """ + return self._current.verbosity_level + + @verbosity_level_current.setter + def verbosity_level_current(self, value: int) -> None: + method_name: str = "verbosity_level_current" + if isinstance(value, bool) or not isinstance(value, int): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be an int. Got {type(value).__name__}." + raise TypeError(msg) + if value < 1 or value > 6: + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be between 1 and 6. Got {value}." + raise ValueError(msg) + self._current.verbosity_level = value diff --git a/plugins/module_utils/rest/sender_nd.py b/plugins/module_utils/rest/sender_nd.py new file mode 100644 index 00000000..ae333dd0 --- /dev/null +++ b/plugins/module_utils/rest/sender_nd.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Sender module conforming to SenderProtocol. + +See plugins/module_utils/protocol_sender.py for the protocol definition. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import inspect +import json +import logging +from typing import Any, Optional + +from ansible.module_utils.basic import AnsibleModule # type: ignore +from ansible.module_utils.connection import Connection # type: ignore +from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class Sender: + """ + # Summary + + An injected dependency for `RestSend` which implements the + `sender` interface. Responses are retrieved using the Ansible HttpApi plugin. + + For the `sender` interface definition, see `plugins/module_utils/protocol_sender.py`. + + ## Raises + + - `ValueError` if: + - `ansible_module` is not set. + - `path` is not set. + - `verb` is not set. + - `TypeError` if: + - `ansible_module` is not an instance of AnsibleModule. + - `payload` is not a `dict`. + - `response` is not a `dict`. + + ## Usage + + `ansible_module` is an instance of `AnsibleModule`. + + ```python + sender = Sender() + try: + sender.ansible_module = ansible_module + rest_send = RestSend() + rest_send.sender = sender + except (TypeError, ValueError) as error: + handle_error(error) + # etc... + # See rest_send.py for RestSend() usage. + ``` + """ + + def __init__( + self, + ansible_module: Optional[AnsibleModule] = None, + verb: Optional[HttpVerbEnum] = None, + path: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + ) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._ansible_module: Optional[AnsibleModule] = ansible_module + self._connection: Optional[Connection] = None + + self._path: Optional[str] = path + self._payload: Optional[dict[str, Any]] = payload + self._response: Optional[dict[str, Any]] = None + self._verb: Optional[HttpVerbEnum] = verb + + msg = "ENTERED Sender(): " + self.log.debug(msg) + + def _get_caller_name(self) -> str: + """ + # Summary + + Get the name of the method that called the current method. + + ## Raises + + None + + ## Returns + + - `str`: The name of the calling method + """ + return inspect.stack()[2][3] + + def commit(self) -> None: + """ + # Summary + + Send the request to the controller + + ## Raises + + - `ValueError` if there is an error with the connection to the controller. + + ## Properties read + + - `verb`: HTTP verb e.g. GET, POST, PATCH, PUT, DELETE + - `path`: HTTP path e.g. /api/v1/some_endpoint + - `payload`: Optional HTTP payload + + ## Properties written + + - `response`: raw response from the controller + """ + method_name = "commit" + caller = self._get_caller_name() + + if self._connection is None: + self._connection = Connection(self.ansible_module._socket_path) # pylint: disable=protected-access + self._connection.set_params(self.ansible_module.params) + + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += "Calling Connection().send_request: " + msg += f"verb {self.verb.value}, path {self.path}" + try: + if self.payload is None: + self.log.debug(msg) + response = self._connection.send_request(self.verb.value, self.path) + else: + msg += ", payload: " + msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + response = self._connection.send_request( + self.verb.value, + self.path, + json.dumps(self.payload), + ) + # Normalize response: if JSON parsing failed, DATA will be None + # and raw content will be in the "raw" key. Convert to consistent format. + response = self._normalize_response(response) + self.response = response + except AnsibleConnectionError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"ConnectionError occurred: {error}" + self.log.error(msg) + raise ValueError(msg) from error + except Exception as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Unexpected error occurred: {error}" + self.log.error(msg) + raise ValueError(msg) from error + + def _normalize_response(self, response: dict) -> dict: + """ + # Summary + + Normalize the HttpApi response to ensure consistent format. + + If the HttpApi plugin failed to parse the response as JSON, the + `DATA` key will be None and the raw response content will be in + the `raw` key. This method converts such responses to a consistent + format where `DATA` contains a dict with the raw content. + + ## Parameters + + - `response`: The response dict from the HttpApi plugin. + + ## Returns + + The normalized response dict. + """ + if response.get("DATA") is None and response.get("raw") is not None: + response["DATA"] = {"raw_response": response.get("raw")} + # If MESSAGE is just the HTTP reason phrase, enhance it + if response.get("MESSAGE") in ("OK", None): + response["MESSAGE"] = "Response could not be parsed as JSON" + return response + + @property + def ansible_module(self) -> AnsibleModule: + """ + # Summary + + The AnsibleModule instance to use for this sender. + + ## Raises + + - `ValueError` if ansible_module is not set. + """ + if self._ansible_module is None: + msg = f"{self.class_name}.ansible_module: " + msg += "ansible_module must be set before accessing ansible_module." + raise ValueError(msg) + return self._ansible_module + + @ansible_module.setter + def ansible_module(self, value: AnsibleModule): + self._ansible_module = value + + @property + def path(self) -> str: + """ + # Summary + + Endpoint path for the REST request. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + ``/appcenter/cisco/ndfc/api/v1/...etc...`` + """ + if self._path is None: + msg = f"{self.class_name}.path: " + msg += "path must be set before accessing path." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str): + self._path = value + + @property + def payload(self) -> Optional[dict[str, Any]]: + """ + # Summary + + Return the payload to send to the controller + + ## Raises + - `TypeError` if value is not a `dict`. + """ + return self._payload + + @payload.setter + def payload(self, value: dict): + method_name = "payload" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._payload = value + + @property + def response(self) -> dict: + """ + # Summary + + The response from the controller. + + - getter: Return a deepcopy of `response` + - setter: Set `response` + + ## Raises + + - getter: `ValueError` if response is not set. + - setter: `TypeError` if value is not a `dict`. + """ + if self._response is None: + msg = f"{self.class_name}.response: " + msg += "response must be set before accessing response." + raise ValueError(msg) + return copy.deepcopy(self._response) + + @response.setter + def response(self, value: dict): + method_name = "response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._response = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request. + + ## Raises + + - getter: `ValueError` if verb is not set. + - setter: `TypeError` if value is not a `HttpVerbEnum`. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: " + msg += "verb must be set before accessing verb." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum): + method_name = "verb" + if value not in HttpVerbEnum.values(): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be one of {HttpVerbEnum.values()}. " + msg += f"Got {value}." + raise TypeError(msg) + self._verb = value diff --git a/tests/sanity/requirements.txt b/tests/sanity/requirements.txt index 8ea87eb9..2bc68e74 100644 --- a/tests/sanity/requirements.txt +++ b/tests/sanity/requirements.txt @@ -1,4 +1,4 @@ packaging # needed for update-bundled and changelog -sphinx ; python_version >= '3.5' # docs build requires python 3+ -sphinx-notfound-page ; python_version >= '3.5' # docs build requires python 3+ -straight.plugin ; python_version >= '3.5' # needed for hacking/build-ansible.py which will host changelog generation and requires python 3+ \ No newline at end of file +sphinx +sphinx-notfound-page +straight.plugin diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json b/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json new file mode 100644 index 00000000..88aa460a --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json @@ -0,0 +1,244 @@ +{ + "TEST_NOTES": [ + "Fixture data for test_rest_send.py tests", + "Provides mock controller responses for REST operations" + ], + "test_rest_send_00100a": { + "TEST_NOTES": ["Successful GET request response"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success", + "result": "test data" + } + }, + "test_rest_send_00110a": { + "TEST_NOTES": ["Successful POST request response"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/create", + "MESSAGE": "Created", + "DATA": { + "id": "12345", + "status": "created" + } + }, + "test_rest_send_00120a": { + "TEST_NOTES": ["Successful PUT request response"], + "RETURN_CODE": 200, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/test/update/12345", + "MESSAGE": "Updated", + "DATA": { + "id": "12345", + "status": "updated" + } + }, + "test_rest_send_00130a": { + "TEST_NOTES": ["Successful DELETE request response"], + "RETURN_CODE": 200, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/test/delete/12345", + "MESSAGE": "Deleted", + "DATA": { + "id": "12345", + "status": "deleted" + } + }, + "test_rest_send_00200a": { + "TEST_NOTES": ["Failed request - 404 Not Found"], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/notfound", + "MESSAGE": "Not Found", + "DATA": { + "error": "Resource not found" + } + }, + "test_rest_send_00210a": { + "TEST_NOTES": ["Failed request - 400 Bad Request"], + "RETURN_CODE": 400, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/badrequest", + "MESSAGE": "Bad Request", + "DATA": { + "error": "Invalid payload" + } + }, + "test_rest_send_00220a": { + "TEST_NOTES": ["Failed request - 500 Internal Server Error"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/servererror", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Server error occurred" + } + }, + "test_rest_send_00300a": { + "TEST_NOTES": ["First response in retry sequence - failure"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Temporary error" + } + }, + "test_rest_send_00300b": { + "TEST_NOTES": ["Second response in retry sequence - success"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "OK", + "DATA": { + "status": "success", + "result": "data after retry" + } + }, + "test_rest_send_00400a": { + "TEST_NOTES": ["GET request successful response"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + }, + "test_rest_send_00410a": { + "TEST_NOTES": ["POST request successful response"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/create", + "MESSAGE": "OK", + "DATA": { + "status": "created" + } + }, + "test_rest_send_00420a": { + "TEST_NOTES": ["PUT request successful response"], + "RETURN_CODE": 200, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/test/update/12345", + "MESSAGE": "OK", + "DATA": { + "status": "updated" + } + }, + "test_rest_send_00430a": { + "TEST_NOTES": ["DELETE request successful response"], + "RETURN_CODE": 200, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/test/delete/12345", + "MESSAGE": "OK", + "DATA": { + "status": "deleted" + } + }, + "test_rest_send_00500a": { + "TEST_NOTES": ["404 Not Found response"], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/notfound", + "MESSAGE": "Not Found", + "DATA": { + "error": "Resource not found" + } + }, + "test_rest_send_00510a": { + "TEST_NOTES": ["400 Bad Request response"], + "RETURN_CODE": 400, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/badrequest", + "MESSAGE": "Bad Request", + "DATA": { + "error": "Invalid request data" + } + }, + "test_rest_send_00520a": { + "TEST_NOTES": ["500 Internal Server Error response"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/servererror", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Server error occurred" + } + }, + "test_rest_send_00600a": { + "TEST_NOTES": ["First response - 500 error for retry test"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Temporary error" + } + }, + "test_rest_send_00600b": { + "TEST_NOTES": ["Second response - success after retry"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + }, + "test_rest_send_00600c": { + "TEST_NOTES": ["Multiple sequential requests - third"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/multi/create", + "MESSAGE": "Created", + "DATA": { + "id": 3, + "name": "third", + "status": "created" + } + }, + "test_rest_send_00700a": { + "TEST_NOTES": ["First sequential GET"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/multi/1", + "MESSAGE": "OK", + "DATA": { + "id": 1 + } + }, + "test_rest_send_00700b": { + "TEST_NOTES": ["Second sequential GET"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/multi/2", + "MESSAGE": "OK", + "DATA": { + "id": 2 + } + }, + "test_rest_send_00700c": { + "TEST_NOTES": ["Third sequential POST"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/multi/create", + "MESSAGE": "OK", + "DATA": { + "id": 3, + "status": "created" + } + }, + "test_rest_send_00900a": { + "TEST_NOTES": ["Response for deepcopy test"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + } +} diff --git a/tests/unit/module_utils/fixtures/load_fixture.py b/tests/unit/module_utils/fixtures/load_fixture.py new file mode 100644 index 00000000..ec5a84d3 --- /dev/null +++ b/tests/unit/module_utils/fixtures/load_fixture.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Function to load test inputs from JSON files. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import json +import os +import sys + +fixture_path = os.path.join(os.path.dirname(__file__), "fixture_data") + + +def load_fixture(filename): + """ + load test inputs from json files + """ + path = os.path.join(fixture_path, f"{filename}.json") + + try: + with open(path, encoding="utf-8") as file_handle: + data = file_handle.read() + except IOError as exception: + msg = f"Exception opening test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + try: + fixture = json.loads(data) + except json.JSONDecodeError as exception: + msg = "Exception reading JSON contents in " + msg += f"test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + return fixture diff --git a/tests/unit/module_utils/mock_ansible_module.py b/tests/unit/module_utils/mock_ansible_module.py new file mode 100644 index 00000000..d58397df --- /dev/null +++ b/tests/unit/module_utils/mock_ansible_module.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Mock AnsibleModule for unit testing. + +This module provides a mock implementation of Ansible's AnsibleModule +to avoid circular import issues between sender_file.py and common_utils.py. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + + +# Define base exception class +class AnsibleFailJson(Exception): + """ + Exception raised by MockAnsibleModule.fail_json() + """ + + +# Try to import AnsibleFailJson from ansible.netcommon if available +# This allows compatibility with tests that expect the netcommon version +try: + from ansible_collections.ansible.netcommon.tests.unit.modules.utils import AnsibleFailJson as _NetcommonFailJson + + # Use the netcommon version if available + AnsibleFailJson = _NetcommonFailJson # type: ignore[misc] +except ImportError: + # Use the local version defined above + pass + + +class MockAnsibleModule: + """ + # Summary + + Mock the AnsibleModule class for unit testing. + + ## Attributes + + - check_mode: Whether the module is running in check mode + - params: Module parameters dictionary + - argument_spec: Module argument specification + - supports_check_mode: Whether the module supports check mode + + ## Methods + + - fail_json: Raises AnsibleFailJson exception with the provided message + """ + + check_mode = False + + params = {"config": {"switches": [{"ip_address": "172.22.150.105"}]}} + argument_spec = { + "config": {"required": True, "type": "dict"}, + "state": {"default": "merged", "choices": ["merged", "deleted", "query"]}, + "check_mode": False, + } + supports_check_mode = True + + @staticmethod + def fail_json(msg, **kwargs) -> AnsibleFailJson: + """ + # Summary + + Mock the fail_json method. + + ## Parameters + + - msg: Error message + - kwargs: Additional keyword arguments (ignored) + + ## Raises + + - AnsibleFailJson: Always raised with the provided message + """ + raise AnsibleFailJson(msg) + + def public_method_for_pylint(self): + """ + # Summary + + Add one public method to appease pylint. + + ## Raises + + None + """ diff --git a/tests/unit/module_utils/response_generator.py b/tests/unit/module_utils/response_generator.py new file mode 100644 index 00000000..e96aad70 --- /dev/null +++ b/tests/unit/module_utils/response_generator.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Response generator for unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + + +class ResponseGenerator: + """ + Given a coroutine which yields dictionaries, return the yielded items + with each call to the next property + + For usage in the context of dcnm_image_policy unit tests, see: + test: test_image_policy_create_bulk_00037 + file: tests/unit/modules/dcnm/dcnm_image_policy/test_image_policy_create_bulk.py + + Simplified usage example below. + + def responses(): + yield {"key1": "value1"} + yield {"key2": "value2"} + + gen = ResponseGenerator(responses()) + + print(gen.next) # {"key1": "value1"} + print(gen.next) # {"key2": "value2"} + """ + + def __init__(self, gen): + self.gen = gen + + @property + def next(self): + """ + Return the next item in the generator + """ + return next(self.gen) + + @property + def implements(self): + """ + ### Summary + Used by Sender() classes to verify Sender().gen is a + response generator which implements the response_generator + interfacee. + """ + return "response_generator" + + def public_method_for_pylint(self): + """ + Add one public method to appease pylint + """ diff --git a/tests/unit/module_utils/sender_file.py b/tests/unit/module_utils/sender_file.py new file mode 100644 index 00000000..7060e8c0 --- /dev/null +++ b/tests/unit/module_utils/sender_file.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Sender module conforming to SenderProtocol for file-based mock responses. + +See plugins/module_utils/protocol_sender.py for the protocol definition. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import inspect +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.mock_ansible_module import MockAnsibleModule +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator + + +class Sender: + """ + # Summary + + An injected dependency for `RestSend` which implements the + `sender` interface. Responses are read from JSON files. + + ## Raises + + - `ValueError` if: + - `gen` is not set. + - `TypeError` if: + - `gen` is not an instance of ResponseGenerator() + + ## Usage + + - `gen` is an instance of `ResponseGenerator()` which yields simulated responses. + In the example below, `responses()` is a generator that yields dictionaries. + However, in practice, it would yield responses read from JSON files. + - `responses()` is a coroutine that yields controller responses. + In the example below, it yields to dictionaries. However, in + practice, it would yield responses read from JSON files. + + ```python + def responses(): + yield {"key1": "value1"} + yield {"key2": "value2"} + + sender = Sender() + sender.gen = ResponseGenerator(responses()) + + try: + rest_send = RestSend() + rest_send.sender = sender + except (TypeError, ValueError) as error: + handle_error(error) + # etc... + # See rest_send.py for RestSend() usage. + ``` + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._ansible_module: Optional[MockAnsibleModule] = None + self._gen: Optional[ResponseGenerator] = None + self._path: Optional[str] = None + self._payload: Optional[dict[str, Any]] = None + self._response: Optional[dict[str, Any]] = None + self._verb: Optional[HttpVerbEnum] = None + + self._raise_method: Optional[str] = None + self._raise_exception: Optional[BaseException] = None + + msg = "ENTERED Sender(): " + self.log.debug(msg) + + def commit(self) -> None: + """ + # Summary + + - Simulate a commit to a controller (does nothing). + - Allows to simulate exceptions for testing error handling in RestSend by setting the `raise_exception` and `raise_method` properties. + + ## Raises + + - `ValueError` if `gen` is not set. + - `self.raise_exception` if set and + `self.raise_method` == "commit" + """ + method_name = "commit" + + if self.raise_method == method_name and self.raise_exception is not None: + msg = f"{self.class_name}.{method_name}: " + msg += f"Simulated {type(self.raise_exception).__name__}." + raise self.raise_exception + + caller = inspect.stack()[1][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"caller {caller}" + self.log.debug(msg) + + @property + def ansible_module(self) -> Optional[MockAnsibleModule]: + """ + # Summary + + Mock ansible_module + """ + return self._ansible_module + + @ansible_module.setter + def ansible_module(self, value: Optional[MockAnsibleModule]): + self._ansible_module = value + + @property + def gen(self) -> ResponseGenerator: + """ + # Summary + + The `ResponseGenerator()` instance which yields simulated responses. + + ## Raises + + - `ValueError` if `gen` is not set. + - `TypeError` if value is not a class implementing the `response_generator` interface. + """ + if self._gen is None: + msg = f"{self.class_name}.gen: gen must be set to a class implementing the response_generator interface." + raise ValueError(msg) + return self._gen + + @gen.setter + def gen(self, value: ResponseGenerator) -> None: + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "Expected a class implementing the " + msg += "response_generator interface. " + msg += f"Got {value}." + try: + implements = value.implements + except AttributeError as error: + raise TypeError(msg) from error + if implements != "response_generator": + raise TypeError(msg) + self._gen = value + + @property + def path(self) -> str: + """ + # Summary + + Dummy path. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + ``/appcenter/cisco/ndfc/api/v1/...etc...`` + """ + if self._path is None: + msg = f"{self.class_name}.path: path must be set before accessing." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str): + self._path = value + + @property + def payload(self) -> Optional[dict[str, Any]]: + """ + # Summary + + Dummy payload. + + ## Raises + + None + """ + return self._payload + + @payload.setter + def payload(self, value: Optional[dict[str, Any]]): + self._payload = value + + @property + def raise_exception(self) -> Optional[BaseException]: + """ + # Summary + + The exception to raise when calling the method specified in `raise_method`. + + ## Raises + + - `TypeError` if value is not a subclass of `BaseException`. + + ## Usage + + ```python + instance = Sender() + instance.raise_method = "commit" + instance.raise_exception = ValueError + instance.commit() # will raise a simulated ValueError + ``` + + ## Notes + + - No error checking is done on the input to this property. + """ + if self._raise_exception is not None and not issubclass(type(self._raise_exception), BaseException): + msg = f"{self.class_name}.raise_exception: " + msg += "raise_exception must be a subclass of BaseException. " + msg += f"Got {self._raise_exception} of type {type(self._raise_exception).__name__}." + raise TypeError(msg) + return self._raise_exception + + @raise_exception.setter + def raise_exception(self, value: Optional[BaseException]): + if value is not None and not issubclass(type(value), BaseException): + msg = f"{self.class_name}.raise_exception: " + msg += "raise_exception must be a subclass of BaseException. " + msg += f"Got {value} of type {type(value).__name__}." + raise TypeError(msg) + self._raise_exception = value + + @property + def raise_method(self) -> Optional[str]: + """ + ## Summary + + The method in which to raise exception `raise_exception`. + + ## Raises + + None + + ## Usage + + See `raise_exception`. + """ + return self._raise_method + + @raise_method.setter + def raise_method(self, value: Optional[str]) -> None: + self._raise_method = value + + @property + def response(self) -> dict[str, Any]: + """ + # Summary + + The simulated response from a file. + + Returns a deepcopy to prevent mutation of the response object. + + ## Raises + + None + """ + return copy.deepcopy(self.gen.next) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Dummy Verb. + + ## Raises + + - `ValueError` if verb is not set. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: verb must be set before accessing." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + self._verb = value diff --git a/tests/unit/module_utils/test_response_handler_nd.py b/tests/unit/module_utils/test_response_handler_nd.py new file mode 100644 index 00000000..f3250dbc --- /dev/null +++ b/tests/unit/module_utils/test_response_handler_nd.py @@ -0,0 +1,1496 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for response_handler_nd.py + +Tests the ResponseHandler class for handling ND controller responses. +""" + +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=invalid-name +# pylint: disable=line-too-long +# pylint: disable=too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_strategies.nd_v1_strategy import NdV1Strategy +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + +# ============================================================================= +# Test: ResponseHandler initialization +# ============================================================================= + + +def test_response_handler_nd_00010(): + """ + # Summary + + Verify ResponseHandler initialization with default values. + + ## Test + + - Instance can be created + - _response defaults to None + - _result defaults to None + - _verb defaults to None + - _strategy defaults to NdV1Strategy instance + + ## Classes and Methods + + - ResponseHandler.__init__() + """ + with does_not_raise(): + instance = ResponseHandler() + assert instance._response is None + assert instance._result is None + assert instance._verb is None + assert isinstance(instance._strategy, NdV1Strategy) + + +def test_response_handler_nd_00015(): + """ + # Summary + + Verify validation_strategy getter returns the default NdV1Strategy and + setter accepts a valid strategy. + + ## Test + + - Default strategy is NdV1Strategy + - Setting a new NdV1Strategy instance is accepted + - Getter returns the newly set strategy + + ## Classes and Methods + + - ResponseHandler.validation_strategy (getter/setter) + """ + instance = ResponseHandler() + assert isinstance(instance.validation_strategy, NdV1Strategy) + + new_strategy = NdV1Strategy() + with does_not_raise(): + instance.validation_strategy = new_strategy + assert instance.validation_strategy is new_strategy + + +def test_response_handler_nd_00020(): + """ + # Summary + + Verify validation_strategy setter raises TypeError for invalid type. + + ## Test + + - Setting validation_strategy to a non-strategy object raises TypeError + + ## Classes and Methods + + - ResponseHandler.validation_strategy (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.validation_strategy:.*Expected ResponseValidationStrategy" + with pytest.raises(TypeError, match=match): + instance.validation_strategy = "not a strategy" # type: ignore[assignment] + + +# ============================================================================= +# Test: ResponseHandler.response property +# ============================================================================= + + +def test_response_handler_nd_00100(): + """ + # Summary + + Verify response getter raises ValueError when not set. + + ## Test + + - Accessing response before setting raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response + + +def test_response_handler_nd_00110(): + """ + # Summary + + Verify response setter/getter with valid dict. + + ## Test + + - response can be set with a valid dict containing RETURN_CODE and MESSAGE + - response getter returns the set value + + ## Classes and Methods + + - ResponseHandler.response (setter/getter) + """ + instance = ResponseHandler() + response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"key": "value"}} + with does_not_raise(): + instance.response = response + result = instance.response + assert result["RETURN_CODE"] == 200 + assert result["MESSAGE"] == "OK" + + +def test_response_handler_nd_00120(): + """ + # Summary + + Verify response setter raises TypeError for non-dict. + + ## Test + + - Setting response to a non-dict raises TypeError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.response = "not a dict" # type: ignore[assignment] + + +def test_response_handler_nd_00130(): + """ + # Summary + + Verify response setter raises ValueError when MESSAGE key is missing. + + ## Test + + - Setting response without MESSAGE raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must have a MESSAGE key" + with pytest.raises(ValueError, match=match): + instance.response = {"RETURN_CODE": 200} + + +def test_response_handler_nd_00140(): + """ + # Summary + + Verify response setter raises ValueError when RETURN_CODE key is missing. + + ## Test + + - Setting response without RETURN_CODE raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must have a RETURN_CODE key" + with pytest.raises(ValueError, match=match): + instance.response = {"MESSAGE": "OK"} + + +# ============================================================================= +# Test: ResponseHandler.verb property +# ============================================================================= + + +def test_response_handler_nd_00200(): + """ + # Summary + + Verify verb getter raises ValueError when not set. + + ## Test + + - Accessing verb before setting raises ValueError + + ## Classes and Methods + + - ResponseHandler.verb (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.verb is not set" + with pytest.raises(ValueError, match=match): + result = instance.verb + + +def test_response_handler_nd_00210(): + """ + # Summary + + Verify verb setter/getter with valid HttpVerbEnum. + + ## Test + + - verb can be set and retrieved with HttpVerbEnum values + + ## Classes and Methods + + - ResponseHandler.verb (setter/getter) + """ + instance = ResponseHandler() + with does_not_raise(): + instance.verb = HttpVerbEnum.GET + result = instance.verb + assert result == HttpVerbEnum.GET + + with does_not_raise(): + instance.verb = HttpVerbEnum.POST + result = instance.verb + assert result == HttpVerbEnum.POST + + +# ============================================================================= +# Test: ResponseHandler.result property +# ============================================================================= + + +def test_response_handler_nd_00300(): + """ + # Summary + + Verify result getter raises ValueError when commit() not called. + + ## Test + + - Accessing result before calling commit() raises ValueError + + ## Classes and Methods + + - ResponseHandler.result (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.result:.*must be set before accessing.*commit" + with pytest.raises(ValueError, match=match): + result = instance.result + + +def test_response_handler_nd_00310(): + """ + # Summary + + Verify result setter raises TypeError for non-dict. + + ## Test + + - Setting result to non-dict raises TypeError + + ## Classes and Methods + + - ResponseHandler.result (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.result.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.result = "not a dict" # type: ignore[assignment] + + +# ============================================================================= +# Test: ResponseHandler.commit() validation +# ============================================================================= + + +def test_response_handler_nd_00400(): + """ + # Summary + + Verify commit() raises ValueError when response is not set. + + ## Test + + - Calling commit() without setting response raises ValueError + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.verb = HttpVerbEnum.GET + match = r"ResponseHandler\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_response_handler_nd_00410(): + """ + # Summary + + Verify commit() raises ValueError when verb is not set. + + ## Test + + - Calling commit() without setting verb raises ValueError + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + match = r"ResponseHandler\.verb is not set" + with pytest.raises(ValueError, match=match): + instance.commit() + + +# ============================================================================= +# Test: ResponseHandler._handle_get_response() +# ============================================================================= + + +def test_response_handler_nd_00500(): + """ + # Summary + + Verify GET response with 200 OK. + + ## Test + + - GET with RETURN_CODE 200 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00510(): + """ + # Summary + + Verify GET response with 201 Created. + + ## Test + + - GET with RETURN_CODE 201 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 201, "MESSAGE": "Created"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00520(): + """ + # Summary + + Verify GET response with 202 Accepted. + + ## Test + + - GET with RETURN_CODE 202 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 202, "MESSAGE": "Accepted"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00530(): + """ + # Summary + + Verify GET response with 204 No Content. + + ## Test + + - GET with RETURN_CODE 204 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 204, "MESSAGE": "No Content"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00535(): + """ + # Summary + + Verify GET response with 207 Multi-Status. + + ## Test + + - GET with RETURN_CODE 207 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 207, "MESSAGE": "Multi-Status"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00540(): + """ + # Summary + + Verify GET response with 404 Not Found. + + ## Test + + - GET with RETURN_CODE 404 sets found=False, success=True + - 404 is treated as "not found but not an error" + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 404, "MESSAGE": "Not Found"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is True + + +def test_response_handler_nd_00550(): + """ + # Summary + + Verify GET response with 500 Internal Server Error. + + ## Test + + - GET with RETURN_CODE 500 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 500, "MESSAGE": "Internal Server Error"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00560(): + """ + # Summary + + Verify GET response with 400 Bad Request. + + ## Test + + - GET with RETURN_CODE 400 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 400, "MESSAGE": "Bad Request"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00570(): + """ + # Summary + + Verify GET response with 401 Unauthorized. + + ## Test + + - GET with RETURN_CODE 401 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 401, "MESSAGE": "Unauthorized"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00575(): + """ + # Summary + + Verify GET response with 405 Method Not Allowed. + + ## Test + + - GET with RETURN_CODE 405 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 405, "MESSAGE": "Method Not Allowed"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00580(): + """ + # Summary + + Verify GET response with 409 Conflict. + + ## Test + + - GET with RETURN_CODE 409 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 409, "MESSAGE": "Conflict"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +# ============================================================================= +# Test: ResponseHandler._handle_post_put_delete_response() +# ============================================================================= + + +def test_response_handler_nd_00600(): + """ + # Summary + + Verify POST response with 200 OK (no errors). + + ## Test + + - POST with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"status": "created"}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00610(): + """ + # Summary + + Verify PUT response with 200 OK. + + ## Test + + - PUT with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"status": "updated"}} + instance.verb = HttpVerbEnum.PUT + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00620(): + """ + # Summary + + Verify DELETE response with 200 OK. + + ## Test + + - DELETE with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00630(): + """ + # Summary + + Verify POST response with 201 Created. + + ## Test + + - POST with RETURN_CODE 201 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 201, "MESSAGE": "Created", "DATA": {}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00640(): + """ + # Summary + + Verify POST response with 202 Accepted. + + ## Test + + - POST with RETURN_CODE 202 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 202, "MESSAGE": "Accepted", "DATA": {}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00650(): + """ + # Summary + + Verify DELETE response with 204 No Content. + + ## Test + + - DELETE with RETURN_CODE 204 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 204, "MESSAGE": "No Content", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00655(): + """ + # Summary + + Verify POST response with 207 Multi-Status. + + ## Test + + - POST with RETURN_CODE 207 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 207, "MESSAGE": "Multi-Status", "DATA": {"status": "partial"}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00660(): + """ + # Summary + + Verify POST response with explicit ERROR key. + + ## Test + + - Response containing ERROR key sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "ERROR": "Something went wrong", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00670(): + """ + # Summary + + Verify POST response with DATA.error (ND error format). + + ## Test + + - Response with DATA containing error key sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"error": "ND error occurred"}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00680(): + """ + # Summary + + Verify POST response with 500 error status code. + + ## Test + + - POST with RETURN_CODE 500 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00690(): + """ + # Summary + + Verify POST response with 400 Bad Request. + + ## Test + + - POST with RETURN_CODE 400 and no explicit errors sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00695(): + """ + # Summary + + Verify POST response with 405 Method Not Allowed. + + ## Test + + - POST with RETURN_CODE 405 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 405, + "MESSAGE": "Method Not Allowed", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00705(): + """ + # Summary + + Verify POST response with 409 Conflict. + + ## Test + + - POST with RETURN_CODE 409 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 409, + "MESSAGE": "Conflict", + "DATA": {"reason": "resource exists"}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +# ============================================================================= +# Test: ResponseHandler.error_message property +# ============================================================================= + + +def test_response_handler_nd_00700(): + """ + # Summary + + Verify error_message returns None on successful response. + + ## Test + + - error_message is None when result indicates success + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is None + + +def test_response_handler_nd_00710(): + """ + # Summary + + Verify error_message returns None when commit() not called. + + ## Test + + - error_message is None when _result is None (commit not called) + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + assert instance.error_message is None + + +def test_response_handler_nd_00720(): + """ + # Summary + + Verify error_message for raw_response format (non-JSON response). + + ## Test + + - When DATA contains raw_response key, error_message indicates non-JSON response + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": {"raw_response": "Error"}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "could not be parsed as JSON" in instance.error_message + + +def test_response_handler_nd_00730(): + """ + # Summary + + Verify error_message for code/message format. + + ## Test + + - When DATA contains code and message keys, error_message includes both + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"code": "INVALID_INPUT", "message": "Field X is required"}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "INVALID_INPUT" in instance.error_message + assert "Field X is required" in instance.error_message + + +def test_response_handler_nd_00740(): + """ + # Summary + + Verify error_message for messages array format. + + ## Test + + - When DATA contains messages array with code/severity/message, + error_message includes all three fields + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": { + "messages": [ + { + "code": "ERR_001", + "severity": "ERROR", + "message": "Validation failed", + } + ] + }, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "ERR_001" in instance.error_message + assert "ERROR" in instance.error_message + assert "Validation failed" in instance.error_message + + +def test_response_handler_nd_00750(): + """ + # Summary + + Verify error_message for errors array format. + + ## Test + + - When DATA contains errors array, error_message includes the first error + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"errors": ["First error message", "Second error message"]}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "First error message" in instance.error_message + + +def test_response_handler_nd_00760(): + """ + # Summary + + Verify error_message when DATA is None (connection failure). + + ## Test + + - When DATA is None, error_message includes REQUEST_PATH and MESSAGE + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Connection refused", + "REQUEST_PATH": "/api/v1/some/endpoint", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "Connection failed" in instance.error_message + assert "/api/v1/some/endpoint" in instance.error_message + assert "Connection refused" in instance.error_message + + +def test_response_handler_nd_00770(): + """ + # Summary + + Verify error_message with non-dict DATA. + + ## Test + + - When DATA is a non-dict value, error_message includes stringified DATA + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": "Unexpected string error", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "Unexpected string error" in instance.error_message + + +def test_response_handler_nd_00780(): + """ + # Summary + + Verify error_message fallback for unknown dict format. + + ## Test + + - When DATA is a dict with no recognized error format, + error_message falls back to including RETURN_CODE + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 503, + "MESSAGE": "Service Unavailable", + "DATA": {"some_unknown_key": "some_value"}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "503" in instance.error_message + + +def test_response_handler_nd_00790(): + """ + # Summary + + Verify error_message returns None when result success is True. + + ## Test + + - Even with error-like DATA, if result is success, error_message is None + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"errors": ["Some error"]}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + # For GET with 200, success is True regardless of DATA content + assert instance.result["success"] is True + assert instance.error_message is None + + +def test_response_handler_nd_00800(): + """ + # Summary + + Verify error_message for connection failure with no REQUEST_PATH. + + ## Test + + - When DATA is None and REQUEST_PATH is missing, error_message uses "unknown" + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Connection timed out", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "unknown" in instance.error_message + assert "Connection timed out" in instance.error_message + + +def test_response_handler_nd_00810(): + """ + # Summary + + Verify error_message for messages array with empty array. + + ## Test + + - When DATA contains an empty messages array, messages format is skipped + and fallback is used + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"messages": []}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "400" in instance.error_message + + +def test_response_handler_nd_00820(): + """ + # Summary + + Verify error_message for errors array with empty array. + + ## Test + + - When DATA contains an empty errors array, errors format is skipped + and fallback is used + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"errors": []}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "400" in instance.error_message + + +# ============================================================================= +# Test: ResponseHandler._handle_response() routing +# ============================================================================= + + +def test_response_handler_nd_00900(): + """ + # Summary + + Verify _handle_response routes GET to _handle_get_response. + + ## Test + + - GET verb produces result with "found" key (not "changed") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_get_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert "found" in instance.result + assert "changed" not in instance.result + + +def test_response_handler_nd_00910(): + """ + # Summary + + Verify _handle_response routes POST to _handle_post_put_delete_response. + + ## Test + + - POST verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.POST + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +def test_response_handler_nd_00920(): + """ + # Summary + + Verify _handle_response routes PUT to _handle_post_put_delete_response. + + ## Test + + - PUT verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.PUT + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +def test_response_handler_nd_00930(): + """ + # Summary + + Verify _handle_response routes DELETE to _handle_post_put_delete_response. + + ## Test + + - DELETE verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +# ============================================================================= +# Test: ResponseHandler with code/message + messages array in same response +# ============================================================================= + + +def test_response_handler_nd_01000(): + """ + # Summary + + Verify error_message prefers code/message format over messages array. + + ## Test + + - When DATA contains both code/message and messages array, + code/message takes priority + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": { + "code": "PRIMARY_ERROR", + "message": "Primary error message", + "messages": [ + { + "code": "SECONDARY", + "severity": "WARNING", + "message": "Secondary message", + } + ], + }, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "PRIMARY_ERROR" in instance.error_message + assert "Primary error message" in instance.error_message + + +# ============================================================================= +# Test: ResponseHandler commit() can be called multiple times +# ============================================================================= + + +def test_response_handler_nd_01100(): + """ + # Summary + + Verify commit() can be called with different responses. + + ## Test + + - First commit with 200 success + - Second commit with 500 error + - result reflects the most recent commit + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + + # First commit - success + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.result["success"] is True + assert instance.result["found"] is True + + # Second commit - failure + instance.response = {"RETURN_CODE": 500, "MESSAGE": "Internal Server Error"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.result["success"] is False + assert instance.result["found"] is False diff --git a/tests/unit/module_utils/test_rest_send.py b/tests/unit/module_utils/test_rest_send.py new file mode 100644 index 00000000..5f5a8500 --- /dev/null +++ b/tests/unit/module_utils/test_rest_send.py @@ -0,0 +1,1551 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for rest_send.py + +Tests the RestSend class for sending REST requests with retries +""" + +# pylint: disable=disallowed-name,protected-access,too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import inspect + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise +from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture +from ansible_collections.cisco.nd.tests.unit.module_utils.mock_ansible_module import MockAnsibleModule +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator +from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender + + +def responses_rest_send(key: str): + """ + Load fixture data for rest_send tests + """ + return load_fixture("test_rest_send")[key] + + +# ============================================================================= +# Test: RestSend initialization +# ============================================================================= + + +def test_rest_send_00010(): + """ + # Summary + + Verify RestSend initialization with default values + + ## Test + + - Instance can be created with params dict + - check_mode defaults to False + - timeout defaults to 300 + - send_interval defaults to 5 + - unit_test defaults to False + + ## Classes and Methods + + - RestSend.__init__() + """ + params = {"check_mode": False, "state": "merged"} + with does_not_raise(): + instance = RestSend(params) + assert instance.check_mode is False + assert instance.timeout == 300 + assert instance.send_interval == 5 + assert instance.unit_test is False + + +def test_rest_send_00020(): + """ + # Summary + + Verify RestSend initialization with check_mode True + + ## Test + + - check_mode can be set via params + + ## Classes and Methods + + - RestSend.__init__() + """ + params = {"check_mode": True, "state": "merged"} + with does_not_raise(): + instance = RestSend(params) + assert instance.check_mode is True + + +def test_rest_send_00030(): + """ + # Summary + + Verify RestSend raises TypeError for invalid check_mode + + ## Test + + - check_mode setter raises TypeError if not bool + + ## Classes and Methods + + - RestSend.check_mode + """ + params = {"check_mode": False} + instance = RestSend(params) + match = r"RestSend\.check_mode:.*must be a boolean" + with pytest.raises(TypeError, match=match): + instance.check_mode = "invalid" # type: ignore[assignment] + + +# ============================================================================= +# Test: RestSend property setters/getters +# ============================================================================= + + +def test_rest_send_00100(): + """ + # Summary + + Verify path property getter/setter + + ## Test + + - path can be set and retrieved + - ValueError raised if accessed before being set + + ## Classes and Methods + + - RestSend.path + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test ValueError when accessing before setting + match = r"RestSend\.path:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.path # pylint: disable=pointless-statement + + # Test setter/getter + with does_not_raise(): + instance.path = "/api/v1/test/endpoint" + result = instance.path + assert result == "/api/v1/test/endpoint" + + +def test_rest_send_00110(): + """ + # Summary + + Verify verb property getter/setter + + ## Test + + - verb can be set and retrieved with HttpVerbEnum + - verb has default value of HttpVerbEnum.GET + - TypeError raised if not HttpVerbEnum + + ## Classes and Methods + + - RestSend.verb + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + with does_not_raise(): + result = instance.verb + assert result == HttpVerbEnum.GET + + # Test TypeError for invalid type + match = r"RestSend\.verb:.*must be an instance of HttpVerbEnum" + with pytest.raises(TypeError, match=match): + instance.verb = "GET" # type: ignore[assignment] + + # Test setter/getter with valid HttpVerbEnum + with does_not_raise(): + instance.verb = HttpVerbEnum.POST + result = instance.verb + assert result == HttpVerbEnum.POST + + +def test_rest_send_00120(): + """ + # Summary + + Verify payload property getter/setter + + ## Test + + - payload can be set and retrieved + - payload defaults to None + - TypeError raised if not dict + + ## Classes and Methods + + - RestSend.payload + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + with does_not_raise(): + result = instance.payload + assert result is None + + # Test TypeError for invalid type + match = r"RestSend\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = "invalid" # type: ignore[assignment] + + # Test setter/getter with dict + with does_not_raise(): + instance.payload = {"key": "value"} + result = instance.payload + assert result == {"key": "value"} + + +def test_rest_send_00130(): + """ + # Summary + + Verify timeout property getter/setter + + ## Test + + - timeout can be set and retrieved + - timeout defaults to 300 + - TypeError raised if not int + + ## Classes and Methods + + - RestSend.timeout + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + assert instance.timeout == 300 + + # Test TypeError for boolean (bool is subclass of int) + match = r"RestSend\.timeout:.*must be an integer" + with pytest.raises(TypeError, match=match): + instance.timeout = True # type: ignore[assignment] + + # Test TypeError for string + with pytest.raises(TypeError, match=match): + instance.timeout = "300" # type: ignore[assignment] + + # Test setter/getter with int + with does_not_raise(): + instance.timeout = 600 + assert instance.timeout == 600 + + +def test_rest_send_00140(): + """ + # Summary + + Verify send_interval property getter/setter + + ## Test + + - send_interval can be set and retrieved + - send_interval defaults to 5 + - TypeError raised if not int + + ## Classes and Methods + + - RestSend.send_interval + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + assert instance.send_interval == 5 + + # Test TypeError for boolean + match = r"RestSend\.send_interval:.*must be an integer" + with pytest.raises(TypeError, match=match): + instance.send_interval = False # type: ignore[assignment] + + # Test setter/getter with int + with does_not_raise(): + instance.send_interval = 10 + assert instance.send_interval == 10 + + +def test_rest_send_00150(): + """ + # Summary + + Verify unit_test property getter/setter + + ## Test + + - unit_test can be set and retrieved + - unit_test defaults to False + - TypeError raised if not bool + + ## Classes and Methods + + - RestSend.unit_test + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + assert instance.unit_test is False + + # Test TypeError for non-bool + match = r"RestSend\.unit_test:.*must be a boolean" + with pytest.raises(TypeError, match=match): + instance.unit_test = "true" # type: ignore[assignment] + + # Test setter/getter with bool + with does_not_raise(): + instance.unit_test = True + assert instance.unit_test is True + + +def test_rest_send_00160(): + """ + # Summary + + Verify sender property getter/setter + + ## Test + + - sender must be set before accessing + - sender must implement SenderProtocol + - ValueError raised if accessed before being set + - TypeError raised if not SenderProtocol + + ## Classes and Methods + + - RestSend.sender + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test ValueError when accessing before setting + match = r"RestSend\.sender:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.sender # pylint: disable=pointless-statement + + # Test TypeError for invalid type + match = r"RestSend\.sender:.*must implement SenderProtocol" + with pytest.raises(TypeError, match=match): + instance.sender = "invalid" # type: ignore[assignment] + + # Test setter/getter with valid Sender + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.sender = sender + result = instance.sender + assert result is sender + + +def test_rest_send_00170(): + """ + # Summary + + Verify response_handler property getter/setter + + ## Test + + - response_handler must be set before accessing + - response_handler must implement ResponseHandlerProtocol + - ValueError raised if accessed before being set + - TypeError raised if not ResponseHandlerProtocol + + ## Classes and Methods + + - RestSend.response_handler + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test ValueError when accessing before setting + match = r"RestSend\.response_handler:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response_handler # pylint: disable=pointless-statement + + # Test TypeError for invalid type + match = r"RestSend\.response_handler:.*must implement ResponseHandlerProtocol" + with pytest.raises(TypeError, match=match): + instance.response_handler = "invalid" # type: ignore[assignment] + + # Test setter/getter with valid ResponseHandler + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + instance.sender = sender + + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + with does_not_raise(): + instance.response_handler = response_handler + result = instance.response_handler + assert result is response_handler + + +# ============================================================================= +# Test: RestSend save_settings() and restore_settings() +# ============================================================================= + + +def test_rest_send_00200(): + """ + # Summary + + Verify save_settings() and restore_settings() + + ## Test + + - save_settings() saves current check_mode and timeout + - restore_settings() restores saved values + + ## Classes and Methods + + - RestSend.save_settings() + - RestSend.restore_settings() + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Set initial values + instance.check_mode = False + instance.timeout = 300 + + # Save settings + with does_not_raise(): + instance.save_settings() + + # Modify values + instance.check_mode = True + instance.timeout = 600 + + # Verify modified values + assert instance.check_mode is True + assert instance.timeout == 600 + + # Restore settings + with does_not_raise(): + instance.restore_settings() + + # Verify restored values + assert instance.check_mode is False + assert instance.timeout == 300 + + +def test_rest_send_00210(): + """ + # Summary + + Verify restore_settings() when save_settings() not called + + ## Test + + - restore_settings() does nothing if save_settings() not called + + ## Classes and Methods + + - RestSend.restore_settings() + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Set values without saving + instance.check_mode = True + instance.timeout = 600 + + # Call restore_settings without prior save + with does_not_raise(): + instance.restore_settings() + + # Values should remain unchanged + assert instance.check_mode is True + assert instance.timeout == 600 + + +# ============================================================================= +# Test: RestSend commit() in check mode +# ============================================================================= + + +def test_rest_send_00300(): + """ + # Summary + + Verify commit() in check_mode for GET request + + ## Test + + - GET requests in check_mode return simulated success response + - response_current contains check mode indicator + - result_current shows success + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_check_mode() + """ + params = {"check_mode": True} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/checkmode" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify check mode response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["METHOD"] == HttpVerbEnum.GET + assert instance.response_current["REQUEST_PATH"] == "/api/v1/test/checkmode" + assert instance.response_current["CHECK_MODE"] is True + assert instance.result_current["success"] is True + assert instance.result_current["found"] is True + + +def test_rest_send_00310(): + """ + # Summary + + Verify commit() in check_mode for POST request + + ## Test + + - POST requests in check_mode return simulated success response + - changed flag is True for write operations + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_check_mode() + """ + params = {"check_mode": True} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + response_handler.verb = HttpVerbEnum.POST + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "test"} + instance.commit() + + # Verify check mode response for write operation + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["METHOD"] == HttpVerbEnum.POST + assert instance.response_current["CHECK_MODE"] is True + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +# ============================================================================= +# Test: RestSend commit() in normal mode with successful responses +# ============================================================================= + + +def test_rest_send_00400(): + """ + # Summary + + Verify commit() with successful GET request + + ## Test + + - GET request returns successful response + - response_current and result_current are populated + - response and result lists contain the responses + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/endpoint" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["METHOD"] == "GET" + assert instance.response_current["DATA"]["status"] == "success" + + # Verify result (GET requests return "found", not "changed") + assert instance.result_current["success"] is True + assert instance.result_current["found"] is True + + # Verify response and result lists + assert len(instance.responses) == 1 + assert len(instance.results) == 1 + + +def test_rest_send_00410(): + """ + # Summary + + Verify commit() with successful POST request + + ## Test + + - POST request with payload returns successful response + - changed flag is True for write operations + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "test"} + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "created" + + # Verify result + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +def test_rest_send_00420(): + """ + # Summary + + Verify commit() with successful PUT request + + ## Test + + - PUT request returns successful response + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/update/12345" + instance.verb = HttpVerbEnum.PUT + instance.payload = {"status": "updated"} + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "updated" + + # Verify result + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +def test_rest_send_00430(): + """ + # Summary + + Verify commit() with successful DELETE request + + ## Test + + - DELETE request returns successful response + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/delete/12345" + instance.verb = HttpVerbEnum.DELETE + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "deleted" + + # Verify result + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +# ============================================================================= +# Test: RestSend commit() with failed responses +# ============================================================================= + + +def test_rest_send_00500(): + """ + # Summary + + Verify commit() with 404 Not Found response + + ## Test + + - Failed GET request returns 404 response + - result shows success=False + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 1 + instance.path = "/api/v1/test/notfound" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify error response (GET with 404 returns "found": False) + assert instance.response_current["RETURN_CODE"] == 404 + assert instance.result_current["success"] is True + assert instance.result_current["found"] is False + + +def test_rest_send_00510(): + """ + # Summary + + Verify commit() with 400 Bad Request response + + ## Test + + - Failed POST request returns 400 response + - Loop retries until timeout is exhausted + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide responses for multiple retry attempts (60 retries * 5 second interval = 300 seconds) + for _ in range(60): + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 10 + instance.send_interval = 5 + instance.path = "/api/v1/test/badrequest" + instance.verb = HttpVerbEnum.POST + instance.payload = {"invalid": "data"} + instance.commit() + + # Verify error response + assert instance.response_current["RETURN_CODE"] == 400 + assert instance.result_current["success"] is False + + +def test_rest_send_00520(): + """ + # Summary + + Verify commit() with 500 Internal Server Error response + + ## Test + + - Failed GET request returns 500 response + - Loop retries until timeout is exhausted + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide responses for multiple retry attempts (60 retries * 5 second interval = 300 seconds) + for _ in range(60): + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 10 + instance.send_interval = 5 + instance.path = "/api/v1/test/servererror" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify error response + assert instance.response_current["RETURN_CODE"] == 500 + assert instance.result_current["success"] is False + + +# ============================================================================= +# Test: RestSend commit() with retry logic +# ============================================================================= + + +def test_rest_send_00600(): + """ + # Summary + + Verify commit() retries on failure then succeeds + + ## Test + + - First response is 500 error + - Second response is 200 success + - Final result is success + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + + def responses(): + # Retry test sequence: error then success + yield responses_rest_send(f"{method_name}a") + yield responses_rest_send(f"{method_name}a") + yield responses_rest_send(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 10 + instance.send_interval = 1 + instance.path = "/api/v1/test/retry" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify final successful response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "success" + assert instance.result_current["success"] is True + + +# ============================================================================= +# Test: RestSend multiple sequential commits +# ============================================================================= + + +def test_rest_send_00700(): + """ + # Summary + + Verify multiple sequential commit() calls + + ## Test + + - Multiple commits append to response and result lists + - Each commit populates response_current and result_current + + ## Classes and Methods + + - RestSend.commit() + """ + method_name = inspect.stack()[0][3] + + def responses(): + # 3 sequential commits + yield responses_rest_send(f"{method_name}a") + yield responses_rest_send(f"{method_name}b") + yield responses_rest_send(f"{method_name}c") + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + + # First commit - GET + with does_not_raise(): + instance.path = "/api/v1/test/multi/1" + instance.verb = HttpVerbEnum.GET + instance.commit() + + assert instance.response_current["DATA"]["id"] == 1 + assert len(instance.responses) == 1 + assert len(instance.results) == 1 + + # Second commit - GET + with does_not_raise(): + instance.path = "/api/v1/test/multi/2" + instance.verb = HttpVerbEnum.GET + instance.commit() + + assert instance.response_current["DATA"]["id"] == 2 + assert len(instance.responses) == 2 + assert len(instance.results) == 2 + + # Third commit - POST + with does_not_raise(): + instance.path = "/api/v1/test/multi/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "third"} + instance.commit() + + assert instance.response_current["DATA"]["id"] == 3 + assert instance.response_current["DATA"]["status"] == "created" + assert len(instance.responses) == 3 + assert len(instance.results) == 3 + + +# ============================================================================= +# Test: RestSend error conditions +# ============================================================================= + + +def test_rest_send_00800(): + """ + # Summary + + Verify commit() raises ValueError when path not set + + ## Test + + - commit() raises ValueError if path not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.verb = HttpVerbEnum.GET + + # Don't set path - should raise ValueError + match = r"RestSend\.path:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_rest_send_00810(): + """ + # Summary + + Verify commit() raises ValueError when verb not set + + ## Test + + - commit() raises ValueError if verb not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.path = "/api/v1/test" + + # Reset verb to None to test ValueError + instance._verb = None # type: ignore[assignment] + + match = r"RestSend\.verb:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_rest_send_00820(): + """ + # Summary + + Verify commit() raises ValueError when sender not set + + ## Test + + - commit() raises ValueError if sender not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + instance = RestSend(params) + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + # Don't set sender - should raise ValueError + match = r"RestSend\.sender:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_rest_send_00830(): + """ + # Summary + + Verify commit() raises ValueError when response_handler not set + + ## Test + + - commit() raises ValueError if response_handler not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + def responses(): + # Stub responses (not consumed in this test) + yield {} + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + # Don't set response_handler - should raise ValueError + match = r"RestSend\.response_handler:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +# ============================================================================= +# Test: RestSend response and result properties +# ============================================================================= + + +def test_rest_send_00900(): + """ + # Summary + + Verify response and result properties return copies + + ## Test + + - response returns deepcopy of response list + - result returns deepcopy of result list + - Modifying returned values doesn't affect internal state + + ## Classes and Methods + + - RestSend.response + - RestSend.result + - RestSend.response_current + - RestSend.result_current + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/endpoint" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Get response and result + response_copy = instance.responses + result_copy = instance.results + response_current_copy = instance.response_current + result_current_copy = instance.result_current + + # Modify copies + response_copy[0]["MODIFIED"] = True + result_copy[0]["MODIFIED"] = True + response_current_copy["MODIFIED"] = True + result_current_copy["MODIFIED"] = True + + # Verify original values unchanged + assert "MODIFIED" not in instance._response[0] + assert "MODIFIED" not in instance._result[0] + assert "MODIFIED" not in instance._response_current + assert "MODIFIED" not in instance._result_current + + +def test_rest_send_00910(): + """ + # Summary + + Verify failed_result property + + ## Test + + - failed_result returns a failure dict with changed=False + + ## Classes and Methods + + - RestSend.failed_result + """ + params = {"check_mode": False} + instance = RestSend(params) + + with does_not_raise(): + result = instance.failed_result + + assert result["failed"] is True + assert result["changed"] is False + + +# ============================================================================= +# Test: RestSend with sender exception simulation +# ============================================================================= + + +def test_rest_send_01000(): + """ + # Summary + + Verify commit() handles sender exceptions + + ## Test + + - Sender.commit() can raise exceptions + - RestSend.commit() propagates the exception + + ## Classes and Methods + + - RestSend.commit() + - Sender.commit() + - Sender.raise_exception + - Sender.raise_method + """ + params = {"check_mode": False} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + # Configure sender to raise exception + sender.raise_method = "commit" + sender.raise_exception = ValueError("Simulated sender error") + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + # commit() should raise ValueError + match = r"Simulated sender error" + with pytest.raises(ValueError, match=match): + instance.commit() + + +# ============================================================================= +# Test: RestSend.add_response() +# ============================================================================= + + +def test_rest_send_add_response_success(): + """ + # Summary + + Verify add_response() appends a valid dict to the response list. + + ## Test + + - add_response() with a valid dict appends to the response list + + ## Classes and Methods + + - RestSend.add_response + """ + params = {"check_mode": False} + instance = RestSend(params) + + with does_not_raise(): + instance.add_response({"RETURN_CODE": 200}) + instance.add_response({"RETURN_CODE": 404}) + + assert len(instance.responses) == 2 + assert instance.responses[0] == {"RETURN_CODE": 200} + assert instance.responses[1] == {"RETURN_CODE": 404} + + +def test_rest_send_add_response_type_error(): + """ + # Summary + + Verify add_response() raises TypeError for non-dict value. + + ## Test + + - add_response() raises TypeError if value is not a dict + + ## Classes and Methods + + - RestSend.add_response + """ + params = {"check_mode": False} + instance = RestSend(params) + + match = r"RestSend\.add_response:.*value must be a dict" + with pytest.raises(TypeError, match=match): + instance.add_response("invalid") # type: ignore[arg-type] + + +# ============================================================================= +# Test: RestSend.add_result() +# ============================================================================= + + +def test_rest_send_add_result_success(): + """ + # Summary + + Verify add_result() appends a valid dict to the result list. + + ## Test + + - add_result() with a valid dict appends to the result list + + ## Classes and Methods + + - RestSend.add_result + """ + params = {"check_mode": False} + instance = RestSend(params) + + with does_not_raise(): + instance.add_result({"changed": True}) + instance.add_result({"changed": False}) + + assert len(instance.results) == 2 + assert instance.results[0] == {"changed": True} + assert instance.results[1] == {"changed": False} + + +def test_rest_send_add_result_type_error(): + """ + # Summary + + Verify add_result() raises TypeError for non-dict value. + + ## Test + + - add_result() raises TypeError if value is not a dict + + ## Classes and Methods + + - RestSend.add_result + """ + params = {"check_mode": False} + instance = RestSend(params) + + match = r"RestSend\.add_result:.*value must be a dict" + with pytest.raises(TypeError, match=match): + instance.add_result("invalid") # type: ignore[arg-type] diff --git a/tests/unit/module_utils/test_results.py b/tests/unit/module_utils/test_results.py new file mode 100644 index 00000000..a7d3609f --- /dev/null +++ b/tests/unit/module_utils/test_results.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for results.py + +Tests the Results class and its Pydantic models for collecting and aggregating +API call results. +""" + +# pylint: disable=protected-access + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum, OperationType +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import ( + ApiCallResult, + PendingApiCall, + Results, +) + +# ============================================================================= +# Helper: register a task with all fields populated +# ============================================================================= + + +def _register_task(results, path="/api/v1/test", verb=HttpVerbEnum.POST, payload=None, verbosity_level=3): + """Register a single task with the given request-side fields.""" + results.path_current = path + results.verb_current = verb + results.payload_current = payload + results.verbosity_level_current = verbosity_level + results.action = "test_action" + results.state = "merged" + results.check_mode = False + results.operation_type = OperationType.DELETE + results.response_current = {"RETURN_CODE": 200, "MESSAGE": "OK"} + results.result_current = {"success": True, "changed": True} + results.diff_current = {"before": {}, "after": {"foo": "bar"}} + results.register_api_call() + + +# ============================================================================= +# Test: PendingApiCall new fields +# ============================================================================= + + +class TestPendingApiCallNewFields: + """Tests for the new fields on PendingApiCall.""" + + def test_defaults(self): + """New fields have correct defaults.""" + pending = PendingApiCall() + assert pending.path == "" + assert pending.verb == HttpVerbEnum.GET + assert pending.payload is None + assert pending.verbosity_level == 3 + + def test_explicit_values(self): + """New fields accept explicit values.""" + pending = PendingApiCall( + path="/api/v1/fabrics", + verb=HttpVerbEnum.DELETE, + payload={"name": "FABRIC_1"}, + verbosity_level=5, + ) + assert pending.path == "/api/v1/fabrics" + assert pending.verb == HttpVerbEnum.DELETE + assert pending.payload == {"name": "FABRIC_1"} + assert pending.verbosity_level == 5 + + def test_verbosity_level_min_boundary(self): + """verbosity_level rejects values below 1.""" + with pytest.raises(Exception): + PendingApiCall(verbosity_level=0) + + def test_verbosity_level_max_boundary(self): + """verbosity_level rejects values above 6.""" + with pytest.raises(Exception): + PendingApiCall(verbosity_level=7) + + def test_verbosity_level_valid_boundaries(self): + """verbosity_level accepts boundary values 1 and 6.""" + p1 = PendingApiCall(verbosity_level=1) + p6 = PendingApiCall(verbosity_level=6) + assert p1.verbosity_level == 1 + assert p6.verbosity_level == 6 + + +# ============================================================================= +# Test: ApiCallResult new fields +# ============================================================================= + + +class TestApiCallResultNewFields: + """Tests for the new fields on ApiCallResult.""" + + @staticmethod + def _make_result(**overrides): + """Create an ApiCallResult with sensible defaults, allowing overrides.""" + defaults = { + "sequence_number": 1, + "path": "/api/v1/test", + "verb": "POST", + "payload": None, + "verbosity_level": 3, + "response": {"RETURN_CODE": 200}, + "result": {"success": True}, + "diff": {}, + "metadata": {"action": "test"}, + "changed": False, + "failed": False, + } + defaults.update(overrides) + return ApiCallResult(**defaults) + + def test_stores_request_fields(self): + """ApiCallResult stores path, verb, payload, verbosity_level.""" + task = self._make_result( + path="/api/v1/fabrics", + verb="DELETE", + payload={"name": "FAB1"}, + verbosity_level=5, + ) + assert task.path == "/api/v1/fabrics" + assert task.verb == "DELETE" + assert task.payload == {"name": "FAB1"} + assert task.verbosity_level == 5 + + def test_verb_validator_coerces_enum(self): + """field_validator coerces HttpVerbEnum to string.""" + task = self._make_result(verb=HttpVerbEnum.PUT) + assert task.verb == "PUT" + assert isinstance(task.verb, str) + + def test_verb_validator_passes_string(self): + """field_validator passes plain strings through.""" + task = self._make_result(verb="GET") + assert task.verb == "GET" + + def test_payload_none_allowed(self): + """payload=None is valid (e.g. for GET requests).""" + task = self._make_result(payload=None) + assert task.payload is None + + def test_verbosity_level_rejects_out_of_range(self): + """verbosity_level outside 1-6 raises ValidationError.""" + with pytest.raises(Exception): + self._make_result(verbosity_level=0) + with pytest.raises(Exception): + self._make_result(verbosity_level=7) + + def test_frozen(self): + """ApiCallResult is immutable.""" + task = self._make_result() + with pytest.raises(Exception): + task.path = "/new/path" + + +# ============================================================================= +# Test: Results current-task properties (getters/setters) +# ============================================================================= + + +class TestResultsCurrentProperties: + """Tests for path_current, verb_current, payload_current, verbosity_level_current.""" + + def test_path_current_get_set(self): + """path_current getter/setter works.""" + r = Results() + assert r.path_current == "" + r.path_current = "/api/v1/foo" + assert r.path_current == "/api/v1/foo" + + def test_path_current_type_error(self): + """path_current setter rejects non-string.""" + r = Results() + with pytest.raises(TypeError, match="value must be a string"): + r.path_current = 123 + + def test_verb_current_get_set(self): + """verb_current getter/setter works.""" + r = Results() + assert r.verb_current == HttpVerbEnum.GET + r.verb_current = HttpVerbEnum.POST + assert r.verb_current == HttpVerbEnum.POST + + def test_verb_current_type_error(self): + """verb_current setter rejects non-HttpVerbEnum.""" + r = Results() + with pytest.raises(TypeError, match="value must be an HttpVerbEnum"): + r.verb_current = "POST" + + def test_payload_current_get_set(self): + """payload_current getter/setter works with dict and None.""" + r = Results() + assert r.payload_current is None + r.payload_current = {"key": "val"} + assert r.payload_current == {"key": "val"} + r.payload_current = None + assert r.payload_current is None + + def test_payload_current_type_error(self): + """payload_current setter rejects non-dict/non-None.""" + r = Results() + with pytest.raises(TypeError, match="value must be a dict or None"): + r.payload_current = "not a dict" + + def test_verbosity_level_current_get_set(self): + """verbosity_level_current getter/setter works.""" + r = Results() + assert r.verbosity_level_current == 3 + r.verbosity_level_current = 5 + assert r.verbosity_level_current == 5 + + def test_verbosity_level_current_type_error(self): + """verbosity_level_current setter rejects non-int.""" + r = Results() + with pytest.raises(TypeError, match="value must be an int"): + r.verbosity_level_current = "high" + + def test_verbosity_level_current_type_error_bool(self): + """verbosity_level_current setter rejects bool (isinstance(True, int) is True).""" + r = Results() + with pytest.raises(TypeError, match="value must be an int"): + r.verbosity_level_current = True + + def test_verbosity_level_current_value_error_low(self): + """verbosity_level_current setter rejects value < 1.""" + r = Results() + with pytest.raises(ValueError, match="value must be between 1 and 6"): + r.verbosity_level_current = 0 + + def test_verbosity_level_current_value_error_high(self): + """verbosity_level_current setter rejects value > 6.""" + r = Results() + with pytest.raises(ValueError, match="value must be between 1 and 6"): + r.verbosity_level_current = 7 + + +# ============================================================================= +# Test: register_api_call captures new fields +# ============================================================================= + + +class TestRegisterApiCallNewFields: + """Tests that register_api_call() captures the new request-side fields.""" + + def test_captures_all_new_fields(self): + """register_api_call stores path, verb, payload, verbosity_level on the task.""" + r = Results() + payload = {"fabric": "FAB1"} + _register_task(r, path="/api/v1/fabrics", verb=HttpVerbEnum.POST, payload=payload, verbosity_level=4) + + assert len(r._tasks) == 1 + task = r._tasks[0] + assert task.path == "/api/v1/fabrics" + assert task.verb == "POST" # coerced from enum + assert task.payload == {"fabric": "FAB1"} + assert task.verbosity_level == 4 + + def test_captures_none_payload(self): + """register_api_call handles None payload correctly.""" + r = Results() + _register_task(r, payload=None) + assert r._tasks[0].payload is None + + def test_payload_is_deep_copied(self): + """register_api_call deep-copies payload to prevent mutation.""" + r = Results() + payload = {"nested": {"key": "original"}} + _register_task(r, payload=payload) + # Mutate original + payload["nested"]["key"] = "mutated" + # Registered copy should be unaffected + assert r._tasks[0].payload["nested"]["key"] == "original" + + def test_defaults_when_not_set(self): + """When new fields are not explicitly set, defaults are used.""" + r = Results() + r.action = "test_action" + r.state = "merged" + r.check_mode = False + r.operation_type = OperationType.QUERY + r.response_current = {"RETURN_CODE": 200, "MESSAGE": "OK"} + r.result_current = {"success": True} + r.diff_current = {} + r.register_api_call() + + task = r._tasks[0] + assert task.path == "" + assert task.verb == "GET" + assert task.payload is None + assert task.verbosity_level == 3 + + +# ============================================================================= +# Test: aggregate properties (path, verb, payload, verbosity_level) +# ============================================================================= + + +class TestAggregateProperties: + """Tests for the aggregate list properties.""" + + def test_aggregate_properties(self): + """Aggregate properties return lists across all registered tasks.""" + r = Results() + _register_task(r, path="/api/v1/a", verb=HttpVerbEnum.GET, payload=None, verbosity_level=1) + _register_task(r, path="/api/v1/b", verb=HttpVerbEnum.POST, payload={"x": 1}, verbosity_level=5) + + assert r.path == ["/api/v1/a", "/api/v1/b"] + assert r.verb == ["GET", "POST"] + assert r.payload == [None, {"x": 1}] + assert r.verbosity_level == [1, 5] + + def test_empty_when_no_tasks(self): + """Aggregate properties return empty lists when no tasks registered.""" + r = Results() + assert r.path == [] + assert r.verb == [] + assert r.payload == [] + assert r.verbosity_level == [] + + +# ============================================================================= +# Test: build_final_result includes new fields +# ============================================================================= + + +class TestBuildFinalResultNewFields: + """Tests that build_final_result() includes the new fields.""" + + def test_final_result_includes_new_fields(self): + """build_final_result populates path, verb, payload, verbosity_level.""" + r = Results() + _register_task(r, path="/api/v1/fabrics", verb=HttpVerbEnum.DELETE, payload={"name": "F1"}, verbosity_level=2) + _register_task(r, path="/api/v1/switches", verb=HttpVerbEnum.GET, payload=None, verbosity_level=4) + + r.build_final_result() + result = r.final_result + + assert result["path"] == ["/api/v1/fabrics", "/api/v1/switches"] + assert result["verb"] == ["DELETE", "GET"] + assert result["payload"] == [{"name": "F1"}, None] + assert result["verbosity_level"] == [2, 4] + + def test_final_result_empty_tasks(self): + """build_final_result with no tasks produces empty lists for new fields.""" + r = Results() + r.build_final_result() + result = r.final_result + + assert result["path"] == [] + assert result["verb"] == [] + assert result["payload"] == [] + assert result["verbosity_level"] == [] diff --git a/tests/unit/module_utils/test_sender_nd.py b/tests/unit/module_utils/test_sender_nd.py new file mode 100644 index 00000000..5edd102f --- /dev/null +++ b/tests/unit/module_utils/test_sender_nd.py @@ -0,0 +1,906 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for sender_nd.py + +Tests the Sender class for sending REST requests via the Ansible HttpApi plugin. +""" + +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=invalid-name +# pylint: disable=line-too-long +# pylint: disable=too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from unittest.mock import MagicMock, patch + +import pytest +from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + +# ============================================================================= +# Test: Sender initialization +# ============================================================================= + + +def test_sender_nd_00010(): + """ + # Summary + + Verify Sender initialization with default values. + + ## Test + + - Instance can be created with no arguments + - All attributes default to None + + ## Classes and Methods + + - Sender.__init__() + """ + with does_not_raise(): + instance = Sender() + assert instance._ansible_module is None + assert instance._connection is None + assert instance._path is None + assert instance._payload is None + assert instance._response is None + assert instance._verb is None + + +def test_sender_nd_00020(): + """ + # Summary + + Verify Sender initialization with all parameters. + + ## Test + + - Instance can be created with all optional constructor arguments + + ## Classes and Methods + + - Sender.__init__() + """ + mock_module = MagicMock() + with does_not_raise(): + instance = Sender( + ansible_module=mock_module, + verb=HttpVerbEnum.GET, + path="/api/v1/test", + payload={"key": "value"}, + ) + assert instance._ansible_module is mock_module + assert instance._path == "/api/v1/test" + assert instance._payload == {"key": "value"} + assert instance._verb == HttpVerbEnum.GET + + +# ============================================================================= +# Test: Sender.ansible_module property +# ============================================================================= + + +def test_sender_nd_00100(): + """ + # Summary + + Verify ansible_module getter raises ValueError when not set. + + ## Test + + - Accessing ansible_module before setting raises ValueError + + ## Classes and Methods + + - Sender.ansible_module (getter) + """ + instance = Sender() + match = r"Sender\.ansible_module:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.ansible_module + + +def test_sender_nd_00110(): + """ + # Summary + + Verify ansible_module setter/getter. + + ## Test + + - ansible_module can be set and retrieved + + ## Classes and Methods + + - Sender.ansible_module (setter/getter) + """ + instance = Sender() + mock_module = MagicMock() + with does_not_raise(): + instance.ansible_module = mock_module + result = instance.ansible_module + assert result is mock_module + + +# ============================================================================= +# Test: Sender.path property +# ============================================================================= + + +def test_sender_nd_00200(): + """ + # Summary + + Verify path getter raises ValueError when not set. + + ## Test + + - Accessing path before setting raises ValueError + + ## Classes and Methods + + - Sender.path (getter) + """ + instance = Sender() + match = r"Sender\.path:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.path + + +def test_sender_nd_00210(): + """ + # Summary + + Verify path setter/getter. + + ## Test + + - path can be set and retrieved + + ## Classes and Methods + + - Sender.path (setter/getter) + """ + instance = Sender() + with does_not_raise(): + instance.path = "/api/v1/test/endpoint" + result = instance.path + assert result == "/api/v1/test/endpoint" + + +# ============================================================================= +# Test: Sender.verb property +# ============================================================================= + + +def test_sender_nd_00300(): + """ + # Summary + + Verify verb getter raises ValueError when not set. + + ## Test + + - Accessing verb before setting raises ValueError + + ## Classes and Methods + + - Sender.verb (getter) + """ + instance = Sender() + match = r"Sender\.verb:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.verb + + +def test_sender_nd_00310(): + """ + # Summary + + Verify verb setter/getter with valid HttpVerbEnum. + + ## Test + + - verb can be set and retrieved with all HttpVerbEnum values + + ## Classes and Methods + + - Sender.verb (setter/getter) + """ + instance = Sender() + for verb in (HttpVerbEnum.GET, HttpVerbEnum.POST, HttpVerbEnum.PUT, HttpVerbEnum.DELETE): + with does_not_raise(): + instance.verb = verb + result = instance.verb + assert result == verb + + +def test_sender_nd_00320(): + """ + # Summary + + Verify verb setter raises TypeError for invalid value. + + ## Test + + - Setting verb to a value not in HttpVerbEnum.values() raises TypeError + + ## Classes and Methods + + - Sender.verb (setter) + """ + instance = Sender() + match = r"Sender\.verb:.*must be one of" + with pytest.raises(TypeError, match=match): + instance.verb = "INVALID" # type: ignore[assignment] + + +# ============================================================================= +# Test: Sender.payload property +# ============================================================================= + + +def test_sender_nd_00400(): + """ + # Summary + + Verify payload defaults to None. + + ## Test + + - payload is None by default + + ## Classes and Methods + + - Sender.payload (getter) + """ + instance = Sender() + with does_not_raise(): + result = instance.payload + assert result is None + + +def test_sender_nd_00410(): + """ + # Summary + + Verify payload setter/getter with valid dict. + + ## Test + + - payload can be set and retrieved + + ## Classes and Methods + + - Sender.payload (setter/getter) + """ + instance = Sender() + with does_not_raise(): + instance.payload = {"name": "test", "config": {"key": "value"}} + result = instance.payload + assert result == {"name": "test", "config": {"key": "value"}} + + +def test_sender_nd_00420(): + """ + # Summary + + Verify payload setter raises TypeError for non-dict. + + ## Test + + - Setting payload to a non-dict raises TypeError + + ## Classes and Methods + + - Sender.payload (setter) + """ + instance = Sender() + match = r"Sender\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = "not a dict" # type: ignore[assignment] + + +def test_sender_nd_00430(): + """ + # Summary + + Verify payload setter raises TypeError for list. + + ## Test + + - Setting payload to a list raises TypeError + + ## Classes and Methods + + - Sender.payload (setter) + """ + instance = Sender() + match = r"Sender\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = [1, 2, 3] # type: ignore[assignment] + + +# ============================================================================= +# Test: Sender.response property +# ============================================================================= + + +def test_sender_nd_00500(): + """ + # Summary + + Verify response getter raises ValueError when not set. + + ## Test + + - Accessing response before commit raises ValueError + + ## Classes and Methods + + - Sender.response (getter) + """ + instance = Sender() + match = r"Sender\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response + + +def test_sender_nd_00510(): + """ + # Summary + + Verify response getter returns deepcopy. + + ## Test + + - response getter returns a deepcopy of the internal response + + ## Classes and Methods + + - Sender.response (getter) + """ + instance = Sender() + instance._response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"key": "value"}} + result = instance.response + # Modify the copy + result["MODIFIED"] = True + # Verify original is unchanged + assert "MODIFIED" not in instance._response + + +def test_sender_nd_00520(): + """ + # Summary + + Verify response setter raises TypeError for non-dict. + + ## Test + + - Setting response to a non-dict raises TypeError + + ## Classes and Methods + + - Sender.response (setter) + """ + instance = Sender() + match = r"Sender\.response:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.response = "not a dict" # type: ignore[assignment] + + +def test_sender_nd_00530(): + """ + # Summary + + Verify response setter accepts valid dict. + + ## Test + + - response can be set with a valid dict + + ## Classes and Methods + + - Sender.response (setter/getter) + """ + instance = Sender() + response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + with does_not_raise(): + instance.response = response + result = instance.response + assert result["RETURN_CODE"] == 200 + assert result["MESSAGE"] == "OK" + + +# ============================================================================= +# Test: Sender._normalize_response() +# ============================================================================= + + +def test_sender_nd_00600(): + """ + # Summary + + Verify _normalize_response with normal JSON response. + + ## Test + + - Response with valid DATA passes through unchanged + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "success"}, + } + result = instance._normalize_response(response) + assert result["DATA"] == {"status": "success"} + assert result["MESSAGE"] == "OK" + + +def test_sender_nd_00610(): + """ + # Summary + + Verify _normalize_response when DATA is None and raw is present. + + ## Test + + - When DATA is None and raw is present, DATA is populated with raw_response + - MESSAGE is set to indicate JSON parsing failure + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": None, + "raw": "Not JSON", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "Not JSON"} + assert result["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00620(): + """ + # Summary + + Verify _normalize_response when DATA is None, raw is present, + and MESSAGE is None. + + ## Test + + - When MESSAGE is None, it is set to indicate JSON parsing failure + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": None, + "DATA": None, + "raw": "raw content", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "raw content"} + assert result["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00630(): + """ + # Summary + + Verify _normalize_response when DATA is None and raw is also None. + + ## Test + + - When both DATA and raw are None, response is not modified + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": None, + } + result = instance._normalize_response(response) + assert result["DATA"] is None + assert result["MESSAGE"] == "Internal Server Error" + + +def test_sender_nd_00640(): + """ + # Summary + + Verify _normalize_response preserves non-OK MESSAGE when raw is present. + + ## Test + + - When DATA is None and raw is present, MESSAGE is only overwritten + if it was "OK" or None + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": None, + "raw": "raw error content", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "raw error content"} + # MESSAGE is NOT overwritten because it's not "OK" or None + assert result["MESSAGE"] == "Internal Server Error" + + +# ============================================================================= +# Test: Sender.commit() with mocked Connection +# ============================================================================= + + +def test_sender_nd_00700(): + """ + # Summary + + Verify commit() with successful GET request (no payload). + + ## Test + + - commit() calls Connection.send_request with verb and path + - response is populated from the Connection response + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "success"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + assert instance.response["DATA"]["status"] == "success" + mock_connection.send_request.assert_called_once_with("GET", "/api/v1/test") + + +def test_sender_nd_00710(): + """ + # Summary + + Verify commit() with POST request including payload. + + ## Test + + - commit() calls Connection.send_request with verb, path, and JSON payload + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "created"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "test"} + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + assert instance.response["DATA"]["status"] == "created" + mock_connection.send_request.assert_called_once_with( + "POST", + "/api/v1/test/create", + '{"name": "test"}', + ) + + +def test_sender_nd_00720(): + """ + # Summary + + Verify commit() raises ValueError on connection failure. + + ## Test + + - When Connection.send_request raises AnsibleConnectionError, + commit() re-raises as ValueError + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.side_effect = AnsibleConnectionError("Connection refused") + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + match = r"Sender\.commit:.*ConnectionError occurred" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_sender_nd_00730(): + """ + # Summary + + Verify commit() raises ValueError on unexpected exception. + + ## Test + + - When Connection.send_request raises an unexpected Exception, + commit() wraps it in ValueError + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.side_effect = RuntimeError("Unexpected error") + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + match = r"Sender\.commit:.*Unexpected error occurred" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_sender_nd_00740(): + """ + # Summary + + Verify commit() reuses existing connection on second call. + + ## Test + + - First commit creates a new Connection + - Second commit reuses the existing connection + - Connection constructor is called only once + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {}, + } + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ) as mock_conn_class: + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + instance.commit() + instance.commit() + + # Connection constructor should only be called once + mock_conn_class.assert_called_once() + # send_request should be called twice + assert mock_connection.send_request.call_count == 2 + + +def test_sender_nd_00750(): + """ + # Summary + + Verify commit() normalizes non-JSON responses. + + ## Test + + - When Connection returns DATA=None with raw content, + commit() normalizes the response + + ## Classes and Methods + + - Sender.commit() + - Sender._normalize_response() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": None, + "raw": "Non-JSON response", + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["DATA"] == {"raw_response": "Non-JSON response"} + assert instance.response["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00760(): + """ + # Summary + + Verify commit() with PUT request including payload. + + ## Test + + - commit() calls Connection.send_request with PUT verb, path, and JSON payload + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "updated"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/update/12345" + instance.verb = HttpVerbEnum.PUT + instance.payload = {"status": "active"} + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + mock_connection.send_request.assert_called_once_with( + "PUT", + "/api/v1/test/update/12345", + '{"status": "active"}', + ) + + +def test_sender_nd_00770(): + """ + # Summary + + Verify commit() with DELETE request (no payload). + + ## Test + + - commit() calls Connection.send_request with DELETE verb and path + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "deleted"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/delete/12345" + instance.verb = HttpVerbEnum.DELETE + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + mock_connection.send_request.assert_called_once_with("DELETE", "/api/v1/test/delete/12345") From 06633f6f169036e76f5e5069844bec0d39220193 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Thu, 12 Mar 2026 16:29:48 -1000 Subject: [PATCH 04/51] [ignore] Fix broken imports after nd42_rest_send restructuring --- plugins/module_utils/endpoints/base.py | 5 +---- .../endpoints/v1/infra/clusterhealth_config.py | 5 +---- .../endpoints/v1/infra/clusterhealth_status.py | 5 +---- plugins/module_utils/endpoints/v1/infra/login.py | 5 +---- tests/unit/module_utils/common_utils.py | 2 +- .../module_utils/endpoints/test_base_model.py | 5 +---- tests/unit/module_utils/test_sender_nd.py | 16 ++++++++-------- 7 files changed, 14 insertions(+), 29 deletions(-) diff --git a/plugins/module_utils/endpoints/base.py b/plugins/module_utils/endpoints/base.py index 9da9620e..3ccdff1c 100644 --- a/plugins/module_utils/endpoints/base.py +++ b/plugins/module_utils/endpoints/base.py @@ -14,10 +14,7 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Literal +from typing import Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( BaseModel, diff --git a/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py b/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py index 607cea39..c0a7b6ca 100644 --- a/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py +++ b/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py @@ -11,10 +11,7 @@ from __future__ import absolute_import, annotations, division, print_function -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Literal +from typing import Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, diff --git a/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py b/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py index 52e6cc14..ef5afd6c 100644 --- a/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py +++ b/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py @@ -11,10 +11,7 @@ from __future__ import absolute_import, annotations, division, print_function -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Literal +from typing import Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, diff --git a/plugins/module_utils/endpoints/v1/infra/login.py b/plugins/module_utils/endpoints/v1/infra/login.py index 70d894d4..70968615 100644 --- a/plugins/module_utils/endpoints/v1/infra/login.py +++ b/plugins/module_utils/endpoints/v1/infra/login.py @@ -12,10 +12,7 @@ from __future__ import absolute_import, annotations, division, print_function -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Literal +from typing import Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, diff --git a/tests/unit/module_utils/common_utils.py b/tests/unit/module_utils/common_utils.py index bc64b0d6..f25c31eb 100644 --- a/tests/unit/module_utils/common_utils.py +++ b/tests/unit/module_utils/common_utils.py @@ -15,7 +15,7 @@ from contextlib import contextmanager import pytest -from ansible_collections.cisco.nd.plugins.module_utils.log import Log +from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender as SenderFile diff --git a/tests/unit/module_utils/endpoints/test_base_model.py b/tests/unit/module_utils/endpoints/test_base_model.py index e2db13be..a14da9d8 100644 --- a/tests/unit/module_utils/endpoints/test_base_model.py +++ b/tests/unit/module_utils/endpoints/test_base_model.py @@ -22,13 +22,10 @@ # pylint: disable=too-few-public-methods from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import Literal import pytest -if TYPE_CHECKING: - from typing import Literal - from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, ) diff --git a/tests/unit/module_utils/test_sender_nd.py b/tests/unit/module_utils/test_sender_nd.py index 5edd102f..4b8d7f47 100644 --- a/tests/unit/module_utils/test_sender_nd.py +++ b/tests/unit/module_utils/test_sender_nd.py @@ -600,7 +600,7 @@ def test_sender_nd_00700(): instance.verb = HttpVerbEnum.GET with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ): with does_not_raise(): @@ -643,7 +643,7 @@ def test_sender_nd_00710(): instance.payload = {"name": "test"} with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ): with does_not_raise(): @@ -686,7 +686,7 @@ def test_sender_nd_00720(): instance.verb = HttpVerbEnum.GET with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ): match = r"Sender\.commit:.*ConnectionError occurred" @@ -722,7 +722,7 @@ def test_sender_nd_00730(): instance.verb = HttpVerbEnum.GET with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ): match = r"Sender\.commit:.*Unexpected error occurred" @@ -758,7 +758,7 @@ def test_sender_nd_00740(): } with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ) as mock_conn_class: instance = Sender() @@ -809,7 +809,7 @@ def test_sender_nd_00750(): instance.verb = HttpVerbEnum.GET with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ): with does_not_raise(): @@ -851,7 +851,7 @@ def test_sender_nd_00760(): instance.payload = {"status": "active"} with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ): with does_not_raise(): @@ -896,7 +896,7 @@ def test_sender_nd_00770(): instance.verb = HttpVerbEnum.DELETE with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + "ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd.Connection", return_value=mock_connection, ): with does_not_raise(): From 1c3b3c6a8274147e770671dd1d0260fd416bab7a Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 19 Aug 2025 12:44:17 -0400 Subject: [PATCH 05/51] [minor_change] Add nd_local_user as a new network resource module for Nexus Dashboard v4.1.0 and higher. --- plugins/module_utils/constants.py | 14 + plugins/module_utils/nd.py | 79 ++--- plugins/module_utils/nd_config_collection.py | 295 ++++++++++++++++++ plugins/module_utils/nd_network_resources.py | 202 ++++++++++++ plugins/module_utils/utils.py | 32 ++ plugins/modules/nd_local_user.py | 269 ++++++++++++++++ .../targets/nd_local_user/tasks/main.yml | 134 ++++++++ 7 files changed, 974 insertions(+), 51 deletions(-) create mode 100644 plugins/module_utils/nd_config_collection.py create mode 100644 plugins/module_utils/nd_network_resources.py create mode 100644 plugins/module_utils/utils.py create mode 100644 plugins/modules/nd_local_user.py create mode 100644 tests/integration/targets/nd_local_user/tasks/main.yml diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 10de9edf..cbba61b3 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"} @@ -170,3 +175,12 @@ ND_SETUP_NODE_DEPLOYMENT_TYPE = {"physical": "cimc", "virtual": "vnode"} BACKUP_TYPE = {"config_only": "config-only", None: "config-only", "": "config-only", "full": "full"} + +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 03ffc85f..5f528bb8 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 @@ -212,7 +185,7 @@ def __init__(self, module): self.previous = dict() self.proposed = dict() self.sent = dict() - self.stdout = None + self.stdout = "" # debug output self.has_modified = False @@ -266,8 +239,13 @@ def request( if file is not None: info = self.connection.send_file_request(method, uri, file, data, None, file_key, file_ext) else: +<<<<<<< HEAD if data: info = self.connection.send_request(method, uri, json.dumps(data)) +======= + if data is not None: + info = conn.send_request(method, uri, json.dumps(data)) +>>>>>>> 7c967c3 ([minor_change] Add nd_local_user as a new network resource module for Nexus Dashboard v4.1.0 and higher.) else: info = self.connection.send_request(method, uri) self.result["data"] = data @@ -324,6 +302,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) @@ -520,30 +500,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 00000000..1cf86756 --- /dev/null +++ b/plugins/module_utils/nd_config_collection.py @@ -0,0 +1,295 @@ +# -*- 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 + +import sys +from copy import deepcopy +from functools import reduce + +# Python 2 and 3 compatibility (To be removed in the future) +if sys.version_info[0] >= 3: + from collections.abc import MutableMapping + iteritems = lambda d: d.items() +else: + from collections import MutableMapping + iteritems = lambda d: d.iteritems() + +# NOTE: Single-Index Hybrid Collection for ND Network Resource Module +class NDConfigCollection(MutableMapping): + + def __init__(self, identifier_keys, data=None, use_composite_keys=False): + self.identifier_keys = identifier_keys + self.use_composite_keys = use_composite_keys + + # Dual Storage + self._list = [] + self._map = {} + + if data: + for item in data: + self.add(item) + + # TODO: add a method to get nested keys, ex: get("spec", {}).get("onboardUrl") + def _get_identifier_value(self, config): + """Generates the internal map key based on the selected mode.""" + if self.use_composite_keys: + # Mode: Composite (Tuple of ALL keys) + values = [] + for key in self.identifier_keys: + val = config.get(key) + if val is None: + return None # Missing a required part + values.append(val) + return tuple(values) + else: + # Mode: Priority (First available key) + for key in self.identifier_keys: + if key in config: + return config[key] + return None + + # Magic Methods + def __getitem__(self, key): + return self._map[key] + + def __setitem__(self, key, value): + if key in self._map: + old_ref = self._map[key] + try: + idx = self._list.index(old_ref) + self._list[idx] = value + self._map[key] = value + except ValueError: + pass + else: + # Add new + self._list.append(value) + self._map[key] = value + + def __delitem__(self, key): + if key in self._map: + obj_ref = self._map[key] + del self._map[key] + self._list.remove(obj_ref) + else: + raise KeyError(key) + + def __iter__(self): + return iter(self._map) + + def __len__(self): + return len(self._list) + + def __eq__(self, other): + if isinstance(other, NDConfigCollection): + return self._list == other._list + elif isinstance(other, list): + return self._list == other + elif isinstance(other, dict): + return self._map == other + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return str(self._list) + + # Helper Methods + def _filter_dict(self, data, ignore_keys): + return {k: v for k, v in iteritems(data) if k not in ignore_keys} + + def _issubset(self, subset, 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 iteritems(subset): + if value is None: + continue + + if key not in superset: + return False + + superset_value = superset.get(key) + + if not self._issubset(value, superset_value): + return False + return True + + def _remove_unwanted_keys(self, data, unwanted_keys): + for key in unwanted_keys: + if isinstance(key, str): + if key in data: + del data[key] + elif isinstance(key, list) and len(key) > 0: + key_path, last = key[:-1], key[-1] + try: + parent = reduce(lambda d, k: d.get(k) if isinstance(d, dict) else None, key_path, data) + if isinstance(parent, dict) and last in parent: + del parent[last] + except (KeyError, TypeError): + pass + return data + + # Core Operations + def to_list(self): + return self._list + + def to_dict(self): + return self._map + + def copy(self): + return NDConfigCollection(self.identifier_keys, deepcopy(self._list), self.use_composite_keys) + + def add(self, config): + ident = self._get_identifier_value(config) + if ident is None: + mode = "Composite" if self.use_composite_keys else "Priority" + raise ValueError("[{0} Mode] Config missing required keys: {1}".format(mode, self.identifier_keys)) + + if ident in self._map: + self.__setitem__(ident, config) + else: + self._list.append(config) + self._map[ident] = config + + def merge(self, new_config): + ident = self._get_identifier_value(new_config) + if ident and ident in self._map: + self._map[ident].update(new_config) + else: + self.add(new_config) + + def replace(self, new_config): + ident = self._get_identifier_value(new_config) + if ident: + self[ident] = new_config + else: + self.add(new_config) + + def remove(self, identifiers): + # Try Map Removal + try: + target_key = self._get_identifier_value(identifiers) + if target_key and target_key in self._map: + self.__delitem__(target_key) + return + except Exception: + pass + + # Fallback: Linear Removal + to_remove = [] + for config in self._list: + match = True + for k, v in iteritems(identifiers): + if config.get(k) != v: + match = False + break + if match: + to_remove.append(self._get_identifier_value(config)) + + for ident in to_remove: + if ident in self._map: + self.__delitem__(ident) + + def get_by_key(self, key, default=None): + return self._map.get(key, default) + + def get_by_idenfiers(self, identifiers, default=None): + # Try Map Lookup + target_key = self._get_identifier_value(identifiers) + if target_key and target_key in self._map: + return self._map[target_key] + + # Fallback: Linear Lookup + valid_search_keys = [k for k in identifiers if k in self.identifier_keys] + if not valid_search_keys: + return default + + for config in self._list: + match = True + for k in valid_search_keys: + if config.get(k) != identifiers[k]: + match = False + break + if match: + return config + return default + + # Diff logic + def get_diff_config(self, new_config, unwanted_keys=None): + unwanted_keys = unwanted_keys or [] + + ident = self._get_identifier_value(new_config) + + if not ident or ident not in self._map: + return "new" + + existing = deepcopy(self._map[ident]) + sent = deepcopy(new_config) + + self._remove_unwanted_keys(existing, unwanted_keys) + self._remove_unwanted_keys(sent, unwanted_keys) + + is_subset = self._issubset(sent, existing) + + if is_subset: + return "no_diff" + else: + return "changed" + + def get_diff_collection(self, new_collection, unwanted_keys=None): + if not isinstance(new_collection, NDConfigCollection): + raise TypeError("Argument must be an NDConfigCollection") + + if len(self) != len(new_collection): + return True + + for item in new_collection.to_list(): + if self.get_diff_config(item, unwanted_keys) != "no_diff": + return True + + for ident in self._map: + if ident not in new_collection._map: + return True + + return False + + def get_diff_identifiers(self, new_collection): + current_identifiers = set(self.config_collection.keys()) + other_identifiers = set(new_collection.config_collection.keys()) + + return list(current_identifiers - other_identifiers) + + # Sanitize Operations + def sanitize(self, keys_to_remove=None, values_to_remove=None, remove_none_values=False): + keys_to_remove = keys_to_remove or [] + values_to_remove = values_to_remove or [] + + def recursive_clean(obj): + if isinstance(obj, dict): + keys = list(obj.keys()) + for k in keys: + v = obj[k] + if k in keys_to_remove or v in values_to_remove or (remove_none_values and v is None): + del obj[k] + continue + if isinstance(v, (dict, list)): + recursive_clean(v) + elif isinstance(obj, list): + for item in obj: + recursive_clean(item) + + for item in self._list: + recursive_clean(item) diff --git a/plugins/module_utils/nd_network_resources.py b/plugins/module_utils/nd_network_resources.py new file mode 100644 index 00000000..b73b24e7 --- /dev/null +++ b/plugins/module_utils/nd_network_resources.py @@ -0,0 +1,202 @@ +# -*- 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 +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 further enhancement to logs and outputs +# NOTE: ONLY works for new API endpoints introduced in ND v4.1.0 and later +class NDNetworkResourceModule(NDModule): + + def __init__(self, module, path, identifier_keys, use_composite_keys=False, actions_overwrite_map=None): + super().__init__(module) + + # Initial variables + self.path = path + self.actions_overwrite_map = actions_overwrite_map or {} + self.identifier_keys = identifier_keys + self.use_composite_keys = use_composite_keys + + # Initial data + self.init_all_data = self._query_all() + + # Info ouput + self.existing = NDConfigCollection(identifier_keys, data=self.init_all_data) + self.previous = NDConfigCollection(identifier_keys) + self.proposed = NDConfigCollection(identifier_keys) + self.sent = NDConfigCollection(identifier_keys) + + # Debug output + self.nd_logs = [] + + # Helper variables + self.current_identifier = "" + self.existing_config = {} + self.proposed_config = {} + + # Actions Operations + def actions_overwrite(action): + def decorator(func): + def wrapper(self, *args, **kwargs): + overwrite_action = self.actions_overwrite_map.get(action) + if callable(overwrite_action): + return overwrite_action(self) + else: + return func(self, *args, **kwargs) + return wrapper + return decorator + + @actions_overwrite("create") + def _create(self): + if not self.module.check_mode: + return self.request(path=self.path, method="POST", data=self.proposed_config) + + @actions_overwrite("update") + def _update(self): + if not self.module.check_mode: + object_path = "{0}/{1}".format(self.path, self.current_identifier) + return self.request(path=object_path, method="PUT", data=self.proposed_config) + + @actions_overwrite("delete") + def _delete(self): + if not self.module.check_mode: + object_path = "{0}/{1}".format(self.path, self.current_identifier) + self.request(path=object_path, method="DELETE") + + @actions_overwrite("query_all") + def _query_all(self): + return self.query_obj(self.path) + + def format_log(self, identifier, status, after_data, sent_payload_data=None): + item_result = { + "identifier": identifier, + "status": status, + "before": self.existing_config, + "after": deepcopy(after_data) if after_data is not None else self.existing_config, + "sent_payload": deepcopy(sent_payload_data) if sent_payload_data is not None else {}, + } + + if not self.module.check_mode and self.url is not None: + item_result.update( + { + "method": self.method, + "response": self.response, + "status": self.status, + "url": self.url, + } + ) + + self.nd_logs.append(item_result) + + # Logs and Outputs formating Operations + def add_logs_and_ouputs(self): + 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.to_list() + if not self.has_modified and self.previous.get_diff_collection(self.existing): + self.result["changed"] = True + if self.stdout: + self.result["stdout"] = self.stdout + + if self.params.get("output_level") == "debug": + self.result["nd_logs"] = self.nd_logs + if self.url is not None: + 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.to_list() + self.result["proposed"] = self.proposed.to_list() + + self.result["current"] = self.existing.to_list() + + # Manage State Operations + def manage_state(self, state, new_configs, unwanted_keys=None, override_exceptions=None): + unwanted_keys = unwanted_keys or [] + override_exceptions = override_exceptions or [] + + self.proposed = NDConfigCollection(self.identifier_keys, data=new_configs) + self.proposed.sanitize() + self.previous = self.existing.copy() + + if state in ["merged", "replaced", "overidden"]: + for identifier, config in self.proposed.items(): + + diff_config_info = self.existing.get_diff_config(config, unwanted_keys) + self.current_identifier = identifier + self.existing_config = deepcopy(self.existing.get_by_key(identifier, {})) + self.proposed_config = config + request_response = None + sent_payload = None + status = "no_change" + + if diff_config_info != "no_diff": + if state == "merged": + self.existing.merge(config) + self.proposed_config = self.existing[identifier] + else: + self.existing.replace(config) + + if diff_config_info == "changed": + request_response = self._update() + status = "updated" + else: + request_response = self._create() + status= "created" + + if not self.module.check_mode: + self.sent.add(self.proposed_config) + sent_payload = self.proposed_config + else: + request_response = self.proposed_config + + self.format_log(identifier, status, request_response, sent_payload) + + + if state == "overidden": + diff_identifiers = self.previous.get_diff_identifiers(self.proposed) + for identifier in diff_identifiers: + if identifier not in override_exceptions: + self.current_identifier = identifier + self.existing_config = deepcopy(self.existing.get_by_key(identifier, {})) + self._delete() + del self.existing[identifier] + self.format_log(identifier, "deleted", after_data={}) + + + elif state == "deleted": + for identifier, config in self.proposed.items(): + if identifier in self.existing.keys(): + self.current_identifier = identifier + self.existing_config = deepcopy(self.existing.get_by_key(identifier, {})) + self.proposed_config = config + self._delete() + del self.existing[identifier] + self.format_log(identifier, "deleted", after_data={}) + + # Outputs Operations + def fail_json(self, msg, **kwargs): + self.add_logs_and_ouputs() + + self.result.update(**kwargs) + self.module.fail_json(msg=msg, **self.result) + + def exit_json(self, **kwargs): + self.add_logs_and_ouputs() + + if self.module._diff and self.result.get("changed") is True: + self.result["diff"] = dict( + before=self.previous.to_list(), + after=self.existing.to_list(), + ) + + self.result.update(**kwargs) + self.module.exit_json(**self.result) diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py new file mode 100644 index 00000000..5bf0a0f0 --- /dev/null +++ b/plugins/module_utils/utils.py @@ -0,0 +1,32 @@ +# -*- 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 + + +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 \ No newline at end of file diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py new file mode 100644 index 00000000..552df3b7 --- /dev/null +++ b/plugins/modules/nd_local_user.py @@ -0,0 +1,269 @@ +#!/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 + + +# Actions overwrite functions +def quey_all_local_users(nd): + return nd.query_obj(nd.path).get("localusers") + + +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"), + ), + ), + override_exceptions=dict(type="list", elements="str"), + 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_keys = ["loginID"] + actions_overwrite_map = {"query_all": quey_all_local_users} + + nd = NDNetworkResourceModule(module, path, identifier_keys, actions_overwrite_map=actions_overwrite_map) + + state = nd.params.get("state") + config = nd.params.get("config") + override_exceptions = nd.params.get("override_exceptions") + new_config = [] + for object in config: + payload = { + "email": object.get("email"), + "firstName": object.get("first_name"), + "lastName": object.get("last_name"), + "loginID": 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) + + nd.manage_state(state=state, new_configs=new_config, unwanted_keys=[["passwordPolicy", "passwordChangeTime"], ["userID"]], override_exceptions=override_exceptions) + + nd.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 00000000..77e55cd1 --- /dev/null +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -0,0 +1,134 @@ +# 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%Test + 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%Test + 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 + +# UPDATE +- name: Update all ansible_local_user's attributes (check mode) + cisco.nd.nd_local_user: &update_first_local_user + <<: *nd_info + config: + - email: updatedansibleuser@example.com + login_id: ansible_local_user + 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 + + +# 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 + + +# 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 From c70a93f06f4a481879a0ffc74e0dfdce24a680c7 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 15 Jan 2026 11:47:32 -0500 Subject: [PATCH 06/51] [ignore] First Pydantic implementation: Add Pydantic Models for nd_local_user. --- .../module_utils/models/local_user_model.py | 142 ++++++++++++++++++ plugins/module_utils/nd_config_collection.py | 1 + plugins/module_utils/nd_network_resources.py | 2 + plugins/modules/nd_local_user.py | 5 +- 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 plugins/module_utils/models/local_user_model.py diff --git a/plugins/module_utils/models/local_user_model.py b/plugins/module_utils/models/local_user_model.py new file mode 100644 index 00000000..f8de1f46 --- /dev/null +++ b/plugins/module_utils/models/local_user_model.py @@ -0,0 +1,142 @@ +# -*- 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 + +import json +from typing import List, Dict, Any, Optional +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# TODO: Add Field validation methods +# TODO: Add a method to get identifier(s) -> define a generic NDNetworkResourceModel +# TODO: Maybe define our own baseModel +# TODO: Look at ansible aliases +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Dict, Any, Optional + +class SecurityDomainModel(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + ) + + name: str = Field(alias="name") + roles: list[str] = Field(default_factory=lambda: ["observer"], alias="roles") + + +class LocalUserModel(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + ) + + email: str = Field(default="", alias="email") + login_id: str = Field(alias="loginID") + first_name: str = Field(default="", alias="firstName") + last_name: str = Field(default="", alias="lastName") + user_password: str = Field(alias="password") + reuse_limitation: int = Field(default=0, alias="reuseLimitation") + time_interval_limitation: int = Field(default=0, alias="timeIntervalLimitation") + security_domains: List[SecurityDomainModel] = Field(default_factory=list, alias="domains") + remote_id_claim: str = Field(default="", alias="remoteIDClaim") + remote_user_authorization: bool = Field(default=False, alias="xLaunch") + + def to_api_payload(self, user_roles_mapping: Dict[str, str] = None) -> Dict[str, Any]: + """Convert the model to the specific API payload format required.""" + if user_roles_mapping is None: + user_roles_mapping = {} + + base_data = self.model_dump(by_alias=True, exclude={'domains', 'reuseLimitation', 'timeIntervalLimitation'}) + + payload = { + "email": base_data.get("email"), + "firstName": base_data.get("firstName"), + "lastName": base_data.get("lastName"), + "loginID": base_data.get("loginID"), + "password": base_data.get("password"), + "remoteIDClaim": base_data.get("remoteIDClaim"), + "xLaunch": base_data.get("xLaunch"), + } + + if self.security_domains: + payload["rbac"] = { + "domains": { + domain.name: { + "roles": [ + user_roles_mapping.get(role, role) for role in domain.roles + ] + } + for domain in self.security_domains + } + } + + if self.reuse_limitation or self.time_interval_limitation: + payload["passwordPolicy"] = { + "reuseLimitation": self.reuse_limitation, + "timeIntervalLimitation": self.time_interval_limitation, + } + + return payload + + @classmethod + def from_api_payload( + cls, + payload: Dict[str, Any], + reverse_user_roles_mapping: Optional[Dict[str, str]] = None + ) -> 'LocalUserModel': + + if reverse_user_roles_mapping is None: + reverse_user_roles_mapping = {} + + user_data = { + "email": payload.get("email", ""), + "loginID": payload.get("loginID", ""), + "firstName": payload.get("firstName", ""), + "lastName": payload.get("lastName", ""), + "password": payload.get("password", ""), + "remoteIDClaim": payload.get("remoteIDClaim", ""), + "xLaunch": payload.get("xLaunch", False), + } + + password_policy = payload.get("passwordPolicy", {}) + user_data["reuseLimitation"] = password_policy.get("reuseLimitation", 0) + user_data["timeIntervalLimitation"] = password_policy.get("timeIntervalLimitation", 0) + + domains_data = [] + rbac = payload.get("rbac", {}) + if rbac and "domains" in rbac: + for domain_name, domain_config in rbac["domains"].items(): + # Map API roles back to internal roles + api_roles = domain_config.get("roles", []) + internal_roles = [ + reverse_user_roles_mapping.get(role, role) for role in api_roles + ] + + domain_data = { + "name": domain_name, + "roles": internal_roles + } + domains_data.append(domain_data) + + user_data["domains"] = domains_data + + return cls(**user_data) + + # @classmethod + # def from_api_payload_json( + # cls, + # json_payload: str, + # reverse_user_roles_mapping: Optional[Dict[str, str]] = None + # ) -> 'LocalUserModel': + + # payload = json.loads(json_payload) + # return cls.from_api_payload(payload, reverse_user_roles_mapping) diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 1cf86756..8f0058bb 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -20,6 +20,7 @@ from collections import MutableMapping iteritems = lambda d: d.iteritems() +# TODO: Adapt to Pydantic Models # NOTE: Single-Index Hybrid Collection for ND Network Resource Module class NDConfigCollection(MutableMapping): diff --git a/plugins/module_utils/nd_network_resources.py b/plugins/module_utils/nd_network_resources.py index b73b24e7..3b549da1 100644 --- a/plugins/module_utils/nd_network_resources.py +++ b/plugins/module_utils/nd_network_resources.py @@ -14,6 +14,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED # TODO: Make further enhancement to logs and outputs +# TODO: Adapt to Pydantic Models # NOTE: ONLY works for new API endpoints introduced in ND v4.1.0 and later class NDNetworkResourceModule(NDModule): @@ -98,6 +99,7 @@ def format_log(self, identifier, status, after_data, sent_payload_data=None): self.nd_logs.append(item_result) # Logs and Outputs formating Operations + # TODO: Move it to different file def add_logs_and_ouputs(self): if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: if self.params.get("output_level") in ("debug", "info"): diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 552df3b7..4a5f1ad2 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -181,10 +181,11 @@ # Actions overwrite functions -def quey_all_local_users(nd): +def query_all_local_users(nd): return nd.query_obj(nd.path).get("localusers") +# TODO: Adapt to Pydantic Model def main(): argument_spec = nd_argument_spec() argument_spec.update( @@ -223,7 +224,7 @@ def main(): path = "/api/v1/infra/aaa/localUsers" identifier_keys = ["loginID"] - actions_overwrite_map = {"query_all": quey_all_local_users} + actions_overwrite_map = {"query_all": query_all_local_users} nd = NDNetworkResourceModule(module, path, identifier_keys, actions_overwrite_map=actions_overwrite_map) From 267e2a84deb552978a5ec1ca5b0c0f595a657419 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 20 Jan 2026 13:17:35 -0500 Subject: [PATCH 07/51] [ignore] Second Pydantic Implementation: Create a NDBaseModel to be inherited from future class models. Modify class models for local_user. --- plugins/module_utils/models/base.py | 57 +++++++ plugins/module_utils/models/local_user.py | 116 ++++++++++++++ .../module_utils/models/local_user_model.py | 142 ------------------ 3 files changed, 173 insertions(+), 142 deletions(-) create mode 100644 plugins/module_utils/models/base.py create mode 100644 plugins/module_utils/models/local_user.py delete mode 100644 plugins/module_utils/models/local_user_model.py diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py new file mode 100644 index 00000000..e7301d14 --- /dev/null +++ b/plugins/module_utils/models/base.py @@ -0,0 +1,57 @@ +# -*- 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 abc import ABC, abstractmethod +from pydantic import BaseModel, ConfigDict +from typing import List, Dict, Any, Optional, ClassVar + + +class NDBaseModel(BaseModel, ABC): + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + ) + + # TODO: find ways to redifine these var in every + identifiers: ClassVar[List[str]] = [] + use_composite_identifiers: ClassVar[bool] = False + + @abstractmethod + def to_payload(self) -> Dict[str, Any]: + pass + + @classmethod + @abstractmethod + def from_response(cls, response: Dict[str, Any]) -> 'NDBaseModel': + pass + + # TODO: Modify to make it more generic and Pydantic + # TODO: add a method to get nested keys, ex: get("spec", {}).get("onboardUrl") + def get_identifier_value(self) -> Any: + """Generates the internal map key based on the selected mode.""" + # if self.use_composite_keys: + # # Mode: Composite (Tuple of ALL keys) + # values = [] + # for key in self.identifier_keys: + # val = config.get(key) + # if val is None: + # return None # Missing a required part + # values.append(val) + # return tuple(values) + # else: + # # Mode: Priority (First available key) + # for key in self.identifier_keys: + # if key in config: + # return config[key] + # return None + pass diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py new file mode 100644 index 00000000..7877a5a5 --- /dev/null +++ b/plugins/module_utils/models/local_user.py @@ -0,0 +1,116 @@ +# -*- 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 pydantic import Field, field_validator +from types import MappingProxyType +from typing import List, Dict, Any, Optional, ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + +# TODO: Add Field validation methods +# TODO: define our own Field class for string versioning, ansible aliases +# TODO: Add a method to get identifier(s) -> define a generic NDNetworkResourceModel +# TODO: Surclass BaseModel -> Priority +# TODO: Look at ansible aliases + +# TODO: use constants.py file in the future +user_roles_mapping = MappingProxyType({}) + + +class LocalUserSecurityDomainModel(NDBaseModel): + + name: str = Field(alias="name") + roles: list[str] = Field(default_factory=lambda: ["observer"], alias="roles") + + def to_payload(self) -> Dict[str, Any]: + return { + self.name: { + "roles": [ + user_roles_mapping.get(role, role) for role in self.roles + ] + } + } + + @classmethod + def from_response(cls, name: str, domain_config: List[str]) -> 'NDBaseModel': + internal_roles = [user_roles_mapping.get(role, role) for role in domain_config.get("roles", [])] + + domain_data = { + "name": name, + "roles": internal_roles + } + + return cls(**domain_data) + + +class LocalUserModel(NDBaseModel): + + # TODO: Define a way to generate it (look at NDBaseModel comments) + identifiers: ClassVar[List[str]] = ["login_id"] + + # TODO: Use Optinal to remove default values (get them from API response instead) + email: str = Field(default="", alias="email") + login_id: str = Field(alias="loginID") + first_name: str = Field(default="", alias="firstName") + last_name: str = Field(default="", alias="lastName") + user_password: str = Field(alias="password") + reuse_limitation: int = Field(default=0, alias="reuseLimitation") + time_interval_limitation: int = Field(default=0, alias="timeIntervalLimitation") + security_domains: List[LocalUserSecurityDomainModel] = Field(default_factory=list, alias="domains") + remote_id_claim: str = Field(default="", alias="remoteIDClaim") + remote_user_authorization: bool = Field(default=False, alias="xLaunch") + + def to_payload(self) -> Dict[str, Any]: + """Convert the model to the specific API payload format required.""" + + payload = self.model_dump(by_alias=True, exclude={'domains', 'reuseLimitation', 'timeIntervalLimitation'}) + + if self.security_domains: + payload["rbac"] = {"domains": {}} + for domain in self.security_domains: + payload["rbac"]["domains"].update(domain.to_api_payload()) + + if self.reuse_limitation or self.time_interval_limitation: + payload["passwordPolicy"] = { + "reuseLimitation": self.reuse_limitation, + "timeIntervalLimitation": self.time_interval_limitation, + } + + return payload + + @classmethod + def from_response(cls, payload: Dict[str, Any]) -> 'LocalUserModel': + + if reverse_user_roles_mapping is None: + reverse_user_roles_mapping = {} + + user_data = { + "email": payload.get("email"), + "loginID": payload.get("loginID"), + "firstName": payload.get("firstName"), + "lastName": payload.get("lastName"), + "password": payload.get("password"), + "remoteIDClaim": payload.get("remoteIDClaim"), + "xLaunch": payload.get("xLaunch"), + } + + password_policy = payload.get("passwordPolicy", {}) + user_data["reuseLimitation"] = password_policy.get("reuseLimitation", 0) + user_data["timeIntervalLimitation"] = password_policy.get("timeIntervalLimitation", 0) + + domains_data = [] + rbac = payload.get("rbac", {}) + if rbac and "domains" in rbac: + for domain_name, domain_config in rbac["domains"].items(): + domains_data.append(LocalUserSecurityDomainModel.from_api_response(domain_name, domain_config)) + + user_data["domains"] = domains_data + + return cls(**user_data) diff --git a/plugins/module_utils/models/local_user_model.py b/plugins/module_utils/models/local_user_model.py deleted file mode 100644 index f8de1f46..00000000 --- a/plugins/module_utils/models/local_user_model.py +++ /dev/null @@ -1,142 +0,0 @@ -# -*- 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 - -import json -from typing import List, Dict, Any, Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator - -# TODO: Add Field validation methods -# TODO: Add a method to get identifier(s) -> define a generic NDNetworkResourceModel -# TODO: Maybe define our own baseModel -# TODO: Look at ansible aliases -from pydantic import BaseModel, Field, ConfigDict -from typing import List, Dict, Any, Optional - -class SecurityDomainModel(BaseModel): - model_config = ConfigDict( - str_strip_whitespace=True, - use_enum_values=True, - validate_assignment=True, - populate_by_name=True, - ) - - name: str = Field(alias="name") - roles: list[str] = Field(default_factory=lambda: ["observer"], alias="roles") - - -class LocalUserModel(BaseModel): - model_config = ConfigDict( - str_strip_whitespace=True, - use_enum_values=True, - validate_assignment=True, - populate_by_name=True, - ) - - email: str = Field(default="", alias="email") - login_id: str = Field(alias="loginID") - first_name: str = Field(default="", alias="firstName") - last_name: str = Field(default="", alias="lastName") - user_password: str = Field(alias="password") - reuse_limitation: int = Field(default=0, alias="reuseLimitation") - time_interval_limitation: int = Field(default=0, alias="timeIntervalLimitation") - security_domains: List[SecurityDomainModel] = Field(default_factory=list, alias="domains") - remote_id_claim: str = Field(default="", alias="remoteIDClaim") - remote_user_authorization: bool = Field(default=False, alias="xLaunch") - - def to_api_payload(self, user_roles_mapping: Dict[str, str] = None) -> Dict[str, Any]: - """Convert the model to the specific API payload format required.""" - if user_roles_mapping is None: - user_roles_mapping = {} - - base_data = self.model_dump(by_alias=True, exclude={'domains', 'reuseLimitation', 'timeIntervalLimitation'}) - - payload = { - "email": base_data.get("email"), - "firstName": base_data.get("firstName"), - "lastName": base_data.get("lastName"), - "loginID": base_data.get("loginID"), - "password": base_data.get("password"), - "remoteIDClaim": base_data.get("remoteIDClaim"), - "xLaunch": base_data.get("xLaunch"), - } - - if self.security_domains: - payload["rbac"] = { - "domains": { - domain.name: { - "roles": [ - user_roles_mapping.get(role, role) for role in domain.roles - ] - } - for domain in self.security_domains - } - } - - if self.reuse_limitation or self.time_interval_limitation: - payload["passwordPolicy"] = { - "reuseLimitation": self.reuse_limitation, - "timeIntervalLimitation": self.time_interval_limitation, - } - - return payload - - @classmethod - def from_api_payload( - cls, - payload: Dict[str, Any], - reverse_user_roles_mapping: Optional[Dict[str, str]] = None - ) -> 'LocalUserModel': - - if reverse_user_roles_mapping is None: - reverse_user_roles_mapping = {} - - user_data = { - "email": payload.get("email", ""), - "loginID": payload.get("loginID", ""), - "firstName": payload.get("firstName", ""), - "lastName": payload.get("lastName", ""), - "password": payload.get("password", ""), - "remoteIDClaim": payload.get("remoteIDClaim", ""), - "xLaunch": payload.get("xLaunch", False), - } - - password_policy = payload.get("passwordPolicy", {}) - user_data["reuseLimitation"] = password_policy.get("reuseLimitation", 0) - user_data["timeIntervalLimitation"] = password_policy.get("timeIntervalLimitation", 0) - - domains_data = [] - rbac = payload.get("rbac", {}) - if rbac and "domains" in rbac: - for domain_name, domain_config in rbac["domains"].items(): - # Map API roles back to internal roles - api_roles = domain_config.get("roles", []) - internal_roles = [ - reverse_user_roles_mapping.get(role, role) for role in api_roles - ] - - domain_data = { - "name": domain_name, - "roles": internal_roles - } - domains_data.append(domain_data) - - user_data["domains"] = domains_data - - return cls(**user_data) - - # @classmethod - # def from_api_payload_json( - # cls, - # json_payload: str, - # reverse_user_roles_mapping: Optional[Dict[str, str]] = None - # ) -> 'LocalUserModel': - - # payload = json.loads(json_payload) - # return cls.from_api_payload(payload, reverse_user_roles_mapping) From 9b6bebdd79ee4eb92044f3e9c11892471150c8f3 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 22 Jan 2026 01:04:05 -0500 Subject: [PATCH 08/51] [ignore] Pydantic Models: Modify and Clean both local_user.py and base.py based on comments. Add a get method and get_identifier_value function to NDBaseModel. --- plugins/module_utils/models/base.py | 43 ++++++------ plugins/module_utils/models/local_user.py | 82 ++++++++++------------- 2 files changed, 57 insertions(+), 68 deletions(-) diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index e7301d14..bdd1b9c2 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -11,6 +11,7 @@ from abc import ABC, abstractmethod from pydantic import BaseModel, ConfigDict from typing import List, Dict, Any, Optional, ClassVar +from typing_extensions import Self class NDBaseModel(BaseModel, ABC): @@ -22,7 +23,7 @@ class NDBaseModel(BaseModel, ABC): populate_by_name=True, ) - # TODO: find ways to redifine these var in every + # TODO: find ways to redifine these var in every future NDBaseModels identifiers: ClassVar[List[str]] = [] use_composite_identifiers: ClassVar[bool] = False @@ -32,26 +33,28 @@ def to_payload(self) -> Dict[str, Any]: @classmethod @abstractmethod - def from_response(cls, response: Dict[str, Any]) -> 'NDBaseModel': + def from_response(cls, response: Dict[str, Any]) -> Self: pass - # TODO: Modify to make it more generic and Pydantic + def get(self, field: str, default: Any = None) -> Any: + """Custom get method to mimic dictionary behavior.""" + return getattr(self, field, default) + + # TODO: Modify to make it more generic and Pydantic | might change and be moved in different Generic Class/Model # TODO: add a method to get nested keys, ex: get("spec", {}).get("onboardUrl") def get_identifier_value(self) -> Any: - """Generates the internal map key based on the selected mode.""" - # if self.use_composite_keys: - # # Mode: Composite (Tuple of ALL keys) - # values = [] - # for key in self.identifier_keys: - # val = config.get(key) - # if val is None: - # return None # Missing a required part - # values.append(val) - # return tuple(values) - # else: - # # Mode: Priority (First available key) - # for key in self.identifier_keys: - # if key in config: - # return config[key] - # return None - pass + """Generates the internal map key based on the selected mode.""" + if self.use_composite_identifiers: + # Mode: Composite (Tuple of ALL keys) + values = [] + for identifier in self.identifiers: + value = self.get(identifier) + if value is None: + return None # Missing a required part | Add Error Handling method here + values.append(value) + return tuple(values) + else: + # Mode: Priority (First available key) + for identifier in self.identifiers: + return self.get(identifier) + return None diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 7877a5a5..28cea27c 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -8,9 +8,10 @@ __metaclass__ = type -from pydantic import Field, field_validator +from pydantic import Field, field_validator, SecretStr from types import MappingProxyType from typing import List, Dict, Any, Optional, ClassVar +from typing_extensions import Self from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel @@ -20,7 +21,7 @@ # TODO: Surclass BaseModel -> Priority # TODO: Look at ansible aliases -# TODO: use constants.py file in the future +# TODO: To be moved in constants.py file user_roles_mapping = MappingProxyType({}) @@ -39,15 +40,11 @@ def to_payload(self) -> Dict[str, Any]: } @classmethod - def from_response(cls, name: str, domain_config: List[str]) -> 'NDBaseModel': - internal_roles = [user_roles_mapping.get(role, role) for role in domain_config.get("roles", [])] - - domain_data = { - "name": name, - "roles": internal_roles - } - - return cls(**domain_data) + def from_response(cls, name: str, domain_config: List[str]) -> Self: + return cls( + name=name, + roles=[user_roles_mapping.get(role, role) for role in domain_config.get("roles", [])] + ) class LocalUserModel(NDBaseModel): @@ -55,17 +52,17 @@ class LocalUserModel(NDBaseModel): # TODO: Define a way to generate it (look at NDBaseModel comments) identifiers: ClassVar[List[str]] = ["login_id"] - # TODO: Use Optinal to remove default values (get them from API response instead) - email: str = Field(default="", alias="email") + email: Optional[str] = Field(alias="email") login_id: str = Field(alias="loginID") - first_name: str = Field(default="", alias="firstName") - last_name: str = Field(default="", alias="lastName") - user_password: str = Field(alias="password") - reuse_limitation: int = Field(default=0, alias="reuseLimitation") - time_interval_limitation: int = Field(default=0, alias="timeIntervalLimitation") - security_domains: List[LocalUserSecurityDomainModel] = Field(default_factory=list, alias="domains") - remote_id_claim: str = Field(default="", alias="remoteIDClaim") - remote_user_authorization: bool = Field(default=False, alias="xLaunch") + first_name: Optional[str] = Field(default="", alias="firstName") + last_name: Optional[str] = Field(default="", alias="lastName") + # TODO: Check secrets manipulation when tracking changes while maintaining security + user_password: Optional[SecretStr] = Field(alias="password") + reuse_limitation: Optional[int] = Field(default=0, alias="reuseLimitation") + time_interval_limitation: Optional[int] = Field(default=0, alias="timeIntervalLimitation") + security_domains: Optional[List[LocalUserSecurityDomainModel]] = Field(alias="domains") + remote_id_claim: Optional[str] = Field(default="", alias="remoteIDClaim") + remote_user_authorization: Optional[bool] = Field(default=False, alias="xLaunch") def to_payload(self) -> Dict[str, Any]: """Convert the model to the specific API payload format required.""" @@ -86,31 +83,20 @@ def to_payload(self) -> Dict[str, Any]: return payload @classmethod - def from_response(cls, payload: Dict[str, Any]) -> 'LocalUserModel': + def from_response(cls, response: Dict[str, Any]) -> Self: - if reverse_user_roles_mapping is None: - reverse_user_roles_mapping = {} - - user_data = { - "email": payload.get("email"), - "loginID": payload.get("loginID"), - "firstName": payload.get("firstName"), - "lastName": payload.get("lastName"), - "password": payload.get("password"), - "remoteIDClaim": payload.get("remoteIDClaim"), - "xLaunch": payload.get("xLaunch"), - } - - password_policy = payload.get("passwordPolicy", {}) - user_data["reuseLimitation"] = password_policy.get("reuseLimitation", 0) - user_data["timeIntervalLimitation"] = password_policy.get("timeIntervalLimitation", 0) - - domains_data = [] - rbac = payload.get("rbac", {}) - if rbac and "domains" in rbac: - for domain_name, domain_config in rbac["domains"].items(): - domains_data.append(LocalUserSecurityDomainModel.from_api_response(domain_name, domain_config)) - - user_data["domains"] = domains_data - - return cls(**user_data) + return cls( + email=response.get("email"), + login_id=response.get("loginID"), + first_name=response.get("firstName"), + last_name=response.get("lastName"), + user_password=response.get("password"), + reuse_limitation=response.get("passwordPolicy", {}).get("reuseLimitation"), + time_interval_limitation=response.get("passwordPolicy", {}).get("timeIntervalLimitation"), + security_domains=[ + LocalUserSecurityDomainModel.from_response(name, domain_config) + for name, domain_config in response.get("rbac", {}).get("domains", {}).items() + ], + remote_id_claim=response.get("remoteIDClaim"), + remote_user_authorization=response.get("xLaunch"), + ) From 019c8e3247c54b15870a3febd1bed53f6429facb Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Fri, 23 Jan 2026 00:56:49 -0500 Subject: [PATCH 09/51] [ignore] Pydantic ND base models and local_user models: Final proposition of core design adding new methods which will be used in NDConfigCollection and NDNetworkResourceModule classes as well as basic error handling and simple docstrings. --- plugins/module_utils/models/base.py | 124 ++++++++++++++---- plugins/module_utils/models/local_user.py | 146 ++++++++++++++-------- 2 files changed, 192 insertions(+), 78 deletions(-) diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index bdd1b9c2..a7eabf17 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -10,51 +10,127 @@ from abc import ABC, abstractmethod from pydantic import BaseModel, ConfigDict -from typing import List, Dict, Any, Optional, ClassVar +from typing import List, Dict, Any, ClassVar, Tuple, Union, Literal from typing_extensions import Self class NDBaseModel(BaseModel, ABC): - + """ + Base model for all Nexus Dashboard API objects. + + Supports three identifier strategies: + - single: One unique required field (e.g., ["login_id"]) + - composite: Multiple required fields as tuple (e.g., ["device", "interface"]) + - hierarchical: Priority-ordered fields (e.g., ["uuid", "name"]) + """ + model_config = ConfigDict( str_strip_whitespace=True, use_enum_values=True, validate_assignment=True, populate_by_name=True, + extra='ignore' ) - - # TODO: find ways to redifine these var in every future NDBaseModels + + # Subclasses MUST define these identifiers: ClassVar[List[str]] = [] - use_composite_identifiers: ClassVar[bool] = False - + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "single" + + # Optional: fields to exclude from diffs (e.g., passwords) + exclude_from_diff: ClassVar[List[str]] = [] + @abstractmethod def to_payload(self) -> Dict[str, Any]: + """ + Convert model to API payload format. + """ pass @classmethod @abstractmethod def from_response(cls, response: Dict[str, Any]) -> Self: + """ + Create model instance from API response. + """ pass - def get(self, field: str, default: Any = None) -> Any: - """Custom get method to mimic dictionary behavior.""" - return getattr(self, field, default) - - # TODO: Modify to make it more generic and Pydantic | might change and be moved in different Generic Class/Model - # TODO: add a method to get nested keys, ex: get("spec", {}).get("onboardUrl") - def get_identifier_value(self) -> Any: - """Generates the internal map key based on the selected mode.""" - if self.use_composite_identifiers: - # Mode: Composite (Tuple of ALL keys) + def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: + """ + Extract identifier value(s) from this instance: + - single identifier: Returns field value. + - composite identifiers: Returns tuple of all field values. + - hierarchical identifiers: Returns tuple of (field_name, value) for first non-None field. + """ + if not self.identifiers: + raise ValueError(f"{self.__class__.__name__} has no identifiers defined") + + if self.identifier_strategy == "single": + value = getattr(self, self.identifiers[0], None) + if value is None: + raise ValueError( + f"Single identifier field '{self.identifiers[0]}' is None" + ) + return value + + elif self.identifier_strategy == "composite": values = [] - for identifier in self.identifiers: - value = self.get(identifier) + missing = [] + + for field in self.identifiers: + value = getattr(self, field, None) if value is None: - return None # Missing a required part | Add Error Handling method here + missing.append(field) values.append(value) + + # NOTE: might not be needed in the future with field_validator + if missing: + raise ValueError( + f"Composite identifier fields {missing} are None. " + f"All required: {self.identifiers}" + ) + return tuple(values) + + elif self.identifier_strategy == "hierarchical": + for field in self.identifiers: + value = getattr(self, field, None) + if value is not None: + return (field, value) + + raise ValueError( + f"No non-None value in hierarchical fields {self.identifiers}" + ) + else: - # Mode: Priority (First available key) - for identifier in self.identifiers: - return self.get(identifier) - return None + raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") + + def to_diff_dict(self) -> Dict[str, Any]: + """ + Export for diff comparison (excludes sensitive fields). + """ + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude=set(self.exclude_from_diff) + ) + +# NOTE: Maybe make it a seperate BaseModel +class NDNestedModel(NDBaseModel): + """ + Base for nested models without identifiers. + """ + + identifiers: ClassVar[List[str]] = [] + + def to_payload(self) -> Dict[str, Any]: + """ + Convert model to API payload format. + """ + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """ + Create model instance from API response. + """ + return cls.model_validate(response) diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 28cea27c..b7069126 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -8,95 +8,133 @@ __metaclass__ = type -from pydantic import Field, field_validator, SecretStr +from pydantic import Field, SecretStr from types import MappingProxyType -from typing import List, Dict, Any, Optional, ClassVar +from typing import List, Dict, Any, Optional, ClassVar, Literal from typing_extensions import Self -from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel, NDNestedModel +from models.base import NDBaseModel, NDNestedModel -# TODO: Add Field validation methods -# TODO: define our own Field class for string versioning, ansible aliases -# TODO: Add a method to get identifier(s) -> define a generic NDNetworkResourceModel -# TODO: Surclass BaseModel -> Priority -# TODO: Look at ansible aliases +# TODO: Move it to constants.py and import it +USER_ROLES_MAPPING = MappingProxyType({ + "fabric_admin": "fabric-admin", + "observer": "observer", + "super_admin": "super-admin", + "support_engineer": "support-engineer", + "approver": "approver", + "designer": "designer", +}) -# TODO: To be moved in constants.py file -user_roles_mapping = MappingProxyType({}) +class LocalUserSecurityDomainModel(NDNestedModel): + """Security domain configuration for local user (nested model).""" -class LocalUserSecurityDomainModel(NDBaseModel): - - name: str = Field(alias="name") - roles: list[str] = Field(default_factory=lambda: ["observer"], alias="roles") - + # Fields + name: str + roles: Optional[List[str]] = None + def to_payload(self) -> Dict[str, Any]: - return { + + return { self.name: { "roles": [ - user_roles_mapping.get(role, role) for role in self.roles + USER_ROLES_MAPPING.get(role, role) + for role in (self.roles or []) ] } } - + @classmethod - def from_response(cls, name: str, domain_config: List[str]) -> Self: + def from_response(cls, name: str, domain_config: Dict[str, Any]) -> Self: + + # NOTE: Maybe create a function from it to be moved to utils.py and to be imported + reverse_mapping = {value: key for key, value in USER_ROLES_MAPPING.items()} + return cls( name=name, - roles=[user_roles_mapping.get(role, role) for role in domain_config.get("roles", [])] + roles=[ + reverse_mapping.get(role, role) + for role in domain_config.get("roles", []) + ] ) class LocalUserModel(NDBaseModel): + """ + Local user configuration. - # TODO: Define a way to generate it (look at NDBaseModel comments) + Identifier: login_id (single field) + """ + + # Identifier configuration identifiers: ClassVar[List[str]] = ["login_id"] - - email: Optional[str] = Field(alias="email") - login_id: str = Field(alias="loginID") - first_name: Optional[str] = Field(default="", alias="firstName") - last_name: Optional[str] = Field(default="", alias="lastName") - # TODO: Check secrets manipulation when tracking changes while maintaining security - user_password: Optional[SecretStr] = Field(alias="password") - reuse_limitation: Optional[int] = Field(default=0, alias="reuseLimitation") - time_interval_limitation: Optional[int] = Field(default=0, alias="timeIntervalLimitation") - security_domains: Optional[List[LocalUserSecurityDomainModel]] = Field(alias="domains") - remote_id_claim: Optional[str] = Field(default="", alias="remoteIDClaim") - remote_user_authorization: Optional[bool] = Field(default=False, alias="xLaunch") - + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "single" + exclude_from_diff: ClassVar[List[str]] = ["user_password"] + + # Fields + login_id: str = Field(..., alias="loginID") + email: Optional[str] = None + first_name: Optional[str] = Field(default=None, alias="firstName") + last_name: Optional[str] = Field(default=None, alias="lastName") + user_password: Optional[SecretStr] = Field(default=None, alias="password") + reuse_limitation: Optional[int] = Field(default=None, alias="reuseLimitation") + time_interval_limitation: Optional[int] = Field(default=None, alias="timeIntervalLimitation") + security_domains: Optional[List[LocalUserSecurityDomainModel]] = Field(default=None, alias="domains") + remote_id_claim: Optional[str] = Field(default=None, alias="remoteIDClaim") + remote_user_authorization: Optional[bool] = Field(default=None, alias="xLaunch") + def to_payload(self) -> Dict[str, Any]: - """Convert the model to the specific API payload format required.""" + payload = self.model_dump( + by_alias=True, + exclude={ + 'domains', + 'security_domains', + 'reuseLimitation', + 'reuse_limitation', + 'timeIntervalLimitation', + 'time_interval_limitation' + }, + exclude_none=True + ) - payload = self.model_dump(by_alias=True, exclude={'domains', 'reuseLimitation', 'timeIntervalLimitation'}) + if self.user_password: + payload["password"] = self.user_password.get_secret_value() if self.security_domains: payload["rbac"] = {"domains": {}} for domain in self.security_domains: - payload["rbac"]["domains"].update(domain.to_api_payload()) - - if self.reuse_limitation or self.time_interval_limitation: - payload["passwordPolicy"] = { - "reuseLimitation": self.reuse_limitation, - "timeIntervalLimitation": self.time_interval_limitation, - } - + payload["rbac"]["domains"].update(domain.to_payload()) + + if self.reuse_limitation is not None or self.time_interval_limitation is not None: + payload["passwordPolicy"] = {} + if self.reuse_limitation is not None: + payload["passwordPolicy"]["reuseLimitation"] = self.reuse_limitation + if self.time_interval_limitation is not None: + payload["passwordPolicy"]["timeIntervalLimitation"] = self.time_interval_limitation + return payload - + @classmethod def from_response(cls, response: Dict[str, Any]) -> Self: + password_policy = response.get("passwordPolicy", {}) + rbac = response.get("rbac", {}) + domains = rbac.get("domains", {}) + + security_domains = [ + LocalUserSecurityDomainModel.from_response(name, config) + for name, config in domains.items() + ] if domains else None return cls( - email=response.get("email"), login_id=response.get("loginID"), + email=response.get("email"), first_name=response.get("firstName"), last_name=response.get("lastName"), user_password=response.get("password"), - reuse_limitation=response.get("passwordPolicy", {}).get("reuseLimitation"), - time_interval_limitation=response.get("passwordPolicy", {}).get("timeIntervalLimitation"), - security_domains=[ - LocalUserSecurityDomainModel.from_response(name, domain_config) - for name, domain_config in response.get("rbac", {}).get("domains", {}).items() - ], + reuse_limitation=password_policy.get("reuseLimitation"), + time_interval_limitation=password_policy.get("timeIntervalLimitation"), + security_domains=security_domains, remote_id_claim=response.get("remoteIDClaim"), - remote_user_authorization=response.get("xLaunch"), + remote_user_authorization=response.get("xLaunch") ) From 427f33f24f9c31b2209a60b06230f1cef5324cb4 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Fri, 23 Jan 2026 13:09:33 -0500 Subject: [PATCH 10/51] [ignore] Pydantic ND Config Collection: Final proposition of core design changing existing methods and adding new ones which will be used in NDNetworkResourceModule class as well as basic error handling and simple docstrings. --- plugins/module_utils/nd_config_collection.py | 515 ++++++++++--------- 1 file changed, 266 insertions(+), 249 deletions(-) diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 8f0058bb..2f256d30 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -8,289 +8,306 @@ __metaclass__ = type -import sys +from typing import TypeVar, Generic, Optional, List, Dict, Any, Union, Tuple, Literal, Callable from copy import deepcopy -from functools import reduce -# Python 2 and 3 compatibility (To be removed in the future) -if sys.version_info[0] >= 3: - from collections.abc import MutableMapping - iteritems = lambda d: d.items() -else: - from collections import MutableMapping - iteritems = lambda d: d.iteritems() +# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from models.base import NDBaseModel -# TODO: Adapt to Pydantic Models -# NOTE: Single-Index Hybrid Collection for ND Network Resource Module -class NDConfigCollection(MutableMapping): +# Type aliases +# NOTE: Maybe add more type aliases in the future if needed +ModelType = TypeVar('ModelType', bound=NDBaseModel) +IdentifierKey = Union[str, int, Tuple[Any, ...]] - def __init__(self, identifier_keys, data=None, use_composite_keys=False): - self.identifier_keys = identifier_keys - self.use_composite_keys = use_composite_keys - - # Dual Storage - self._list = [] - self._map = {} + +class NDConfigCollection(Generic[ModelType]): + """ + Nexus Dashboard configuration collection for NDBaseModel instances. + """ + + def __init__(self, model_class: type[ModelType], items: Optional[List[ModelType]] = None): + """ + Initialize collection. + """ + self._model_class = model_class - if data: - for item in data: + # Dual storage + self._items: List[ModelType] = [] + self._index: Dict[IdentifierKey, int] = {} + + if items: + for item in items: self.add(item) - # TODO: add a method to get nested keys, ex: get("spec", {}).get("onboardUrl") - def _get_identifier_value(self, config): - """Generates the internal map key based on the selected mode.""" - if self.use_composite_keys: - # Mode: Composite (Tuple of ALL keys) - values = [] - for key in self.identifier_keys: - val = config.get(key) - if val is None: - return None # Missing a required part - values.append(val) - return tuple(values) - else: - # Mode: Priority (First available key) - for key in self.identifier_keys: - if key in config: - return config[key] - return None - - # Magic Methods - def __getitem__(self, key): - return self._map[key] - - def __setitem__(self, key, value): - if key in self._map: - old_ref = self._map[key] - try: - idx = self._list.index(old_ref) - self._list[idx] = value - self._map[key] = value - except ValueError: - pass - else: - # Add new - self._list.append(value) - self._map[key] = value - - def __delitem__(self, key): - if key in self._map: - obj_ref = self._map[key] - del self._map[key] - self._list.remove(obj_ref) + def _extract_key(self, item: ModelType) -> IdentifierKey: + """ + Extract identifier key from item. + """ + try: + return item.get_identifier_value() + except Exception as e: + raise ValueError(f"Failed to extract identifier: {e}") from e + + def _rebuild_index(self) -> None: + """Rebuild index from scratch (O(n) operation).""" + self._index.clear() + for index, item in enumerate(self._items): + key = self._extract_key(item) + self._index[key] = index + + # Core CRUD Operations + + def add(self, item: ModelType) -> IdentifierKey: + """ + Add item to collection (O(1) operation). + """ + if not isinstance(item, self._model_class): + raise TypeError( + f"Item must be instance of {self._model_class.__name__}, " + f"got {type(item).__name__}" + ) + + key = self._extract_key(item) + + if key in self._index: + raise ValueError( + f"Item with identifier {key} already exists. Use replace() to update" + ) + + position = len(self._items) + self._items.append(item) + self._index[key] = position + + return key + + def get(self, key: IdentifierKey) -> Optional[ModelType]: + """ + Get item by identifier key (O(1) operation). + """ + index = self._index.get(key) + return self._items[index] if index is not None else None + + def replace(self, item: ModelType) -> bool: + """ + Replace existing item with same identifier (O(1) operation). + """ + if not isinstance(item, self._model_class): + raise TypeError( + f"Item must be instance of {self._model_class.__name__}, " + f"got {type(item).__name__}" + ) + + key = self._extract_key(item) + index = self._index.get(key) + + if index is None: + return False + + self._items[index] = item + return True + + def merge(self, item: ModelType, custom_merge_function: Optional[Callable[[ModelType, ModelType], ModelType]] = None) -> ModelType: + """ + Merge item with existing, or add if not present. + """ + key = self._extract_key(item) + existing = self.get(key) + + if existing is None: + self.add(item) + return item + + # Custom or default merge + if custom_merge_function: + merged = custom_merge_function(existing, item) else: - raise KeyError(key) - - def __iter__(self): - return iter(self._map) - - def __len__(self): - return len(self._list) + # Default merge + existing_data = existing.model_dump() + new_data = item.model_dump(exclude_unset=True) + merged_data = self._deep_merge(existing_data, new_data) + merged = self._model_class.model_validate(merged_data) + + self.replace(merged) + return merged - def __eq__(self, other): - if isinstance(other, NDConfigCollection): - return self._list == other._list - elif isinstance(other, list): - return self._list == other - elif isinstance(other, dict): - return self._map == other - return False + def _deep_merge(self, base: Dict, update: Dict) -> Dict: + """Recursively merge dictionaries.""" + result = base.copy() + + for key, value in update.items(): + if value is None: + continue + + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._deep_merge(result[key], value) + else: + result[key] = value + + return result + + def delete(self, key: IdentifierKey) -> bool: + """ + Delete item by identifier (O(n) operation due to index rebuild) + """ + index = self._index.get(key) + + if index is None: + return False + + del self._items[index] + self._rebuild_index() + + return True + + # Diff Operations + + def get_diff_config(self, new_item: ModelType, unwanted_keys: Optional[List[Union[str, List[str]]]] = None) -> Literal["new", "no_diff", "changed"]: + """ + Compare single item against collection. + """ + try: + key = self._extract_key(new_item) + except ValueError: + return "new" + + existing = self.get(key) + + if existing is None: + return "new" - def __ne__(self, other): - return not self.__eq__(other) + existing_data = existing.to_diff_dict() + new_data = new_item.to_diff_dict() + + if unwanted_keys: + existing_data = self._remove_unwanted_keys(existing_data, unwanted_keys) + new_data = self._remove_unwanted_keys(new_data, unwanted_keys) - def __repr__(self): - return str(self._list) + is_subset = self._issubset(new_data, existing_data) + + return "no_diff" if is_subset else "changed" + + def get_diff_collection(self, other: "NDConfigCollection[ModelType]", unwanted_keys: Optional[List[Union[str, List[str]]]] = None) -> bool: + """ + Check if two collections differ. + """ + if not isinstance(other, NDConfigCollection): + raise TypeError("Argument must be NDConfigCollection") + + if len(self) != len(other): + return True - # Helper Methods - def _filter_dict(self, data, ignore_keys): - return {k: v for k, v in iteritems(data) if k not in ignore_keys} + for item in other: + if self.get_diff_config(item, unwanted_keys) != "no_diff": + return True - def _issubset(self, subset, superset): + for key in self.keys(): + if other.get(key) is None: + return True + + return False + + def get_diff_identifiers(self, other: "NDConfigCollection[ModelType]") -> List[IdentifierKey]: + """ + Get identifiers in self but not in other. + """ + current_keys = set(self.keys()) + other_keys = set(other.keys()) + return list(current_keys - other_keys) + + def _issubset(self, subset: Any, superset: Any) -> bool: + """Check if subset is contained in 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 iteritems(subset): + + for key, value in subset.items(): if value is None: continue - + if key not in superset: return False - - superset_value = superset.get(key) - - if not self._issubset(value, superset_value): + + if not self._issubset(value, superset[key]): return False + return True - def _remove_unwanted_keys(self, data, unwanted_keys): + def _remove_unwanted_keys(self, data: Dict, unwanted_keys: List[Union[str, List[str]]]) -> Dict: + """Remove unwanted keys from dict (supports nested paths).""" + data = deepcopy(data) + for key in unwanted_keys: if isinstance(key, str): if key in data: del data[key] + elif isinstance(key, list) and len(key) > 0: - key_path, last = key[:-1], key[-1] try: - parent = reduce(lambda d, k: d.get(k) if isinstance(d, dict) else None, key_path, data) - if isinstance(parent, dict) and last in parent: - del parent[last] - except (KeyError, TypeError): + parent = data + for k in key[:-1]: + if isinstance(parent, dict) and k in parent: + parent = parent[k] + else: + break + else: + if isinstance(parent, dict) and key[-1] in parent: + del parent[key[-1]] + except (KeyError, TypeError, IndexError): pass + return data - - # Core Operations - def to_list(self): - return self._list + + # Collection Operations - def to_dict(self): - return self._map - - def copy(self): - return NDConfigCollection(self.identifier_keys, deepcopy(self._list), self.use_composite_keys) - - def add(self, config): - ident = self._get_identifier_value(config) - if ident is None: - mode = "Composite" if self.use_composite_keys else "Priority" - raise ValueError("[{0} Mode] Config missing required keys: {1}".format(mode, self.identifier_keys)) - - if ident in self._map: - self.__setitem__(ident, config) - else: - self._list.append(config) - self._map[ident] = config - - def merge(self, new_config): - ident = self._get_identifier_value(new_config) - if ident and ident in self._map: - self._map[ident].update(new_config) - else: - self.add(new_config) - - def replace(self, new_config): - ident = self._get_identifier_value(new_config) - if ident: - self[ident] = new_config - else: - self.add(new_config) - - def remove(self, identifiers): - # Try Map Removal - try: - target_key = self._get_identifier_value(identifiers) - if target_key and target_key in self._map: - self.__delitem__(target_key) - return - except Exception: - pass - - # Fallback: Linear Removal - to_remove = [] - for config in self._list: - match = True - for k, v in iteritems(identifiers): - if config.get(k) != v: - match = False - break - if match: - to_remove.append(self._get_identifier_value(config)) - - for ident in to_remove: - if ident in self._map: - self.__delitem__(ident) - - def get_by_key(self, key, default=None): - return self._map.get(key, default) - - def get_by_idenfiers(self, identifiers, default=None): - # Try Map Lookup - target_key = self._get_identifier_value(identifiers) - if target_key and target_key in self._map: - return self._map[target_key] - - # Fallback: Linear Lookup - valid_search_keys = [k for k in identifiers if k in self.identifier_keys] - if not valid_search_keys: - return default - - for config in self._list: - match = True - for k in valid_search_keys: - if config.get(k) != identifiers[k]: - match = False - break - if match: - return config - return default - - # Diff logic - def get_diff_config(self, new_config, unwanted_keys=None): - unwanted_keys = unwanted_keys or [] - - ident = self._get_identifier_value(new_config) - - if not ident or ident not in self._map: - return "new" - - existing = deepcopy(self._map[ident]) - sent = deepcopy(new_config) - - self._remove_unwanted_keys(existing, unwanted_keys) - self._remove_unwanted_keys(sent, unwanted_keys) - - is_subset = self._issubset(sent, existing) - - if is_subset: - return "no_diff" - else: - return "changed" - - def get_diff_collection(self, new_collection, unwanted_keys=None): - if not isinstance(new_collection, NDConfigCollection): - raise TypeError("Argument must be an NDConfigCollection") - - if len(self) != len(new_collection): - return True - - for item in new_collection.to_list(): - if self.get_diff_config(item, unwanted_keys) != "no_diff": - return True - - for ident in self._map: - if ident not in new_collection._map: - return True - - return False - - def get_diff_identifiers(self, new_collection): - current_identifiers = set(self.config_collection.keys()) - other_identifiers = set(new_collection.config_collection.keys()) - - return list(current_identifiers - other_identifiers) + def __len__(self) -> int: + """Return number of items.""" + return len(self._items) + + def __iter__(self): + """Iterate over items.""" + return iter(self._items) - # Sanitize Operations - def sanitize(self, keys_to_remove=None, values_to_remove=None, remove_none_values=False): - keys_to_remove = keys_to_remove or [] - values_to_remove = values_to_remove or [] + def keys(self) -> List[IdentifierKey]: + """Get all identifier keys.""" + return list(self._index.keys()) - def recursive_clean(obj): - if isinstance(obj, dict): - keys = list(obj.keys()) - for k in keys: - v = obj[k] - if k in keys_to_remove or v in values_to_remove or (remove_none_values and v is None): - del obj[k] - continue - if isinstance(v, (dict, list)): - recursive_clean(v) - elif isinstance(obj, list): - for item in obj: - recursive_clean(item) + def copy(self) -> "NDConfigCollection[ModelType]": + """Create deep copy of collection.""" + return NDConfigCollection( + model_class=self._model_class, + items=deepcopy(self._items) + ) - for item in self._list: - recursive_clean(item) + # Serialization + + def to_list(self, **kwargs) -> List[Dict]: + """ + Export as list of dicts (with aliases). + """ + return [item.model_dump(by_alias=True, exclude_none=True, **kwargs) for item in self._items] + + def to_payload_list(self) -> List[Dict[str, Any]]: + """ + Export as list of API payloads. + """ + return [item.to_payload() for item in self._items] + + @classmethod + def from_list(cls, data: List[Dict], model_class: type[ModelType]) -> "NDConfigCollection[ModelType]": + """ + Create collection from list of dicts. + """ + items = [model_class.model_validate(item_data) for item_data in data] + return cls(model_class=model_class, items=items) + + @classmethod + def from_api_response(cls, response_data: List[Dict[str, Any]], model_class: type[ModelType]) -> "NDConfigCollection[ModelType]": + """ + Create collection from API response. + """ + items = [model_class.from_response(item_data) for item_data in response_data] + return cls(model_class=model_class, items=items) From 6fe3bbff551efe1c895533f5e69bd4a4e8868ff3 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Fri, 23 Jan 2026 13:51:54 -0500 Subject: [PATCH 11/51] [ignore] Pydantic Base ND Network Resource Module: Final proposition of core design changing existing methods and adding new ones which will be used in future as a based for ND network resource modules as well as basic error handling and simple docstrings. --- plugins/module_utils/nd_network_resources.py | 561 ++++++++++++++----- 1 file changed, 411 insertions(+), 150 deletions(-) diff --git a/plugins/module_utils/nd_network_resources.py b/plugins/module_utils/nd_network_resources.py index 3b549da1..ab7df9e2 100644 --- a/plugins/module_utils/nd_network_resources.py +++ b/plugins/module_utils/nd_network_resources.py @@ -9,196 +9,457 @@ __metaclass__ = type from copy import deepcopy -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 +from typing import Optional, List, Dict, Any, Callable, Literal +from pydantic import ValidationError -# TODO: Make further enhancement to logs and outputs -# TODO: Adapt to Pydantic Models -# NOTE: ONLY works for new API endpoints introduced in ND v4.1.0 and later -class NDNetworkResourceModule(NDModule): +# TODO: To be replaced with: +# 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.models.base import NDBaseModel +# from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED +from nd import NDModule +from nd_config_collection import NDConfigCollection +from models.base import NDBaseModel +from constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED - def __init__(self, module, path, identifier_keys, use_composite_keys=False, actions_overwrite_map=None): - super().__init__(module) - # Initial variables +class NDNetworkResourceModule(NDModule): + """ + Generic Network Resource Module for Nexus Dashboard. + """ + + def __init__(self, module, path: str, model_class: type[NDBaseModel], actions_overwrite_map: Optional[Dict[str, Callable]] = None): + """ + Initialize the Network Resource Module. + """ + super().__init__(module) + + # Configuration self.path = path + self.model_class = model_class self.actions_overwrite_map = actions_overwrite_map or {} - self.identifier_keys = identifier_keys - self.use_composite_keys = use_composite_keys - - # Initial data - self.init_all_data = self._query_all() - - # Info ouput - self.existing = NDConfigCollection(identifier_keys, data=self.init_all_data) - self.previous = NDConfigCollection(identifier_keys) - self.proposed = NDConfigCollection(identifier_keys) - self.sent = NDConfigCollection(identifier_keys) - - # Debug output - self.nd_logs = [] - - # Helper variables - self.current_identifier = "" - self.existing_config = {} - self.proposed_config = {} - - # Actions Operations - def actions_overwrite(action): + + # Initialize collections + try: + init_all_data = self._query_all() + + self.existing = NDConfigCollection.from_api_response( + response_data=init_all_data, + model_class=model_class + ) + self.previous = NDConfigCollection(model_class=model_class) + self.proposed = NDConfigCollection(model_class=model_class) + self.sent = NDConfigCollection(model_class=model_class) + + except Exception as e: + self.fail_json( + msg=f"Initialization failed: {str(e)}", + error=str(e) + ) + + # Operation tracking + self.nd_logs: List[Dict[str, Any]] = [] + + # Current operation context + self.current_identifier = None + self.existing_config: Dict[str, Any] = {} + self.proposed_config: Dict[str, Any] = {} + + # Action Decorator + + @staticmethod + def actions_overwrite(action: str): + """ + Decorator to allow overriding default action operations. + """ def decorator(func): def wrapper(self, *args, **kwargs): overwrite_action = self.actions_overwrite_map.get(action) if callable(overwrite_action): - return overwrite_action(self) + return overwrite_action(self, *args, **kwargs) else: return func(self, *args, **kwargs) return wrapper return decorator - + + # Action Operations + @actions_overwrite("create") - def _create(self): - if not self.module.check_mode: + def _create(self) -> Optional[Dict[str, Any]]: + """ + Create a new configuration object. + """ + if self.module.check_mode: + return self.proposed_config + + try: return self.request(path=self.path, method="POST", data=self.proposed_config) - + except Exception as e: + raise Exception(f"Create failed for {self.current_identifier}: {e}") from e + @actions_overwrite("update") - def _update(self): - if not self.module.check_mode: - object_path = "{0}/{1}".format(self.path, self.current_identifier) + def _update(self) -> Optional[Dict[str, Any]]: + """ + Update an existing configuration object. + """ + if self.module.check_mode: + return self.proposed_config + + try: + object_path = f"{self.path}/{self.current_identifier}" return self.request(path=object_path, method="PUT", data=self.proposed_config) - + except Exception as e: + raise Exception(f"Update failed for {self.current_identifier}: {e}") from e + @actions_overwrite("delete") - def _delete(self): - if not self.module.check_mode: - object_path = "{0}/{1}".format(self.path, self.current_identifier) + def _delete(self) -> None: + """Delete a configuration object.""" + if self.module.check_mode: + return + + try: + object_path = f"{self.path}/{self.current_identifier}" self.request(path=object_path, method="DELETE") + except Exception as e: + raise Exception(f"Delete failed for {self.current_identifier}: {e}") from e @actions_overwrite("query_all") - def _query_all(self): - return self.query_obj(self.path) - - def format_log(self, identifier, status, after_data, sent_payload_data=None): - item_result = { + def _query_all(self) -> List[Dict[str, Any]]: + """ + Query all configuration objects from device. + """ + try: + result = self.query_obj(self.path) + return result or [] + except Exception as e: + raise Exception(f"Query all failed: {e}") from e + + # Logging + + def format_log(self, identifier, status: Literal["created", "updated", "deleted", "no_change"], after_data: Optional[Dict[str, Any]] = None, sent_payload_data: Optional[Dict[str, Any]] = None) -> None: + """ + Create and append a log entry. + """ + log_entry = { "identifier": identifier, "status": status, - "before": self.existing_config, + "before": deepcopy(self.existing_config), "after": deepcopy(after_data) if after_data is not None else self.existing_config, - "sent_payload": deepcopy(sent_payload_data) if sent_payload_data is not None else {}, + "sent_payload": deepcopy(sent_payload_data) if sent_payload_data is not None else {} } - + + # Add HTTP details if not in check mode if not self.module.check_mode and self.url is not None: - item_result.update( - { - "method": self.method, - "response": self.response, - "status": self.status, - "url": self.url, - } + log_entry.update({ + "method": self.method, + "response": self.response, + "status": self.status, + "url": self.url + }) + + self.nd_logs.append(log_entry) + + # State Management + + def manage_state( + self, state: Literal["merged", "replaced", "overridden", "deleted"], new_configs: List[Dict[str, Any]], unwanted_keys: Optional[List] = None, override_exceptions: Optional[List] = None) -> None: + """ + Manage state according to desired configuration. + """ + unwanted_keys = unwanted_keys or [] + override_exceptions = override_exceptions or [] + + # Parse and validate configs + try: + parsed_items = [] + for config in new_configs: + try: + # Parse config into model + item = self.model_class.model_validate(config) + parsed_items.append(item) + except ValidationError as e: + self.fail_json( + msg=f"Invalid configuration: {e}", + config=config, + validation_errors=e.errors() + ) + return + + # Create proposed collection + self.proposed = NDConfigCollection( + model_class=self.model_class, + items=parsed_items ) + + # Save previous state + self.previous = self.existing.copy() - self.nd_logs.append(item_result) - - # Logs and Outputs formating Operations - # TODO: Move it to different file - def add_logs_and_ouputs(self): - if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: - if self.params.get("output_level") in ("debug", "info"): + except Exception as e: + self.fail_json( + msg=f"Failed to prepare configurations: {e}", + error=str(e) + ) + return + + # Execute state operations + if state in ["merged", "replaced", "overridden"]: + self._manage_create_update_state(state, unwanted_keys) + + if state == "overridden": + self._manage_override_deletions(override_exceptions) + + elif state == "deleted": + self._manage_delete_state() + + else: + self.fail_json(msg=f"Invalid state: {state}") + + def _manage_create_update_state(self,state: Literal["merged", "replaced", "overridden"], unwanted_keys: List) -> None: + """ + Handle merged/replaced/overridden states. + """ + for proposed_item in self.proposed: + try: + # Extract identifier + identifier = proposed_item.get_identifier_value() + self.current_identifier = identifier + + existing_item = self.existing.get(identifier) + self.existing_config = ( + existing_item.model_dump(by_alias=True, exclude_none=True) + if existing_item + else {} + ) + + # Determine diff status + diff_status = self.existing.get_diff_config( + proposed_item, + unwanted_keys=unwanted_keys + ) + + # No changes needed + if diff_status == "no_diff": + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config + ) + continue + + # Prepare final config based on state + if state == "merged" and existing_item: + # Merge with existing + merged_item = self.existing.merge(proposed_item) + final_item = merged_item + else: + # Replace or create + if existing_item: + self.existing.replace(proposed_item) + else: + self.existing.add(proposed_item) + final_item = proposed_item + + # Convert to API payload + self.proposed_config = final_item.to_payload() + + # Execute API operation + if diff_status == "changed": + response = self._update() + operation_status = "updated" + else: + response = self._create() + operation_status = "created" + + # Track sent payload + if not self.module.check_mode: + self.sent.add(final_item) + sent_payload = self.proposed_config + else: + sent_payload = None + + # Log operation + self.format_log( + identifier=identifier, + status=operation_status, + after_data=( + response if not self.module.check_mode + else final_item.model_dump(by_alias=True, exclude_none=True) + ), + sent_payload_data=sent_payload + ) + + except Exception as e: + error_msg = f"Failed to process {identifier}: {e}" + + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config + ) + + if not self.module.params.get("ignore_errors", False): + self.fail_json( + msg=error_msg, + identifier=str(identifier), + error=str(e) + ) + return + + def _manage_override_deletions(self, override_exceptions: List) -> None: + """ + Delete items not in proposed config (for overridden state). + """ + diff_identifiers = self.previous.get_diff_identifiers(self.proposed) + + for identifier in diff_identifiers: + if identifier in override_exceptions: + continue + + try: + self.current_identifier = identifier + + existing_item = self.existing.get(identifier) + if not existing_item: + continue + + self.existing_config = existing_item.model_dump( + by_alias=True, + exclude_none=True + ) + + # Execute delete + self._delete() + + # Remove from collection + self.existing.delete(identifier) + + # Log deletion + self.format_log( + identifier=identifier, + status="deleted", + after_data={} + ) + + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + + if not self.module.params.get("ignore_errors", False): + self.fail_json( + msg=error_msg, + identifier=str(identifier), + error=str(e) + ) + return + + def _manage_delete_state(self) -> None: + """Handle deleted state.""" + for proposed_item in self.proposed: + try: + identifier = proposed_item.get_identifier_value() + self.current_identifier = identifier + + existing_item = self.existing.get(identifier) + if not existing_item: + # Already deleted or doesn't exist + self.format_log( + identifier=identifier, + status="no_change", + after_data={} + ) + continue + + self.existing_config = existing_item.model_dump( + by_alias=True, + exclude_none=True + ) + + # Execute delete + self._delete() + + # Remove from collection + self.existing.delete(identifier) + + # Log deletion + self.format_log( + identifier=identifier, + status="deleted", + after_data={} + ) + + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + + if not self.module.params.get("ignore_errors", False): + self.fail_json( + msg=error_msg, + identifier=str(identifier), + error=str(e) + ) + return + + # Output Formatting + + def add_logs_and_outputs(self) -> None: + """Add logs and outputs to module result based on output_level.""" + output_level = self.params.get("output_level", "normal") + state = self.params.get("state") + + # Add previous state for certain states and output levels + if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: + if output_level in ("debug", "info"): self.result["previous"] = self.previous.to_list() + + # Check if there were changes if not self.has_modified and self.previous.get_diff_collection(self.existing): self.result["changed"] = True + + # Add stdout if present if self.stdout: self.result["stdout"] = self.stdout - - if self.params.get("output_level") == "debug": + + # Add debug information + if output_level == "debug": self.result["nd_logs"] = self.nd_logs + if self.url is not None: 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.to_list() - self.result["proposed"] = self.proposed.to_list() - - self.result["current"] = self.existing.to_list() - - # Manage State Operations - def manage_state(self, state, new_configs, unwanted_keys=None, override_exceptions=None): - unwanted_keys = unwanted_keys or [] - override_exceptions = override_exceptions or [] - - self.proposed = NDConfigCollection(self.identifier_keys, data=new_configs) - self.proposed.sanitize() - self.previous = self.existing.copy() - - if state in ["merged", "replaced", "overidden"]: - for identifier, config in self.proposed.items(): - - diff_config_info = self.existing.get_diff_config(config, unwanted_keys) - self.current_identifier = identifier - self.existing_config = deepcopy(self.existing.get_by_key(identifier, {})) - self.proposed_config = config - request_response = None - sent_payload = None - status = "no_change" - - if diff_config_info != "no_diff": - if state == "merged": - self.existing.merge(config) - self.proposed_config = self.existing[identifier] - else: - self.existing.replace(config) - - if diff_config_info == "changed": - request_response = self._update() - status = "updated" - else: - request_response = self._create() - status= "created" - - if not self.module.check_mode: - self.sent.add(self.proposed_config) - sent_payload = self.proposed_config - else: - request_response = self.proposed_config - - self.format_log(identifier, status, request_response, sent_payload) - - if state == "overidden": - diff_identifiers = self.previous.get_diff_identifiers(self.proposed) - for identifier in diff_identifiers: - if identifier not in override_exceptions: - self.current_identifier = identifier - self.existing_config = deepcopy(self.existing.get_by_key(identifier, {})) - self._delete() - del self.existing[identifier] - self.format_log(identifier, "deleted", after_data={}) + if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: + self.result["sent"] = self.sent.to_payload_list() + self.result["proposed"] = self.proposed.to_list() - - elif state == "deleted": - for identifier, config in self.proposed.items(): - if identifier in self.existing.keys(): - self.current_identifier = identifier - self.existing_config = deepcopy(self.existing.get_by_key(identifier, {})) - self.proposed_config = config - self._delete() - del self.existing[identifier] - self.format_log(identifier, "deleted", after_data={}) - - # Outputs Operations - def fail_json(self, msg, **kwargs): - self.add_logs_and_ouputs() - + # Always include current state + self.result["current"] = self.existing.to_list() + + # Module Exit Methods + + def fail_json(self, msg: str, **kwargs) -> None: + """ + Exit module with failure. + """ + self.add_logs_and_outputs() self.result.update(**kwargs) self.module.fail_json(msg=msg, **self.result) - - def exit_json(self, **kwargs): - self.add_logs_and_ouputs() - + + def exit_json(self, **kwargs) -> None: + """ + Exit module successfully. + """ + self.add_logs_and_outputs() + + # Add diff if module supports it if self.module._diff and self.result.get("changed") is True: - self.result["diff"] = dict( - before=self.previous.to_list(), - after=self.existing.to_list(), - ) - + try: + # Use diff-safe dicts (excludes sensitive fields) + before = [item.to_diff_dict() for item in self.previous] + after = [item.to_diff_dict() for item in self.existing] + + self.result["diff"] = dict( + before=before, + after=after + ) + except Exception: + pass # Don't fail on diff generation + self.result.update(**kwargs) self.module.exit_json(**self.result) From 0b36b2d1fc317f943aaecefddc91e594141dc98b Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Fri, 23 Jan 2026 14:37:44 -0500 Subject: [PATCH 12/51] [ignore] Modify nd_local_user based on Pydantic implementation and changes added to NDNetworkResourceModule. --- plugins/modules/nd_local_user.py | 91 +++++++++++++++----------------- 1 file changed, 43 insertions(+), 48 deletions(-) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 4a5f1ad2..3dcaf1a4 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -175,23 +175,34 @@ """ 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 +# TODO: To be replaced with: +# from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +# from ansible_collections.cisco.nd.plugins.module_utils.nd_network_resource_module import NDNetworkResourceModule +# from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel +# from ansible_collections.cisco.nd.plugins.module_utils.constants import USER_ROLES_MAPPING +from module_utils.nd import nd_argument_spec +from module_utils.nd_network_resources import NDNetworkResourceModule +from module_utils.models.local_user import LocalUserModel +from module_utils.constants import USER_ROLES_MAPPING -# Actions overwrite functions -def query_all_local_users(nd): - return nd.query_obj(nd.path).get("localusers") +# NOTE: Maybe Add the overwrite action in the LocalUserModel +def query_all_local_users(nd_module): + """ + Custom query_all action to extract 'localusers' from response. + """ + response = nd_module.query_obj(nd_module.path) + return response.get("localusers", []) -# TODO: Adapt to Pydantic Model +# NOTE: Maybe Add More aliases like in the LocalUserModel / Revisit the argmument_spec def main(): argument_spec = nd_argument_spec() argument_spec.update( config=dict( type="list", elements="dict", + required=True, options=dict( email=dict(type="str"), login_id=dict(type="str", required=True), @@ -221,49 +232,33 @@ def main(): argument_spec=argument_spec, supports_check_mode=True, ) - - path = "/api/v1/infra/aaa/localUsers" - identifier_keys = ["loginID"] - actions_overwrite_map = {"query_all": query_all_local_users} - - nd = NDNetworkResourceModule(module, path, identifier_keys, actions_overwrite_map=actions_overwrite_map) - - state = nd.params.get("state") - config = nd.params.get("config") - override_exceptions = nd.params.get("override_exceptions") - new_config = [] - for object in config: - payload = { - "email": object.get("email"), - "firstName": object.get("first_name"), - "lastName": object.get("last_name"), - "loginID": 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"), + + try: + # Create NDNetworkResourceModule with LocalUserModel + nd_module = NDNetworkResourceModule( + module=module, + path="/api/v1/infra/aaa/localUsers", + model_class=LocalUserModel, + actions_overwrite_map={ + "query_all": query_all_local_users } - new_config.append(payload) - - nd.manage_state(state=state, new_configs=new_config, unwanted_keys=[["passwordPolicy", "passwordChangeTime"], ["userID"]], override_exceptions=override_exceptions) + ) + + # Manage state + nd_module.manage_state( + state=module.params["state"], + new_configs=module.params["config"], + unwanted_keys=[ + ["passwordPolicy", "passwordChangeTime"], # Nested path + ["userID"] # Simple key + ], + override_exceptions=module.params.get("override_exceptions") + ) - nd.exit_json() + nd_module.exit_json() + + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}") if __name__ == "__main__": From e37636f1a576aed2fd8d39a9db41ab02cb033bfd Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 17 Feb 2026 13:46:10 -0500 Subject: [PATCH 13/51] [ignore] Add api_endpoints for configuring endpoints and orchestrators for orchestrating crud api operations with model instances and endpoints. --- plugins/module_utils/api_endpoints/base.py | 178 ++++++++++++++++++ plugins/module_utils/api_endpoints/enums.py | 46 +++++ .../module_utils/api_endpoints/local_user.py | 178 ++++++++++++++++++ plugins/module_utils/api_endpoints/mixins.py | 25 +++ plugins/module_utils/orchestrators/base.py | 79 ++++++++ .../module_utils/orchestrators/local_user.py | 42 +++++ 6 files changed, 548 insertions(+) create mode 100644 plugins/module_utils/api_endpoints/base.py create mode 100644 plugins/module_utils/api_endpoints/enums.py create mode 100644 plugins/module_utils/api_endpoints/local_user.py create mode 100644 plugins/module_utils/api_endpoints/mixins.py create mode 100644 plugins/module_utils/orchestrators/base.py create mode 100644 plugins/module_utils/orchestrators/local_user.py diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py new file mode 100644 index 00000000..1a9cd768 --- /dev/null +++ b/plugins/module_utils/api_endpoints/base.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# Copyright: (c) 2026, 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 abc import ABC, abstractmethod +from pydantic import BaseModel, ConfigDict +from typing import Final, Union, Tuple, Any + +IdentifierKey = Union[str, int, Tuple[Any, ...], None] + +class NDBaseSmartEndpoint(BaseModel, ABC): + + # TODO: maybe to be modified in the future + model_config = ConfigDict(validate_assignment=True) + + base_path: str + + @abstractmethod + @property + def path(self) -> str: + pass + + @abstractmethod + @property + def verb(self) -> str: + pass + + # TODO: Maybe to be modifed to be more Pydantic + # TODO: Maybe change function's name + # NOTE: function to set mixins fields from identifiers + @abstractmethod + def set_identifiers(self, identifier: IdentifierKey = None): + pass + + +class NDBasePath: + """ + # Summary + + Centralized API Base Paths + + ## Description + + Provides centralized base path definitions for all ND API endpoints. + This allows API path changes to be managed in a single location. + + ## Usage + + ```python + # Get a complete base path + path = BasePath.control_fabrics("MyFabric", "config-deploy") + # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/MyFabric/config-deploy + + # Build custom paths + path = BasePath.v1("custom", "endpoint") + # Returns: /appcenter/cisco/ndfc/api/v1/custom/endpoint + ``` + + ## Design Notes + + - All base paths are defined as class constants for easy modification + - Helper methods compose paths from base constants + - Use these methods in Pydantic endpoint models to ensure consistency + - If NDFC changes base API paths, only this class needs updating + """ + + # Root API paths + NDFC_API: Final = "/appcenter/cisco/ndfc/api" + ND_INFRA_API: Final = "/api/v1/infra" + ONEMANAGE: Final = "/onemanage" + LOGIN: Final = "/login" + + @classmethod + def api(cls, *segments: str) -> str: + """ + # Summary + + Build path from NDFC API root. + + ## Parameters + + - segments: Path segments to append + + ## Returns + + - Complete path string + + ## Example + + ```python + path = BasePath.api("custom", "endpoint") + # Returns: /appcenter/cisco/ndfc/api/custom/endpoint + ``` + """ + if not segments: + return cls.NDFC_API + return f"{cls.NDFC_API}/{'/'.join(segments)}" + + @classmethod + def v1(cls, *segments: str) -> str: + """ + # Summary + + Build v1 API path. + + ## Parameters + + - segments: Path segments to append after v1 + + ## Returns + + - Complete v1 API path + + ## Example + + ```python + path = BasePath.v1("lan-fabric", "rest") + # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest + ``` + """ + return cls.api("v1", *segments) + + @classmethod + def nd_infra(cls, *segments: str) -> str: + """ + # Summary + + Build ND infra API path. + + ## Parameters + + - segments: Path segments to append after /api/v1/infra + + ## Returns + + - Complete ND infra API path + + ## Example + + ```python + path = BasePath.nd_infra("aaa", "localUsers") + # Returns: /api/v1/infra/aaa/localUsers + ``` + """ + if not segments: + return cls.ND_INFRA_API + return f"{cls.ND_INFRA_API}/{'/'.join(segments)}" + + @classmethod + def nd_infra_aaa(cls, *segments: str) -> str: + """ + # Summary + + Build ND infra AAA API path. + + ## Parameters + + - segments: Path segments to append after aaa (e.g., "localUsers") + + ## Returns + + - Complete ND infra AAA path + + ## Example + + ```python + path = BasePath.nd_infra_aaa("localUsers") + # Returns: /api/v1/infra/aaa/localUsers + ``` + """ + return cls.nd_infra("aaa", *segments) diff --git a/plugins/module_utils/api_endpoints/enums.py b/plugins/module_utils/api_endpoints/enums.py new file mode 100644 index 00000000..afb4dd5c --- /dev/null +++ b/plugins/module_utils/api_endpoints/enums.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Enums used in api_endpoints. +""" +from enum import Enum + + +class VerbEnum(str, Enum): + """ + # Summary + + Enum for HTTP verb values used in endpoints. + + ## Members + + - GET: Represents the HTTP GET method. + - POST: Represents the HTTP POST method. + - PUT: Represents the HTTP PUT method. + - DELETE: Represents the HTTP DELETE method. + """ + + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + + +class BooleanStringEnum(str, Enum): + """ + # Summary + + Enum for boolean string values used in query parameters. + + ## Members + + - TRUE: Represents the string "true". + - FALSE: Represents the string "false". + """ + + TRUE = "true" + FALSE = "false" \ No newline at end of file diff --git a/plugins/module_utils/api_endpoints/local_user.py b/plugins/module_utils/api_endpoints/local_user.py new file mode 100644 index 00000000..de493e40 --- /dev/null +++ b/plugins/module_utils/api_endpoints/local_user.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra AAA LocalUsers endpoint models. + +This module contains endpoint definitions for LocalUsers-related operations +in the ND Infra AAA API. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from typing import Literal, Union, Tuple, Any, Final +from mixins import LoginIdMixin +from enums import VerbEnum +from base import NDBaseSmartEndpoint, NDBasePath +from pydantic import Field + +IdentifierKey = Union[str, int, Tuple[Any, ...], None] + +class _EpApiV1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseSmartEndpoint): + """ + Base class for ND Infra AAA Local Users endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/infra/aaa/localUsers endpoint. + """ + + base_path: Final = NDBasePath.nd_infra_aaa("localUsers") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Returns + + - Complete endpoint path string, optionally including login_id + """ + if self.login_id is not None: + return NDBasePath.nd_infra_aaa("localUsers", self.login_id) + return self.base_path + + def set_identifiers(self, identifier: IdentifierKey = None): + self.login_id = identifier + + +class EpApiV1InfraAaaLocalUsersGet(_EpApiV1InfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users GET Endpoint + + ## Description + + Endpoint to retrieve local users from the ND Infra AAA service. + Optionally retrieve a specific local user by login_id. + + ## Path + + - /api/v1/infra/aaa/localUsers + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - GET + """ + + class_name: Literal["EpApiV1InfraAaaLocalUsersGet"] = Field( + default="EpApiV1InfraAaaLocalUsersGet", + description="Class name for backward compatibility", + frozen=True, + ) + + @property + def verb(self) -> VerbEnum: + """Return the HTTP verb for this endpoint.""" + return VerbEnum.GET + + +class EpApiV1InfraAaaLocalUsersPost(_EpApiV1InfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users POST Endpoint + + ## Description + + Endpoint to create a local user in the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers + + ## Verb + + - POST + """ + + class_name: Literal["EpApiV1InfraAaaLocalUsersPost"] = Field( + default="EpApiV1InfraAaaLocalUsersPost", + description="Class name for backward compatibility", + frozen=True, + ) + + @property + def verb(self) -> VerbEnum: + """Return the HTTP verb for this endpoint.""" + return VerbEnum.POST + + +class EpApiV1InfraAaaLocalUsersPut(_EpApiV1InfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users PUT Endpoint + + ## Description + + Endpoint to update a local user in the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - PUT + """ + + class_name: Literal["EpApiV1InfraAaaLocalUsersPut"] = Field( + default="EpApiV1InfraAaaLocalUsersPut", + description="Class name for backward compatibility", + frozen=True, + ) + + @property + def verb(self) -> VerbEnum: + """Return the HTTP verb for this endpoint.""" + return VerbEnum.PUT + + +class EpApiV1InfraAaaLocalUsersDelete(_EpApiV1InfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users DELETE Endpoint + + ## Description + + Endpoint to delete a local user from the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - DELETE + """ + + class_name: Literal["EpApiV1InfraAaaLocalUsersDelete"] = Field( + default="EpApiV1InfraAaaLocalUsersDelete", + description="Class name for backward compatibility", + frozen=True, + ) + + @property + def verb(self) -> VerbEnum: + """Return the HTTP verb for this endpoint.""" + return VerbEnum.DELETE diff --git a/plugins/module_utils/api_endpoints/mixins.py b/plugins/module_utils/api_endpoints/mixins.py new file mode 100644 index 00000000..8ff3218f --- /dev/null +++ b/plugins/module_utils/api_endpoints/mixins.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Reusable mixin classes for endpoint models. + +This module provides mixin classes that can be composed to add common +fields to endpoint models without duplication. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from typing import TYPE_CHECKING, Optional +from pydantic import BaseModel, Field + + +class LoginIdMixin(BaseModel): + """Mixin for endpoints that require login_id parameter.""" + + login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") \ No newline at end of file diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py new file mode 100644 index 00000000..120ea475 --- /dev/null +++ b/plugins/module_utils/orchestrators/base.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, 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 ..models.base import NDBaseModel +from ..nd import NDModule +from ..api_endpoints.base import NDBaseSmartEndpoint +from typing import Dict, List, Any, Union, ClassVar, Type +from pydantic import BaseModel + + +ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] + + +# TODO: Revisit naming them "Orchestrator" +class NDBaseOrchestrator(BaseModel): + + model_class: ClassVar[Type[NDBaseModel]] = Type[NDBaseModel] + + # NOTE: if not defined by subclasses, return an error as they are required + post_endpoint: NDBaseSmartEndpoint + put_endpoint: NDBaseSmartEndpoint + delete_endpoint: NDBaseSmartEndpoint + get_endpoint: NDBaseSmartEndpoint + + # NOTE: Module Field is always required + # TODO: Replace it with future sender + module: NDModule + + # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") + # TODO: Explore how to make them even more general + def create(self, model_instance: NDBaseModel) -> ResponseType: + if self.module.check_mode: + return model_instance.model_dump() + + try: + return self.module.request(path=self.post_endpoint.base_path, method=self.post_endpoint.verb, data=model_instance.model_dump()) + except Exception as e: + raise Exception(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e + + def update(self, model_instance: NDBaseModel) -> ResponseType: + if self.module.check_mode: + return model_instance.model_dump() + + try: + self.put_endpoint.set_identifiers(model_instance.get_identifier_value()) + return self.module.request(path=self.put_endpoint.path, method=self.put_endpoint.verb, data=model_instance.model_dump()) + except Exception as e: + raise Exception(f"Update failed for {self.current_identifier}: {e}") from e + + def delete(self, model_instance: NDBaseModel) -> ResponseType: + if self.module.check_mode: + return model_instance.model_dump() + + try: + self.delete_endpoint.set_identifiers(model_instance.get_identifier_value()) + return self.module.request(path=self.delete_endpoint.path, method=self.delete_endpoint.verb) + except Exception as e: + raise Exception(f"Delete failed for {self.current_identifier}: {e}") from e + + def query_one(self, model_instance: NDBaseModel) -> ResponseType: + try: + self.get_endpoint.set_identifiers(model_instance.get_identifier_value()) + return self.module.request(path=self.get_endpoint.path, method=self.get_endpoint.verb) + except Exception as e: + raise Exception(f"Query failed for {self.current_identifier}: {e}") from e + + def query_all(self) -> ResponseType: + try: + result = self.module.query_obj(self.get_endpoint.path) + return result or [] + except Exception as e: + raise Exception(f"Query all failed: {e}") from e \ No newline at end of file diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py new file mode 100644 index 00000000..b156512c --- /dev/null +++ b/plugins/module_utils/orchestrators/local_user.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, 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 .base import NDBaseOrchestrator +from ..models.local_user import LocalUserModel +from typing import Dict, List, Any, Union, Type +from ..api_endpoints.local_user import ( + EpApiV1InfraAaaLocalUsersPost, + EpApiV1InfraAaaLocalUsersPut, + EpApiV1InfraAaaLocalUsersDelete, + EpApiV1InfraAaaLocalUsersGet, +) + + +ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] + +class LocalUserOrchestrator(NDBaseOrchestrator): + + model_class = Type[LocalUserModel] + + post_endpoint = EpApiV1InfraAaaLocalUsersPost() + put_endpoint = EpApiV1InfraAaaLocalUsersPut() + delete_endpoint = EpApiV1InfraAaaLocalUsersDelete() + get_endpoint = EpApiV1InfraAaaLocalUsersGet() + + def query_all(self): + """ + Custom query_all action to extract 'localusers' from response. + """ + try: + result = self.module.query_obj(self.get_endpoint.base_path) + return result.get("localusers", []) or [] + except Exception as e: + raise Exception(f"Query all failed: {e}") from e + \ No newline at end of file From fcde8c9b213b8b36294f37406da1440fd5573d8a Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 18 Feb 2026 01:23:37 -0500 Subject: [PATCH 14/51] [ignore] Modifiy models/local_user to take full advantage of Pydantic built-in functionalities. Slightly modify models/base.py to enforce identifiers definitions in NDBaseModel subclasses. Added multiple notes to assert next steps. --- plugins/module_utils/models/base.py | 48 ++++- plugins/module_utils/models/local_user.py | 216 ++++++++++++++-------- 2 files changed, 183 insertions(+), 81 deletions(-) diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index a7eabf17..5a64c7a9 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -10,10 +10,11 @@ from abc import ABC, abstractmethod from pydantic import BaseModel, ConfigDict -from typing import List, Dict, Any, ClassVar, Tuple, Union, Literal +from typing import List, Dict, Any, ClassVar, Tuple, Union, Literal, Optional from typing_extensions import Self +# TODO: Revisit identifiers strategy (low priority) class NDBaseModel(BaseModel, ABC): """ Base model for all Nexus Dashboard API objects. @@ -22,8 +23,9 @@ class NDBaseModel(BaseModel, ABC): - single: One unique required field (e.g., ["login_id"]) - composite: Multiple required fields as tuple (e.g., ["device", "interface"]) - hierarchical: Priority-ordered fields (e.g., ["uuid", "name"]) + - none: no identifiers required (e.g., only a single instance can exist in Nexus Dasboard) """ - + # TODO: revisit initial Model Configurations (low priority) model_config = ConfigDict( str_strip_whitespace=True, use_enum_values=True, @@ -31,14 +33,38 @@ class NDBaseModel(BaseModel, ABC): populate_by_name=True, extra='ignore' ) - - # Subclasses MUST define these - identifiers: ClassVar[List[str]] = [] - identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "single" + + # TODO: Revisit identifiers strategy (low priority) + identifiers: ClassVar[Optional[List[str]]] = None + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "none"]]] = None # Optional: fields to exclude from diffs (e.g., passwords) exclude_from_diff: ClassVar[List[str]] = [] + + # TODO: Revisit it with identifiers strategy (low priority) + def __init_subclass__(cls, **kwargs): + """ + Enforce configuration for identifiers definition. + """ + super().__init_subclass__(**kwargs) + + # Skip enforcement for nested models + # TODO: Remove if `NDNestedModel` is a separated BaseModel (low priority) + if cls.__name__ in ['NDNestedModel']: + return + + if not hasattr(cls, "identifiers") or cls.identifiers is None: + raise ValueError( + f"Class {cls.__name__} must define 'identifiers' and 'identifier_strategy'." + f"Example: `identifiers: ClassVar[Optional[List[str]]] = ['login_id']`" + ) + if not hasattr(cls, "identifier_strategy") or cls.identifier_strategy is None: + raise ValueError( + f"Class {cls.__name__} must define 'identifiers' and 'identifier_strategy'." + f"Example: `identifier_strategy: ClassVar[Optional[Literal['single', 'composite', 'hierarchical', 'none']]] = 'single'`" + ) + # NOTE: Might not need to make them absractmethod because of the Pydantic built-in methods (low priority) @abstractmethod def to_payload(self) -> Dict[str, Any]: """ @@ -54,6 +80,8 @@ def from_response(cls, response: Dict[str, Any]) -> Self: """ pass + # TODO: Revisit this function when revisiting identifier strategy (low priority) + # TODO: Add condition when there is no identifiers (high priority) def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: """ Extract identifier value(s) from this instance: @@ -82,7 +110,7 @@ def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: missing.append(field) values.append(value) - # NOTE: might not be needed in the future with field_validator + # NOTE: might be redefined with Pydantic (low priority) if missing: raise ValueError( f"Composite identifier fields {missing} are None. " @@ -104,6 +132,7 @@ def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: else: raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") + def to_diff_dict(self) -> Dict[str, Any]: """ Export for diff comparison (excludes sensitive fields). @@ -114,12 +143,13 @@ def to_diff_dict(self) -> Dict[str, Any]: exclude=set(self.exclude_from_diff) ) -# NOTE: Maybe make it a seperate BaseModel +# TODO: Make it a seperated BaseModel (low priority) class NDNestedModel(NDBaseModel): """ Base for nested models without identifiers. """ + # TODO: Configuration Fields to be clearly defined here (low priority) identifiers: ClassVar[List[str]] = [] def to_payload(self) -> Dict[str, Any]: @@ -133,4 +163,4 @@ def from_response(cls, response: Dict[str, Any]) -> Self: """ Create model instance from API response. """ - return cls.model_validate(response) + return cls.model_validate(response, by_alias=True) diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index b7069126..4be05991 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -8,15 +8,15 @@ __metaclass__ = type -from pydantic import Field, SecretStr +from pydantic import Field, SecretStr, model_serializer, field_serializer, field_validator, model_validator, computed_field from types import MappingProxyType from typing import List, Dict, Any, Optional, ClassVar, Literal from typing_extensions import Self # TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel, NDNestedModel -from models.base import NDBaseModel, NDNestedModel +from .base import NDBaseModel, NDNestedModel -# TODO: Move it to constants.py and import it +# TODO: Move it to constants.py and make a reverse class Map for this USER_ROLES_MAPPING = MappingProxyType({ "fabric_admin": "fabric-admin", "observer": "observer", @@ -31,11 +31,13 @@ class LocalUserSecurityDomainModel(NDNestedModel): """Security domain configuration for local user (nested model).""" # Fields - name: str - roles: Optional[List[str]] = None - - def to_payload(self) -> Dict[str, Any]: + name: str = Field(..., alias="name", exclude=True) + roles: Optional[List[str]] = Field(default=None, alias="roles", exclude=True) + + # -- Serialization (Model instance -> API payload) -- + @model_serializer() + def serialize_model(self) -> Dict: return { self.name: { "roles": [ @@ -44,22 +46,12 @@ def to_payload(self) -> Dict[str, Any]: ] } } - - @classmethod - def from_response(cls, name: str, domain_config: Dict[str, Any]) -> Self: - # NOTE: Maybe create a function from it to be moved to utils.py and to be imported - reverse_mapping = {value: key for key, value in USER_ROLES_MAPPING.items()} - - return cls( - name=name, - roles=[ - reverse_mapping.get(role, role) - for role in domain_config.get("roles", []) - ] - ) + # -- Deserialization (API response / Ansible payload -> Model instance) -- + # NOTE: Not needed as it already defined in `LocalUserModel` -> investigate if needed +# TODO: Add field validation (e.g. me, le, choices, etc...) (medium priority) class LocalUserModel(NDBaseModel): """ Local user configuration. @@ -68,73 +60,153 @@ class LocalUserModel(NDBaseModel): """ # Identifier configuration - identifiers: ClassVar[List[str]] = ["login_id"] - identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "single" + # TODO: Revisit this identifiers strategy (low priority) + identifiers: ClassVar[Optional[List[str]]] = ["login_id"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "none"]]] = "single" + + # Keys management configurations + # TODO: Revisit these configurations (low priority) exclude_from_diff: ClassVar[List[str]] = ["user_password"] + unwanted_keys: ClassVar[List[List[str]]]= [ + ["passwordPolicy", "passwordChangeTime"], # Nested path + ["userID"] # Simple key + ] # Fields + # NOTE: `alias` are NOT the ansible aliases. they are the equivalent attribute's names from the API spec login_id: str = Field(..., alias="loginID") - email: Optional[str] = None + email: Optional[str] = Field(default=None, alias="email") first_name: Optional[str] = Field(default=None, alias="firstName") last_name: Optional[str] = Field(default=None, alias="lastName") user_password: Optional[SecretStr] = Field(default=None, alias="password") - reuse_limitation: Optional[int] = Field(default=None, alias="reuseLimitation") - time_interval_limitation: Optional[int] = Field(default=None, alias="timeIntervalLimitation") - security_domains: Optional[List[LocalUserSecurityDomainModel]] = Field(default=None, alias="domains") + reuse_limitation: Optional[int] = Field(default=None, alias="reuseLimitation", exclude=True) + time_interval_limitation: Optional[int] = Field(default=None, alias="timeIntervalLimitation", exclude=True) + security_domains: Optional[List[LocalUserSecurityDomainModel]] = Field(default=None, alias="rbac") remote_id_claim: Optional[str] = Field(default=None, alias="remoteIDClaim") remote_user_authorization: Optional[bool] = Field(default=None, alias="xLaunch") - + + # -- Serialization (Model instance -> API payload) -- + + @computed_field(alias="passwordPolicy") + @property + def password_policy(self) -> Optional[Dict[str, int]]: + """Computed nested structure for API payload.""" + if self.reuse_limitation is None and self.time_interval_limitation is None: + return None + + policy = {} + if self.reuse_limitation is not None: + policy["reuseLimitation"] = self.reuse_limitation + if self.time_interval_limitation is not None: + policy["timeIntervalLimitation"] = self.time_interval_limitation + return policy + + @field_serializer("user_password") + def serialize_password(self, value: Optional[SecretStr]) -> Optional[str]: + return value.get_secret_value() if value else None + + + @field_serializer("security_domains") + def serialize_domains(self, value: Optional[List[LocalUserSecurityDomainModel]]) -> Optional[Dict]: + # NOTE: exclude `None` values and empty list (-> should we exclude empty list?) + if not value: + return None + + domains_dict = {} + for domain in value: + domains_dict.update(domain.to_payload()) + + return { + "domains": domains_dict + } + + def to_payload(self) -> Dict[str, Any]: - payload = self.model_dump( - by_alias=True, - exclude={ - 'domains', - 'security_domains', - 'reuseLimitation', - 'reuse_limitation', - 'timeIntervalLimitation', - 'time_interval_limitation' - }, - exclude_none=True - ) + return self.model_dump(by_alias=True, exclude_none=True) - if self.user_password: - payload["password"] = self.user_password.get_secret_value() + # -- Deserialization (API response / Ansible payload -> Model instance) -- - if self.security_domains: - payload["rbac"] = {"domains": {}} - for domain in self.security_domains: - payload["rbac"]["domains"].update(domain.to_payload()) + @model_validator(mode="before") + @classmethod + def deserialize_password_policy(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data - if self.reuse_limitation is not None or self.time_interval_limitation is not None: - payload["passwordPolicy"] = {} - if self.reuse_limitation is not None: - payload["passwordPolicy"]["reuseLimitation"] = self.reuse_limitation - if self.time_interval_limitation is not None: - payload["passwordPolicy"]["timeIntervalLimitation"] = self.time_interval_limitation + password_policy = data.get("passwordPolicy") - return payload - + if password_policy and isinstance(password_policy, dict): + if "reuseLimitation" in password_policy: + data["reuse_limitation"] = password_policy["reuseLimitation"] + if "timeIntervalLimitation" in password_policy: + data["time_interval_limitation"] = password_policy["timeIntervalLimitation"] + + # Remove the nested structure from data to avoid conflicts + # (since it's a computed field, not a real field) + data.pop("passwordPolicy", None) + + return data + + @field_validator("security_domains", mode="before") @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - password_policy = response.get("passwordPolicy", {}) - rbac = response.get("rbac", {}) - domains = rbac.get("domains", {}) + def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: + if value is None: + return None + + # If already in list format (Ansible module representation), return as-is + if isinstance(value, list): + return value + + # If in the nested dict format (API representation) + if isinstance(value, dict) and "domains" in value: + domains_dict = value["domains"] + domains_list = [] + + for domain_name, domain_data in domains_dict.items(): + domains_list.append({ + "name": domain_name, + "roles": [USER_ROLES_MAPPING.get(role, role) for role in domain_data.get("roles", [])] + }) + + return domains_list - security_domains = [ - LocalUserSecurityDomainModel.from_response(name, config) - for name, config in domains.items() - ] if domains else None + return value + + # TODO: only works for api responses but NOT for Ansible configs -> needs to be fixed (high priority) + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + return cls.model_validate(response, by_alias=True) - return cls( - login_id=response.get("loginID"), - email=response.get("email"), - first_name=response.get("firstName"), - last_name=response.get("lastName"), - user_password=response.get("password"), - reuse_limitation=password_policy.get("reuseLimitation"), - time_interval_limitation=password_policy.get("timeIntervalLimitation"), - security_domains=security_domains, - remote_id_claim=response.get("remoteIDClaim"), - remote_user_authorization=response.get("xLaunch") + + # -- Extra -- + + # TODO: to generate from Fields (low priority) + def get_argument_spec(self): + return dict( + config=dict( + type="list", + elements="dict", + required=True, + 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"), + ), + ), + override_exceptions=dict(type="list", elements="str"), + state=dict(type="str", default="merged", choices=["merged", "replaced", "overridden", "deleted"]), ) From 5cf2a4863fc5d220ef72ba2da3da5bb437e74461 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 24 Feb 2026 12:57:37 -0500 Subject: [PATCH 15/51] [ignore] Adapt the Network Resource Module architecture for ND to smart endpoints and Pydantic models modification (works for merge and replace states). Add comments for next steps. --- plugins/module_utils/api_endpoints/base.py | 5 +- .../module_utils/api_endpoints/local_user.py | 1 + plugins/module_utils/models/base.py | 25 ++- plugins/module_utils/models/local_user.py | 12 +- plugins/module_utils/nd_config_collection.py | 76 ++------ plugins/module_utils/nd_network_resources.py | 163 ++++++------------ plugins/module_utils/orchestrators/base.py | 27 +-- .../module_utils/orchestrators/local_user.py | 12 +- plugins/module_utils/utils.py | 26 ++- plugins/modules/nd_local_user.py | 63 +------ 10 files changed, 159 insertions(+), 251 deletions(-) diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py index 1a9cd768..747c3283 100644 --- a/plugins/module_utils/api_endpoints/base.py +++ b/plugins/module_utils/api_endpoints/base.py @@ -15,11 +15,14 @@ IdentifierKey = Union[str, int, Tuple[Any, ...], None] +# TODO: Rename it to APIEndpoint +# NOTE: This is a very minimalist endpoint package -> needs to be enhanced class NDBaseSmartEndpoint(BaseModel, ABC): # TODO: maybe to be modified in the future model_config = ConfigDict(validate_assignment=True) + # TODO: to remove base_path: str @abstractmethod @@ -34,7 +37,7 @@ def verb(self) -> str: # TODO: Maybe to be modifed to be more Pydantic # TODO: Maybe change function's name - # NOTE: function to set mixins fields from identifiers + # NOTE: function to set endpoints attribute fields from identifiers @abstractmethod def set_identifiers(self, identifier: IdentifierKey = None): pass diff --git a/plugins/module_utils/api_endpoints/local_user.py b/plugins/module_utils/api_endpoints/local_user.py index de493e40..61f52ad8 100644 --- a/plugins/module_utils/api_endpoints/local_user.py +++ b/plugins/module_utils/api_endpoints/local_user.py @@ -31,6 +31,7 @@ class _EpApiV1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseSmartEndpoint): /api/v1/infra/aaa/localUsers endpoint. """ + # TODO: Remove it base_path: Final = NDBasePath.nd_infra_aaa("localUsers") @property diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 5a64c7a9..db7fd9ae 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -40,6 +40,7 @@ class NDBaseModel(BaseModel, ABC): # Optional: fields to exclude from diffs (e.g., passwords) exclude_from_diff: ClassVar[List[str]] = [] + unwanted_keys: ClassVar[List] = [] # TODO: Revisit it with identifiers strategy (low priority) def __init_subclass__(cls, **kwargs): @@ -65,8 +66,9 @@ def __init_subclass__(cls, **kwargs): ) # NOTE: Might not need to make them absractmethod because of the Pydantic built-in methods (low priority) + # NOTE: Should we use keyword arguments? @abstractmethod - def to_payload(self) -> Dict[str, Any]: + def to_payload(self, **kwargs) -> Dict[str, Any]: """ Convert model to API payload format. """ @@ -74,7 +76,7 @@ def to_payload(self) -> Dict[str, Any]: @classmethod @abstractmethod - def from_response(cls, response: Dict[str, Any]) -> Self: + def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: """ Create model instance from API response. """ @@ -142,6 +144,25 @@ def to_diff_dict(self) -> Dict[str, Any]: exclude_none=True, exclude=set(self.exclude_from_diff) ) + + # NOTE: initialize and return a deep copy of the instance? + # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? -> similar to NDCOnfigCollection... + def merge(self, other_model: "NDBaseModel") -> Self: + if not isinstance(other_model, type(self)): + # TODO: Change error message + return TypeError("models are not of the same type.") + + for field, value in other_model: + if value is None: + continue + + current_value = getattr(self, field) + if isinstance(current_value, NDBaseModel) and isinstance(value, NDBaseModel): + setattr(self, field, current_value.merge(value)) + + else: + setattr(self, field, value) + return self # TODO: Make it a seperated BaseModel (low priority) class NDNestedModel(NDBaseModel): diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 4be05991..ea511097 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -67,14 +67,14 @@ class LocalUserModel(NDBaseModel): # Keys management configurations # TODO: Revisit these configurations (low priority) exclude_from_diff: ClassVar[List[str]] = ["user_password"] - unwanted_keys: ClassVar[List[List[str]]]= [ + unwanted_keys: ClassVar[List]= [ ["passwordPolicy", "passwordChangeTime"], # Nested path ["userID"] # Simple key ] # Fields # NOTE: `alias` are NOT the ansible aliases. they are the equivalent attribute's names from the API spec - login_id: str = Field(..., alias="loginID") + login_id: str = Field(alias="loginID") email: Optional[str] = Field(default=None, alias="email") first_name: Optional[str] = Field(default=None, alias="firstName") last_name: Optional[str] = Field(default=None, alias="lastName") @@ -121,8 +121,8 @@ def serialize_domains(self, value: Optional[List[LocalUserSecurityDomainModel]]) } - def to_payload(self) -> Dict[str, Any]: - return self.model_dump(by_alias=True, exclude_none=True) + def to_payload(self, **kwargs) -> Dict[str, Any]: + return self.model_dump(by_alias=True, exclude_none=True, **kwargs) # -- Deserialization (API response / Ansible payload -> Model instance) -- @@ -173,8 +173,8 @@ def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: # TODO: only works for api responses but NOT for Ansible configs -> needs to be fixed (high priority) @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - return cls.model_validate(response, by_alias=True) + def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: + return cls.model_validate(response, by_alias=True, **kwargs) # -- Extra -- diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 2f256d30..a25287aa 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -12,24 +12,26 @@ from copy import deepcopy # TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from models.base import NDBaseModel +from .models.base import NDBaseModel +from .utils import issubset # Type aliases # NOTE: Maybe add more type aliases in the future if needed ModelType = TypeVar('ModelType', bound=NDBaseModel) +# TODO: Defined the same acros multiple files -> maybe move to constants.py IdentifierKey = Union[str, int, Tuple[Any, ...]] - +# TODO:Might make it a Pydantic RootModel (low priority but medium impact on NDNetworkResourceModule) class NDConfigCollection(Generic[ModelType]): """ Nexus Dashboard configuration collection for NDBaseModel instances. """ - def __init__(self, model_class: type[ModelType], items: Optional[List[ModelType]] = None): + def __init__(self, model_class: ModelType, items: Optional[List[ModelType]] = None): """ Initialize collection. """ - self._model_class = model_class + self._model_class: ModelType = model_class # Dual storage self._items: List[ModelType] = [] @@ -39,6 +41,7 @@ def __init__(self, model_class: type[ModelType], items: Optional[List[ModelType] for item in items: self.add(item) + # TODO: might not be necessary def _extract_key(self, item: ModelType) -> IdentifierKey: """ Extract identifier key from item. @@ -48,6 +51,7 @@ def _extract_key(self, item: ModelType) -> IdentifierKey: except Exception as e: raise ValueError(f"Failed to extract identifier: {e}") from e + # TODO: optimize it -> only needed for delete method (low priority) def _rebuild_index(self) -> None: """Rebuild index from scratch (O(n) operation).""" self._index.clear() @@ -105,8 +109,8 @@ def replace(self, item: ModelType) -> bool: self._items[index] = item return True - - def merge(self, item: ModelType, custom_merge_function: Optional[Callable[[ModelType, ModelType], ModelType]] = None) -> ModelType: + + def merge(self, item: ModelType) -> ModelType: """ Merge item with existing, or add if not present. """ @@ -116,35 +120,11 @@ def merge(self, item: ModelType, custom_merge_function: Optional[Callable[[Model if existing is None: self.add(item) return item - - # Custom or default merge - if custom_merge_function: - merged = custom_merge_function(existing, item) else: - # Default merge - existing_data = existing.model_dump() - new_data = item.model_dump(exclude_unset=True) - merged_data = self._deep_merge(existing_data, new_data) - merged = self._model_class.model_validate(merged_data) - + merged = existing.merge(item) self.replace(merged) return merged - - def _deep_merge(self, base: Dict, update: Dict) -> Dict: - """Recursively merge dictionaries.""" - result = base.copy() - - for key, value in update.items(): - if value is None: - continue - - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = self._deep_merge(result[key], value) - else: - result[key] = value - - return result - + def delete(self, key: IdentifierKey) -> bool: """ Delete item by identifier (O(n) operation due to index rebuild) @@ -161,6 +141,7 @@ def delete(self, key: IdentifierKey) -> bool: # Diff Operations + # NOTE: Maybe add a similar one in the NDBaseModel (-> but is it necessary?) def get_diff_config(self, new_item: ModelType, unwanted_keys: Optional[List[Union[str, List[str]]]] = None) -> Literal["new", "no_diff", "changed"]: """ Compare single item against collection. @@ -182,7 +163,7 @@ def get_diff_config(self, new_item: ModelType, unwanted_keys: Optional[List[Unio existing_data = self._remove_unwanted_keys(existing_data, unwanted_keys) new_data = self._remove_unwanted_keys(new_data, unwanted_keys) - is_subset = self._issubset(new_data, existing_data) + is_subset = issubset(new_data, existing_data) return "no_diff" if is_subset else "changed" @@ -214,28 +195,7 @@ def get_diff_identifiers(self, other: "NDConfigCollection[ModelType]") -> List[I other_keys = set(other.keys()) return list(current_keys - other_keys) - def _issubset(self, subset: Any, superset: Any) -> bool: - """Check if subset is contained in 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(): - if value is None: - continue - - if key not in superset: - return False - - if not self._issubset(value, superset[key]): - return False - - return True - + # TODO: Maybe not necessary def _remove_unwanted_keys(self, data: Dict, unwanted_keys: List[Union[str, List[str]]]) -> Dict: """Remove unwanted keys from dict (supports nested paths).""" data = deepcopy(data) @@ -282,8 +242,8 @@ def copy(self) -> "NDConfigCollection[ModelType]": items=deepcopy(self._items) ) - # Serialization - + # Collection Serialization + def to_list(self, **kwargs) -> List[Dict]: """ Export as list of dicts (with aliases). @@ -301,7 +261,7 @@ def from_list(cls, data: List[Dict], model_class: type[ModelType]) -> "NDConfigC """ Create collection from list of dicts. """ - items = [model_class.model_validate(item_data) for item_data in data] + items = [model_class.model_validate(item_data, by_name=True) for item_data in data] return cls(model_class=model_class, items=items) @classmethod diff --git a/plugins/module_utils/nd_network_resources.py b/plugins/module_utils/nd_network_resources.py index ab7df9e2..d52fb9de 100644 --- a/plugins/module_utils/nd_network_resources.py +++ b/plugins/module_utils/nd_network_resources.py @@ -9,8 +9,9 @@ __metaclass__ = type from copy import deepcopy -from typing import Optional, List, Dict, Any, Callable, Literal +from typing import Optional, List, Dict, Any, Literal, Type from pydantic import ValidationError +from ansible.module_utils.basic import AnsibleModule # TODO: To be replaced with: # from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule @@ -20,36 +21,48 @@ from nd import NDModule from nd_config_collection import NDConfigCollection from models.base import NDBaseModel +from .orchestrators.base import NDBaseOrchestrator from constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED - +# TODO: replace path and verbs with smart Endpoint (Top priority) +# TODO: Rename it (low priority) +# TODO: Revisit Deserialization in every method (high priority) class NDNetworkResourceModule(NDModule): """ Generic Network Resource Module for Nexus Dashboard. """ - def __init__(self, module, path: str, model_class: type[NDBaseModel], actions_overwrite_map: Optional[Dict[str, Callable]] = None): + def __init__(self, module: AnsibleModule, model_class: Type[NDBaseModel], model_orchestrator: Type[NDBaseOrchestrator]): """ Initialize the Network Resource Module. """ + # TODO: Revisit Module initialization and configuration (medium priority). e.g., use instead: + # nd_module = NDModule() super().__init__(module) # Configuration - self.path = path + # TODO: make sure `model_class` is the same as the one in `model_orchestrator`. if not, error out (high priority) self.model_class = model_class - self.actions_overwrite_map = actions_overwrite_map or {} + self.model_orchestrator = model_orchestrator(module=module) + # TODO: Revisit these class variables when udpating Module intialization and configuration (medium priority) + self.state = self.params["state"] + self.ansible_config = self.params["config"] + # Initialize collections + # TODO: Revisit collections initialization especially `init_all_data` (medium priority) + # TODO: Revisit class variables `previous`, `existing`, etc... (medium priority) + self.nd_config_collection = NDConfigCollection[model_class] try: - init_all_data = self._query_all() + init_all_data = self.model_orchestrator.query_all() - self.existing = NDConfigCollection.from_api_response( + self.existing = self.nd_config_collection.from_api_response( response_data=init_all_data, model_class=model_class ) - self.previous = NDConfigCollection(model_class=model_class) - self.proposed = NDConfigCollection(model_class=model_class) - self.sent = NDConfigCollection(model_class=model_class) + self.previous = self.nd_config_collection(model_class=model_class) + self.proposed = self.nd_config_collection(model_class=model_class) + self.sent = self.nd_config_collection(model_class=model_class) except Exception as e: self.fail_json( @@ -59,83 +72,10 @@ def __init__(self, module, path: str, model_class: type[NDBaseModel], actions_ov # Operation tracking self.nd_logs: List[Dict[str, Any]] = [] - - # Current operation context - self.current_identifier = None - self.existing_config: Dict[str, Any] = {} - self.proposed_config: Dict[str, Any] = {} - - # Action Decorator - - @staticmethod - def actions_overwrite(action: str): - """ - Decorator to allow overriding default action operations. - """ - def decorator(func): - def wrapper(self, *args, **kwargs): - overwrite_action = self.actions_overwrite_map.get(action) - if callable(overwrite_action): - return overwrite_action(self, *args, **kwargs) - else: - return func(self, *args, **kwargs) - return wrapper - return decorator - - # Action Operations - - @actions_overwrite("create") - def _create(self) -> Optional[Dict[str, Any]]: - """ - Create a new configuration object. - """ - if self.module.check_mode: - return self.proposed_config - - try: - return self.request(path=self.path, method="POST", data=self.proposed_config) - except Exception as e: - raise Exception(f"Create failed for {self.current_identifier}: {e}") from e - - @actions_overwrite("update") - def _update(self) -> Optional[Dict[str, Any]]: - """ - Update an existing configuration object. - """ - if self.module.check_mode: - return self.proposed_config - - try: - object_path = f"{self.path}/{self.current_identifier}" - return self.request(path=object_path, method="PUT", data=self.proposed_config) - except Exception as e: - raise Exception(f"Update failed for {self.current_identifier}: {e}") from e - - @actions_overwrite("delete") - def _delete(self) -> None: - """Delete a configuration object.""" - if self.module.check_mode: - return - - try: - object_path = f"{self.path}/{self.current_identifier}" - self.request(path=object_path, method="DELETE") - except Exception as e: - raise Exception(f"Delete failed for {self.current_identifier}: {e}") from e - - @actions_overwrite("query_all") - def _query_all(self) -> List[Dict[str, Any]]: - """ - Query all configuration objects from device. - """ - try: - result = self.query_obj(self.path) - return result or [] - except Exception as e: - raise Exception(f"Query all failed: {e}") from e - + # Logging - + # NOTE: format log placeholder + # TODO: use a proper logger (low priority) def format_log(self, identifier, status: Literal["created", "updated", "deleted", "no_change"], after_data: Optional[Dict[str, Any]] = None, sent_payload_data: Optional[Dict[str, Any]] = None) -> None: """ Create and append a log entry. @@ -159,20 +99,20 @@ def format_log(self, identifier, status: Literal["created", "updated", "deleted" self.nd_logs.append(log_entry) - # State Management - - def manage_state( - self, state: Literal["merged", "replaced", "overridden", "deleted"], new_configs: List[Dict[str, Any]], unwanted_keys: Optional[List] = None, override_exceptions: Optional[List] = None) -> None: + # State Management (core function) + # TODO: adapt all `manage` functions to endpoint/orchestrator strategies (Top priority) + def manage_state(self) -> None: """ Manage state according to desired configuration. """ unwanted_keys = unwanted_keys or [] - override_exceptions = override_exceptions or [] # Parse and validate configs + # TODO: move it to init() (top priority) + # TODO: Modify it if NDConfigCollection becomes a Pydantic RootModel (low priority) try: parsed_items = [] - for config in new_configs: + for config in self.ansible_config: try: # Parse config into model item = self.model_class.model_validate(config) @@ -186,7 +126,7 @@ def manage_state( return # Create proposed collection - self.proposed = NDConfigCollection( + self.proposed = self.nd_config_collection( model_class=self.model_class, items=parsed_items ) @@ -202,27 +142,29 @@ def manage_state( return # Execute state operations - if state in ["merged", "replaced", "overridden"]: - self._manage_create_update_state(state, unwanted_keys) + if self.state in ["merged", "replaced", "overridden"]: + self._manage_create_update_state() - if state == "overridden": - self._manage_override_deletions(override_exceptions) + if self.state == "overridden": + self._manage_override_deletions() - elif state == "deleted": + elif self.state == "deleted": self._manage_delete_state() + # TODO: not needed with Ansible `argument_spec` validation. Keep it for now but needs to be removed (low priority) else: - self.fail_json(msg=f"Invalid state: {state}") + self.fail_json(msg=f"Invalid state: {self.state}") - def _manage_create_update_state(self,state: Literal["merged", "replaced", "overridden"], unwanted_keys: List) -> None: + + def _manage_create_update_state(self) -> None: """ Handle merged/replaced/overridden states. """ for proposed_item in self.proposed: try: # Extract identifier + # TODO: Remove self.current_identifier, get it directly into the action functions identifier = proposed_item.get_identifier_value() - self.current_identifier = identifier existing_item = self.existing.get(identifier) self.existing_config = ( @@ -232,10 +174,7 @@ def _manage_create_update_state(self,state: Literal["merged", "replaced", "overr ) # Determine diff status - diff_status = self.existing.get_diff_config( - proposed_item, - unwanted_keys=unwanted_keys - ) + diff_status = self.existing.get_diff_config(proposed_item) # No changes needed if diff_status == "no_diff": @@ -247,7 +186,7 @@ def _manage_create_update_state(self,state: Literal["merged", "replaced", "overr continue # Prepare final config based on state - if state == "merged" and existing_item: + if self.state == "merged" and existing_item: # Merge with existing merged_item = self.existing.merge(proposed_item) final_item = merged_item @@ -264,16 +203,16 @@ def _manage_create_update_state(self,state: Literal["merged", "replaced", "overr # Execute API operation if diff_status == "changed": - response = self._update() + response = self.model_orchestrator.update(final_item) operation_status = "updated" else: - response = self._create() + response = self.model_orchestrator.create(final_item) operation_status = "created" # Track sent payload if not self.module.check_mode: self.sent.add(final_item) - sent_payload = self.proposed_config + sent_payload = final_item else: sent_payload = None @@ -297,7 +236,7 @@ def _manage_create_update_state(self,state: Literal["merged", "replaced", "overr after_data=self.existing_config ) - if not self.module.params.get("ignore_errors", False): + if not self.params.get("ignore_errors", False): self.fail_json( msg=error_msg, identifier=str(identifier), @@ -305,6 +244,7 @@ def _manage_create_update_state(self,state: Literal["merged", "replaced", "overr ) return + # TODO: Refactor with orchestrator (Top priority) def _manage_override_deletions(self, override_exceptions: List) -> None: """ Delete items not in proposed config (for overridden state). @@ -351,6 +291,7 @@ def _manage_override_deletions(self, override_exceptions: List) -> None: ) return + # TODO: Refactor with orchestrator (Top priority) def _manage_delete_state(self) -> None: """Handle deleted state.""" for proposed_item in self.proposed: @@ -398,7 +339,7 @@ def _manage_delete_state(self) -> None: return # Output Formatting - + # TODO: move to separate Class (results) -> align it with rest_send PR def add_logs_and_outputs(self) -> None: """Add logs and outputs to module result based on output_level.""" output_level = self.params.get("output_level", "normal") diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 120ea475..e2d9fa75 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -24,39 +24,43 @@ class NDBaseOrchestrator(BaseModel): model_class: ClassVar[Type[NDBaseModel]] = Type[NDBaseModel] # NOTE: if not defined by subclasses, return an error as they are required - post_endpoint: NDBaseSmartEndpoint - put_endpoint: NDBaseSmartEndpoint - delete_endpoint: NDBaseSmartEndpoint - get_endpoint: NDBaseSmartEndpoint + # TODO: change name from http method to crud (e.g. post -> create) + post_endpoint: Type[NDBaseSmartEndpoint] + put_endpoint: Type[NDBaseSmartEndpoint] + delete_endpoint: Type[NDBaseSmartEndpoint] + get_endpoint: Type[NDBaseSmartEndpoint] # NOTE: Module Field is always required # TODO: Replace it with future sender module: NDModule # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") - # TODO: Explore how to make them even more general + # TODO: Explore new ways to make them even more general + # TODO: Revisit Deserialization def create(self, model_instance: NDBaseModel) -> ResponseType: if self.module.check_mode: - return model_instance.model_dump() + return model_instance.to_payload() try: - return self.module.request(path=self.post_endpoint.base_path, method=self.post_endpoint.verb, data=model_instance.model_dump()) + api_endpoint = self.post_endpoint() + return self.module.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) except Exception as e: raise Exception(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e + # TODO: Make the same changes as create() with local api_endpoint variable def update(self, model_instance: NDBaseModel) -> ResponseType: if self.module.check_mode: - return model_instance.model_dump() + return model_instance.to_payload() try: self.put_endpoint.set_identifiers(model_instance.get_identifier_value()) - return self.module.request(path=self.put_endpoint.path, method=self.put_endpoint.verb, data=model_instance.model_dump()) + return self.module.request(path=self.put_endpoint.path, method=self.put_endpoint.verb, data=model_instance.to_payload()) except Exception as e: raise Exception(f"Update failed for {self.current_identifier}: {e}") from e def delete(self, model_instance: NDBaseModel) -> ResponseType: if self.module.check_mode: - return model_instance.model_dump() + return model_instance.to_payload() try: self.delete_endpoint.set_identifiers(model_instance.get_identifier_value()) @@ -71,7 +75,8 @@ def query_one(self, model_instance: NDBaseModel) -> ResponseType: except Exception as e: raise Exception(f"Query failed for {self.current_identifier}: {e}") from e - def query_all(self) -> ResponseType: + # TODO: Revisit the straegy around the query_all (see local_user's case) + def query_all(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: result = self.module.query_obj(self.get_endpoint.path) return result or [] diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index b156512c..3810fa83 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -9,8 +9,10 @@ __metaclass__ = type from .base import NDBaseOrchestrator +from ..models.base import NDBaseModel from ..models.local_user import LocalUserModel from typing import Dict, List, Any, Union, Type +from ..api_endpoints.base import NDBaseSmartEndpoint from ..api_endpoints.local_user import ( EpApiV1InfraAaaLocalUsersPost, EpApiV1InfraAaaLocalUsersPut, @@ -23,12 +25,12 @@ class LocalUserOrchestrator(NDBaseOrchestrator): - model_class = Type[LocalUserModel] + model_class: Type[NDBaseModel] = LocalUserModel - post_endpoint = EpApiV1InfraAaaLocalUsersPost() - put_endpoint = EpApiV1InfraAaaLocalUsersPut() - delete_endpoint = EpApiV1InfraAaaLocalUsersDelete() - get_endpoint = EpApiV1InfraAaaLocalUsersGet() + post_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPost + put_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPut + delete_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersDelete + get_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet def query_all(self): """ diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 5bf0a0f0..72ccbcd7 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -9,6 +9,7 @@ __metaclass__ = type from copy import deepcopy +from typing import Any def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remove_none_values=True): @@ -29,4 +30,27 @@ def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remo for index, item in enumerate(v): if isinstance(item, dict): result[k][index] = sanitize_dict(item, keys, values) - return result \ No newline at end of file + return result + + +def issubset(subset: Any, superset: Any) -> bool: + """Check if subset is contained in 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(): + if value is None: + continue + + if key not in superset: + return False + + if not issubset(value, superset[key]): + return False + + return True diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 3dcaf1a4..901549fb 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -180,53 +180,15 @@ # from ansible_collections.cisco.nd.plugins.module_utils.nd_network_resource_module import NDNetworkResourceModule # from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel # from ansible_collections.cisco.nd.plugins.module_utils.constants import USER_ROLES_MAPPING -from module_utils.nd import nd_argument_spec -from module_utils.nd_network_resources import NDNetworkResourceModule -from module_utils.models.local_user import LocalUserModel -from module_utils.constants import USER_ROLES_MAPPING +from ..module_utils.nd import nd_argument_spec +from ..module_utils.nd_network_resources import NDNetworkResourceModule +from ..module_utils.models.local_user import LocalUserModel +from ..module_utils.orchestrators.local_user import LocalUserOrchestrator -# NOTE: Maybe Add the overwrite action in the LocalUserModel -def query_all_local_users(nd_module): - """ - Custom query_all action to extract 'localusers' from response. - """ - response = nd_module.query_obj(nd_module.path) - return response.get("localusers", []) - - -# NOTE: Maybe Add More aliases like in the LocalUserModel / Revisit the argmument_spec def main(): argument_spec = nd_argument_spec() - argument_spec.update( - config=dict( - type="list", - elements="dict", - required=True, - 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"), - ), - ), - override_exceptions=dict(type="list", elements="str"), - state=dict(type="str", default="merged", choices=["merged", "replaced", "overridden", "deleted"]), - ) + argument_spec.update(LocalUserModel.get_argument_spec()) module = AnsibleModule( argument_spec=argument_spec, @@ -237,23 +199,12 @@ def main(): # Create NDNetworkResourceModule with LocalUserModel nd_module = NDNetworkResourceModule( module=module, - path="/api/v1/infra/aaa/localUsers", model_class=LocalUserModel, - actions_overwrite_map={ - "query_all": query_all_local_users - } + model_orchestrator=LocalUserOrchestrator, ) # Manage state - nd_module.manage_state( - state=module.params["state"], - new_configs=module.params["config"], - unwanted_keys=[ - ["passwordPolicy", "passwordChangeTime"], # Nested path - ["userID"] # Simple key - ], - override_exceptions=module.params.get("override_exceptions") - ) + nd_module.manage_state() nd_module.exit_json() From 6c411bcf7661dc444f902e11959a74a0837abaf2 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 25 Feb 2026 08:24:28 -0500 Subject: [PATCH 16/51] [ignore] Default to none and update condition for regarding in models/base.py. --- plugins/module_utils/models/base.py | 8 +++++--- plugins/module_utils/models/local_user.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index db7fd9ae..4ddeacd0 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -15,6 +15,7 @@ # TODO: Revisit identifiers strategy (low priority) +# TODO: add kwargs to every sub method class NDBaseModel(BaseModel, ABC): """ Base model for all Nexus Dashboard API objects. @@ -26,6 +27,7 @@ class NDBaseModel(BaseModel, ABC): - none: no identifiers required (e.g., only a single instance can exist in Nexus Dasboard) """ # TODO: revisit initial Model Configurations (low priority) + # TODO: enable extra model_config = ConfigDict( str_strip_whitespace=True, use_enum_values=True, @@ -36,7 +38,7 @@ class NDBaseModel(BaseModel, ABC): # TODO: Revisit identifiers strategy (low priority) identifiers: ClassVar[Optional[List[str]]] = None - identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "none"]]] = None + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "none"]]] = "none" # Optional: fields to exclude from diffs (e.g., passwords) exclude_from_diff: ClassVar[List[str]] = [] @@ -51,7 +53,7 @@ def __init_subclass__(cls, **kwargs): # Skip enforcement for nested models # TODO: Remove if `NDNestedModel` is a separated BaseModel (low priority) - if cls.__name__ in ['NDNestedModel']: + if cls.__name__ in ["NDNestedModel"] or any(base.__name__ == "NDNestedModel" for base in cls.__mro__): return if not hasattr(cls, "identifiers") or cls.identifiers is None: @@ -146,7 +148,7 @@ def to_diff_dict(self) -> Dict[str, Any]: ) # NOTE: initialize and return a deep copy of the instance? - # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? -> similar to NDCOnfigCollection... + # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? -> similar to NDCOnfigCollection... -> add argument to make it optional either replace def merge(self, other_model: "NDBaseModel") -> Self: if not isinstance(other_model, type(self)): # TODO: Change error message diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index ea511097..77307d07 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -74,6 +74,7 @@ class LocalUserModel(NDBaseModel): # Fields # NOTE: `alias` are NOT the ansible aliases. they are the equivalent attribute's names from the API spec + # TODO: use extra for generating argument_spec (low priority) login_id: str = Field(alias="loginID") email: Optional[str] = Field(default=None, alias="email") first_name: Optional[str] = Field(default=None, alias="firstName") From 8ed627c9b653febfb12f86e753c23dd1cec182cd Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 26 Feb 2026 10:38:50 -0500 Subject: [PATCH 17/51] [ignore] Add choice for when no identifier is needed. Add quick comments and changes to models/local_user.py and api_endpoints/base.py --- plugins/module_utils/api_endpoints/base.py | 6 ++--- plugins/module_utils/models/base.py | 29 +++++++++++----------- plugins/module_utils/models/local_user.py | 6 ++--- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py index 747c3283..90ef5c87 100644 --- a/plugins/module_utils/api_endpoints/base.py +++ b/plugins/module_utils/api_endpoints/base.py @@ -35,9 +35,9 @@ def path(self) -> str: def verb(self) -> str: pass - # TODO: Maybe to be modifed to be more Pydantic - # TODO: Maybe change function's name - # NOTE: function to set endpoints attribute fields from identifiers + # TODO: Maybe to be modifed to be more Pydantic (low priority) + # TODO: Maybe change function's name (low priority) + # NOTE: function to set endpoints attribute fields from identifiers -> acts as the bridge between Models and Endpoints for API Request Orchestration @abstractmethod def set_identifiers(self, identifier: IdentifierKey = None): pass diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 4ddeacd0..159acb93 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -15,7 +15,6 @@ # TODO: Revisit identifiers strategy (low priority) -# TODO: add kwargs to every sub method class NDBaseModel(BaseModel, ABC): """ Base model for all Nexus Dashboard API objects. @@ -24,24 +23,24 @@ class NDBaseModel(BaseModel, ABC): - single: One unique required field (e.g., ["login_id"]) - composite: Multiple required fields as tuple (e.g., ["device", "interface"]) - hierarchical: Priority-ordered fields (e.g., ["uuid", "name"]) - - none: no identifiers required (e.g., only a single instance can exist in Nexus Dasboard) + - singleton: no identifiers required (e.g., only a single instance can exist in Nexus Dasboard) """ # TODO: revisit initial Model Configurations (low priority) - # TODO: enable extra model_config = ConfigDict( str_strip_whitespace=True, use_enum_values=True, validate_assignment=True, populate_by_name=True, - extra='ignore' + extra='allow', # NOTE: enabled extra: allows to add extra Field infos for generating Ansible argument_spec and Module Docs ) # TODO: Revisit identifiers strategy (low priority) identifiers: ClassVar[Optional[List[str]]] = None - identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "none"]]] = "none" + # TODO: Rvisit no identifiers strategy naming (`singleton`) (low priority) + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" # Optional: fields to exclude from diffs (e.g., passwords) - exclude_from_diff: ClassVar[List[str]] = [] + exclude_from_diff: ClassVar[List] = [] unwanted_keys: ClassVar[List] = [] # TODO: Revisit it with identifiers strategy (low priority) @@ -52,7 +51,7 @@ def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) # Skip enforcement for nested models - # TODO: Remove if `NDNestedModel` is a separated BaseModel (low priority) + # TODO: Remove if `NDNestedModel` is a separated BaseModel (low conditional priority) if cls.__name__ in ["NDNestedModel"] or any(base.__name__ == "NDNestedModel" for base in cls.__mro__): return @@ -64,11 +63,10 @@ def __init_subclass__(cls, **kwargs): if not hasattr(cls, "identifier_strategy") or cls.identifier_strategy is None: raise ValueError( f"Class {cls.__name__} must define 'identifiers' and 'identifier_strategy'." - f"Example: `identifier_strategy: ClassVar[Optional[Literal['single', 'composite', 'hierarchical', 'none']]] = 'single'`" + f"Example: `identifier_strategy: ClassVar[Optional[Literal['single', 'composite', 'hierarchical', 'singleton']]] = 'single'`" ) # NOTE: Might not need to make them absractmethod because of the Pydantic built-in methods (low priority) - # NOTE: Should we use keyword arguments? @abstractmethod def to_payload(self, **kwargs) -> Dict[str, Any]: """ @@ -85,16 +83,15 @@ def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: pass # TODO: Revisit this function when revisiting identifier strategy (low priority) - # TODO: Add condition when there is no identifiers (high priority) - def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: + def get_identifier_value(self, **kwargs) -> Union[str, int, Tuple[Any, ...]]: """ Extract identifier value(s) from this instance: - single identifier: Returns field value. - composite identifiers: Returns tuple of all field values. - hierarchical identifiers: Returns tuple of (field_name, value) for first non-None field. """ - if not self.identifiers: - raise ValueError(f"{self.__class__.__name__} has no identifiers defined") + if not self.identifiers and self.identifier_strategy != "singleton": + raise ValueError(f"{self.__class__.__name__} must have identifiers defined with its current identifier strategy: `{self.identifier_strategy}`") if self.identifier_strategy == "single": value = getattr(self, self.identifiers[0], None) @@ -133,6 +130,10 @@ def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: f"No non-None value in hierarchical fields {self.identifiers}" ) + # TODO: Revisit condition when there is no identifiers (low priority) + elif self.identifier_strategy == "singleton": + return self.identifier_strategy + else: raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") @@ -166,7 +167,7 @@ def merge(self, other_model: "NDBaseModel") -> Self: setattr(self, field, value) return self -# TODO: Make it a seperated BaseModel (low priority) +# TODO: Make it a seperated BaseModel? (low conditional priority) class NDNestedModel(NDBaseModel): """ Base for nested models without identifiers. diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 77307d07..ed09666d 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -16,7 +16,7 @@ # TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel, NDNestedModel from .base import NDBaseModel, NDNestedModel -# TODO: Move it to constants.py and make a reverse class Map for this +# TODO: Move it to constants.py and make a reverse class Map for this (low priority) USER_ROLES_MAPPING = MappingProxyType({ "fabric_admin": "fabric-admin", "observer": "observer", @@ -51,7 +51,7 @@ def serialize_model(self) -> Dict: # NOTE: Not needed as it already defined in `LocalUserModel` -> investigate if needed -# TODO: Add field validation (e.g. me, le, choices, etc...) (medium priority) +# TODO: Add field validation (e.g. me, le, choices, etc...) (low priority) class LocalUserModel(NDBaseModel): """ Local user configuration. @@ -62,7 +62,7 @@ class LocalUserModel(NDBaseModel): # Identifier configuration # TODO: Revisit this identifiers strategy (low priority) identifiers: ClassVar[Optional[List[str]]] = ["login_id"] - identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "none"]]] = "single" + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" # Keys management configurations # TODO: Revisit these configurations (low priority) From 1ddd995e5b42018d920fa97825b0eec17b9d6afb Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 26 Feb 2026 10:42:12 -0500 Subject: [PATCH 18/51] [ignore] Complete orchestrators/base.py by making simple CRUD operations methods that work for single_identifier strategy (meant to be overridden if needed). --- plugins/module_utils/orchestrators/base.py | 48 ++++++++++--------- .../module_utils/orchestrators/local_user.py | 9 ++-- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index e2d9fa75..611f39a6 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -24,61 +24,63 @@ class NDBaseOrchestrator(BaseModel): model_class: ClassVar[Type[NDBaseModel]] = Type[NDBaseModel] # NOTE: if not defined by subclasses, return an error as they are required - # TODO: change name from http method to crud (e.g. post -> create) - post_endpoint: Type[NDBaseSmartEndpoint] - put_endpoint: Type[NDBaseSmartEndpoint] + create_endpoint: Type[NDBaseSmartEndpoint] + update_endpoint: Type[NDBaseSmartEndpoint] delete_endpoint: Type[NDBaseSmartEndpoint] - get_endpoint: Type[NDBaseSmartEndpoint] + query_endpoint: Type[NDBaseSmartEndpoint] # NOTE: Module Field is always required - # TODO: Replace it with future sender + # TODO: Replace it with future sender (low priority) module: NDModule # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") - # TODO: Explore new ways to make them even more general + # TODO: Explore new ways to make them even more general -> e.g., create a general API operation function (low priority) # TODO: Revisit Deserialization - def create(self, model_instance: NDBaseModel) -> ResponseType: + def create(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: if self.module.check_mode: return model_instance.to_payload() try: - api_endpoint = self.post_endpoint() + api_endpoint = self.create_endpoint() return self.module.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) except Exception as e: raise Exception(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e - # TODO: Make the same changes as create() with local api_endpoint variable - def update(self, model_instance: NDBaseModel) -> ResponseType: + def update(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: if self.module.check_mode: return model_instance.to_payload() try: - self.put_endpoint.set_identifiers(model_instance.get_identifier_value()) - return self.module.request(path=self.put_endpoint.path, method=self.put_endpoint.verb, data=model_instance.to_payload()) + api_endpoint = self.update_endpoint() + api_endpoint.set_identifiers(model_instance.get_identifier_value()) + return self.module.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) except Exception as e: - raise Exception(f"Update failed for {self.current_identifier}: {e}") from e + raise Exception(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e - def delete(self, model_instance: NDBaseModel) -> ResponseType: + def delete(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: if self.module.check_mode: return model_instance.to_payload() try: - self.delete_endpoint.set_identifiers(model_instance.get_identifier_value()) - return self.module.request(path=self.delete_endpoint.path, method=self.delete_endpoint.verb) + api_endpoint = self.delete_endpoint() + api_endpoint.set_identifiers(model_instance.get_identifier_value()) + return self.module.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) except Exception as e: - raise Exception(f"Delete failed for {self.current_identifier}: {e}") from e + raise Exception(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e - def query_one(self, model_instance: NDBaseModel) -> ResponseType: + def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: - self.get_endpoint.set_identifiers(model_instance.get_identifier_value()) - return self.module.request(path=self.get_endpoint.path, method=self.get_endpoint.verb) + api_endpoint = self.query_endpoint() + api_endpoint.set_identifiers(model_instance.get_identifier_value()) + self.query_endpoint.set_identifiers(model_instance.get_identifier_value()) + return self.module.request(path=api_endpoint.path, method=api_endpoint.verb) except Exception as e: - raise Exception(f"Query failed for {self.current_identifier}: {e}") from e + raise Exception(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e # TODO: Revisit the straegy around the query_all (see local_user's case) def query_all(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: - result = self.module.query_obj(self.get_endpoint.path) + result = self.module.query_obj(self.query_endpoint.path) return result or [] except Exception as e: - raise Exception(f"Query all failed: {e}") from e \ No newline at end of file + raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index 3810fa83..caacc5aa 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -27,18 +27,17 @@ class LocalUserOrchestrator(NDBaseOrchestrator): model_class: Type[NDBaseModel] = LocalUserModel - post_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPost - put_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPut + create_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPost + update_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPut delete_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersDelete - get_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet + query_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet def query_all(self): """ Custom query_all action to extract 'localusers' from response. """ try: - result = self.module.query_obj(self.get_endpoint.base_path) + result = self.module.query_obj(self.query_endpoint.base_path) return result.get("localusers", []) or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e - \ No newline at end of file From aa99bbf9214c65accde6d9b996e27a4a7c6d87ea Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 26 Feb 2026 10:44:23 -0500 Subject: [PATCH 19/51] [ignore] Fix and in nd_config_collections.py. Move to utils.py. --- plugins/module_utils/nd_config_collection.py | 42 +++----------------- plugins/module_utils/utils.py | 29 +++++++++++++- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index a25287aa..fa6662c9 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -18,10 +18,10 @@ # Type aliases # NOTE: Maybe add more type aliases in the future if needed ModelType = TypeVar('ModelType', bound=NDBaseModel) -# TODO: Defined the same acros multiple files -> maybe move to constants.py +# TODO: Defined the same acros multiple files -> maybe move to constants.py (low priority) IdentifierKey = Union[str, int, Tuple[Any, ...]] -# TODO:Might make it a Pydantic RootModel (low priority but medium impact on NDNetworkResourceModule) +# TODO: Make it a Pydantic RootModel? (low conditional priority but medium impact on NDNetworkResourceModule) class NDConfigCollection(Generic[ModelType]): """ Nexus Dashboard configuration collection for NDBaseModel instances. @@ -59,7 +59,7 @@ def _rebuild_index(self) -> None: key = self._extract_key(item) self._index[key] = index - # Core CRUD Operations + # Core Operations def add(self, item: ModelType) -> IdentifierKey: """ @@ -142,7 +142,7 @@ def delete(self, key: IdentifierKey) -> bool: # Diff Operations # NOTE: Maybe add a similar one in the NDBaseModel (-> but is it necessary?) - def get_diff_config(self, new_item: ModelType, unwanted_keys: Optional[List[Union[str, List[str]]]] = None) -> Literal["new", "no_diff", "changed"]: + def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "changed"]: """ Compare single item against collection. """ @@ -158,16 +158,12 @@ def get_diff_config(self, new_item: ModelType, unwanted_keys: Optional[List[Unio existing_data = existing.to_diff_dict() new_data = new_item.to_diff_dict() - - if unwanted_keys: - existing_data = self._remove_unwanted_keys(existing_data, unwanted_keys) - new_data = self._remove_unwanted_keys(new_data, unwanted_keys) is_subset = issubset(new_data, existing_data) return "no_diff" if is_subset else "changed" - def get_diff_collection(self, other: "NDConfigCollection[ModelType]", unwanted_keys: Optional[List[Union[str, List[str]]]] = None) -> bool: + def get_diff_collection(self, other: "NDConfigCollection[ModelType]") -> bool: """ Check if two collections differ. """ @@ -178,7 +174,7 @@ def get_diff_collection(self, other: "NDConfigCollection[ModelType]", unwanted_k return True for item in other: - if self.get_diff_config(item, unwanted_keys) != "no_diff": + if self.get_diff_config(item) != "no_diff": return True for key in self.keys(): @@ -195,32 +191,6 @@ def get_diff_identifiers(self, other: "NDConfigCollection[ModelType]") -> List[I other_keys = set(other.keys()) return list(current_keys - other_keys) - # TODO: Maybe not necessary - def _remove_unwanted_keys(self, data: Dict, unwanted_keys: List[Union[str, List[str]]]) -> Dict: - """Remove unwanted keys from dict (supports nested paths).""" - data = deepcopy(data) - - for key in unwanted_keys: - if isinstance(key, str): - if key in data: - del data[key] - - elif isinstance(key, list) and len(key) > 0: - try: - parent = data - for k in key[:-1]: - if isinstance(parent, dict) and k in parent: - parent = parent[k] - else: - break - else: - if isinstance(parent, dict) and key[-1] in parent: - del parent[key[-1]] - except (KeyError, TypeError, IndexError): - pass - - return data - # Collection Operations def __len__(self) -> int: diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 72ccbcd7..a7c1d3dc 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -9,7 +9,7 @@ __metaclass__ = type from copy import deepcopy -from typing import Any +from typing import Any, Dict, List, Union def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remove_none_values=True): @@ -54,3 +54,30 @@ def issubset(subset: Any, superset: Any) -> bool: return False return True + + +# TODO: Might not necessary with Pydantic validation and serialization built-in methods +def remove_unwanted_keys(data: Dict, unwanted_keys: List[Union[str, List[str]]]) -> Dict: + """Remove unwanted keys from dict (supports nested paths).""" + data = deepcopy(data) + + for key in unwanted_keys: + if isinstance(key, str): + if key in data: + del data[key] + + elif isinstance(key, list) and len(key) > 0: + try: + parent = data + for k in key[:-1]: + if isinstance(parent, dict) and k in parent: + parent = parent[k] + else: + break + else: + if isinstance(parent, dict) and key[-1] in parent: + del parent[key[-1]] + except (KeyError, TypeError, IndexError): + pass + + return data From 04c73ff73cc3ea62a186967f1e69f747050a020d Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 26 Feb 2026 14:01:24 -0500 Subject: [PATCH 20/51] [ignore] Rename NDNetworkResourceModule to NDStateMachine. Add file for NDNestedModel. Add types.file. Various Renaming and small Modifications across the repo. WIP. --- plugins/module_utils/api_endpoints/base.py | 2 +- .../module_utils/api_endpoints/local_user.py | 3 +- plugins/module_utils/constants.py | 21 ++++--- plugins/module_utils/models/base.py | 58 +++++++------------ plugins/module_utils/models/local_user.py | 28 ++++----- plugins/module_utils/models/nested.py | 22 +++++++ plugins/module_utils/nd.py | 5 -- plugins/module_utils/nd_config_collection.py | 28 +++++---- ...twork_resources.py => nd_state_machine.py} | 23 ++++---- plugins/module_utils/orchestrators/base.py | 8 +-- .../module_utils/orchestrators/local_user.py | 5 +- plugins/module_utils/types.py | 14 +++++ plugins/modules/nd_local_user.py | 7 +-- 13 files changed, 115 insertions(+), 109 deletions(-) create mode 100644 plugins/module_utils/models/nested.py rename plugins/module_utils/{nd_network_resources.py => nd_state_machine.py} (95%) create mode 100644 plugins/module_utils/types.py diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py index 90ef5c87..0355a1de 100644 --- a/plugins/module_utils/api_endpoints/base.py +++ b/plugins/module_utils/api_endpoints/base.py @@ -12,8 +12,8 @@ from abc import ABC, abstractmethod from pydantic import BaseModel, ConfigDict from typing import Final, Union, Tuple, Any +from ..types import IdentifierKey -IdentifierKey = Union[str, int, Tuple[Any, ...], None] # TODO: Rename it to APIEndpoint # NOTE: This is a very minimalist endpoint package -> needs to be enhanced diff --git a/plugins/module_utils/api_endpoints/local_user.py b/plugins/module_utils/api_endpoints/local_user.py index 61f52ad8..666782ab 100644 --- a/plugins/module_utils/api_endpoints/local_user.py +++ b/plugins/module_utils/api_endpoints/local_user.py @@ -20,8 +20,7 @@ from enums import VerbEnum from base import NDBaseSmartEndpoint, NDBasePath from pydantic import Field - -IdentifierKey = Union[str, int, Tuple[Any, ...], None] +from ..types import IdentifierKey class _EpApiV1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseSmartEndpoint): """ diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index cbba61b3..7bb7e95d 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -9,6 +9,18 @@ __metaclass__ = type +from typing import Dict +from types import MappingProxyType +from copy import deepcopy + +class NDConstantMapping(Dict): + + def __init__(self, data: Dict): + new_dict = deepcopy(data) + for k,v in data.items(): + new_dict[v] = k + return MappingProxyType(new_dict) + OBJECT_TYPES = { "tenant": "OST_TENANT", "vrf": "OST_VRF", @@ -175,12 +187,3 @@ ND_SETUP_NODE_DEPLOYMENT_TYPE = {"physical": "cimc", "virtual": "vnode"} BACKUP_TYPE = {"config_only": "config-only", None: "config-only", "": "config-only", "full": "full"} - -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/models/base.py b/plugins/module_utils/models/base.py index 159acb93..ca672fd5 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -15,6 +15,7 @@ # TODO: Revisit identifiers strategy (low priority) +# NOTE: what about List of NestedModels? -> make it a separate Sub Model class NDBaseModel(BaseModel, ABC): """ Base model for all Nexus Dashboard API objects. @@ -36,11 +37,12 @@ class NDBaseModel(BaseModel, ABC): # TODO: Revisit identifiers strategy (low priority) identifiers: ClassVar[Optional[List[str]]] = None - # TODO: Rvisit no identifiers strategy naming (`singleton`) (low priority) + # TODO: Revisit no identifiers strategy naming (`singleton` -> `unique`, `unnamed`) (low priority) identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" # Optional: fields to exclude from diffs (e.g., passwords) exclude_from_diff: ClassVar[List] = [] + # TODO: To be removed in the future (see local_user model) unwanted_keys: ClassVar[List] = [] # TODO: Revisit it with identifiers strategy (low priority) @@ -51,7 +53,6 @@ def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) # Skip enforcement for nested models - # TODO: Remove if `NDNestedModel` is a separated BaseModel (low conditional priority) if cls.__name__ in ["NDNestedModel"] or any(base.__name__ == "NDNestedModel" for base in cls.__mro__): return @@ -65,22 +66,26 @@ def __init_subclass__(cls, **kwargs): f"Class {cls.__name__} must define 'identifiers' and 'identifier_strategy'." f"Example: `identifier_strategy: ClassVar[Optional[Literal['single', 'composite', 'hierarchical', 'singleton']]] = 'single'`" ) - - # NOTE: Might not need to make them absractmethod because of the Pydantic built-in methods (low priority) - @abstractmethod + def to_payload(self, **kwargs) -> Dict[str, Any]: """ Convert model to API payload format. """ - pass + return self.model_dump(by_alias=True, exclude_none=True, **kwargs) - @classmethod - @abstractmethod - def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: + def to_config(self, **kwargs) -> Dict[str, Any]: """ - Create model instance from API response. + Convert model to Ansible config format. """ - pass + return self.model_dump(by_name=True, exclude_none=True, **kwargs) + + @classmethod + def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: + return cls.model_validate(response, by_alias=True, **kwargs) + + @classmethod + def from_config(cls, ansible_config: Dict[str, Any], **kwargs) -> Self: + return cls.model_validate(ansible_config, by_name=True, **kwargs) # TODO: Revisit this function when revisiting identifier strategy (low priority) def get_identifier_value(self, **kwargs) -> Union[str, int, Tuple[Any, ...]]: @@ -132,25 +137,26 @@ def get_identifier_value(self, **kwargs) -> Union[str, int, Tuple[Any, ...]]: # TODO: Revisit condition when there is no identifiers (low priority) elif self.identifier_strategy == "singleton": - return self.identifier_strategy + return None else: raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") - def to_diff_dict(self) -> Dict[str, Any]: + def to_diff_dict(self, **kwargs) -> Dict[str, Any]: """ Export for diff comparison (excludes sensitive fields). """ return self.model_dump( by_alias=True, exclude_none=True, - exclude=set(self.exclude_from_diff) + exclude=set(self.exclude_from_diff), + **kwargs ) # NOTE: initialize and return a deep copy of the instance? # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? -> similar to NDCOnfigCollection... -> add argument to make it optional either replace - def merge(self, other_model: "NDBaseModel") -> Self: + def merge(self, other_model: "NDBaseModel", **kwargs) -> Self: if not isinstance(other_model, type(self)): # TODO: Change error message return TypeError("models are not of the same type.") @@ -166,25 +172,3 @@ def merge(self, other_model: "NDBaseModel") -> Self: else: setattr(self, field, value) return self - -# TODO: Make it a seperated BaseModel? (low conditional priority) -class NDNestedModel(NDBaseModel): - """ - Base for nested models without identifiers. - """ - - # TODO: Configuration Fields to be clearly defined here (low priority) - identifiers: ClassVar[List[str]] = [] - - def to_payload(self) -> Dict[str, Any]: - """ - Convert model to API payload format. - """ - return self.model_dump(by_alias=True, exclude_none=True) - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - """ - Create model instance from API response. - """ - return cls.model_validate(response, by_alias=True) diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index ed09666d..dba35aee 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -13,11 +13,14 @@ from typing import List, Dict, Any, Optional, ClassVar, Literal from typing_extensions import Self -# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel, NDNestedModel -from .base import NDBaseModel, NDNestedModel - -# TODO: Move it to constants.py and make a reverse class Map for this (low priority) -USER_ROLES_MAPPING = MappingProxyType({ +# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from .base import NDBaseModel +from .nested import NDNestedModel +from ..constants import NDConstantMapping + +# Constant defined here as it is only used in this model +USER_ROLES_MAPPING = NDConstantMapping({ "fabric_admin": "fabric-admin", "observer": "observer", "super_admin": "super-admin", @@ -31,7 +34,7 @@ class LocalUserSecurityDomainModel(NDNestedModel): """Security domain configuration for local user (nested model).""" # Fields - name: str = Field(..., alias="name", exclude=True) + name: str = Field(alias="name", exclude=True) roles: Optional[List[str]] = Field(default=None, alias="roles", exclude=True) # -- Serialization (Model instance -> API payload) -- @@ -47,8 +50,7 @@ def serialize_model(self) -> Dict: } } - # -- Deserialization (API response / Ansible payload -> Model instance) -- - # NOTE: Not needed as it already defined in `LocalUserModel` -> investigate if needed + # NOTE: Deserialization defined in `LocalUserModel` due to API response complexity # TODO: Add field validation (e.g. me, le, choices, etc...) (low priority) @@ -121,10 +123,6 @@ def serialize_domains(self, value: Optional[List[LocalUserSecurityDomainModel]]) "domains": domains_dict } - - def to_payload(self, **kwargs) -> Dict[str, Any]: - return self.model_dump(by_alias=True, exclude_none=True, **kwargs) - # -- Deserialization (API response / Ansible payload -> Model instance) -- @model_validator(mode="before") @@ -172,12 +170,6 @@ def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: return value - # TODO: only works for api responses but NOT for Ansible configs -> needs to be fixed (high priority) - @classmethod - def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: - return cls.model_validate(response, by_alias=True, **kwargs) - - # -- Extra -- # TODO: to generate from Fields (low priority) diff --git a/plugins/module_utils/models/nested.py b/plugins/module_utils/models/nested.py new file mode 100644 index 00000000..f2560819 --- /dev/null +++ b/plugins/module_utils/models/nested.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, 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 typing import List, ClassVar +from .base import NDBaseModel + + +class NDNestedModel(NDBaseModel): + """ + Base for nested models without identifiers. + """ + + # NOTE: model_config, ClassVar, and Fields can be overwritten here if needed + + identifiers: ClassVar[List[str]] = [] diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index 5f528bb8..07af68e5 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -239,13 +239,8 @@ def request( if file is not None: info = self.connection.send_file_request(method, uri, file, data, None, file_key, file_ext) else: -<<<<<<< HEAD if data: info = self.connection.send_request(method, uri, json.dumps(data)) -======= - if data is not None: - info = conn.send_request(method, uri, json.dumps(data)) ->>>>>>> 7c967c3 ([minor_change] Add nd_local_user as a new network resource module for Nexus Dashboard v4.1.0 and higher.) else: info = self.connection.send_request(method, uri) self.result["data"] = data diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index fa6662c9..364519b8 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -14,14 +14,12 @@ # TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from .models.base import NDBaseModel from .utils import issubset +from .types import IdentifierKey # Type aliases -# NOTE: Maybe add more type aliases in the future if needed ModelType = TypeVar('ModelType', bound=NDBaseModel) -# TODO: Defined the same acros multiple files -> maybe move to constants.py (low priority) -IdentifierKey = Union[str, int, Tuple[Any, ...]] -# TODO: Make it a Pydantic RootModel? (low conditional priority but medium impact on NDNetworkResourceModule) + class NDConfigCollection(Generic[ModelType]): """ Nexus Dashboard configuration collection for NDBaseModel instances. @@ -156,9 +154,9 @@ def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "cha if existing is None: return "new" + # TODO: make a diff class level method for NDBaseModel existing_data = existing.to_diff_dict() new_data = new_item.to_diff_dict() - is_subset = issubset(new_data, existing_data) return "no_diff" if is_subset else "changed" @@ -214,30 +212,30 @@ def copy(self) -> "NDConfigCollection[ModelType]": # Collection Serialization - def to_list(self, **kwargs) -> List[Dict]: + def to_ansible_config(self, **kwargs) -> List[Dict]: """ - Export as list of dicts (with aliases). + Export as an Ansible config. """ - return [item.model_dump(by_alias=True, exclude_none=True, **kwargs) for item in self._items] + return [item.to_config(**kwargs) for item in self._items] - def to_payload_list(self) -> List[Dict[str, Any]]: + def to_payload_list(self, **kwargs) -> List[Dict[str, Any]]: """ Export as list of API payloads. """ - return [item.to_payload() for item in self._items] + return [item.to_payload(**kwargs) for item in self._items] @classmethod - def from_list(cls, data: List[Dict], model_class: type[ModelType]) -> "NDConfigCollection[ModelType]": + def from_ansible_config(cls, data: List[Dict], model_class: type[ModelType], **kwargs) -> "NDConfigCollection[ModelType]": """ - Create collection from list of dicts. + Create collection from Ansible config. """ - items = [model_class.model_validate(item_data, by_name=True) for item_data in data] + items = [model_class.from_config(item_data, **kwargs) for item_data in data] return cls(model_class=model_class, items=items) @classmethod - def from_api_response(cls, response_data: List[Dict[str, Any]], model_class: type[ModelType]) -> "NDConfigCollection[ModelType]": + def from_api_response(cls, response_data: List[Dict[str, Any]], model_class: type[ModelType], **kwargs) -> "NDConfigCollection[ModelType]": """ Create collection from API response. """ - items = [model_class.from_response(item_data) for item_data in response_data] + items = [model_class.from_response(item_data, **kwargs) for item_data in response_data] return cls(model_class=model_class, items=items) diff --git a/plugins/module_utils/nd_network_resources.py b/plugins/module_utils/nd_state_machine.py similarity index 95% rename from plugins/module_utils/nd_network_resources.py rename to plugins/module_utils/nd_state_machine.py index d52fb9de..5306bfe8 100644 --- a/plugins/module_utils/nd_network_resources.py +++ b/plugins/module_utils/nd_state_machine.py @@ -24,26 +24,24 @@ from .orchestrators.base import NDBaseOrchestrator from constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED -# TODO: replace path and verbs with smart Endpoint (Top priority) -# TODO: Rename it (low priority) + # TODO: Revisit Deserialization in every method (high priority) -class NDNetworkResourceModule(NDModule): +class NDStateMachine(NDModule): """ Generic Network Resource Module for Nexus Dashboard. """ - def __init__(self, module: AnsibleModule, model_class: Type[NDBaseModel], model_orchestrator: Type[NDBaseOrchestrator]): + def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchestrator]): """ Initialize the Network Resource Module. """ # TODO: Revisit Module initialization and configuration (medium priority). e.g., use instead: # nd_module = NDModule() super().__init__(module) - + # Configuration - # TODO: make sure `model_class` is the same as the one in `model_orchestrator`. if not, error out (high priority) - self.model_class = model_class self.model_orchestrator = model_orchestrator(module=module) + self.model_class = self.model_orchestrator.model_class # TODO: Revisit these class variables when udpating Module intialization and configuration (medium priority) self.state = self.params["state"] self.ansible_config = self.params["config"] @@ -52,17 +50,17 @@ def __init__(self, module: AnsibleModule, model_class: Type[NDBaseModel], model_ # Initialize collections # TODO: Revisit collections initialization especially `init_all_data` (medium priority) # TODO: Revisit class variables `previous`, `existing`, etc... (medium priority) - self.nd_config_collection = NDConfigCollection[model_class] + self.nd_config_collection = NDConfigCollection[self.model_class] try: init_all_data = self.model_orchestrator.query_all() self.existing = self.nd_config_collection.from_api_response( response_data=init_all_data, - model_class=model_class + model_class=self.model_class ) - self.previous = self.nd_config_collection(model_class=model_class) - self.proposed = self.nd_config_collection(model_class=model_class) - self.sent = self.nd_config_collection(model_class=model_class) + self.previous = self.nd_config_collection(model_class=self.model_class) + self.proposed = self.nd_config_collection(model_class=self.model_class) + self.sent = self.nd_config_collection(model_class=self.model_class) except Exception as e: self.fail_json( @@ -340,6 +338,7 @@ def _manage_delete_state(self) -> None: # Output Formatting # TODO: move to separate Class (results) -> align it with rest_send PR + # TODO: return a defined ordered list of config (for integration test) def add_logs_and_outputs(self) -> None: """Add logs and outputs to module result based on output_level.""" output_level = self.params.get("output_level", "normal") diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 611f39a6..db72b740 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -27,7 +27,8 @@ class NDBaseOrchestrator(BaseModel): create_endpoint: Type[NDBaseSmartEndpoint] update_endpoint: Type[NDBaseSmartEndpoint] delete_endpoint: Type[NDBaseSmartEndpoint] - query_endpoint: Type[NDBaseSmartEndpoint] + query_one_endpoint: Type[NDBaseSmartEndpoint] + query_all_endpoint: Type[NDBaseSmartEndpoint] # NOTE: Module Field is always required # TODO: Replace it with future sender (low priority) @@ -70,9 +71,8 @@ def delete(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: - api_endpoint = self.query_endpoint() + api_endpoint = self.query_one_endpoint() api_endpoint.set_identifiers(model_instance.get_identifier_value()) - self.query_endpoint.set_identifiers(model_instance.get_identifier_value()) return self.module.request(path=api_endpoint.path, method=api_endpoint.verb) except Exception as e: raise Exception(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e @@ -80,7 +80,7 @@ def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: # TODO: Revisit the straegy around the query_all (see local_user's case) def query_all(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: - result = self.module.query_obj(self.query_endpoint.path) + result = self.module.query_obj(self.query_all_endpoint.path) return result or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index caacc5aa..ef2aa36a 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -30,14 +30,15 @@ class LocalUserOrchestrator(NDBaseOrchestrator): create_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPost update_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPut delete_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersDelete - query_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet + query_one_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet + query_all_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet def query_all(self): """ Custom query_all action to extract 'localusers' from response. """ try: - result = self.module.query_obj(self.query_endpoint.base_path) + result = self.module.query_obj(self.query_all_endpoint.base_path) return result.get("localusers", []) or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/module_utils/types.py b/plugins/module_utils/types.py new file mode 100644 index 00000000..124aedd5 --- /dev/null +++ b/plugins/module_utils/types.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, 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 typing import Any, Union, Tuple + + +IdentifierKey = Union[str, int, Tuple[Any, ...]] diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 901549fb..67fb3e80 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -181,7 +181,7 @@ # from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel # from ansible_collections.cisco.nd.plugins.module_utils.constants import USER_ROLES_MAPPING from ..module_utils.nd import nd_argument_spec -from ..module_utils.nd_network_resources import NDNetworkResourceModule +from ..module_utils.nd_state_machine import NDStateMachine from ..module_utils.models.local_user import LocalUserModel from ..module_utils.orchestrators.local_user import LocalUserOrchestrator @@ -194,12 +194,11 @@ def main(): argument_spec=argument_spec, supports_check_mode=True, ) - + try: # Create NDNetworkResourceModule with LocalUserModel - nd_module = NDNetworkResourceModule( + nd_module = NDStateMachine( module=module, - model_class=LocalUserModel, model_orchestrator=LocalUserOrchestrator, ) From 85c36e8f10b4840f3a0f2faf87bf2acec3e7bb6b Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 26 Feb 2026 14:09:18 -0500 Subject: [PATCH 21/51] [ignore] Make a small change to NDModule request function. --- plugins/module_utils/nd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index 07af68e5..42b1b118 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -239,7 +239,7 @@ def request( if file is not None: info = self.connection.send_file_request(method, uri, file, data, None, file_key, file_ext) else: - if data: + if data is not None: info = self.connection.send_request(method, uri, json.dumps(data)) else: info = self.connection.send_request(method, uri) From 034b49f6e67656be64c439b8c59b187a727fcfc2 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Mon, 2 Mar 2026 17:59:17 -0500 Subject: [PATCH 22/51] [ignore] Modify nd_state_machine to work with orchestrators/models/api_endpoints. Adapt api_endpoints, models, orchestrators accordingly. Integration Tests passing for nd_local_user module. Still WIP. --- plugins/module_utils/api_endpoints/base.py | 6 +- .../module_utils/api_endpoints/local_user.py | 6 +- plugins/module_utils/constants.py | 9 +- plugins/module_utils/models/base.py | 3 +- plugins/module_utils/models/local_user.py | 5 +- plugins/module_utils/nd_state_machine.py | 237 ++++++++---------- plugins/module_utils/orchestrators/base.py | 34 ++- .../module_utils/orchestrators/local_user.py | 2 +- plugins/modules/nd_local_user.py | 4 +- requirements.txt | 3 +- .../network-integration.requirements.txt | 3 +- 11 files changed, 140 insertions(+), 172 deletions(-) diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py index 0355a1de..832476ed 100644 --- a/plugins/module_utils/api_endpoints/base.py +++ b/plugins/module_utils/api_endpoints/base.py @@ -25,13 +25,13 @@ class NDBaseSmartEndpoint(BaseModel, ABC): # TODO: to remove base_path: str - @abstractmethod @property + @abstractmethod def path(self) -> str: pass - - @abstractmethod + @property + @abstractmethod def verb(self) -> str: pass diff --git a/plugins/module_utils/api_endpoints/local_user.py b/plugins/module_utils/api_endpoints/local_user.py index 666782ab..cae1326b 100644 --- a/plugins/module_utils/api_endpoints/local_user.py +++ b/plugins/module_utils/api_endpoints/local_user.py @@ -16,9 +16,9 @@ __metaclass__ = type # pylint: disable=invalid-name from typing import Literal, Union, Tuple, Any, Final -from mixins import LoginIdMixin -from enums import VerbEnum -from base import NDBaseSmartEndpoint, NDBasePath +from .mixins import LoginIdMixin +from .enums import VerbEnum +from .base import NDBaseSmartEndpoint, NDBasePath from pydantic import Field from ..types import IdentifierKey diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 7bb7e95d..784a7f51 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -16,10 +16,13 @@ class NDConstantMapping(Dict): def __init__(self, data: Dict): - new_dict = deepcopy(data) + self.new_dict = deepcopy(data) for k,v in data.items(): - new_dict[v] = k - return MappingProxyType(new_dict) + self.new_dict[v] = k + self.new_dict = MappingProxyType(self.new_dict) + + def get_dict(self): + return self.new_dict OBJECT_TYPES = { "tenant": "OST_TENANT", diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index ca672fd5..7b569a58 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -32,6 +32,7 @@ class NDBaseModel(BaseModel, ABC): use_enum_values=True, validate_assignment=True, populate_by_name=True, + arbitrary_types_allowed=True, extra='allow', # NOTE: enabled extra: allows to add extra Field infos for generating Ansible argument_spec and Module Docs ) @@ -77,7 +78,7 @@ def to_config(self, **kwargs) -> Dict[str, Any]: """ Convert model to Ansible config format. """ - return self.model_dump(by_name=True, exclude_none=True, **kwargs) + return self.model_dump(by_alias=False, exclude_none=True, **kwargs) @classmethod def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index dba35aee..713d6040 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -27,7 +27,7 @@ "support_engineer": "support-engineer", "approver": "approver", "designer": "designer", -}) +}).get_dict() class LocalUserSecurityDomainModel(NDNestedModel): @@ -173,7 +173,8 @@ def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: # -- Extra -- # TODO: to generate from Fields (low priority) - def get_argument_spec(self): + @classmethod + def get_argument_spec(cls) -> Dict: return dict( config=dict( type="list", diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 5306bfe8..5b1f770c 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -16,16 +16,16 @@ # TODO: To be replaced with: # 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.models.base import NDBaseModel +# from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +# from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey # from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED -from nd import NDModule -from nd_config_collection import NDConfigCollection -from models.base import NDBaseModel +from .nd import NDModule +from .nd_config_collection import NDConfigCollection from .orchestrators.base import NDBaseOrchestrator -from constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED +from .types import IdentifierKey +from .constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED -# TODO: Revisit Deserialization in every method (high priority) class NDStateMachine(NDModule): """ Generic Network Resource Module for Nexus Dashboard. @@ -35,16 +35,21 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest """ Initialize the Network Resource Module. """ - # TODO: Revisit Module initialization and configuration (medium priority). e.g., use instead: + # TODO: Revisit Module initialization and configuration # nd_module = NDModule() - super().__init__(module) + self.module = module + self.nd_module = NDModule(module) + + # Operation tracking + self.nd_logs: List[Dict[str, Any]] = [] + self.result: Dict[str, Any] = {"changed": False} # Configuration - self.model_orchestrator = model_orchestrator(module=module) + self.model_orchestrator = model_orchestrator(sender=self.nd_module) self.model_class = self.model_orchestrator.model_class # TODO: Revisit these class variables when udpating Module intialization and configuration (medium priority) - self.state = self.params["state"] - self.ansible_config = self.params["config"] + self.state = self.module.params["state"] + self.ansible_config = self.module.params.get("config", []) # Initialize collections @@ -53,46 +58,64 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest self.nd_config_collection = NDConfigCollection[self.model_class] try: init_all_data = self.model_orchestrator.query_all() - + self.existing = self.nd_config_collection.from_api_response( response_data=init_all_data, model_class=self.model_class ) - self.previous = self.nd_config_collection(model_class=self.model_class) + # Save previous state + self.previous = self.existing.copy() self.proposed = self.nd_config_collection(model_class=self.model_class) self.sent = self.nd_config_collection(model_class=self.model_class) - + + for config in self.ansible_config: + try: + # Parse config into model + item = self.model_class.from_config(config) + self.proposed.add(item) + except ValidationError as e: + self.fail_json( + msg=f"Invalid configuration: {e}", + config=config, + validation_errors=e.errors() + ) + return + except Exception as e: self.fail_json( msg=f"Initialization failed: {str(e)}", error=str(e) ) - - # Operation tracking - self.nd_logs: List[Dict[str, Any]] = [] # Logging # NOTE: format log placeholder # TODO: use a proper logger (low priority) - def format_log(self, identifier, status: Literal["created", "updated", "deleted", "no_change"], after_data: Optional[Dict[str, Any]] = None, sent_payload_data: Optional[Dict[str, Any]] = None) -> None: + def format_log( + self, + identifier: IdentifierKey, + operation_status: Literal["no_change", "created", "updated", "deleted"], + before: Optional[Dict[str, Any]] = None, + after: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> None: """ Create and append a log entry. """ log_entry = { "identifier": identifier, - "status": status, - "before": deepcopy(self.existing_config), - "after": deepcopy(after_data) if after_data is not None else self.existing_config, - "sent_payload": deepcopy(sent_payload_data) if sent_payload_data is not None else {} + "operation_status": operation_status, + "before": before, + "after": after, + "payload": payload, } # Add HTTP details if not in check mode - if not self.module.check_mode and self.url is not None: + if not self.module.check_mode and self.nd_module.url is not None: log_entry.update({ - "method": self.method, - "response": self.response, - "status": self.status, - "url": self.url + "method": self.nd_module.method, + "response": self.nd_module.response, + "status": self.nd_module.status, + "url": self.nd_module.url }) self.nd_logs.append(log_entry) @@ -103,42 +126,6 @@ def manage_state(self) -> None: """ Manage state according to desired configuration. """ - unwanted_keys = unwanted_keys or [] - - # Parse and validate configs - # TODO: move it to init() (top priority) - # TODO: Modify it if NDConfigCollection becomes a Pydantic RootModel (low priority) - try: - parsed_items = [] - for config in self.ansible_config: - try: - # Parse config into model - item = self.model_class.model_validate(config) - parsed_items.append(item) - except ValidationError as e: - self.fail_json( - msg=f"Invalid configuration: {e}", - config=config, - validation_errors=e.errors() - ) - return - - # Create proposed collection - self.proposed = self.nd_config_collection( - model_class=self.model_class, - items=parsed_items - ) - - # Save previous state - self.previous = self.existing.copy() - - except Exception as e: - self.fail_json( - msg=f"Failed to prepare configurations: {e}", - error=str(e) - ) - return - # Execute state operations if self.state in ["merged", "replaced", "overridden"]: self._manage_create_update_state() @@ -159,18 +146,10 @@ def _manage_create_update_state(self) -> None: Handle merged/replaced/overridden states. """ for proposed_item in self.proposed: + # Extract identifier + identifier = proposed_item.get_identifier_value() + existing_config = self.existing.get(identifier).to_config() if self.existing.get(identifier) else {} try: - # Extract identifier - # TODO: Remove self.current_identifier, get it directly into the action functions - identifier = proposed_item.get_identifier_value() - - existing_item = self.existing.get(identifier) - self.existing_config = ( - existing_item.model_dump(by_alias=True, exclude_none=True) - if existing_item - else {} - ) - # Determine diff status diff_status = self.existing.get_diff_config(proposed_item) @@ -178,51 +157,44 @@ def _manage_create_update_state(self) -> None: if diff_status == "no_diff": self.format_log( identifier=identifier, - status="no_change", - after_data=self.existing_config + operation_status="no_change", + before=existing_config, + after=existing_config, ) continue # Prepare final config based on state - if self.state == "merged" and existing_item: + if self.state == "merged": # Merge with existing merged_item = self.existing.merge(proposed_item) final_item = merged_item else: # Replace or create - if existing_item: + if diff_status == "changed": self.existing.replace(proposed_item) else: self.existing.add(proposed_item) final_item = proposed_item - - # Convert to API payload - self.proposed_config = final_item.to_payload() - + # Execute API operation if diff_status == "changed": - response = self.model_orchestrator.update(final_item) + if not self.module.check_mode: + response = self.model_orchestrator.update(final_item) + self.sent.add(final_item) operation_status = "updated" - else: - response = self.model_orchestrator.create(final_item) + elif diff_status == "new": + if not self.module.check_mode: + response = self.model_orchestrator.create(final_item) + self.sent.add(final_item) operation_status = "created" - # Track sent payload - if not self.module.check_mode: - self.sent.add(final_item) - sent_payload = final_item - else: - sent_payload = None - # Log operation self.format_log( identifier=identifier, - status=operation_status, - after_data=( - response if not self.module.check_mode - else final_item.model_dump(by_alias=True, exclude_none=True) - ), - sent_payload_data=sent_payload + operation_status=operation_status, + before=existing_config, + after=self.model_class.model_validate(response).to_config() if not self.module.check_mode else final_item.to_config(), + payload=final_item.to_payload(), ) except Exception as e: @@ -230,11 +202,12 @@ def _manage_create_update_state(self) -> None: self.format_log( identifier=identifier, - status="no_change", - after_data=self.existing_config + operation_status="no_change", + before=existing_config, + after=existing_config, ) - if not self.params.get("ignore_errors", False): + if not self.module.params.get("ignore_errors", False): self.fail_json( msg=error_msg, identifier=str(identifier), @@ -243,30 +216,21 @@ def _manage_create_update_state(self) -> None: return # TODO: Refactor with orchestrator (Top priority) - def _manage_override_deletions(self, override_exceptions: List) -> None: + def _manage_override_deletions(self) -> None: """ Delete items not in proposed config (for overridden state). """ diff_identifiers = self.previous.get_diff_identifiers(self.proposed) for identifier in diff_identifiers: - if identifier in override_exceptions: - continue - try: - self.current_identifier = identifier - existing_item = self.existing.get(identifier) if not existing_item: continue - - self.existing_config = existing_item.model_dump( - by_alias=True, - exclude_none=True - ) - + # Execute delete - self._delete() + if not self.module.check_mode: + response = self.model_orchestrator.delete(existing_item) # Remove from collection self.existing.delete(identifier) @@ -274,8 +238,10 @@ def _manage_override_deletions(self, override_exceptions: List) -> None: # Log deletion self.format_log( identifier=identifier, - status="deleted", - after_data={} + operation_status="deleted", + before=existing_item.to_config(), + after={}, + ) except Exception as e: @@ -295,25 +261,21 @@ def _manage_delete_state(self) -> None: for proposed_item in self.proposed: try: identifier = proposed_item.get_identifier_value() - self.current_identifier = identifier existing_item = self.existing.get(identifier) if not existing_item: # Already deleted or doesn't exist self.format_log( identifier=identifier, - status="no_change", - after_data={} + operation_status="no_change", + before={}, + after={}, ) continue - self.existing_config = existing_item.model_dump( - by_alias=True, - exclude_none=True - ) - # Execute delete - self._delete() + if not self.module.check_mode: + response = self.model_orchestrator.delete(existing_item) # Remove from collection self.existing.delete(identifier) @@ -321,8 +283,9 @@ def _manage_delete_state(self) -> None: # Log deletion self.format_log( identifier=identifier, - status="deleted", - after_data={} + operation_status="deleted", + before=existing_item.to_config(), + after={}, ) except Exception as e: @@ -341,35 +304,35 @@ def _manage_delete_state(self) -> None: # TODO: return a defined ordered list of config (for integration test) def add_logs_and_outputs(self) -> None: """Add logs and outputs to module result based on output_level.""" - output_level = self.params.get("output_level", "normal") - state = self.params.get("state") + output_level = self.module.params.get("output_level", "normal") + state = self.module.params.get("state") # Add previous state for certain states and output levels if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: if output_level in ("debug", "info"): - self.result["previous"] = self.previous.to_list() + self.result["previous"] = self.previous.to_ansible_config() # Check if there were changes - if not self.has_modified and self.previous.get_diff_collection(self.existing): + if self.previous.get_diff_collection(self.existing): self.result["changed"] = True # Add stdout if present - if self.stdout: - self.result["stdout"] = self.stdout + if self.nd_module.stdout: + self.result["stdout"] = self.nd_module.stdout # Add debug information if output_level == "debug": self.result["nd_logs"] = self.nd_logs - if self.url is not None: - self.result["httpapi_logs"] = self.httpapi_logs + if self.nd_module.url is not None: + self.result["httpapi_logs"] = self.nd_module.httpapi_logs if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: self.result["sent"] = self.sent.to_payload_list() - self.result["proposed"] = self.proposed.to_list() + self.result["proposed"] = self.proposed.to_ansible_config() # Always include current state - self.result["current"] = self.existing.to_list() + self.result["current"] = self.existing.to_ansible_config() # Module Exit Methods diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index db72b740..924ea4b0 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -11,8 +11,8 @@ from ..models.base import NDBaseModel from ..nd import NDModule from ..api_endpoints.base import NDBaseSmartEndpoint -from typing import Dict, List, Any, Union, ClassVar, Type -from pydantic import BaseModel +from typing import Dict, List, Any, Union, ClassVar, Type, Optional +from pydantic import BaseModel, ConfigDict ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] @@ -21,6 +21,13 @@ # TODO: Revisit naming them "Orchestrator" class NDBaseOrchestrator(BaseModel): + model_config = ConfigDict( + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + arbitrary_types_allowed=True, + ) + model_class: ClassVar[Type[NDBaseModel]] = Type[NDBaseModel] # NOTE: if not defined by subclasses, return an error as they are required @@ -32,40 +39,31 @@ class NDBaseOrchestrator(BaseModel): # NOTE: Module Field is always required # TODO: Replace it with future sender (low priority) - module: NDModule + sender: NDModule # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") # TODO: Explore new ways to make them even more general -> e.g., create a general API operation function (low priority) # TODO: Revisit Deserialization def create(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: - if self.module.check_mode: - return model_instance.to_payload() - try: api_endpoint = self.create_endpoint() - return self.module.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) except Exception as e: raise Exception(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e def update(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: - if self.module.check_mode: - return model_instance.to_payload() - try: api_endpoint = self.update_endpoint() api_endpoint.set_identifiers(model_instance.get_identifier_value()) - return self.module.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) except Exception as e: raise Exception(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e def delete(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: - if self.module.check_mode: - return model_instance.to_payload() - try: api_endpoint = self.delete_endpoint() api_endpoint.set_identifiers(model_instance.get_identifier_value()) - return self.module.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb) except Exception as e: raise Exception(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e @@ -73,14 +71,14 @@ def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: api_endpoint = self.query_one_endpoint() api_endpoint.set_identifiers(model_instance.get_identifier_value()) - return self.module.request(path=api_endpoint.path, method=api_endpoint.verb) + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb) except Exception as e: raise Exception(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e # TODO: Revisit the straegy around the query_all (see local_user's case) - def query_all(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: + def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> ResponseType: try: - result = self.module.query_obj(self.query_all_endpoint.path) + result = self.sender.query_obj(self.query_all_endpoint.path) return result or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index ef2aa36a..46a4ea07 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -38,7 +38,7 @@ def query_all(self): Custom query_all action to extract 'localusers' from response. """ try: - result = self.module.query_obj(self.query_all_endpoint.base_path) + result = self.sender.query_obj(self.query_all_endpoint.base_path) return result.get("localusers", []) or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 67fb3e80..b6acee72 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -177,9 +177,9 @@ from ansible.module_utils.basic import AnsibleModule # TODO: To be replaced with: # from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec -# from ansible_collections.cisco.nd.plugins.module_utils.nd_network_resource_module import NDNetworkResourceModule +# from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine # from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel -# from ansible_collections.cisco.nd.plugins.module_utils.constants import USER_ROLES_MAPPING +# from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.local_user import LocalUserOrchestrator from ..module_utils.nd import nd_argument_spec from ..module_utils.nd_state_machine import NDStateMachine from ..module_utils.models.local_user import LocalUserModel diff --git a/requirements.txt b/requirements.txt index 514632d1..98907e9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests_toolbelt jsonpath-ng -lxml \ No newline at end of file +lxml +pydantic==2.12.5 \ No newline at end of file diff --git a/tests/integration/network-integration.requirements.txt b/tests/integration/network-integration.requirements.txt index 514632d1..98907e9a 100644 --- a/tests/integration/network-integration.requirements.txt +++ b/tests/integration/network-integration.requirements.txt @@ -1,3 +1,4 @@ requests_toolbelt jsonpath-ng -lxml \ No newline at end of file +lxml +pydantic==2.12.5 \ No newline at end of file From b6ddee406b72cf93f24854981db82d58f7e8f474 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 3 Mar 2026 11:46:31 -0500 Subject: [PATCH 23/51] [ignore] Add proper path dependencies and Ran black formatting. --- plugins/module_utils/api_endpoints/base.py | 5 +- plugins/module_utils/api_endpoints/enums.py | 2 +- .../module_utils/api_endpoints/local_user.py | 13 +- plugins/module_utils/api_endpoints/mixins.py | 2 +- plugins/module_utils/constants.py | 7 +- plugins/module_utils/models/base.py | 60 +++--- plugins/module_utils/models/local_user.py | 75 +++----- plugins/module_utils/models/nested.py | 2 +- plugins/module_utils/nd_config_collection.py | 94 +++++----- plugins/module_utils/nd_state_machine.py | 171 +++++++----------- plugins/module_utils/orchestrators/base.py | 9 +- .../module_utils/orchestrators/local_user.py | 12 +- plugins/module_utils/utils.py | 16 +- plugins/modules/nd_api_key.py | 1 - plugins/modules/nd_local_user.py | 25 +-- 15 files changed, 204 insertions(+), 290 deletions(-) diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py index 832476ed..954c1f6a 100644 --- a/plugins/module_utils/api_endpoints/base.py +++ b/plugins/module_utils/api_endpoints/base.py @@ -12,13 +12,12 @@ from abc import ABC, abstractmethod from pydantic import BaseModel, ConfigDict from typing import Final, Union, Tuple, Any -from ..types import IdentifierKey +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.types import IdentifierKey # TODO: Rename it to APIEndpoint # NOTE: This is a very minimalist endpoint package -> needs to be enhanced class NDBaseSmartEndpoint(BaseModel, ABC): - # TODO: maybe to be modified in the future model_config = ConfigDict(validate_assignment=True) @@ -29,7 +28,7 @@ class NDBaseSmartEndpoint(BaseModel, ABC): @abstractmethod def path(self) -> str: pass - + @property @abstractmethod def verb(self) -> str: diff --git a/plugins/module_utils/api_endpoints/enums.py b/plugins/module_utils/api_endpoints/enums.py index afb4dd5c..ced62ba7 100644 --- a/plugins/module_utils/api_endpoints/enums.py +++ b/plugins/module_utils/api_endpoints/enums.py @@ -43,4 +43,4 @@ class BooleanStringEnum(str, Enum): """ TRUE = "true" - FALSE = "false" \ No newline at end of file + FALSE = "false" diff --git a/plugins/module_utils/api_endpoints/local_user.py b/plugins/module_utils/api_endpoints/local_user.py index cae1326b..72639495 100644 --- a/plugins/module_utils/api_endpoints/local_user.py +++ b/plugins/module_utils/api_endpoints/local_user.py @@ -16,11 +16,12 @@ __metaclass__ = type # pylint: disable=invalid-name from typing import Literal, Union, Tuple, Any, Final -from .mixins import LoginIdMixin -from .enums import VerbEnum -from .base import NDBaseSmartEndpoint, NDBasePath +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.mixins import LoginIdMixin +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.enums import VerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint, NDBasePath from pydantic import Field -from ..types import IdentifierKey +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + class _EpApiV1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseSmartEndpoint): """ @@ -105,7 +106,7 @@ class EpApiV1InfraAaaLocalUsersPost(_EpApiV1InfraAaaLocalUsersBase): """ class_name: Literal["EpApiV1InfraAaaLocalUsersPost"] = Field( - default="EpApiV1InfraAaaLocalUsersPost", + default="EpApiV1InfraAaaLocalUsersPost", description="Class name for backward compatibility", frozen=True, ) @@ -136,7 +137,7 @@ class EpApiV1InfraAaaLocalUsersPut(_EpApiV1InfraAaaLocalUsersBase): """ class_name: Literal["EpApiV1InfraAaaLocalUsersPut"] = Field( - default="EpApiV1InfraAaaLocalUsersPut", + default="EpApiV1InfraAaaLocalUsersPut", description="Class name for backward compatibility", frozen=True, ) diff --git a/plugins/module_utils/api_endpoints/mixins.py b/plugins/module_utils/api_endpoints/mixins.py index 8ff3218f..9516c9ce 100644 --- a/plugins/module_utils/api_endpoints/mixins.py +++ b/plugins/module_utils/api_endpoints/mixins.py @@ -22,4 +22,4 @@ class LoginIdMixin(BaseModel): """Mixin for endpoints that require login_id parameter.""" - login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") \ No newline at end of file + login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 784a7f51..afa0a2b0 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -13,17 +13,18 @@ from types import MappingProxyType from copy import deepcopy -class NDConstantMapping(Dict): +class NDConstantMapping(Dict): def __init__(self, data: Dict): self.new_dict = deepcopy(data) - for k,v in data.items(): + for k, v in data.items(): self.new_dict[v] = k self.new_dict = MappingProxyType(self.new_dict) - + def get_dict(self): return self.new_dict + OBJECT_TYPES = { "tenant": "OST_TENANT", "vrf": "OST_VRF", diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 7b569a58..94fb9cc5 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -19,13 +19,14 @@ class NDBaseModel(BaseModel, ABC): """ Base model for all Nexus Dashboard API objects. - + Supports three identifier strategies: - single: One unique required field (e.g., ["login_id"]) - composite: Multiple required fields as tuple (e.g., ["device", "interface"]) - hierarchical: Priority-ordered fields (e.g., ["uuid", "name"]) - singleton: no identifiers required (e.g., only a single instance can exist in Nexus Dasboard) """ + # TODO: revisit initial Model Configurations (low priority) model_config = ConfigDict( str_strip_whitespace=True, @@ -33,14 +34,14 @@ class NDBaseModel(BaseModel, ABC): validate_assignment=True, populate_by_name=True, arbitrary_types_allowed=True, - extra='allow', # NOTE: enabled extra: allows to add extra Field infos for generating Ansible argument_spec and Module Docs + extra="allow", # NOTE: enabled extra: allows to add extra Field infos for generating Ansible argument_spec and Module Docs ) # TODO: Revisit identifiers strategy (low priority) identifiers: ClassVar[Optional[List[str]]] = None # TODO: Revisit no identifiers strategy naming (`singleton` -> `unique`, `unnamed`) (low priority) identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" - + # Optional: fields to exclude from diffs (e.g., passwords) exclude_from_diff: ClassVar[List] = [] # TODO: To be removed in the future (see local_user model) @@ -52,7 +53,7 @@ def __init_subclass__(cls, **kwargs): Enforce configuration for identifiers definition. """ super().__init_subclass__(**kwargs) - + # Skip enforcement for nested models if cls.__name__ in ["NDNestedModel"] or any(base.__name__ == "NDNestedModel" for base in cls.__mro__): return @@ -73,7 +74,7 @@ def to_payload(self, **kwargs) -> Dict[str, Any]: Convert model to API payload format. """ return self.model_dump(by_alias=True, exclude_none=True, **kwargs) - + def to_config(self, **kwargs) -> Dict[str, Any]: """ Convert model to Ansible config format. @@ -83,11 +84,11 @@ def to_config(self, **kwargs) -> Dict[str, Any]: @classmethod def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: return cls.model_validate(response, by_alias=True, **kwargs) - + @classmethod def from_config(cls, ansible_config: Dict[str, Any], **kwargs) -> Self: return cls.model_validate(ansible_config, by_name=True, **kwargs) - + # TODO: Revisit this function when revisiting identifier strategy (low priority) def get_identifier_value(self, **kwargs) -> Union[str, int, Tuple[Any, ...]]: """ @@ -98,74 +99,61 @@ def get_identifier_value(self, **kwargs) -> Union[str, int, Tuple[Any, ...]]: """ if not self.identifiers and self.identifier_strategy != "singleton": raise ValueError(f"{self.__class__.__name__} must have identifiers defined with its current identifier strategy: `{self.identifier_strategy}`") - + if self.identifier_strategy == "single": value = getattr(self, self.identifiers[0], None) if value is None: - raise ValueError( - f"Single identifier field '{self.identifiers[0]}' is None" - ) + raise ValueError(f"Single identifier field '{self.identifiers[0]}' is None") return value - + elif self.identifier_strategy == "composite": values = [] missing = [] - + for field in self.identifiers: value = getattr(self, field, None) if value is None: missing.append(field) values.append(value) - + # NOTE: might be redefined with Pydantic (low priority) if missing: - raise ValueError( - f"Composite identifier fields {missing} are None. " - f"All required: {self.identifiers}" - ) - + raise ValueError(f"Composite identifier fields {missing} are None. " f"All required: {self.identifiers}") + return tuple(values) - + elif self.identifier_strategy == "hierarchical": for field in self.identifiers: value = getattr(self, field, None) if value is not None: return (field, value) - - raise ValueError( - f"No non-None value in hierarchical fields {self.identifiers}" - ) - + + raise ValueError(f"No non-None value in hierarchical fields {self.identifiers}") + # TODO: Revisit condition when there is no identifiers (low priority) elif self.identifier_strategy == "singleton": return None - + else: raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") - def to_diff_dict(self, **kwargs) -> Dict[str, Any]: """ Export for diff comparison (excludes sensitive fields). """ - return self.model_dump( - by_alias=True, - exclude_none=True, - exclude=set(self.exclude_from_diff), - **kwargs - ) - + return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff), **kwargs) + # NOTE: initialize and return a deep copy of the instance? # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? -> similar to NDCOnfigCollection... -> add argument to make it optional either replace def merge(self, other_model: "NDBaseModel", **kwargs) -> Self: if not isinstance(other_model, type(self)): # TODO: Change error message return TypeError("models are not of the same type.") - + for field, value in other_model: if value is None: continue - + current_value = getattr(self, field) if isinstance(current_value, NDBaseModel) and isinstance(value, NDBaseModel): setattr(self, field, current_value.merge(value)) diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 713d6040..e759a6fb 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -8,26 +8,25 @@ __metaclass__ = type -from pydantic import Field, SecretStr, model_serializer, field_serializer, field_validator, model_validator, computed_field from types import MappingProxyType from typing import List, Dict, Any, Optional, ClassVar, Literal from typing_extensions import Self - -# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel -from .base import NDBaseModel -from .nested import NDNestedModel -from ..constants import NDConstantMapping +from pydantic import Field, SecretStr, model_serializer, field_serializer, field_validator, model_validator, computed_field +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping # Constant defined here as it is only used in this model -USER_ROLES_MAPPING = NDConstantMapping({ - "fabric_admin": "fabric-admin", - "observer": "observer", - "super_admin": "super-admin", - "support_engineer": "support-engineer", - "approver": "approver", - "designer": "designer", -}).get_dict() +USER_ROLES_MAPPING = NDConstantMapping( + { + "fabric_admin": "fabric-admin", + "observer": "observer", + "super_admin": "super-admin", + "support_engineer": "support-engineer", + "approver": "approver", + "designer": "designer", + } +).get_dict() class LocalUserSecurityDomainModel(NDNestedModel): @@ -41,14 +40,7 @@ class LocalUserSecurityDomainModel(NDNestedModel): @model_serializer() def serialize_model(self) -> Dict: - return { - self.name: { - "roles": [ - USER_ROLES_MAPPING.get(role, role) - for role in (self.roles or []) - ] - } - } + return {self.name: {"roles": [USER_ROLES_MAPPING.get(role, role) for role in (self.roles or [])]}} # NOTE: Deserialization defined in `LocalUserModel` due to API response complexity @@ -60,7 +52,7 @@ class LocalUserModel(NDBaseModel): Identifier: login_id (single field) """ - + # Identifier configuration # TODO: Revisit this identifiers strategy (low priority) identifiers: ClassVar[Optional[List[str]]] = ["login_id"] @@ -69,11 +61,8 @@ class LocalUserModel(NDBaseModel): # Keys management configurations # TODO: Revisit these configurations (low priority) exclude_from_diff: ClassVar[List[str]] = ["user_password"] - unwanted_keys: ClassVar[List]= [ - ["passwordPolicy", "passwordChangeTime"], # Nested path - ["userID"] # Simple key - ] - + unwanted_keys: ClassVar[List] = [["passwordPolicy", "passwordChangeTime"], ["userID"]] # Nested path # Simple key + # Fields # NOTE: `alias` are NOT the ansible aliases. they are the equivalent attribute's names from the API spec # TODO: use extra for generating argument_spec (low priority) @@ -96,7 +85,7 @@ def password_policy(self) -> Optional[Dict[str, int]]: """Computed nested structure for API payload.""" if self.reuse_limitation is None and self.time_interval_limitation is None: return None - + policy = {} if self.reuse_limitation is not None: policy["reuseLimitation"] = self.reuse_limitation @@ -108,7 +97,6 @@ def password_policy(self) -> Optional[Dict[str, int]]: def serialize_password(self, value: Optional[SecretStr]) -> Optional[str]: return value.get_secret_value() if value else None - @field_serializer("security_domains") def serialize_domains(self, value: Optional[List[LocalUserSecurityDomainModel]]) -> Optional[Dict]: # NOTE: exclude `None` values and empty list (-> should we exclude empty list?) @@ -119,9 +107,7 @@ def serialize_domains(self, value: Optional[List[LocalUserSecurityDomainModel]]) for domain in value: domains_dict.update(domain.to_payload()) - return { - "domains": domains_dict - } + return {"domains": domains_dict} # -- Deserialization (API response / Ansible payload -> Model instance) -- @@ -132,17 +118,17 @@ def deserialize_password_policy(cls, data: Any) -> Any: return data password_policy = data.get("passwordPolicy") - + if password_policy and isinstance(password_policy, dict): if "reuseLimitation" in password_policy: data["reuse_limitation"] = password_policy["reuseLimitation"] if "timeIntervalLimitation" in password_policy: data["time_interval_limitation"] = password_policy["timeIntervalLimitation"] - + # Remove the nested structure from data to avoid conflicts # (since it's a computed field, not a real field) data.pop("passwordPolicy", None) - + return data @field_validator("security_domains", mode="before") @@ -150,24 +136,21 @@ def deserialize_password_policy(cls, data: Any) -> Any: def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: if value is None: return None - + # If already in list format (Ansible module representation), return as-is if isinstance(value, list): return value - + # If in the nested dict format (API representation) if isinstance(value, dict) and "domains" in value: domains_dict = value["domains"] domains_list = [] - + for domain_name, domain_data in domains_dict.items(): - domains_list.append({ - "name": domain_name, - "roles": [USER_ROLES_MAPPING.get(role, role) for role in domain_data.get("roles", [])] - }) - + domains_list.append({"name": domain_name, "roles": [USER_ROLES_MAPPING.get(role, role) for role in domain_data.get("roles", [])]}) + return domains_list - + return value # -- Extra -- diff --git a/plugins/module_utils/models/nested.py b/plugins/module_utils/models/nested.py index f2560819..0573e5f8 100644 --- a/plugins/module_utils/models/nested.py +++ b/plugins/module_utils/models/nested.py @@ -9,7 +9,7 @@ __metaclass__ = type from typing import List, ClassVar -from .base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel class NDNestedModel(NDBaseModel): diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 364519b8..1aa0e2ec 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -10,27 +10,26 @@ from typing import TypeVar, Generic, Optional, List, Dict, Any, Union, Tuple, Literal, Callable from copy import deepcopy +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey -# TODO: To be replaced with: from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from .models.base import NDBaseModel -from .utils import issubset -from .types import IdentifierKey # Type aliases -ModelType = TypeVar('ModelType', bound=NDBaseModel) +ModelType = TypeVar("ModelType", bound=NDBaseModel) class NDConfigCollection(Generic[ModelType]): """ Nexus Dashboard configuration collection for NDBaseModel instances. """ - + def __init__(self, model_class: ModelType, items: Optional[List[ModelType]] = None): """ Initialize collection. """ self._model_class: ModelType = model_class - + # Dual storage self._items: List[ModelType] = [] self._index: Dict[IdentifierKey, int] = {} @@ -38,7 +37,7 @@ def __init__(self, model_class: ModelType, items: Optional[List[ModelType]] = No if items: for item in items: self.add(item) - + # TODO: might not be necessary def _extract_key(self, item: ModelType) -> IdentifierKey: """ @@ -48,7 +47,7 @@ def _extract_key(self, item: ModelType) -> IdentifierKey: return item.get_identifier_value() except Exception as e: raise ValueError(f"Failed to extract identifier: {e}") from e - + # TODO: optimize it -> only needed for delete method (low priority) def _rebuild_index(self) -> None: """Rebuild index from scratch (O(n) operation).""" @@ -56,55 +55,47 @@ def _rebuild_index(self) -> None: for index, item in enumerate(self._items): key = self._extract_key(item) self._index[key] = index - + # Core Operations - + def add(self, item: ModelType) -> IdentifierKey: """ Add item to collection (O(1) operation). """ if not isinstance(item, self._model_class): - raise TypeError( - f"Item must be instance of {self._model_class.__name__}, " - f"got {type(item).__name__}" - ) - + raise TypeError(f"Item must be instance of {self._model_class.__name__}, " f"got {type(item).__name__}") + key = self._extract_key(item) - + if key in self._index: - raise ValueError( - f"Item with identifier {key} already exists. Use replace() to update" - ) - + raise ValueError(f"Item with identifier {key} already exists. Use replace() to update") + position = len(self._items) self._items.append(item) self._index[key] = position - + return key - + def get(self, key: IdentifierKey) -> Optional[ModelType]: """ Get item by identifier key (O(1) operation). """ index = self._index.get(key) return self._items[index] if index is not None else None - + def replace(self, item: ModelType) -> bool: """ Replace existing item with same identifier (O(1) operation). """ if not isinstance(item, self._model_class): - raise TypeError( - f"Item must be instance of {self._model_class.__name__}, " - f"got {type(item).__name__}" - ) - + raise TypeError(f"Item must be instance of {self._model_class.__name__}, " f"got {type(item).__name__}") + key = self._extract_key(item) index = self._index.get(key) - + if index is None: return False - + self._items[index] = item return True @@ -114,7 +105,7 @@ def merge(self, item: ModelType) -> ModelType: """ key = self._extract_key(item) existing = self.get(key) - + if existing is None: self.add(item) return item @@ -128,17 +119,17 @@ def delete(self, key: IdentifierKey) -> bool: Delete item by identifier (O(n) operation due to index rebuild) """ index = self._index.get(key) - + if index is None: return False - + del self._items[index] self._rebuild_index() - + return True - + # Diff Operations - + # NOTE: Maybe add a similar one in the NDBaseModel (-> but is it necessary?) def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "changed"]: """ @@ -148,9 +139,9 @@ def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "cha key = self._extract_key(new_item) except ValueError: return "new" - + existing = self.get(key) - + if existing is None: return "new" @@ -158,16 +149,16 @@ def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "cha existing_data = existing.to_diff_dict() new_data = new_item.to_diff_dict() is_subset = issubset(new_data, existing_data) - + return "no_diff" if is_subset else "changed" - + def get_diff_collection(self, other: "NDConfigCollection[ModelType]") -> bool: """ Check if two collections differ. """ if not isinstance(other, NDConfigCollection): raise TypeError("Argument must be NDConfigCollection") - + if len(self) != len(other): return True @@ -178,9 +169,9 @@ def get_diff_collection(self, other: "NDConfigCollection[ModelType]") -> bool: for key in self.keys(): if other.get(key) is None: return True - + return False - + def get_diff_identifiers(self, other: "NDConfigCollection[ModelType]") -> List[IdentifierKey]: """ Get identifiers in self but not in other. @@ -190,11 +181,11 @@ def get_diff_identifiers(self, other: "NDConfigCollection[ModelType]") -> List[I return list(current_keys - other_keys) # Collection Operations - + def __len__(self) -> int: """Return number of items.""" return len(self._items) - + def __iter__(self): """Iterate over items.""" return iter(self._items) @@ -205,10 +196,7 @@ def keys(self) -> List[IdentifierKey]: def copy(self) -> "NDConfigCollection[ModelType]": """Create deep copy of collection.""" - return NDConfigCollection( - model_class=self._model_class, - items=deepcopy(self._items) - ) + return NDConfigCollection(model_class=self._model_class, items=deepcopy(self._items)) # Collection Serialization @@ -217,13 +205,13 @@ def to_ansible_config(self, **kwargs) -> List[Dict]: Export as an Ansible config. """ return [item.to_config(**kwargs) for item in self._items] - + def to_payload_list(self, **kwargs) -> List[Dict[str, Any]]: """ Export as list of API payloads. """ return [item.to_payload(**kwargs) for item in self._items] - + @classmethod def from_ansible_config(cls, data: List[Dict], model_class: type[ModelType], **kwargs) -> "NDConfigCollection[ModelType]": """ @@ -231,7 +219,7 @@ def from_ansible_config(cls, data: List[Dict], model_class: type[ModelType], **k """ items = [model_class.from_config(item_data, **kwargs) for item_data in data] return cls(model_class=model_class, items=items) - + @classmethod def from_api_response(cls, response_data: List[Dict[str, Any]], model_class: type[ModelType], **kwargs) -> "NDConfigCollection[ModelType]": """ diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 5b1f770c..be5849d4 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -12,31 +12,25 @@ from typing import Optional, List, Dict, Any, Literal, Type from pydantic import ValidationError from ansible.module_utils.basic import AnsibleModule - -# TODO: To be replaced with: -# 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.orchestrators.base import NDBaseOrchestrator -# from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey -# from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED -from .nd import NDModule -from .nd_config_collection import NDConfigCollection -from .orchestrators.base import NDBaseOrchestrator -from .types import IdentifierKey -from .constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED +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.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey +from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED +# TODO: Revisit StateMachine when there is more arguments than config (e.g., "fabric" and "config" for switches config) +# TODO: class NDStateMachine(NDModule): """ Generic Network Resource Module for Nexus Dashboard. """ - + def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchestrator]): """ Initialize the Network Resource Module. """ - # TODO: Revisit Module initialization and configuration - # nd_module = NDModule() + # TODO: Revisit Module initialization and configuration with rest_send self.module = module self.nd_module = NDModule(module) @@ -51,18 +45,13 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest self.state = self.module.params["state"] self.ansible_config = self.module.params.get("config", []) - # Initialize collections - # TODO: Revisit collections initialization especially `init_all_data` (medium priority) # TODO: Revisit class variables `previous`, `existing`, etc... (medium priority) self.nd_config_collection = NDConfigCollection[self.model_class] try: init_all_data = self.model_orchestrator.query_all() - self.existing = self.nd_config_collection.from_api_response( - response_data=init_all_data, - model_class=self.model_class - ) + self.existing = self.nd_config_collection.from_api_response(response_data=init_all_data, model_class=self.model_class) # Save previous state self.previous = self.existing.copy() self.proposed = self.nd_config_collection(model_class=self.model_class) @@ -74,30 +63,23 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest item = self.model_class.from_config(config) self.proposed.add(item) except ValidationError as e: - self.fail_json( - msg=f"Invalid configuration: {e}", - config=config, - validation_errors=e.errors() - ) + self.fail_json(msg=f"Invalid configuration: {e}", config=config, validation_errors=e.errors()) return except Exception as e: - self.fail_json( - msg=f"Initialization failed: {str(e)}", - error=str(e) - ) + self.fail_json(msg=f"Initialization failed: {str(e)}", error=str(e)) # Logging # NOTE: format log placeholder # TODO: use a proper logger (low priority) def format_log( - self, - identifier: IdentifierKey, - operation_status: Literal["no_change", "created", "updated", "deleted"], - before: Optional[Dict[str, Any]] = None, - after: Optional[Dict[str, Any]] = None, - payload: Optional[Dict[str, Any]] = None, - ) -> None: + self, + identifier: IdentifierKey, + operation_status: Literal["no_change", "created", "updated", "deleted"], + before: Optional[Dict[str, Any]] = None, + after: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> None: """ Create and append a log entry. """ @@ -108,18 +90,15 @@ def format_log( "after": after, "payload": payload, } - + # Add HTTP details if not in check mode if not self.module.check_mode and self.nd_module.url is not None: - log_entry.update({ - "method": self.nd_module.method, - "response": self.nd_module.response, - "status": self.nd_module.status, - "url": self.nd_module.url - }) - + log_entry.update( + {"method": self.nd_module.method, "response": self.nd_module.response, "status": self.nd_module.status, "url": self.nd_module.url} + ) + self.nd_logs.append(log_entry) - + # State Management (core function) # TODO: adapt all `manage` functions to endpoint/orchestrator strategies (Top priority) def manage_state(self) -> None: @@ -129,17 +108,17 @@ def manage_state(self) -> None: # Execute state operations if self.state in ["merged", "replaced", "overridden"]: self._manage_create_update_state() - + if self.state == "overridden": self._manage_override_deletions() - + elif self.state == "deleted": self._manage_delete_state() - + # TODO: not needed with Ansible `argument_spec` validation. Keep it for now but needs to be removed (low priority) + # TODO: boil down an Exception instead of using `fail_json` method else: self.fail_json(msg=f"Invalid state: {self.state}") - def _manage_create_update_state(self) -> None: """ @@ -152,7 +131,7 @@ def _manage_create_update_state(self) -> None: try: # Determine diff status diff_status = self.existing.get_diff_config(proposed_item) - + # No changes needed if diff_status == "no_diff": self.format_log( @@ -162,7 +141,7 @@ def _manage_create_update_state(self) -> None: after=existing_config, ) continue - + # Prepare final config based on state if self.state == "merged": # Merge with existing @@ -187,7 +166,7 @@ def _manage_create_update_state(self) -> None: response = self.model_orchestrator.create(final_item) self.sent.add(final_item) operation_status = "created" - + # Log operation self.format_log( identifier=identifier, @@ -196,32 +175,27 @@ def _manage_create_update_state(self) -> None: after=self.model_class.model_validate(response).to_config() if not self.module.check_mode else final_item.to_config(), payload=final_item.to_payload(), ) - + except Exception as e: error_msg = f"Failed to process {identifier}: {e}" - + self.format_log( identifier=identifier, operation_status="no_change", before=existing_config, after=existing_config, ) - + if not self.module.params.get("ignore_errors", False): - self.fail_json( - msg=error_msg, - identifier=str(identifier), - error=str(e) - ) + self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) return - - # TODO: Refactor with orchestrator (Top priority) + def _manage_override_deletions(self) -> None: """ Delete items not in proposed config (for overridden state). """ diff_identifiers = self.previous.get_diff_identifiers(self.proposed) - + for identifier in diff_identifiers: try: existing_item = self.existing.get(identifier) @@ -231,37 +205,31 @@ def _manage_override_deletions(self) -> None: # Execute delete if not self.module.check_mode: response = self.model_orchestrator.delete(existing_item) - + # Remove from collection self.existing.delete(identifier) - + # Log deletion self.format_log( identifier=identifier, operation_status="deleted", before=existing_item.to_config(), after={}, - ) - + except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" - + if not self.module.params.get("ignore_errors", False): - self.fail_json( - msg=error_msg, - identifier=str(identifier), - error=str(e) - ) + self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) return - - # TODO: Refactor with orchestrator (Top priority) + def _manage_delete_state(self) -> None: """Handle deleted state.""" for proposed_item in self.proposed: try: identifier = proposed_item.get_identifier_value() - + existing_item = self.existing.get(identifier) if not existing_item: # Already deleted or doesn't exist @@ -272,14 +240,14 @@ def _manage_delete_state(self) -> None: after={}, ) continue - + # Execute delete if not self.module.check_mode: response = self.model_orchestrator.delete(existing_item) - + # Remove from collection self.existing.delete(identifier) - + # Log deletion self.format_log( identifier=identifier, @@ -287,18 +255,14 @@ def _manage_delete_state(self) -> None: before=existing_item.to_config(), after={}, ) - + except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" - + if not self.module.params.get("ignore_errors", False): - self.fail_json( - msg=error_msg, - identifier=str(identifier), - error=str(e) - ) + self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) return - + # Output Formatting # TODO: move to separate Class (results) -> align it with rest_send PR # TODO: return a defined ordered list of config (for integration test) @@ -306,36 +270,36 @@ def add_logs_and_outputs(self) -> None: """Add logs and outputs to module result based on output_level.""" output_level = self.module.params.get("output_level", "normal") state = self.module.params.get("state") - + # Add previous state for certain states and output levels if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: if output_level in ("debug", "info"): self.result["previous"] = self.previous.to_ansible_config() - + # Check if there were changes if self.previous.get_diff_collection(self.existing): self.result["changed"] = True - + # Add stdout if present if self.nd_module.stdout: self.result["stdout"] = self.nd_module.stdout - + # Add debug information if output_level == "debug": self.result["nd_logs"] = self.nd_logs - + if self.nd_module.url is not None: self.result["httpapi_logs"] = self.nd_module.httpapi_logs - + if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: self.result["sent"] = self.sent.to_payload_list() self.result["proposed"] = self.proposed.to_ansible_config() - + # Always include current state self.result["current"] = self.existing.to_ansible_config() - + # Module Exit Methods - + def fail_json(self, msg: str, **kwargs) -> None: """ Exit module with failure. @@ -343,26 +307,23 @@ def fail_json(self, msg: str, **kwargs) -> None: self.add_logs_and_outputs() self.result.update(**kwargs) self.module.fail_json(msg=msg, **self.result) - + def exit_json(self, **kwargs) -> None: """ Exit module successfully. """ self.add_logs_and_outputs() - + # Add diff if module supports it if self.module._diff and self.result.get("changed") is True: try: # Use diff-safe dicts (excludes sensitive fields) before = [item.to_diff_dict() for item in self.previous] after = [item.to_diff_dict() for item in self.existing] - - self.result["diff"] = dict( - before=before, - after=after - ) + + self.result["diff"] = dict(before=before, after=after) except Exception: pass # Don't fail on diff generation - + self.result.update(**kwargs) self.module.exit_json(**self.result) diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 924ea4b0..f9a63fa1 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -8,11 +8,11 @@ __metaclass__ = type -from ..models.base import NDBaseModel -from ..nd import NDModule -from ..api_endpoints.base import NDBaseSmartEndpoint -from typing import Dict, List, Any, Union, ClassVar, Type, Optional from pydantic import BaseModel, ConfigDict +from typing import Dict, List, Any, Union, ClassVar, Type, Optional +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] @@ -20,7 +20,6 @@ # TODO: Revisit naming them "Orchestrator" class NDBaseOrchestrator(BaseModel): - model_config = ConfigDict( use_enum_values=True, validate_assignment=True, diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index 46a4ea07..04f7707f 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -8,12 +8,12 @@ __metaclass__ = type -from .base import NDBaseOrchestrator -from ..models.base import NDBaseModel -from ..models.local_user import LocalUserModel from typing import Dict, List, Any, Union, Type -from ..api_endpoints.base import NDBaseSmartEndpoint -from ..api_endpoints.local_user import ( +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.local_user import ( EpApiV1InfraAaaLocalUsersPost, EpApiV1InfraAaaLocalUsersPut, EpApiV1InfraAaaLocalUsersDelete, @@ -23,8 +23,8 @@ ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] -class LocalUserOrchestrator(NDBaseOrchestrator): +class LocalUserOrchestrator(NDBaseOrchestrator): model_class: Type[NDBaseModel] = LocalUserModel create_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPost diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index a7c1d3dc..0bf7cfc8 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -37,22 +37,22 @@ def issubset(subset: Any, superset: Any) -> bool: """Check if subset is contained in 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(): if value is None: continue - + if key not in superset: return False - + if not issubset(value, superset[key]): return False - + return True @@ -60,12 +60,12 @@ def issubset(subset: Any, superset: Any) -> bool: def remove_unwanted_keys(data: Dict, unwanted_keys: List[Union[str, List[str]]]) -> Dict: """Remove unwanted keys from dict (supports nested paths).""" data = deepcopy(data) - + for key in unwanted_keys: if isinstance(key, str): if key in data: del data[key] - + elif isinstance(key, list) and len(key) > 0: try: parent = data @@ -79,5 +79,5 @@ def remove_unwanted_keys(data: Dict, unwanted_keys: List[Union[str, List[str]]]) del parent[key[-1]] except (KeyError, TypeError, IndexError): pass - + return data diff --git a/plugins/modules/nd_api_key.py b/plugins/modules/nd_api_key.py index c00428a9..1a3e4823 100644 --- a/plugins/modules/nd_api_key.py +++ b/plugins/modules/nd_api_key.py @@ -146,7 +146,6 @@ def main(): nd.existing = nd.previous = nd.query_objs(path, key="apiKeys") if state == "present": - if len(api_key_name) > 32 or len(api_key_name) < 1: nd.fail_json("A length of 1 to 32 characters is allowed.") elif re.search(r"[^a-zA-Z0-9_.-]", api_key_name): diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index b6acee72..a6972c07 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -175,15 +175,10 @@ """ from ansible.module_utils.basic import AnsibleModule -# TODO: To be replaced with: -# from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec -# from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine -# from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel -# from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.local_user import LocalUserOrchestrator -from ..module_utils.nd import nd_argument_spec -from ..module_utils.nd_state_machine import NDStateMachine -from ..module_utils.models.local_user import LocalUserModel -from ..module_utils.orchestrators.local_user import LocalUserOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.local_user import LocalUserOrchestrator def main(): @@ -196,17 +191,17 @@ def main(): ) try: - # Create NDNetworkResourceModule with LocalUserModel - nd_module = NDStateMachine( + # Initialize StateMachine + nd_state_machine = NDStateMachine( module=module, model_orchestrator=LocalUserOrchestrator, ) - + # Manage state - nd_module.manage_state() + nd_state_machine.manage_state() + + nd_state_machine.exit_json() - nd_module.exit_json() - except Exception as e: module.fail_json(msg=f"Module execution failed: {str(e)}") From 1d96db11cc1bd04dea83118b2053efd57fc2b04e Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 3 Mar 2026 12:02:02 -0500 Subject: [PATCH 24/51] [ignore] Clean code for sanity purposes (except Pydantic import checks. --- plugins/module_utils/api_endpoints/base.py | 2 +- plugins/module_utils/api_endpoints/enums.py | 5 +++++ plugins/module_utils/api_endpoints/local_user.py | 4 ++-- plugins/module_utils/api_endpoints/mixins.py | 2 +- plugins/module_utils/models/base.py | 5 +++-- plugins/module_utils/models/local_user.py | 2 -- plugins/module_utils/nd_config_collection.py | 3 +-- plugins/module_utils/nd_state_machine.py | 1 - plugins/module_utils/orchestrators/base.py | 6 ++---- plugins/module_utils/orchestrators/local_user.py | 8 +++----- plugins/module_utils/orchestrators/types.py | 13 +++++++++++++ plugins/module_utils/types.py | 1 - 12 files changed, 31 insertions(+), 21 deletions(-) create mode 100644 plugins/module_utils/orchestrators/types.py diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py index 954c1f6a..8428ffe8 100644 --- a/plugins/module_utils/api_endpoints/base.py +++ b/plugins/module_utils/api_endpoints/base.py @@ -11,7 +11,7 @@ from abc import ABC, abstractmethod from pydantic import BaseModel, ConfigDict -from typing import Final, Union, Tuple, Any +from typing import Final from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.types import IdentifierKey diff --git a/plugins/module_utils/api_endpoints/enums.py b/plugins/module_utils/api_endpoints/enums.py index ced62ba7..18a7f5eb 100644 --- a/plugins/module_utils/api_endpoints/enums.py +++ b/plugins/module_utils/api_endpoints/enums.py @@ -7,6 +7,11 @@ """ Enums used in api_endpoints. """ + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + from enum import Enum diff --git a/plugins/module_utils/api_endpoints/local_user.py b/plugins/module_utils/api_endpoints/local_user.py index 72639495..890b38e7 100644 --- a/plugins/module_utils/api_endpoints/local_user.py +++ b/plugins/module_utils/api_endpoints/local_user.py @@ -13,9 +13,9 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type # pylint: disable=invalid-name +__metaclass__ = type -from typing import Literal, Union, Tuple, Any, Final +from typing import Literal, Final from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.mixins import LoginIdMixin from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.enums import VerbEnum from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint, NDBasePath diff --git a/plugins/module_utils/api_endpoints/mixins.py b/plugins/module_utils/api_endpoints/mixins.py index 9516c9ce..56cdcfc5 100644 --- a/plugins/module_utils/api_endpoints/mixins.py +++ b/plugins/module_utils/api_endpoints/mixins.py @@ -15,7 +15,7 @@ __metaclass__ = type # pylint: disable=invalid-name -from typing import TYPE_CHECKING, Optional +from typing import Optional from pydantic import BaseModel, Field diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 94fb9cc5..8cdcc765 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -8,7 +8,7 @@ __metaclass__ = type -from abc import ABC, abstractmethod +from abc import ABC from pydantic import BaseModel, ConfigDict from typing import List, Dict, Any, ClassVar, Tuple, Union, Literal, Optional from typing_extensions import Self @@ -144,7 +144,8 @@ def to_diff_dict(self, **kwargs) -> Dict[str, Any]: return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff), **kwargs) # NOTE: initialize and return a deep copy of the instance? - # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? -> similar to NDCOnfigCollection... -> add argument to make it optional either replace + # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? + # -> similar to NDCOnfigCollection... -> add argument to make it optional either replace def merge(self, other_model: "NDBaseModel", **kwargs) -> Self: if not isinstance(other_model, type(self)): # TODO: Change error message diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index e759a6fb..fe2f2bb5 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -8,9 +8,7 @@ __metaclass__ = type -from types import MappingProxyType from typing import List, Dict, Any, Optional, ClassVar, Literal -from typing_extensions import Self from pydantic import Field, SecretStr, model_serializer, field_serializer, field_validator, model_validator, computed_field from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 1aa0e2ec..5fd9886d 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -8,13 +8,12 @@ __metaclass__ = type -from typing import TypeVar, Generic, Optional, List, Dict, Any, Union, Tuple, Literal, Callable +from typing import TypeVar, Generic, Optional, List, Dict, Any, Literal from copy import deepcopy from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey - # Type aliases ModelType = TypeVar("ModelType", bound=NDBaseModel) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index be5849d4..923f0b69 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -8,7 +8,6 @@ __metaclass__ = type -from copy import deepcopy from typing import Optional, List, Dict, Any, Literal, Type from pydantic import ValidationError from ansible.module_utils.basic import AnsibleModule diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index f9a63fa1..4df0797d 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -9,13 +9,11 @@ __metaclass__ = type from pydantic import BaseModel, ConfigDict -from typing import Dict, List, Any, Union, ClassVar, Type, Optional +from typing import ClassVar, Type, Optional from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint - - -ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.types import ResponseType # TODO: Revisit naming them "Orchestrator" diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index 04f7707f..d30b29f8 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -8,11 +8,12 @@ __metaclass__ = type -from typing import Dict, List, Any, Union, Type +from typing import Type from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.types import ResponseType from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.local_user import ( EpApiV1InfraAaaLocalUsersPost, EpApiV1InfraAaaLocalUsersPut, @@ -21,9 +22,6 @@ ) -ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] - - class LocalUserOrchestrator(NDBaseOrchestrator): model_class: Type[NDBaseModel] = LocalUserModel @@ -33,7 +31,7 @@ class LocalUserOrchestrator(NDBaseOrchestrator): query_one_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet query_all_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet - def query_all(self): + def query_all(self) -> ResponseType: """ Custom query_all action to extract 'localusers' from response. """ diff --git a/plugins/module_utils/orchestrators/types.py b/plugins/module_utils/orchestrators/types.py new file mode 100644 index 00000000..b721c65b --- /dev/null +++ b/plugins/module_utils/orchestrators/types.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, 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 typing import Any, Union, List, Dict + +ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] diff --git a/plugins/module_utils/types.py b/plugins/module_utils/types.py index 124aedd5..3111a095 100644 --- a/plugins/module_utils/types.py +++ b/plugins/module_utils/types.py @@ -10,5 +10,4 @@ from typing import Any, Union, Tuple - IdentifierKey = Union[str, int, Tuple[Any, ...]] From 5d1f52f4ead919d3afe3968c587414c5c2639622 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 3 Mar 2026 12:18:35 -0500 Subject: [PATCH 25/51] [ignore] Restructure api_endpoints folder into endpoints -> v1. Fix some sanity issues. --- plugins/module_utils/api_endpoints/base.py | 180 ------------------ plugins/module_utils/api_endpoints/mixins.py | 25 --- plugins/module_utils/endpoints/base.py | 7 + .../{api_endpoints => endpoints}/enums.py | 2 +- plugins/module_utils/endpoints/mixins.py | 3 +- .../v1/infra_aaa_local_users.py} | 32 ++-- plugins/module_utils/nd_state_machine.py | 1 + plugins/module_utils/orchestrators/base.py | 4 +- .../module_utils/orchestrators/local_user.py | 24 +-- 9 files changed, 41 insertions(+), 237 deletions(-) delete mode 100644 plugins/module_utils/api_endpoints/base.py delete mode 100644 plugins/module_utils/api_endpoints/mixins.py rename plugins/module_utils/{api_endpoints => endpoints}/enums.py (97%) rename plugins/module_utils/{api_endpoints/local_user.py => endpoints/v1/infra_aaa_local_users.py} (74%) diff --git a/plugins/module_utils/api_endpoints/base.py b/plugins/module_utils/api_endpoints/base.py deleted file mode 100644 index 8428ffe8..00000000 --- a/plugins/module_utils/api_endpoints/base.py +++ /dev/null @@ -1,180 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@allenrobel) -# Copyright: (c) 2026, 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 abc import ABC, abstractmethod -from pydantic import BaseModel, ConfigDict -from typing import Final -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.types import IdentifierKey - - -# TODO: Rename it to APIEndpoint -# NOTE: This is a very minimalist endpoint package -> needs to be enhanced -class NDBaseSmartEndpoint(BaseModel, ABC): - # TODO: maybe to be modified in the future - model_config = ConfigDict(validate_assignment=True) - - # TODO: to remove - base_path: str - - @property - @abstractmethod - def path(self) -> str: - pass - - @property - @abstractmethod - def verb(self) -> str: - pass - - # TODO: Maybe to be modifed to be more Pydantic (low priority) - # TODO: Maybe change function's name (low priority) - # NOTE: function to set endpoints attribute fields from identifiers -> acts as the bridge between Models and Endpoints for API Request Orchestration - @abstractmethod - def set_identifiers(self, identifier: IdentifierKey = None): - pass - - -class NDBasePath: - """ - # Summary - - Centralized API Base Paths - - ## Description - - Provides centralized base path definitions for all ND API endpoints. - This allows API path changes to be managed in a single location. - - ## Usage - - ```python - # Get a complete base path - path = BasePath.control_fabrics("MyFabric", "config-deploy") - # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest/control/fabrics/MyFabric/config-deploy - - # Build custom paths - path = BasePath.v1("custom", "endpoint") - # Returns: /appcenter/cisco/ndfc/api/v1/custom/endpoint - ``` - - ## Design Notes - - - All base paths are defined as class constants for easy modification - - Helper methods compose paths from base constants - - Use these methods in Pydantic endpoint models to ensure consistency - - If NDFC changes base API paths, only this class needs updating - """ - - # Root API paths - NDFC_API: Final = "/appcenter/cisco/ndfc/api" - ND_INFRA_API: Final = "/api/v1/infra" - ONEMANAGE: Final = "/onemanage" - LOGIN: Final = "/login" - - @classmethod - def api(cls, *segments: str) -> str: - """ - # Summary - - Build path from NDFC API root. - - ## Parameters - - - segments: Path segments to append - - ## Returns - - - Complete path string - - ## Example - - ```python - path = BasePath.api("custom", "endpoint") - # Returns: /appcenter/cisco/ndfc/api/custom/endpoint - ``` - """ - if not segments: - return cls.NDFC_API - return f"{cls.NDFC_API}/{'/'.join(segments)}" - - @classmethod - def v1(cls, *segments: str) -> str: - """ - # Summary - - Build v1 API path. - - ## Parameters - - - segments: Path segments to append after v1 - - ## Returns - - - Complete v1 API path - - ## Example - - ```python - path = BasePath.v1("lan-fabric", "rest") - # Returns: /appcenter/cisco/ndfc/api/v1/lan-fabric/rest - ``` - """ - return cls.api("v1", *segments) - - @classmethod - def nd_infra(cls, *segments: str) -> str: - """ - # Summary - - Build ND infra API path. - - ## Parameters - - - segments: Path segments to append after /api/v1/infra - - ## Returns - - - Complete ND infra API path - - ## Example - - ```python - path = BasePath.nd_infra("aaa", "localUsers") - # Returns: /api/v1/infra/aaa/localUsers - ``` - """ - if not segments: - return cls.ND_INFRA_API - return f"{cls.ND_INFRA_API}/{'/'.join(segments)}" - - @classmethod - def nd_infra_aaa(cls, *segments: str) -> str: - """ - # Summary - - Build ND infra AAA API path. - - ## Parameters - - - segments: Path segments to append after aaa (e.g., "localUsers") - - ## Returns - - - Complete ND infra AAA path - - ## Example - - ```python - path = BasePath.nd_infra_aaa("localUsers") - # Returns: /api/v1/infra/aaa/localUsers - ``` - """ - return cls.nd_infra("aaa", *segments) diff --git a/plugins/module_utils/api_endpoints/mixins.py b/plugins/module_utils/api_endpoints/mixins.py deleted file mode 100644 index 56cdcfc5..00000000 --- a/plugins/module_utils/api_endpoints/mixins.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@allenrobel) -# Copyright: (c) 2026, Gaspard Micol (@gmicol) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -Reusable mixin classes for endpoint models. - -This module provides mixin classes that can be composed to add common -fields to endpoint models without duplication. -""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type # pylint: disable=invalid-name - -from typing import Optional -from pydantic import BaseModel, Field - - -class LoginIdMixin(BaseModel): - """Mixin for endpoints that require login_id parameter.""" - - login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") diff --git a/plugins/module_utils/endpoints/base.py b/plugins/module_utils/endpoints/base.py index 3ccdff1c..bfd59ee1 100644 --- a/plugins/module_utils/endpoints/base.py +++ b/plugins/module_utils/endpoints/base.py @@ -1,4 +1,5 @@ # Copyright: (c) 2026, Allen Robel (@arobel) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ @@ -22,6 +23,7 @@ Field, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.types import IdentifierKey class NDEndpointBaseModel(BaseModel, ABC): @@ -129,3 +131,8 @@ def verb(self) -> HttpVerbEnum: None """ + + # NOTE: function to set endpoints attribute fields from identifiers -> acts as the bridge between Models and Endpoints for API Request Orchestration + @abstractmethod + def set_identifiers(self, identifier: IdentifierKey = None): + pass diff --git a/plugins/module_utils/api_endpoints/enums.py b/plugins/module_utils/endpoints/enums.py similarity index 97% rename from plugins/module_utils/api_endpoints/enums.py rename to plugins/module_utils/endpoints/enums.py index 18a7f5eb..802b8fe8 100644 --- a/plugins/module_utils/api_endpoints/enums.py +++ b/plugins/module_utils/endpoints/enums.py @@ -5,7 +5,7 @@ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ -Enums used in api_endpoints. +Enums used in endpoints. """ from __future__ import absolute_import, division, print_function diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index 47695611..22d9a2dc 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -1,4 +1,5 @@ -# Copyright: (c) 2026, Allen Robel (@arobel) +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ diff --git a/plugins/module_utils/api_endpoints/local_user.py b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py similarity index 74% rename from plugins/module_utils/api_endpoints/local_user.py rename to plugins/module_utils/endpoints/v1/infra_aaa_local_users.py index 890b38e7..1e1d7823 100644 --- a/plugins/module_utils/api_endpoints/local_user.py +++ b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py @@ -16,14 +16,14 @@ __metaclass__ = type from typing import Literal, Final -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.mixins import LoginIdMixin -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.enums import VerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint, NDBasePath +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import LoginIdMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.enums import VerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseSmartEndpoint, NDBasePath from pydantic import Field from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey -class _EpApiV1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseSmartEndpoint): +class _V1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseSmartEndpoint): """ Base class for ND Infra AAA Local Users endpoints. @@ -53,7 +53,7 @@ def set_identifiers(self, identifier: IdentifierKey = None): self.login_id = identifier -class EpApiV1InfraAaaLocalUsersGet(_EpApiV1InfraAaaLocalUsersBase): +class V1InfraAaaLocalUsersGet(_V1InfraAaaLocalUsersBase): """ # Summary @@ -74,8 +74,8 @@ class EpApiV1InfraAaaLocalUsersGet(_EpApiV1InfraAaaLocalUsersBase): - GET """ - class_name: Literal["EpApiV1InfraAaaLocalUsersGet"] = Field( - default="EpApiV1InfraAaaLocalUsersGet", + class_name: Literal["V1InfraAaaLocalUsersGet"] = Field( + default="V1InfraAaaLocalUsersGet", description="Class name for backward compatibility", frozen=True, ) @@ -86,7 +86,7 @@ def verb(self) -> VerbEnum: return VerbEnum.GET -class EpApiV1InfraAaaLocalUsersPost(_EpApiV1InfraAaaLocalUsersBase): +class V1InfraAaaLocalUsersPost(_V1InfraAaaLocalUsersBase): """ # Summary @@ -105,8 +105,8 @@ class EpApiV1InfraAaaLocalUsersPost(_EpApiV1InfraAaaLocalUsersBase): - POST """ - class_name: Literal["EpApiV1InfraAaaLocalUsersPost"] = Field( - default="EpApiV1InfraAaaLocalUsersPost", + class_name: Literal["V1InfraAaaLocalUsersPost"] = Field( + default="V1InfraAaaLocalUsersPost", description="Class name for backward compatibility", frozen=True, ) @@ -117,7 +117,7 @@ def verb(self) -> VerbEnum: return VerbEnum.POST -class EpApiV1InfraAaaLocalUsersPut(_EpApiV1InfraAaaLocalUsersBase): +class V1InfraAaaLocalUsersPut(_V1InfraAaaLocalUsersBase): """ # Summary @@ -136,8 +136,8 @@ class EpApiV1InfraAaaLocalUsersPut(_EpApiV1InfraAaaLocalUsersBase): - PUT """ - class_name: Literal["EpApiV1InfraAaaLocalUsersPut"] = Field( - default="EpApiV1InfraAaaLocalUsersPut", + class_name: Literal["V1InfraAaaLocalUsersPut"] = Field( + default="V1InfraAaaLocalUsersPut", description="Class name for backward compatibility", frozen=True, ) @@ -148,7 +148,7 @@ def verb(self) -> VerbEnum: return VerbEnum.PUT -class EpApiV1InfraAaaLocalUsersDelete(_EpApiV1InfraAaaLocalUsersBase): +class V1InfraAaaLocalUsersDelete(_V1InfraAaaLocalUsersBase): """ # Summary @@ -167,8 +167,8 @@ class EpApiV1InfraAaaLocalUsersDelete(_EpApiV1InfraAaaLocalUsersBase): - DELETE """ - class_name: Literal["EpApiV1InfraAaaLocalUsersDelete"] = Field( - default="EpApiV1InfraAaaLocalUsersDelete", + class_name: Literal["V1InfraAaaLocalUsersDelete"] = Field( + default="V1InfraAaaLocalUsersDelete", description="Class name for backward compatibility", frozen=True, ) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 923f0b69..ae0a67ce 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -125,6 +125,7 @@ def _manage_create_update_state(self) -> None: """ for proposed_item in self.proposed: # Extract identifier + response = {} identifier = proposed_item.get_identifier_value() existing_config = self.existing.get(identifier).to_config() if self.existing.get(identifier) else {} try: diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 4df0797d..8c84de8e 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -12,8 +12,8 @@ from typing import ClassVar, Type, Optional from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseSmartEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.types import ResponseType # TODO: Revisit naming them "Orchestrator" diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index d30b29f8..bea4a486 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -12,24 +12,24 @@ from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.base import NDBaseSmartEndpoint -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.types import ResponseType -from ansible_collections.cisco.nd.plugins.module_utils.api_endpoints.local_user import ( - EpApiV1InfraAaaLocalUsersPost, - EpApiV1InfraAaaLocalUsersPut, - EpApiV1InfraAaaLocalUsersDelete, - EpApiV1InfraAaaLocalUsersGet, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseSmartEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra_aaa_local_users import ( + V1InfraAaaLocalUsersPost, + V1InfraAaaLocalUsersPut, + V1InfraAaaLocalUsersDelete, + V1InfraAaaLocalUsersGet, ) class LocalUserOrchestrator(NDBaseOrchestrator): model_class: Type[NDBaseModel] = LocalUserModel - create_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPost - update_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersPut - delete_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersDelete - query_one_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet - query_all_endpoint: Type[NDBaseSmartEndpoint] = EpApiV1InfraAaaLocalUsersGet + create_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersPost + update_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersPut + delete_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersDelete + query_one_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersGet + query_all_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersGet def query_all(self) -> ResponseType: """ From 039103ee8d322b85dc7b2e74449b902fb9673d65 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 3 Mar 2026 15:06:04 -0500 Subject: [PATCH 26/51] [ignore] Remove NDModule inheritence from NDStateMachine. Add first iteration of (Mock Pydantic objects/methods) to pass sanity checks for Pydantic importation. --- plugins/module_utils/nd_state_machine.py | 6 +- plugins/module_utils/pydantic_compat.py | 200 +++++++++++++++++++++++ 2 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 plugins/module_utils/pydantic_compat.py diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index ae0a67ce..e68010fb 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -19,8 +19,8 @@ # TODO: Revisit StateMachine when there is more arguments than config (e.g., "fabric" and "config" for switches config) -# TODO: -class NDStateMachine(NDModule): +# TODO: Remove inheritence from NDModule (Top Priority) +class NDStateMachine: """ Generic Network Resource Module for Nexus Dashboard. """ @@ -31,7 +31,7 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest """ # TODO: Revisit Module initialization and configuration with rest_send self.module = module - self.nd_module = NDModule(module) + self.nd_module = NDModule(self.module) # Operation tracking self.nd_logs: List[Dict[str, Any]] = [] diff --git a/plugins/module_utils/pydantic_compat.py b/plugins/module_utils/pydantic_compat.py new file mode 100644 index 00000000..f1d90fe3 --- /dev/null +++ b/plugins/module_utils/pydantic_compat.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=too-few-public-methods +""" +Pydantic compatibility layer. + +This module provides a single location for Pydantic imports with fallback +implementations when Pydantic is not available. This ensures consistent +behavior across all modules and follows the DRY principle. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import traceback +from typing import TYPE_CHECKING, Any, Callable, Union + +if TYPE_CHECKING: + # Type checkers always see the real Pydantic types + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + model_serializer, + field_validator, + model_validator, + validator, + ) +else: + # Runtime: try to import, with fallback + try: + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + model_serializer, + field_validator, + model_validator, + validator, + ) + except ImportError: + HAS_PYDANTIC = False # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name + + # Fallback: Minimal BaseModel replacement + class BaseModel: + """Fallback BaseModel when pydantic is not available.""" + + model_config = {"validate_assignment": False, "use_enum_values": False} + + def __init__(self, **kwargs): + """Accept keyword arguments and set them as attributes.""" + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self, exclude_none: bool = False, exclude_defaults: bool = False) -> dict: # pylint: disable=unused-argument + """Return a dictionary of field names and values. + + Args: + exclude_none: If True, exclude fields with None values + exclude_defaults: Accepted for API compatibility but not implemented in fallback + """ + result = {} + for key, value in self.__dict__.items(): + if exclude_none and value is None: + continue + result[key] = value + return result + + # Fallback: ConfigDict that does nothing + def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-name + """Pydantic ConfigDict fallback when pydantic is not available.""" + return kwargs + + # Fallback: Field that does nothing + def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name + """Pydantic Field fallback when pydantic is not available.""" + if "default_factory" in kwargs: + return kwargs["default_factory"]() + return kwargs.get("default") + + # Fallback: field_serializer decorator that does nothing + def field_serializer(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic field_serializer fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: model_serializer decorator that does nothing + def model_serializer(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic model_serializer fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: field_validator decorator that does nothing + def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name + """Pydantic field_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: AfterValidator that returns the function unchanged + def AfterValidator(func): # pylint: disable=invalid-name + """Pydantic AfterValidator fallback when pydantic is not available.""" + return func + + # Fallback: BeforeValidator that returns the function unchanged + def BeforeValidator(func): # pylint: disable=invalid-name + """Pydantic BeforeValidator fallback when pydantic is not available.""" + return func + + # Fallback: PydanticExperimentalWarning + PydanticExperimentalWarning = Warning + + # Fallback: StrictBool + StrictBool = bool + + # Fallback: ValidationError + class ValidationError(Exception): + """ + Pydantic ValidationError fallback when pydantic is not available. + """ + + def __init__(self, message="A custom error occurred."): + self.message = message + super().__init__(self.message) + + def __str__(self): + return f"ValidationError: {self.message}" + + # Fallback: model_validator decorator that does nothing + def model_validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic model_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: validator decorator that does nothing + def validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + else: + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name + +# Set HAS_PYDANTIC for when TYPE_CHECKING is True +if TYPE_CHECKING: + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name + +__all__ = [ + "AfterValidator", + "BaseModel", + "BeforeValidator", + "ConfigDict", + "Field", + "HAS_PYDANTIC", + "PYDANTIC_IMPORT_ERROR", + "PydanticExperimentalWarning", + "StrictBool", + "ValidationError", + "field_serializer", + "model_serializer", + "field_validator", + "model_validator", + "validator", +] From c1774d1801c99b1138c784524075fece6a94a67f Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 3 Mar 2026 17:19:47 -0500 Subject: [PATCH 27/51] [ignore] Rename NDBaseSmartEndpoint to NDBaseEndpoint. Fix importation issues. --- plugins/module_utils/endpoints/base.py | 4 +++- .../endpoints/v1/infra_aaa_local_users.py | 4 ++-- plugins/module_utils/orchestrators/base.py | 14 +++++++------- plugins/module_utils/orchestrators/local_user.py | 14 +++++++------- plugins/modules/nd_local_user.py | 5 ++++- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/plugins/module_utils/endpoints/base.py b/plugins/module_utils/endpoints/base.py index bfd59ee1..2d214878 100644 --- a/plugins/module_utils/endpoints/base.py +++ b/plugins/module_utils/endpoints/base.py @@ -23,7 +23,7 @@ Field, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.types import IdentifierKey +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey class NDEndpointBaseModel(BaseModel, ABC): @@ -132,6 +132,8 @@ def verb(self) -> HttpVerbEnum: None """ + # TODO: Maybe to be modifed to be more Pydantic (low priority) + # TODO: Maybe change function's name (low priority) # NOTE: function to set endpoints attribute fields from identifiers -> acts as the bridge between Models and Endpoints for API Request Orchestration @abstractmethod def set_identifiers(self, identifier: IdentifierKey = None): diff --git a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py index 1e1d7823..0008b188 100644 --- a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py +++ b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py @@ -18,12 +18,12 @@ from typing import Literal, Final from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import LoginIdMixin from ansible_collections.cisco.nd.plugins.module_utils.endpoints.enums import VerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseSmartEndpoint, NDBasePath +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseEndpoint, NDBasePath from pydantic import Field from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey -class _V1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseSmartEndpoint): +class _V1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseEndpoint): """ Base class for ND Infra AAA Local Users endpoints. diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 8c84de8e..b0e34b61 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -12,8 +12,8 @@ from typing import ClassVar, Type, Optional from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseSmartEndpoint -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType # TODO: Revisit naming them "Orchestrator" @@ -28,11 +28,11 @@ class NDBaseOrchestrator(BaseModel): model_class: ClassVar[Type[NDBaseModel]] = Type[NDBaseModel] # NOTE: if not defined by subclasses, return an error as they are required - create_endpoint: Type[NDBaseSmartEndpoint] - update_endpoint: Type[NDBaseSmartEndpoint] - delete_endpoint: Type[NDBaseSmartEndpoint] - query_one_endpoint: Type[NDBaseSmartEndpoint] - query_all_endpoint: Type[NDBaseSmartEndpoint] + create_endpoint: Type[NDBaseEndpoint] + update_endpoint: Type[NDBaseEndpoint] + delete_endpoint: Type[NDBaseEndpoint] + query_one_endpoint: Type[NDBaseEndpoint] + query_all_endpoint: Type[NDBaseEndpoint] # NOTE: Module Field is always required # TODO: Replace it with future sender (low priority) diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index bea4a486..5e52a00b 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -12,8 +12,8 @@ from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseSmartEndpoint -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra_aaa_local_users import ( V1InfraAaaLocalUsersPost, V1InfraAaaLocalUsersPut, @@ -25,11 +25,11 @@ class LocalUserOrchestrator(NDBaseOrchestrator): model_class: Type[NDBaseModel] = LocalUserModel - create_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersPost - update_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersPut - delete_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersDelete - query_one_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersGet - query_all_endpoint: Type[NDBaseSmartEndpoint] = V1InfraAaaLocalUsersGet + create_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersPost + update_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersPut + delete_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersDelete + query_one_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersGet + query_all_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersGet def query_all(self) -> ResponseType: """ diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index a6972c07..6f296065 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -198,8 +198,11 @@ def main(): ) # Manage state + # TODO: return module output class object: + # output = nd_state_machine.manage_state() + # module.exit_json(**output) nd_state_machine.manage_state() - + nd_state_machine.exit_json() except Exception as e: From 872b5f41ee5d18ce912563909509ac818e01e090 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 4 Mar 2026 11:12:27 -0500 Subject: [PATCH 28/51] [ignore] Replace all pydantic imports with pydantic_compat. Fix sanity issues. --- plugins/module_utils/constants.py | 4 ++++ .../endpoints/v1/infra_aaa_local_users.py | 2 +- plugins/module_utils/models/base.py | 9 ++++----- plugins/module_utils/models/local_user.py | 19 ++++++++++++------ plugins/module_utils/nd_state_machine.py | 2 +- plugins/module_utils/orchestrators/base.py | 2 +- plugins/module_utils/pydantic_compat.py | 20 ++++++++++++++++++- plugins/modules/nd_local_user.py | 3 ++- 8 files changed, 45 insertions(+), 16 deletions(-) diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index afa0a2b0..563041a0 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -16,6 +16,7 @@ class NDConstantMapping(Dict): def __init__(self, data: Dict): + self.data = data self.new_dict = deepcopy(data) for k, v in data.items(): self.new_dict[v] = k @@ -24,6 +25,9 @@ def __init__(self, data: Dict): def get_dict(self): return self.new_dict + def get_original_data(self): + return list(self.data.keys()) + OBJECT_TYPES = { "tenant": "OST_TENANT", diff --git a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py index 0008b188..d1013e24 100644 --- a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py +++ b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py @@ -19,7 +19,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import LoginIdMixin from ansible_collections.cisco.nd.plugins.module_utils.endpoints.enums import VerbEnum from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseEndpoint, NDBasePath -from pydantic import Field +from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import Field from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 8cdcc765..67ce5de0 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -9,9 +9,8 @@ __metaclass__ = type from abc import ABC -from pydantic import BaseModel, ConfigDict +from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict from typing import List, Dict, Any, ClassVar, Tuple, Union, Literal, Optional -from typing_extensions import Self # TODO: Revisit identifiers strategy (low priority) @@ -82,11 +81,11 @@ def to_config(self, **kwargs) -> Dict[str, Any]: return self.model_dump(by_alias=False, exclude_none=True, **kwargs) @classmethod - def from_response(cls, response: Dict[str, Any], **kwargs) -> Self: + def from_response(cls, response: Dict[str, Any], **kwargs) -> "NDBaseModel": return cls.model_validate(response, by_alias=True, **kwargs) @classmethod - def from_config(cls, ansible_config: Dict[str, Any], **kwargs) -> Self: + def from_config(cls, ansible_config: Dict[str, Any], **kwargs) -> "NDBaseModel": return cls.model_validate(ansible_config, by_name=True, **kwargs) # TODO: Revisit this function when revisiting identifier strategy (low priority) @@ -146,7 +145,7 @@ def to_diff_dict(self, **kwargs) -> Dict[str, Any]: # NOTE: initialize and return a deep copy of the instance? # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? # -> similar to NDCOnfigCollection... -> add argument to make it optional either replace - def merge(self, other_model: "NDBaseModel", **kwargs) -> Self: + def merge(self, other_model: "NDBaseModel", **kwargs) -> "NDBaseModel": if not isinstance(other_model, type(self)): # TODO: Change error message return TypeError("models are not of the same type.") diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index fe2f2bb5..0575c1be 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -9,7 +9,15 @@ __metaclass__ = type from typing import List, Dict, Any, Optional, ClassVar, Literal -from pydantic import Field, SecretStr, model_serializer, field_serializer, field_validator, model_validator, computed_field +from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import ( + Field, + SecretStr, + model_serializer, + field_serializer, + field_validator, + model_validator, + computed_field, +) from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping @@ -24,7 +32,7 @@ "approver": "approver", "designer": "designer", } -).get_dict() +) class LocalUserSecurityDomainModel(NDNestedModel): @@ -38,7 +46,7 @@ class LocalUserSecurityDomainModel(NDNestedModel): @model_serializer() def serialize_model(self) -> Dict: - return {self.name: {"roles": [USER_ROLES_MAPPING.get(role, role) for role in (self.roles or [])]}} + return {self.name: {"roles": [USER_ROLES_MAPPING.get_dict().get(role, role) for role in (self.roles or [])]}} # NOTE: Deserialization defined in `LocalUserModel` due to API response complexity @@ -145,7 +153,7 @@ def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: domains_list = [] for domain_name, domain_data in domains_dict.items(): - domains_list.append({"name": domain_name, "roles": [USER_ROLES_MAPPING.get(role, role) for role in domain_data.get("roles", [])]}) + domains_list.append({"name": domain_name, "roles": [USER_ROLES_MAPPING.get_dict().get(role, role) for role in domain_data.get("roles", [])]}) return domains_list @@ -174,7 +182,7 @@ def get_argument_spec(cls) -> Dict: 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)), + roles=dict(type="list", elements="str", choices=USER_ROLES_MAPPING.get_original_data()), ), aliases=["domains"], ), @@ -182,6 +190,5 @@ def get_argument_spec(cls) -> Dict: remote_user_authorization=dict(type="bool"), ), ), - override_exceptions=dict(type="list", elements="str"), state=dict(type="str", default="merged", choices=["merged", "replaced", "overridden", "deleted"]), ) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index e68010fb..81d6a966 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -9,7 +9,7 @@ __metaclass__ = type from typing import Optional, List, Dict, Any, Literal, Type -from pydantic import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import ValidationError from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index b0e34b61..1a3b1921 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -8,7 +8,7 @@ __metaclass__ = type -from pydantic import BaseModel, ConfigDict +from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict from typing import ClassVar, Type, Optional from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule diff --git a/plugins/module_utils/pydantic_compat.py b/plugins/module_utils/pydantic_compat.py index f1d90fe3..e8924cd2 100644 --- a/plugins/module_utils/pydantic_compat.py +++ b/plugins/module_utils/pydantic_compat.py @@ -32,12 +32,14 @@ Field, PydanticExperimentalWarning, StrictBool, + SecretStr, ValidationError, field_serializer, model_serializer, field_validator, model_validator, validator, + computed_field, ) else: # Runtime: try to import, with fallback @@ -50,12 +52,14 @@ Field, PydanticExperimentalWarning, StrictBool, + SecretStr, ValidationError, field_serializer, model_serializer, field_validator, model_validator, validator, + computed_field, ) except ImportError: HAS_PYDANTIC = False # pylint: disable=invalid-name @@ -106,7 +110,7 @@ def decorator(func): return func return decorator - + # Fallback: model_serializer decorator that does nothing def model_serializer(*args, **kwargs): # pylint: disable=unused-argument """Pydantic model_serializer fallback when pydantic is not available.""" @@ -125,6 +129,15 @@ def decorator(func): return decorator + # Fallback: computed_field decorator that does nothing + def computed_field(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic computed_field fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + # Fallback: AfterValidator that returns the function unchanged def AfterValidator(func): # pylint: disable=invalid-name """Pydantic AfterValidator fallback when pydantic is not available.""" @@ -141,6 +154,9 @@ def BeforeValidator(func): # pylint: disable=invalid-name # Fallback: StrictBool StrictBool = bool + # Fallback: SecretStr + SecretStr = str + # Fallback: ValidationError class ValidationError(Exception): """ @@ -191,10 +207,12 @@ def decorator(func): "PYDANTIC_IMPORT_ERROR", "PydanticExperimentalWarning", "StrictBool", + "SecretStr", "ValidationError", "field_serializer", "model_serializer", "field_validator", "model_validator", "validator", + "computed_field", ] diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 6f296065..65f2e464 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -27,6 +27,7 @@ - The list of the local users to configure. type: list elements: dict + required: True suboptions: email: description: @@ -202,7 +203,7 @@ def main(): # output = nd_state_machine.manage_state() # module.exit_json(**output) nd_state_machine.manage_state() - + nd_state_machine.exit_json() except Exception as e: From 24c068659bcfba15145ad6d2d86d5d92609b9ce6 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Fri, 6 Mar 2026 13:35:38 -0500 Subject: [PATCH 29/51] [ignore] Add NDOutput class. Modify NDStateMachine and nd_local_user accordingly --- plugins/module_utils/models/base.py | 7 +- plugins/module_utils/models/local_user.py | 3 +- plugins/module_utils/nd_config_collection.py | 3 +- plugins/module_utils/nd_output.py | 70 +++++++ plugins/module_utils/nd_state_machine.py | 186 +++---------------- plugins/module_utils/orchestrators/base.py | 3 - plugins/module_utils/utils.py | 2 +- plugins/modules/nd_local_user.py | 12 +- 8 files changed, 107 insertions(+), 179 deletions(-) create mode 100644 plugins/module_utils/nd_output.py diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 67ce5de0..14c04945 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -143,12 +143,11 @@ def to_diff_dict(self, **kwargs) -> Dict[str, Any]: return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff), **kwargs) # NOTE: initialize and return a deep copy of the instance? - # TODO: Might be missing a proper merge on fields of type `List[NDNestedModel]`? - # -> similar to NDCOnfigCollection... -> add argument to make it optional either replace def merge(self, other_model: "NDBaseModel", **kwargs) -> "NDBaseModel": if not isinstance(other_model, type(self)): - # TODO: Change error message - return TypeError("models are not of the same type.") + return TypeError( + f"NDBaseModel.merge method requires models of the same type. self of type {type(self)} and other_model of type {type(other_model)}" + ) for field, value in other_model: if value is None: diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 0575c1be..e2e7faf8 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -71,7 +71,6 @@ class LocalUserModel(NDBaseModel): # Fields # NOTE: `alias` are NOT the ansible aliases. they are the equivalent attribute's names from the API spec - # TODO: use extra for generating argument_spec (low priority) login_id: str = Field(alias="loginID") email: Optional[str] = Field(default=None, alias="email") first_name: Optional[str] = Field(default=None, alias="firstName") @@ -161,7 +160,7 @@ def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: # -- Extra -- - # TODO: to generate from Fields (low priority) + # TODO: to generate from Fields: use extra for generating argument_spec (low priority) @classmethod def get_argument_spec(cls) -> Dict: return dict( diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 5fd9886d..1f751822 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -37,7 +37,6 @@ def __init__(self, model_class: ModelType, items: Optional[List[ModelType]] = No for item in items: self.add(item) - # TODO: might not be necessary def _extract_key(self, item: ModelType) -> IdentifierKey: """ Extract identifier key from item. @@ -144,7 +143,7 @@ def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "cha if existing is None: return "new" - # TODO: make a diff class level method for NDBaseModel + # TODO: make a diff class level method for NDBaseModel (high priority) existing_data = existing.to_diff_dict() new_data = new_item.to_diff_dict() is_subset = issubset(new_data, existing_data) diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py new file mode 100644 index 00000000..027592df --- /dev/null +++ b/plugins/module_utils/nd_output.py @@ -0,0 +1,70 @@ +# -*- 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 typing import Dict, Any, Optional, List, Union +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection + + +class NDOutput: + def __init__(self, module: AnsibleModule): + self._output_level: str = module.params.get("output_level", "normal") + self._changed: bool = False + self._before: Union[NDConfigCollection, List] = [] + self._after: Union[NDConfigCollection, List] = [] + self._diff: Union[NDConfigCollection, List] = [] + self._proposed: Union[NDConfigCollection, List] = [] + self._logs: List = [] + self._extra: Dict[str, Any] = {} + + def format(self, **kwargs): + if isinstance(self._before, NDConfigCollection) and isinstance(self._after, NDConfigCollection) and self._before.get_diff_collection(self._after): + self._changed = True + + output = { + "output_level": self._output_level, + "changed": self._changed, + "after": self._after.to_ansible_config() if isinstance(self._after, NDConfigCollection) else self._after, + "before": self._before.to_ansible_config() if isinstance(self._before, NDConfigCollection) else self._before, + "diff": self._diff.to_ansible_config() if isinstance(self._diff, NDConfigCollection) else self._diff, + } + + if self._output_level in ("debug", "info"): + output["proposed"] = self._proposed.to_ansible_config() if isinstance(self._proposed, NDConfigCollection) else self._proposed + if self._output_level == "debug": + output["logs"] = "Not yet implemented" + + if self._extra: + output.update(self._extra) + + output.update(**kwargs) + + return output + + def assign( + self, + after: Optional[NDConfigCollection] = None, + before: Optional[NDConfigCollection] = None, + diff: Optional[NDConfigCollection] = None, + proposed: Optional[NDConfigCollection] = None, + logs: Optional[List] = None, + **kwargs + ) -> None: + if isinstance(after, NDConfigCollection): + self._after = after + if isinstance(before, NDConfigCollection): + self._before = before + if isinstance(diff, NDConfigCollection): + self._diff = diff + if isinstance(proposed, NDConfigCollection): + self._proposed = proposed + if isinstance(logs, List): + self._logs = logs + self._extra.update(**kwargs) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 81d6a966..4146926e 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -8,18 +8,15 @@ __metaclass__ = type -from typing import Optional, List, Dict, Any, Literal, Type +from typing import List, Dict, Any, Type from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import ValidationError from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator -from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey -from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED -# TODO: Revisit StateMachine when there is more arguments than config (e.g., "fabric" and "config" for switches config) -# TODO: Remove inheritence from NDModule (Top Priority) class NDStateMachine: """ Generic Network Resource Module for Nexus Dashboard. @@ -34,29 +31,27 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest self.nd_module = NDModule(self.module) # Operation tracking - self.nd_logs: List[Dict[str, Any]] = [] - self.result: Dict[str, Any] = {"changed": False} + self.output = NDOutput(self.module) # Configuration self.model_orchestrator = model_orchestrator(sender=self.nd_module) self.model_class = self.model_orchestrator.model_class - # TODO: Revisit these class variables when udpating Module intialization and configuration (medium priority) + # TODO: Revisit these class variables when udpating Module intialization and configuration (low priority) self.state = self.module.params["state"] - self.ansible_config = self.module.params.get("config", []) # Initialize collections - # TODO: Revisit class variables `previous`, `existing`, etc... (medium priority) self.nd_config_collection = NDConfigCollection[self.model_class] try: - init_all_data = self.model_orchestrator.query_all() - - self.existing = self.nd_config_collection.from_api_response(response_data=init_all_data, model_class=self.model_class) - # Save previous state - self.previous = self.existing.copy() - self.proposed = self.nd_config_collection(model_class=self.model_class) + response_data = self.model_orchestrator.query_all() + # State of configuration objects in ND before change execution + self.before = self.nd_config_collection.from_api_response(response_data=response_data, model_class=self.model_class) + # State of current configuration objects in ND during change execution + self.existing = self.before.copy() + # Ongoing collection of configuration objects that were changed self.sent = self.nd_config_collection(model_class=self.model_class) - - for config in self.ansible_config: + # Collection of configuration objects given by user + self.proposed = self.nd_config_collection(model_class=self.model_class) + for config in self.module.params.get("config", []): try: # Parse config into model item = self.model_class.from_config(config) @@ -64,42 +59,11 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest except ValidationError as e: self.fail_json(msg=f"Invalid configuration: {e}", config=config, validation_errors=e.errors()) return - + self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) except Exception as e: - self.fail_json(msg=f"Initialization failed: {str(e)}", error=str(e)) - - # Logging - # NOTE: format log placeholder - # TODO: use a proper logger (low priority) - def format_log( - self, - identifier: IdentifierKey, - operation_status: Literal["no_change", "created", "updated", "deleted"], - before: Optional[Dict[str, Any]] = None, - after: Optional[Dict[str, Any]] = None, - payload: Optional[Dict[str, Any]] = None, - ) -> None: - """ - Create and append a log entry. - """ - log_entry = { - "identifier": identifier, - "operation_status": operation_status, - "before": before, - "after": after, - "payload": payload, - } - - # Add HTTP details if not in check mode - if not self.module.check_mode and self.nd_module.url is not None: - log_entry.update( - {"method": self.nd_module.method, "response": self.nd_module.response, "status": self.nd_module.status, "url": self.nd_module.url} - ) - - self.nd_logs.append(log_entry) + self.fail_json(msg=f"NDStateMachine initialization failed: {str(e)}", error=str(e)) # State Management (core function) - # TODO: adapt all `manage` functions to endpoint/orchestrator strategies (Top priority) def manage_state(self) -> None: """ Manage state according to desired configuration. @@ -114,7 +78,6 @@ def manage_state(self) -> None: elif self.state == "deleted": self._manage_delete_state() - # TODO: not needed with Ansible `argument_spec` validation. Keep it for now but needs to be removed (low priority) # TODO: boil down an Exception instead of using `fail_json` method else: self.fail_json(msg=f"Invalid state: {self.state}") @@ -125,28 +88,19 @@ def _manage_create_update_state(self) -> None: """ for proposed_item in self.proposed: # Extract identifier - response = {} identifier = proposed_item.get_identifier_value() - existing_config = self.existing.get(identifier).to_config() if self.existing.get(identifier) else {} try: # Determine diff status diff_status = self.existing.get_diff_config(proposed_item) # No changes needed if diff_status == "no_diff": - self.format_log( - identifier=identifier, - operation_status="no_change", - before=existing_config, - after=existing_config, - ) continue # Prepare final config based on state if self.state == "merged": # Merge with existing - merged_item = self.existing.merge(proposed_item) - final_item = merged_item + final_item = self.existing.merge(proposed_item) else: # Replace or create if diff_status == "changed": @@ -158,34 +112,18 @@ def _manage_create_update_state(self) -> None: # Execute API operation if diff_status == "changed": if not self.module.check_mode: - response = self.model_orchestrator.update(final_item) + self.model_orchestrator.update(final_item) self.sent.add(final_item) - operation_status = "updated" elif diff_status == "new": if not self.module.check_mode: - response = self.model_orchestrator.create(final_item) + self.model_orchestrator.create(final_item) self.sent.add(final_item) - operation_status = "created" # Log operation - self.format_log( - identifier=identifier, - operation_status=operation_status, - before=existing_config, - after=self.model_class.model_validate(response).to_config() if not self.module.check_mode else final_item.to_config(), - payload=final_item.to_payload(), - ) + self.output.assign(after=self.existing) except Exception as e: error_msg = f"Failed to process {identifier}: {e}" - - self.format_log( - identifier=identifier, - operation_status="no_change", - before=existing_config, - after=existing_config, - ) - if not self.module.params.get("ignore_errors", False): self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) return @@ -194,7 +132,7 @@ def _manage_override_deletions(self) -> None: """ Delete items not in proposed config (for overridden state). """ - diff_identifiers = self.previous.get_diff_identifiers(self.proposed) + diff_identifiers = self.before.get_diff_identifiers(self.proposed) for identifier in diff_identifiers: try: @@ -204,18 +142,13 @@ def _manage_override_deletions(self) -> None: # Execute delete if not self.module.check_mode: - response = self.model_orchestrator.delete(existing_item) + self.model_orchestrator.delete(existing_item) # Remove from collection self.existing.delete(identifier) # Log deletion - self.format_log( - identifier=identifier, - operation_status="deleted", - before=existing_item.to_config(), - after={}, - ) + self.output.assign(after=self.existing) except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" @@ -232,29 +165,17 @@ def _manage_delete_state(self) -> None: existing_item = self.existing.get(identifier) if not existing_item: - # Already deleted or doesn't exist - self.format_log( - identifier=identifier, - operation_status="no_change", - before={}, - after={}, - ) continue # Execute delete if not self.module.check_mode: - response = self.model_orchestrator.delete(existing_item) + self.model_orchestrator.delete(existing_item) # Remove from collection self.existing.delete(identifier) # Log deletion - self.format_log( - identifier=identifier, - operation_status="deleted", - before=existing_item.to_config(), - after={}, - ) + self.output.assign(after=self.existing) except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" @@ -263,67 +184,10 @@ def _manage_delete_state(self) -> None: self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) return - # Output Formatting - # TODO: move to separate Class (results) -> align it with rest_send PR - # TODO: return a defined ordered list of config (for integration test) - def add_logs_and_outputs(self) -> None: - """Add logs and outputs to module result based on output_level.""" - output_level = self.module.params.get("output_level", "normal") - state = self.module.params.get("state") - - # Add previous state for certain states and output levels - if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: - if output_level in ("debug", "info"): - self.result["previous"] = self.previous.to_ansible_config() - - # Check if there were changes - if self.previous.get_diff_collection(self.existing): - self.result["changed"] = True - - # Add stdout if present - if self.nd_module.stdout: - self.result["stdout"] = self.nd_module.stdout - - # Add debug information - if output_level == "debug": - self.result["nd_logs"] = self.nd_logs - - if self.nd_module.url is not None: - self.result["httpapi_logs"] = self.nd_module.httpapi_logs - - if state in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED: - self.result["sent"] = self.sent.to_payload_list() - self.result["proposed"] = self.proposed.to_ansible_config() - - # Always include current state - self.result["current"] = self.existing.to_ansible_config() - # Module Exit Methods def fail_json(self, msg: str, **kwargs) -> None: """ Exit module with failure. """ - self.add_logs_and_outputs() - self.result.update(**kwargs) - self.module.fail_json(msg=msg, **self.result) - - def exit_json(self, **kwargs) -> None: - """ - Exit module successfully. - """ - self.add_logs_and_outputs() - - # Add diff if module supports it - if self.module._diff and self.result.get("changed") is True: - try: - # Use diff-safe dicts (excludes sensitive fields) - before = [item.to_diff_dict() for item in self.previous] - after = [item.to_diff_dict() for item in self.existing] - - self.result["diff"] = dict(before=before, after=after) - except Exception: - pass # Don't fail on diff generation - - self.result.update(**kwargs) - self.module.exit_json(**self.result) + self.module.fail_json(msg=msg) diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 1a3b1921..1a8b4f10 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -16,7 +16,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType -# TODO: Revisit naming them "Orchestrator" class NDBaseOrchestrator(BaseModel): model_config = ConfigDict( use_enum_values=True, @@ -40,7 +39,6 @@ class NDBaseOrchestrator(BaseModel): # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") # TODO: Explore new ways to make them even more general -> e.g., create a general API operation function (low priority) - # TODO: Revisit Deserialization def create(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: api_endpoint = self.create_endpoint() @@ -72,7 +70,6 @@ def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: except Exception as e: raise Exception(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e - # TODO: Revisit the straegy around the query_all (see local_user's case) def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> ResponseType: try: result = self.sender.query_obj(self.query_all_endpoint.path) diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 0bf7cfc8..e09bd499 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -56,7 +56,7 @@ def issubset(subset: Any, superset: Any) -> bool: return True -# TODO: Might not necessary with Pydantic validation and serialization built-in methods +# TODO: Might not necessary with Pydantic validation and serialization built-in methods (see models/local_user) def remove_unwanted_keys(data: Dict, unwanted_keys: List[Union[str, List[str]]]) -> Dict: """Remove unwanted keys from dict (supports nested paths).""" data = deepcopy(data) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 65f2e464..d1d871fe 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -128,10 +128,10 @@ reuse_limitation: 20 time_interval_limitation: 10 security_domains: - name: all - roles: - - observer - - support_engineer + - name: all + roles: + - observer + - support_engineer remote_id_claim: remote_user remote_user_authorization: true state: merged @@ -204,10 +204,10 @@ def main(): # module.exit_json(**output) nd_state_machine.manage_state() - nd_state_machine.exit_json() + module.exit_json(**nd_state_machine.output.format()) except Exception as e: - module.fail_json(msg=f"Module execution failed: {str(e)}") + module.fail_json(msg=f"Module execution failed: {str(e)}", **nd_state_machine.output.format()) if __name__ == "__main__": From dedc9588a10fddd6ca8753b2ddbb85590a194a63 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 10 Mar 2026 13:36:50 -0400 Subject: [PATCH 30/51] [ignore] Update NDOutput class. Remove all fail_json dependencies in NDStateMachineand add custom Exception for it in common/exceptions dir. Set json mode for to_diff_dict method in NDBaseModel. --- plugins/module_utils/common/exceptions.py | 11 ++++++-- plugins/module_utils/models/base.py | 4 +-- plugins/module_utils/nd_output.py | 7 +++-- plugins/module_utils/nd_state_machine.py | 32 +++++++---------------- 4 files changed, 23 insertions(+), 31 deletions(-) diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py index 16e31ac6..0d7b7bcc 100644 --- a/plugins/module_utils/common/exceptions.py +++ b/plugins/module_utils/common/exceptions.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ @@ -144,3 +143,11 @@ def to_dict(self) -> dict[str, Any]: - None """ return self.error_data.model_dump(exclude_none=True) + + +class NDStateMachineError(Exception): + """ + Raised when NDStateMachine is failing. + """ + + pass diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 14c04945..30e5de5e 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -72,7 +72,7 @@ def to_payload(self, **kwargs) -> Dict[str, Any]: """ Convert model to API payload format. """ - return self.model_dump(by_alias=True, exclude_none=True, **kwargs) + return self.model_dump(by_alias=True, exclude_none=True, mode="json", **kwargs) def to_config(self, **kwargs) -> Dict[str, Any]: """ @@ -140,7 +140,7 @@ def to_diff_dict(self, **kwargs) -> Dict[str, Any]: """ Export for diff comparison (excludes sensitive fields). """ - return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff), **kwargs) + return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff), mode="json", **kwargs) # NOTE: initialize and return a deep copy of the instance? def merge(self, other_model: "NDBaseModel", **kwargs) -> "NDBaseModel": diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py index 027592df..dbfc2cd2 100644 --- a/plugins/module_utils/nd_output.py +++ b/plugins/module_utils/nd_output.py @@ -9,13 +9,12 @@ __metaclass__ = type from typing import Dict, Any, Optional, List, Union -from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection class NDOutput: - def __init__(self, module: AnsibleModule): - self._output_level: str = module.params.get("output_level", "normal") + def __init__(self, output_level: str): + self._output_level: str = output_level self._changed: bool = False self._before: Union[NDConfigCollection, List] = [] self._after: Union[NDConfigCollection, List] = [] @@ -24,7 +23,7 @@ def __init__(self, module: AnsibleModule): self._logs: List = [] self._extra: Dict[str, Any] = {} - def format(self, **kwargs): + def format(self, **kwargs) -> Dict[str, Any]: if isinstance(self._before, NDConfigCollection) and isinstance(self._after, NDConfigCollection) and self._before.get_diff_collection(self._after): self._changed = True diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 4146926e..bd86da3c 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -8,13 +8,14 @@ __metaclass__ = type -from typing import List, Dict, Any, Type +from typing import Type from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import ValidationError from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDStateMachineError class NDStateMachine: @@ -31,7 +32,7 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest self.nd_module = NDModule(self.module) # Operation tracking - self.output = NDOutput(self.module) + self.output = NDOutput(output_level=module.params.get("output_level", "normal")) # Configuration self.model_orchestrator = model_orchestrator(sender=self.nd_module) @@ -57,11 +58,10 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest item = self.model_class.from_config(config) self.proposed.add(item) except ValidationError as e: - self.fail_json(msg=f"Invalid configuration: {e}", config=config, validation_errors=e.errors()) - return + raise NDStateMachineError(f"Invalid configuration. for config {config}: {str(e)}") self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) except Exception as e: - self.fail_json(msg=f"NDStateMachine initialization failed: {str(e)}", error=str(e)) + raise NDStateMachineError(f"Initialization failed: {str(e)}") # State Management (core function) def manage_state(self) -> None: @@ -78,9 +78,8 @@ def manage_state(self) -> None: elif self.state == "deleted": self._manage_delete_state() - # TODO: boil down an Exception instead of using `fail_json` method else: - self.fail_json(msg=f"Invalid state: {self.state}") + raise NDStateMachineError(f"Invalid state: {self.state}") def _manage_create_update_state(self) -> None: """ @@ -125,8 +124,7 @@ def _manage_create_update_state(self) -> None: except Exception as e: error_msg = f"Failed to process {identifier}: {e}" if not self.module.params.get("ignore_errors", False): - self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) - return + raise NDStateMachineError(error_msg) def _manage_override_deletions(self) -> None: """ @@ -152,10 +150,8 @@ def _manage_override_deletions(self) -> None: except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" - if not self.module.params.get("ignore_errors", False): - self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) - return + raise NDStateMachineError(error_msg) def _manage_delete_state(self) -> None: """Handle deleted state.""" @@ -179,15 +175,5 @@ def _manage_delete_state(self) -> None: except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" - if not self.module.params.get("ignore_errors", False): - self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) - return - - # Module Exit Methods - - def fail_json(self, msg: str, **kwargs) -> None: - """ - Exit module with failure. - """ - self.module.fail_json(msg=msg) + raise NDStateMachineError(error_msg) From 2d472d9c051c235ac9c8584c543519a3966e1dcf Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 11 Mar 2026 11:48:38 -0400 Subject: [PATCH 31/51] [ignore] Fix serialization of model with minimal changes to base.py and local_user.py. Add method to NDBaseModel and apply relevant changes to nd_config_collection. --- plugins/module_utils/models/base.py | 211 ++++++++++++------- plugins/module_utils/models/local_user.py | 180 ++++++++++------ plugins/module_utils/nd_config_collection.py | 5 +- plugins/module_utils/pydantic_compat.py | 6 + 4 files changed, 256 insertions(+), 146 deletions(-) diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 30e5de5e..79f9ec80 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -9,154 +9,221 @@ __metaclass__ = type from abc import ABC -from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict -from typing import List, Dict, Any, ClassVar, Tuple, Union, Literal, Optional +from pydantic import BaseModel, ConfigDict +from typing import List, Dict, Any, ClassVar, Set, Tuple, Union, Literal, Optional +from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset -# TODO: Revisit identifiers strategy (low priority) -# NOTE: what about List of NestedModels? -> make it a separate Sub Model class NDBaseModel(BaseModel, ABC): """ Base model for all Nexus Dashboard API objects. - Supports three identifier strategies: - - single: One unique required field (e.g., ["login_id"]) - - composite: Multiple required fields as tuple (e.g., ["device", "interface"]) - - hierarchical: Priority-ordered fields (e.g., ["uuid", "name"]) - - singleton: no identifiers required (e.g., only a single instance can exist in Nexus Dasboard) + Class-level configuration attributes: + identifiers: List of field names used to uniquely identify this object. + identifier_strategy: How identifiers are interpreted. + exclude_from_diff: Fields excluded from diff comparisons. + unwanted_keys: Keys to strip from API responses before processing. + payload_nested_fields: Mapping of {payload_key: [field_names]} for fields + that should be grouped under a nested key in payload mode but remain + flat in config mode. + payload_exclude_fields: Fields to exclude from payload output + (e.g., because they are restructured into nested keys). + config_exclude_fields: Fields to exclude from config output + (e.g., computed payload-only structures). """ - # TODO: revisit initial Model Configurations (low priority) model_config = ConfigDict( str_strip_whitespace=True, use_enum_values=True, validate_assignment=True, populate_by_name=True, arbitrary_types_allowed=True, - extra="allow", # NOTE: enabled extra: allows to add extra Field infos for generating Ansible argument_spec and Module Docs + extra="ignore", ) - # TODO: Revisit identifiers strategy (low priority) + # --- Identifier Configuration --- + identifiers: ClassVar[Optional[List[str]]] = None - # TODO: Revisit no identifiers strategy naming (`singleton` -> `unique`, `unnamed`) (low priority) identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" - # Optional: fields to exclude from diffs (e.g., passwords) - exclude_from_diff: ClassVar[List] = [] - # TODO: To be removed in the future (see local_user model) + # --- Serialization Configuration --- + + exclude_from_diff: ClassVar[Set[str]] = set() unwanted_keys: ClassVar[List] = [] - # TODO: Revisit it with identifiers strategy (low priority) + # Declarative nested-field grouping for payload mode + # e.g., {"passwordPolicy": ["reuse_limitation", "time_interval_limitation"]} + # means: in payload mode, remove these fields from top level and nest them + # under "passwordPolicy" with their alias names. + payload_nested_fields: ClassVar[Dict[str, List[str]]] = {} + + # Fields to explicitly exclude per mode + payload_exclude_fields: ClassVar[Set[str]] = set() + config_exclude_fields: ClassVar[Set[str]] = set() + + # --- Subclass Validation --- + def __init_subclass__(cls, **kwargs): - """ - Enforce configuration for identifiers definition. - """ super().__init_subclass__(**kwargs) # Skip enforcement for nested models - if cls.__name__ in ["NDNestedModel"] or any(base.__name__ == "NDNestedModel" for base in cls.__mro__): + if cls.__name__ == "NDNestedModel" or any(base.__name__ == "NDNestedModel" for base in cls.__mro__): return if not hasattr(cls, "identifiers") or cls.identifiers is None: - raise ValueError( - f"Class {cls.__name__} must define 'identifiers' and 'identifier_strategy'." - f"Example: `identifiers: ClassVar[Optional[List[str]]] = ['login_id']`" - ) + raise ValueError(f"Class {cls.__name__} must define 'identifiers'. " f"Example: identifiers: ClassVar[Optional[List[str]]] = ['login_id']") if not hasattr(cls, "identifier_strategy") or cls.identifier_strategy is None: - raise ValueError( - f"Class {cls.__name__} must define 'identifiers' and 'identifier_strategy'." - f"Example: `identifier_strategy: ClassVar[Optional[Literal['single', 'composite', 'hierarchical', 'singleton']]] = 'single'`" - ) + raise ValueError(f"Class {cls.__name__} must define 'identifier_strategy'. " f"Example: identifier_strategy: ClassVar[...] = 'single'") - def to_payload(self, **kwargs) -> Dict[str, Any]: + # --- Core Serialization --- + + def _build_payload_nested(self, data: Dict[str, Any]) -> Dict[str, Any]: """ - Convert model to API payload format. + Apply payload_nested_fields: pull specified fields out of the top-level + dict and group them under their declared parent key. """ - return self.model_dump(by_alias=True, exclude_none=True, mode="json", **kwargs) + if not self.payload_nested_fields: + return data + + result = dict(data) + + for nested_key, field_names in self.payload_nested_fields.items(): + nested_dict = {} + for field_name in field_names: + # Resolve the alias for this field + field_info = self.__class__.model_fields.get(field_name) + if field_info is None: + continue + + alias = field_info.alias or field_name + + # Pull value from the serialized data (which uses aliases in payload mode) + if alias in result: + nested_dict[alias] = result.pop(alias) + + if nested_dict: + result[nested_key] = nested_dict + + return result + + def to_payload(self, **kwargs) -> Dict[str, Any]: + """Convert model to API payload format (aliased keys, nested structures).""" + data = self.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + context={"mode": "payload"}, + exclude=self.payload_exclude_fields or None, + **kwargs, + ) + return self._build_payload_nested(data) def to_config(self, **kwargs) -> Dict[str, Any]: - """ - Convert model to Ansible config format. - """ - return self.model_dump(by_alias=False, exclude_none=True, **kwargs) + """Convert model to Ansible config format (Python field names, flat structure).""" + return self.model_dump( + by_alias=False, + exclude_none=True, + context={"mode": "config"}, + exclude=self.config_exclude_fields or None, + **kwargs, + ) + + # --- Core Deserialization --- @classmethod def from_response(cls, response: Dict[str, Any], **kwargs) -> "NDBaseModel": + """Create model instance from API response dict.""" return cls.model_validate(response, by_alias=True, **kwargs) @classmethod def from_config(cls, ansible_config: Dict[str, Any], **kwargs) -> "NDBaseModel": + """Create model instance from Ansible config dict.""" return cls.model_validate(ansible_config, by_name=True, **kwargs) - # TODO: Revisit this function when revisiting identifier strategy (low priority) - def get_identifier_value(self, **kwargs) -> Union[str, int, Tuple[Any, ...]]: + # --- Identifier Access --- + + def get_identifier_value(self) -> Optional[Union[str, int, Tuple[Any, ...]]]: """ - Extract identifier value(s) from this instance: - - single identifier: Returns field value. - - composite identifiers: Returns tuple of all field values. - - hierarchical identifiers: Returns tuple of (field_name, value) for first non-None field. + Extract identifier value(s) based on the configured strategy. + + Returns: + - single: The field value + - composite: Tuple of all field values + - hierarchical: Tuple of (field_name, value) for first non-None field + - singleton: None """ - if not self.identifiers and self.identifier_strategy != "singleton": - raise ValueError(f"{self.__class__.__name__} must have identifiers defined with its current identifier strategy: `{self.identifier_strategy}`") + strategy = self.identifier_strategy - if self.identifier_strategy == "single": + if strategy == "singleton": + return None + + if not self.identifiers: + raise ValueError(f"{self.__class__.__name__} has strategy '{strategy}' but no identifiers defined.") + + if strategy == "single": value = getattr(self, self.identifiers[0], None) if value is None: raise ValueError(f"Single identifier field '{self.identifiers[0]}' is None") return value - elif self.identifier_strategy == "composite": + elif strategy == "composite": values = [] missing = [] - for field in self.identifiers: value = getattr(self, field, None) if value is None: missing.append(field) values.append(value) - - # NOTE: might be redefined with Pydantic (low priority) if missing: raise ValueError(f"Composite identifier fields {missing} are None. " f"All required: {self.identifiers}") - return tuple(values) - elif self.identifier_strategy == "hierarchical": + elif strategy == "hierarchical": for field in self.identifiers: value = getattr(self, field, None) if value is not None: return (field, value) - raise ValueError(f"No non-None value in hierarchical fields {self.identifiers}") - # TODO: Revisit condition when there is no identifiers (low priority) - elif self.identifier_strategy == "singleton": - return None - else: - raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") + raise ValueError(f"Unknown identifier strategy: {strategy}") + + # --- Diff & Merge --- def to_diff_dict(self, **kwargs) -> Dict[str, Any]: + """Export for diff comparison, excluding sensitive fields.""" + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude=self.exclude_from_diff or None, + mode="json", + **kwargs, + ) + + def get_diff(self, other: "NDBaseModel") -> bool: + """Diff comparison.""" + self_data = self.to_diff_dict() + other_data = other.to_diff_dict() + return issubset(other_data, self_data) + + def merge(self, other: "NDBaseModel") -> "NDBaseModel": """ - Export for diff comparison (excludes sensitive fields). - """ - return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff), mode="json", **kwargs) + Merge another model's non-None values into this instance. + Recursively merges nested NDBaseModel fields. - # NOTE: initialize and return a deep copy of the instance? - def merge(self, other_model: "NDBaseModel", **kwargs) -> "NDBaseModel": - if not isinstance(other_model, type(self)): - return TypeError( - f"NDBaseModel.merge method requires models of the same type. self of type {type(self)} and other_model of type {type(other_model)}" - ) + Returns self for chaining. + """ + if not isinstance(other, type(self)): + raise TypeError(f"Cannot merge {type(other).__name__} into {type(self).__name__}. " f"Both must be the same type.") - for field, value in other_model: + for field_name, value in other: if value is None: continue - current_value = getattr(self, field) - if isinstance(current_value, NDBaseModel) and isinstance(value, NDBaseModel): - setattr(self, field, current_value.merge(value)) - + current = getattr(self, field_name) + if isinstance(current, NDBaseModel) and isinstance(value, NDBaseModel): + current.merge(value) else: - setattr(self, field, value) + setattr(self, field_name, value) + return self diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index e2e7faf8..0320d3c1 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -16,13 +16,14 @@ field_serializer, field_validator, model_validator, - computed_field, + FieldSerializationInfo, + SerializationInfo, ) from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping -# Constant defined here as it is only used in this model + USER_ROLES_MAPPING = NDConstantMapping( { "fabric_admin": "fabric-admin", @@ -36,131 +37,155 @@ class LocalUserSecurityDomainModel(NDNestedModel): - """Security domain configuration for local user (nested model).""" + """ + Security domain with assigned roles for a local user. - # Fields - name: str = Field(alias="name", exclude=True) - roles: Optional[List[str]] = Field(default=None, alias="roles", exclude=True) + Canonical form (config): {"name": "all", "roles": ["observer", "support_engineer"]} + API payload form: {"all": {"roles": ["observer", "support-engineer"]}} + """ - # -- Serialization (Model instance -> API payload) -- + name: str = Field(alias="name") + roles: Optional[List[str]] = Field(default=None, alias="roles") @model_serializer() - def serialize_model(self) -> Dict: - return {self.name: {"roles": [USER_ROLES_MAPPING.get_dict().get(role, role) for role in (self.roles or [])]}} + def serialize(self, info: SerializationInfo) -> Any: + mode = (info.context or {}).get("mode", "payload") - # NOTE: Deserialization defined in `LocalUserModel` due to API response complexity + if mode == "config": + result = {"name": self.name} + if self.roles is not None: + result["roles"] = list(self.roles) + return result + + # Payload mode: nested dict with API role names + api_roles = [USER_ROLES_MAPPING.get_dict().get(role, role) for role in (self.roles or [])] + return {self.name: {"roles": api_roles}} -# TODO: Add field validation (e.g. me, le, choices, etc...) (low priority) class LocalUserModel(NDBaseModel): """ - Local user configuration. + Local user configuration for Nexus Dashboard. + + Identifier: login_id (single) - Identifier: login_id (single field) + Serialization notes: + - In payload mode, `reuse_limitation` and `time_interval_limitation` + are nested under `passwordPolicy` (handled by base class via + `payload_nested_fields`). + - In config mode, they remain as flat top-level fields. + - `security_domains` serializes as a nested dict in payload mode + and a flat list of dicts in config mode. """ - # Identifier configuration - # TODO: Revisit this identifiers strategy (low priority) + # --- Identifier Configuration --- + identifiers: ClassVar[Optional[List[str]]] = ["login_id"] identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" - # Keys management configurations - # TODO: Revisit these configurations (low priority) - exclude_from_diff: ClassVar[List[str]] = ["user_password"] - unwanted_keys: ClassVar[List] = [["passwordPolicy", "passwordChangeTime"], ["userID"]] # Nested path # Simple key + # --- Serialization Configuration --- + + exclude_from_diff: ClassVar[set] = {"user_password"} + unwanted_keys: ClassVar[List] = [ + ["passwordPolicy", "passwordChangeTime"], + ["userID"], + ] + + # In payload mode, nest these fields under "passwordPolicy" + payload_nested_fields: ClassVar[Dict[str, List[str]]] = { + "passwordPolicy": ["reuse_limitation", "time_interval_limitation"], + } + + # --- Fields --- - # Fields - # NOTE: `alias` are NOT the ansible aliases. they are the equivalent attribute's names from the API spec login_id: str = Field(alias="loginID") email: Optional[str] = Field(default=None, alias="email") first_name: Optional[str] = Field(default=None, alias="firstName") last_name: Optional[str] = Field(default=None, alias="lastName") user_password: Optional[SecretStr] = Field(default=None, alias="password") - reuse_limitation: Optional[int] = Field(default=None, alias="reuseLimitation", exclude=True) - time_interval_limitation: Optional[int] = Field(default=None, alias="timeIntervalLimitation", exclude=True) + reuse_limitation: Optional[int] = Field(default=None, alias="reuseLimitation") + time_interval_limitation: Optional[int] = Field(default=None, alias="timeIntervalLimitation") security_domains: Optional[List[LocalUserSecurityDomainModel]] = Field(default=None, alias="rbac") remote_id_claim: Optional[str] = Field(default=None, alias="remoteIDClaim") remote_user_authorization: Optional[bool] = Field(default=None, alias="xLaunch") - # -- Serialization (Model instance -> API payload) -- - - @computed_field(alias="passwordPolicy") - @property - def password_policy(self) -> Optional[Dict[str, int]]: - """Computed nested structure for API payload.""" - if self.reuse_limitation is None and self.time_interval_limitation is None: - return None - - policy = {} - if self.reuse_limitation is not None: - policy["reuseLimitation"] = self.reuse_limitation - if self.time_interval_limitation is not None: - policy["timeIntervalLimitation"] = self.time_interval_limitation - return policy + # --- Serializers --- @field_serializer("user_password") def serialize_password(self, value: Optional[SecretStr]) -> Optional[str]: return value.get_secret_value() if value else None @field_serializer("security_domains") - def serialize_domains(self, value: Optional[List[LocalUserSecurityDomainModel]]) -> Optional[Dict]: - # NOTE: exclude `None` values and empty list (-> should we exclude empty list?) + def serialize_security_domains( + self, + value: Optional[List[LocalUserSecurityDomainModel]], + info: FieldSerializationInfo, + ) -> Any: if not value: return None + mode = (info.context or {}).get("mode", "payload") + + if mode == "config": + return [domain.model_dump(context=info.context) for domain in value] + + # Payload mode: merge all domain dicts into {"domains": {...}} domains_dict = {} for domain in value: - domains_dict.update(domain.to_payload()) - + domains_dict.update(domain.model_dump(context=info.context)) return {"domains": domains_dict} - # -- Deserialization (API response / Ansible payload -> Model instance) -- + # --- Validators (Deserialization) --- @model_validator(mode="before") @classmethod - def deserialize_password_policy(cls, data: Any) -> Any: + def flatten_password_policy(cls, data: Any) -> Any: + """ + Flatten nested passwordPolicy from API response into top-level fields. + This is the inverse of the payload_nested_fields nesting. + """ if not isinstance(data, dict): return data - password_policy = data.get("passwordPolicy") - - if password_policy and isinstance(password_policy, dict): - if "reuseLimitation" in password_policy: - data["reuse_limitation"] = password_policy["reuseLimitation"] - if "timeIntervalLimitation" in password_policy: - data["time_interval_limitation"] = password_policy["timeIntervalLimitation"] - - # Remove the nested structure from data to avoid conflicts - # (since it's a computed field, not a real field) - data.pop("passwordPolicy", None) + policy = data.pop("passwordPolicy", None) + if isinstance(policy, dict): + if "reuseLimitation" in policy: + data.setdefault("reuseLimitation", policy["reuseLimitation"]) + if "timeIntervalLimitation" in policy: + data.setdefault("timeIntervalLimitation", policy["timeIntervalLimitation"]) return data @field_validator("security_domains", mode="before") @classmethod - def deserialize_domains(cls, value: Any) -> Optional[List[Dict]]: + def normalize_security_domains(cls, value: Any) -> Optional[List[Dict]]: + """ + Accept security_domains in either format: + - List of dicts (Ansible config): [{"name": "all", "roles": [...]}] + - Nested dict (API response): {"domains": {"all": {"roles": [...]}}} + Always normalizes to the list-of-dicts form for model storage. + """ if value is None: return None - # If already in list format (Ansible module representation), return as-is + # Already normalized (from Ansible config) if isinstance(value, list): return value - # If in the nested dict format (API representation) + # API response format if isinstance(value, dict) and "domains" in value: - domains_dict = value["domains"] - domains_list = [] - - for domain_name, domain_data in domains_dict.items(): - domains_list.append({"name": domain_name, "roles": [USER_ROLES_MAPPING.get_dict().get(role, role) for role in domain_data.get("roles", [])]}) - - return domains_list + reverse_mapping = {v: k for k, v in USER_ROLES_MAPPING.get_dict().items()} + return [ + { + "name": domain_name, + "roles": [reverse_mapping.get(role, role) for role in domain_data.get("roles", [])], + } + for domain_name, domain_data in value["domains"].items() + ] return value - # -- Extra -- + # --- Argument Spec --- - # TODO: to generate from Fields: use extra for generating argument_spec (low priority) @classmethod def get_argument_spec(cls) -> Dict: return dict( @@ -180,8 +205,19 @@ def get_argument_spec(cls) -> 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=USER_ROLES_MAPPING.get_original_data()), + name=dict( + type="str", + required=True, + aliases=[ + "security_domain_name", + "domain_name", + ], + ), + roles=dict( + type="list", + elements="str", + choices=USER_ROLES_MAPPING.get_original_data(), + ), ), aliases=["domains"], ), @@ -189,5 +225,9 @@ def get_argument_spec(cls) -> Dict: remote_user_authorization=dict(type="bool"), ), ), - state=dict(type="str", default="merged", choices=["merged", "replaced", "overridden", "deleted"]), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), ) diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 1f751822..364b8a8f 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -143,10 +143,7 @@ def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "cha if existing is None: return "new" - # TODO: make a diff class level method for NDBaseModel (high priority) - existing_data = existing.to_diff_dict() - new_data = new_item.to_diff_dict() - is_subset = issubset(new_data, existing_data) + is_subset = existing.get_diff(new_item) return "no_diff" if is_subset else "changed" diff --git a/plugins/module_utils/pydantic_compat.py b/plugins/module_utils/pydantic_compat.py index e8924cd2..4456018a 100644 --- a/plugins/module_utils/pydantic_compat.py +++ b/plugins/module_utils/pydantic_compat.py @@ -40,6 +40,8 @@ model_validator, validator, computed_field, + FieldSerializationInfo, + SerializationInfo, ) else: # Runtime: try to import, with fallback @@ -60,6 +62,8 @@ model_validator, validator, computed_field, + FieldSerializationInfo, + SerializationInfo, ) except ImportError: HAS_PYDANTIC = False # pylint: disable=invalid-name @@ -215,4 +219,6 @@ def decorator(func): "model_validator", "validator", "computed_field", + "FieldSerializationInfo", + "SerializationInfo", ] From de81d560c591d3bd2f8802708345495ea6b79872 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 11 Mar 2026 13:56:54 -0400 Subject: [PATCH 32/51] [ignore] Complete nd_local_user integration test for creation and update asserts. --- .../targets/nd_local_user/tasks/main.yml | 296 +++++++++++++++++- 1 file changed, 288 insertions(+), 8 deletions(-) diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml index 77e55cd1..de8ad5ed 100644 --- a/tests/integration/targets/nd_local_user/tasks/main.yml +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -46,15 +46,125 @@ - name: all state: merged check_mode: true - register: cm_create_local_user + register: cm_create_local_users - name: Create local users with full and minimum configuration (normal mode) cisco.nd.nd_local_user: <<: *create_local_user - register: nm_create_local_user + register: nm_create_local_users + +- name: Asserts for local users creation tasks + ansible.builtin.assert: + that: + - cm_create_local_users is changed + - cm_create_local_users.after | length == 3 + - cm_create_local_users.after.0.login_id == "admin" + - cm_create_local_users.after.0.first_name == "admin" + - cm_create_local_users.after.0.remote_user_authorization == false + - cm_create_local_users.after.0.reuse_limitation == 0 + - cm_create_local_users.after.0.security_domains | length == 1 + - cm_create_local_users.after.0.security_domains.0.name == "all" + - cm_create_local_users.after.0.security_domains.0.roles | length == 1 + - cm_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - cm_create_local_users.after.0.time_interval_limitation == 0 + - cm_create_local_users.after.1.email == "ansibleuser@example.com" + - cm_create_local_users.after.1.first_name == "Ansible first name" + - cm_create_local_users.after.1.last_name == "Ansible last name" + - cm_create_local_users.after.1.login_id == "ansible_local_user" + - cm_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - cm_create_local_users.after.1.remote_user_authorization == true + - cm_create_local_users.after.1.reuse_limitation == 20 + - cm_create_local_users.after.1.security_domains | length == 1 + - cm_create_local_users.after.1.security_domains.0.name == "all" + - cm_create_local_users.after.1.security_domains.0.roles | length == 2 + - cm_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - cm_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - cm_create_local_users.after.1.time_interval_limitation == 10 + - cm_create_local_users.after.2.login_id == "ansible_local_user_2" + - cm_create_local_users.after.2.security_domains | length == 1 + - cm_create_local_users.after.2.security_domains.0.name == "all" + - cm_create_local_users.before | length == 1 + - cm_create_local_users.before.0.login_id == "admin" + - cm_create_local_users.before.0.first_name == "admin" + - cm_create_local_users.before.0.remote_user_authorization == false + - cm_create_local_users.before.0.reuse_limitation == 0 + - cm_create_local_users.before.0.security_domains | length == 1 + - cm_create_local_users.before.0.security_domains.0.name == "all" + - cm_create_local_users.before.0.security_domains.0.roles | length == 1 + - cm_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - cm_create_local_users.before.0.time_interval_limitation == 0 + - cm_create_local_users.diff == [] + - cm_create_local_users.proposed.0.email == "ansibleuser@example.com" + - cm_create_local_users.proposed.0.first_name == "Ansible first name" + - cm_create_local_users.proposed.0.last_name == "Ansible last name" + - cm_create_local_users.proposed.0.login_id == "ansible_local_user" + - cm_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - cm_create_local_users.proposed.0.remote_user_authorization == true + - cm_create_local_users.proposed.0.reuse_limitation == 20 + - cm_create_local_users.proposed.0.security_domains | length == 1 + - cm_create_local_users.proposed.0.security_domains.0.name == "all" + - cm_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - cm_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - cm_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - cm_create_local_users.proposed.0.time_interval_limitation == 10 + - cm_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - cm_create_local_users.proposed.1.security_domains | length == 1 + - cm_create_local_users.proposed.1.security_domains.0.name == "all" + - nm_create_local_users is changed + - nm_create_local_users.after.0.first_name == "admin" + - nm_create_local_users.after.0.remote_user_authorization == false + - nm_create_local_users.after.0.reuse_limitation == 0 + - nm_create_local_users.after.0.security_domains | length == 1 + - nm_create_local_users.after.0.security_domains.0.name == "all" + - nm_create_local_users.after.0.security_domains.0.roles | length == 1 + - nm_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - nm_create_local_users.after.0.time_interval_limitation == 0 + - nm_create_local_users.after.1.email == "ansibleuser@example.com" + - nm_create_local_users.after.1.first_name == "Ansible first name" + - nm_create_local_users.after.1.last_name == "Ansible last name" + - nm_create_local_users.after.1.login_id == "ansible_local_user" + - nm_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - nm_create_local_users.after.1.remote_user_authorization == true + - nm_create_local_users.after.1.reuse_limitation == 20 + - nm_create_local_users.after.1.security_domains | length == 1 + - nm_create_local_users.after.1.security_domains.0.name == "all" + - nm_create_local_users.after.1.security_domains.0.roles | length == 2 + - nm_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - nm_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - nm_create_local_users.after.1.time_interval_limitation == 10 + - nm_create_local_users.after.2.login_id == "ansible_local_user_2" + - nm_create_local_users.after.2.security_domains | length == 1 + - nm_create_local_users.after.2.security_domains.0.name == "all" + - nm_create_local_users.before | length == 1 + - nm_create_local_users.before.0.login_id == "admin" + - nm_create_local_users.before.0.first_name == "admin" + - nm_create_local_users.before.0.remote_user_authorization == false + - nm_create_local_users.before.0.reuse_limitation == 0 + - nm_create_local_users.before.0.security_domains | length == 1 + - nm_create_local_users.before.0.security_domains.0.name == "all" + - nm_create_local_users.before.0.security_domains.0.roles | length == 1 + - nm_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - nm_create_local_users.before.0.time_interval_limitation == 0 + - nm_create_local_users.diff == [] + - nm_create_local_users.proposed.0.email == "ansibleuser@example.com" + - nm_create_local_users.proposed.0.first_name == "Ansible first name" + - nm_create_local_users.proposed.0.last_name == "Ansible last name" + - nm_create_local_users.proposed.0.login_id == "ansible_local_user" + - nm_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - nm_create_local_users.proposed.0.remote_user_authorization == true + - nm_create_local_users.proposed.0.reuse_limitation == 20 + - nm_create_local_users.proposed.0.security_domains | length == 1 + - nm_create_local_users.proposed.0.security_domains.0.name == "all" + - nm_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - nm_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - nm_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - nm_create_local_users.proposed.0.time_interval_limitation == 10 + - nm_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - nm_create_local_users.proposed.1.security_domains | length == 1 + - nm_create_local_users.proposed.1.security_domains.0.name == "all" # UPDATE -- name: Update all ansible_local_user's attributes (check mode) +- name: Replace all ansible_local_user's attributes (check mode) cisco.nd.nd_local_user: &update_first_local_user <<: *nd_info config: @@ -72,12 +182,12 @@ remote_user_authorization: false state: replaced check_mode: true - register: cm_update_local_user + register: cm_replace_local_user -- name: Update local user (normal mode) +- name: Replace all ansible_local_user's attributes (normal mode) cisco.nd.nd_local_user: <<: *update_first_local_user - register: nm_update_local_user + register: nm_replace_local_user - name: Update all ansible_local_user_2's attributes except password cisco.nd.nd_local_user: &update_second_local_user @@ -95,12 +205,178 @@ remote_id_claim: ansible_remote_user_2 remote_user_authorization: true state: merged - register: nm_update_local_user_2 + register: nm_merge_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 + register: nm_merge_local_user_2_again + + +- name: Override local users with minimum configuration + cisco.nd.nd_local_user: + <<: *nd_info + config: + - login_id: admin + first_name: admin + remote_user_authorization: false + reuse_limitation: 0 + time_interval_limitation: 0 + security_domains: + - name: all + roles: + - super_admin + - email: overrideansibleuser@example.com + login_id: ansible_local_user + first_name: Overridden Ansible first name + last_name: Overriden Ansible last name + user_password: overideansibleLocalUserPassword1% + reuse_limitation: 15 + time_interval_limitation: 5 + security_domains: + - name: all + roles: + - observer + remote_id_claim: ansible_remote_user + remote_user_authorization: true + - login_id: ansible_local_user_3 + user_password: ansibleLocalUser3Password1%Test + security_domains: + - name: all + state: overridden + register: nm_override_local_users + +- name: Asserts for local users update tasks + ansible.builtin.assert: + that: + - cm_replace_local_user is changed + - cm_replace_local_user.after | length == 3 + - cm_replace_local_user.after.0.login_id == "ansible_local_user_2" + - cm_replace_local_user.after.0.security_domains | length == 1 + - cm_replace_local_user.after.0.security_domains.0.name == "all" + - cm_replace_local_user.after.1.email == "updatedansibleuser@example.com"" + - cm_replace_local_user.after.1.first_name == "Updated Ansible first name" + - cm_replace_local_user.after.1.last_name == "Updated Ansible last name" + - cm_replace_local_user.after.1.login_id == "ansible_local_user" + - cm_replace_local_user.after.1.remote_id_claim == "" + - cm_replace_local_user.after.1.remote_user_authorization == false + - cm_replace_local_user.after.1.reuse_limitation == 25 + - cm_replace_local_user.after.1.security_domains | length == 1 + - cm_replace_local_user.after.1.security_domains.0.name == "all" + - cm_replace_local_user.after.1.security_domains.0.roles | length == 1 + - cm_replace_local_user.after.1.security_domains.0.roles.0 == "super_admin" + - cm_replace_local_user.after.1.time_interval_limitation == 15 + - cm_replace_local_user.after.2.login_id == "admin" + - cm_replace_local_user.after.2.first_name == "admin" + - cm_replace_local_user.after.2.remote_user_authorization == false + - cm_replace_local_user.after.2.reuse_limitation == 0 + - cm_replace_local_user.after.2.security_domains | length == 1 + - cm_replace_local_user.after.2.security_domains.0.name == "all" + - cm_replace_local_user.after.2.security_domains.0.roles | length == 1 + - cm_replace_local_user.after.2.security_domains.0.roles.0 == "super_admin" + - cm_replace_local_user.after.2.time_interval_limitation == 0 + - cm_replace_local_user.before | length == 3 + - cm_replace_local_user.before.2.first_name == "admin" + - cm_replace_local_user.before.2.remote_user_authorization == false + - cm_replace_local_user.before.2.reuse_limitation == 0 + - cm_replace_local_user.before.2.security_domains | length == 1 + - cm_replace_local_user.before.2.security_domains.0.name == "all" + - cm_replace_local_user.before.2.security_domains.0.roles | length == 1 + - cm_replace_local_user.before.2.security_domains.0.roles.0 == "super_admin" + - cm_replace_local_user.before.2.time_interval_limitation == 0 + - cm_replace_local_user.before.1.email == "ansibleuser@example.com" + - cm_replace_local_user.before.1.first_name == "Ansible first name" + - cm_replace_local_user.before.1.last_name == "Ansible last name" + - cm_replace_local_user.before.1.login_id == "ansible_local_user" + - cm_replace_local_user.before.1.remote_id_claim == "ansible_remote_user" + - cm_replace_local_user.before.1.remote_user_authorization == true + - cm_replace_local_user.before.1.reuse_limitation == 20 + - cm_replace_local_user.before.1.security_domains | length == 1 + - cm_replace_local_user.before.1.security_domains.0.name == "all" + - cm_replace_local_user.before.1.security_domains.0.roles | length == 2 + - cm_replace_local_user.before.1.security_domains.0.roles.0 == "observer" + - cm_replace_local_user.before.1.security_domains.0.roles.1 == "support_engineer" + - cm_replace_local_user.before.1.time_interval_limitation == 10 + - cm_replace_local_user.before.0.login_id == "ansible_local_user_2" + - cm_replace_local_user.before.0.security_domains | length == 1 + - cm_replace_local_user.before.0.security_domains.0.name == "all" + - cm_replace_local_user.diff == [] + - cm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com"" + - cm_replace_local_user.proposed.0.first_name == "Updated Ansible first name" + - cm_replace_local_user.proposed.0.last_name == "Updated Ansible last name" + - cm_replace_local_user.proposed.0.login_id == "ansible_local_user" + - cm_replace_local_user.proposed.0.remote_id_claim == "" + - cm_replace_local_user.proposed.0.remote_user_authorization == false + - cm_replace_local_user.proposed.0.reuse_limitation == 25 + - cm_replace_local_user.proposed.0.security_domains | length == 1 + - cm_replace_local_user.proposed.0.security_domains.0.name == "all" + - cm_replace_local_user.proposed.0.security_domains.0.roles | length == 1 + - cm_replace_local_user.proposed.0.security_domains.0.roles.0 == "super_admin" + - cm_replace_local_user.proposed.0.time_interval_limitation == 15 + - nm_replace_local_user is changed + - nm_replace_local_user.after | length == 3 + - nm_replace_local_user.after.0.login_id == "ansible_local_user_2" + - nm_replace_local_user.after.0.security_domains | length == 1 + - nm_replace_local_user.after.0.security_domains.0.name == "all" + - nm_replace_local_user.after.1.email == "updatedansibleuser@example.com"" + - nm_replace_local_user.after.1.first_name == "Updated Ansible first name" + - nm_replace_local_user.after.1.last_name == "Updated Ansible last name" + - nm_replace_local_user.after.1.login_id == "ansible_local_user" + - nm_replace_local_user.after.1.remote_id_claim == "" + - nm_replace_local_user.after.1.remote_user_authorization == false + - nm_replace_local_user.after.1.reuse_limitation == 25 + - nm_replace_local_user.after.1.security_domains | length == 1 + - nm_replace_local_user.after.1.security_domains.0.name == "all" + - nm_replace_local_user.after.1.security_domains.0.roles | length == 1 + - nm_replace_local_user.after.1.security_domains.0.roles.0 == "super_admin" + - nm_replace_local_user.after.1.time_interval_limitation == 15 + - nm_replace_local_user.after.2.login_id == "admin" + - nm_replace_local_user.after.2.first_name == "admin" + - nm_replace_local_user.after.2.remote_user_authorization == false + - nm_replace_local_user.after.2.reuse_limitation == 0 + - nm_replace_local_user.after.2.security_domains | length == 1 + - nm_replace_local_user.after.2.security_domains.0.name == "all" + - nm_replace_local_user.after.2.security_domains.0.roles | length == 1 + - nm_replace_local_user.after.2.security_domains.0.roles.0 == "super_admin" + - nm_replace_local_user.after.2.time_interval_limitation == 0 + - nm_replace_local_user.before | length == 3 + - nm_replace_local_user.before.2.first_name == "admin" + - nm_replace_local_user.before.2.remote_user_authorization == false + - nm_replace_local_user.before.2.reuse_limitation == 0 + - nm_replace_local_user.before.2.security_domains | length == 1 + - nm_replace_local_user.before.2.security_domains.0.name == "all" + - nm_replace_local_user.before.2.security_domains.0.roles | length == 1 + - nm_replace_local_user.before.2.security_domains.0.roles.0 == "super_admin" + - nm_replace_local_user.before.2.time_interval_limitation == 0 + - nm_replace_local_user.before.1.email == "ansibleuser@example.com" + - nm_replace_local_user.before.1.first_name == "Ansible first name" + - nm_replace_local_user.before.1.last_name == "Ansible last name" + - nm_replace_local_user.before.1.login_id == "ansible_local_user" + - nm_replace_local_user.before.1.remote_id_claim == "ansible_remote_user" + - nm_replace_local_user.before.1.remote_user_authorization == true + - nm_replace_local_user.before.1.reuse_limitation == 20 + - nm_replace_local_user.before.1.security_domains | length == 1 + - nm_replace_local_user.before.1.security_domains.0.name == "all" + - nm_replace_local_user.before.1.security_domains.0.roles | length == 2 + - nm_replace_local_user.before.1.security_domains.0.roles.0 == "observer" + - nm_replace_local_user.before.1.security_domains.0.roles.1 == "support_engineer" + - nm_replace_local_user.before.1.time_interval_limitation == 10 + - nm_replace_local_user.before.0.login_id == "ansible_local_user_2" + - nm_replace_local_user.before.0.security_domains | length == 1 + - nm_replace_local_user.before.0.security_domains.0.name == "all" + - nm_replace_local_user.diff == [] + - nm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com"" + - nm_replace_local_user.proposed.0.first_name == "Updated Ansible first name" + - nm_replace_local_user.proposed.0.last_name == "Updated Ansible last name" + - nm_replace_local_user.proposed.0.login_id == "ansible_local_user" + - nm_replace_local_user.proposed.0.remote_id_claim == "" + - nm_replace_local_user.proposed.0.remote_user_authorization == false + - nm_replace_local_user.proposed.0.reuse_limitation == 25 + - nm_replace_local_user.proposed.0.security_domains | length == 1 + - nm_replace_local_user.proposed.0.security_domains.0.name == "all" + - nm_replace_local_user.proposed.0.security_domains.0.roles | length == 1 + - nm_replace_local_user.proposed.0.security_domains.0.roles.0 == "super_admin" + - nm_replace_local_user.proposed.0.time_interval_limitation == 15 # DELETE @@ -123,6 +399,9 @@ <<: *delete_local_user register: nm_delete_local_user_again +- name: Asserts for local users deletion tasks + ansible.builtin.assert: + that: # CLEAN UP - name: Ensure local users do not exist @@ -131,4 +410,5 @@ config: - login_id: ansible_local_user - login_id: ansible_local_user_2 + - login_id: ansible_local_user_3 state: deleted From 401deedce51f5284617b534b9d3ffc8031989d8e Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 12 Mar 2026 11:45:29 -0400 Subject: [PATCH 33/51] [ignore] Finish integration test file for nd_local_user module. Remove Generic Class inheritence from NDConfigCollection. Clean Pydantic imports. --- plugins/module_utils/models/base.py | 2 +- plugins/module_utils/nd_config_collection.py | 33 +-- plugins/module_utils/nd_output.py | 2 +- plugins/module_utils/nd_state_machine.py | 9 +- plugins/module_utils/utils.py | 2 +- plugins/modules/nd_local_user.py | 2 +- .../targets/nd_local_user/tasks/main.yml | 267 +++++++++++++++++- 7 files changed, 275 insertions(+), 42 deletions(-) diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 79f9ec80..21fb983e 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -9,7 +9,7 @@ __metaclass__ = type from abc import ABC -from pydantic import BaseModel, ConfigDict +from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict from typing import List, Dict, Any, ClassVar, Set, Tuple, Union, Literal, Optional from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 364b8a8f..d34ca462 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -11,33 +11,30 @@ from typing import TypeVar, Generic, Optional, List, Dict, Any, Literal from copy import deepcopy from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey -# Type aliases -ModelType = TypeVar("ModelType", bound=NDBaseModel) -class NDConfigCollection(Generic[ModelType]): +class NDConfigCollection: """ Nexus Dashboard configuration collection for NDBaseModel instances. """ - def __init__(self, model_class: ModelType, items: Optional[List[ModelType]] = None): + def __init__(self, model_class: NDBaseModel, items: Optional[List[NDBaseModel]] = None): """ Initialize collection. """ - self._model_class: ModelType = model_class + self._model_class: NDBaseModel = model_class # Dual storage - self._items: List[ModelType] = [] + self._items: List[NDBaseModel] = [] self._index: Dict[IdentifierKey, int] = {} if items: for item in items: self.add(item) - def _extract_key(self, item: ModelType) -> IdentifierKey: + def _extract_key(self, item: NDBaseModel) -> IdentifierKey: """ Extract identifier key from item. """ @@ -56,7 +53,7 @@ def _rebuild_index(self) -> None: # Core Operations - def add(self, item: ModelType) -> IdentifierKey: + def add(self, item: NDBaseModel) -> IdentifierKey: """ Add item to collection (O(1) operation). """ @@ -74,14 +71,14 @@ def add(self, item: ModelType) -> IdentifierKey: return key - def get(self, key: IdentifierKey) -> Optional[ModelType]: + def get(self, key: IdentifierKey) -> Optional[NDBaseModel]: """ Get item by identifier key (O(1) operation). """ index = self._index.get(key) return self._items[index] if index is not None else None - def replace(self, item: ModelType) -> bool: + def replace(self, item: NDBaseModel) -> bool: """ Replace existing item with same identifier (O(1) operation). """ @@ -97,7 +94,7 @@ def replace(self, item: ModelType) -> bool: self._items[index] = item return True - def merge(self, item: ModelType) -> ModelType: + def merge(self, item: NDBaseModel) -> NDBaseModel: """ Merge item with existing, or add if not present. """ @@ -129,7 +126,7 @@ def delete(self, key: IdentifierKey) -> bool: # Diff Operations # NOTE: Maybe add a similar one in the NDBaseModel (-> but is it necessary?) - def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "changed"]: + def get_diff_config(self, new_item: NDBaseModel) -> Literal["new", "no_diff", "changed"]: """ Compare single item against collection. """ @@ -147,7 +144,7 @@ def get_diff_config(self, new_item: ModelType) -> Literal["new", "no_diff", "cha return "no_diff" if is_subset else "changed" - def get_diff_collection(self, other: "NDConfigCollection[ModelType]") -> bool: + def get_diff_collection(self, other: "NDConfigCollection") -> bool: """ Check if two collections differ. """ @@ -167,7 +164,7 @@ def get_diff_collection(self, other: "NDConfigCollection[ModelType]") -> bool: return False - def get_diff_identifiers(self, other: "NDConfigCollection[ModelType]") -> List[IdentifierKey]: + def get_diff_identifiers(self, other: "NDConfigCollection") -> List[IdentifierKey]: """ Get identifiers in self but not in other. """ @@ -189,7 +186,7 @@ def keys(self) -> List[IdentifierKey]: """Get all identifier keys.""" return list(self._index.keys()) - def copy(self) -> "NDConfigCollection[ModelType]": + def copy(self) -> "NDConfigCollection": """Create deep copy of collection.""" return NDConfigCollection(model_class=self._model_class, items=deepcopy(self._items)) @@ -208,7 +205,7 @@ def to_payload_list(self, **kwargs) -> List[Dict[str, Any]]: return [item.to_payload(**kwargs) for item in self._items] @classmethod - def from_ansible_config(cls, data: List[Dict], model_class: type[ModelType], **kwargs) -> "NDConfigCollection[ModelType]": + def from_ansible_config(cls, data: List[Dict], model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection": """ Create collection from Ansible config. """ @@ -216,7 +213,7 @@ def from_ansible_config(cls, data: List[Dict], model_class: type[ModelType], **k return cls(model_class=model_class, items=items) @classmethod - def from_api_response(cls, response_data: List[Dict[str, Any]], model_class: type[ModelType], **kwargs) -> "NDConfigCollection[ModelType]": + def from_api_response(cls, response_data: List[Dict[str, Any]], model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection": """ Create collection from API response. """ diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py index dbfc2cd2..0e5ed6ef 100644 --- a/plugins/module_utils/nd_output.py +++ b/plugins/module_utils/nd_output.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index bd86da3c..3b6c891c 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -41,17 +41,16 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest self.state = self.module.params["state"] # Initialize collections - self.nd_config_collection = NDConfigCollection[self.model_class] try: response_data = self.model_orchestrator.query_all() # State of configuration objects in ND before change execution - self.before = self.nd_config_collection.from_api_response(response_data=response_data, model_class=self.model_class) + self.before = NDConfigCollection.from_api_response(response_data=response_data, model_class=self.model_class) # State of current configuration objects in ND during change execution self.existing = self.before.copy() # Ongoing collection of configuration objects that were changed - self.sent = self.nd_config_collection(model_class=self.model_class) + self.sent = NDConfigCollection(model_class=self.model_class) # Collection of configuration objects given by user - self.proposed = self.nd_config_collection(model_class=self.model_class) + self.proposed = NDConfigCollection(model_class=self.model_class) for config in self.module.params.get("config", []): try: # Parse config into model diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index e09bd499..76e936bb 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index d1d871fe..56e59ad5 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml index de8ad5ed..b7f205ae 100644 --- a/tests/integration/targets/nd_local_user/tasks/main.yml +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -1,5 +1,5 @@ # Test code for the ND modules -# Copyright: (c) 2025, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -19,6 +19,7 @@ config: - login_id: ansible_local_user - login_id: ansible_local_user_2 + - login_id: ansible_local_user_3 state: deleted # CREATE @@ -217,19 +218,10 @@ cisco.nd.nd_local_user: <<: *nd_info config: - - login_id: admin - first_name: admin - remote_user_authorization: false - reuse_limitation: 0 - time_interval_limitation: 0 - security_domains: - - name: all - roles: - - super_admin - email: overrideansibleuser@example.com login_id: ansible_local_user first_name: Overridden Ansible first name - last_name: Overriden Ansible last name + last_name: Overridden Ansible last name user_password: overideansibleLocalUserPassword1% reuse_limitation: 15 time_interval_limitation: 5 @@ -239,6 +231,15 @@ - observer remote_id_claim: ansible_remote_user remote_user_authorization: true + - login_id: admin + first_name: admin + remote_user_authorization: false + reuse_limitation: 0 + time_interval_limitation: 0 + security_domains: + - name: all + roles: + - super_admin - login_id: ansible_local_user_3 user_password: ansibleLocalUser3Password1%Test security_domains: @@ -254,7 +255,7 @@ - cm_replace_local_user.after.0.login_id == "ansible_local_user_2" - cm_replace_local_user.after.0.security_domains | length == 1 - cm_replace_local_user.after.0.security_domains.0.name == "all" - - cm_replace_local_user.after.1.email == "updatedansibleuser@example.com"" + - cm_replace_local_user.after.1.email == "updatedansibleuser@example.com" - cm_replace_local_user.after.1.first_name == "Updated Ansible first name" - cm_replace_local_user.after.1.last_name == "Updated Ansible last name" - cm_replace_local_user.after.1.login_id == "ansible_local_user" @@ -301,7 +302,7 @@ - cm_replace_local_user.before.0.security_domains | length == 1 - cm_replace_local_user.before.0.security_domains.0.name == "all" - cm_replace_local_user.diff == [] - - cm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com"" + - cm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com" - cm_replace_local_user.proposed.0.first_name == "Updated Ansible first name" - cm_replace_local_user.proposed.0.last_name == "Updated Ansible last name" - cm_replace_local_user.proposed.0.login_id == "ansible_local_user" @@ -318,7 +319,7 @@ - nm_replace_local_user.after.0.login_id == "ansible_local_user_2" - nm_replace_local_user.after.0.security_domains | length == 1 - nm_replace_local_user.after.0.security_domains.0.name == "all" - - nm_replace_local_user.after.1.email == "updatedansibleuser@example.com"" + - nm_replace_local_user.after.1.email == "updatedansibleuser@example.com" - nm_replace_local_user.after.1.first_name == "Updated Ansible first name" - nm_replace_local_user.after.1.last_name == "Updated Ansible last name" - nm_replace_local_user.after.1.login_id == "ansible_local_user" @@ -365,7 +366,7 @@ - nm_replace_local_user.before.0.security_domains | length == 1 - nm_replace_local_user.before.0.security_domains.0.name == "all" - nm_replace_local_user.diff == [] - - nm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com"" + - nm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com" - nm_replace_local_user.proposed.0.first_name == "Updated Ansible first name" - nm_replace_local_user.proposed.0.last_name == "Updated Ansible last name" - nm_replace_local_user.proposed.0.login_id == "ansible_local_user" @@ -377,6 +378,161 @@ - nm_replace_local_user.proposed.0.security_domains.0.roles | length == 1 - nm_replace_local_user.proposed.0.security_domains.0.roles.0 == "super_admin" - nm_replace_local_user.proposed.0.time_interval_limitation == 15 + - nm_merge_local_user_2 is changed + - nm_merge_local_user_2.after | length == 3 + - nm_merge_local_user_2.after.0.email == "secondansibleuser@example.com" + - nm_merge_local_user_2.after.0.first_name == "Second Ansible first name" + - nm_merge_local_user_2.after.0.last_name == "Second Ansible last name" + - nm_merge_local_user_2.after.0.login_id == "ansible_local_user_2" + - nm_merge_local_user_2.after.0.remote_id_claim == "ansible_remote_user_2" + - nm_merge_local_user_2.after.0.remote_user_authorization == true + - nm_merge_local_user_2.after.0.reuse_limitation == 20 + - nm_merge_local_user_2.after.0.security_domains | length == 1 + - nm_merge_local_user_2.after.0.security_domains.0.name == "all" + - nm_merge_local_user_2.after.0.security_domains.0.roles | length == 1 + - nm_merge_local_user_2.after.0.security_domains.0.roles.0 == "fabric_admin" + - nm_merge_local_user_2.after.0.time_interval_limitation == 10 + - nm_merge_local_user_2.after.1.email == "updatedansibleuser@example.com" + - nm_merge_local_user_2.after.1.first_name == "Updated Ansible first name" + - nm_merge_local_user_2.after.1.last_name == "Updated Ansible last name" + - nm_merge_local_user_2.after.1.login_id == "ansible_local_user" + - nm_merge_local_user_2.after.1.remote_user_authorization == false + - nm_merge_local_user_2.after.1.reuse_limitation == 25 + - nm_merge_local_user_2.after.1.security_domains | length == 1 + - nm_merge_local_user_2.after.1.security_domains.0.name == "all" + - nm_merge_local_user_2.after.1.security_domains.0.roles | length == 1 + - nm_merge_local_user_2.after.1.security_domains.0.roles.0 == "super_admin" + - nm_merge_local_user_2.after.1.time_interval_limitation == 15 + - nm_merge_local_user_2.after.2.login_id == "admin" + - nm_merge_local_user_2.after.2.first_name == "admin" + - nm_merge_local_user_2.after.2.remote_user_authorization == false + - nm_merge_local_user_2.after.2.reuse_limitation == 0 + - nm_merge_local_user_2.after.2.security_domains | length == 1 + - nm_merge_local_user_2.after.2.security_domains.0.name == "all" + - nm_merge_local_user_2.after.2.security_domains.0.roles | length == 1 + - nm_merge_local_user_2.after.2.security_domains.0.roles.0 == "super_admin" + - nm_merge_local_user_2.after.2.time_interval_limitation == 0 + - nm_merge_local_user_2.before | length == 3 + - nm_merge_local_user_2.before.2.first_name == "admin" + - nm_merge_local_user_2.before.2.remote_user_authorization == false + - nm_merge_local_user_2.before.2.reuse_limitation == 0 + - nm_merge_local_user_2.before.2.security_domains | length == 1 + - nm_merge_local_user_2.before.2.security_domains.0.name == "all" + - nm_merge_local_user_2.before.2.security_domains.0.roles | length == 1 + - nm_merge_local_user_2.before.2.security_domains.0.roles.0 == "super_admin" + - nm_merge_local_user_2.before.2.time_interval_limitation == 0 + - nm_merge_local_user_2.before.1.email == "updatedansibleuser@example.com" + - nm_merge_local_user_2.before.1.first_name == "Updated Ansible first name" + - nm_merge_local_user_2.before.1.last_name == "Updated Ansible last name" + - nm_merge_local_user_2.before.1.login_id == "ansible_local_user" + - nm_merge_local_user_2.before.1.remote_user_authorization == false + - nm_merge_local_user_2.before.1.reuse_limitation == 25 + - nm_merge_local_user_2.before.1.security_domains | length == 1 + - nm_merge_local_user_2.before.1.security_domains.0.name == "all" + - nm_merge_local_user_2.before.1.security_domains.0.roles | length == 1 + - nm_merge_local_user_2.before.1.security_domains.0.roles.0 == "super_admin" + - nm_merge_local_user_2.before.1.time_interval_limitation == 15 + - nm_merge_local_user_2.before.0.login_id == "ansible_local_user_2" + - nm_merge_local_user_2.before.0.security_domains | length == 1 + - nm_merge_local_user_2.before.0.security_domains.0.name == "all" + - nm_merge_local_user_2.diff == [] + - nm_merge_local_user_2.proposed.0.email == "secondansibleuser@example.com" + - nm_merge_local_user_2.proposed.0.first_name == "Second Ansible first name" + - nm_merge_local_user_2.proposed.0.last_name == "Second Ansible last name" + - nm_merge_local_user_2.proposed.0.login_id == "ansible_local_user_2" + - nm_merge_local_user_2.proposed.0.remote_id_claim == "ansible_remote_user_2" + - nm_merge_local_user_2.proposed.0.remote_user_authorization == true + - nm_merge_local_user_2.proposed.0.reuse_limitation == 20 + - nm_merge_local_user_2.proposed.0.security_domains | length == 1 + - nm_merge_local_user_2.proposed.0.security_domains.0.name == "all" + - nm_merge_local_user_2.proposed.0.security_domains.0.roles | length == 1 + - nm_merge_local_user_2.proposed.0.security_domains.0.roles.0 == "fabric_admin" + - nm_merge_local_user_2.proposed.0.time_interval_limitation == 10 + - nm_merge_local_user_2_again is not changed + - nm_merge_local_user_2_again.after == nm_merge_local_user_2.after + - nm_merge_local_user_2_again.diff == [] + - nm_merge_local_user_2_again.proposed == nm_merge_local_user_2.proposed + - nm_override_local_users is changed + - nm_override_local_users.after | length == 3 + - nm_override_local_users.after.0.email == "overrideansibleuser@example.com" + - nm_override_local_users.after.0.first_name == "Overridden Ansible first name" + - nm_override_local_users.after.0.last_name == "Overridden Ansible last name" + - nm_override_local_users.after.0.login_id == "ansible_local_user" + - nm_override_local_users.after.0.remote_id_claim == "ansible_remote_user" + - nm_override_local_users.after.0.remote_user_authorization == true + - nm_override_local_users.after.0.reuse_limitation == 15 + - nm_override_local_users.after.0.security_domains | length == 1 + - nm_override_local_users.after.0.security_domains.0.name == "all" + - nm_override_local_users.after.0.security_domains.0.roles | length == 1 + - nm_override_local_users.after.0.security_domains.0.roles.0 == "observer" + - nm_override_local_users.after.0.time_interval_limitation == 5 + - nm_override_local_users.after.1.login_id == "admin" + - nm_override_local_users.after.1.first_name == "admin" + - nm_override_local_users.after.1.remote_user_authorization == false + - nm_override_local_users.after.1.reuse_limitation == 0 + - nm_override_local_users.after.1.security_domains | length == 1 + - nm_override_local_users.after.1.security_domains.0.name == "all" + - nm_override_local_users.after.1.security_domains.0.roles | length == 1 + - nm_override_local_users.after.1.security_domains.0.roles.0 == "super_admin" + - nm_override_local_users.after.1.time_interval_limitation == 0 + - nm_override_local_users.after.2.login_id == "ansible_local_user_3" + - nm_override_local_users.after.2.security_domains.0.name == "all" + - nm_override_local_users.before | length == 3 + - nm_override_local_users.before.2.first_name == "admin" + - nm_override_local_users.before.2.remote_user_authorization == false + - nm_override_local_users.before.2.reuse_limitation == 0 + - nm_override_local_users.before.2.security_domains | length == 1 + - nm_override_local_users.before.2.security_domains.0.name == "all" + - nm_override_local_users.before.2.security_domains.0.roles | length == 1 + - nm_override_local_users.before.2.security_domains.0.roles.0 == "super_admin" + - nm_override_local_users.before.2.time_interval_limitation == 0 + - nm_override_local_users.before.1.email == "updatedansibleuser@example.com" + - nm_override_local_users.before.1.first_name == "Updated Ansible first name" + - nm_override_local_users.before.1.last_name == "Updated Ansible last name" + - nm_override_local_users.before.1.login_id == "ansible_local_user" + - nm_override_local_users.before.1.remote_user_authorization == false + - nm_override_local_users.before.1.reuse_limitation == 25 + - nm_override_local_users.before.1.security_domains | length == 1 + - nm_override_local_users.before.1.security_domains.0.name == "all" + - nm_override_local_users.before.1.security_domains.0.roles | length == 1 + - nm_override_local_users.before.1.security_domains.0.roles.0 == "super_admin" + - nm_override_local_users.before.1.time_interval_limitation == 15 + - nm_override_local_users.before.0.email == "secondansibleuser@example.com" + - nm_override_local_users.before.0.first_name == "Second Ansible first name" + - nm_override_local_users.before.0.last_name == "Second Ansible last name" + - nm_override_local_users.before.0.login_id == "ansible_local_user_2" + - nm_override_local_users.before.0.remote_id_claim == "ansible_remote_user_2" + - nm_override_local_users.before.0.remote_user_authorization == true + - nm_override_local_users.before.0.reuse_limitation == 20 + - nm_override_local_users.before.0.security_domains | length == 1 + - nm_override_local_users.before.0.security_domains.0.name == "all" + - nm_override_local_users.before.0.security_domains.0.roles | length == 1 + - nm_override_local_users.before.0.security_domains.0.roles.0 == "fabric_admin" + - nm_override_local_users.before.0.time_interval_limitation == 10 + - nm_override_local_users.diff == [] + - nm_override_local_users.proposed.0.email == "overrideansibleuser@example.com" + - nm_override_local_users.proposed.0.first_name == "Overridden Ansible first name" + - nm_override_local_users.proposed.0.last_name == "Overridden Ansible last name" + - nm_override_local_users.proposed.0.login_id == "ansible_local_user" + - nm_override_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - nm_override_local_users.proposed.0.remote_user_authorization == true + - nm_override_local_users.proposed.0.reuse_limitation == 15 + - nm_override_local_users.proposed.0.security_domains | length == 1 + - nm_override_local_users.proposed.0.security_domains.0.name == "all" + - nm_override_local_users.proposed.0.security_domains.0.roles | length == 1 + - nm_override_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - nm_override_local_users.proposed.0.time_interval_limitation == 5 + - nm_override_local_users.proposed.1.login_id == "admin" + - nm_override_local_users.proposed.1.first_name == "admin" + - nm_override_local_users.proposed.1.remote_user_authorization == false + - nm_override_local_users.proposed.1.reuse_limitation == 0 + - nm_override_local_users.proposed.1.security_domains | length == 1 + - nm_override_local_users.proposed.1.security_domains.0.name == "all" + - nm_override_local_users.proposed.1.security_domains.0.roles | length == 1 + - nm_override_local_users.proposed.1.security_domains.0.roles.0 == "super_admin" + - nm_override_local_users.proposed.1.time_interval_limitation == 0 + - nm_override_local_users.proposed.2.login_id == "ansible_local_user_3" + - nm_override_local_users.proposed.2.security_domains.0.name == "all" # DELETE @@ -402,6 +558,87 @@ - name: Asserts for local users deletion tasks ansible.builtin.assert: that: + - cm_delete_local_user is changed + - cm_delete_local_user.after | length == 2 + - cm_delete_local_user.after.0.login_id == "ansible_local_user_3" + - cm_delete_local_user.after.0.security_domains.0.name == "all" + - cm_delete_local_user.after.1.login_id == "admin" + - cm_delete_local_user.after.1.first_name == "admin" + - cm_delete_local_user.after.1.remote_user_authorization == false + - cm_delete_local_user.after.1.reuse_limitation == 0 + - cm_delete_local_user.after.1.security_domains | length == 1 + - cm_delete_local_user.after.1.security_domains.0.name == "all" + - cm_delete_local_user.after.1.security_domains.0.roles | length == 1 + - cm_delete_local_user.after.1.security_domains.0.roles.0 == "super_admin" + - cm_delete_local_user.after.1.time_interval_limitation == 0 + - cm_delete_local_user.before | length == 3 + - cm_delete_local_user.before.0.email == "overrideansibleuser@example.com" + - cm_delete_local_user.before.0.first_name == "Overridden Ansible first name" + - cm_delete_local_user.before.0.last_name == "Overridden Ansible last name" + - cm_delete_local_user.before.0.login_id == "ansible_local_user" + - cm_delete_local_user.before.0.remote_id_claim == "ansible_remote_user" + - cm_delete_local_user.before.0.remote_user_authorization == true + - cm_delete_local_user.before.0.reuse_limitation == 15 + - cm_delete_local_user.before.0.security_domains | length == 1 + - cm_delete_local_user.before.0.security_domains.0.name == "all" + - cm_delete_local_user.before.0.security_domains.0.roles | length == 1 + - cm_delete_local_user.before.0.security_domains.0.roles.0 == "observer" + - cm_delete_local_user.before.0.time_interval_limitation == 5 + - cm_delete_local_user.before.1.login_id == "ansible_local_user_3" + - cm_delete_local_user.before.1.security_domains.0.name == "all" + - cm_delete_local_user.before.2.first_name == "admin" + - cm_delete_local_user.before.2.remote_user_authorization == false + - cm_delete_local_user.before.2.reuse_limitation == 0 + - cm_delete_local_user.before.2.security_domains | length == 1 + - cm_delete_local_user.before.2.security_domains.0.name == "all" + - cm_delete_local_user.before.2.security_domains.0.roles | length == 1 + - cm_delete_local_user.before.2.security_domains.0.roles.0 == "super_admin" + - cm_delete_local_user.before.2.time_interval_limitation == 0 + - cm_delete_local_user.diff == [] + - cm_delete_local_user.proposed.0.login_id == "ansible_local_user" + - nm_delete_local_user is changed + - nm_delete_local_user.after | length == 2 + - nm_delete_local_user.after.0.login_id == "ansible_local_user_3" + - nm_delete_local_user.after.0.security_domains.0.name == "all" + - nm_delete_local_user.after.1.login_id == "admin" + - nm_delete_local_user.after.1.first_name == "admin" + - nm_delete_local_user.after.1.remote_user_authorization == false + - nm_delete_local_user.after.1.reuse_limitation == 0 + - nm_delete_local_user.after.1.security_domains | length == 1 + - nm_delete_local_user.after.1.security_domains.0.name == "all" + - nm_delete_local_user.after.1.security_domains.0.roles | length == 1 + - nm_delete_local_user.after.1.security_domains.0.roles.0 == "super_admin" + - nm_delete_local_user.after.1.time_interval_limitation == 0 + - nm_delete_local_user.before | length == 3 + - nm_delete_local_user.before.0.email == "overrideansibleuser@example.com" + - nm_delete_local_user.before.0.first_name == "Overridden Ansible first name" + - nm_delete_local_user.before.0.last_name == "Overridden Ansible last name" + - nm_delete_local_user.before.0.login_id == "ansible_local_user" + - nm_delete_local_user.before.0.remote_id_claim == "ansible_remote_user" + - nm_delete_local_user.before.0.remote_user_authorization == true + - nm_delete_local_user.before.0.reuse_limitation == 15 + - nm_delete_local_user.before.0.security_domains | length == 1 + - nm_delete_local_user.before.0.security_domains.0.name == "all" + - nm_delete_local_user.before.0.security_domains.0.roles | length == 1 + - nm_delete_local_user.before.0.security_domains.0.roles.0 == "observer" + - nm_delete_local_user.before.0.time_interval_limitation == 5 + - nm_delete_local_user.before.1.login_id == "ansible_local_user_3" + - nm_delete_local_user.before.1.security_domains.0.name == "all" + - nm_delete_local_user.before.2.first_name == "admin" + - nm_delete_local_user.before.2.remote_user_authorization == false + - nm_delete_local_user.before.2.reuse_limitation == 0 + - nm_delete_local_user.before.2.security_domains | length == 1 + - nm_delete_local_user.before.2.security_domains.0.name == "all" + - nm_delete_local_user.before.2.security_domains.0.roles | length == 1 + - nm_delete_local_user.before.2.security_domains.0.roles.0 == "super_admin" + - nm_delete_local_user.before.2.time_interval_limitation == 0 + - nm_delete_local_user.diff == [] + - nm_delete_local_user.proposed.0.login_id == "ansible_local_user" + - nm_delete_local_user_again is not changed + - nm_delete_local_user_again.after == nm_delete_local_user.after + - nm_delete_local_user_again.before == nm_delete_local_user.after + - nm_delete_local_user_again.diff == [] + - nm_delete_local_user_again.proposed == nm_delete_local_user.proposed # CLEAN UP - name: Ensure local users do not exist From a325955615dbcf6e11a6eb9e2e3ee63d0ebfa4c5 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 12 Mar 2026 12:33:23 -0400 Subject: [PATCH 34/51] [ignore] Fix sanity issues by enhancing pydantic_compat.py. Fix Black formatting. --- plugins/module_utils/models/local_user.py | 1 - plugins/module_utils/nd_config_collection.py | 3 +-- plugins/module_utils/pydantic_compat.py | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 0320d3c1..38f2b5d2 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -23,7 +23,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel from ansible_collections.cisco.nd.plugins.module_utils.constants import NDConstantMapping - USER_ROLES_MAPPING = NDConstantMapping( { "fabric_admin": "fabric-admin", diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index d34ca462..fa574ca2 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -8,13 +8,12 @@ __metaclass__ = type -from typing import TypeVar, Generic, Optional, List, Dict, Any, Literal +from typing import Optional, List, Dict, Any, Literal from copy import deepcopy from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey - class NDConfigCollection: """ Nexus Dashboard configuration collection for NDBaseModel instances. diff --git a/plugins/module_utils/pydantic_compat.py b/plugins/module_utils/pydantic_compat.py index 4456018a..2596d852 100644 --- a/plugins/module_utils/pydantic_compat.py +++ b/plugins/module_utils/pydantic_compat.py @@ -192,6 +192,20 @@ def decorator(func): return decorator + # Fallback: FieldSerializationInfo placeholder class that does nothing + class FieldSerializationInfo: + """Pydantic FieldSerializationInfo fallback when pydantic is not available.""" + + def __init__(self, **kwargs): + pass + + # Fallback: SerializationInfo placeholder class that does nothing + class SerializationInfo: + """Pydantic SerializationInfo fallback when pydantic is not available.""" + + def __init__(self, **kwargs): + pass + else: HAS_PYDANTIC = True # pylint: disable=invalid-name PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name From ea067693ae4078b77859ba9447ee24b547dd08b3 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 12 Mar 2026 12:48:36 -0400 Subject: [PATCH 35/51] [ignore] Remove all TODO comments. --- plugins/module_utils/endpoints/base.py | 4 +--- plugins/module_utils/endpoints/v1/infra_aaa_local_users.py | 1 - plugins/module_utils/nd_config_collection.py | 1 - plugins/module_utils/nd_state_machine.py | 2 -- plugins/module_utils/orchestrators/base.py | 2 -- plugins/module_utils/utils.py | 1 - plugins/modules/nd_local_user.py | 3 --- 7 files changed, 1 insertion(+), 13 deletions(-) diff --git a/plugins/module_utils/endpoints/base.py b/plugins/module_utils/endpoints/base.py index 2d214878..e5eb8c72 100644 --- a/plugins/module_utils/endpoints/base.py +++ b/plugins/module_utils/endpoints/base.py @@ -131,9 +131,7 @@ def verb(self) -> HttpVerbEnum: None """ - - # TODO: Maybe to be modifed to be more Pydantic (low priority) - # TODO: Maybe change function's name (low priority) + # NOTE: function to set endpoints attribute fields from identifiers -> acts as the bridge between Models and Endpoints for API Request Orchestration @abstractmethod def set_identifiers(self, identifier: IdentifierKey = None): diff --git a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py index d1013e24..9235afb6 100644 --- a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py +++ b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py @@ -31,7 +31,6 @@ class _V1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseEndpoint): /api/v1/infra/aaa/localUsers endpoint. """ - # TODO: Remove it base_path: Final = NDBasePath.nd_infra_aaa("localUsers") @property diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index fa574ca2..abcfc0f7 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -42,7 +42,6 @@ def _extract_key(self, item: NDBaseModel) -> IdentifierKey: except Exception as e: raise ValueError(f"Failed to extract identifier: {e}") from e - # TODO: optimize it -> only needed for delete method (low priority) def _rebuild_index(self) -> None: """Rebuild index from scratch (O(n) operation).""" self._index.clear() diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 3b6c891c..3840b360 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -27,7 +27,6 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest """ Initialize the Network Resource Module. """ - # TODO: Revisit Module initialization and configuration with rest_send self.module = module self.nd_module = NDModule(self.module) @@ -37,7 +36,6 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest # Configuration self.model_orchestrator = model_orchestrator(sender=self.nd_module) self.model_class = self.model_orchestrator.model_class - # TODO: Revisit these class variables when udpating Module intialization and configuration (low priority) self.state = self.module.params["state"] # Initialize collections diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 1a8b4f10..ddcb7569 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -34,11 +34,9 @@ class NDBaseOrchestrator(BaseModel): query_all_endpoint: Type[NDBaseEndpoint] # NOTE: Module Field is always required - # TODO: Replace it with future sender (low priority) sender: NDModule # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") - # TODO: Explore new ways to make them even more general -> e.g., create a general API operation function (low priority) def create(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: try: api_endpoint = self.create_endpoint() diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 76e936bb..2e62c6eb 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -56,7 +56,6 @@ def issubset(subset: Any, superset: Any) -> bool: return True -# TODO: Might not necessary with Pydantic validation and serialization built-in methods (see models/local_user) def remove_unwanted_keys(data: Dict, unwanted_keys: List[Union[str, List[str]]]) -> Dict: """Remove unwanted keys from dict (supports nested paths).""" data = deepcopy(data) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 56e59ad5..f5efea03 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -199,9 +199,6 @@ def main(): ) # Manage state - # TODO: return module output class object: - # output = nd_state_machine.manage_state() - # module.exit_json(**output) nd_state_machine.manage_state() module.exit_json(**nd_state_machine.output.format()) From 47af5f4be6f886dcb855c7ef6028423f1f4ecb4f Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 12 Mar 2026 13:47:23 -0400 Subject: [PATCH 36/51] [ignore] Update endpoints to match latest nd42_integration branch. Update orchestrators accordingly. --- .../endpoints/v1/infra/aaa_local_users.py | 209 +++++++++ .../endpoints/v1/infra_aaa_local_users.py | 178 ------- plugins/module_utils/orchestrators/base.py | 14 +- .../module_utils/orchestrators/local_user.py | 24 +- ..._endpoints_api_v1_infra_aaa_local_users.py | 437 ++++++++++++++++++ 5 files changed, 665 insertions(+), 197 deletions(-) create mode 100644 plugins/module_utils/endpoints/v1/infra/aaa_local_users.py delete mode 100644 plugins/module_utils/endpoints/v1/infra_aaa_local_users.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa_local_users.py diff --git a/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py new file mode 100644 index 00000000..925c5548 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py @@ -0,0 +1,209 @@ +# Copyright: (c) 2026, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra AAA Local Users endpoint models. + +This module contains endpoint definitions for AAA Local Users operations in the ND Infra API. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import LoginIdMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.base_path import BasePath + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class _EpInfraAaaLocalUsersBase(LoginIdMixin, NDEndpointBaseModel): + """ + Base class for ND Infra AAA Local Users endpoints. + + Provides common functionality for all HTTP methods on the /api/v1/infra/aaa/localUsers endpoint. + """ + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Returns + + - Complete endpoint path string, optionally including login_id + """ + if self.login_id is not None: + return BasePath.path("aaa", "localUsers", self.login_id) + return BasePath.path("aaa", "localUsers") + + def set_identifiers(self, identifier: IdentifierKey = None): + self.login_id = identifier + + +class EpInfraAaaLocalUsersGet(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users GET Endpoint + + ## Description + + Endpoint to retrieve local users from the ND Infra AAA service. + Optionally retrieve a specific local user by login_id. + + ## Path + + - /api/v1/infra/aaa/localUsers + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - GET + + ## Usage + + ```python + # Get all local users + request = EpApiV1InfraAaaLocalUsersGet() + path = request.path + verb = request.verb + + # Get specific local user + request = EpApiV1InfraAaaLocalUsersGet() + request.login_id = "admin" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersGet"] = Field(default="EpInfraAaaLocalUsersGet", frozen=True, description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpInfraAaaLocalUsersPost(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users POST Endpoint + + ## Description + + Endpoint to create a local user in the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers + + ## Verb + + - POST + + ## Usage + + ```python + request = EpApiV1InfraAaaLocalUsersPost() + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersPost"] = Field( + default="EpInfraAaaLocalUsersPost", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpInfraAaaLocalUsersPut(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users PUT Endpoint + + ## Description + + Endpoint to update a local user in the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpApiV1InfraAaaLocalUsersPut() + request.login_id = "admin" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersPut"] = Field(default="EpInfraAaaLocalUsersPut", frozen=True, description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.PUT + + +class EpInfraAaaLocalUsersDelete(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users DELETE Endpoint + + ## Description + + Endpoint to delete a local user from the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - DELETE + + ## Usage + + ```python + request = EpApiV1InfraAaaLocalUsersDelete() + request.login_id = "admin" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersDelete"] = Field( + default="EpInfraAaaLocalUsersDelete", frozen=True, description="Class name for backward compatibility" + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.DELETE diff --git a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py deleted file mode 100644 index 9235afb6..00000000 --- a/plugins/module_utils/endpoints/v1/infra_aaa_local_users.py +++ /dev/null @@ -1,178 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@allenrobel) -# Copyright: (c) 2026, Gaspard Micol (@gmicol) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -ND Infra AAA LocalUsers endpoint models. - -This module contains endpoint definitions for LocalUsers-related operations -in the ND Infra AAA API. -""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -from typing import Literal, Final -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import LoginIdMixin -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.enums import VerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseEndpoint, NDBasePath -from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import Field -from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey - - -class _V1InfraAaaLocalUsersBase(LoginIdMixin, NDBaseEndpoint): - """ - Base class for ND Infra AAA Local Users endpoints. - - Provides common functionality for all HTTP methods on the - /api/v1/infra/aaa/localUsers endpoint. - """ - - base_path: Final = NDBasePath.nd_infra_aaa("localUsers") - - @property - def path(self) -> str: - """ - # Summary - - Build the endpoint path. - - ## Returns - - - Complete endpoint path string, optionally including login_id - """ - if self.login_id is not None: - return NDBasePath.nd_infra_aaa("localUsers", self.login_id) - return self.base_path - - def set_identifiers(self, identifier: IdentifierKey = None): - self.login_id = identifier - - -class V1InfraAaaLocalUsersGet(_V1InfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users GET Endpoint - - ## Description - - Endpoint to retrieve local users from the ND Infra AAA service. - Optionally retrieve a specific local user by login_id. - - ## Path - - - /api/v1/infra/aaa/localUsers - - /api/v1/infra/aaa/localUsers/{login_id} - - ## Verb - - - GET - """ - - class_name: Literal["V1InfraAaaLocalUsersGet"] = Field( - default="V1InfraAaaLocalUsersGet", - description="Class name for backward compatibility", - frozen=True, - ) - - @property - def verb(self) -> VerbEnum: - """Return the HTTP verb for this endpoint.""" - return VerbEnum.GET - - -class V1InfraAaaLocalUsersPost(_V1InfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users POST Endpoint - - ## Description - - Endpoint to create a local user in the ND Infra AAA service. - - ## Path - - - /api/v1/infra/aaa/localUsers - - ## Verb - - - POST - """ - - class_name: Literal["V1InfraAaaLocalUsersPost"] = Field( - default="V1InfraAaaLocalUsersPost", - description="Class name for backward compatibility", - frozen=True, - ) - - @property - def verb(self) -> VerbEnum: - """Return the HTTP verb for this endpoint.""" - return VerbEnum.POST - - -class V1InfraAaaLocalUsersPut(_V1InfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users PUT Endpoint - - ## Description - - Endpoint to update a local user in the ND Infra AAA service. - - ## Path - - - /api/v1/infra/aaa/localUsers/{login_id} - - ## Verb - - - PUT - """ - - class_name: Literal["V1InfraAaaLocalUsersPut"] = Field( - default="V1InfraAaaLocalUsersPut", - description="Class name for backward compatibility", - frozen=True, - ) - - @property - def verb(self) -> VerbEnum: - """Return the HTTP verb for this endpoint.""" - return VerbEnum.PUT - - -class V1InfraAaaLocalUsersDelete(_V1InfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users DELETE Endpoint - - ## Description - - Endpoint to delete a local user from the ND Infra AAA service. - - ## Path - - - /api/v1/infra/aaa/localUsers/{login_id} - - ## Verb - - - DELETE - """ - - class_name: Literal["V1InfraAaaLocalUsersDelete"] = Field( - default="V1InfraAaaLocalUsersDelete", - description="Class name for backward compatibility", - frozen=True, - ) - - @property - def verb(self) -> VerbEnum: - """Return the HTTP verb for this endpoint.""" - return VerbEnum.DELETE diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index ddcb7569..651a9d30 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -12,7 +12,7 @@ from typing import ClassVar, Type, Optional from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType @@ -27,11 +27,11 @@ class NDBaseOrchestrator(BaseModel): model_class: ClassVar[Type[NDBaseModel]] = Type[NDBaseModel] # NOTE: if not defined by subclasses, return an error as they are required - create_endpoint: Type[NDBaseEndpoint] - update_endpoint: Type[NDBaseEndpoint] - delete_endpoint: Type[NDBaseEndpoint] - query_one_endpoint: Type[NDBaseEndpoint] - query_all_endpoint: Type[NDBaseEndpoint] + create_endpoint: Type[NDEndpointBaseModel] + update_endpoint: Type[NDEndpointBaseModel] + delete_endpoint: Type[NDEndpointBaseModel] + query_one_endpoint: Type[NDEndpointBaseModel] + query_all_endpoint: Type[NDEndpointBaseModel] # NOTE: Module Field is always required sender: NDModule @@ -70,7 +70,7 @@ def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> ResponseType: try: - result = self.sender.query_obj(self.query_all_endpoint.path) + result = self.sender.query_obj(self.query_all_endpoint().path) return result or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index 5e52a00b..db7bbfdc 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -12,31 +12,31 @@ from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDBaseEndpoint +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra_aaa_local_users import ( - V1InfraAaaLocalUsersPost, - V1InfraAaaLocalUsersPut, - V1InfraAaaLocalUsersDelete, - V1InfraAaaLocalUsersGet, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.aaa_local_users import ( + EpInfraAaaLocalUsersPost, + EpInfraAaaLocalUsersPut, + EpInfraAaaLocalUsersDelete, + EpInfraAaaLocalUsersGet, ) class LocalUserOrchestrator(NDBaseOrchestrator): model_class: Type[NDBaseModel] = LocalUserModel - create_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersPost - update_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersPut - delete_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersDelete - query_one_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersGet - query_all_endpoint: Type[NDBaseEndpoint] = V1InfraAaaLocalUsersGet + create_endpoint: Type[NDEndpointBaseModel] = EpInfraAaaLocalUsersPost + update_endpoint: Type[NDEndpointBaseModel] = EpInfraAaaLocalUsersPut + delete_endpoint: Type[NDEndpointBaseModel] = EpInfraAaaLocalUsersDelete + query_one_endpoint: Type[NDEndpointBaseModel] = EpInfraAaaLocalUsersGet + query_all_endpoint: Type[NDEndpointBaseModel] = EpInfraAaaLocalUsersGet def query_all(self) -> ResponseType: """ Custom query_all action to extract 'localusers' from response. """ try: - result = self.sender.query_obj(self.query_all_endpoint.base_path) + result = self.sender.query_obj(self.query_all_endpoint().path) return result.get("localusers", []) or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa_local_users.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa_local_users.py new file mode 100644 index 00000000..71cfd9b6 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa_local_users.py @@ -0,0 +1,437 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for infra_aaa_local_users.py + +Tests the ND Infra AAA endpoint classes +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.aaa_local_users import ( + EpInfraAaaLocalUsersDelete, + EpInfraAaaLocalUsersGet, + EpInfraAaaLocalUsersPost, + EpInfraAaaLocalUsersPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: EpInfraAaaLocalUsersGet +# ============================================================================= + + +def test_endpoints_api_v1_infra_aaa_00010(): + """ + # Summary + + Verify EpInfraAaaLocalUsersGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet.__init__() + - EpInfraAaaLocalUsersGet.verb + - EpInfraAaaLocalUsersGet.class_name + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersGet() + assert instance.class_name == "EpInfraAaaLocalUsersGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_infra_aaa_00020(): + """ + # Summary + + Verify EpInfraAaaLocalUsersGet path without login_id + + ## Test + + - path returns "/api/v1/infra/aaa/localUsers" when login_id is None + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet.path + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersGet() + result = instance.path + assert result == "/api/v1/infra/aaa/localUsers" + + +def test_endpoints_api_v1_infra_aaa_00030(): + """ + # Summary + + Verify EpInfraAaaLocalUsersGet path with login_id + + ## Test + + - path returns "/api/v1/infra/aaa/localUsers/admin" when login_id is set + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet.path + - EpInfraAaaLocalUsersGet.login_id + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersGet() + instance.login_id = "admin" + result = instance.path + assert result == "/api/v1/infra/aaa/localUsers/admin" + + +def test_endpoints_api_v1_infra_aaa_00040(): + """ + # Summary + + Verify EpInfraAaaLocalUsersGet login_id can be set at instantiation + + ## Test + + - login_id can be provided during instantiation + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet.__init__() + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersGet(login_id="testuser") + assert instance.login_id == "testuser" + assert instance.path == "/api/v1/infra/aaa/localUsers/testuser" + + +# ============================================================================= +# Test: EpInfraAaaLocalUsersPost +# ============================================================================= + + +def test_endpoints_api_v1_infra_aaa_00100(): + """ + # Summary + + Verify EpInfraAaaLocalUsersPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpInfraAaaLocalUsersPost.__init__() + - EpInfraAaaLocalUsersPost.verb + - EpInfraAaaLocalUsersPost.class_name + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersPost() + assert instance.class_name == "EpInfraAaaLocalUsersPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_infra_aaa_00110(): + """ + # Summary + + Verify EpInfraAaaLocalUsersPost path + + ## Test + + - path returns "/api/v1/infra/aaa/localUsers" for POST + + ## Classes and Methods + + - EpInfraAaaLocalUsersPost.path + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersPost() + result = instance.path + assert result == "/api/v1/infra/aaa/localUsers" + + +def test_endpoints_api_v1_infra_aaa_00120(): + """ + # Summary + + Verify EpInfraAaaLocalUsersPost path with login_id + + ## Test + + - path returns "/api/v1/infra/aaa/localUsers/admin" when login_id is set + + ## Classes and Methods + + - EpInfraAaaLocalUsersPost.path + - EpInfraAaaLocalUsersPost.login_id + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersPost() + instance.login_id = "admin" + result = instance.path + assert result == "/api/v1/infra/aaa/localUsers/admin" + + +# ============================================================================= +# Test: EpInfraAaaLocalUsersPut +# ============================================================================= + + +def test_endpoints_api_v1_infra_aaa_00200(): + """ + # Summary + + Verify EpInfraAaaLocalUsersPut basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is PUT + + ## Classes and Methods + + - EpInfraAaaLocalUsersPut.__init__() + - EpInfraAaaLocalUsersPut.verb + - EpInfraAaaLocalUsersPut.class_name + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersPut() + assert instance.class_name == "EpInfraAaaLocalUsersPut" + assert instance.verb == HttpVerbEnum.PUT + + +def test_endpoints_api_v1_infra_aaa_00210(): + """ + # Summary + + Verify EpInfraAaaLocalUsersPut path with login_id + + ## Test + + - path returns "/api/v1/infra/aaa/localUsers/admin" when login_id is set + + ## Classes and Methods + + - EpInfraAaaLocalUsersPut.path + - EpInfraAaaLocalUsersPut.login_id + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersPut() + instance.login_id = "admin" + result = instance.path + assert result == "/api/v1/infra/aaa/localUsers/admin" + + +def test_endpoints_api_v1_infra_aaa_00220(): + """ + # Summary + + Verify EpInfraAaaLocalUsersPut with complex login_id + + ## Test + + - login_id with special characters is handled correctly + + ## Classes and Methods + + - EpInfraAaaLocalUsersPut.path + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersPut(login_id="user-name_123") + assert instance.path == "/api/v1/infra/aaa/localUsers/user-name_123" + + +# ============================================================================= +# Test: EpInfraAaaLocalUsersDelete +# ============================================================================= + + +def test_endpoints_api_v1_infra_aaa_00300(): + """ + # Summary + + Verify EpInfraAaaLocalUsersDelete basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is DELETE + + ## Classes and Methods + + - EpInfraAaaLocalUsersDelete.__init__() + - EpInfraAaaLocalUsersDelete.verb + - EpInfraAaaLocalUsersDelete.class_name + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersDelete() + assert instance.class_name == "EpInfraAaaLocalUsersDelete" + assert instance.verb == HttpVerbEnum.DELETE + + +def test_endpoints_api_v1_infra_aaa_00310(): + """ + # Summary + + Verify EpInfraAaaLocalUsersDelete path with login_id + + ## Test + + - path returns "/api/v1/infra/aaa/localUsers/admin" when login_id is set + + ## Classes and Methods + + - EpInfraAaaLocalUsersDelete.path + - EpInfraAaaLocalUsersDelete.login_id + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersDelete() + instance.login_id = "admin" + result = instance.path + assert result == "/api/v1/infra/aaa/localUsers/admin" + + +def test_endpoints_api_v1_infra_aaa_00320(): + """ + # Summary + + Verify EpInfraAaaLocalUsersDelete without login_id + + ## Test + + - path returns base path when login_id is None + + ## Classes and Methods + + - EpInfraAaaLocalUsersDelete.path + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersDelete() + result = instance.path + assert result == "/api/v1/infra/aaa/localUsers" + + +# ============================================================================= +# Test: All HTTP methods on same endpoint +# ============================================================================= + + +def test_endpoints_api_v1_infra_aaa_00400(): + """ + # Summary + + Verify all HTTP methods work correctly on same resource + + ## Test + + - GET, POST, PUT, DELETE all return correct paths for same login_id + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet + - EpInfraAaaLocalUsersPost + - EpInfraAaaLocalUsersPut + - EpInfraAaaLocalUsersDelete + """ + login_id = "testuser" + + with does_not_raise(): + get_ep = EpInfraAaaLocalUsersGet(login_id=login_id) + post_ep = EpInfraAaaLocalUsersPost(login_id=login_id) + put_ep = EpInfraAaaLocalUsersPut(login_id=login_id) + delete_ep = EpInfraAaaLocalUsersDelete(login_id=login_id) + + # All should have same path when login_id is set + expected_path = "/api/v1/infra/aaa/localUsers/testuser" + assert get_ep.path == expected_path + assert post_ep.path == expected_path + assert put_ep.path == expected_path + assert delete_ep.path == expected_path + + # But different verbs + assert get_ep.verb == HttpVerbEnum.GET + assert post_ep.verb == HttpVerbEnum.POST + assert put_ep.verb == HttpVerbEnum.PUT + assert delete_ep.verb == HttpVerbEnum.DELETE + + +# ============================================================================= +# Test: Pydantic validation +# ============================================================================= + + +def test_endpoints_api_v1_infra_aaa_00500(): + """ + # Summary + + Verify Pydantic validation for login_id + + ## Test + + - Empty string is rejected for login_id (min_length=1) + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet.__init__() + """ + with pytest.raises(ValueError): + EpInfraAaaLocalUsersGet(login_id="") + + +def test_endpoints_api_v1_infra_aaa_00510(): + """ + # Summary + + Verify login_id can be None + + ## Test + + - login_id accepts None as valid value + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet.__init__() + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersGet(login_id=None) + assert instance.login_id is None + + +def test_endpoints_api_v1_infra_aaa_00520(): + """ + # Summary + + Verify login_id can be modified after instantiation + + ## Test + + - login_id can be changed after object creation + + ## Classes and Methods + + - EpInfraAaaLocalUsersGet.login_id + """ + with does_not_raise(): + instance = EpInfraAaaLocalUsersGet() + assert instance.login_id is None + instance.login_id = "newuser" + assert instance.login_id == "newuser" + assert instance.path == "/api/v1/infra/aaa/localUsers/newuser" From 2f6de8a2e60ec86c4e042941edecba467e6c78ba Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 12 Mar 2026 14:33:06 -0400 Subject: [PATCH 37/51] [ignore] Update pydantic_compat.py to support extra Pydantic methods and classes. --- .../module_utils/common/pydantic_compat.py | 57 ++++- plugins/module_utils/endpoints/base.py | 1 - .../endpoints/v1/infra/aaa_local_users.py | 12 +- plugins/module_utils/models/base.py | 2 +- plugins/module_utils/models/local_user.py | 2 +- plugins/module_utils/nd_state_machine.py | 2 +- plugins/module_utils/orchestrators/base.py | 5 +- .../module_utils/orchestrators/local_user.py | 3 +- plugins/module_utils/pydantic_compat.py | 238 ------------------ .../module_utils/endpoints/test_base_model.py | 5 - 10 files changed, 61 insertions(+), 266 deletions(-) delete mode 100644 plugins/module_utils/pydantic_compat.py diff --git a/plugins/module_utils/common/pydantic_compat.py b/plugins/module_utils/common/pydantic_compat.py index e1550a18..b26559d2 100644 --- a/plugins/module_utils/common/pydantic_compat.py +++ b/plugins/module_utils/common/pydantic_compat.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) +# Copyright: (c) 2026, Gaspard Micol (@gmicol) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -34,10 +33,6 @@ # fmt: on # isort: on -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - import traceback from typing import TYPE_CHECKING, Any, Callable, Union @@ -51,11 +46,16 @@ Field, PydanticExperimentalWarning, StrictBool, + SecretStr, ValidationError, field_serializer, + model_serializer, field_validator, model_validator, validator, + computed_field, + FieldSerializationInfo, + SerializationInfo, ) HAS_PYDANTIC = True # pylint: disable=invalid-name @@ -71,11 +71,16 @@ Field, PydanticExperimentalWarning, StrictBool, + SecretStr, ValidationError, field_serializer, + model_serializer, field_validator, model_validator, validator, + computed_field, + FieldSerializationInfo, + SerializationInfo, ) except ImportError: HAS_PYDANTIC = False # pylint: disable=invalid-name @@ -127,6 +132,15 @@ def decorator(func): return decorator + # Fallback: model_serializer decorator that does nothing + def model_serializer(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic model_serializer fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + # Fallback: field_validator decorator that does nothing def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name """Pydantic field_validator fallback when pydantic is not available.""" @@ -136,6 +150,15 @@ def decorator(func): return decorator + # Fallback: computed_field decorator that does nothing + def computed_field(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic computed_field fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + # Fallback: AfterValidator that returns the function unchanged def AfterValidator(func): # pylint: disable=invalid-name """Pydantic AfterValidator fallback when pydantic is not available.""" @@ -152,6 +175,9 @@ def BeforeValidator(func): # pylint: disable=invalid-name # Fallback: StrictBool StrictBool = bool + # Fallback: SecretStr + SecretStr = str + # Fallback: ValidationError class ValidationError(Exception): """ @@ -183,6 +209,20 @@ def decorator(func): return decorator + # Fallback: FieldSerializationInfo placeholder class that does nothing + class FieldSerializationInfo: + """Pydantic FieldSerializationInfo fallback when pydantic is not available.""" + + def __init__(self, **kwargs): + pass + + # Fallback: SerializationInfo placeholder class that does nothing + class SerializationInfo: + """Pydantic SerializationInfo fallback when pydantic is not available.""" + + def __init__(self, **kwargs): + pass + else: HAS_PYDANTIC = True # pylint: disable=invalid-name PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name @@ -234,10 +274,15 @@ def main(): "PYDANTIC_IMPORT_ERROR", "PydanticExperimentalWarning", "StrictBool", + "SecretStr", "ValidationError", "field_serializer", + "model_serializer", "field_validator", "model_validator", "require_pydantic", "validator", + "computed_field", + "FieldSerializationInfo", + "SerializationInfo", ] diff --git a/plugins/module_utils/endpoints/base.py b/plugins/module_utils/endpoints/base.py index e5eb8c72..c3d7f4e1 100644 --- a/plugins/module_utils/endpoints/base.py +++ b/plugins/module_utils/endpoints/base.py @@ -133,6 +133,5 @@ def verb(self) -> HttpVerbEnum: """ # NOTE: function to set endpoints attribute fields from identifiers -> acts as the bridge between Models and Endpoints for API Request Orchestration - @abstractmethod def set_identifiers(self, identifier: IdentifierKey = None): pass diff --git a/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py index 925c5548..26660622 100644 --- a/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py +++ b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py @@ -10,15 +10,7 @@ from __future__ import absolute_import, annotations, division, print_function -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Literal - +from typing import Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import LoginIdMixin @@ -49,7 +41,7 @@ def path(self) -> str: if self.login_id is not None: return BasePath.path("aaa", "localUsers", self.login_id) return BasePath.path("aaa", "localUsers") - + def set_identifiers(self, identifier: IdentifierKey = None): self.login_id = identifier diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 21fb983e..07b6ee28 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -9,7 +9,7 @@ __metaclass__ = type from abc import ABC -from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict from typing import List, Dict, Any, ClassVar, Set, Tuple, Union, Literal, Optional from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index 38f2b5d2..a47a4a0a 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -9,7 +9,7 @@ __metaclass__ = type from typing import List, Dict, Any, Optional, ClassVar, Literal -from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import ( +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, SecretStr, model_serializer, diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 3840b360..efed3517 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -9,7 +9,7 @@ __metaclass__ = type from typing import Type -from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 651a9d30..1f4e3e69 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -8,7 +8,7 @@ __metaclass__ = type -from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict from typing import ClassVar, Type, Optional from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule @@ -70,7 +70,8 @@ def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> ResponseType: try: - result = self.sender.query_obj(self.query_all_endpoint().path) + api_endpoint = self.query_all_endpoint() + result = self.sender.query_obj(api_endpoint.path) return result or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index db7bbfdc..689ba9dc 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -36,7 +36,8 @@ def query_all(self) -> ResponseType: Custom query_all action to extract 'localusers' from response. """ try: - result = self.sender.query_obj(self.query_all_endpoint().path) + api_endpoint = self.query_all_endpoint() + result = self.sender.query_obj(api_endpoint.path) return result.get("localusers", []) or [] except Exception as e: raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/module_utils/pydantic_compat.py b/plugins/module_utils/pydantic_compat.py deleted file mode 100644 index 2596d852..00000000 --- a/plugins/module_utils/pydantic_compat.py +++ /dev/null @@ -1,238 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -# pylint: disable=too-few-public-methods -""" -Pydantic compatibility layer. - -This module provides a single location for Pydantic imports with fallback -implementations when Pydantic is not available. This ensures consistent -behavior across all modules and follows the DRY principle. -""" - -from __future__ import absolute_import, annotations, division, print_function - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import traceback -from typing import TYPE_CHECKING, Any, Callable, Union - -if TYPE_CHECKING: - # Type checkers always see the real Pydantic types - from pydantic import ( - AfterValidator, - BaseModel, - BeforeValidator, - ConfigDict, - Field, - PydanticExperimentalWarning, - StrictBool, - SecretStr, - ValidationError, - field_serializer, - model_serializer, - field_validator, - model_validator, - validator, - computed_field, - FieldSerializationInfo, - SerializationInfo, - ) -else: - # Runtime: try to import, with fallback - try: - from pydantic import ( - AfterValidator, - BaseModel, - BeforeValidator, - ConfigDict, - Field, - PydanticExperimentalWarning, - StrictBool, - SecretStr, - ValidationError, - field_serializer, - model_serializer, - field_validator, - model_validator, - validator, - computed_field, - FieldSerializationInfo, - SerializationInfo, - ) - except ImportError: - HAS_PYDANTIC = False # pylint: disable=invalid-name - PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name - - # Fallback: Minimal BaseModel replacement - class BaseModel: - """Fallback BaseModel when pydantic is not available.""" - - model_config = {"validate_assignment": False, "use_enum_values": False} - - def __init__(self, **kwargs): - """Accept keyword arguments and set them as attributes.""" - for key, value in kwargs.items(): - setattr(self, key, value) - - def model_dump(self, exclude_none: bool = False, exclude_defaults: bool = False) -> dict: # pylint: disable=unused-argument - """Return a dictionary of field names and values. - - Args: - exclude_none: If True, exclude fields with None values - exclude_defaults: Accepted for API compatibility but not implemented in fallback - """ - result = {} - for key, value in self.__dict__.items(): - if exclude_none and value is None: - continue - result[key] = value - return result - - # Fallback: ConfigDict that does nothing - def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-name - """Pydantic ConfigDict fallback when pydantic is not available.""" - return kwargs - - # Fallback: Field that does nothing - def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name - """Pydantic Field fallback when pydantic is not available.""" - if "default_factory" in kwargs: - return kwargs["default_factory"]() - return kwargs.get("default") - - # Fallback: field_serializer decorator that does nothing - def field_serializer(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic field_serializer fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: model_serializer decorator that does nothing - def model_serializer(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic model_serializer fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: field_validator decorator that does nothing - def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name - """Pydantic field_validator fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: computed_field decorator that does nothing - def computed_field(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic computed_field fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: AfterValidator that returns the function unchanged - def AfterValidator(func): # pylint: disable=invalid-name - """Pydantic AfterValidator fallback when pydantic is not available.""" - return func - - # Fallback: BeforeValidator that returns the function unchanged - def BeforeValidator(func): # pylint: disable=invalid-name - """Pydantic BeforeValidator fallback when pydantic is not available.""" - return func - - # Fallback: PydanticExperimentalWarning - PydanticExperimentalWarning = Warning - - # Fallback: StrictBool - StrictBool = bool - - # Fallback: SecretStr - SecretStr = str - - # Fallback: ValidationError - class ValidationError(Exception): - """ - Pydantic ValidationError fallback when pydantic is not available. - """ - - def __init__(self, message="A custom error occurred."): - self.message = message - super().__init__(self.message) - - def __str__(self): - return f"ValidationError: {self.message}" - - # Fallback: model_validator decorator that does nothing - def model_validator(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic model_validator fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: validator decorator that does nothing - def validator(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic validator fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: FieldSerializationInfo placeholder class that does nothing - class FieldSerializationInfo: - """Pydantic FieldSerializationInfo fallback when pydantic is not available.""" - - def __init__(self, **kwargs): - pass - - # Fallback: SerializationInfo placeholder class that does nothing - class SerializationInfo: - """Pydantic SerializationInfo fallback when pydantic is not available.""" - - def __init__(self, **kwargs): - pass - - else: - HAS_PYDANTIC = True # pylint: disable=invalid-name - PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name - -# Set HAS_PYDANTIC for when TYPE_CHECKING is True -if TYPE_CHECKING: - HAS_PYDANTIC = True # pylint: disable=invalid-name - PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name - -__all__ = [ - "AfterValidator", - "BaseModel", - "BeforeValidator", - "ConfigDict", - "Field", - "HAS_PYDANTIC", - "PYDANTIC_IMPORT_ERROR", - "PydanticExperimentalWarning", - "StrictBool", - "SecretStr", - "ValidationError", - "field_serializer", - "model_serializer", - "field_validator", - "model_validator", - "validator", - "computed_field", - "FieldSerializationInfo", - "SerializationInfo", -] diff --git a/tests/unit/module_utils/endpoints/test_base_model.py b/tests/unit/module_utils/endpoints/test_base_model.py index a14da9d8..ce9d1e8d 100644 --- a/tests/unit/module_utils/endpoints/test_base_model.py +++ b/tests/unit/module_utils/endpoints/test_base_model.py @@ -99,7 +99,6 @@ def test_base_model_00200(): with pytest.raises(TypeError, match=match): class _BadEndpoint(NDEndpointBaseModel): - @property def path(self) -> str: return "/api/v1/test/bad" @@ -132,7 +131,6 @@ def test_base_model_00300(): """ class _MiddleABC(NDEndpointBaseModel, ABC): - @property @abstractmethod def extra(self) -> str: @@ -182,7 +180,6 @@ def test_base_model_00310(): """ class _MiddleABC2(NDEndpointBaseModel, ABC): - @property @abstractmethod def extra(self) -> str: @@ -192,7 +189,6 @@ def extra(self) -> str: with pytest.raises(TypeError, match=match): class _BadConcreteFromMiddle(_MiddleABC2): - @property def path(self) -> str: return "/api/v1/test/bad-middle" @@ -229,7 +225,6 @@ def test_base_model_00400(): with pytest.raises(TypeError, match=r'Literal\["_ExampleEndpoint"\]') as exc_info: class _ExampleEndpoint(NDEndpointBaseModel): - @property def path(self) -> str: return "/api/v1/test/example" From 79dc00094f45f100affa659c91d83376547942e1 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 17 Mar 2026 12:01:56 -0400 Subject: [PATCH 38/51] [ignore] Remove Python 2.7 compatibilities. --- plugins/module_utils/common/exceptions.py | 4 ---- plugins/module_utils/common/log.py | 6 ------ plugins/module_utils/constants.py | 4 ---- plugins/module_utils/endpoints/enums.py | 4 ---- plugins/module_utils/endpoints/mixins.py | 1 - plugins/module_utils/endpoints/query_params.py | 2 -- plugins/module_utils/endpoints/v1/infra/base_path.py | 2 -- plugins/module_utils/endpoints/v1/infra/login.py | 2 -- plugins/module_utils/endpoints/v1/manage/base_path.py | 2 -- plugins/module_utils/enums.py | 5 ----- plugins/module_utils/models/base.py | 4 ---- plugins/module_utils/models/local_user.py | 4 ---- plugins/module_utils/models/nested.py | 4 ---- plugins/module_utils/nd.py | 4 ---- plugins/module_utils/nd_argument_specs.py | 4 ---- plugins/module_utils/nd_config_collection.py | 4 ---- plugins/module_utils/nd_output.py | 4 ---- plugins/module_utils/nd_state_machine.py | 4 ---- plugins/module_utils/nd_v2.py | 6 ------ plugins/module_utils/ndi.py | 3 --- plugins/module_utils/ndi_argument_specs.py | 4 ---- plugins/module_utils/orchestrators/base.py | 4 ---- plugins/module_utils/orchestrators/local_user.py | 4 ---- plugins/module_utils/orchestrators/types.py | 4 ---- plugins/module_utils/rest/protocols/response_handler.py | 3 +-- plugins/module_utils/rest/protocols/response_validation.py | 4 +--- plugins/module_utils/rest/protocols/sender.py | 4 ++-- plugins/module_utils/rest/response_handler_nd.py | 4 +--- .../rest/response_strategies/nd_v1_strategy.py | 4 +--- plugins/module_utils/rest/rest_send.py | 1 - plugins/module_utils/rest/results.py | 2 -- plugins/module_utils/rest/sender_nd.py | 4 +--- plugins/module_utils/types.py | 4 ---- plugins/module_utils/utils.py | 4 ---- plugins/modules/nd_local_user.py | 7 +------ 35 files changed, 8 insertions(+), 122 deletions(-) diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py index 0d7b7bcc..0c53c2c2 100644 --- a/plugins/module_utils/common/exceptions.py +++ b/plugins/module_utils/common/exceptions.py @@ -15,10 +15,6 @@ # fmt: on # isort: on -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - from typing import Any, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( diff --git a/plugins/module_utils/common/log.py b/plugins/module_utils/common/log.py index 29182539..f43d9018 100644 --- a/plugins/module_utils/common/log.py +++ b/plugins/module_utils/common/log.py @@ -1,15 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # 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 -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - import json import logging from enum import Enum diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 563041a0..adbe345e 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2022, Akini Ross (@akinross) # Copyright: (c) 2024, Gaspard Micol (@gmicol) @@ -7,8 +5,6 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type - from typing import Dict from types import MappingProxyType from copy import deepcopy diff --git a/plugins/module_utils/endpoints/enums.py b/plugins/module_utils/endpoints/enums.py index 802b8fe8..92ae5783 100644 --- a/plugins/module_utils/endpoints/enums.py +++ b/plugins/module_utils/endpoints/enums.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@allenrobel) # Copyright: (c) 2026, Gaspard Micol (@gmicol) @@ -10,8 +8,6 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type - from enum import Enum diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index 22d9a2dc..e7f0620c 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -11,7 +11,6 @@ from __future__ import absolute_import, annotations, division, print_function - from typing import Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum diff --git a/plugins/module_utils/endpoints/query_params.py b/plugins/module_utils/endpoints/query_params.py index 5bf8ff08..2cddd97d 100644 --- a/plugins/module_utils/endpoints/query_params.py +++ b/plugins/module_utils/endpoints/query_params.py @@ -11,12 +11,10 @@ from __future__ import absolute_import, annotations, division, print_function - from enum import Enum from typing import Optional, Protocol from urllib.parse import quote - from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( BaseModel, Field, diff --git a/plugins/module_utils/endpoints/v1/infra/base_path.py b/plugins/module_utils/endpoints/v1/infra/base_path.py index f0612025..0db15ae9 100644 --- a/plugins/module_utils/endpoints/v1/infra/base_path.py +++ b/plugins/module_utils/endpoints/v1/infra/base_path.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/endpoints/v1/infra/login.py b/plugins/module_utils/endpoints/v1/infra/login.py index 70968615..6fff9159 100644 --- a/plugins/module_utils/endpoints/v1/infra/login.py +++ b/plugins/module_utils/endpoints/v1/infra/login.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/endpoints/v1/manage/base_path.py b/plugins/module_utils/endpoints/v1/manage/base_path.py index 5f043ced..52bb4e56 100644 --- a/plugins/module_utils/endpoints/v1/manage/base_path.py +++ b/plugins/module_utils/endpoints/v1/manage/base_path.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/enums.py b/plugins/module_utils/enums.py index 55d1f1ac..83f1f76d 100644 --- a/plugins/module_utils/enums.py +++ b/plugins/module_utils/enums.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # pylint: disable=wrong-import-position # pylint: disable=missing-module-docstring # Copyright: (c) 2026, Allen Robel (@allenrobel) @@ -21,10 +20,6 @@ # fmt: on # isort: on -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - from enum import Enum diff --git a/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py index 07b6ee28..a62a12b1 100644 --- a/plugins/module_utils/models/base.py +++ b/plugins/module_utils/models/base.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 abc import ABC from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict from typing import List, Dict, Any, ClassVar, Set, Tuple, Union, Literal, Optional diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user.py index a47a4a0a..6d4960f3 100644 --- a/plugins/module_utils/models/local_user.py +++ b/plugins/module_utils/models/local_user.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import List, Dict, Any, Optional, ClassVar, Literal from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, diff --git a/plugins/module_utils/models/nested.py b/plugins/module_utils/models/nested.py index 0573e5f8..c3af1d71 100644 --- a/plugins/module_utils/models/nested.py +++ b/plugins/module_utils/models/nested.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import List, ClassVar from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index 42b1b118..50a5eeb2 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2021, Lionel Hercot (@lhercot) # Copyright: (c) 2022, Cindy Zhao (@cizhao) # Copyright: (c) 2022, Akini Ross (@akinross) @@ -9,8 +7,6 @@ from __future__ import absolute_import, division, print_function from functools import reduce -__metaclass__ = type - from copy import deepcopy import os import shutil diff --git a/plugins/module_utils/nd_argument_specs.py b/plugins/module_utils/nd_argument_specs.py index 7ef10d04..798ca90f 100644 --- a/plugins/module_utils/nd_argument_specs.py +++ b/plugins/module_utils/nd_argument_specs.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2023, 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 - def ntp_server_spec(): return dict( diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index abcfc0f7..0da7247f 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import Optional, List, Dict, Any, Literal from copy import deepcopy from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py index 0e5ed6ef..8088b09b 100644 --- a/plugins/module_utils/nd_output.py +++ b/plugins/module_utils/nd_output.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import Dict, Any, Optional, List, Union from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index efed3517..e3ea328c 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import Type from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError from ansible.module_utils.basic import AnsibleModule diff --git a/plugins/module_utils/nd_v2.py b/plugins/module_utils/nd_v2.py index 0a3fe61a..a622d77f 100644 --- a/plugins/module_utils/nd_v2.py +++ b/plugins/module_utils/nd_v2.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -47,10 +45,6 @@ def main(): # fmt: on # isort: on -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - import logging from typing import Any, Optional diff --git a/plugins/module_utils/ndi.py b/plugins/module_utils/ndi.py index 37e7ec56..6ff912aa 100644 --- a/plugins/module_utils/ndi.py +++ b/plugins/module_utils/ndi.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2021, Lionel Hercot (@lhercot) # Copyright: (c) 2022, Cindy Zhao (@cizhao) # Copyright: (c) 2022, Akini Ross (@akinross) @@ -16,7 +14,6 @@ HAS_JSONPATH_NG_PARSE = True except ImportError: HAS_JSONPATH_NG_PARSE = False -__metaclass__ = type from ansible_collections.cisco.nd.plugins.module_utils.constants import OBJECT_TYPES, MATCH_TYPES diff --git a/plugins/module_utils/ndi_argument_specs.py b/plugins/module_utils/ndi_argument_specs.py index 641e675c..a367e3c5 100644 --- a/plugins/module_utils/ndi_argument_specs.py +++ b/plugins/module_utils/ndi_argument_specs.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2022, Akini Ross (@akinross) # 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.constants import MATCH_TYPES, OPERATORS, TCP_FLAGS diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index 1f4e3e69..fe16a524 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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.common.pydantic_compat import BaseModel, ConfigDict from typing import ClassVar, Type, Optional from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index 689ba9dc..332719bf 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import Type from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel diff --git a/plugins/module_utils/orchestrators/types.py b/plugins/module_utils/orchestrators/types.py index b721c65b..415526c7 100644 --- a/plugins/module_utils/orchestrators/types.py +++ b/plugins/module_utils/orchestrators/types.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import Any, Union, List, Dict ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] diff --git a/plugins/module_utils/rest/protocols/response_handler.py b/plugins/module_utils/rest/protocols/response_handler.py index 487e12cf..ab658c99 100644 --- a/plugins/module_utils/rest/protocols/response_handler.py +++ b/plugins/module_utils/rest/protocols/response_handler.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # pylint: disable=missing-module-docstring # pylint: disable=unnecessary-ellipsis # pylint: disable=wrong-import-position @@ -13,7 +12,7 @@ # isort: on # pylint: disable=invalid-name -__metaclass__ = type + # pylint: enable=invalid-name """ diff --git a/plugins/module_utils/rest/protocols/response_validation.py b/plugins/module_utils/rest/protocols/response_validation.py index d1ec5ef0..30a81b97 100644 --- a/plugins/module_utils/rest/protocols/response_validation.py +++ b/plugins/module_utils/rest/protocols/response_validation.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -26,7 +24,7 @@ # isort: on # pylint: disable=invalid-name -__metaclass__ = type + # pylint: enable=invalid-name try: diff --git a/plugins/module_utils/rest/protocols/sender.py b/plugins/module_utils/rest/protocols/sender.py index 5e55047c..df9f4d1b 100644 --- a/plugins/module_utils/rest/protocols/sender.py +++ b/plugins/module_utils/rest/protocols/sender.py @@ -1,7 +1,7 @@ # pylint: disable=wrong-import-position # pylint: disable=missing-module-docstring # pylint: disable=unnecessary-ellipsis -# -*- coding: utf-8 -*- + # Copyright: (c) 2026, Allen Robel (@arobel) @@ -15,7 +15,7 @@ # isort: on # pylint: disable=invalid-name -__metaclass__ = type + # pylint: enable=invalid-name try: diff --git a/plugins/module_utils/rest/response_handler_nd.py b/plugins/module_utils/rest/response_handler_nd.py index e7026d30..f0f30b94 100644 --- a/plugins/module_utils/rest/response_handler_nd.py +++ b/plugins/module_utils/rest/response_handler_nd.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -62,7 +60,7 @@ class (e.g. `NdV2Strategy`) conforming to `ResponseValidationStrategy` and injec # isort: on # pylint: disable=invalid-name -__metaclass__ = type + # pylint: enable=invalid-name import copy diff --git a/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py index 58c7784f..a5953789 100644 --- a/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py +++ b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -25,7 +23,7 @@ # isort: on # pylint: disable=invalid-name -__metaclass__ = type + # pylint: enable=invalid-name from typing import Any, Optional diff --git a/plugins/module_utils/rest/rest_send.py b/plugins/module_utils/rest/rest_send.py index c87009a5..7631b0dd 100644 --- a/plugins/module_utils/rest/rest_send.py +++ b/plugins/module_utils/rest/rest_send.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # pylint: disable=wrong-import-position # pylint: disable=missing-module-docstring # Copyright: (c) 2026, Allen Robel (@arobel) diff --git a/plugins/module_utils/rest/results.py b/plugins/module_utils/rest/results.py index 59281683..faee00dc 100644 --- a/plugins/module_utils/rest/results.py +++ b/plugins/module_utils/rest/results.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/rest/sender_nd.py b/plugins/module_utils/rest/sender_nd.py index ae333dd0..b5ed9b85 100644 --- a/plugins/module_utils/rest/sender_nd.py +++ b/plugins/module_utils/rest/sender_nd.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, Allen Robel (@arobel) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -17,7 +15,7 @@ # isort: on # pylint: disable=invalid-name -__metaclass__ = type + # pylint: enable=invalid-name import copy diff --git a/plugins/module_utils/types.py b/plugins/module_utils/types.py index 3111a095..b0056d5a 100644 --- a/plugins/module_utils/types.py +++ b/plugins/module_utils/types.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 typing import Any, Union, Tuple IdentifierKey = Union[str, int, Tuple[Any, ...]] diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 2e62c6eb..7d05e4af 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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 from typing import Any, Dict, List, Union diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index f5efea03..25d04fb5 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -1,14 +1,9 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - # Copyright: (c) 2026, 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""" @@ -112,7 +107,7 @@ - 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 only supported on Nexus Dashboard having version 4.2.1 or higher. - This module is not idempotent when creating or updating a local user object when O(config.user_password) is used. """ From 2c7ec7837618a2aead4f3432e93c28c9839be12b Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 17 Mar 2026 12:50:52 -0400 Subject: [PATCH 39/51] [ignore] Fix comments and docstrings. made and static methods for class. --- .../endpoints/v1/infra/aaa_local_users.py | 12 ++++++------ plugins/module_utils/nd_config_collection.py | 13 ++++++------- plugins/module_utils/nd_output.py | 2 +- plugins/module_utils/nd_state_machine.py | 4 ++-- plugins/modules/nd_local_user.py | 2 +- .../targets/nd_local_user/tasks/main.yml | 4 ++-- 6 files changed, 18 insertions(+), 19 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py index 26660622..ea3b1f4b 100644 --- a/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py +++ b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py @@ -32,7 +32,7 @@ def path(self) -> str: """ # Summary - Build the endpoint path. + Build the /api/v1/infra/aaa/localUsers endpoint path. ## Returns @@ -70,12 +70,12 @@ class EpInfraAaaLocalUsersGet(_EpInfraAaaLocalUsersBase): ```python # Get all local users - request = EpApiV1InfraAaaLocalUsersGet() + request = EpInfraAaaLocalUsersGet() path = request.path verb = request.verb # Get specific local user - request = EpApiV1InfraAaaLocalUsersGet() + request = EpInfraAaaLocalUsersGet() request.login_id = "admin" path = request.path verb = request.verb @@ -111,7 +111,7 @@ class EpInfraAaaLocalUsersPost(_EpInfraAaaLocalUsersBase): ## Usage ```python - request = EpApiV1InfraAaaLocalUsersPost() + request = EpInfraAaaLocalUsersPost() path = request.path verb = request.verb ``` @@ -148,7 +148,7 @@ class EpInfraAaaLocalUsersPut(_EpInfraAaaLocalUsersBase): ## Usage ```python - request = EpApiV1InfraAaaLocalUsersPut() + request = EpInfraAaaLocalUsersPut() request.login_id = "admin" path = request.path verb = request.verb @@ -184,7 +184,7 @@ class EpInfraAaaLocalUsersDelete(_EpInfraAaaLocalUsersBase): ## Usage ```python - request = EpApiV1InfraAaaLocalUsersDelete() + request = EpInfraAaaLocalUsersDelete() request.login_id = "admin" path = request.path verb = request.verb diff --git a/plugins/module_utils/nd_config_collection.py b/plugins/module_utils/nd_config_collection.py index 0da7247f..832cc132 100644 --- a/plugins/module_utils/nd_config_collection.py +++ b/plugins/module_utils/nd_config_collection.py @@ -119,7 +119,6 @@ def delete(self, key: IdentifierKey) -> bool: # Diff Operations - # NOTE: Maybe add a similar one in the NDBaseModel (-> but is it necessary?) def get_diff_config(self, new_item: NDBaseModel) -> Literal["new", "no_diff", "changed"]: """ Compare single item against collection. @@ -198,18 +197,18 @@ def to_payload_list(self, **kwargs) -> List[Dict[str, Any]]: """ return [item.to_payload(**kwargs) for item in self._items] - @classmethod - def from_ansible_config(cls, data: List[Dict], model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection": + @staticmethod + def from_ansible_config(data: List[Dict], model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection": """ Create collection from Ansible config. """ items = [model_class.from_config(item_data, **kwargs) for item_data in data] - return cls(model_class=model_class, items=items) + return NDConfigCollection(model_class=model_class, items=items) - @classmethod - def from_api_response(cls, response_data: List[Dict[str, Any]], model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection": + @staticmethod + def from_api_response(response_data: List[Dict[str, Any]], model_class: type[NDBaseModel], **kwargs) -> "NDConfigCollection": """ Create collection from API response. """ items = [model_class.from_response(item_data, **kwargs) for item_data in response_data] - return cls(model_class=model_class, items=items) + return NDConfigCollection(model_class=model_class, items=items) diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py index 8088b09b..09759b96 100644 --- a/plugins/module_utils/nd_output.py +++ b/plugins/module_utils/nd_output.py @@ -34,7 +34,7 @@ def format(self, **kwargs) -> Dict[str, Any]: if self._output_level in ("debug", "info"): output["proposed"] = self._proposed.to_ansible_config() if isinstance(self._proposed, NDConfigCollection) else self._proposed if self._output_level == "debug": - output["logs"] = "Not yet implemented" + output["logs"] = self._logs if self._extra: output.update(self._extra) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index e3ea328c..d6af1c6f 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -16,12 +16,12 @@ class NDStateMachine: """ - Generic Network Resource Module for Nexus Dashboard. + Generic State Machine for Nexus Dashboard. """ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchestrator]): """ - Initialize the Network Resource Module. + Initialize the ND State Machine. """ self.module = module self.nd_module = NDModule(self.module) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 25d04fb5..f672cc91 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -9,7 +9,7 @@ DOCUMENTATION = r""" --- module: nd_local_user -version_added: "1.4.0" +version_added: "1.6.0" short_description: Manage local users on Cisco Nexus Dashboard description: - Manage local users on Cisco Nexus Dashboard (ND). diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml index b7f205ae..c4540568 100644 --- a/tests/integration/targets/nd_local_user/tasks/main.yml +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -536,7 +536,7 @@ # DELETE -- name: Delete local user by name (check mode) +- name: Delete local user (check mode) cisco.nd.nd_local_user: &delete_local_user <<: *nd_info config: @@ -545,7 +545,7 @@ check_mode: true register: cm_delete_local_user -- name: Delete local user by name (normal mode) +- name: Delete local user (normal mode) cisco.nd.nd_local_user: <<: *delete_local_user register: nm_delete_local_user From 7142c4369f54c73d0ffa3ed0bd2172c5b7bb8517 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 18 Mar 2026 13:25:47 -0400 Subject: [PATCH 40/51] [ignore] Slightly modify Exceptions handling in NDStateMachine. Remove self.send from check_mode guards in NDStateMachine. Fix documentation for nd_local_user. --- plugins/module_utils/nd_state_machine.py | 25 ++++++++++++------------ plugins/modules/nd_local_user.py | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index d6af1c6f..37324020 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -45,16 +45,18 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest self.sent = NDConfigCollection(model_class=self.model_class) # Collection of configuration objects given by user self.proposed = NDConfigCollection(model_class=self.model_class) + for config in self.module.params.get("config", []): - try: - # Parse config into model - item = self.model_class.from_config(config) - self.proposed.add(item) - except ValidationError as e: - raise NDStateMachineError(f"Invalid configuration. for config {config}: {str(e)}") + # Parse config into model + item = self.model_class.from_config(config) + self.proposed.add(item) + self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) + + except ValidationError as e: + raise NDStateMachineError(f"Invalid configuration. for config {config}: {str(e)}") from e except Exception as e: - raise NDStateMachineError(f"Initialization failed: {str(e)}") + raise NDStateMachineError(f"Initialization failed: {str(e)}") from e # State Management (core function) def manage_state(self) -> None: @@ -105,11 +107,10 @@ def _manage_create_update_state(self) -> None: if diff_status == "changed": if not self.module.check_mode: self.model_orchestrator.update(final_item) - self.sent.add(final_item) elif diff_status == "new": if not self.module.check_mode: self.model_orchestrator.create(final_item) - self.sent.add(final_item) + self.sent.add(final_item) # Log operation self.output.assign(after=self.existing) @@ -117,7 +118,7 @@ def _manage_create_update_state(self) -> None: except Exception as e: error_msg = f"Failed to process {identifier}: {e}" if not self.module.params.get("ignore_errors", False): - raise NDStateMachineError(error_msg) + raise NDStateMachineError(error_msg) from e def _manage_override_deletions(self) -> None: """ @@ -144,7 +145,7 @@ def _manage_override_deletions(self) -> None: except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" if not self.module.params.get("ignore_errors", False): - raise NDStateMachineError(error_msg) + raise NDStateMachineError(error_msg) from e def _manage_delete_state(self) -> None: """Handle deleted state.""" @@ -169,4 +170,4 @@ def _manage_delete_state(self) -> None: except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" if not self.module.params.get("ignore_errors", False): - raise NDStateMachineError(error_msg) + raise NDStateMachineError(error_msg) from e diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index f672cc91..d6c02d00 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -13,7 +13,7 @@ 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. +- It supports creating, updating, and deleting local users. author: - Gaspard Micol (@gmicol) options: From bc5cfb088f604bd61e0c84faebb0544f48f6cebd Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 18 Mar 2026 13:31:33 -0400 Subject: [PATCH 41/51] [ignore] Rename aaa_local_users.py to infra_aaa_local_users.py. Move models/local_user.py to new dir models/local_user. --- .../v1/infra/{aaa_local_users.py => infra_aaa_local_users.py} | 0 plugins/module_utils/models/{ => local_user}/local_user.py | 0 plugins/module_utils/orchestrators/local_user.py | 4 ++-- plugins/modules/nd_local_user.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename plugins/module_utils/endpoints/v1/infra/{aaa_local_users.py => infra_aaa_local_users.py} (100%) rename plugins/module_utils/models/{ => local_user}/local_user.py (100%) diff --git a/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra/infra_aaa_local_users.py similarity index 100% rename from plugins/module_utils/endpoints/v1/infra/aaa_local_users.py rename to plugins/module_utils/endpoints/v1/infra/infra_aaa_local_users.py diff --git a/plugins/module_utils/models/local_user.py b/plugins/module_utils/models/local_user/local_user.py similarity index 100% rename from plugins/module_utils/models/local_user.py rename to plugins/module_utils/models/local_user/local_user.py diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index 332719bf..0c2a6bf8 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -7,10 +7,10 @@ from typing import Type from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel +from ansible_collections.cisco.nd.plugins.module_utils.models.local_user.local_user import LocalUserModel from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.aaa_local_users import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.infra_aaa_local_users import ( EpInfraAaaLocalUsersPost, EpInfraAaaLocalUsersPut, EpInfraAaaLocalUsersDelete, diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index d6c02d00..53680e99 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -173,7 +173,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine -from ansible_collections.cisco.nd.plugins.module_utils.models.local_user import LocalUserModel +from ansible_collections.cisco.nd.plugins.module_utils.models.local_user.local_user import LocalUserModel from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.local_user import LocalUserOrchestrator From ed33a5a60f7665f3b4743e84626ef5943a1d7704 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 19 Mar 2026 12:54:08 -0400 Subject: [PATCH 42/51] [ignore] Update integration tests for nd_local_user module. --- .../targets/nd_local_user/tasks/main.yml | 1136 ++++++++++++----- 1 file changed, 800 insertions(+), 336 deletions(-) diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml index c4540568..c76e22c3 100644 --- a/tests/integration/targets/nd_local_user/tasks/main.yml +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -14,7 +14,7 @@ output_level: '{{ api_key_output_level | default("debug") }}' - name: Ensure local users do not exist before test starts - cisco.nd.nd_local_user: + cisco.nd.nd_local_user: &clean_all_local_users <<: *nd_info config: - login_id: ansible_local_user @@ -22,9 +22,12 @@ - login_id: ansible_local_user_3 state: deleted -# CREATE -- name: Create local users with full and minimum configuration (check mode) - cisco.nd.nd_local_user: &create_local_user + +# --- MERGED STATE TESTS --- + +# MERGED STATE TESTS: CREATE +- name: Create local users with full and minimum configuration (merged state - check mode) + cisco.nd.nd_local_user: &create_local_user_merged_state <<: *nd_info config: - email: ansibleuser@example.com @@ -47,151 +50,124 @@ - name: all state: merged check_mode: true - register: cm_create_local_users + register: cm_merged_create_local_users -- name: Create local users with full and minimum configuration (normal mode) +- name: Create local users with full and minimum configuration (merged state - normal mode) cisco.nd.nd_local_user: - <<: *create_local_user - register: nm_create_local_users + <<: *create_local_user_merged_state + register: nm_merged_create_local_users -- name: Asserts for local users creation tasks +- name: Asserts for local users merged state creation tasks ansible.builtin.assert: that: - - cm_create_local_users is changed - - cm_create_local_users.after | length == 3 - - cm_create_local_users.after.0.login_id == "admin" - - cm_create_local_users.after.0.first_name == "admin" - - cm_create_local_users.after.0.remote_user_authorization == false - - cm_create_local_users.after.0.reuse_limitation == 0 - - cm_create_local_users.after.0.security_domains | length == 1 - - cm_create_local_users.after.0.security_domains.0.name == "all" - - cm_create_local_users.after.0.security_domains.0.roles | length == 1 - - cm_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" - - cm_create_local_users.after.0.time_interval_limitation == 0 - - cm_create_local_users.after.1.email == "ansibleuser@example.com" - - cm_create_local_users.after.1.first_name == "Ansible first name" - - cm_create_local_users.after.1.last_name == "Ansible last name" - - cm_create_local_users.after.1.login_id == "ansible_local_user" - - cm_create_local_users.after.1.remote_id_claim == "ansible_remote_user" - - cm_create_local_users.after.1.remote_user_authorization == true - - cm_create_local_users.after.1.reuse_limitation == 20 - - cm_create_local_users.after.1.security_domains | length == 1 - - cm_create_local_users.after.1.security_domains.0.name == "all" - - cm_create_local_users.after.1.security_domains.0.roles | length == 2 - - cm_create_local_users.after.1.security_domains.0.roles.0 == "observer" - - cm_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" - - cm_create_local_users.after.1.time_interval_limitation == 10 - - cm_create_local_users.after.2.login_id == "ansible_local_user_2" - - cm_create_local_users.after.2.security_domains | length == 1 - - cm_create_local_users.after.2.security_domains.0.name == "all" - - cm_create_local_users.before | length == 1 - - cm_create_local_users.before.0.login_id == "admin" - - cm_create_local_users.before.0.first_name == "admin" - - cm_create_local_users.before.0.remote_user_authorization == false - - cm_create_local_users.before.0.reuse_limitation == 0 - - cm_create_local_users.before.0.security_domains | length == 1 - - cm_create_local_users.before.0.security_domains.0.name == "all" - - cm_create_local_users.before.0.security_domains.0.roles | length == 1 - - cm_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" - - cm_create_local_users.before.0.time_interval_limitation == 0 - - cm_create_local_users.diff == [] - - cm_create_local_users.proposed.0.email == "ansibleuser@example.com" - - cm_create_local_users.proposed.0.first_name == "Ansible first name" - - cm_create_local_users.proposed.0.last_name == "Ansible last name" - - cm_create_local_users.proposed.0.login_id == "ansible_local_user" - - cm_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" - - cm_create_local_users.proposed.0.remote_user_authorization == true - - cm_create_local_users.proposed.0.reuse_limitation == 20 - - cm_create_local_users.proposed.0.security_domains | length == 1 - - cm_create_local_users.proposed.0.security_domains.0.name == "all" - - cm_create_local_users.proposed.0.security_domains.0.roles | length == 2 - - cm_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" - - cm_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" - - cm_create_local_users.proposed.0.time_interval_limitation == 10 - - cm_create_local_users.proposed.1.login_id == "ansible_local_user_2" - - cm_create_local_users.proposed.1.security_domains | length == 1 - - cm_create_local_users.proposed.1.security_domains.0.name == "all" - - nm_create_local_users is changed - - nm_create_local_users.after.0.first_name == "admin" - - nm_create_local_users.after.0.remote_user_authorization == false - - nm_create_local_users.after.0.reuse_limitation == 0 - - nm_create_local_users.after.0.security_domains | length == 1 - - nm_create_local_users.after.0.security_domains.0.name == "all" - - nm_create_local_users.after.0.security_domains.0.roles | length == 1 - - nm_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" - - nm_create_local_users.after.0.time_interval_limitation == 0 - - nm_create_local_users.after.1.email == "ansibleuser@example.com" - - nm_create_local_users.after.1.first_name == "Ansible first name" - - nm_create_local_users.after.1.last_name == "Ansible last name" - - nm_create_local_users.after.1.login_id == "ansible_local_user" - - nm_create_local_users.after.1.remote_id_claim == "ansible_remote_user" - - nm_create_local_users.after.1.remote_user_authorization == true - - nm_create_local_users.after.1.reuse_limitation == 20 - - nm_create_local_users.after.1.security_domains | length == 1 - - nm_create_local_users.after.1.security_domains.0.name == "all" - - nm_create_local_users.after.1.security_domains.0.roles | length == 2 - - nm_create_local_users.after.1.security_domains.0.roles.0 == "observer" - - nm_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" - - nm_create_local_users.after.1.time_interval_limitation == 10 - - nm_create_local_users.after.2.login_id == "ansible_local_user_2" - - nm_create_local_users.after.2.security_domains | length == 1 - - nm_create_local_users.after.2.security_domains.0.name == "all" - - nm_create_local_users.before | length == 1 - - nm_create_local_users.before.0.login_id == "admin" - - nm_create_local_users.before.0.first_name == "admin" - - nm_create_local_users.before.0.remote_user_authorization == false - - nm_create_local_users.before.0.reuse_limitation == 0 - - nm_create_local_users.before.0.security_domains | length == 1 - - nm_create_local_users.before.0.security_domains.0.name == "all" - - nm_create_local_users.before.0.security_domains.0.roles | length == 1 - - nm_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" - - nm_create_local_users.before.0.time_interval_limitation == 0 - - nm_create_local_users.diff == [] - - nm_create_local_users.proposed.0.email == "ansibleuser@example.com" - - nm_create_local_users.proposed.0.first_name == "Ansible first name" - - nm_create_local_users.proposed.0.last_name == "Ansible last name" - - nm_create_local_users.proposed.0.login_id == "ansible_local_user" - - nm_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" - - nm_create_local_users.proposed.0.remote_user_authorization == true - - nm_create_local_users.proposed.0.reuse_limitation == 20 - - nm_create_local_users.proposed.0.security_domains | length == 1 - - nm_create_local_users.proposed.0.security_domains.0.name == "all" - - nm_create_local_users.proposed.0.security_domains.0.roles | length == 2 - - nm_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" - - nm_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" - - nm_create_local_users.proposed.0.time_interval_limitation == 10 - - nm_create_local_users.proposed.1.login_id == "ansible_local_user_2" - - nm_create_local_users.proposed.1.security_domains | length == 1 - - nm_create_local_users.proposed.1.security_domains.0.name == "all" - -# UPDATE -- name: Replace all ansible_local_user's attributes (check mode) - cisco.nd.nd_local_user: &update_first_local_user - <<: *nd_info - config: - - email: updatedansibleuser@example.com - login_id: ansible_local_user - 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_replace_local_user + - cm_merged_create_local_users is changed + - cm_merged_create_local_users.after | length == 3 + - cm_merged_create_local_users.after.0.login_id == "admin" + - cm_merged_create_local_users.after.0.first_name == "admin" + - cm_merged_create_local_users.after.0.remote_user_authorization == false + - cm_merged_create_local_users.after.0.reuse_limitation == 0 + - cm_merged_create_local_users.after.0.security_domains | length == 1 + - cm_merged_create_local_users.after.0.security_domains.0.name == "all" + - cm_merged_create_local_users.after.0.security_domains.0.roles | length == 1 + - cm_merged_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - cm_merged_create_local_users.after.0.time_interval_limitation == 0 + - cm_merged_create_local_users.after.1.email == "ansibleuser@example.com" + - cm_merged_create_local_users.after.1.first_name == "Ansible first name" + - cm_merged_create_local_users.after.1.last_name == "Ansible last name" + - cm_merged_create_local_users.after.1.login_id == "ansible_local_user" + - cm_merged_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - cm_merged_create_local_users.after.1.remote_user_authorization == true + - cm_merged_create_local_users.after.1.reuse_limitation == 20 + - cm_merged_create_local_users.after.1.security_domains | length == 1 + - cm_merged_create_local_users.after.1.security_domains.0.name == "all" + - cm_merged_create_local_users.after.1.security_domains.0.roles | length == 2 + - cm_merged_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - cm_merged_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - cm_merged_create_local_users.after.1.time_interval_limitation == 10 + - cm_merged_create_local_users.after.2.login_id == "ansible_local_user_2" + - cm_merged_create_local_users.after.2.security_domains | length == 1 + - cm_merged_create_local_users.after.2.security_domains.0.name == "all" + - cm_merged_create_local_users.before | length == 1 + - cm_merged_create_local_users.before.0.login_id == "admin" + - cm_merged_create_local_users.before.0.first_name == "admin" + - cm_merged_create_local_users.before.0.remote_user_authorization == false + - cm_merged_create_local_users.before.0.reuse_limitation == 0 + - cm_merged_create_local_users.before.0.security_domains | length == 1 + - cm_merged_create_local_users.before.0.security_domains.0.name == "all" + - cm_merged_create_local_users.before.0.security_domains.0.roles | length == 1 + - cm_merged_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - cm_merged_create_local_users.before.0.time_interval_limitation == 0 + - cm_merged_create_local_users.proposed.0.email == "ansibleuser@example.com" + - cm_merged_create_local_users.proposed.0.first_name == "Ansible first name" + - cm_merged_create_local_users.proposed.0.last_name == "Ansible last name" + - cm_merged_create_local_users.proposed.0.login_id == "ansible_local_user" + - cm_merged_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - cm_merged_create_local_users.proposed.0.remote_user_authorization == true + - cm_merged_create_local_users.proposed.0.reuse_limitation == 20 + - cm_merged_create_local_users.proposed.0.security_domains | length == 1 + - cm_merged_create_local_users.proposed.0.security_domains.0.name == "all" + - cm_merged_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - cm_merged_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - cm_merged_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - cm_merged_create_local_users.proposed.0.time_interval_limitation == 10 + - cm_merged_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - cm_merged_create_local_users.proposed.1.security_domains | length == 1 + - cm_merged_create_local_users.proposed.1.security_domains.0.name == "all" + - nm_merged_create_local_users is changed + - nm_merged_create_local_users.after.0.first_name == "admin" + - nm_merged_create_local_users.after.0.remote_user_authorization == false + - nm_merged_create_local_users.after.0.reuse_limitation == 0 + - nm_merged_create_local_users.after.0.security_domains | length == 1 + - nm_merged_create_local_users.after.0.security_domains.0.name == "all" + - nm_merged_create_local_users.after.0.security_domains.0.roles | length == 1 + - nm_merged_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - nm_merged_create_local_users.after.0.time_interval_limitation == 0 + - nm_merged_create_local_users.after.1.email == "ansibleuser@example.com" + - nm_merged_create_local_users.after.1.first_name == "Ansible first name" + - nm_merged_create_local_users.after.1.last_name == "Ansible last name" + - nm_merged_create_local_users.after.1.login_id == "ansible_local_user" + - nm_merged_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - nm_merged_create_local_users.after.1.remote_user_authorization == true + - nm_merged_create_local_users.after.1.reuse_limitation == 20 + - nm_merged_create_local_users.after.1.security_domains | length == 1 + - nm_merged_create_local_users.after.1.security_domains.0.name == "all" + - nm_merged_create_local_users.after.1.security_domains.0.roles | length == 2 + - nm_merged_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - nm_merged_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - nm_merged_create_local_users.after.1.time_interval_limitation == 10 + - nm_merged_create_local_users.after.2.login_id == "ansible_local_user_2" + - nm_merged_create_local_users.after.2.security_domains | length == 1 + - nm_merged_create_local_users.after.2.security_domains.0.name == "all" + - nm_merged_create_local_users.before | length == 1 + - nm_merged_create_local_users.before.0.login_id == "admin" + - nm_merged_create_local_users.before.0.first_name == "admin" + - nm_merged_create_local_users.before.0.remote_user_authorization == false + - nm_merged_create_local_users.before.0.reuse_limitation == 0 + - nm_merged_create_local_users.before.0.security_domains | length == 1 + - nm_merged_create_local_users.before.0.security_domains.0.name == "all" + - nm_merged_create_local_users.before.0.security_domains.0.roles | length == 1 + - nm_merged_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - nm_merged_create_local_users.before.0.time_interval_limitation == 0 + - nm_merged_create_local_users.proposed.0.email == "ansibleuser@example.com" + - nm_merged_create_local_users.proposed.0.first_name == "Ansible first name" + - nm_merged_create_local_users.proposed.0.last_name == "Ansible last name" + - nm_merged_create_local_users.proposed.0.login_id == "ansible_local_user" + - nm_merged_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - nm_merged_create_local_users.proposed.0.remote_user_authorization == true + - nm_merged_create_local_users.proposed.0.reuse_limitation == 20 + - nm_merged_create_local_users.proposed.0.security_domains | length == 1 + - nm_merged_create_local_users.proposed.0.security_domains.0.name == "all" + - nm_merged_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - nm_merged_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - nm_merged_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - nm_merged_create_local_users.proposed.0.time_interval_limitation == 10 + - nm_merged_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - nm_merged_create_local_users.proposed.1.security_domains | length == 1 + - nm_merged_create_local_users.proposed.1.security_domains.0.name == "all" -- name: Replace all ansible_local_user's attributes (normal mode) - cisco.nd.nd_local_user: - <<: *update_first_local_user - register: nm_replace_local_user - -- name: Update all ansible_local_user_2's attributes except password - cisco.nd.nd_local_user: &update_second_local_user +# MERGED STATE TESTS: UPDATE +- name: Update all ansible_local_user_2's attributes except password (merge state - check mode) + cisco.nd.nd_local_user: &update_second_local_user_merged_state <<: *nd_info config: - email: secondansibleuser@example.com @@ -206,48 +182,341 @@ remote_id_claim: ansible_remote_user_2 remote_user_authorization: true state: merged - register: nm_merge_local_user_2 + check_mode: true + register: cm_merged_update_local_user_2 -- name: Update all ansible_local_user_2's attributes except password again (idempotency) +- name: Update all ansible_local_user_2's attributes except password (merge state - normal mode) cisco.nd.nd_local_user: - <<: *update_second_local_user - register: nm_merge_local_user_2_again + <<: *update_second_local_user_merged_state + register: nm_merged_update_local_user_2 +- name: Update all ansible_local_user_2's attributes except password again (merge state - idempotency) + cisco.nd.nd_local_user: + <<: *update_second_local_user_merged_state + register: nm_merged_update_local_user_2_again -- name: Override local users with minimum configuration +- name: Asserts for local users update tasks + ansible.builtin.assert: + that: + - cm_merged_update_local_user_2 is changed + - cm_merged_update_local_user_2.after | length == 3 + - cm_merged_update_local_user_2.after.0.email == "secondansibleuser@example.com" + - cm_merged_update_local_user_2.after.0.first_name == "Second Ansible first name" + - cm_merged_update_local_user_2.after.0.last_name == "Second Ansible last name" + - cm_merged_update_local_user_2.after.0.login_id == "ansible_local_user_2" + - cm_merged_update_local_user_2.after.0.remote_id_claim == "ansible_remote_user_2" + - cm_merged_update_local_user_2.after.0.remote_user_authorization == true + - cm_merged_update_local_user_2.after.0.reuse_limitation == 20 + - cm_merged_update_local_user_2.after.0.security_domains | length == 1 + - cm_merged_update_local_user_2.after.0.security_domains.0.name == "all" + - cm_merged_update_local_user_2.after.0.security_domains.0.roles | length == 1 + - cm_merged_update_local_user_2.after.0.security_domains.0.roles.0 == "fabric_admin" + - cm_merged_update_local_user_2.after.0.time_interval_limitation == 10 + - cm_merged_update_local_user_2.after.1.email == "updatedansibleuser@example.com" + - cm_merged_update_local_user_2.after.1.first_name == "Updated Ansible first name" + - cm_merged_update_local_user_2.after.1.last_name == "Updated Ansible last name" + - cm_merged_update_local_user_2.after.1.login_id == "ansible_local_user" + - cm_merged_update_local_user_2.after.1.remote_user_authorization == false + - cm_merged_update_local_user_2.after.1.reuse_limitation == 25 + - cm_merged_update_local_user_2.after.1.security_domains | length == 1 + - cm_merged_update_local_user_2.after.1.security_domains.0.name == "all" + - cm_merged_update_local_user_2.after.1.security_domains.0.roles | length == 1 + - cm_merged_update_local_user_2.after.1.security_domains.0.roles.0 == "super_admin" + - cm_merged_update_local_user_2.after.1.time_interval_limitation == 15 + - cm_merged_update_local_user_2.after.2.login_id == "admin" + - cm_merged_update_local_user_2.after.2.first_name == "admin" + - cm_merged_update_local_user_2.after.2.remote_user_authorization == false + - cm_merged_update_local_user_2.after.2.reuse_limitation == 0 + - cm_merged_update_local_user_2.after.2.security_domains | length == 1 + - cm_merged_update_local_user_2.after.2.security_domains.0.name == "all" + - cm_merged_update_local_user_2.after.2.security_domains.0.roles | length == 1 + - cm_merged_update_local_user_2.after.2.security_domains.0.roles.0 == "super_admin" + - cm_merged_update_local_user_2.after.2.time_interval_limitation == 0 + - cm_merged_update_local_user_2.before | length == 3 + - cm_merged_update_local_user_2.before.2.first_name == "admin" + - cm_merged_update_local_user_2.before.2.remote_user_authorization == false + - cm_merged_update_local_user_2.before.2.reuse_limitation == 0 + - cm_merged_update_local_user_2.before.2.security_domains | length == 1 + - cm_merged_update_local_user_2.before.2.security_domains.0.name == "all" + - cm_merged_update_local_user_2.before.2.security_domains.0.roles | length == 1 + - cm_merged_update_local_user_2.before.2.security_domains.0.roles.0 == "super_admin" + - cm_merged_update_local_user_2.before.2.time_interval_limitation == 0 + - cm_merged_update_local_user_2.before.1.email == "ansibleuser@example.com" + - cm_merged_update_local_user_2.before.1.first_name == "Ansible first name" + - cm_merged_update_local_user_2.before.1.last_name == "Ansible last name" + - cm_merged_update_local_user_2.before.1.login_id == "ansible_local_user" + - cm_merged_update_local_user_2.before.1.remote_id_claim == "ansible_remote_user" + - cm_merged_update_local_user_2.before.1.remote_user_authorization == true + - cm_merged_update_local_user_2.before.1.reuse_limitation == 20 + - cm_merged_update_local_user_2.before.1.security_domains | length == 1 + - cm_merged_update_local_user_2.before.1.security_domains.0.name == "all" + - cm_merged_update_local_user_2.before.1.security_domains.0.roles | length == 2 + - cm_merged_update_local_user_2.before.1.security_domains.0.roles.0 == "observer" + - cm_merged_update_local_user_2.before.1.security_domains.0.roles.0 == "support-engineer" + - cm_merged_update_local_user_2.before.1.time_interval_limitation == 10 + - cm_merged_update_local_user_2.before.0.login_id == "ansible_local_user_2" + - cm_merged_update_local_user_2.before.0.security_domains | length == 1 + - cm_merged_update_local_user_2.before.0.security_domains.0.name == "all" + - cm_merged_update_local_user_2.proposed.0.email == "secondansibleuser@example.com" + - cm_merged_update_local_user_2.proposed.0.first_name == "Second Ansible first name" + - cm_merged_update_local_user_2.proposed.0.last_name == "Second Ansible last name" + - cm_merged_update_local_user_2.proposed.0.login_id == "ansible_local_user_2" + - cm_merged_update_local_user_2.proposed.0.remote_id_claim == "ansible_remote_user_2" + - cm_merged_update_local_user_2.proposed.0.remote_user_authorization == true + - cm_merged_update_local_user_2.proposed.0.reuse_limitation == 20 + - cm_merged_update_local_user_2.proposed.0.security_domains | length == 1 + - cm_merged_update_local_user_2.proposed.0.security_domains.0.name == "all" + - cm_merged_update_local_user_2.proposed.0.security_domains.0.roles | length == 1 + - cm_merged_update_local_user_2.proposed.0.security_domains.0.roles.0 == "fabric_admin" + - cm_merged_update_local_user_2.proposed.0.time_interval_limitation == 10 + - nm_merged_update_local_user_2 is changed + - nm_merged_update_local_user_2.after | length == 3 + - nm_merged_update_local_user_2.after.0.email == "secondansibleuser@example.com" + - nm_merged_update_local_user_2.after.0.first_name == "Second Ansible first name" + - nm_merged_update_local_user_2.after.0.last_name == "Second Ansible last name" + - nm_merged_update_local_user_2.after.0.login_id == "ansible_local_user_2" + - nm_merged_update_local_user_2.after.0.remote_id_claim == "ansible_remote_user_2" + - nm_merged_update_local_user_2.after.0.remote_user_authorization == true + - nm_merged_update_local_user_2.after.0.reuse_limitation == 20 + - nm_merged_update_local_user_2.after.0.security_domains | length == 1 + - nm_merged_update_local_user_2.after.0.security_domains.0.name == "all" + - nm_merged_update_local_user_2.after.0.security_domains.0.roles | length == 1 + - nm_merged_update_local_user_2.after.0.security_domains.0.roles.0 == "fabric_admin" + - nm_merged_update_local_user_2.after.0.time_interval_limitation == 10 + - nm_merged_update_local_user_2.after.1.email == "updatedansibleuser@example.com" + - nm_merged_update_local_user_2.after.1.first_name == "Updated Ansible first name" + - nm_merged_update_local_user_2.after.1.last_name == "Updated Ansible last name" + - nm_merged_update_local_user_2.after.1.login_id == "ansible_local_user" + - nm_merged_update_local_user_2.after.1.remote_user_authorization == false + - nm_merged_update_local_user_2.after.1.reuse_limitation == 25 + - nm_merged_update_local_user_2.after.1.security_domains | length == 1 + - nm_merged_update_local_user_2.after.1.security_domains.0.name == "all" + - nm_merged_update_local_user_2.after.1.security_domains.0.roles | length == 1 + - nm_merged_update_local_user_2.after.1.security_domains.0.roles.0 == "super_admin" + - nm_merged_update_local_user_2.after.1.time_interval_limitation == 15 + - nm_merged_update_local_user_2.after.2.login_id == "admin" + - nm_merged_update_local_user_2.after.2.first_name == "admin" + - nm_merged_update_local_user_2.after.2.remote_user_authorization == false + - nm_merged_update_local_user_2.after.2.reuse_limitation == 0 + - nm_merged_update_local_user_2.after.2.security_domains | length == 1 + - nm_merged_update_local_user_2.after.2.security_domains.0.name == "all" + - nm_merged_update_local_user_2.after.2.security_domains.0.roles | length == 1 + - nm_merged_update_local_user_2.after.2.security_domains.0.roles.0 == "super_admin" + - nm_merged_update_local_user_2.after.2.time_interval_limitation == 0 + - nm_merged_update_local_user_2.before | length == 3 + - nm_merged_update_local_user_2.before.2.first_name == "admin" + - nm_merged_update_local_user_2.before.2.remote_user_authorization == false + - nm_merged_update_local_user_2.before.2.reuse_limitation == 0 + - nm_merged_update_local_user_2.before.2.security_domains | length == 1 + - nm_merged_update_local_user_2.before.2.security_domains.0.name == "all" + - nm_merged_update_local_user_2.before.2.security_domains.0.roles | length == 1 + - nm_merged_update_local_user_2.before.2.security_domains.0.roles.0 == "super_admin" + - nm_merged_update_local_user_2.before.2.time_interval_limitation == 0 + - nm_merged_update_local_user_2.before.1.email == "ansibleuser@example.com" + - nm_merged_update_local_user_2.before.1.first_name == "Ansible first name" + - nm_merged_update_local_user_2.before.1.last_name == "Ansible last name" + - nm_merged_update_local_user_2.before.1.login_id == "ansible_local_user" + - nm_merged_update_local_user_2.before.1.remote_id_claim == "ansible_remote_user" + - nm_merged_update_local_user_2.before.1.remote_user_authorization == true + - nm_merged_update_local_user_2.before.1.reuse_limitation == 20 + - nm_merged_update_local_user_2.before.1.security_domains | length == 1 + - nm_merged_update_local_user_2.before.1.security_domains.0.name == "all" + - nm_merged_update_local_user_2.before.1.security_domains.0.roles | length == 2 + - nm_merged_update_local_user_2.before.1.security_domains.0.roles.0 == "observer" + - nm_merged_update_local_user_2.before.1.security_domains.0.roles.0 == "support-engineer" + - nm_merged_update_local_user_2.before.1.time_interval_limitation == 10 + - nm_merged_update_local_user_2.before.0.login_id == "ansible_local_user_2" + - nm_merged_update_local_user_2.before.0.security_domains | length == 1 + - nm_merged_update_local_user_2.before.0.security_domains.0.name == "all" + - nm_merged_update_local_user_2.proposed.0.email == "secondansibleuser@example.com" + - nm_merged_update_local_user_2.proposed.0.first_name == "Second Ansible first name" + - nm_merged_update_local_user_2.proposed.0.last_name == "Second Ansible last name" + - nm_merged_update_local_user_2.proposed.0.login_id == "ansible_local_user_2" + - nm_merged_update_local_user_2.proposed.0.remote_id_claim == "ansible_remote_user_2" + - nm_merged_update_local_user_2.proposed.0.remote_user_authorization == true + - nm_merged_update_local_user_2.proposed.0.reuse_limitation == 20 + - nm_merged_update_local_user_2.proposed.0.security_domains | length == 1 + - nm_merged_update_local_user_2.proposed.0.security_domains.0.name == "all" + - nm_merged_update_local_user_2.proposed.0.security_domains.0.roles | length == 1 + - nm_merged_update_local_user_2.proposed.0.security_domains.0.roles.0 == "fabric_admin" + - nm_merged_update_local_user_2.proposed.0.time_interval_limitation == 10 + - nm_merged_update_local_user_2_again is not changed + - nm_merged_update_local_user_2_again.after == nm_merged_update_local_user_2.after + - nm_merged_update_local_user_2_again.proposed == nm_merged_update_local_user_2.proposed + +- name: Ensure local users do not exist for next tests cisco.nd.nd_local_user: + <<: *clean_all_local_users + +# --- REPLACED STATE TESTS --- + +# REPLACED STATE TESTS: CREATE +- name: Create local users with full and minimum configuration (replaced state - check mode) + cisco.nd.nd_local_user: &create_local_user_replaced_state <<: *nd_info config: - - email: overrideansibleuser@example.com + - email: ansibleuser@example.com login_id: ansible_local_user - first_name: Overridden Ansible first name - last_name: Overridden Ansible last name - user_password: overideansibleLocalUserPassword1% - reuse_limitation: 15 - time_interval_limitation: 5 + first_name: Ansible first name + last_name: Ansible last name + user_password: ansibleLocalUserPassword1%Test + 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: admin - first_name: admin - remote_user_authorization: false - reuse_limitation: 0 - time_interval_limitation: 0 + - login_id: ansible_local_user_2 + user_password: ansibleLocalUser2Password1%Test security_domains: - name: all - roles: - - super_admin - - login_id: ansible_local_user_3 - user_password: ansibleLocalUser3Password1%Test + state: replaced + check_mode: true + register: cm_replaced_create_local_users + +- name: Create local users with full and minimum configuration (replaced state - normal mode) + cisco.nd.nd_local_user: + <<: *create_local_user_replaced_state + register: nm_replaced_create_local_users + +- name: Asserts for local users replaced state creation tasks + ansible.builtin.assert: + that: + - cm_replaced_create_local_users is changed + - cm_replaced_create_local_users.after | length == 3 + - cm_replaced_create_local_users.after.0.login_id == "admin" + - cm_replaced_create_local_users.after.0.first_name == "admin" + - cm_replaced_create_local_users.after.0.remote_user_authorization == false + - cm_replaced_create_local_users.after.0.reuse_limitation == 0 + - cm_replaced_create_local_users.after.0.security_domains | length == 1 + - cm_replaced_create_local_users.after.0.security_domains.0.name == "all" + - cm_replaced_create_local_users.after.0.security_domains.0.roles | length == 1 + - cm_replaced_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - cm_replaced_create_local_users.after.0.time_interval_limitation == 0 + - cm_replaced_create_local_users.after.1.email == "ansibleuser@example.com" + - cm_replaced_create_local_users.after.1.first_name == "Ansible first name" + - cm_replaced_create_local_users.after.1.last_name == "Ansible last name" + - cm_replaced_create_local_users.after.1.login_id == "ansible_local_user" + - cm_replaced_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - cm_replaced_create_local_users.after.1.remote_user_authorization == true + - cm_replaced_create_local_users.after.1.reuse_limitation == 20 + - cm_replaced_create_local_users.after.1.security_domains | length == 1 + - cm_replaced_create_local_users.after.1.security_domains.0.name == "all" + - cm_replaced_create_local_users.after.1.security_domains.0.roles | length == 2 + - cm_replaced_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - cm_replaced_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - cm_replaced_create_local_users.after.1.time_interval_limitation == 10 + - cm_replaced_create_local_users.after.2.login_id == "ansible_local_user_2" + - cm_replaced_create_local_users.after.2.security_domains | length == 1 + - cm_replaced_create_local_users.after.2.security_domains.0.name == "all" + - cm_replaced_create_local_users.before | length == 1 + - cm_replaced_create_local_users.before.0.login_id == "admin" + - cm_replaced_create_local_users.before.0.first_name == "admin" + - cm_replaced_create_local_users.before.0.remote_user_authorization == false + - cm_replaced_create_local_users.before.0.reuse_limitation == 0 + - cm_replaced_create_local_users.before.0.security_domains | length == 1 + - cm_replaced_create_local_users.before.0.security_domains.0.name == "all" + - cm_replaced_create_local_users.before.0.security_domains.0.roles | length == 1 + - cm_replaced_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - cm_replaced_create_local_users.before.0.time_interval_limitation == 0 + - cm_replaced_create_local_users.proposed.0.email == "ansibleuser@example.com" + - cm_replaced_create_local_users.proposed.0.first_name == "Ansible first name" + - cm_replaced_create_local_users.proposed.0.last_name == "Ansible last name" + - cm_replaced_create_local_users.proposed.0.login_id == "ansible_local_user" + - cm_replaced_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - cm_replaced_create_local_users.proposed.0.remote_user_authorization == true + - cm_replaced_create_local_users.proposed.0.reuse_limitation == 20 + - cm_replaced_create_local_users.proposed.0.security_domains | length == 1 + - cm_replaced_create_local_users.proposed.0.security_domains.0.name == "all" + - cm_replaced_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - cm_replaced_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - cm_replaced_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - cm_replaced_create_local_users.proposed.0.time_interval_limitation == 10 + - cm_replaced_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - cm_replaced_create_local_users.proposed.1.security_domains | length == 1 + - cm_replaced_create_local_users.proposed.1.security_domains.0.name == "all" + - nm_replaced_create_local_users is changed + - nm_replaced_create_local_users.after.0.first_name == "admin" + - nm_replaced_create_local_users.after.0.remote_user_authorization == false + - nm_replaced_create_local_users.after.0.reuse_limitation == 0 + - nm_replaced_create_local_users.after.0.security_domains | length == 1 + - nm_replaced_create_local_users.after.0.security_domains.0.name == "all" + - nm_replaced_create_local_users.after.0.security_domains.0.roles | length == 1 + - nm_replaced_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - nm_replaced_create_local_users.after.0.time_interval_limitation == 0 + - nm_replaced_create_local_users.after.1.email == "ansibleuser@example.com" + - nm_replaced_create_local_users.after.1.first_name == "Ansible first name" + - nm_replaced_create_local_users.after.1.last_name == "Ansible last name" + - nm_replaced_create_local_users.after.1.login_id == "ansible_local_user" + - nm_replaced_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - nm_replaced_create_local_users.after.1.remote_user_authorization == true + - nm_replaced_create_local_users.after.1.reuse_limitation == 20 + - nm_replaced_create_local_users.after.1.security_domains | length == 1 + - nm_replaced_create_local_users.after.1.security_domains.0.name == "all" + - nm_replaced_create_local_users.after.1.security_domains.0.roles | length == 2 + - nm_replaced_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - nm_replaced_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - nm_replaced_create_local_users.after.1.time_interval_limitation == 10 + - nm_replaced_create_local_users.after.2.login_id == "ansible_local_user_2" + - nm_replaced_create_local_users.after.2.security_domains | length == 1 + - nm_replaced_create_local_users.after.2.security_domains.0.name == "all" + - nm_replaced_create_local_users.before | length == 1 + - nm_replaced_create_local_users.before.0.login_id == "admin" + - nm_replaced_create_local_users.before.0.first_name == "admin" + - nm_replaced_create_local_users.before.0.remote_user_authorization == false + - nm_replaced_create_local_users.before.0.reuse_limitation == 0 + - nm_replaced_create_local_users.before.0.security_domains | length == 1 + - nm_replaced_create_local_users.before.0.security_domains.0.name == "all" + - nm_replaced_create_local_users.before.0.security_domains.0.roles | length == 1 + - nm_replaced_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - nm_replaced_create_local_users.before.0.time_interval_limitation == 0 + - nm_replaced_create_local_users.proposed.0.email == "ansibleuser@example.com" + - nm_replaced_create_local_users.proposed.0.first_name == "Ansible first name" + - nm_replaced_create_local_users.proposed.0.last_name == "Ansible last name" + - nm_replaced_create_local_users.proposed.0.login_id == "ansible_local_user" + - nm_replaced_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - nm_replaced_create_local_users.proposed.0.remote_user_authorization == true + - nm_replaced_create_local_users.proposed.0.reuse_limitation == 20 + - nm_replaced_create_local_users.proposed.0.security_domains | length == 1 + - nm_replaced_create_local_users.proposed.0.security_domains.0.name == "all" + - nm_replaced_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - nm_replaced_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - nm_replaced_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - nm_replaced_create_local_users.proposed.0.time_interval_limitation == 10 + - nm_replaced_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - nm_replaced_create_local_users.proposed.1.security_domains | length == 1 + - nm_replaced_create_local_users.proposed.1.security_domains.0.name == "all" + +# REPLACED STATE TESTS: UPDATE +- name: Replace all ansible_local_user's attributes (replaced state - check mode) + cisco.nd.nd_local_user: &update_first_local_user_replaced_state + <<: *nd_info + config: + - email: updatedansibleuser@example.com + login_id: ansible_local_user + 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 - state: overridden - register: nm_override_local_users + roles: super_admin + remote_id_claim: "" + remote_user_authorization: false + state: replaced + check_mode: true + register: cm_replace_local_user -- name: Asserts for local users update tasks +- name: Replace all ansible_local_user's attributes (replaced - normal mode) + cisco.nd.nd_local_user: + <<: *update_first_local_user_replaced_state + register: nm_replace_local_user + +- name: Asserts for local users replaced state update tasks ansible.builtin.assert: that: - cm_replace_local_user is changed @@ -301,7 +570,6 @@ - cm_replace_local_user.before.0.login_id == "ansible_local_user_2" - cm_replace_local_user.before.0.security_domains | length == 1 - cm_replace_local_user.before.0.security_domains.0.name == "all" - - cm_replace_local_user.diff == [] - cm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com" - cm_replace_local_user.proposed.0.first_name == "Updated Ansible first name" - cm_replace_local_user.proposed.0.last_name == "Updated Ansible last name" @@ -365,7 +633,6 @@ - nm_replace_local_user.before.0.login_id == "ansible_local_user_2" - nm_replace_local_user.before.0.security_domains | length == 1 - nm_replace_local_user.before.0.security_domains.0.name == "all" - - nm_replace_local_user.diff == [] - nm_replace_local_user.proposed.0.email == "updatedansibleuser@example.com" - nm_replace_local_user.proposed.0.first_name == "Updated Ansible first name" - nm_replace_local_user.proposed.0.last_name == "Updated Ansible last name" @@ -378,164 +645,368 @@ - nm_replace_local_user.proposed.0.security_domains.0.roles | length == 1 - nm_replace_local_user.proposed.0.security_domains.0.roles.0 == "super_admin" - nm_replace_local_user.proposed.0.time_interval_limitation == 15 - - nm_merge_local_user_2 is changed - - nm_merge_local_user_2.after | length == 3 - - nm_merge_local_user_2.after.0.email == "secondansibleuser@example.com" - - nm_merge_local_user_2.after.0.first_name == "Second Ansible first name" - - nm_merge_local_user_2.after.0.last_name == "Second Ansible last name" - - nm_merge_local_user_2.after.0.login_id == "ansible_local_user_2" - - nm_merge_local_user_2.after.0.remote_id_claim == "ansible_remote_user_2" - - nm_merge_local_user_2.after.0.remote_user_authorization == true - - nm_merge_local_user_2.after.0.reuse_limitation == 20 - - nm_merge_local_user_2.after.0.security_domains | length == 1 - - nm_merge_local_user_2.after.0.security_domains.0.name == "all" - - nm_merge_local_user_2.after.0.security_domains.0.roles | length == 1 - - nm_merge_local_user_2.after.0.security_domains.0.roles.0 == "fabric_admin" - - nm_merge_local_user_2.after.0.time_interval_limitation == 10 - - nm_merge_local_user_2.after.1.email == "updatedansibleuser@example.com" - - nm_merge_local_user_2.after.1.first_name == "Updated Ansible first name" - - nm_merge_local_user_2.after.1.last_name == "Updated Ansible last name" - - nm_merge_local_user_2.after.1.login_id == "ansible_local_user" - - nm_merge_local_user_2.after.1.remote_user_authorization == false - - nm_merge_local_user_2.after.1.reuse_limitation == 25 - - nm_merge_local_user_2.after.1.security_domains | length == 1 - - nm_merge_local_user_2.after.1.security_domains.0.name == "all" - - nm_merge_local_user_2.after.1.security_domains.0.roles | length == 1 - - nm_merge_local_user_2.after.1.security_domains.0.roles.0 == "super_admin" - - nm_merge_local_user_2.after.1.time_interval_limitation == 15 - - nm_merge_local_user_2.after.2.login_id == "admin" - - nm_merge_local_user_2.after.2.first_name == "admin" - - nm_merge_local_user_2.after.2.remote_user_authorization == false - - nm_merge_local_user_2.after.2.reuse_limitation == 0 - - nm_merge_local_user_2.after.2.security_domains | length == 1 - - nm_merge_local_user_2.after.2.security_domains.0.name == "all" - - nm_merge_local_user_2.after.2.security_domains.0.roles | length == 1 - - nm_merge_local_user_2.after.2.security_domains.0.roles.0 == "super_admin" - - nm_merge_local_user_2.after.2.time_interval_limitation == 0 - - nm_merge_local_user_2.before | length == 3 - - nm_merge_local_user_2.before.2.first_name == "admin" - - nm_merge_local_user_2.before.2.remote_user_authorization == false - - nm_merge_local_user_2.before.2.reuse_limitation == 0 - - nm_merge_local_user_2.before.2.security_domains | length == 1 - - nm_merge_local_user_2.before.2.security_domains.0.name == "all" - - nm_merge_local_user_2.before.2.security_domains.0.roles | length == 1 - - nm_merge_local_user_2.before.2.security_domains.0.roles.0 == "super_admin" - - nm_merge_local_user_2.before.2.time_interval_limitation == 0 - - nm_merge_local_user_2.before.1.email == "updatedansibleuser@example.com" - - nm_merge_local_user_2.before.1.first_name == "Updated Ansible first name" - - nm_merge_local_user_2.before.1.last_name == "Updated Ansible last name" - - nm_merge_local_user_2.before.1.login_id == "ansible_local_user" - - nm_merge_local_user_2.before.1.remote_user_authorization == false - - nm_merge_local_user_2.before.1.reuse_limitation == 25 - - nm_merge_local_user_2.before.1.security_domains | length == 1 - - nm_merge_local_user_2.before.1.security_domains.0.name == "all" - - nm_merge_local_user_2.before.1.security_domains.0.roles | length == 1 - - nm_merge_local_user_2.before.1.security_domains.0.roles.0 == "super_admin" - - nm_merge_local_user_2.before.1.time_interval_limitation == 15 - - nm_merge_local_user_2.before.0.login_id == "ansible_local_user_2" - - nm_merge_local_user_2.before.0.security_domains | length == 1 - - nm_merge_local_user_2.before.0.security_domains.0.name == "all" - - nm_merge_local_user_2.diff == [] - - nm_merge_local_user_2.proposed.0.email == "secondansibleuser@example.com" - - nm_merge_local_user_2.proposed.0.first_name == "Second Ansible first name" - - nm_merge_local_user_2.proposed.0.last_name == "Second Ansible last name" - - nm_merge_local_user_2.proposed.0.login_id == "ansible_local_user_2" - - nm_merge_local_user_2.proposed.0.remote_id_claim == "ansible_remote_user_2" - - nm_merge_local_user_2.proposed.0.remote_user_authorization == true - - nm_merge_local_user_2.proposed.0.reuse_limitation == 20 - - nm_merge_local_user_2.proposed.0.security_domains | length == 1 - - nm_merge_local_user_2.proposed.0.security_domains.0.name == "all" - - nm_merge_local_user_2.proposed.0.security_domains.0.roles | length == 1 - - nm_merge_local_user_2.proposed.0.security_domains.0.roles.0 == "fabric_admin" - - nm_merge_local_user_2.proposed.0.time_interval_limitation == 10 - - nm_merge_local_user_2_again is not changed - - nm_merge_local_user_2_again.after == nm_merge_local_user_2.after - - nm_merge_local_user_2_again.diff == [] - - nm_merge_local_user_2_again.proposed == nm_merge_local_user_2.proposed - - nm_override_local_users is changed - - nm_override_local_users.after | length == 3 - - nm_override_local_users.after.0.email == "overrideansibleuser@example.com" - - nm_override_local_users.after.0.first_name == "Overridden Ansible first name" - - nm_override_local_users.after.0.last_name == "Overridden Ansible last name" - - nm_override_local_users.after.0.login_id == "ansible_local_user" - - nm_override_local_users.after.0.remote_id_claim == "ansible_remote_user" - - nm_override_local_users.after.0.remote_user_authorization == true - - nm_override_local_users.after.0.reuse_limitation == 15 - - nm_override_local_users.after.0.security_domains | length == 1 - - nm_override_local_users.after.0.security_domains.0.name == "all" - - nm_override_local_users.after.0.security_domains.0.roles | length == 1 - - nm_override_local_users.after.0.security_domains.0.roles.0 == "observer" - - nm_override_local_users.after.0.time_interval_limitation == 5 - - nm_override_local_users.after.1.login_id == "admin" - - nm_override_local_users.after.1.first_name == "admin" - - nm_override_local_users.after.1.remote_user_authorization == false - - nm_override_local_users.after.1.reuse_limitation == 0 - - nm_override_local_users.after.1.security_domains | length == 1 - - nm_override_local_users.after.1.security_domains.0.name == "all" - - nm_override_local_users.after.1.security_domains.0.roles | length == 1 - - nm_override_local_users.after.1.security_domains.0.roles.0 == "super_admin" - - nm_override_local_users.after.1.time_interval_limitation == 0 - - nm_override_local_users.after.2.login_id == "ansible_local_user_3" - - nm_override_local_users.after.2.security_domains.0.name == "all" - - nm_override_local_users.before | length == 3 - - nm_override_local_users.before.2.first_name == "admin" - - nm_override_local_users.before.2.remote_user_authorization == false - - nm_override_local_users.before.2.reuse_limitation == 0 - - nm_override_local_users.before.2.security_domains | length == 1 - - nm_override_local_users.before.2.security_domains.0.name == "all" - - nm_override_local_users.before.2.security_domains.0.roles | length == 1 - - nm_override_local_users.before.2.security_domains.0.roles.0 == "super_admin" - - nm_override_local_users.before.2.time_interval_limitation == 0 - - nm_override_local_users.before.1.email == "updatedansibleuser@example.com" - - nm_override_local_users.before.1.first_name == "Updated Ansible first name" - - nm_override_local_users.before.1.last_name == "Updated Ansible last name" - - nm_override_local_users.before.1.login_id == "ansible_local_user" - - nm_override_local_users.before.1.remote_user_authorization == false - - nm_override_local_users.before.1.reuse_limitation == 25 - - nm_override_local_users.before.1.security_domains | length == 1 - - nm_override_local_users.before.1.security_domains.0.name == "all" - - nm_override_local_users.before.1.security_domains.0.roles | length == 1 - - nm_override_local_users.before.1.security_domains.0.roles.0 == "super_admin" - - nm_override_local_users.before.1.time_interval_limitation == 15 - - nm_override_local_users.before.0.email == "secondansibleuser@example.com" - - nm_override_local_users.before.0.first_name == "Second Ansible first name" - - nm_override_local_users.before.0.last_name == "Second Ansible last name" - - nm_override_local_users.before.0.login_id == "ansible_local_user_2" - - nm_override_local_users.before.0.remote_id_claim == "ansible_remote_user_2" - - nm_override_local_users.before.0.remote_user_authorization == true - - nm_override_local_users.before.0.reuse_limitation == 20 - - nm_override_local_users.before.0.security_domains | length == 1 - - nm_override_local_users.before.0.security_domains.0.name == "all" - - nm_override_local_users.before.0.security_domains.0.roles | length == 1 - - nm_override_local_users.before.0.security_domains.0.roles.0 == "fabric_admin" - - nm_override_local_users.before.0.time_interval_limitation == 10 - - nm_override_local_users.diff == [] - - nm_override_local_users.proposed.0.email == "overrideansibleuser@example.com" - - nm_override_local_users.proposed.0.first_name == "Overridden Ansible first name" - - nm_override_local_users.proposed.0.last_name == "Overridden Ansible last name" - - nm_override_local_users.proposed.0.login_id == "ansible_local_user" - - nm_override_local_users.proposed.0.remote_id_claim == "ansible_remote_user" - - nm_override_local_users.proposed.0.remote_user_authorization == true - - nm_override_local_users.proposed.0.reuse_limitation == 15 - - nm_override_local_users.proposed.0.security_domains | length == 1 - - nm_override_local_users.proposed.0.security_domains.0.name == "all" - - nm_override_local_users.proposed.0.security_domains.0.roles | length == 1 - - nm_override_local_users.proposed.0.security_domains.0.roles.0 == "observer" - - nm_override_local_users.proposed.0.time_interval_limitation == 5 - - nm_override_local_users.proposed.1.login_id == "admin" - - nm_override_local_users.proposed.1.first_name == "admin" - - nm_override_local_users.proposed.1.remote_user_authorization == false - - nm_override_local_users.proposed.1.reuse_limitation == 0 - - nm_override_local_users.proposed.1.security_domains | length == 1 - - nm_override_local_users.proposed.1.security_domains.0.name == "all" - - nm_override_local_users.proposed.1.security_domains.0.roles | length == 1 - - nm_override_local_users.proposed.1.security_domains.0.roles.0 == "super_admin" - - nm_override_local_users.proposed.1.time_interval_limitation == 0 - - nm_override_local_users.proposed.2.login_id == "ansible_local_user_3" - - nm_override_local_users.proposed.2.security_domains.0.name == "all" - - -# DELETE + +- name: Ensure local users do not exist for next tests + cisco.nd.nd_local_user: + <<: *clean_all_local_users + +# --- OVERRIDDEN STATE TESTS --- + +# OVERRIDDEN STATE TESTS: CREATE +- name: Create local users with full and minimum configuration (overridden state - check mode) + cisco.nd.nd_local_user: &create_local_user_overridden_state + <<: *nd_info + config: + - login_id: admin + first_name: admin + remote_user_authorization: false + reuse_limitation: 0 + time_interval_limitation: 0 + security_domains: + - name: all + roles: + - super_admin + - email: ansibleuser@example.com + login_id: ansible_local_user + first_name: Ansible first name + last_name: Ansible last name + user_password: ansibleLocalUserPassword1%Test + 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%Test + security_domains: + - name: all + state: merged + check_mode: true + register: cm_overridden_create_local_users + +- name: Create local users with full and minimum configuration (overridden state - normal mode) + cisco.nd.nd_local_user: + <<: *create_local_user_overridden_state + register: nm_overridden_create_local_users + +- name: Asserts for local users overridden state creation tasks + ansible.builtin.assert: + that: + - cm_overridden_create_local_users is changed + - cm_overridden_create_local_users.after | length == 3 + - cm_overridden_create_local_users.after.0.login_id == "admin" + - cm_overridden_create_local_users.after.0.first_name == "admin" + - cm_overridden_create_local_users.after.0.remote_user_authorization == false + - cm_overridden_create_local_users.after.0.reuse_limitation == 0 + - cm_overridden_create_local_users.after.0.security_domains | length == 1 + - cm_overridden_create_local_users.after.0.security_domains.0.name == "all" + - cm_overridden_create_local_users.after.0.security_domains.0.roles | length == 1 + - cm_overridden_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - cm_overridden_create_local_users.after.0.time_interval_limitation == 0 + - cm_overridden_create_local_users.after.1.email == "ansibleuser@example.com" + - cm_overridden_create_local_users.after.1.first_name == "Ansible first name" + - cm_overridden_create_local_users.after.1.last_name == "Ansible last name" + - cm_overridden_create_local_users.after.1.login_id == "ansible_local_user" + - cm_overridden_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - cm_overridden_create_local_users.after.1.remote_user_authorization == true + - cm_overridden_create_local_users.after.1.reuse_limitation == 20 + - cm_overridden_create_local_users.after.1.security_domains | length == 1 + - cm_overridden_create_local_users.after.1.security_domains.0.name == "all" + - cm_overridden_create_local_users.after.1.security_domains.0.roles | length == 2 + - cm_overridden_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - cm_overridden_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - cm_overridden_create_local_users.after.1.time_interval_limitation == 10 + - cm_overridden_create_local_users.after.2.login_id == "ansible_local_user_2" + - cm_overridden_create_local_users.after.2.security_domains | length == 1 + - cm_overridden_create_local_users.after.2.security_domains.0.name == "all" + - cm_overridden_create_local_users.before | length == 1 + - cm_overridden_create_local_users.before.0.login_id == "admin" + - cm_overridden_create_local_users.before.0.first_name == "admin" + - cm_overridden_create_local_users.before.0.remote_user_authorization == false + - cm_overridden_create_local_users.before.0.reuse_limitation == 0 + - cm_overridden_create_local_users.before.0.security_domains | length == 1 + - cm_overridden_create_local_users.before.0.security_domains.0.name == "all" + - cm_overridden_create_local_users.before.0.security_domains.0.roles | length == 1 + - cm_overridden_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - cm_overridden_create_local_users.before.0.time_interval_limitation == 0 + - cm_overridden_create_local_users.proposed.0.email == "ansibleuser@example.com" + - cm_overridden_create_local_users.proposed.0.first_name == "Ansible first name" + - cm_overridden_create_local_users.proposed.0.last_name == "Ansible last name" + - cm_overridden_create_local_users.proposed.0.login_id == "ansible_local_user" + - cm_overridden_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - cm_overridden_create_local_users.proposed.0.remote_user_authorization == true + - cm_overridden_create_local_users.proposed.0.reuse_limitation == 20 + - cm_overridden_create_local_users.proposed.0.security_domains | length == 1 + - cm_overridden_create_local_users.proposed.0.security_domains.0.name == "all" + - cm_overridden_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - cm_overridden_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - cm_overridden_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - cm_overridden_create_local_users.proposed.0.time_interval_limitation == 10 + - cm_overridden_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - cm_overridden_create_local_users.proposed.1.security_domains | length == 1 + - cm_overridden_create_local_users.proposed.1.security_domains.0.name == "all" + - nm_overridden_create_local_users is changed + - nm_overridden_create_local_users.after.0.first_name == "admin" + - nm_overridden_create_local_users.after.0.remote_user_authorization == false + - nm_overridden_create_local_users.after.0.reuse_limitation == 0 + - nm_overridden_create_local_users.after.0.security_domains | length == 1 + - nm_overridden_create_local_users.after.0.security_domains.0.name == "all" + - nm_overridden_create_local_users.after.0.security_domains.0.roles | length == 1 + - nm_overridden_create_local_users.after.0.security_domains.0.roles.0 == "super_admin" + - nm_overridden_create_local_users.after.0.time_interval_limitation == 0 + - nm_overridden_create_local_users.after.1.email == "ansibleuser@example.com" + - nm_overridden_create_local_users.after.1.first_name == "Ansible first name" + - nm_overridden_create_local_users.after.1.last_name == "Ansible last name" + - nm_overridden_create_local_users.after.1.login_id == "ansible_local_user" + - nm_overridden_create_local_users.after.1.remote_id_claim == "ansible_remote_user" + - nm_overridden_create_local_users.after.1.remote_user_authorization == true + - nm_overridden_create_local_users.after.1.reuse_limitation == 20 + - nm_overridden_create_local_users.after.1.security_domains | length == 1 + - nm_overridden_create_local_users.after.1.security_domains.0.name == "all" + - nm_overridden_create_local_users.after.1.security_domains.0.roles | length == 2 + - nm_overridden_create_local_users.after.1.security_domains.0.roles.0 == "observer" + - nm_overridden_create_local_users.after.1.security_domains.0.roles.1 == "support_engineer" + - nm_overridden_create_local_users.after.1.time_interval_limitation == 10 + - nm_overridden_create_local_users.after.2.login_id == "ansible_local_user_2" + - nm_overridden_create_local_users.after.2.security_domains | length == 1 + - nm_overridden_create_local_users.after.2.security_domains.0.name == "all" + - nm_overridden_create_local_users.before | length == 1 + - nm_overridden_create_local_users.before.0.login_id == "admin" + - nm_overridden_create_local_users.before.0.first_name == "admin" + - nm_overridden_create_local_users.before.0.remote_user_authorization == false + - nm_overridden_create_local_users.before.0.reuse_limitation == 0 + - nm_overridden_create_local_users.before.0.security_domains | length == 1 + - nm_overridden_create_local_users.before.0.security_domains.0.name == "all" + - nm_overridden_create_local_users.before.0.security_domains.0.roles | length == 1 + - nm_overridden_create_local_users.before.0.security_domains.0.roles.0 == "super_admin" + - nm_overridden_create_local_users.before.0.time_interval_limitation == 0 + - nm_overridden_create_local_users.proposed.0.email == "ansibleuser@example.com" + - nm_overridden_create_local_users.proposed.0.first_name == "Ansible first name" + - nm_overridden_create_local_users.proposed.0.last_name == "Ansible last name" + - nm_overridden_create_local_users.proposed.0.login_id == "ansible_local_user" + - nm_overridden_create_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - nm_overridden_create_local_users.proposed.0.remote_user_authorization == true + - nm_overridden_create_local_users.proposed.0.reuse_limitation == 20 + - nm_overridden_create_local_users.proposed.0.security_domains | length == 1 + - nm_overridden_create_local_users.proposed.0.security_domains.0.name == "all" + - nm_overridden_create_local_users.proposed.0.security_domains.0.roles | length == 2 + - nm_overridden_create_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - nm_overridden_create_local_users.proposed.0.security_domains.0.roles.1 == "support_engineer" + - nm_overridden_create_local_users.proposed.0.time_interval_limitation == 10 + - nm_overridden_create_local_users.proposed.1.login_id == "ansible_local_user_2" + - nm_overridden_create_local_users.proposed.1.security_domains | length == 1 + - nm_overridden_create_local_users.proposed.1.security_domains.0.name == "all" + +# OVERRIDDEN STATE TESTS: UPDATE +- name: Override local users with minimum configuration (overridden state - check mode) + cisco.nd.nd_local_user: &update_all_local_users_overridden_state + <<: *nd_info + config: + - email: overrideansibleuser@example.com + login_id: ansible_local_user + first_name: Overridden Ansible first name + last_name: Overridden Ansible last name + user_password: overideansibleLocalUserPassword1% + reuse_limitation: 15 + time_interval_limitation: 5 + security_domains: + - name: all + roles: + - observer + remote_id_claim: ansible_remote_user + remote_user_authorization: true + - login_id: admin + first_name: admin + remote_user_authorization: false + reuse_limitation: 0 + time_interval_limitation: 0 + security_domains: + - name: all + roles: + - super_admin + - login_id: ansible_local_user_3 + user_password: ansibleLocalUser3Password1%Test + security_domains: + - name: all + state: overridden + check_mode: true + register: cm_override_local_users + +- name: Override local users with minimum configuration (overridden state - normal mode) + cisco.nd.nd_local_user: + <<: *update_all_local_users_overridden_state + register: nm_override_local_users + +- name: Asserts for local users overridden state update tasks + ansible.builtin.assert: + that: + - cm_override_local_users is changed + - cm_override_local_users.after | length == 3 + - cm_override_local_users.after.0.email == "overrideansibleuser@example.com" + - cm_override_local_users.after.0.first_name == "Overridden Ansible first name" + - cm_override_local_users.after.0.last_name == "Overridden Ansible last name" + - cm_override_local_users.after.0.login_id == "ansible_local_user" + - cm_override_local_users.after.0.remote_id_claim == "ansible_remote_user" + - cm_override_local_users.after.0.remote_user_authorization == true + - cm_override_local_users.after.0.reuse_limitation == 15 + - cm_override_local_users.after.0.security_domains | length == 1 + - cm_override_local_users.after.0.security_domains.0.name == "all" + - cm_override_local_users.after.0.security_domains.0.roles | length == 1 + - cm_override_local_users.after.0.security_domains.0.roles.0 == "observer" + - cm_override_local_users.after.0.time_interval_limitation == 5 + - cm_override_local_users.after.1.login_id == "admin" + - cm_override_local_users.after.1.first_name == "admin" + - cm_override_local_users.after.1.remote_user_authorization == false + - cm_override_local_users.after.1.reuse_limitation == 0 + - cm_override_local_users.after.1.security_domains | length == 1 + - cm_override_local_users.after.1.security_domains.0.name == "all" + - cm_override_local_users.after.1.security_domains.0.roles | length == 1 + - cm_override_local_users.after.1.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.after.1.time_interval_limitation == 0 + - cm_override_local_users.after.2.login_id == "ansible_local_user_3" + - cm_override_local_users.after.2.security_domains.0.name == "all" + - cm_override_local_users.before | length == 3 + - cm_override_local_users.before.2.first_name == "admin" + - cm_override_local_users.before.2.remote_user_authorization == false + - cm_override_local_users.before.2.reuse_limitation == 0 + - cm_override_local_users.before.2.security_domains | length == 1 + - cm_override_local_users.before.2.security_domains.0.name == "all" + - cm_override_local_users.before.2.security_domains.0.roles | length == 1 + - cm_override_local_users.before.2.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.before.2.time_interval_limitation == 0 + - cm_override_local_users.before.1.email == "updatedansibleuser@example.com" + - cm_override_local_users.before.1.first_name == "Updated Ansible first name" + - cm_override_local_users.before.1.last_name == "Updated Ansible last name" + - cm_override_local_users.before.1.login_id == "ansible_local_user" + - cm_override_local_users.before.1.remote_user_authorization == false + - cm_override_local_users.before.1.reuse_limitation == 25 + - cm_override_local_users.before.1.security_domains | length == 1 + - cm_override_local_users.before.1.security_domains.0.name == "all" + - cm_override_local_users.before.1.security_domains.0.roles | length == 1 + - cm_override_local_users.before.1.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.before.1.time_interval_limitation == 15 + - cm_override_local_users.before.0.email == "secondansibleuser@example.com" + - cm_override_local_users.before.0.first_name == "Second Ansible first name" + - cm_override_local_users.before.0.last_name == "Second Ansible last name" + - cm_override_local_users.before.0.login_id == "ansible_local_user_2" + - cm_override_local_users.before.0.remote_id_claim == "ansible_remote_user_2" + - cm_override_local_users.before.0.remote_user_authorization == true + - cm_override_local_users.before.0.reuse_limitation == 20 + - cm_override_local_users.before.0.security_domains | length == 1 + - cm_override_local_users.before.0.security_domains.0.name == "all" + - cm_override_local_users.before.0.security_domains.0.roles | length == 1 + - cm_override_local_users.before.0.security_domains.0.roles.0 == "fabric_admin" + - cm_override_local_users.before.0.time_interval_limitation == 10 + - cm_override_local_users.proposed.0.email == "overrideansibleuser@example.com" + - cm_override_local_users.proposed.0.first_name == "Overridden Ansible first name" + - cm_override_local_users.proposed.0.last_name == "Overridden Ansible last name" + - cm_override_local_users.proposed.0.login_id == "ansible_local_user" + - cm_override_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - cm_override_local_users.proposed.0.remote_user_authorization == true + - cm_override_local_users.proposed.0.reuse_limitation == 15 + - cm_override_local_users.proposed.0.security_domains | length == 1 + - cm_override_local_users.proposed.0.security_domains.0.name == "all" + - cm_override_local_users.proposed.0.security_domains.0.roles | length == 1 + - cm_override_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - cm_override_local_users.proposed.0.time_interval_limitation == 5 + - cm_override_local_users.proposed.1.login_id == "admin" + - cm_override_local_users.proposed.1.first_name == "admin" + - cm_override_local_users.proposed.1.remote_user_authorization == false + - cm_override_local_users.proposed.1.reuse_limitation == 0 + - cm_override_local_users.proposed.1.security_domains | length == 1 + - cm_override_local_users.proposed.1.security_domains.0.name == "all" + - cm_override_local_users.proposed.1.security_domains.0.roles | length == 1 + - cm_override_local_users.proposed.1.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.proposed.1.time_interval_limitation == 0 + - cm_override_local_users.proposed.2.login_id == "ansible_local_user_3" + - cm_override_local_users.proposed.2.security_domains.0.name == "all" + - cm_override_local_users is changed + - cm_override_local_users.after | length == 3 + - cm_override_local_users.after.0.email == "overrideansibleuser@example.com" + - cm_override_local_users.after.0.first_name == "Overridden Ansible first name" + - cm_override_local_users.after.0.last_name == "Overridden Ansible last name" + - cm_override_local_users.after.0.login_id == "ansible_local_user" + - cm_override_local_users.after.0.remote_id_claim == "ansible_remote_user" + - cm_override_local_users.after.0.remote_user_authorization == true + - cm_override_local_users.after.0.reuse_limitation == 15 + - cm_override_local_users.after.0.security_domains | length == 1 + - cm_override_local_users.after.0.security_domains.0.name == "all" + - cm_override_local_users.after.0.security_domains.0.roles | length == 1 + - cm_override_local_users.after.0.security_domains.0.roles.0 == "observer" + - cm_override_local_users.after.0.time_interval_limitation == 5 + - cm_override_local_users.after.1.login_id == "admin" + - cm_override_local_users.after.1.first_name == "admin" + - cm_override_local_users.after.1.remote_user_authorization == false + - cm_override_local_users.after.1.reuse_limitation == 0 + - cm_override_local_users.after.1.security_domains | length == 1 + - cm_override_local_users.after.1.security_domains.0.name == "all" + - cm_override_local_users.after.1.security_domains.0.roles | length == 1 + - cm_override_local_users.after.1.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.after.1.time_interval_limitation == 0 + - cm_override_local_users.after.2.login_id == "ansible_local_user_3" + - cm_override_local_users.after.2.security_domains.0.name == "all" + - cm_override_local_users.before | length == 3 + - cm_override_local_users.before.2.first_name == "admin" + - cm_override_local_users.before.2.remote_user_authorization == false + - cm_override_local_users.before.2.reuse_limitation == 0 + - cm_override_local_users.before.2.security_domains | length == 1 + - cm_override_local_users.before.2.security_domains.0.name == "all" + - cm_override_local_users.before.2.security_domains.0.roles | length == 1 + - cm_override_local_users.before.2.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.before.2.time_interval_limitation == 0 + - cm_override_local_users.before.1.email == "updatedansibleuser@example.com" + - cm_override_local_users.before.1.first_name == "Updated Ansible first name" + - cm_override_local_users.before.1.last_name == "Updated Ansible last name" + - cm_override_local_users.before.1.login_id == "ansible_local_user" + - cm_override_local_users.before.1.remote_user_authorization == false + - cm_override_local_users.before.1.reuse_limitation == 25 + - cm_override_local_users.before.1.security_domains | length == 1 + - cm_override_local_users.before.1.security_domains.0.name == "all" + - cm_override_local_users.before.1.security_domains.0.roles | length == 1 + - cm_override_local_users.before.1.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.before.1.time_interval_limitation == 15 + - cm_override_local_users.before.0.email == "secondansibleuser@example.com" + - cm_override_local_users.before.0.first_name == "Second Ansible first name" + - cm_override_local_users.before.0.last_name == "Second Ansible last name" + - cm_override_local_users.before.0.login_id == "ansible_local_user_2" + - cm_override_local_users.before.0.remote_id_claim == "ansible_remote_user_2" + - cm_override_local_users.before.0.remote_user_authorization == true + - cm_override_local_users.before.0.reuse_limitation == 20 + - cm_override_local_users.before.0.security_domains | length == 1 + - cm_override_local_users.before.0.security_domains.0.name == "all" + - cm_override_local_users.before.0.security_domains.0.roles | length == 1 + - cm_override_local_users.before.0.security_domains.0.roles.0 == "fabric_admin" + - cm_override_local_users.before.0.time_interval_limitation == 10 + - cm_override_local_users.proposed.0.email == "overrideansibleuser@example.com" + - cm_override_local_users.proposed.0.first_name == "Overridden Ansible first name" + - cm_override_local_users.proposed.0.last_name == "Overridden Ansible last name" + - cm_override_local_users.proposed.0.login_id == "ansible_local_user" + - cm_override_local_users.proposed.0.remote_id_claim == "ansible_remote_user" + - cm_override_local_users.proposed.0.remote_user_authorization == true + - cm_override_local_users.proposed.0.reuse_limitation == 15 + - cm_override_local_users.proposed.0.security_domains | length == 1 + - cm_override_local_users.proposed.0.security_domains.0.name == "all" + - cm_override_local_users.proposed.0.security_domains.0.roles | length == 1 + - cm_override_local_users.proposed.0.security_domains.0.roles.0 == "observer" + - cm_override_local_users.proposed.0.time_interval_limitation == 5 + - cm_override_local_users.proposed.1.login_id == "admin" + - cm_override_local_users.proposed.1.first_name == "admin" + - cm_override_local_users.proposed.1.remote_user_authorization == false + - cm_override_local_users.proposed.1.reuse_limitation == 0 + - cm_override_local_users.proposed.1.security_domains | length == 1 + - cm_override_local_users.proposed.1.security_domains.0.name == "all" + - cm_override_local_users.proposed.1.security_domains.0.roles | length == 1 + - cm_override_local_users.proposed.1.security_domains.0.roles.0 == "super_admin" + - cm_override_local_users.proposed.1.time_interval_limitation == 0 + - cm_override_local_users.proposed.2.login_id == "ansible_local_user_3" + - cm_override_local_users.proposed.2.security_domains.0.name == "all" + +# --- DELETED STATE TESTS --- + - name: Delete local user (check mode) cisco.nd.nd_local_user: &delete_local_user <<: *nd_info @@ -594,7 +1065,6 @@ - cm_delete_local_user.before.2.security_domains.0.roles | length == 1 - cm_delete_local_user.before.2.security_domains.0.roles.0 == "super_admin" - cm_delete_local_user.before.2.time_interval_limitation == 0 - - cm_delete_local_user.diff == [] - cm_delete_local_user.proposed.0.login_id == "ansible_local_user" - nm_delete_local_user is changed - nm_delete_local_user.after | length == 2 @@ -632,20 +1102,14 @@ - nm_delete_local_user.before.2.security_domains.0.roles | length == 1 - nm_delete_local_user.before.2.security_domains.0.roles.0 == "super_admin" - nm_delete_local_user.before.2.time_interval_limitation == 0 - - nm_delete_local_user.diff == [] - nm_delete_local_user.proposed.0.login_id == "ansible_local_user" - nm_delete_local_user_again is not changed - nm_delete_local_user_again.after == nm_delete_local_user.after - nm_delete_local_user_again.before == nm_delete_local_user.after - - nm_delete_local_user_again.diff == [] - nm_delete_local_user_again.proposed == nm_delete_local_user.proposed -# CLEAN UP +# --- 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 - - login_id: ansible_local_user_3 - state: deleted + <<: *clean_all_local_users From a859d133f8c73fd5ff0239888aa61674ae10db8f Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 19 Mar 2026 13:03:29 -0400 Subject: [PATCH 43/51] [ignore] Revert local users endpoints filename to aaa_local_users.py. --- .../v1/infra/{infra_aaa_local_users.py => aaa_local_users.py} | 0 plugins/module_utils/orchestrators/local_user.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename plugins/module_utils/endpoints/v1/infra/{infra_aaa_local_users.py => aaa_local_users.py} (100%) diff --git a/plugins/module_utils/endpoints/v1/infra/infra_aaa_local_users.py b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py similarity index 100% rename from plugins/module_utils/endpoints/v1/infra/infra_aaa_local_users.py rename to plugins/module_utils/endpoints/v1/infra/aaa_local_users.py diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index 0c2a6bf8..b567efa5 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -10,7 +10,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.local_user.local_user import LocalUserModel from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.infra_aaa_local_users import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra.aaa_local_users import ( EpInfraAaaLocalUsersPost, EpInfraAaaLocalUsersPut, EpInfraAaaLocalUsersDelete, From 96a492d7db9267799a467b4d985a26de9f5cea6d Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 19 Mar 2026 13:30:59 -0400 Subject: [PATCH 44/51] [ignore] Change in NDStateMachine initialization to take advantage of from_ansible_config static method from NDConfigCollection. --- plugins/module_utils/nd_state_machine.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 37324020..56adc9a9 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -44,17 +44,10 @@ def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchest # Ongoing collection of configuration objects that were changed self.sent = NDConfigCollection(model_class=self.model_class) # Collection of configuration objects given by user - self.proposed = NDConfigCollection(model_class=self.model_class) - - for config in self.module.params.get("config", []): - # Parse config into model - item = self.model_class.from_config(config) - self.proposed.add(item) + self.proposed = NDConfigCollection.from_ansible_config(data=self.module.params.get("config", []), model_class=self.model_class) self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) - except ValidationError as e: - raise NDStateMachineError(f"Invalid configuration. for config {config}: {str(e)}") from e except Exception as e: raise NDStateMachineError(f"Initialization failed: {str(e)}") from e From 4c593dfe325137a6de26aa6a63143407eaae4077 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 19 Mar 2026 13:32:38 -0400 Subject: [PATCH 45/51] [ignore] Remove ValidationError import from nd_state_machine.py. --- plugins/module_utils/nd_state_machine.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index 56adc9a9..fb812c33 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -5,7 +5,6 @@ from __future__ import absolute_import, division, print_function from typing import Type -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput From f3a0eff5f0d1bd3e044fa665d422a073e969600e Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 24 Mar 2026 12:21:33 -0400 Subject: [PATCH 46/51] [ignore] Add function to nd_local_user module. Slighty fix Documentation and Example sections in nd_local_user module. Remove Dict class inheritance from NDConstantMapping. --- plugins/module_utils/constants.py | 2 +- plugins/modules/nd_local_user.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index adbe345e..f5bfd977 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -10,7 +10,7 @@ from copy import deepcopy -class NDConstantMapping(Dict): +class NDConstantMapping: def __init__(self, data: Dict): self.data = data self.new_dict = deepcopy(data) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 53680e99..4f1ff197 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -109,6 +109,7 @@ notes: - This module is only supported on Nexus Dashboard having version 4.2.1 or higher. - This module is not idempotent when creating or updating a local user object when O(config.user_password) is used. +- When using O(state=overridden), admin user configuration must be specified as it cannot be deleted. """ EXAMPLES = r""" @@ -137,13 +138,14 @@ config: - login_id: local_user_min user_password: localUserMinuser_password - security_domain: all + security_domains: + - name: all state: merged - name: Update local user cisco.nd.nd_local_user: config: - - email: udpateduser@example.com + - email: updateduser@example.com login_id: local_user first_name: Updated user first name last_name: Updated user last name @@ -155,7 +157,6 @@ roles: super_admin - name: ansible_domain roles: observer - roles: super_admin remote_id_claim: "" remote_user_authorization: false state: replaced @@ -173,6 +174,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic from ansible_collections.cisco.nd.plugins.module_utils.models.local_user.local_user import LocalUserModel from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.local_user import LocalUserOrchestrator @@ -185,6 +187,7 @@ def main(): argument_spec=argument_spec, supports_check_mode=True, ) + require_pydantic(module) try: # Initialize StateMachine From a8073b618511b2735225e4c7de21d5f1d9c4ac44 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 24 Mar 2026 12:33:17 -0400 Subject: [PATCH 47/51] [ignore] Make NDBaseOrchestrator a Generic class. --- plugins/module_utils/orchestrators/base.py | 18 ++++++++++-------- .../module_utils/orchestrators/local_user.py | 6 +++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/plugins/module_utils/orchestrators/base.py b/plugins/module_utils/orchestrators/base.py index fe16a524..be790125 100644 --- a/plugins/module_utils/orchestrators/base.py +++ b/plugins/module_utils/orchestrators/base.py @@ -5,14 +5,16 @@ from __future__ import absolute_import, division, print_function from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict -from typing import ClassVar, Type, Optional +from typing import ClassVar, Type, Optional, Generic, TypeVar from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType +ModelType = TypeVar("ModelType", bound=NDBaseModel) -class NDBaseOrchestrator(BaseModel): + +class NDBaseOrchestrator(BaseModel, Generic[ModelType]): model_config = ConfigDict( use_enum_values=True, validate_assignment=True, @@ -20,7 +22,7 @@ class NDBaseOrchestrator(BaseModel): arbitrary_types_allowed=True, ) - model_class: ClassVar[Type[NDBaseModel]] = Type[NDBaseModel] + model_class: ClassVar[Type[NDBaseModel]] = NDBaseModel # NOTE: if not defined by subclasses, return an error as they are required create_endpoint: Type[NDEndpointBaseModel] @@ -33,14 +35,14 @@ class NDBaseOrchestrator(BaseModel): sender: NDModule # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") - def create(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: + def create(self, model_instance: ModelType, **kwargs) -> ResponseType: try: api_endpoint = self.create_endpoint() return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=model_instance.to_payload()) except Exception as e: raise Exception(f"Create failed for {model_instance.get_identifier_value()}: {e}") from e - def update(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: + def update(self, model_instance: ModelType, **kwargs) -> ResponseType: try: api_endpoint = self.update_endpoint() api_endpoint.set_identifiers(model_instance.get_identifier_value()) @@ -48,7 +50,7 @@ def update(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: except Exception as e: raise Exception(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e - def delete(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: + def delete(self, model_instance: ModelType, **kwargs) -> ResponseType: try: api_endpoint = self.delete_endpoint() api_endpoint.set_identifiers(model_instance.get_identifier_value()) @@ -56,7 +58,7 @@ def delete(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: except Exception as e: raise Exception(f"Delete failed for {model_instance.get_identifier_value()}: {e}") from e - def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: + def query_one(self, model_instance: ModelType, **kwargs) -> ResponseType: try: api_endpoint = self.query_one_endpoint() api_endpoint.set_identifiers(model_instance.get_identifier_value()) @@ -64,7 +66,7 @@ def query_one(self, model_instance: NDBaseModel, **kwargs) -> ResponseType: except Exception as e: raise Exception(f"Query failed for {model_instance.get_identifier_value()}: {e}") from e - def query_all(self, model_instance: Optional[NDBaseModel] = None, **kwargs) -> ResponseType: + def query_all(self, model_instance: Optional[ModelType] = None, **kwargs) -> ResponseType: try: api_endpoint = self.query_all_endpoint() result = self.sender.query_obj(api_endpoint.path) diff --git a/plugins/module_utils/orchestrators/local_user.py b/plugins/module_utils/orchestrators/local_user.py index b567efa5..e95a3003 100644 --- a/plugins/module_utils/orchestrators/local_user.py +++ b/plugins/module_utils/orchestrators/local_user.py @@ -4,7 +4,7 @@ from __future__ import absolute_import, division, print_function -from typing import Type +from typing import Type, ClassVar from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel from ansible_collections.cisco.nd.plugins.module_utils.models.local_user.local_user import LocalUserModel @@ -18,8 +18,8 @@ ) -class LocalUserOrchestrator(NDBaseOrchestrator): - model_class: Type[NDBaseModel] = LocalUserModel +class LocalUserOrchestrator(NDBaseOrchestrator[LocalUserModel]): + model_class: ClassVar[Type[NDBaseModel]] = LocalUserModel create_endpoint: Type[NDEndpointBaseModel] = EpInfraAaaLocalUsersPost update_endpoint: Type[NDEndpointBaseModel] = EpInfraAaaLocalUsersPut From 3c54a5ca0cd8a41461d30216d775288c1bfd8a25 Mon Sep 17 00:00:00 2001 From: samitab Date: Tue, 17 Mar 2026 23:03:19 +1000 Subject: [PATCH 48/51] [ignore] Enable CI unit tests --- .github/workflows/ansible-test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ansible-test.yml b/.github/workflows/ansible-test.yml index 21cbabf7..8a91e417 100644 --- a/.github/workflows/ansible-test.yml +++ b/.github/workflows/ansible-test.yml @@ -158,7 +158,6 @@ jobs: units: name: Units in ubuntu-latest - if: false # No unit tests yet needs: - importer runs-on: ubuntu-latest @@ -202,7 +201,7 @@ jobs: integration: name: Integration in ubuntu-latest needs: - # - units + - units - sanity runs-on: ubuntu-latest strategy: From 0af63e6c8286f239308f8c30530efa29a800f688 Mon Sep 17 00:00:00 2001 From: samitab Date: Mon, 23 Mar 2026 18:39:15 +1000 Subject: [PATCH 49/51] [ignore] Add pydantic to requirements.txt --- requirements.txt | 2 +- tests/integration/network-integration.requirements.txt | 2 +- tests/unit/requirements.txt | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 tests/unit/requirements.txt diff --git a/requirements.txt b/requirements.txt index 98907e9a..cc6b2c4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ requests_toolbelt jsonpath-ng lxml -pydantic==2.12.5 \ No newline at end of file +pydantic==2.12.5 diff --git a/tests/integration/network-integration.requirements.txt b/tests/integration/network-integration.requirements.txt index 98907e9a..cc6b2c4b 100644 --- a/tests/integration/network-integration.requirements.txt +++ b/tests/integration/network-integration.requirements.txt @@ -1,4 +1,4 @@ requests_toolbelt jsonpath-ng lxml -pydantic==2.12.5 \ No newline at end of file +pydantic==2.12.5 diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt new file mode 100644 index 00000000..98907e9a --- /dev/null +++ b/tests/unit/requirements.txt @@ -0,0 +1,4 @@ +requests_toolbelt +jsonpath-ng +lxml +pydantic==2.12.5 \ No newline at end of file From 932a18682c766b60a50d830f055be388247a33b1 Mon Sep 17 00:00:00 2001 From: Matt Tarkington Date: Thu, 2 Apr 2026 14:25:20 -0400 Subject: [PATCH 50/51] temp update to pr branch list for ci --- .github/workflows/ansible-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ansible-test.yml b/.github/workflows/ansible-test.yml index 9b1d87e0..e98fd030 100644 --- a/.github/workflows/ansible-test.yml +++ b/.github/workflows/ansible-test.yml @@ -9,6 +9,7 @@ on: branches: - develop - main + - nd42_integration # schedule: # # * is a special character in YAML so you have to quote this string # - cron: '0 6 * * *' From 85523e3b21e1a1f2936c47a91ff10d37dbae8d82 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Mon, 6 Apr 2026 17:44:56 -0400 Subject: [PATCH 51/51] [minor_change] Addition of module for ND 4.2 Links comprising manage and one_manage --- plugins/module_utils/endpoints/mixins.py | 27 ++ .../endpoints/v1/manage/base_path.py | 84 +--- .../endpoints/v1/manage/link_actions.py | 17 + .../module_utils/endpoints/v1/manage/links.py | 59 +++ .../endpoints/v1/one_manage/base_path.py | 29 ++ .../endpoints/v1/one_manage/link_actions.py | 17 + .../endpoints/v1/one_manage/links.py | 57 +++ plugins/module_utils/models/links/links.py | 393 ++++++++++++++++++ plugins/module_utils/nd_state_machine.py | 189 +++++---- plugins/module_utils/orchestrators/links.py | 222 ++++++++++ .../orchestrators/strategies/base_link.py | 78 ++++ .../orchestrators/strategies/manage_link.py | 90 ++++ .../strategies/one_manage_link.py | 97 +++++ plugins/modules/nd_links.py | 259 ++++++++++++ 14 files changed, 1463 insertions(+), 155 deletions(-) create mode 100644 plugins/module_utils/endpoints/v1/manage/link_actions.py create mode 100644 plugins/module_utils/endpoints/v1/manage/links.py create mode 100644 plugins/module_utils/endpoints/v1/one_manage/base_path.py create mode 100644 plugins/module_utils/endpoints/v1/one_manage/link_actions.py create mode 100644 plugins/module_utils/endpoints/v1/one_manage/links.py create mode 100644 plugins/module_utils/models/links/links.py create mode 100644 plugins/module_utils/orchestrators/links.py create mode 100644 plugins/module_utils/orchestrators/strategies/base_link.py create mode 100644 plugins/module_utils/orchestrators/strategies/manage_link.py create mode 100644 plugins/module_utils/orchestrators/strategies/one_manage_link.py create mode 100644 plugins/modules/nd_links.py diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index e7f0620c..82efc6f9 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -84,3 +84,30 @@ class VrfNameMixin(BaseModel): """Mixin for endpoints that require vrf_name parameter.""" vrf_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="VRF name") + +class SrcClusterNameMixin(BaseModel): + """Mixin for endpoints that require src_cluster_name parameter.""" + src_cluster_name: Optional[str] = Field(default=None, min_length=1, description="Source cluster name") + + +class DstClusterNameMixin(BaseModel): + """Mixin for endpoints that require dst_cluster_name parameter.""" + dst_cluster_name: Optional[str] = Field(default=None, min_length=1, description="Destination cluster name") + + +class TicketIdMixin(BaseModel): + """Mixin for endpoints that support change control tickets.""" + ticket_id: Optional[str] = Field(default=None, description="Change Control Ticket Id") + + +class IsLogicalLinkMixin(BaseModel): + """Mixin for endpoints that accept isLogicalLink parameter.""" + is_logical_link: BooleanStringEnum = Field( + default=BooleanStringEnum.FALSE, + description="Indicates if the link is a logical link" + ) + + +class SwitchIdMixin(BaseModel): + """Mixin for endpoints that accept switchId filter.""" + switch_id: Optional[str] = Field(default=None, min_length=1, description="Switch serial number or Id") diff --git a/plugins/module_utils/endpoints/v1/manage/base_path.py b/plugins/module_utils/endpoints/v1/manage/base_path.py index 52bb4e56..6106773c 100644 --- a/plugins/module_utils/endpoints/v1/manage/base_path.py +++ b/plugins/module_utils/endpoints/v1/manage/base_path.py @@ -1,78 +1,18 @@ -# Copyright: (c) 2026, Allen Robel (@arobel) +# -*- coding: utf-8 -*- -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -Centralized base paths for ND Manage API endpoints. +# Single-cluster link endpoints live under /api/v1 (no /manage segment). +# BasePathLinks.path("links") → "/api/v1/links" +# BasePathLinks.path("linkActions", "remove") → "/api/v1/linkActions/remove" -/api/v1/manage +API = "/api/v1" -This module provides a single location to manage all API Manage base paths, -allowing easy modification when API paths change. All endpoint classes -should use these path builders for consistency. -""" -from __future__ import absolute_import, annotations, division, print_function +class BasePathLinks: + """Constructs absolute API paths anchored to /api/v1.""" - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Final - - -class BasePath: - """ - # Summary - - API Endpoints for ND Manage - - ## Description - - Provides centralized endpoint definitions for all ND Manage API endpoints. - This allows API path changes to be managed in a single location. - - ## Usage - - ```python - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath - - # Get a complete base path for ND Manage - path = BasePath.path("inventory", "switches") - # Returns: /api/v1/manage/inventory/switches - ``` - - ## Design Notes - - - All base paths are defined as class constants for easy modification - - Helper methods compose paths from base constants - - Use these methods in Pydantic endpoint models to ensure consistency - - If ND Manage changes base API paths, only this class needs updating - """ - - API: Final = "/api/v1/manage" - - @classmethod - def path(cls, *segments: str) -> str: - """ - # Summary - - Build ND manage API path. - - ## Parameters - - - segments: Path segments to append after /api/v1/manage - - ## Returns - - - Complete ND manage API path - - ## Example - - ```python - path = BasePath.path("inventory", "switches") - # Returns: /api/v1/manage/inventory/switches - ``` + @staticmethod + def path(*segments: str) -> str: + """Join segments onto the base API path. """ - if not segments: - return cls.API - return f"{cls.API}/{'/'.join(segments)}" + tail = "/".join(segments) + return f"{API}/{tail}" diff --git a/plugins/module_utils/endpoints/v1/manage/link_actions.py b/plugins/module_utils/endpoints/v1/manage/link_actions.py new file mode 100644 index 00000000..887cd0bf --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/link_actions.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +# Bulk-delete endpoint for single-cluster links. +# POST /api/v1/linkActions/remove body: {"links": ["uuid-1", ...]} + +from __future__ import annotations + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.enums import VerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.manage.base_path import BasePathLinks + + +class LinkActionsRemovePost(NDEndpointBaseModel): + """POST /api/v1/linkActions/remove — bulk-delete links by UUID list.""" + + path: str = BasePathLinks.path("linkActions", "remove") + verb: VerbEnum = VerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/manage/links.py b/plugins/module_utils/endpoints/v1/manage/links.py new file mode 100644 index 00000000..40be6b15 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/links.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +# Single-cluster (NDFC) link endpoints. +# GET/POST → /api/v1/links PUT → /api/v1/links/{linkId} +# +# These serve fabrics managed by a single NDFC cluster, so query params +# use clusterName / ticketId / switchId rather than srcCluster / dstCluster. + +from __future__ import annotations + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.enums import VerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ClusterNameMixin, + LinkUuidMixin, + SwitchIdMixin, + TicketIdMixin, +) +from .base_path import BasePathLinks + + +# ── GET (list / filtered) ──────────────────────────────────────────── +class LinksGet( + ClusterNameMixin, + TicketIdMixin, + SwitchIdMixin, + NDEndpointBaseModel, +): + """GET /api/v1/links — retrieve links for a single cluster. + + Optional query params: clusterName, ticketId, switchId. + Note: fabricName is required but handled by the strategy's + build_query_all_params() as a query string parameter. + """ + + path: str = BasePathLinks.path("links") + verb: VerbEnum = VerbEnum.GET + + +# ── POST (create — single or bulk) ─────────────────────────────────── +class LinksPost(NDEndpointBaseModel): + """POST /api/v1/links — create one or many links. + + Payload: {"links": [{...}, ...]} + """ + + path: str = BasePathLinks.path("links") + verb: VerbEnum = VerbEnum.POST + + +# ── PUT (update one link by UUID) ──────────────────────────────────── +class LinkPut(LinkUuidMixin, NDEndpointBaseModel): + """PUT /api/v1/links/{linkId} — update a single link. + + The orchestrator appends /{linkId} to the path at request time. + """ + + path: str = BasePathLinks.path("links") + verb: VerbEnum = VerbEnum.PUT diff --git a/plugins/module_utils/endpoints/v1/one_manage/base_path.py b/plugins/module_utils/endpoints/v1/one_manage/base_path.py new file mode 100644 index 00000000..ce2e76d3 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/one_manage/base_path.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +# Multi-cluster endpoints live under /api/v1/manage +# BasePath.path("links") → "/api/v1/manage/links" +# BasePath.path("linkActions", "remove") → "/api/v1/manage/linkActions/remove" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + + +class BasePath: + """Constructs absolute API paths anchored to /api/v1/manage.""" + + API: Final = "/api/v1/manage" + + @classmethod + def path(cls, *segments: str) -> str: + """Build ND manage API path. + + Example: + BasePath.path("links") → "/api/v1/manage/links" + """ + if not segments: + return cls.API + return f"{cls.API}/{'/'.join(segments)}" diff --git a/plugins/module_utils/endpoints/v1/one_manage/link_actions.py b/plugins/module_utils/endpoints/v1/one_manage/link_actions.py new file mode 100644 index 00000000..91f579bd --- /dev/null +++ b/plugins/module_utils/endpoints/v1/one_manage/link_actions.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +# Bulk-delete endpoint for multi-cluster links. +# POST /api/v1/manage/linkActions/remove body: {"links": ["uuid-1", ...]} + +from __future__ import annotations + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import VerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.one_manage.base import BasePath + + +class LinkActionsRemovePost(NDEndpointBaseModel): + """POST /api/v1/manage/linkActions/remove — bulk-delete links by UUID list.""" + + path: str = BasePath.path("linkActions", "remove") + verb: VerbEnum = VerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/one_manage/links.py b/plugins/module_utils/endpoints/v1/one_manage/links.py new file mode 100644 index 00000000..ab3be010 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/one_manage/links.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +# Multi-cluster link endpoints (cross-cluster fabric interconnect). +# GET/POST → /api/v1/manage/links PUT → /api/v1/manage/links/{linkId} +# +# Query params use srcClusterName / dstClusterName because the link +# spans two independent clusters. + +from __future__ import annotations + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.enums import VerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + DstClusterNameMixin, + LinkUuidMixin, + SrcClusterNameMixin, +) +from .base_path import BasePath + + +# ── GET (list / filtered) ──────────────────────────────────────────── +class LinksGet( + SrcClusterNameMixin, + DstClusterNameMixin, + NDEndpointBaseModel, +): + """GET /api/v1/manage/links — retrieve links across clusters. + + Optional query params: srcClusterName, dstClusterName. + Note: fabricName is required but handled by the strategy's + build_query_all_params() as a query string parameter. + """ + + path: str = BasePath.path("links") + verb: VerbEnum = VerbEnum.GET + + +# ── POST (create — single or bulk) ─────────────────────────────────── +class LinksPost(NDEndpointBaseModel): + """POST /api/v1/manage/links — create one or many links. + + Payload: {"links": [{...}, ...]} + """ + + path: str = BasePath.path("links") + verb: VerbEnum = VerbEnum.POST + + +# ── PUT (update one link by UUID) ──────────────────────────────────── +class LinkPut(LinkUuidMixin, NDEndpointBaseModel): + """PUT /api/v1/manage/links/{linkId} — update a single link. + + The orchestrator appends /{linkId} to the path at request time. + """ + + path: str = BasePath.path("links") + verb: VerbEnum = VerbEnum.PUT diff --git a/plugins/module_utils/models/links/links.py b/plugins/module_utils/models/links/links.py new file mode 100644 index 00000000..62a1b4a2 --- /dev/null +++ b/plugins/module_utils/models/links/links.py @@ -0,0 +1,393 @@ +# Copyright: (c) 2026, Cisco and/or its affiliates. +# 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 + +from typing import List, Dict, Any, Optional, ClassVar, Literal, Tuple, Union +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class LinkTemplateInputsModel(NDNestedModel): + """ + Template specific configuration parameters for a link. + """ + + # --- eBGP core settings --- + src_ebgp_asn: Optional[str] = Field(default=None, alias="srcEbgpAsn") + dst_ebgp_asn: Optional[str] = Field(default=None, alias="dstEbgpAsn") + src_ip_address_mask: Optional[str] = Field(default=None, alias="srcIpAddressMask") + dst_ip_address_mask: Optional[str] = Field(default=None, alias="dstIpAddressMask") + dst_ip_address: Optional[str] = Field(default=None, alias="dstIpAddress") + src_ip_address: Optional[str] = Field(default=None, alias="srcIpAddress") + src_ipv6_address_mask: Optional[str] = Field(default=None, alias="srcIpv6AddressMask") + dst_ipv6_address: Optional[str] = Field(default=None, alias="dstIpv6Address") + dst_ipv6_address_mask: Optional[str] = Field(default=None, alias="dstIpv6AddressMask") + link_mtu: Optional[int] = Field(default=None, alias="linkMtu") + routing_tag: Optional[str] = Field(default=None, alias="routingTag") + + # --- eBGP password --- + enable_ebgp_password: Optional[bool] = Field(default=None, alias="enableEbgpPassword") + ebgp_password: Optional[str] = Field(default=None, alias="ebgpPassword") + ebgp_auth_key_encryption_type: Optional[str] = Field(default=None, alias="ebgpAuthKeyEncryptionType") + inherit_ebgp_password_msd_settings: Optional[bool] = Field(default=None, alias="inheritEbgpPasswordMsdSettings") + + # --- eBGP advanced --- + ebgp_bfd: Optional[bool] = Field(default=None, alias="ebgpBfd") + ebgp_log_neighbor_change: Optional[bool] = Field(default=None, alias="ebgpLogNeighborChange") + ebgp_maximum_paths: Optional[int] = Field(default=None, alias="ebgpMaximumPaths") + ebgp_send_comboth: Optional[bool] = Field(default=None, alias="ebgpSendComboth") + ebgp_multihop: Optional[int] = Field(default=None, alias="ebgpMultihop") + + # --- Interface settings --- + interface_admin_state: Optional[bool] = Field(default=None, alias="interfaceAdminState") + macsec: Optional[bool] = Field(default=None, alias="macsec") + fec: Optional[str] = Field(default=None, alias="fec") + speed: Optional[str] = Field(default=None, alias="speed") + mtu: Optional[int] = Field(default=None, alias="mtu") + + # --- DCI tracking --- + dci_tracking_enable_flag: Optional[bool] = Field(default=None, alias="dciTrackingEnableFlag") + dci_tracking: Optional[bool] = Field(default=None, alias="dciTracking") + inherit_ttag_fabric_setting: Optional[bool] = Field(default=None, alias="inheritTtagFabricSetting") + + # --- Interface descriptions and freeform config --- + src_interface_description: Optional[str] = Field(default=None, alias="srcInterfaceDescription") + dst_interface_description: Optional[str] = Field(default=None, alias="dstInterfaceDescription") + src_interface_config: Optional[str] = Field(default=None, alias="srcInterfaceConfig") + dst_interface_config: Optional[str] = Field(default=None, alias="dstInterfaceConfig") + + # --- IP addressing (numbered / unnumbered / vpcPeerKeepalive) --- + src_ip: Optional[str] = Field(default=None, alias="srcIp") + dst_ip: Optional[str] = Field(default=None, alias="dstIp") + src_ipv6: Optional[str] = Field(default=None, alias="srcIpv6") + dst_ipv6: Optional[str] = Field(default=None, alias="dstIpv6") + + # --- BFD / DHCP relay (numbered links) --- + bfd_echo_on_src_interface: Optional[bool] = Field(default=None, alias="bfdEchoOnSrcInterface") + bfd_echo_on_dst_interface: Optional[bool] = Field(default=None, alias="bfdEchoOnDstInterface") + dhcp_relay_on_src_interface: Optional[bool] = Field(default=None, alias="dhcpRelayOnSrcInterface") + dhcp_relay_on_dst_interface: Optional[bool] = Field(default=None, alias="dhcpRelayOnDstInterface") + + # --- Multisite overlay --- + ipv4_trm: Optional[bool] = Field(default=None, alias="ipv4Trm") + ipv6_trm: Optional[bool] = Field(default=None, alias="ipv6Trm") + redistribute_route_server: Optional[bool] = Field(default=None, alias="redistributeRouteServer") + route_server_routing_tag: Optional[str] = Field(default=None, alias="routeServerRoutingTag") + skip_config_generation: Optional[bool] = Field(default=None, alias="skipConfigGeneration") + + # --- Layer3 DCI VRF Lite --- + dst_vrf_name: Optional[str] = Field(default=None, alias="dstVrfName") + src_vrf_name: Optional[str] = Field(default=None, alias="srcVrfName") + ip_redirects: Optional[bool] = Field(default=None, alias="ipRedirects") + ipv4_pim: Optional[bool] = Field(default=None, alias="ipv4Pim") + ipv6_pim: Optional[bool] = Field(default=None, alias="ipv6Pim") + netflow_on_src_interface: Optional[bool] = Field(default=None, alias="netflowOnSrcInterface") + netflow_on_dst_interface: Optional[bool] = Field(default=None, alias="netflowOnDstInterface") + src_netflow_monitor_name: Optional[str] = Field(default=None, alias="srcNetflowMonitorName") + dst_netflow_monitor_name: Optional[str] = Field(default=None, alias="dstNetflowMonitorName") + + # --- eBGP VRF Lite --- + auto_gen_config_default_vrf: Optional[bool] = Field(default=None, alias="autoGenConfigDefaultVrf") + auto_gen_config_nx_peer_default_vrf: Optional[bool] = Field(default=None, alias="autoGenConfigNxPeerDefaultVrf") + auto_gen_config_peer: Optional[bool] = Field(default=None, alias="autoGenConfigPeer") + default_vrf_ebgp_neighbor_password: Optional[str] = Field(default=None, alias="defaultVrfEbgpNeighborPassword") + redistrib_ebgp_route_map_name: Optional[str] = Field(default=None, alias="redistribEbgpRouteMapName") + template_config_gen_peer: Optional[str] = Field(default=None, alias="templateConfigGenPeer") + vrf_name_nx_peer_switch: Optional[str] = Field(default=None, alias="vrfNameNxPeerSwitch") + override_fabric_macsec: Optional[bool] = Field(default=None, alias="overrideFabricMacsec") + + # --- MACsec --- + macsec_cipher_suite: Optional[str] = Field(default=None, alias="macsecCipherSuite") + macsec_fallback_cryptographic_algorithm: Optional[str] = Field(default=None, alias="macsecFallbackCryptographicAlgorithm") + macsec_fallback_key_string: Optional[str] = Field(default=None, alias="macsecFallbackKeyString") + macsec_primary_cryptographic_algorithm: Optional[str] = Field(default=None, alias="macsecPrimaryCryptographicAlgorithm") + macsec_primary_key_string: Optional[str] = Field(default=None, alias="macsecPrimaryKeyString") + + # --- QKD / KME / Trustpoint --- + qkd: Optional[bool] = Field(default=None, alias="qkd") + ignore_certificate: Optional[bool] = Field(default=None, alias="ignoreCertificate") + src_kme_server_ip: Optional[str] = Field(default=None, alias="srcKmeServerIp") + dst_kme_server_ip: Optional[str] = Field(default=None, alias="dstKmeServerIp") + src_macsec_key_chain_prefix: Optional[str] = Field(default=None, alias="srcMacsecKeyChainPrefix") + dst_macsec_key_chain_prefix: Optional[str] = Field(default=None, alias="dstMacsecKeyChainPrefix") + src_qkd_profile_name: Optional[str] = Field(default=None, alias="srcQkdProfileName") + dst_qkd_profile_name: Optional[str] = Field(default=None, alias="dstQkdProfileName") + src_trustpoint_label: Optional[str] = Field(default=None, alias="srcTrustpointLabel") + dst_trustpoint_label: Optional[str] = Field(default=None, alias="dstTrustpointLabel") + + # --- Layer2 DCI --- + bpdu_guard: Optional[str] = Field(default=None, alias="bpduGuard") + trunk_allowed_vlans: Optional[str] = Field(default=None, alias="trunkAllowedVlans") + native_vlan: Optional[int] = Field(default=None, alias="nativeVlan") + port_type_fast: Optional[bool] = Field(default=None, alias="portTypeFast") + mtu_type: Optional[str] = Field(default=None, alias="mtuType") + + # --- vPC Peer Keepalive --- + interface_vrf: Optional[str] = Field(default=None, alias="interfaceVrf") + + +class LinkConfigDataModel(NDNestedModel): + """ + The configData block inside a link. + """ + + policy_type: Optional[str] = Field(default=None, alias="policyType") + template_inputs: Optional[LinkTemplateInputsModel] = Field(default=None, alias="templateInputs") + template_name: Optional[str] = Field(default=None, alias="templateName") + + +class NDLinkModel(NDBaseModel): + """ + Nexus Dashboard Link configuration model. + """ + + # --- Identifier Configuration --- + # Defaults to all 8 fields (multi-cluster scope). + # nd_links.py overrides this at runtime based on strategy. + identifiers: ClassVar[Optional[List[str]]] = [ + "src_cluster_name", + "dst_cluster_name", + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "composite" + + # Alternative identifier set for single cluster scope (reference only; + # the strategy's identifier_fields property returns this list) + MANAGE_SCOPE_IDENTIFIERS: ClassVar[List[str]] = [ + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + + # --- Diff Configuration --- + # API-generated fields to strip before comparing + unwanted_keys: ClassVar[List] = [ + ["linkId"], + ["linkState"], + ["linkDiscovered"], + ["linkPlanned"], + ["linkPresent"], + ["portChannel"], + ["srcSwitchInfo"], + ["dstSwitchInfo"], + ] + + # Fields excluded from diff comparison + exclude_from_diff: ClassVar[set] = { + "link_id", + "src_switch_id", + "dst_switch_id", + } + + # Fields excluded from POST/PUT payload + payload_exclude_fields: ClassVar[set] = { + "link_id", + "src_switch_id", + "dst_switch_id", + } + + # --- Identity Fields --- + src_cluster_name: Optional[str] = Field(default=None, alias="srcClusterName") + dst_cluster_name: Optional[str] = Field(default=None, alias="dstClusterName") + src_fabric_name: Optional[str] = Field(default=None, alias="srcFabricName") + dst_fabric_name: Optional[str] = Field(default=None, alias="dstFabricName") + src_switch_name: Optional[str] = Field(default=None, alias="srcSwitchName") + dst_switch_name: Optional[str] = Field(default=None, alias="dstSwitchName") + src_interface_name: Optional[str] = Field(default=None, alias="srcInterfaceName") + dst_interface_name: Optional[str] = Field(default=None, alias="dstInterfaceName") + + # --- Metadata --- + link_type: Optional[str] = Field(default=None, alias="linkType") + + # --- API-generated fields (captured from GET, excluded from payload) --- + link_id: Optional[str] = Field(default=None, alias="linkId") + src_switch_id: Optional[str] = Field(default=None, alias="srcSwitchId") + dst_switch_id: Optional[str] = Field(default=None, alias="dstSwitchId") + + # --- The actual link configuration --- + config_data: Optional[LinkConfigDataModel] = Field(default=None, alias="configData") + + # --- Diff helper --- + + def to_diff_dict(self, **kwargs) -> Dict[str, Any]: + """ + Override to strip unwanted_keys from the serialized dict + before comparison. The base issubset() works on plain dicts, + so we need to clean the dict first. + """ + data = self.model_dump( + by_alias=True, + exclude_none=True, + exclude=self.exclude_from_diff or None, + mode="json", + **kwargs, + ) + for key_path in self.unwanted_keys: + self._remove_nested_key(data, key_path) + return data + + @staticmethod + def _remove_nested_key(data: Dict, key_path: List[str]) -> None: + """Remove a nested key from a dict by path.""" + current = data + for key in key_path[:-1]: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return + if isinstance(current, dict) and key_path[-1] in current: + del current[key_path[-1]] + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict: + """Ansible argument spec for playbook validation.""" + return dict( + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + src_cluster_name=dict(type="str"), + dst_cluster_name=dict(type="str"), + src_fabric_name=dict(type="str"), + dst_fabric_name=dict(type="str"), + src_switch_name=dict(type="str"), + dst_switch_name=dict(type="str"), + src_interface_name=dict(type="str"), + dst_interface_name=dict(type="str"), + src_switch_id=dict(type="str"), + dst_switch_id=dict(type="str"), + link_type=dict(type="str", default="multi_cluster_planned_link"), + config_data=dict( + type="dict", + options=dict( + policy_type=dict(type="str"), + template_name=dict(type="str"), + template_inputs=dict( + type="dict", + options=dict( + # eBGP core + src_ebgp_asn=dict(type="str"), + dst_ebgp_asn=dict(type="str"), + src_ip_address_mask=dict(type="str"), + dst_ip_address_mask=dict(type="str"), + dst_ip_address=dict(type="str"), + src_ip_address=dict(type="str"), + src_ipv6_address_mask=dict(type="str"), + dst_ipv6_address=dict(type="str"), + dst_ipv6_address_mask=dict(type="str"), + link_mtu=dict(type="int"), + routing_tag=dict(type="str"), + # eBGP password + enable_ebgp_password=dict(type="bool"), + ebgp_password=dict(type="str", no_log=True), + ebgp_auth_key_encryption_type=dict(type="str"), + inherit_ebgp_password_msd_settings=dict(type="bool"), + # eBGP advanced + ebgp_bfd=dict(type="bool"), + ebgp_log_neighbor_change=dict(type="bool"), + ebgp_maximum_paths=dict(type="int"), + ebgp_send_comboth=dict(type="bool"), + ebgp_multihop=dict(type="int"), + # Interface + interface_admin_state=dict(type="bool"), + macsec=dict(type="bool"), + fec=dict(type="str"), + speed=dict(type="str"), + mtu=dict(type="int"), + # DCI / TTAG + dci_tracking_enable_flag=dict(type="bool"), + dci_tracking=dict(type="bool"), + inherit_ttag_fabric_setting=dict(type="bool"), + # Descriptions + src_interface_description=dict(type="str"), + dst_interface_description=dict(type="str"), + src_interface_config=dict(type="str"), + dst_interface_config=dict(type="str"), + # IP addressing + src_ip=dict(type="str"), + dst_ip=dict(type="str"), + src_ipv6=dict(type="str"), + dst_ipv6=dict(type="str"), + # BFD / DHCP + bfd_echo_on_src_interface=dict(type="bool"), + bfd_echo_on_dst_interface=dict(type="bool"), + dhcp_relay_on_src_interface=dict(type="bool"), + dhcp_relay_on_dst_interface=dict(type="bool"), + # Multisite overlay + ipv4_trm=dict(type="bool"), + ipv6_trm=dict(type="bool"), + redistribute_route_server=dict(type="bool"), + route_server_routing_tag=dict(type="str"), + skip_config_generation=dict(type="bool"), + # L3 DCI VRF Lite + dst_vrf_name=dict(type="str"), + src_vrf_name=dict(type="str"), + ip_redirects=dict(type="bool"), + ipv4_pim=dict(type="bool"), + ipv6_pim=dict(type="bool"), + netflow_on_src_interface=dict(type="bool"), + netflow_on_dst_interface=dict(type="bool"), + src_netflow_monitor_name=dict(type="str"), + dst_netflow_monitor_name=dict(type="str"), + # eBGP VRF Lite + auto_gen_config_default_vrf=dict(type="bool"), + auto_gen_config_nx_peer_default_vrf=dict(type="bool"), + auto_gen_config_peer=dict(type="bool"), + default_vrf_ebgp_neighbor_password=dict(type="str", no_log=True), + redistrib_ebgp_route_map_name=dict(type="str"), + template_config_gen_peer=dict(type="str"), + vrf_name_nx_peer_switch=dict(type="str"), + override_fabric_macsec=dict(type="bool"), + # MACsec + macsec_cipher_suite=dict(type="str"), + macsec_fallback_cryptographic_algorithm=dict(type="str"), + macsec_fallback_key_string=dict(type="str", no_log=True), + macsec_primary_cryptographic_algorithm=dict(type="str"), + macsec_primary_key_string=dict(type="str", no_log=True), + # QKD / KME / Trustpoint + qkd=dict(type="bool"), + ignore_certificate=dict(type="bool"), + src_kme_server_ip=dict(type="str"), + dst_kme_server_ip=dict(type="str"), + src_macsec_key_chain_prefix=dict(type="str"), + dst_macsec_key_chain_prefix=dict(type="str"), + src_qkd_profile_name=dict(type="str"), + dst_qkd_profile_name=dict(type="str"), + src_trustpoint_label=dict(type="str"), + dst_trustpoint_label=dict(type="str"), + # L2 DCI + bpdu_guard=dict(type="str"), + trunk_allowed_vlans=dict(type="str"), + native_vlan=dict(type="int"), + port_type_fast=dict(type="bool"), + mtu_type=dict(type="str"), + # vPC + interface_vrf=dict(type="str"), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) diff --git a/plugins/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py index fb812c33..79736571 100644 --- a/plugins/module_utils/nd_state_machine.py +++ b/plugins/module_utils/nd_state_machine.py @@ -1,10 +1,11 @@ # Copyright: (c) 2026, Gaspard Micol (@gmicol) +# Copyright: (c) 2026, 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 -from typing import Type +from typing import Union, List from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput @@ -15,151 +16,173 @@ class NDStateMachine: """ - Generic State Machine for Nexus Dashboard. + Generic State Machine for Nexus Dashboard (Bulk Support). """ - def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchestrator]): - """ - Initialize the ND State Machine. - """ + def __init__(self, module: AnsibleModule, model_orchestrator: Union[NDBaseOrchestrator, type]): self.module = module self.nd_module = NDModule(self.module) - # Operation tracking self.output = NDOutput(output_level=module.params.get("output_level", "normal")) - # Configuration - self.model_orchestrator = model_orchestrator(sender=self.nd_module) + # Accept either an orchestrator instance or a class. + # Instance: complex modules like links (pre-configured with strategy) + # Class: simple modules like local_users (instantiated here with sender) + if isinstance(model_orchestrator, type): + self.model_orchestrator = model_orchestrator(sender=self.nd_module) + else: + self.model_orchestrator = model_orchestrator + self.model_class = self.model_orchestrator.model_class self.state = self.module.params["state"] - # Initialize collections try: response_data = self.model_orchestrator.query_all() - # State of configuration objects in ND before change execution - self.before = NDConfigCollection.from_api_response(response_data=response_data, model_class=self.model_class) - # State of current configuration objects in ND during change execution + + # before = snapshot of ND state before any changes + self.before = NDConfigCollection.from_api_response( + response_data=response_data, model_class=self.model_class + ) + # existing = mutable copy that tracks state during changes self.existing = self.before.copy() - # Ongoing collection of configuration objects that were changed + # sent = what we actually sent to the API self.sent = NDConfigCollection(model_class=self.model_class) - # Collection of configuration objects given by user - self.proposed = NDConfigCollection.from_ansible_config(data=self.module.params.get("config", []), model_class=self.model_class) + # proposed = what the user wants (from playbook config) + self.proposed = NDConfigCollection.from_ansible_config( + data=self.module.params.get("config", []), + model_class=self.model_class, + ) - self.output.assign(after=self.existing, before=self.before, proposed=self.proposed) + self.output.assign( + after=self.existing, before=self.before, proposed=self.proposed + ) except Exception as e: raise NDStateMachineError(f"Initialization failed: {str(e)}") from e - # State Management (core function) def manage_state(self) -> None: - """ - Manage state according to desired configuration. - """ - # Execute state operations + """Main entry point — routes to the appropriate handler.""" if self.state in ["merged", "replaced", "overridden"]: self._manage_create_update_state() - if self.state == "overridden": self._manage_override_deletions() - elif self.state == "deleted": self._manage_delete_state() - else: raise NDStateMachineError(f"Invalid state: {self.state}") def _manage_create_update_state(self) -> None: - """ - Handle merged/replaced/overridden states. - """ + """Handle merged/replaced/overridden with bulk optimization.""" + + # PHASE 1: COLLECT (no API calls) + to_create: List = [] + to_update: List = [] + for proposed_item in self.proposed: - # Extract identifier identifier = proposed_item.get_identifier_value() try: - # Determine diff status diff_status = self.existing.get_diff_config(proposed_item) - # No changes needed if diff_status == "no_diff": continue - # Prepare final config based on state if self.state == "merged": - # Merge with existing final_item = self.existing.merge(proposed_item) else: - # Replace or create if diff_status == "changed": self.existing.replace(proposed_item) else: self.existing.add(proposed_item) final_item = proposed_item - # Execute API operation - if diff_status == "changed": - if not self.module.check_mode: - self.model_orchestrator.update(final_item) - elif diff_status == "new": - if not self.module.check_mode: - self.model_orchestrator.create(final_item) - self.sent.add(final_item) + if diff_status == "new": + to_create.append(final_item) + elif diff_status == "changed": + to_update.append(final_item) - # Log operation - self.output.assign(after=self.existing) + self.sent.add(final_item) except Exception as e: error_msg = f"Failed to process {identifier}: {e}" if not self.module.params.get("ignore_errors", False): raise NDStateMachineError(error_msg) from e + # PHASE 2: EXECUTE (minimum API calls) + + # Creates: bulk if orchestrator supports it, individual otherwise + if to_create: + if not self.module.check_mode: + if ( + getattr(self.model_orchestrator, "supports_bulk_create", False) + and hasattr(self.model_orchestrator, "create_bulk") + ): + self.model_orchestrator.create_bulk(to_create) + else: + for item in to_create: + self.model_orchestrator.create(item) + + # Updates: always individual (no bulk PUT exists in ND APIs) + if to_update: + if not self.module.check_mode: + for item in to_update: + self.model_orchestrator.update(item) + + self.output.assign(after=self.existing) + def _manage_override_deletions(self) -> None: - """ - Delete items not in proposed config (for overridden state). - """ + """Delete items not in proposed config (overridden state).""" diff_identifiers = self.before.get_diff_identifiers(self.proposed) + to_delete: List = [] for identifier in diff_identifiers: - try: - existing_item = self.existing.get(identifier) - if not existing_item: - continue - - # Execute delete - if not self.module.check_mode: - self.model_orchestrator.delete(existing_item) + existing_item = self.existing.get(identifier) + if not existing_item: + continue + to_delete.append(existing_item) - # Remove from collection - self.existing.delete(identifier) + if to_delete: + self._execute_deletes(to_delete) + for item in to_delete: + self.existing.delete(item.get_identifier_value()) - # Log deletion - self.output.assign(after=self.existing) - - except Exception as e: - error_msg = f"Failed to delete {identifier}: {e}" - if not self.module.params.get("ignore_errors", False): - raise NDStateMachineError(error_msg) from e + self.output.assign(after=self.existing) def _manage_delete_state(self) -> None: - """Handle deleted state.""" - for proposed_item in self.proposed: - try: - identifier = proposed_item.get_identifier_value() + """Handle deleted state with bulk optimization.""" + to_delete: List = [] - existing_item = self.existing.get(identifier) - if not existing_item: - continue - - # Execute delete - if not self.module.check_mode: - self.model_orchestrator.delete(existing_item) + for proposed_item in self.proposed: + identifier = proposed_item.get_identifier_value() + existing_item = self.existing.get(identifier) + if not existing_item: + continue + to_delete.append(existing_item) - # Remove from collection - self.existing.delete(identifier) + if to_delete: + self._execute_deletes(to_delete) + for item in to_delete: + self.existing.delete(item.get_identifier_value()) - # Log deletion - self.output.assign(after=self.existing) + self.output.assign(after=self.existing) - except Exception as e: - error_msg = f"Failed to delete {identifier}: {e}" - if not self.module.params.get("ignore_errors", False): - raise NDStateMachineError(error_msg) from e + def _execute_deletes(self, items_to_delete: List) -> None: + """ + Shared delete executor — bulk if supported, individual otherwise. + Used by both _manage_delete_state and _manage_override_deletions. + """ + if not items_to_delete or self.module.check_mode: + return + + if ( + getattr(self.model_orchestrator, "supports_bulk_delete", False) + and hasattr(self.model_orchestrator, "delete_bulk") + ): + self.model_orchestrator.delete_bulk(items_to_delete) + else: + for item in items_to_delete: + try: + self.model_orchestrator.delete(item) + except Exception as e: + error_msg = f"Failed to delete {item.get_identifier_value()}: {e}" + if not self.module.params.get("ignore_errors", False): + raise NDStateMachineError(error_msg) from e diff --git a/plugins/module_utils/orchestrators/links.py b/plugins/module_utils/orchestrators/links.py new file mode 100644 index 00000000..fb0ac593 --- /dev/null +++ b/plugins/module_utils/orchestrators/links.py @@ -0,0 +1,222 @@ +# Copyright: (c) 2026, Cisco and/or its affiliates. +# 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 + +from typing import ClassVar, Type, List, Dict, Optional +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_link import NDLinkModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.base_link_strategy import BaseLinkStrategy + +class NDLinkOrchestrator(NDBaseOrchestrator["NDLinkModel"]): + """ + Orchestrator for ND Link operations. + + Delegates endpoint selection to a BaseLinkStrategy, enabling the same + orchestrator to work with both single cluster and multi cluster scopes. + + Adds on top of the base orchestrator: + - Bulk create (POST with {"links": [...]}) + - Bulk delete (POST to /linkActions/remove) + - linkId resolution (composite_key -> API generated UUID mapping) + """ + + model_class: ClassVar[Type[NDBaseModel]] = NDLinkModel + + # Bulk support flags — the state machine checks these to decide + # whether to batch operations or call individually + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True + supports_bulk_update: ClassVar[bool] = False + + create_endpoint: Optional[Type] = None + update_endpoint: Optional[Type] = None + delete_endpoint: Optional[Type] = None + query_one_endpoint: Optional[Type] = None + query_all_endpoint: Optional[Type] = None + + # The strategy instance — injected by nd_links.py at construction time + strategy: Optional[BaseLinkStrategy] = None + + def model_post_init(self, __context) -> None: + """ + Initialize per-instance state after Pydantic construction. + + Creates _link_id_map as instance state (not class state) + to avoid shared mutable state between orchestrator instances. + """ + if self.strategy is None: + raise ValueError("NDLinkOrchestrator requires a strategy instance") + + # Instance-level linkId map — NOT a class variable. + # Using object.__setattr__ because Pydantic's __setattr__ + # would reject underscore-prefixed attributes. + object.__setattr__(self, '_link_id_map', {}) + + # ─── Query Operations ──────────────────────────────────────────── + + def query_all(self, model_instance=None, **kwargs) -> ResponseType: + """ + GET all links for the fabric and build the linkId mapping. + + The strategy provides: + - The correct endpoint class (/links vs /manage/links) + - The correct query params (fabricName, clusterName, etc.) + """ + try: + endpoint = self.strategy.links_get_cls() + params = self.strategy.build_query_all_params(**kwargs) + + # Build path with query string + path = endpoint.path + if params: + qs = "&".join(f"{k}={v}" for k, v in params.items()) + path = f"{path}?{qs}" + + result = self.sender.query_obj(path) + + # API may wrap links in different keys depending on scope/version + if isinstance(result, dict): + links_list = result.get("items", result.get("links", [])) + elif isinstance(result, list): + links_list = result + else: + links_list = [] + + # Build composite_key → linkId mapping for update/delete + self._build_link_id_map(links_list) + + return links_list + except Exception as e: + raise Exception(f"Query all links failed: {e}") from e + + def _build_link_id_map(self, links_list: List[Dict]) -> None: + """ + Build mapping from composite identity → linkId. + """ + link_id_map = {} + for link_data in links_list: + try: + model = NDLinkModel.from_response(link_data) + composite_key = model.get_identifier_value() + link_id = link_data.get("linkId") + if composite_key and link_id: + link_id_map[composite_key] = link_id + except (ValueError, KeyError): + # Skip unparseable links (e.g., discovered links with + # incomplete identity fields) + continue + object.__setattr__(self, '_link_id_map', link_id_map) + + def _resolve_link_id(self, model_instance: NDLinkModel) -> str: + """ + Resolve a model's composite key to its API-generated linkId. + + Raises ValueError if the link doesn't exist in the mapping. + """ + try: + composite_key = model_instance.get_identifier_value() + except ValueError as e: + raise ValueError( + f"Cannot resolve linkId — invalid composite key: {e}" + ) from e + + link_id = self._link_id_map.get(composite_key) + if not link_id: + raise ValueError( + f"Cannot resolve linkId for {composite_key}. " + f"Link may not exist on ND or query_all() wasn't called." + ) + return link_id + + # ─── Create Operations ─────────────────────────────────────────── + + def create(self, model_instance: NDLinkModel, **kwargs) -> ResponseType: + """ + Create a single link via the bulk POST endpoint. + + Even for one link we use the bulk endpoint because the Links + API only has POST /links which accepts {"links": [...]}. + """ + return self.create_bulk([model_instance]) + + def create_bulk(self, model_instances: List[NDLinkModel], **kwargs) -> ResponseType: + """ + Create multiple links in a single POST call. + + Payload: {"links": [{link1_payload}, {link2_payload}, ...]} + 1 API call instead of N. + """ + if not model_instances: + return {} + + try: + endpoint = self.strategy.links_post_cls() + payload = { + "links": [instance.to_payload() for instance in model_instances] + } + return self.sender.request( + path=endpoint.path, + method=endpoint.verb, + data=payload, + ) + except Exception as e: + raise Exception(f"Bulk create failed: {e}") from e + + # ─── Update Operations ─────────────────────────────────────────── + + def update(self, model_instance: NDLinkModel, **kwargs) -> ResponseType: + """ + Update a single link via PUT /links/{linkId}. + + Resolves the linkId from the composite key mapping, appends + it to the base path, and sends the full link payload. + """ + try: + link_id = self._resolve_link_id(model_instance) + endpoint = self.strategy.link_put_cls() + + # Append linkId: /api/v1/links → /api/v1/links/LINK-UUID-15130 + path = f"{endpoint.path}/{link_id}" + payload = model_instance.to_payload() + + return self.sender.request( + path=path, + method=endpoint.verb, + data=payload, + ) + except Exception as e: + raise Exception( + f"Update failed for {model_instance.get_identifier_value()}: {e}" + ) from e + + # ─── Delete Operations ─────────────────────────────────────────── + + def delete(self, model_instance: NDLinkModel, **kwargs) -> ResponseType: + """Delete a single link via the bulk delete endpoint.""" + return self.delete_bulk([model_instance]) + + def delete_bulk(self, model_instances: List[NDLinkModel], **kwargs) -> ResponseType: + """ + Delete multiple links in a single POST call. + + Uses POST /linkActions/remove with {"links": ["UUID-1", "UUID-2"]} + The delete API uses POST (not DELETE) because it accepts a list body. + """ + if not model_instances: + return {} + + try: + link_ids = [self._resolve_link_id(inst) for inst in model_instances] + endpoint = self.strategy.link_actions_remove_post_cls() + payload = {"links": link_ids} + + return self.sender.request( + path=endpoint.path, + method=endpoint.verb, + data=payload, + ) + except Exception as e: + raise Exception(f"Bulk delete failed: {e}") from e diff --git a/plugins/module_utils/orchestrators/strategies/base_link.py b/plugins/module_utils/orchestrators/strategies/base_link.py new file mode 100644 index 00000000..f277bd02 --- /dev/null +++ b/plugins/module_utils/orchestrators/strategies/base_link.py @@ -0,0 +1,78 @@ +# Copyright: (c) 2026, Cisco and/or its affiliates. +# 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 + +from abc import ABC, abstractmethod +from typing import Type, List, Optional, Dict, Any +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel + +class BaseLinkStrategy(ABC): + """ + Abstract base for link endpoint strategies. + """ + + def __init__( + self, + fabric_name: str, + cluster_name: Optional[str] = None, + ticket_id: Optional[str] = None, + **kwargs, + ): + """ + Common parameters shared by all strategies. + + Args: + fabric_name: Required — used in GET query params to filter by fabric. + cluster_name: Optional — used by single cluster endpoints. + ticket_id: Optional — used for change control ticket tracking. + """ + self.fabric_name = fabric_name + self.cluster_name = cluster_name + self.ticket_id = ticket_id + + # -- Endpoint class properties (return the CLASS, not an instance) ----- + + @property + @abstractmethod + def links_get_cls(self) -> Type[NDEndpointBaseModel]: + """Endpoint class for GET (list/filter) links.""" + pass + + @property + @abstractmethod + def links_post_cls(self) -> Type[NDEndpointBaseModel]: + """Endpoint class for POST (create) links.""" + pass + + @property + @abstractmethod + def link_put_cls(self) -> Type[NDEndpointBaseModel]: + """Endpoint class for PUT (update single) link.""" + pass + + @property + @abstractmethod + def link_actions_remove_post_cls(self) -> Type[NDEndpointBaseModel]: + """Endpoint class for POST bulk-delete.""" + pass + + # -- Identity configuration ------------------------------------------- + + @property + @abstractmethod + def identifier_fields(self) -> List[str]: + """Which model fields form the composite identity for this scope.""" + pass + + # -- Query parameter builders ----------------------------------------- + + @abstractmethod + def build_query_all_params(self, **kwargs) -> Optional[Dict[str, Any]]: + """Build query params dict for GET all links.""" + pass + + @abstractmethod + def build_query_one_params(self, model_instance, **kwargs) -> Optional[Dict[str, Any]]: + """Build query params dict for GET single link (client-side filter).""" + pass diff --git a/plugins/module_utils/orchestrators/strategies/manage_link.py b/plugins/module_utils/orchestrators/strategies/manage_link.py new file mode 100644 index 00000000..1147fc57 --- /dev/null +++ b/plugins/module_utils/orchestrators/strategies/manage_link.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Strategy for single-cluster (NDFC) link operations. + +Folder layout: endpoints/v1/manage/ +API path: /api/v1/links (NO /manage in the URL path) + +The folder name "manage" reflects the ND internal module structure, +NOT the URL path. Single-cluster links live at /api/v1/links. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.base_link import BaseLinkStrategy + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.links import LinksGet, LinksPost, LinkPut +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.link_actions import LinkActionsRemovePost + + +class ManageLinkStrategy(BaseLinkStrategy): + """Single-cluster (NDFC) scope. + + API surface + ----------- + GET /api/v1/links + POST /api/v1/links (bulk create) + PUT /api/v1/links/{linkId} (single update) + POST /api/v1/linkActions/remove (bulk delete) + + Composite identity (6 fields — no cluster names) + ------------------------------------------------- + (src_fabric, dst_fabric, src_switch, dst_switch, src_intf, dst_intf) + """ + + # -- endpoint classes -------------------------------------------------- + + @property + def links_get_cls(self): + return LinksGet + + @property + def links_post_cls(self): + return LinksPost + + @property + def link_put_cls(self): + return LinkPut + + @property + def link_actions_remove_post_cls(self): + return LinkActionsRemovePost + + # -- identity configuration ------------------------------------------- + + @property + def identifier_fields(self): + """Single-cluster links don't include cluster names in identity.""" + return [ + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + + # -- query-param helpers ----------------------------------------------- + + def build_query_all_params(self, **kwargs): + """Build query params for GET /api/v1/links. + + Required: fabricName (from self.fabric_name) + Optional kwargs: cluster_name, ticket_id, switch_id + """ + params = {"fabricName": self.fabric_name} + if self.cluster_name: + params["clusterName"] = self.cluster_name + if self.ticket_id: + params["ticketId"] = self.ticket_id + if kwargs.get("switch_id"): + params["switchId"] = kwargs["switch_id"] + return params + + def build_query_one_params(self, model_instance, **kwargs): + """No dedicated single-get endpoint; query_one filters client-side.""" + return self.build_query_all_params(**kwargs) diff --git a/plugins/module_utils/orchestrators/strategies/one_manage_link.py b/plugins/module_utils/orchestrators/strategies/one_manage_link.py new file mode 100644 index 00000000..974e6035 --- /dev/null +++ b/plugins/module_utils/orchestrators/strategies/one_manage_link.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Strategy for multi-cluster link operations. + +Folder layout: endpoints/v1/one_manage/ +API path: /api/v1/manage/links (HAS /manage in the URL path) + +The folder name "one_manage" reflects ND's "One Manage" multi-cluster +orchestration feature. These endpoints manage links that span across +two independent clusters. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from strategies.base_link_strategy import BaseLinkStrategy + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.one_manage.links import LinksGet, LinksPost, LinkPut +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.one_manage.link_actions import LinkActionsRemovePost + + +class OneManageLinkStrategy(BaseLinkStrategy): + """Multi-cluster scope. + + API surface + ----------- + GET /api/v1/manage/links + POST /api/v1/manage/links (bulk create) + PUT /api/v1/manage/links/{linkId} (single update) + POST /api/v1/manage/linkActions/remove (bulk delete) + + Composite identity (8 fields — includes cluster names) + ------------------------------------------------------ + (src_cluster, dst_cluster, src_fabric, dst_fabric, + src_switch, dst_switch, src_intf, dst_intf) + """ + + # -- endpoint classes -------------------------------------------------- + + @property + def links_get_cls(self): + return LinksGet + + @property + def links_post_cls(self): + return LinksPost + + @property + def link_put_cls(self): + return LinkPut + + @property + def link_actions_remove_post_cls(self): + return LinkActionsRemovePost + + # -- identity configuration ------------------------------------------- + + @property + def identifier_fields(self): + """Multi-cluster links use 8-field identity including cluster names.""" + return [ + "src_cluster_name", + "dst_cluster_name", + "src_fabric_name", + "dst_fabric_name", + "src_switch_name", + "dst_switch_name", + "src_interface_name", + "dst_interface_name", + ] + + # -- query-param helpers ----------------------------------------------- + + def build_query_all_params(self, **kwargs): + """Build query params for GET /api/v1/manage/links. + + Required: fabricName (from self.fabric_name) + Optional kwargs: src_cluster_name, dst_cluster_name + """ + params = {"fabricName": self.fabric_name} + if kwargs.get("src_cluster_name"): + params["srcClusterName"] = kwargs["src_cluster_name"] + if kwargs.get("dst_cluster_name"): + params["dstClusterName"] = kwargs["dst_cluster_name"] + return params + + def build_query_one_params(self, model_instance, **kwargs): + """Pre-filter by cluster names when available for efficiency.""" + params = {"fabricName": self.fabric_name} + if hasattr(model_instance, "src_cluster_name") and model_instance.src_cluster_name: + params["srcClusterName"] = model_instance.src_cluster_name + if hasattr(model_instance, "dst_cluster_name") and model_instance.dst_cluster_name: + params["dstClusterName"] = model_instance.dst_cluster_name + return params diff --git a/plugins/modules/nd_links.py b/plugins/modules/nd_links.py new file mode 100644 index 00000000..bdc9ff75 --- /dev/null +++ b/plugins/modules/nd_links.py @@ -0,0 +1,259 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco and/or its affiliates. +# 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 + +DOCUMENTATION = r""" +--- +module: nd_link +version_added: "1.0.0" +short_description: Manages links on Cisco Nexus Dashboard. +description: +- Manages network links between switches on Cisco Nexus Dashboard. +- Supports both single-cluster (/manage/links) and multi-cluster (/links) scopes. +- Supports bulk operations for efficient creation and deletion. +- Auto-detects scope from config, or use O(link_scope) to override. +author: +- Cisco ND Team +options: + fabric_name: + description: + - Name of the fabric. Required for querying links. + type: str + required: true + link_scope: + description: + - Which API scope to use for link operations. + - V(auto) auto-detects based on presence of cluster fields in config. + - V(manage) uses /api/v1/manage/links (single-cluster scope). + - V(one_manage) uses /api/v1/links (multi-cluster scope). + type: str + choices: [ auto, manage, one_manage ] + default: auto + cluster_name: + description: + - Target cluster name for multi-cluster operations. + - Only used when O(link_scope=one_manage) or auto-detected. + type: str + ticket_id: + description: + - Change Control Ticket Id for multi-cluster operations. + - Only used when O(link_scope=one_manage) or auto-detected. + type: str + config: + description: + - A list of link configurations to manage. + type: list + elements: dict + required: true + state: + description: + - The desired state of the link resources. + type: str + choices: [ merged, replaced, overridden, deleted ] + default: merged +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +""" + +EXAMPLES = r""" +# Multi-cluster links (auto-detected from cluster fields in config) +- name: Create multi-cluster links (bulk) + cisco.nd.nd_link: + fabric_name: fab1 + config: + - src_cluster_name: cluster-191 + dst_cluster_name: cluster-187 + src_fabric_name: fab2 + dst_fabric_name: fab1 + src_switch_name: v1-bgw2 + dst_switch_name: v1-bgw1 + src_interface_name: Ethernet1/12 + dst_interface_name: Ethernet1/12 + link_type: multi_cluster_planned_link + config_data: + policy_type: multisiteUnderlay + template_inputs: + src_ebgp_asn: "200" + dst_ebgp_asn: "100" + src_ip_address_mask: "30.30.30.10/31" + dst_ip_address: "30.30.30.11" + link_mtu: 9216 + state: merged + +# Single-cluster links (auto-detected, no cluster names) +- name: Create fabric links + cisco.nd.nd_link: + fabric_name: fab1 + config: + - src_fabric_name: fab1 + dst_fabric_name: fab1 + src_switch_name: leaf-1 + dst_switch_name: spine-1 + src_interface_name: Ethernet1/1 + dst_interface_name: Ethernet1/1 + config_data: + policy_type: ipv6LinkLocal + template_inputs: + interface_admin_state: true + mtu: 9216 + state: merged + +# Explicit scope override with change control +- name: Create links with change control + cisco.nd.nd_link: + fabric_name: fab1 + link_scope: one_manage + cluster_name: cluster-191 + ticket_id: CHG-12345 + config: + - src_fabric_name: fab2 + dst_fabric_name: fab1 + src_switch_name: v1-bgw2 + dst_switch_name: v1-bgw1 + src_interface_name: Ethernet1/12 + dst_interface_name: Ethernet1/12 + config_data: + policy_type: multisiteUnderlay + template_inputs: + src_ebgp_asn: "200" + dst_ebgp_asn: "100" + state: merged + +# Delete links (bulk operation) +- name: Delete links + cisco.nd.nd_link: + fabric_name: fab1 + config: + - src_cluster_name: cluster-191 + dst_cluster_name: cluster-187 + src_fabric_name: fab2 + dst_fabric_name: fab1 + src_switch_name: v1-bgw2 + dst_switch_name: v1-bgw1 + src_interface_name: Ethernet1/12 + dst_interface_name: Ethernet1/12 + 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_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.nd_link_orchestrator import NDLinkOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.manage_link_strategy import ManageLinkStrategy +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.strategies.one_manage_link_strategy import OneManageLinkStrategy +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_link import NDLinkModel +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDStateMachineError + + +def determine_strategy(module): + """ + Pick the right strategy based on link_scope param or auto-detection. + + Auto-detection logic: + - If ANY config item has src_cluster_name or dst_cluster_name → one_manage + - Otherwise → manage + """ + link_scope = module.params.get("link_scope", "auto") + fabric_name = module.params.get("fabric_name") + cluster_name = module.params.get("cluster_name") + ticket_id = module.params.get("ticket_id") + + if link_scope == "manage": + return ManageLinkStrategy( + fabric_name=fabric_name, + ) + + elif link_scope == "one_manage": + return OneManageLinkStrategy( + fabric_name=fabric_name, + cluster_name=cluster_name, + ticket_id=ticket_id, + ) + + elif link_scope == "auto": + #Auto-detect by inspecting config items + config = module.params.get("config", []) + has_cluster_fields = any( + item.get("src_cluster_name") or item.get("dst_cluster_name") + for item in config + ) + + if has_cluster_fields: + return OneManageLinkStrategy( + fabric_name=fabric_name, + cluster_name=cluster_name, + ticket_id=ticket_id, + ) + else: + return ManageLinkStrategy( + fabric_name=fabric_name, + ) + + else: + module.fail_json(msg=f"Invalid link_scope: {link_scope}") + + +def main(): + #Combine base ND args with link-specific args + argument_spec = nd_argument_spec() + argument_spec.update(NDLinkModel.get_argument_spec()) + argument_spec.update( + fabric_name=dict(type="str", required=True), + link_scope=dict( + type="str", + default="auto", + choices=["auto", "manage", "one_manage"], + ), + cluster_name=dict(type="str"), + ticket_id=dict(type="str"), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + try: + #Step 1 — Determine which API scope to use + strategy = determine_strategy(module) + + #Step 2 — Set model identifiers based on scope + NDLinkModel.identifiers = strategy.identifier_fields + + #Step 3 — Create orchestrator with strategy + nd_module = NDModule(module) + orchestrator = NDLinkOrchestrator( + sender=nd_module, + strategy=strategy, + ) + + #Step 4 — State machine handles everything + state_machine = NDStateMachine( + module=module, + model_orchestrator=orchestrator, + ) + + state_machine.manage_state() + + #Step 5 — Return results + result = state_machine.output.format() + module.exit_json(**result) + + except NDStateMachineError as e: + module.fail_json(msg=str(e)) + except Exception as e: + module.fail_json(msg=f"Unexpected error: {str(e)}") + + +if __name__ == "__main__": + main()