From 5c8b04d12a157430186d70ade94b0ca2959e5299 Mon Sep 17 00:00:00 2001 From: Allen Robel Date: Mon, 2 Mar 2026 13:53:49 -1000 Subject: [PATCH 01/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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/61] [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 8620046ecb6d7becb2522bc94d83b4d8cb36139b Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Tue, 24 Mar 2026 17:12:46 +0530 Subject: [PATCH 50/61] feat(nd_policy): rebase policy module with nd42_integration --- plugins/module_utils/endpoints/mixins.py | 12 + .../v1/manage/manage_config_templates.py | 133 + .../endpoints/v1/manage/manage_policies.py | 426 +++ .../v1/manage/manage_policy_actions.py | 328 ++ plugins/module_utils/models/__init__.py | 1 + .../models/manage_policies/__init__.py | 51 + .../models/manage_policies/enums.py | 90 + .../models/manage_policies/policy_actions.py | 132 + .../models/manage_policies/policy_base.py | 175 ++ .../models/manage_policies/policy_crud.py | 147 + plugins/module_utils/nd_policy_resources.py | 2658 +++++++++++++++++ plugins/modules/nd_policy.py | 683 +++++ ...ndpoints_api_v1_manage_config_templates.py | 237 ++ .../test_endpoints_api_v1_manage_policies.py | 683 +++++ ..._endpoints_api_v1_manage_policy_actions.py | 442 +++ 15 files changed, 6198 insertions(+) create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_config_templates.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_policies.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py create mode 100644 plugins/module_utils/models/__init__.py create mode 100644 plugins/module_utils/models/manage_policies/__init__.py create mode 100644 plugins/module_utils/models/manage_policies/enums.py create mode 100644 plugins/module_utils/models/manage_policies/policy_actions.py create mode 100644 plugins/module_utils/models/manage_policies/policy_base.py create mode 100644 plugins/module_utils/models/manage_policies/policy_crud.py create mode 100644 plugins/module_utils/nd_policy_resources.py create mode 100644 plugins/modules/nd_policy.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policies.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policy_actions.py diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index e7f0620c..a59819c7 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -74,12 +74,24 @@ class NodeNameMixin(BaseModel): node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") +class PolicyIdMixin(BaseModel): + """Mixin for endpoints that require policy_id parameter.""" + + policy_id: Optional[str] = Field(default=None, min_length=1, description="Policy ID (e.g., POLICY-12345)") + + 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 TicketIdMixin(BaseModel): + """Mixin for endpoints that require ticket_id parameter.""" + + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + class VrfNameMixin(BaseModel): """Mixin for endpoints that require vrf_name parameter.""" diff --git a/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py new file mode 100644 index 00000000..dcf02d47 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ND Manage Config Templates endpoint models. + +This module contains endpoint definitions for configuration template +operations in the ND Manage API. + +Endpoints covered: +- GET /configTemplates/{templateName}/parameters - Get template parameters +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +from typing import Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) + + +# ============================================================================ +# Query parameter classes +# ============================================================================ + + +class ConfigTemplateEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for configTemplates endpoints. + + ## Description + + Per manage.json, the GET /configTemplates/{templateName}/parameters + endpoint accepts ``clusterName`` as a query parameter. + + ## Parameters + + - cluster_name → clusterName + """ + + model_config = ConfigDict(extra="forbid") + + cluster_name: Optional[str] = Field( + default=None, + min_length=1, + description="Target cluster name for multi-cluster deployments", + ) + + +# ============================================================================ +# GET /configTemplates/{templateName}/parameters +# ============================================================================ + + +class EpManageConfigTemplateParametersGet(NDEndpointBaseModel): + """ + # Summary + + ND Manage Config Template Parameters GET Endpoint + + ## Description + + Retrieve only the parameters for a configuration template. + Returns the ``parameters`` array without the template content. + + ## Path + + - /api/v1/manage/configTemplates/{templateName}/parameters + + ## Verb + + - GET + + ## Usage + + ```python + ep = EpManageConfigTemplateParametersGet() + ep.template_name = "switch_freeform" + path = ep.path # /api/v1/manage/configTemplates/switch_freeform/parameters + verb = ep.verb # GET + ``` + """ + + class_name: Literal["EpManageConfigTemplateParametersGet"] = Field( + default="EpManageConfigTemplateParametersGet", + frozen=True, + description="Class name for backward compatibility", + ) + template_name: Optional[str] = Field( + default=None, + min_length=1, + description="Configuration template name (e.g., switch_freeform, feature_enable)", + ) + endpoint_params: ConfigTemplateEndpointParams = Field( + default_factory=ConfigTemplateEndpointParams, + description="Query parameters: clusterName", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + if self.template_name is None: + raise ValueError("template_name must be set before accessing path") + base = BasePath.path("configTemplates", self.template_name, "parameters") + qs = self.endpoint_params.to_query_string() + return f"{base}?{qs}" if qs else base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/manage/manage_policies.py b/plugins/module_utils/endpoints/v1/manage/manage_policies.py new file mode 100644 index 00000000..1ba84a50 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_policies.py @@ -0,0 +1,426 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ND Manage Policies endpoint models. + +This module contains endpoint definitions for policy CRUD operations +in the ND Manage API. + +Endpoints covered: +- GET /fabrics/{fabricName}/policies - List policies (with Lucene filtering) +- GET /fabrics/{fabricName}/policies/{policyId} - Get policy by ID +- POST /fabrics/{fabricName}/policies - Create policies in bulk +- PUT /fabrics/{fabricName}/policies/{policyId} - Update a policy +- DELETE /fabrics/{fabricName}/policies/{policyId} - Delete a policy +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +from typing import Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + PolicyIdMixin, + TicketIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + CompositeQueryParams, + EndpointQueryParams, + LuceneQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) + + +# ============================================================================ +# Query parameter classes +# ============================================================================ + + +class PoliciesGetEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for GET /policies. + + ## Description + + Per manage.json, the GET /policies endpoint accepts only ``clusterName`` + as a named query parameter. Lucene filtering (filter, max, offset, sort) + is handled separately via ``LuceneQueryParams``. + + ## Parameters + + - cluster_name → clusterName + """ + + model_config = ConfigDict(extra="forbid") + + cluster_name: Optional[str] = Field( + default=None, + min_length=1, + description="Target cluster name for multi-cluster deployments", + ) + + +class PolicyMutationEndpointParams(EndpointQueryParams): + """ + # Summary + + Shared query parameters for policy mutation endpoints. + + ## Description + + Per manage.json, the following mutation endpoints accept + ``clusterName`` and ``ticketId``: + + - POST /policies + - PUT /policies/{policyId} + - DELETE /policies/{policyId} + + ## Parameters + + - cluster_name → clusterName + - ticket_id → ticketId + """ + + model_config = ConfigDict(extra="forbid") + + cluster_name: Optional[str] = Field( + default=None, + min_length=1, + description="Target cluster name for multi-cluster deployments", + ) + ticket_id: Optional[str] = Field( + default=None, + min_length=1, + max_length=64, + pattern=r"^[a-zA-Z][a-zA-Z0-9_-]+$", + description="Change Control Ticket Id", + ) + + +# ============================================================================ +# Base class for /fabrics/{fabricName}/policies +# ============================================================================ + + +class _EpManagePoliciesBase(FabricNameMixin, NDEndpointBaseModel): + """ + Base class for Fabric Policies endpoints. + + Provides the common base path for all HTTP methods on the + ``/api/v1/manage/fabrics/{fabricName}/policies`` endpoint. + """ + + @property + def _base_path(self) -> str: + """Build the base endpoint path (without policyId).""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.path("fabrics", self.fabric_name, "policies") + + +# ============================================================================ +# GET /fabrics/{fabricName}/policies +# GET /fabrics/{fabricName}/policies/{policyId} +# ============================================================================ + + +class EpManagePoliciesGet(PolicyIdMixin, _EpManagePoliciesBase): + """ + # Summary + + ND Manage Policies GET Endpoint + + ## Description + + Retrieve policies from a fabric. Supports querying all policies, + a specific policy by ID, or filtered results via Lucene parameters. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/policies + - /api/v1/manage/fabrics/{fabricName}/policies/{policyId} + + ## Verb + + - GET + + ## Usage + + ```python + # All policies for a fabric + ep = EpManagePoliciesGet() + ep.fabric_name = "my-fabric" + path = ep.path # /api/v1/manage/fabrics/my-fabric/policies + verb = ep.verb # GET + + # Specific policy by ID + ep = EpManagePoliciesGet() + ep.fabric_name = "my-fabric" + ep.policy_id = "POLICY-12345" + path = ep.path # /api/v1/manage/fabrics/my-fabric/policies/POLICY-12345 + + # Lucene-filtered query + ep = EpManagePoliciesGet() + ep.fabric_name = "my-fabric" + ep.lucene_params.filter = "switchId:FDO123 AND templateName:switch_freeform" + ep.lucene_params.max = 100 + path = ep.path + ``` + """ + + class_name: Literal["EpManagePoliciesGet"] = Field( + default="EpManagePoliciesGet", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: PoliciesGetEndpointParams = Field( + default_factory=PoliciesGetEndpointParams, + description="Endpoint-specific query parameters", + ) + lucene_params: LuceneQueryParams = Field( + default_factory=LuceneQueryParams, + description="Lucene-style filtering parameters (max, offset, sort, filter)", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + if self.policy_id: + base = f"{self._base_path}/{self.policy_id}" + else: + base = self._base_path + + composite = CompositeQueryParams() + composite.add(self.endpoint_params) + composite.add(self.lucene_params) + qs = composite.to_query_string() + return f"{base}?{qs}" if qs else base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +# ============================================================================ +# POST /fabrics/{fabricName}/policies +# ============================================================================ + + +class EpManagePoliciesPost(_EpManagePoliciesBase): + """ + # Summary + + ND Manage Policies POST Endpoint + + ## Description + + Create one or more policies in a fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/policies + + ## Verb + + - POST + + ## Usage + + ```python + ep = EpManagePoliciesPost() + ep.fabric_name = "my-fabric" + path = ep.path + verb = ep.verb + ``` + + ## Request Body Example + + ```json + { + "policies": [ + { + "switchId": "FDO25031SY4", + "templateName": "feature_enable", + "entityType": "switch", + "entityName": "SWITCH", + "templateInputs": {"featureName": "lacp"}, + "priority": 500 + } + ] + } + ``` + """ + + class_name: Literal["EpManagePoliciesPost"] = Field( + default="EpManagePoliciesPost", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: PolicyMutationEndpointParams = Field( + default_factory=PolicyMutationEndpointParams, + description="Query parameters: clusterName, ticketId", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + qs = self.endpoint_params.to_query_string() + return f"{self._base_path}?{qs}" if qs else self._base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +# ============================================================================ +# PUT /fabrics/{fabricName}/policies/{policyId} +# ============================================================================ + + +class EpManagePoliciesPut(PolicyIdMixin, _EpManagePoliciesBase): + """ + # Summary + + ND Manage Policies PUT Endpoint + + ## Description + + Update a specific policy in a fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/policies/{policyId} + + ## Verb + + - PUT + + ## Usage + + ```python + ep = EpManagePoliciesPut() + ep.fabric_name = "my-fabric" + ep.policy_id = "POLICY-12345" + path = ep.path + verb = ep.verb + ``` + + ## Request Body Example + + ```json + { + "switchId": "FDO25031SY4", + "templateName": "feature_enable", + "entityType": "switch", + "entityName": "SWITCH", + "templateInputs": {"featureName": "lacp"}, + "priority": 100 + } + ``` + """ + + class_name: Literal["EpManagePoliciesPut"] = Field( + default="EpManagePoliciesPut", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: PolicyMutationEndpointParams = Field( + default_factory=PolicyMutationEndpointParams, + description="Query parameters: clusterName, ticketId", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + if self.policy_id is None: + raise ValueError("policy_id must be set before accessing path") + base = f"{self._base_path}/{self.policy_id}" + qs = self.endpoint_params.to_query_string() + return f"{base}?{qs}" if qs else base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.PUT + + +# ============================================================================ +# DELETE /fabrics/{fabricName}/policies/{policyId} +# ============================================================================ + + +class EpManagePoliciesDelete(PolicyIdMixin, _EpManagePoliciesBase): + """ + # Summary + + ND Manage Policies DELETE Endpoint + + ## Description + + Delete a specific policy from a fabric by its policy ID. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/policies/{policyId} + + ## Verb + + - DELETE + + ## Usage + + ```python + ep = EpManagePoliciesDelete() + ep.fabric_name = "my-fabric" + ep.policy_id = "POLICY-12345" + path = ep.path + verb = ep.verb + ``` + """ + + class_name: Literal["EpManagePoliciesDelete"] = Field( + default="EpManagePoliciesDelete", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: PolicyMutationEndpointParams = Field( + default_factory=PolicyMutationEndpointParams, + description="Query parameters: clusterName, ticketId", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + if self.policy_id is None: + raise ValueError("policy_id must be set before accessing path") + base = f"{self._base_path}/{self.policy_id}" + qs = self.endpoint_params.to_query_string() + return f"{base}?{qs}" if qs else base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.DELETE diff --git a/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py new file mode 100644 index 00000000..7ae6bd24 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ND Manage Policy Actions endpoint models. + +This module contains endpoint definitions for policy action operations +in the ND Manage API. + +Endpoints covered: +- POST /fabrics/{fabricName}/policyActions/markDelete - Mark-delete policies +- POST /fabrics/{fabricName}/policyActions/pushConfig - Deploy policy configs +- POST /fabrics/{fabricName}/policyActions/remove - Remove policies in bulk +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +from typing import Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) + + +# ============================================================================ +# Query parameter classes +# ============================================================================ + + +class PolicyActionMutationEndpointParams(EndpointQueryParams): + """ + # Summary + + Shared query parameters for policy action mutation endpoints. + + ## Description + + Per manage.json, the following policy action endpoints accept + ``clusterName`` and ``ticketId``: + + - POST /policyActions/markDelete + - POST /policyActions/remove + + ## Parameters + + - cluster_name → clusterName + - ticket_id → ticketId + """ + + model_config = ConfigDict(extra="forbid") + + cluster_name: Optional[str] = Field( + default=None, + min_length=1, + description="Target cluster name for multi-cluster deployments", + ) + ticket_id: Optional[str] = Field( + default=None, + min_length=1, + max_length=64, + pattern=r"^[a-zA-Z][a-zA-Z0-9_-]+$", + description="Change Control Ticket Id", + ) + + +class PolicyPushConfigEndpointParams(EndpointQueryParams): + """ + # Summary + + Query parameters for the pushConfig endpoint. + + ## Description + + Per manage.json, ``POST /policyActions/pushConfig`` accepts only + ``clusterName`` (no ``ticketId``). + + ## Parameters + + - cluster_name → clusterName + """ + + model_config = ConfigDict(extra="forbid") + + cluster_name: Optional[str] = Field( + default=None, + min_length=1, + description="Target cluster name for multi-cluster deployments", + ) + + +# ============================================================================ +# Base class for /fabrics/{fabricName}/policyActions/{action} +# ============================================================================ + + +class _EpManagePolicyActionsBase(FabricNameMixin, NDEndpointBaseModel): + """ + Base class for Policy Actions endpoints. + + Provides the common base path builder for all HTTP methods on the + ``/api/v1/manage/fabrics/{fabricName}/policyActions/{action}`` endpoints. + """ + + def _action_path(self, action: str) -> str: + """Build the base endpoint path for a specific action.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.path("fabrics", self.fabric_name, "policyActions", action) + + +# ============================================================================ +# POST /fabrics/{fabricName}/policyActions/markDelete +# ============================================================================ + + +class EpManagePolicyActionsMarkDeletePost(_EpManagePolicyActionsBase): + """ + # Summary + + ND Manage Policy Actions — Mark Delete Endpoint + + ## Description + + Mark-delete policies in bulk. This flags policies for deletion + without immediately removing them from the controller. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/policyActions/markDelete + + ## Verb + + - POST + + ## Usage + + ```python + ep = EpManagePolicyActionsMarkDeletePost() + ep.fabric_name = "my-fabric" + path = ep.path + verb = ep.verb + ``` + + ## Request Body Example + + ```json + { + "policyIds": ["POLICY-121110", "POLICY-121120"] + } + ``` + """ + + class_name: Literal["EpManagePolicyActionsMarkDeletePost"] = Field( + default="EpManagePolicyActionsMarkDeletePost", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: PolicyActionMutationEndpointParams = Field( + default_factory=PolicyActionMutationEndpointParams, + description="Query parameters: clusterName, ticketId", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + base = self._action_path("markDelete") + qs = self.endpoint_params.to_query_string() + return f"{base}?{qs}" if qs else base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +# ============================================================================ +# POST /fabrics/{fabricName}/policyActions/pushConfig +# ============================================================================ + + +class EpManagePolicyActionsPushConfigPost(_EpManagePolicyActionsBase): + """ + # Summary + + ND Manage Policy Actions — Push Config Endpoint + + ## Description + + Push (deploy) configuration for policies in bulk. This deploys + the policy configurations to the target switches. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/policyActions/pushConfig + + ## Verb + + - POST + + ## Usage + + ```python + ep = EpManagePolicyActionsPushConfigPost() + ep.fabric_name = "my-fabric" + path = ep.path + verb = ep.verb + ``` + + ## Note + + pushConfig does NOT accept ``ticketId`` per manage.json spec. + + ## Request Body Example + + ```json + { + "policyIds": ["POLICY-121110", "POLICY-121120"] + } + ``` + """ + + class_name: Literal["EpManagePolicyActionsPushConfigPost"] = Field( + default="EpManagePolicyActionsPushConfigPost", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: PolicyPushConfigEndpointParams = Field( + default_factory=PolicyPushConfigEndpointParams, + description="Query parameters: clusterName only (no ticketId)", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + base = self._action_path("pushConfig") + qs = self.endpoint_params.to_query_string() + return f"{base}?{qs}" if qs else base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +# ============================================================================ +# POST /fabrics/{fabricName}/policyActions/remove +# ============================================================================ + + +class EpManagePolicyActionsRemovePost(_EpManagePolicyActionsBase): + """ + # Summary + + ND Manage Policy Actions — Remove Endpoint + + ## Description + + Remove (permanently delete) policies in bulk. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/policyActions/remove + + ## Verb + + - POST + + ## Usage + + ```python + ep = EpManagePolicyActionsRemovePost() + ep.fabric_name = "my-fabric" + path = ep.path + verb = ep.verb + ``` + + ## Request Body Example + + ```json + { + "policyIds": ["POLICY-121110", "POLICY-121120"] + } + ``` + """ + + class_name: Literal["EpManagePolicyActionsRemovePost"] = Field( + default="EpManagePolicyActionsRemovePost", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: PolicyActionMutationEndpointParams = Field( + default_factory=PolicyActionMutationEndpointParams, + description="Query parameters: clusterName, ticketId", + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + base = self._action_path("remove") + qs = self.endpoint_params.to_query_string() + return f"{base}?{qs}" if qs else base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/models/__init__.py b/plugins/module_utils/models/__init__.py new file mode 100644 index 00000000..7c68785e --- /dev/null +++ b/plugins/module_utils/models/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/plugins/module_utils/models/manage_policies/__init__.py b/plugins/module_utils/models/manage_policies/__init__.py new file mode 100644 index 00000000..bc63e129 --- /dev/null +++ b/plugins/module_utils/models/manage_policies/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""manage_policies models package. + +Re-exports all model classes and enums from their individual modules so +that consumers can import directly from the package: + + from .models.manage_policies import PolicyCreate, PolicyEntityType, ... +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +# --- Enums --- +from .enums import ( # noqa: F401 + PolicyEntityType, +) + +# --- Base models --- +from .policy_base import ( # noqa: F401 + PolicyCreate, +) + +# --- CRUD models --- +from .policy_crud import ( # noqa: F401 + PolicyCreateBulk, + PolicyUpdate, +) + +# --- Action models --- +from .policy_actions import ( # noqa: F401 + PolicyIds, +) + + +__all__ = [ + # Enums + "PolicyEntityType", + # Base models + "PolicyCreate", + # CRUD models + "PolicyCreateBulk", + "PolicyUpdate", + # Action models + "PolicyIds", +] \ No newline at end of file diff --git a/plugins/module_utils/models/manage_policies/enums.py b/plugins/module_utils/models/manage_policies/enums.py new file mode 100644 index 00000000..d0794ff0 --- /dev/null +++ b/plugins/module_utils/models/manage_policies/enums.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Enumerations for Policy Operations. + +Extracted from OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from enum import Enum +from typing import List, Union + + +# ============================================================================= +# ENUMS - Extracted from OpenAPI Schema components/schemas +# ============================================================================= + + +class PolicyEntityType(str, Enum): + """ + Valid entity types for policies. + + Based on: components/schemas/policyEntityType + Description: Type of entity the policy is attached to. + """ + + SWITCH = "switch" + CONFIG_PROFILE = "configProfile" + INTERFACE = "interface" + + @classmethod + def choices(cls) -> List[str]: + """Return list of valid choices.""" + return [e.value for e in cls] + + @classmethod + def from_user_input(cls, value: str) -> "PolicyEntityType": + """ + Convert user-friendly input to enum value. + Accepts underscore-separated values like 'config_profile' -> 'configProfile' + """ + if not value: + return cls.SWITCH + # Try direct match first + try: + return cls(value) + except ValueError: + pass + # Try converting underscore to camelCase + parts = value.lower().split("_") + camel_case = parts[0] + "".join(word.capitalize() for word in parts[1:]) + try: + return cls(camel_case) + except ValueError: + raise ValueError(f"Invalid entity type: {value}. Valid options: {cls.choices()}") + + @classmethod + def normalize(cls, value: Union[str, "PolicyEntityType", None]) -> "PolicyEntityType": + """ + Normalize input to enum value (case-insensitive). + Accepts: SWITCH, switch, config_profile, configProfile, etc. + """ + if value is None: + return cls.SWITCH + if isinstance(value, cls): + return value + if isinstance(value, str): + v_lower = value.lower() + for et in cls: + if et.value.lower() == v_lower: + return et + # Try converting underscore to camelCase + parts = v_lower.split("_") + if len(parts) > 1: + camel_case = parts[0] + "".join(word.capitalize() for word in parts[1:]) + for et in cls: + if et.value == camel_case: + return et + raise ValueError(f"Invalid PolicyEntityType: {value}. Valid: {cls.choices()}") + + +__all__ = [ + "PolicyEntityType", +] diff --git a/plugins/module_utils/models/manage_policies/policy_actions.py b/plugins/module_utils/models/manage_policies/policy_actions.py new file mode 100644 index 00000000..9374475a --- /dev/null +++ b/plugins/module_utils/models/manage_policies/policy_actions.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Pydantic model for policy bulk-action request bodies. + +This module provides ``PolicyIds``, the request body used by all three +policy action endpoints: + +- POST /api/v1/manage/fabrics/{fabricName}/policyActions/markDelete +- POST /api/v1/manage/fabrics/{fabricName}/policyActions/pushConfig +- POST /api/v1/manage/fabrics/{fabricName}/policyActions/remove + +## Schema origin (manage.json) + +- ``PolicyIds`` ← ``policyActions`` schema +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +from typing import Any, ClassVar, Dict, List + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + + +class PolicyIds(NDNestedModel): + """ + Request body model for policy bulk actions. + + ## Description + + Used for markDelete, pushConfig, and remove policy actions. + Contains a list of policy IDs to perform the action on. + + ## API Endpoints + + - POST /api/v1/manage/fabrics/{fabricName}/policyActions/markDelete + - POST /api/v1/manage/fabrics/{fabricName}/policyActions/pushConfig + - POST /api/v1/manage/fabrics/{fabricName}/policyActions/remove + + ## Request Body Schema (from manage.json) + + ```json + { + "policyIds": ["POLICY-121110", "POLICY-121120"] + } + ``` + + ## Usage + + ```python + # Mark-delete policies + body = PolicyIds(policy_ids=["POLICY-121110", "POLICY-121120"]) + payload = body.to_request_dict() + # {"policyIds": ["POLICY-121110", "POLICY-121120"]} + + # Push config for policies + body = PolicyIds(policy_ids=["POLICY-121110"]) + payload = body.to_request_dict() + + # Remove/delete policies + body = PolicyIds(policy_ids=["POLICY-121110", "POLICY-121120", "POLICY-121130"]) + payload = body.to_request_dict() + ``` + """ + + identifiers: ClassVar[List[str]] = [] + + policy_ids: List[str] = Field( + default_factory=list, + min_length=1, + alias="policyIds", + description="List of policy IDs to perform action on", + ) + + @field_validator("policy_ids") + @classmethod + def validate_policy_ids(cls, v: List[str]) -> List[str]: + """ + Validate that all policy IDs are non-empty strings. + + ## Parameters + + - v: List of policy IDs + + ## Returns + + - Validated list of policy IDs + + ## Raises + + - ValueError: If any policy ID is empty or not a string + """ + if not v: + raise ValueError("policy_ids must contain at least one policy ID") + for policy_id in v: + if not isinstance(policy_id, str) or not policy_id.strip(): + raise ValueError(f"Invalid policy ID: {policy_id!r}. Must be a non-empty string.") + return v + + def to_request_dict(self) -> Dict[str, Any]: + """ + Convert to API request dictionary with camelCase keys. + + Delegates to ``NDBaseModel.to_payload()`` for consistency. + + ## Returns + + Dictionary suitable for JSON request body. + + ## Example + + ```python + body = PolicyIds(policy_ids=["POLICY-123", "POLICY-456"]) + payload = body.to_request_dict() + # {"policyIds": ["POLICY-123", "POLICY-456"]} + ``` + """ + return self.to_payload() diff --git a/plugins/module_utils/models/manage_policies/policy_base.py b/plugins/module_utils/models/manage_policies/policy_base.py new file mode 100644 index 00000000..5c3c049c --- /dev/null +++ b/plugins/module_utils/models/manage_policies/policy_base.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Base Pydantic model for Policy API request bodies. + +This module provides the foundational ``PolicyCreate`` model. All other +policy models that extend or wrap ``PolicyCreate`` live in separate files +and import from here. + +The ``PolicyEntityType`` enum is in ``enums.py``. + +## Schema origin (manage.json) + +- ``PolicyCreate`` ← ``createPolicy`` (extends ``createBasePolicy``) +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +from typing import Any, ClassVar, Dict, List, Literal, Optional + +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 .enums import PolicyEntityType + + +# ============================================================================ +# Policy Create Model (base for all CRUD body models) +# ============================================================================ + + +class PolicyCreate(NDBaseModel): + """ + Request body model for creating a single policy. + + ## Description + + Based on ``createPolicy`` schema from manage.json which extends + ``createBasePolicy``. + + ## API Endpoint + + POST /api/v1/manage/fabrics/{fabricName}/policies + + ## Required Fields + + - switch_id: Switch serial number (e.g., "FDO25031SY4") + - template_name: Name of the policy template (e.g., "switch_freeform", "feature_enable") + - entity_type: Type of entity (switch, configProfile, interface) + - entity_name: Name of the entity (e.g., "SWITCH", "Ethernet1/1") + + ## Optional Fields + + - description: Policy description (max 255 chars) + - priority: Policy priority (1-2000, default 500) + - source: Source of the policy (e.g., "UNDERLAY", "OVERLAY", "") + - template_inputs: Name/value pairs passed to the template + - secondary_entity_name: Secondary entity name (for configProfile) + - secondary_entity_type: Secondary entity type + + ## Usage + + ```python + from .enums import PolicyEntityType + + policy = PolicyCreate( + switch_id="FDO25031SY4", + template_name="feature_enable", + entity_type=PolicyEntityType.SWITCH, + entity_name="SWITCH", + template_inputs={"featureName": "lacp"}, + priority=500 + ) + payload = policy.to_request_dict() + ``` + """ + + # --- NDBaseModel ClassVars --- + identifiers: ClassVar[List[str]] = ["switch_id", "template_name", "description"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "composite" + exclude_from_diff: ClassVar[set] = {"source"} + + # Required fields from createPolicy schema + switch_id: str = Field( + ..., + alias="switchId", + description="Switch serial number (e.g., FDO25031SY4)", + ) + template_name: str = Field( + ..., + max_length=255, + alias="templateName", + description="Name of the policy template", + ) + entity_type: PolicyEntityType = Field( + ..., + alias="entityType", + description="Type of the entity (switch, configProfile, interface)", + ) + entity_name: str = Field( + ..., + max_length=255, + alias="entityName", + description="Name of the entity. Use 'SWITCH' for switch-level, or interface name for interface-level", + ) + + # Optional fields + description: Optional[str] = Field( + default=None, + max_length=255, + description="Description of the policy", + ) + priority: Optional[int] = Field( + default=500, + ge=1, + le=2000, + description="Priority of the policy (1-2000)", + ) + source: Optional[str] = Field( + default="", + max_length=255, + description="Source of the policy (UNDERLAY, OVERLAY, LINK, etc.). Empty means any source can update.", + ) + template_inputs: Optional[Dict[str, Any]] = Field( + default=None, + alias="templateInputs", + description="Name/value parameter list passed to the template", + ) + secondary_entity_name: Optional[str] = Field( + default=None, + alias="secondaryEntityName", + description="Name of the secondary entity (e.g., overlay name for configProfile)", + ) + secondary_entity_type: Optional[PolicyEntityType] = Field( + default=None, + alias="secondaryEntityType", + description="Type of the secondary entity", + ) + + def to_request_dict(self) -> Dict[str, Any]: + """ + Convert model to API request dictionary with camelCase keys. + + Delegates to ``NDBaseModel.to_payload()`` for consistency. + + ## Returns + + Dictionary suitable for JSON request body, excluding None values. + + ## Example + + ```python + policy = PolicyCreate( + switch_id="FDO123", + template_name="feature_enable", + entity_type=PolicyEntityType.SWITCH, + entity_name="SWITCH" + ) + payload = policy.to_request_dict() + # {"switchId": "FDO123", "templateName": "feature_enable", ...} + ``` + """ + return self.to_payload() diff --git a/plugins/module_utils/models/manage_policies/policy_crud.py b/plugins/module_utils/models/manage_policies/policy_crud.py new file mode 100644 index 00000000..2de3fdd4 --- /dev/null +++ b/plugins/module_utils/models/manage_policies/policy_crud.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Pydantic models for Policy CRUD request bodies. + +This module provides ``PolicyCreateBulk`` (bulk create wrapper) and +``PolicyUpdate`` (update a single policy). Both depend on the base +``PolicyCreate`` model defined in ``policy_base``. + +## Schema origin (manage.json) + +- ``PolicyCreateBulk`` ← wraps a list of ``createPolicy`` +- ``PolicyUpdate`` ← ``policyPut`` (identical to ``createPolicy``) +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +from typing import Any, ClassVar, Dict, List + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_base import ( + PolicyCreate, +) + + +# ============================================================================ +# Policy Create Bulk Model +# ============================================================================ + + +class PolicyCreateBulk(NDNestedModel): + """ + Request body model for creating multiple policies in bulk. + + ## Description + + Wrapper for bulk policy creation via POST endpoint. + + ## API Endpoint + + POST /api/v1/manage/fabrics/{fabricName}/policies + + ## Usage + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_base import ( + PolicyCreate, + ) + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.enums import ( + PolicyEntityType, + ) + + bulk = PolicyCreateBulk(policies=[ + PolicyCreate( + switch_id="FDO123", + template_name="feature_enable", + entity_type=PolicyEntityType.SWITCH, + entity_name="SWITCH", + template_inputs={"featureName": "lacp"} + ), + PolicyCreate( + switch_id="FDO456", + template_name="power_redundancy", + entity_type=PolicyEntityType.SWITCH, + entity_name="SWITCH", + template_inputs={"REDUNDANCY_MODE": "ps-redundant"} + ), + ]) + payload = bulk.to_request_dict() + ``` + """ + + identifiers: ClassVar[List[str]] = [] + + policies: List[PolicyCreate] = Field( + default_factory=list, + min_length=1, + description="List of policies to create", + ) + + def to_request_dict(self) -> Dict[str, Any]: + """ + Convert to API request dictionary. + + ## Returns + + Dictionary with 'policies' key containing list of policy dicts. + """ + return {"policies": [policy.to_request_dict() for policy in self.policies]} + + +# ============================================================================ +# Policy Update Model +# ============================================================================ + + +class PolicyUpdate(PolicyCreate): + """ + Request body model for updating a policy. + + ## Description + + Based on ``policyPut`` schema from manage.json which extends ``createPolicy``. + Inherits all fields from ``PolicyCreate``. + + ## API Endpoint + + PUT /api/v1/manage/fabrics/{fabricName}/policies/{policyId} + + ## Note + + The policyId is passed as a path parameter, not in the request body. + All fields from PolicyCreate are available for update. + + ## Usage + + ```python + from .enums import PolicyEntityType + + update = PolicyUpdate( + switch_id="FDO25031SY4", + template_name="feature_enable", + entity_type=PolicyEntityType.SWITCH, + entity_name="SWITCH", + template_inputs={"featureName": "lacp"}, + priority=100, + description="Updated policy description" + ) + payload = update.to_request_dict() + ``` + """ + + # All fields inherited from PolicyCreate + # policyPut schema is identical to createPolicy per manage.json diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py new file mode 100644 index 00000000..a35ee538 --- /dev/null +++ b/plugins/module_utils/nd_policy_resources.py @@ -0,0 +1,2658 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ND Policy Resource Module. + +Provides all business logic for switch policy management on NDFC 4.x: + - Policy CRUD (create, read, update, delete) + - Idempotency diff calculation for merged, query, deleted states + - Deploy (pushConfig) orchestration + - Conditional delete flow: + deploy=true → markDelete → pushConfig → remove + deploy=false → markDelete only + +The module file ``nd_policy.py`` contains only DOCUMENTATION, argument_spec, +and a thin ``main()`` that instantiates this class and calls ``manage_state()``. + +Models (from ``models.nd_manage_policies``): + - ``PolicyCreate`` - single policy create payload + - ``PolicyCreateBulk`` - bulk policy create wrapper + - ``PolicyUpdate`` - policy update payload (extends PolicyCreate) + - ``PolicyIds`` - list of policy IDs for actions +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +import copy +import logging +import re +from typing import Any, Dict, List, Optional, Tuple + +from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_config_templates import ( + EpManageConfigTemplateParametersGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_policies import ( + EpManagePoliciesDelete, + EpManagePoliciesGet, + EpManagePoliciesPost, + EpManagePoliciesPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_policy_actions import ( + EpManagePolicyActionsMarkDeletePost, + EpManagePolicyActionsPushConfigPost, + EpManagePolicyActionsRemovePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_base import ( + PolicyCreate, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_crud import ( + PolicyCreateBulk, + PolicyUpdate, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_actions import ( + PolicyIds, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule, + NDModuleError, +) +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + + +# ============================================================================= +# Module-level helpers (stateless, used by NDPolicyModule) +# ============================================================================= + + +def _looks_like_ip(value): + """Return True if *value* looks like a dotted-quad IPv4 address.""" + parts = value.split(".") + if len(parts) == 4: + return all(p.isdigit() and 0 <= int(p) <= 255 for p in parts) + return False + + +def _needs_resolution(value): + """Return True if the switch identifier needs IP/hostname → serial resolution. + + Serial numbers are alphanumeric strings (e.g. ``FDO25031SY4``). + IPs look like dotted quads. Hostnames contain dots or look like FQDNs. + If the value is already a serial number we can skip the fabric API call. + """ + if not value: + return False + v = str(value).strip() + if _looks_like_ip(v): + return True + if "." in v: + return True + return False + + +class NDPolicyModule: + """Specialized module for switch policy lifecycle management. + + Provides policy-specific operations on top of NDModule: + - Query and match existing policies (Lucene + post-filtering) + - Idempotent diff calculation across 16 merged / 13 query / 16 deleted cases + - Create, update, delete_and_create actions + - Bulk deploy via pushConfig + - Conditional delete flow: + deploy=true → markDelete → pushConfig → remove + deploy=false → markDelete only + + Schema models (from ``models.nd_manage_policies``): + - ``PolicyCreate`` - single policy create request body + - ``PolicyCreateBulk`` - bulk create wrapper + - ``PolicyUpdate`` - update request body (extends PolicyCreate) + - ``PolicyIds`` - list of policy IDs for bulk actions + """ + + # ========================================================================= + # Initialization & Lifecycle + # ========================================================================= + + def __init__( + self, + nd: NDModule, + results: Results, + logger: Optional[logging.Logger] = None, + ): + """Initialize the Policy Resource Module. + + Args: + nd: NDModule instance (wraps the Ansible module and REST client). + results: Results aggregation instance for task output. + logger: Optional logger; defaults to ``nd.NDPolicyModule``. + """ + self.log = logger or logging.getLogger("nd.NDPolicyModule") + self.nd = nd + self.module = nd.module + self.results = results + + # Module parameters + self.fabric_name = self.module.params.get("fabric_name") + self.config = self.module.params.get("config") + self.state = self.module.params.get("state") + self.use_desc_as_key = self.module.params.get("use_desc_as_key") + self.deploy = self.module.params.get("deploy") + self.ticket_id = self.module.params.get("ticket_id") + self.cluster_name = self.module.params.get("cluster_name") + self.check_mode = self.module.check_mode + + # Template parameter cache: {templateName: [param_dict, ...]} + # Populated lazily by _fetch_template_params() to avoid + # redundant API calls when multiple config entries share the + # same template. + self._template_params_cache: Dict[str, List[Dict]] = {} + + # Before/after snapshot lists — populated during _execute_* methods. + # Merged into exit_json output so the caller sees what changed. + self._before: List[Dict] = [] + self._after: List[Dict] = [] + self._proposed: List[Dict] = [] + + self.log.info( + f"Initialized NDPolicyModule for fabric: {self.fabric_name}, state: {self.state}" + ) + + def exit_json(self) -> None: + """Build final result from all registered tasks and exit. + + Merges the ``Results`` aggregation and the before/after/proposed + snapshot lists, then delegates to ``exit_json`` or ``fail_json``. + """ + self.results.build_final_result() + final = self.results.final_result + + # Attach before/after snapshots + final["before"] = self._before + final["after"] = self._after + + # Only expose proposed at info/debug output levels + output_level = self.module.params.get("output_level", "normal") + if output_level in ("debug", "info"): + final["proposed"] = self._proposed + + if True in self.results.failed: + self.module.fail_json( + msg="Policy operation failed. See task results for details.", + **final, + ) + self.module.exit_json(**final) + + # ========================================================================= + # Config Translation & Switch Resolution + # ========================================================================= + + @staticmethod + def translate_config(config, use_desc_as_key): + """Translate the playbook config into a flat list of per-switch policy dicts. + + The playbook config uses a two-level structure: + - Global policy entries: dicts with ``name``, ``description``, etc. + - A switch entry: a dict with ``switch`` key containing a list of + switch dicts, each with ``serial_number`` and optional ``policies``. + + This function: + 1. Pops the switch entry from the config. + 2. For each switch, adds its serial number to every global policy. + 3. If a switch has per-switch ``policies``, those override (by template + name) the global policies for that switch (when + ``use_desc_as_key=false``). When ``use_desc_as_key=true``, + per-switch policies are simply merged with global policies. + 4. Returns a flat list where each dict has a ``switch`` key with a + single serial number string. + + Args: + config: The raw config list from the playbook (will be mutated). + use_desc_as_key: Whether descriptions are used as unique keys. + + Returns: + Flat list of policy dicts, each with a ``switch`` (serial number) key. + """ + if config is None: + return [] + + # Find the switch entry (the dict that has a "switch" key containing a list) + pos = next( + (index for index, d in enumerate(config) if "switch" in d and isinstance(d["switch"], list)), + None, + ) + + if pos is None: + return config + + sw_dict = config.pop(pos) + global_policies = config + + override_config = [] + for sw in sw_dict["switch"]: + sn = sw.get("serial_number") or sw.get("ip", "") + + if sw.get("policies"): + for pol in sw["policies"]: + entry = copy.deepcopy(pol) + entry["switch"] = sn + override_config.append(entry) + + for cfg in global_policies: + if "switch" not in cfg or not isinstance(cfg["switch"], list): + if "switch" not in cfg or cfg["switch"] is None: + cfg["switch"] = [] + elif isinstance(cfg["switch"], str): + cfg["switch"] = [cfg["switch"]] + if sn not in cfg["switch"]: + cfg["switch"].append(sn) + + # Switch-only: no global policies AND no per-switch overrides → generate + # a bare entry per switch for query/deleted. + if not global_policies and not override_config: + return [{"switch": sw.get("serial_number") or sw.get("ip", "")} for sw in sw_dict["switch"]] + + # Flatten: when use_desc_as_key is false, per-switch policies override + # global policies with the same template name for that switch. + if global_policies and not use_desc_as_key: + updated_config = [] + for ovr_cfg in override_config: + for cfg in global_policies: + if cfg.get("name") == ovr_cfg.get("name"): + ovr_sw = ovr_cfg["switch"] + if isinstance(cfg.get("switch"), list) and ovr_sw in cfg["switch"]: + cfg["switch"].remove(ovr_sw) + if ovr_cfg not in updated_config: + updated_config.append(ovr_cfg) + for cfg in global_policies: + if isinstance(cfg.get("switch"), list) and cfg["switch"]: + updated_config.append(cfg) + flat_config = updated_config + else: + flat_config = list(global_policies) + override_config + + # Expand multi-switch global policies into one entry per switch + result = [] + for cfg in flat_config: + if isinstance(cfg.get("switch"), list): + for sw in cfg["switch"]: + entry = copy.deepcopy(cfg) + entry["switch"] = sw + result.append(entry) + else: + result.append(cfg) + + return result + + def resolve_switch_identifiers(self, config): + """Resolve switch IP/hostname inputs to serial numbers. + + The user's arg-spec field is ``serial_number`` with alias ``ip``. + After ``translate_config()`` the value lives in ``entry["switch"]`` + as a plain string. + + Resolution logic: + 1. If the value does NOT look like an IP or hostname it is + assumed to be a serial number already → pass through. + 2. If the value looks like an IP or hostname, query the fabric + switch inventory and resolve it to a serial number. + 3. If resolution fails, fail with a clear error. + + Args: + config: Flat config list from ``translate_config()``. + + Returns: + The config list with all switch identifiers resolved to serials. + """ + if config is None: + return [] + + needs_lookup = set() + for entry in config: + switch_value = entry.get("switch") + if isinstance(switch_value, list): + for switch_entry in switch_value: + val = switch_entry.get("serial_number") or switch_entry.get("ip") or "" + if _needs_resolution(val): + needs_lookup.add(val) + elif isinstance(switch_value, str) and _needs_resolution(switch_value): + needs_lookup.add(switch_value) + + if not needs_lookup: + return config + + switches = self._query_fabric_switches() + + ip_map = {} + hostname_map = {} + for switch in switches: + switch_id = switch.get("switchId") or switch.get("serialNumber") + if not switch_id: + continue + fabric_ip = switch.get("fabricManagementIp") or switch.get("ip") + if fabric_ip: + ip_map[str(fabric_ip).strip()] = switch_id + hostname = switch.get("hostname") + if hostname: + hostname_map[str(hostname).strip().lower()] = switch_id + + def _resolve(identifier): + if identifier is None: + return None + value = str(identifier).strip() + if not value: + return value + return ip_map.get(value) or hostname_map.get(value.lower()) + + for entry in config: + switch_value = entry.get("switch") + + if isinstance(switch_value, list): + for switch_entry in switch_value: + original = switch_entry.get("serial_number") or switch_entry.get("ip") + if not _needs_resolution(original): + continue + resolved = _resolve(original) + if resolved is None: + self.module.fail_json( + msg=( + f"Unable to resolve switch identifier '{original}' to a serial number " + f"in fabric '{self.fabric_name}'. Provide a valid switch serial_number, " + "management IP, or hostname from the fabric inventory." + ) + ) + switch_entry["serial_number"] = resolved + if "ip" in switch_entry: + switch_entry["ip"] = resolved + elif isinstance(switch_value, str): + if not _needs_resolution(switch_value): + continue + resolved = _resolve(switch_value) + if resolved is None: + self.module.fail_json( + msg=( + f"Unable to resolve switch identifier '{switch_value}' to a serial number " + f"in fabric '{self.fabric_name}'. Provide a valid switch serial_number, " + "management IP, or hostname from the fabric inventory." + ) + ) + entry["switch"] = resolved + + return config + + def _query_fabric_switches(self): + """Query all switches for the fabric and return raw switch records. + + Uses RestSend save_settings/restore_settings to temporarily force + check_mode=False so that this read-only GET always hits the controller, + even when the module is running in Ansible check mode. + """ + path = f"{BasePath.nd_manage_fabrics(self.fabric_name, 'switches')}?max=10000" + + rest_send = self.nd._get_rest_send() + rest_send.save_settings() + rest_send.check_mode = False + try: + response = self.nd.request(path) + finally: + rest_send.restore_settings() + + if isinstance(response, list): + return response + if isinstance(response, dict): + return response.get("switches", []) + return [] + + def validate_translated_config(self, translated_config): + """Validate the translated (flat) config before handing it to manage_state. + + Checks performed: + - ``name`` is required for each entry when state=merged. + - Every entry must have a ``switch`` serial number. + + Args: + translated_config: Flat config list from ``translate_config()``. + + Raises: + Calls ``module.fail_json`` on validation failure. + """ + if self.state == "merged": + for idx, entry in enumerate(translated_config): + if not entry.get("name"): + self.module.fail_json( + msg=f"config[{idx}].name is required when state=merged." + ) + + for idx, entry in enumerate(translated_config): + if not entry.get("switch"): + self.module.fail_json( + msg=f"config[{idx}]: every policy entry must have a switch serial number after translation." + ) + + # ========================================================================= + # Public API - State Management + # ========================================================================= + + def manage_state(self) -> None: + """Main entry point for state management. + + Reads ``self.state`` and delegates to the appropriate handler: + - **merged** - create / update / skip policies + - **query** - read-only lookup + - **deleted** - deploy=true: markDelete → pushConfig → remove + - deploy=false: markDelete only + + Before dispatching to the state handler, ``_validate_config()`` is + called to perform upfront validation. When ``use_desc_as_key=true`` + the entire task is treated as an atomic unit — any validation + failure aborts the run before any changes are made. + """ + self.log.info(f"Managing state: {self.state}") + + # Upfront validation — hard-fail before any API mutations + self._validate_config() + + if self.state == "merged": + self._handle_merged_state() + elif self.state == "query": + self._handle_query_state() + elif self.state == "deleted": + self._handle_deleted_state() + else: + self.module.fail_json(msg=f"Unsupported state: {self.state}") + + # ========================================================================= + # Upfront Validation + # ========================================================================= + + def _validate_config(self) -> None: + """Validate the playbook config before any API calls are made. + + When ``use_desc_as_key=true``: + 1. Every config entry for ``merged`` / ``deleted`` states **must** + have a non-empty ``description`` (unless ``name`` is a policy ID + or ``name`` is omitted for switch-only operations). + 2. The ``description + switch`` combination must be unique across + all config entries within the playbook. Duplicate pairs would + lead to ambiguous matching at the controller and are rejected. + + These checks ensure the entire task fails atomically before + making any changes, rather than partially executing. + """ + if not self.use_desc_as_key: + return + + self.log.debug("ENTER: _validate_config() [use_desc_as_key=true]") + + desc_switch_counts: Dict[str, int] = {} + + for idx, entry in enumerate(self.config): + name = entry.get("name", "") + switch = entry.get("switch", "") + description = entry.get("description", "") + + # Skip validation for policy-ID lookups (direct by ID) and + # switch-only entries (no name → "all policies on switch"). + if name and self._is_policy_id(name): + continue + if not name: + # Switch-only: valid for query/deleted (no description needed) + continue + + # Check 1: description must not be empty when use_desc_as_key=true + # and a template name is given (merged or deleted state). + if self.state in ("merged", "deleted") and not description: + self.module.fail_json( + msg=( + f"config[{idx}]: description cannot be empty when " + f"use_desc_as_key=true and name is a template name " + f"('{name}'). Provide a unique description for each " + f"policy or set use_desc_as_key=false." + ) + ) + + # Check 2: description + switch must be unique within the playbook. + if description: + key = f"{description}|{switch}" + desc_switch_counts[key] = desc_switch_counts.get(key, 0) + 1 + + # Report all duplicates at once + duplicates = [ + f"description='{k.split('|')[0]}', switch='{k.split('|')[1]}'" + for k, count in desc_switch_counts.items() + if count > 1 + ] + if duplicates: + self.module.fail_json( + msg=( + "Duplicate description+switch combinations found in the " + "playbook config (use_desc_as_key=true requires each " + "description to be unique per switch): " + + "; ".join(duplicates) + ) + ) + + self.log.debug("EXIT: _validate_config() — all checks passed") + + # ========================================================================= + # State Handlers + # ========================================================================= + + def _handle_merged_state(self) -> None: + """Handle state=merged: create, update, or skip policies.""" + self.log.debug("ENTER: _handle_merged_state()") + self.log.info("Handling merged state") + self.log.debug(f"Config entries: {len(self.config)}") + + # Phase 1: Build want and have for each config entry + diff_results = [] + for config_entry in self.config: + want = self._build_want(config_entry, state="merged") + + # Phase 1a: Validate templateInputs against template schema + template_name = want.get("templateName") + template_inputs = want.get("templateInputs") or {} + if template_name and not self._is_policy_id(template_name): + validation_errors = self._validate_template_inputs( + template_name, template_inputs + ) + if validation_errors: + error_msg = ( + f"Template input validation failed for '{template_name}': " + + "; ".join(validation_errors) + ) + self.log.error(error_msg) + diff_results.append({ + "action": "fail", + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": error_msg, + }) + continue + + have_list, error_msg = self._build_have(want) + + if error_msg: + self.log.error(f"Build have failed: {error_msg}") + diff_results.append({ + "action": "fail", + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": error_msg, + }) + continue + + # Phase 2: Compute diff + diff_entry = self._get_diff_merged_single(want, have_list) + self.log.debug( + f"Diff result for {want.get('templateName', want.get('policyId', 'unknown'))}: " + f"action={diff_entry['action']}" + ) + diff_results.append(diff_entry) + + self.log.info(f"Computed {len(diff_results)} diff results") + + # Phase 3: Execute actions + policy_ids_to_deploy = self._execute_merged(diff_results) + + # Phase 4: Deploy if requested + if self.deploy and policy_ids_to_deploy: + self.log.info(f"Deploying {len(policy_ids_to_deploy)} policies") + deploy_success = self._deploy_policies(policy_ids_to_deploy) + if not deploy_success: + self.log.error( + "pushConfig failed for one or more policies after " + "create/update. Policies exist on the controller but " + "have not been deployed to the switch." + ) + self._register_result( + action="policy_deploy_failed", + operation_type=OperationType.UPDATE, + return_code=-1, + message=( + "pushConfig failed for one or more policies. " + "Policies were created/updated on the controller but " + "not deployed to the switch. Fix device connectivity " + "and re-run with deploy=true." + ), + success=False, + found=True, + diff={ + "action": "deploy_failed", + "policy_ids": policy_ids_to_deploy, + "reason": "pushConfig per-policy failure", + }, + ) + elif not self.deploy: + self.log.info("Deploy not requested, skipping pushConfig") + + self.log.debug("EXIT: _handle_merged_state()") + + def _handle_query_state(self) -> None: + """Handle state=query: read-only lookup of policies.""" + self.log.debug("ENTER: _handle_query_state()") + self.log.info("Handling query state") + self.log.debug(f"Config entries: {len(self.config)}") + + # Phase 1: Build want and have for each config entry + diff_results = [] + for config_entry in self.config: + want = self._build_want(config_entry, state="query") + have_list, error_msg = self._build_have(want) + + if error_msg: + self.log.error(f"Build have failed: {error_msg}") + diff_results.append({ + "action": "fail", + "want": want, + "policies": [], + "match_count": 0, + "warning": None, + "error_msg": error_msg, + }) + continue + + self.log.debug( + f"Found {len(have_list)} existing policies for " + f"{want.get('templateName', want.get('policyId', 'switch-only'))}" + ) + + # Phase 2: Compute query result + diff_entry = self._get_diff_query_single(want, have_list) + diff_results.append(diff_entry) + + # Phase 3: Register results + self.log.info(f"Computed {len(diff_results)} query results") + self._execute_query(diff_results) + self.log.debug("EXIT: _handle_query_state()") + + def _handle_deleted_state(self) -> None: + """Handle state=deleted: remove policies from NDFC.""" + self.log.debug("ENTER: _handle_deleted_state()") + self.log.info("Handling deleted state") + self.log.debug(f"Config entries: {len(self.config)}") + + # Phase 1: Build want and have for each config entry + diff_results = [] + for config_entry in self.config: + want = self._build_want(config_entry, state="deleted") + have_list, error_msg = self._build_have(want) + + if error_msg: + self.log.error(f"Build have failed: {error_msg}") + diff_results.append({ + "action": "fail", + "want": want, + "policies": [], + "policy_ids": [], + "match_count": 0, + "warning": None, + "error_msg": error_msg, + }) + continue + + # Phase 2: Compute delete result + diff_entry = self._get_diff_deleted_single(want, have_list) + self.log.debug( + f"Delete diff for {want.get('templateName', want.get('policyId', 'switch-only'))}: " + f"action={diff_entry['action']}" + ) + diff_results.append(diff_entry) + + # Phase 3: Execute delete actions + self.log.info(f"Computed {len(diff_results)} delete results") + self._execute_deleted(diff_results) + self.log.debug("EXIT: _handle_deleted_state()") + + # ========================================================================= + # Helpers: Classification & Filtering + # ========================================================================= + + @staticmethod + def _is_policy_id(name: str) -> bool: + """Return True if name looks like a policy ID (starts with POLICY-).""" + return name.upper().startswith("POLICY-") + + @staticmethod + def _build_lucene_filter(**kwargs: Any) -> str: + """Build a Lucene filter string from keyword arguments. + + Example:: + + _build_lucene_filter(switchId="FDO123", templateName="feature_enable") + # Returns: "switchId:FDO123 AND templateName:feature_enable" + """ + parts = [] + for key, value in kwargs.items(): + if value is not None: + parts.append(f"{key}:{value}") + return " AND ".join(parts) + + @staticmethod + def _policies_differ(want: Dict, have: Dict) -> Dict: + """Compare want vs have policy to determine if an update is needed. + + Fields compared: + - description + - priority + - templateInputs (only keys the user specified, with str() normalization. + The controller injects extra keys like FABRIC_NAME that we must ignore.) + + Fields NOT compared (identity/read-only): + - policyId, switchId, templateName, source + - entityType, entityName, createTimestamp, updateTimestamp + - generatedConfig, markDeleted + + Returns: + Dict with changed fields, or empty dict if identical. + """ + diff = {} + + # Compare description + want_desc = want.get("description", "") or "" + have_desc = have.get("description", "") or "" + if want_desc != have_desc: + diff["description"] = {"want": want_desc, "have": have_desc} + + # Compare priority + want_priority = want.get("priority", 500) + have_priority = have.get("priority", 500) + if want_priority != have_priority: + diff["priority"] = {"want": want_priority, "have": have_priority} + + # Compare templateInputs — only check keys the user specified. + # The controller injects additional keys (e.g., FABRIC_NAME) that + # the user didn't provide. We must ignore those to avoid false diffs. + want_inputs = want.get("templateInputs") or {} + have_inputs = have.get("templateInputs") or {} + input_diff = {} + for key in want_inputs: + want_val = str(want_inputs[key]) + have_val = str(have_inputs.get(key, "")) + if want_val != have_val: + input_diff[key] = {"want": want_inputs[key], "have": have_inputs.get(key)} + if input_diff: + diff["templateInputs"] = input_diff + + return diff + + # ========================================================================= + # API Query Helpers + # ========================================================================= + + def _query_policies_raw( + self, lucene_filter: Optional[str] = None + ) -> List[Dict]: + """Query policies from the controller using GET /policies (unfiltered). + + Returns **all** matching policies including ``markDeleted`` and + internal (``source != ""``) entries. Callers that need the raw + list (cleanup routines, query-state display) should use this + directly. For idempotency checks use ``_query_policies()`` + which filters out stale records. + + Args: + lucene_filter: Optional Lucene filter string. + + Returns: + List of policy dicts from the response. + """ + self.log.debug(f"Querying policies (raw) with filter: {lucene_filter}") + + ep = EpManagePoliciesGet() + ep.fabric_name = self.fabric_name + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + if lucene_filter: + ep.lucene_params.filter = lucene_filter + # Set max to retrieve all matching policies. + # Default page size is 10 which causes missed matches. + ep.lucene_params.max = 10000 + + data = self.nd.request(ep.path, ep.verb) + if isinstance(data, dict): + policies = data.get("policies", []) + self.log.debug(f"Raw query returned {len(policies)} policies") + return policies + self.log.debug("Query returned non-dict response, returning empty list") + return [] + + def _query_policies( + self, + lucene_filter: Optional[str] = None, + include_mark_deleted: bool = False, + ) -> List[Dict]: + """Query policies with idempotency-safe filtering. + + Wraps ``_query_policies_raw()`` and applies post-filters: + + - **markDeleted** — when ``include_mark_deleted=False`` (default), + policies pending deletion are excluded so they don't interfere + with idempotency checks. When ``True``, they are kept and + annotated with ``_markDeleted_stale: True`` so callers (e.g. + query state) can surface the status to the user. + - **source != ""** — internal NDFC sub-policies are always + excluded; they are artefacts that cause false duplicate + matches. + + Args: + lucene_filter: Optional Lucene filter string. + include_mark_deleted: When True, keep markDeleted policies + and annotate them instead of filtering them out. + + Returns: + List of policy dicts from the response. + """ + raw = self._query_policies_raw(lucene_filter) + if not raw: + return [] + + result: List[Dict] = [] + excluded = 0 + for p in raw: + # Always exclude internal NDFC sub-policies (source != "") + if p.get("source", "") != "": + excluded += 1 + continue + + if p.get("markDeleted", False): + if include_mark_deleted: + # Annotate so callers can display the status + p["_markDeleted_stale"] = True + result.append(p) + else: + excluded += 1 + continue + + result.append(p) + + self.log.debug( + f"After filtering: {len(result)} policies " + f"(excluded {excluded}, include_mark_deleted={include_mark_deleted})" + ) + return result + + def _query_policy_by_id( + self, policy_id: str, include_mark_deleted: bool = False + ) -> Optional[Dict]: + """Query a single policy by its ID. + + By default, policies marked for deletion (``markDeleted=True``) + are treated as non-existent because they are pending removal + and cannot be updated. When ``include_mark_deleted=True`` (used + by query state), they are returned with an annotation so the + caller can surface the status. + + Args: + policy_id: Policy ID (e.g., "POLICY-121110"). + include_mark_deleted: When True, return markDeleted policies + annotated with ``_markDeleted_stale: True``. + + Returns: + Policy dict, or None if not found. + """ + self.log.debug(f"Looking up policy by ID: {policy_id}") + + ep = EpManagePoliciesGet() + ep.fabric_name = self.fabric_name + ep.policy_id = policy_id + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + + try: + data = self.nd.request(ep.path, ep.verb) + if isinstance(data, dict) and data: + # The controller may return a 200 with an error body when the + # policy is not found, e.g. {'code': 404, 'message': '...not found'}. + # Only treat the response as a valid policy if it contains a policyId. + if "policyId" not in data: + self.log.info( + f"Policy {policy_id} not found (response has no policyId: " + f"{data.get('message', data.get('code', 'unknown'))})" + ) + return None + if data.get("markDeleted", False): + if include_mark_deleted: + data["_markDeleted_stale"] = True + self.log.info( + f"Policy {policy_id} is marked for deletion (included with annotation)" + ) + return data + self.log.info( + f"Policy {policy_id} is marked for deletion, treating as not found" + ) + return None + self.log.debug(f"Policy {policy_id} found") + return data + self.log.info(f"Policy {policy_id} not found (empty response)") + return None + except NDModuleError as error: + # 404 means policy not found + if error.status == 404: + self.log.info(f"Policy {policy_id} not found (404)") + return None + raise + + # ========================================================================= + # Core: Build want / have + # ========================================================================= + + def _build_want(self, config_entry: Dict, state: str = "merged") -> Dict: + """Translate a single user config entry to the API-compatible want dict. + + For merged state, ``name`` is required and all fields are included. + For query/deleted state, ``name`` is optional — when omitted, only + ``switchId`` is set, which means "return all policies on this switch". + + Args: + config_entry: Single dict from the user's config list. + state: Module state ("merged", "query", or "deleted"). + + Returns: + Dict with camelCase keys matching the API schema. + """ + self.log.debug(f"Building want for state={state}, name={config_entry.get('name')}") + + want = { + "switchId": config_entry["switch"], + } + + name = config_entry.get("name") + + if name and self._is_policy_id(name): + want["policyId"] = name + elif name: + want["templateName"] = name + + # Per-entry create_additional_policy flag (carried on want dict) + want["create_additional_policy"] = config_entry.get("create_additional_policy", True) + + # For merged state, include all payload fields + if state == "merged": + want["entityType"] = "switch" + want["entityName"] = "SWITCH" + want["description"] = config_entry.get("description", "") + want["priority"] = config_entry.get("priority", 500) + want["templateInputs"] = config_entry.get("template_inputs") or {} + else: + # For query/deleted state, only include description if provided + description = config_entry.get("description", "") + if description: + want["description"] = description + + self.log.debug(f"Built want: {want}") + return want + + # ========================================================================= + # Template Input Validation + # ========================================================================= + + def _fetch_template_params(self, template_name: str) -> List[Dict]: + """Fetch and cache parameter definitions for a config template. + + Calls ``GET /api/v1/manage/configTemplates/{templateName}`` and + extracts the ``parameters`` array. Results are cached per + ``template_name`` so multiple config entries sharing the same + template incur only one API call. + + Args: + template_name: The NDFC template name (e.g., ``switch_freeform``). + + Returns: + List of parameter dicts, each with at minimum ``name``, + ``parameterType``, ``optional``, and ``defaultValue`` keys. + Returns an empty list if the template has no parameters or + the API call fails. + """ + self.log.debug(f"ENTER: _fetch_template_params(template_name={template_name})") + + if template_name in self._template_params_cache: + self.log.debug( + f"Template params cache hit for '{template_name}': " + f"{len(self._template_params_cache[template_name])} params" + ) + return self._template_params_cache[template_name] + + ep = EpManageConfigTemplateParametersGet() + ep.template_name = template_name + + try: + data = self.nd.request(ep.path, ep.verb) + except Exception as exc: + self.log.warning( + f"Failed to fetch template '{template_name}' parameters: {exc}. " + "Skipping template input validation." + ) + self._template_params_cache[template_name] = [] + return [] + + # The response is a templateData object with 'parameters' key. + # 'parameters' is a list of templateParameter objects. + params = data.get("parameters") if isinstance(data, dict) else [] + if params is None: + params = [] + + self._template_params_cache[template_name] = params + self.log.info( + f"Fetched {len(params)} parameter definitions for template '{template_name}'" + ) + self.log.debug( + f"Template '{template_name}' param names: " + f"{[p.get('name') for p in params]}" + ) + self.log.debug(f"EXIT: _fetch_template_params()") + return params + + def _validate_template_inputs( + self, template_name: str, template_inputs: Dict[str, Any] + ) -> List[str]: + """Validate user-provided templateInputs against the template schema. + + Performs three checks: + 1. **Unknown keys** — every key in ``template_inputs`` must + correspond to a parameter ``name`` in the template definition. + 2. **Missing required parameters** — every parameter where + ``optional`` is ``False`` AND ``defaultValue`` is empty/null + must be supplied by the user. + 3. **Basic type validation** — lightweight format checks for + common ``parameterType`` values (boolean, Integer, ipV4Address, + etc.). Values that fail these checks are reported as warnings, + not hard failures, because the controller's own validation is + authoritative. + + Args: + template_name: Template name for fetching parameter definitions. + template_inputs: User-provided ``templateInputs`` dict. + + Returns: + List of validation error message strings. Empty list means all + inputs are valid. + """ + self.log.debug( + f"ENTER: _validate_template_inputs(template={template_name}, " + f"input_keys={list(template_inputs.keys())})" + ) + + params = self._fetch_template_params(template_name) + if not params: + self.log.debug("No template params available, skipping validation") + return [] + + errors: List[str] = [] + + # Build lookup: param_name -> param_def + # Filter out internal parameters (annotations.IsInternal == "true") + # that the controller auto-populates (e.g., SERIAL_NUMBER, POLICY_ID, + # SOURCE, FABRIC_NAME). Users should never need to set these. + param_map: Dict[str, Dict] = {} + internal_names: set = set() + for p in params: + name = p.get("name") + if not name: + continue + annotations = p.get("annotations") or {} + if str(annotations.get("IsInternal", "")).lower() == "true": + internal_names.add(name) + else: + param_map[name] = p + + self.log.debug( + f"Template '{template_name}': {len(param_map)} user params, " + f"{len(internal_names)} internal params ({sorted(internal_names)})" + ) + + # ------------------------------------------------------------------ + # Check 1: Unknown keys (skip internal params — they are allowed + # but not advertised to users) + # ------------------------------------------------------------------ + valid_names = set(param_map.keys()) | internal_names + user_facing_names = set(param_map.keys()) + for user_key in template_inputs: + if user_key not in valid_names: + errors.append( + f"Unknown templateInput key '{user_key}' for template " + f"'{template_name}'. Valid keys: {sorted(user_facing_names)}" + ) + + # ------------------------------------------------------------------ + # Check 2: Missing required parameters + # ------------------------------------------------------------------ + for pname, pdef in param_map.items(): + is_optional = pdef.get("optional", True) + default_val = pdef.get("defaultValue") + has_default = default_val is not None and str(default_val).strip() != "" + + if not is_optional and not has_default and pname not in template_inputs: + errors.append( + f"Required templateInput '{pname}' (type={pdef.get('parameterType', '?')}) " + f"is missing for template '{template_name}'" + ) + + # ------------------------------------------------------------------ + # Check 3: Basic type validation (soft checks) + # ------------------------------------------------------------------ + for user_key, user_val in template_inputs.items(): + pdef = param_map.get(user_key) + if not pdef: + continue # Already flagged as unknown above + + ptype = (pdef.get("parameterType") or "").lower() + val_str = str(user_val) + + if ptype == "boolean": + if val_str.lower() not in ("true", "false"): + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects boolean (true/false), got '{val_str}'" + ) + + elif ptype == "integer": + try: + int(val_str) + except ValueError: + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects integer, got '{val_str}'" + ) + + elif ptype == "long": + try: + int(val_str) + except ValueError: + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects long integer, got '{val_str}'" + ) + + elif ptype == "float": + try: + float(val_str) + except ValueError: + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects float, got '{val_str}'" + ) + + elif ptype in ("ipv4address", "ipaddress"): + # Basic IPv4 check + ipv4_pattern = r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$" + if not re.match(ipv4_pattern, val_str): + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects IPv4 address (e.g., 192.168.1.1), got '{val_str}'" + ) + + elif ptype == "ipv4addresswithsubnet": + ipv4_subnet_pattern = r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$" + if not re.match(ipv4_subnet_pattern, val_str): + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects IPv4 address with subnet (e.g., 192.168.1.1/24), got '{val_str}'" + ) + + elif ptype == "macaddress": + mac_pattern = r"^([0-9a-fA-F]{4}\.){2}[0-9a-fA-F]{4}$|^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$" + if not re.match(mac_pattern, val_str): + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects MAC address, got '{val_str}'" + ) + + elif ptype == "enum": + # If metaProperties contains 'validValues', check against them + meta = pdef.get("metaProperties") or {} + valid_values_str = meta.get("validValues") + if valid_values_str: + # validValues format is typically "val1,val2,val3" + valid_values = [v.strip() for v in valid_values_str.split(",")] + if val_str not in valid_values: + errors.append( + f"templateInput '{user_key}' for template '{template_name}' " + f"expects one of {valid_values}, got '{val_str}'" + ) + + if errors: + self.log.warning( + f"Template input validation found {len(errors)} errors " + f"for template '{template_name}': {errors}" + ) + else: + self.log.debug( + f"Template input validation passed for template '{template_name}'" + ) + + self.log.debug("EXIT: _validate_template_inputs()") + return errors + + def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: + """Query the controller to find existing policies matching the want. + + Handles all lookup strategies: + - Case A: Policy ID given → direct lookup + - Case B: use_desc_as_key=false, templateName given → switchId + templateName + - Case C: use_desc_as_key=true, templateName given → switchId + description + - Case D: Switch-only (no templateName or policyId) → all policies on switch + + Returns: + Tuple of (have_list, error_msg). + """ + self.log.debug("ENTER: _build_have()") + + # For query state, include markDeleted policies (annotated) so the + # user sees the full picture. For merged/deleted, exclude them to + # avoid false idempotency matches. + incl_md = self.state == "query" + + # Case A: Policy ID given directly + if "policyId" in want: + self.log.debug(f"Case A: Direct policy ID lookup: {want['policyId']}") + policy = self._query_policy_by_id(want["policyId"], include_mark_deleted=incl_md) + if policy: + self.log.info(f"Policy {want['policyId']} found") + return [policy], None + self.log.info(f"Policy {want['policyId']} not found") + return [], None + + # Case D: Switch-only — no name or policyId given + if "templateName" not in want: + self.log.debug(f"Case D: Switch-only lookup for {want['switchId']}") + lucene = self._build_lucene_filter(switchId=want["switchId"]) + policies = self._query_policies(lucene, include_mark_deleted=incl_md) + self.log.info(f"Found {len(policies)} policies on switch {want['switchId']}") + return policies, None + + # Case B: use_desc_as_key=false, search by switchId + templateName + if not self.use_desc_as_key: + self.log.debug( + f"Case B: Lookup by switchId={want['switchId']} + " + f"templateName={want['templateName']}" + ) + lucene = self._build_lucene_filter( + switchId=want["switchId"], + templateName=want["templateName"], + ) + policies = self._query_policies(lucene, include_mark_deleted=incl_md) + + # If description is provided, use it as an additional post-filter + want_desc = want.get("description", "") + if want_desc: + pre_filter_count = len(policies) + policies = [ + p for p in policies + if (p.get("description", "") or "") == want_desc + ] + self.log.debug( + f"Post-filtered by description: {len(policies)} of {pre_filter_count}" + ) + + self.log.info(f"Case B matched {len(policies)} policies") + return policies, None + + # Case C: use_desc_as_key=true, search by switchId + description + want_desc = want.get("description", "") or "" + self.log.debug( + f"Case C: Lookup by switchId={want['switchId']} + " + f"description='{want_desc}'" + ) + if not want_desc: + self.log.warning("Case C: description is required but not provided") + return [], "description is required when use_desc_as_key=true and name is a template name" + + lucene = self._build_lucene_filter( + switchId=want["switchId"], + description=want_desc, + ) + policies = self._query_policies(lucene, include_mark_deleted=incl_md) + + # IMPORTANT: Lucene does tokenized matching, not exact match. + # Post-filter to ensure exact description match. + exact_matches = [ + p for p in policies + if (p.get("description", "") or "") == want_desc + ] + self.log.debug( + f"Exact description match: {len(exact_matches)} of {len(policies)}" + ) + + # For query state with use_desc_as_key=true, also filter by templateName + # so the user can narrow results to a specific template. + # For merged/deleted states, do NOT filter by templateName — the diff + # logic handles template mismatches (e.g. Case 15: delete_and_create). + if self.state == "query": + template_name = want.get("templateName") + if template_name: + pre_count = len(exact_matches) + exact_matches = [ + p for p in exact_matches + if p.get("templateName") == template_name + ] + self.log.debug( + f"Post-filtered by templateName={template_name}: " + f"{len(exact_matches)} of {pre_count}" + ) + + self.log.info(f"Case C matched {len(exact_matches)} policies") + self.log.debug("EXIT: _build_have()") + return exact_matches, None + + # ========================================================================= + # Diff: Merged State (16 cases) + # ========================================================================= + + def _get_diff_merged_single(self, want: Dict, have_list: List[Dict]) -> Dict: + """Compute the diff and determine the action for a single config entry. + + Returns: + Dict with keys: action, want, have, diff, policy_id, error_msg. + """ + result = { + "action": None, + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": None, + } + + match_count = len(have_list) + + # ================================================================= + # CASES 1-6: Template name given, use_desc_as_key=false + # + # Template names are not unique — multiple policies can share the + # same template. Therefore, existing policies are never updated + # in-place when identified by template name alone. A new policy + # is always created. To update a specific policy, the user must + # provide its policy ID. + # create_additional_policy controls whether an identical (no-diff) + # policy is duplicated. + # ================================================================= + create_additional = want.get("create_additional_policy", True) + + if not self.use_desc_as_key and "templateName" in want: + if match_count == 0: + # Case 1: No match → CREATE + result["action"] = "create" + return result + + if match_count == 1: + have = have_list[0] + diff = self._policies_differ(want, have) + result["have"] = have + result["policy_id"] = have.get("policyId") + + if not diff: + if create_additional: + # Case 2a: Exact match, create_additional=true → CREATE duplicate + result["action"] = "create" + return result + # Case 2b: Exact match, create_additional=false → SKIP + result["action"] = "skip" + return result + + # Case 3/4: Diff exists — template name cannot uniquely + # identify a policy, so always CREATE a new one. + result["action"] = "create" + result["diff"] = diff + return result + + # match_count >= 2 + if create_additional: + # Case 5: Multiple matches, create_additional=true → CREATE another + result["action"] = "create" + return result + + # Case 6: Multiple matches, create_additional=false → SKIP + result["action"] = "skip" + return result + + # ================================================================= + # CASES 7-11: Policy ID given + # ================================================================= + if "policyId" in want: + if match_count == 0: + # Case 7: Policy ID not found → SKIP + result["action"] = "skip" + result["error_msg"] = ( + f"Policy {want['policyId']} not found. " + "Cannot create a policy with a specific ID." + ) + return result + + have = have_list[0] + diff = self._policies_differ(want, have) + result["have"] = have + result["policy_id"] = have.get("policyId") + + # Carry forward templateName from existing policy for update payload + if "templateName" not in want and "templateName" in have: + want["templateName"] = have["templateName"] + + if not diff: + if create_additional: + # Case 8a: Exact match, create_additional=true → CREATE duplicate + # Strip policyId so create doesn't fail with "not unique" + want.pop("policyId", None) + result["action"] = "create" + return result + # Case 8b: Match, no diff → SKIP + result["action"] = "skip" + return result + + # Case 10/11: Match, has diff → UPDATE (policy ID uniquely + # identifies the policy, so in-place update is safe) + result["action"] = "update" + result["diff"] = diff + return result + + # ================================================================= + # CASES 12-16: use_desc_as_key=true + # ================================================================= + if self.use_desc_as_key: + if match_count == 0: + # Case 12: No match → CREATE + result["action"] = "create" + return result + + if match_count == 1: + have = have_list[0] + result["have"] = have + result["policy_id"] = have.get("policyId") + + # Check if template matches + templates_match = want.get("templateName") == have.get("templateName") + + if templates_match: + diff = self._policies_differ(want, have) + if not diff: + # Case 13: Same template, no diff → SKIP + result["action"] = "skip" + return result + + # Case 14: Same template, fields differ → UPDATE + result["action"] = "update" + result["diff"] = diff + return result + + # Case 15: Different template → DELETE old + CREATE new + result["action"] = "delete_and_create" + result["diff"] = { + "templateName": { + "want": want.get("templateName"), + "have": have.get("templateName"), + } + } + return result + + # Case 16: Multiple matches → hard FAIL (ambiguous) + # Abort the entire task atomically — no partial changes. + self.module.fail_json( + msg=( + f"Multiple policies ({match_count}) found with description " + f"'{want.get('description')}' on switch {want.get('switchId')}. " + "Cannot determine which policy to update when " + "use_desc_as_key=true. Remove the duplicate policies from " + "NDFC or use a policy ID directly." + ) + ) + + # Should not reach here + result["action"] = "fail" + result["error_msg"] = "Unable to determine action for policy config." + return result + + # ========================================================================= + # Execute: Merged State + # ========================================================================= + + def _execute_merged(self, diff_results: List[Dict]) -> List[str]: + """Execute the computed actions for all config entries. + + Args: + diff_results: List of diff result dicts from _get_diff_merged_single. + + Returns: + List of policy IDs to deploy (if deploy=true). + """ + self.log.debug("ENTER: _execute_merged()") + self.log.debug(f"Processing {len(diff_results)} diff entries") + policy_ids_to_deploy = [] + + for diff_entry in diff_results: + action = diff_entry["action"] + want = diff_entry["want"] + have = diff_entry["have"] + policy_id = diff_entry["policy_id"] + field_diff = diff_entry["diff"] + error_msg = diff_entry["error_msg"] + + self.log.info( + f"Executing action={action} for " + f"{want.get('templateName', want.get('policyId', 'unknown'))}" + ) + + # --- FAIL --- + if action == "fail": + self._proposed.append(want) + self._register_result( + action="policy_merged", + operation_type=OperationType.QUERY, + return_code=-1, + message=error_msg, + success=False, + found=False, + diff={"action": action, "want": want, "error": error_msg}, + ) + continue + + # --- SKIP --- + if action == "skip": + self._proposed.append(want) + if have: + self._before.append(have) + self._after.append(have) # unchanged + diff_payload = {"action": action, "want": want} + if error_msg: + diff_payload["warning"] = error_msg + self._register_result( + action="policy_merged", + operation_type=OperationType.QUERY, + return_code=200, + message="No changes needed", + data=have or {}, + success=True, + found=have is not None, + diff=diff_payload, + ) + continue + + # --- CREATE --- + if action == "create": + self._proposed.append(want) + if self.check_mode: + self._after.append(want) # would-be state + self._register_result( + action="policy_create", + operation_type=OperationType.CREATE, + return_code=200, + message="OK (check_mode)", + success=True, + found=False, + diff={"action": action, "want": want, "diff": field_diff}, + ) + continue + + created_id = None + try: + created_id = self._api_create_policy(want) + except NDModuleError as create_err: + self.log.error(f"Create failed: {create_err.msg}") + self._register_result( + action="policy_create", + operation_type=OperationType.CREATE, + return_code=create_err.status or -1, + message=create_err.msg, + data=create_err.response_payload or {}, + success=False, + found=False, + diff={"action": "fail", "want": want, "error": create_err.msg}, + ) + continue + + if created_id: + policy_ids_to_deploy.append(created_id) + + self._after.append({**want, "policyId": created_id}) + + self._register_result( + action="policy_create", + operation_type=OperationType.CREATE, + return_code=200, + message="OK", + success=True, + found=False, + diff={ + "action": action, + "before": None, + "after": {**want, "policyId": created_id}, + "want": want, + "diff": field_diff, + "created_policy_id": created_id, + }, + ) + continue + + # --- UPDATE --- + if action == "update": + self._proposed.append(want) + self._before.append(have) + if self.check_mode: + self._after.append({**have, **want}) + self._register_result( + action="policy_update", + operation_type=OperationType.UPDATE, + return_code=200, + message="OK (check_mode)", + success=True, + found=True, + diff={ + "action": action, + "before": have, + "after": {**have, **want}, + "want": want, + "have": have, + "diff": field_diff, + "policy_id": policy_id, + }, + ) + continue + + self._api_update_policy(want, have, policy_id) + policy_ids_to_deploy.append(policy_id) + + after_merged = {**have, **want, "policyId": policy_id} + self._after.append(after_merged) + + self._register_result( + action="policy_update", + operation_type=OperationType.UPDATE, + return_code=200, + message="OK", + success=True, + found=True, + diff={ + "action": action, + "before": have, + "after": after_merged, + "want": want, + "have": have, + "diff": field_diff, + "policy_id": policy_id, + }, + ) + continue + + # --- DELETE_AND_CREATE --- + if action == "delete_and_create": + self._proposed.append(want) + self._before.append(have) + if self.check_mode: + self._after.append(want) # would-be replacement + self._register_result( + action="policy_replace", + operation_type=OperationType.UPDATE, + return_code=200, + message="OK (check_mode)", + success=True, + found=True, + diff={ + "action": action, + "before": have, + "after": want, + "want": want, + "have": have, + "diff": field_diff, + "delete_policy_id": policy_id, + }, + ) + continue + + # Step 1: Delete the old policy + self._api_remove_policies([policy_id]) + + # Step 2: Create the new policy + created_id = None + try: + created_id = self._api_create_policy(want) + except NDModuleError as create_err: + self.log.error(f"Re-create after delete failed: {create_err.msg}") + self._register_result( + action="policy_replace", + operation_type=OperationType.UPDATE, + return_code=create_err.status or -1, + message=f"Deleted policy {policy_id} but re-create failed: {create_err.msg}", + data=create_err.response_payload or {}, + success=False, + found=False, + diff={"action": "fail", "want": want, "have": have, + "deleted_policy_id": policy_id, "error": create_err.msg}, + ) + continue + + if created_id: + policy_ids_to_deploy.append(created_id) + + self._after.append({**want, "policyId": created_id}) + + self._register_result( + action="policy_replace", + operation_type=OperationType.UPDATE, + return_code=200, + message="OK", + success=True, + found=True, + diff={ + "action": action, + "before": have, + "after": {**want, "policyId": created_id}, + "want": want, + "have": have, + "diff": field_diff, + "deleted_policy_id": policy_id, + "created_policy_id": created_id, + }, + ) + continue + + self.log.info(f"Merged execute complete: {len(policy_ids_to_deploy)} policies to deploy") + self.log.debug("EXIT: _execute_merged()") + return policy_ids_to_deploy + + # ========================================================================= + # Diff: Query State (13 cases) + # ========================================================================= + + def _get_diff_query_single(self, want: Dict, have_list: List[Dict]) -> Dict: + """Compute the query result for a single config entry. + + Returns: + Dict with keys: action, want, policies, match_count, warning, error_msg. + """ + result = { + "action": None, + "want": want, + "policies": have_list, + "match_count": len(have_list), + "warning": None, + "error_msg": None, + } + + match_count = len(have_list) + + # Q-1, Q-2: Policy ID given + if "policyId" in want: + result["action"] = "found" if match_count >= 1 else "not_found" + return result + + # Switch-only: No name given → return everything found on this switch + if "templateName" not in want: + result["action"] = "found" if match_count >= 1 else "not_found" + return result + + # Q-3 to Q-9: Template name given, use_desc_as_key=false + if not self.use_desc_as_key: + result["action"] = "found" if match_count > 0 else "not_found" + return result + + # Q-10 to Q-13: Template name given, use_desc_as_key=true + if self.use_desc_as_key: + want_desc = want.get("description", "") + if not want_desc: + # Q-10: description required but not given + result["action"] = "fail" + result["error_msg"] = ( + "description is required when use_desc_as_key=true " + "and name is a template name." + ) + return result + + if match_count == 0: + result["action"] = "not_found" + return result + + if match_count == 1: + result["action"] = "found" + return result + + # Q-13: Multiple matches — return all with a warning. + # Query is read-only so there's no risk of ambiguous mutation. + # The warning alerts the user that descriptions aren't unique. + result["action"] = "found" + result["warning"] = ( + f"Multiple policies ({match_count}) found with description " + f"'{want_desc}' on switch {want.get('switchId')}. " + "Descriptions should be unique per switch when " + "use_desc_as_key=true." + ) + return result + + # Should not reach here + result["action"] = "not_found" + return result + + # ========================================================================= + # Execute: Query State + # ========================================================================= + + def _execute_query(self, diff_results: List[Dict]) -> None: + """Register results for all query config entries. + + Query state never makes changes — ``changed`` is always ``false``. + """ + self.log.debug("ENTER: _execute_query()") + self.log.debug(f"Processing {len(diff_results)} query entries") + + for diff_entry in diff_results: + action = diff_entry["action"] + want = diff_entry["want"] + policies = diff_entry["policies"] + match_count = diff_entry["match_count"] + warning = diff_entry["warning"] + error_msg = diff_entry["error_msg"] + + self.log.debug( + f"Query action={action} for " + f"{want.get('templateName', want.get('policyId', 'switch-only'))}, " + f"match_count={match_count}" + ) + + if action == "fail": + self.log.warning(f"Query failed: {error_msg}") + self._proposed.append(want) + self._register_result( + action="policy_query", + state="query", + operation_type=OperationType.QUERY, + return_code=-1, + message=error_msg, + success=False, + found=False, + diff={"action": action, "want": want, "error": error_msg}, + ) + continue + + if action == "not_found": + self._proposed.append(want) + self._register_result( + action="policy_query", + state="query", + operation_type=OperationType.QUERY, + return_code=200, + message="Not found", + success=True, + found=False, + diff={"action": action, "want": want, "policies": [], "match_count": 0}, + ) + continue + + if action == "found": + self._proposed.append(want) + self._after.extend(policies) # query: "after" = what exists now + diff_payload = { + "action": action, + "want": want, + "policies": policies, + "match_count": match_count, + } + if warning: + diff_payload["warning"] = warning + self._register_result( + action="policy_query", + state="query", + operation_type=OperationType.QUERY, + return_code=200, + message="OK", + data=policies, + success=True, + found=True, + diff=diff_payload, + ) + continue + + self.log.debug("EXIT: _execute_query()") + + # ========================================================================= + # Diff: Deleted State (16 cases) + # ========================================================================= + + def _get_diff_deleted_single(self, want: Dict, have_list: List[Dict]) -> Dict: + """Compute the delete result for a single config entry. + + Returns: + Dict with keys: action, want, policies, policy_ids, match_count, + warning, error_msg. + """ + policy_ids = [p.get("policyId") for p in have_list if p.get("policyId")] + result = { + "action": None, + "want": want, + "policies": have_list, + "policy_ids": policy_ids, + "match_count": len(have_list), + "warning": None, + "error_msg": None, + } + + match_count = len(have_list) + + # D-7, D-8: Policy ID given + if "policyId" in want: + if match_count == 0: + result["action"] = "skip" + else: + result["action"] = "delete" + return result + + # D-13 to D-16: Switch-only (no name given) + if "templateName" not in want: + if match_count == 0: + result["action"] = "skip" + else: + result["action"] = "delete_all" + return result + + # D-1 to D-6: Template name given, use_desc_as_key=false + if not self.use_desc_as_key: + if match_count == 0: + result["action"] = "skip" + elif match_count == 1: + result["action"] = "delete" + else: + result["action"] = "delete_all" + return result + + # D-9 to D-12: Template name given, use_desc_as_key=true + if self.use_desc_as_key: + want_desc = want.get("description", "") + if not want_desc: + # D-9: description required but not given + result["action"] = "fail" + result["error_msg"] = ( + "description is required when use_desc_as_key=true " + "and name is a template name." + ) + return result + + if match_count == 0: + result["action"] = "skip" + return result + + if match_count == 1: + result["action"] = "delete" + return result + + # D-12: Multiple matches → hard FAIL (ambiguous) + # Abort the entire task atomically — do not silently delete + # multiple policies when descriptions should be unique. + self.module.fail_json( + msg=( + f"Multiple policies ({match_count}) found with description " + f"'{want_desc}' on switch {want.get('switchId')}. " + "Descriptions must be unique per switch when " + "use_desc_as_key=true. Remove the duplicate policies from " + "NDFC or use a policy ID directly." + ) + ) + + # Should not reach here + result["action"] = "skip" + return result + + # ========================================================================= + # Execute: Deleted State + # ========================================================================= + + def _execute_deleted(self, diff_results: List[Dict]) -> None: + """Execute the computed actions for all deleted config entries. + + Collects all policy IDs to delete across all config entries, then + performs bulk API calls. PYTHON content-type templates (e.g. + ``switch_freeform``) use direct DELETE; everything else uses the + normal markDelete → pushConfig → remove flow. + + - deploy=true: markDelete → pushConfig → remove (3-step) + - deploy=false: markDelete only (1-step) + - PYTHON-type: direct DELETE (1-step, regardless of deploy) + """ + self.log.debug("ENTER: _execute_deleted()") + self.log.debug(f"Processing {len(diff_results)} delete entries") + + # Phase A: Register per-entry results and collect all policy IDs + all_policy_ids_to_delete = [] + all_switch_ids = [] + # Map policy ID → templateName so Phase B can route switch_freeform + # policies through a direct DELETE instead of markDelete. + policy_template_map: Dict[str, str] = {} + + for diff_entry in diff_results: + action = diff_entry["action"] + want = diff_entry["want"] + policies = diff_entry["policies"] + policy_ids = diff_entry["policy_ids"] + match_count = diff_entry["match_count"] + warning = diff_entry["warning"] + error_msg = diff_entry["error_msg"] + + self.log.debug( + f"Delete action={action} for " + f"{want.get('templateName', want.get('policyId', 'switch-only'))}, " + f"policy_ids={policy_ids}" + ) + + # --- FAIL --- + if action == "fail": + self.log.warning(f"Delete failed: {error_msg}") + self._proposed.append(want) + self._register_result( + action="policy_deleted", + state="deleted", + operation_type=OperationType.QUERY, + return_code=-1, + message=error_msg, + success=False, + found=False, + diff={"action": action, "want": want, "error": error_msg}, + ) + continue + + # --- SKIP --- + if action == "skip": + self.log.info( + f"Policy not found for deletion: " + f"{want.get('templateName', want.get('policyId', 'switch-only'))}" + ) + self._proposed.append(want) + self._register_result( + action="policy_deleted", + state="deleted", + operation_type=OperationType.QUERY, + return_code=200, + message="Policy not found — already absent", + success=True, + found=False, + diff={"action": action, "want": want}, + ) + continue + + # --- DELETE / DELETE_ALL --- + if action in ("delete", "delete_all"): + self.log.info( + f"Collecting {len(policy_ids)} policy(ies) for deletion: {policy_ids}" + ) + self._proposed.append(want) + self._before.extend(policies) # what existed before deletion + all_policy_ids_to_delete.extend(policy_ids) + + # Track templateName per policy for direct-delete routing + for p in policies: + pid = p.get("policyId", "") + tname = p.get("templateName", "") + if pid: + policy_template_map[pid] = tname + + # Collect switch IDs for result tracking + for p in policies: + sw = p.get("switchId", "") + if sw and sw not in all_switch_ids: + all_switch_ids.append(sw) + + if self.check_mode: + self.log.info(f"Check mode: would delete {len(policy_ids)} policy(ies)") + diff_payload = { + "action": action, + "want": want, + "policy_ids": policy_ids, + "match_count": match_count, + } + if warning: + diff_payload["warning"] = warning + self._register_result( + action="policy_deleted", + state="deleted", + operation_type=OperationType.DELETE, + return_code=200, + message="OK (check_mode)", + success=True, + found=True, + diff=diff_payload, + ) + continue + + # Register intent — actual API calls happen in bulk below + diff_payload = { + "action": action, + "want": want, + "policy_ids": policy_ids, + "match_count": match_count, + "policies": policies, + } + if warning: + diff_payload["warning"] = warning + self._register_result( + action="policy_deleted", + state="deleted", + operation_type=OperationType.DELETE, + return_code=200, + message="Pending bulk delete", + success=True, + found=True, + diff=diff_payload, + ) + continue + + # Phase B: Execute bulk API calls (skip if check_mode or nothing to delete) + if self.check_mode or not all_policy_ids_to_delete: + self.log.info( + "Skipping bulk delete: " + f"{'check_mode' if self.check_mode else 'no policies to delete'}" + ) + self.log.debug("EXIT: _execute_deleted()") + return + + # Deduplicate policy IDs (same policy could match multiple config entries) + unique_policy_ids = list(dict.fromkeys(all_policy_ids_to_delete)) + self.log.info( + f"Total policies to delete: {len(unique_policy_ids)} " + f"(deduplicated from {len(all_policy_ids_to_delete)})" + ) + + # --------------------------------------------------------------------- + # Split policies into two buckets: + # + # 1. PYTHON content-type templates (e.g. switch_freeform): + # markDelete FAILS for these ("Policies with content type PYTHON + # or without generated config can't be mark deleted"). + # Use direct DELETE /policies/{policyId} instead. + # NDFC may leave a ghost switch_freeform_config (markDeleted, + # negative priority) which gets cleaned up on next + # deploy/recalculate — no explicit cleanup needed. + # This matches the dcnm_policy approach (line 1149). + # + # 2. Everything else (TEMPLATE_CLI, e.g. feature_enable): + # Normal markDelete → pushConfig → remove flow. + # --------------------------------------------------------------------- + DIRECT_DELETE_TEMPLATES = {"switch_freeform"} + + direct_delete_ids = [ + pid for pid in unique_policy_ids + if policy_template_map.get(pid, "") in DIRECT_DELETE_TEMPLATES + ] + normal_delete_ids = [ + pid for pid in unique_policy_ids + if pid not in set(direct_delete_ids) + ] + + self.log.info( + f"Delete routing: {len(direct_delete_ids)} direct-DELETE " + f"(PYTHON-type), {len(normal_delete_ids)} markDelete flow" + ) + + # ----- Direct DELETE for PYTHON-type policies ----- + if direct_delete_ids: + self.log.info( + f"Direct-deleting {len(direct_delete_ids)} PYTHON-type " + f"policies: {direct_delete_ids}" + ) + deleted_direct = [] + failed_direct = [] + for pid in direct_delete_ids: + try: + self._api_delete_policy(pid) + deleted_direct.append(pid) + except Exception: # noqa: BLE001 + self.log.error(f"Direct DELETE failed for {pid}") + failed_direct.append(pid) + + if deleted_direct: + msg = ( + f"Directly deleted {len(deleted_direct)} " + f"switch_freeform policy(ies). " + "Note: NDFC may leave a residual " + "switch_freeform_config policy in markDeleted " + "state which is cleaned up automatically on " + "the next deploy or recalculate cycle." + ) + if self.deploy: + msg += ( + " deploy=true was requested but does not " + "apply to switch_freeform — these policies " + "are removed via direct DELETE without " + "pushConfig." + ) + self._register_result( + action="policy_direct_delete", + state="deleted", + operation_type=OperationType.DELETE, + return_code=200, + message=msg, + success=True, + found=True, + diff={ + "action": "direct_delete", + "policy_ids": deleted_direct, + "template": "switch_freeform", + }, + ) + if failed_direct: + self._register_result( + action="policy_direct_delete", + state="deleted", + operation_type=OperationType.DELETE, + return_code=-1, + message=( + f"Direct DELETE failed for {len(failed_direct)} " + f"policy(ies): {failed_direct}" + ), + success=False, + found=True, + diff={ + "action": "direct_delete_failed", + "policy_ids": failed_direct, + }, + ) + + # ----- Normal markDelete → pushConfig → remove for the rest ----- + if not normal_delete_ids: + self.log.info( + "No TEMPLATE_CLI policies to process through " + "markDelete flow — done" + ) + self.log.debug("EXIT: _execute_deleted()") + return + + # Step 1: markDelete + self.log.info( + f"{'Step 1/3' if self.deploy else 'Step 1/1'}: " + f"markDelete for {len(normal_delete_ids)} policies" + ) + self._api_mark_delete(normal_delete_ids) + + self._register_result( + action="policy_mark_delete", + state="deleted", + operation_type=OperationType.DELETE, + return_code=200, + message=f"Marked {len(normal_delete_ids)} policies for deletion", + success=True, + found=True, + diff={ + "action": "mark_delete", + "policy_ids": normal_delete_ids, + }, + ) + + # Step 2 (deploy=true only): pushConfig + deploy_success = True + if self.deploy: + self.log.info( + f"Step 2/3: pushConfig for {len(normal_delete_ids)} policies" + ) + deploy_success = self._deploy_policies( + normal_delete_ids, state="deleted" + ) + + # deploy=false legacy behavior: stop after markDelete + if not self.deploy: + self.log.info( + "Deploy=false: skipping pushConfig/remove; " + "policies remain marked for deletion" + ) + self.log.debug("EXIT: _execute_deleted()") + return + + # If pushConfig failed, do NOT proceed to remove. + if not deploy_success: + self.log.error( + "pushConfig failed — aborting remove. " + "Policies remain in markDeleted state." + ) + self._register_result( + action="policy_deploy_abort", + state="deleted", + operation_type=OperationType.DELETE, + return_code=-1, + message=( + "pushConfig failed for one or more policies. " + "Aborting remove — policies remain marked for deletion " + "with negative priority. Fix device connectivity and re-run." + ), + success=False, + found=True, + diff={ + "action": "deploy_abort", + "policy_ids": normal_delete_ids, + "reason": "pushConfig per-policy failure", + }, + ) + self.log.debug("EXIT: _execute_deleted()") + return + + # Step 3: remove — hard-delete policy records from NDFC + self.log.info( + f"Step 3/3: remove {len(normal_delete_ids)} policies" + ) + self._api_remove_policies(normal_delete_ids) + + self._register_result( + action="policy_remove", + state="deleted", + operation_type=OperationType.DELETE, + return_code=200, + message=f"Removed {len(normal_delete_ids)} policies", + success=True, + found=True, + diff={ + "action": "remove", + "policy_ids": normal_delete_ids, + }, + ) + + self.log.debug("EXIT: _execute_deleted()") + + # ========================================================================= + # Deploy: pushConfig + # ========================================================================= + + def _deploy_policies( + self, + policy_ids: List[str], + state: str = "merged", + ) -> bool: + """Deploy policies by calling pushConfig. + + Inspects the 207 Multi-Status response body for per-policy + failures (e.g., device connectivity issues). If any policy + has ``status: "failed"``, the deploy is considered failed. + + Args: + policy_ids: List of policy IDs to deploy. + state: Module state for result reporting. + + Returns: + True if all policies deployed successfully, False if any failed. + """ + if not policy_ids: + self.log.debug("No policy IDs to deploy, skipping") + return True + + self.log.info(f"Deploying {len(policy_ids)} policies via pushConfig") + + self.results.action = "policy_deploy" + self.results.state = state + self.results.check_mode = self.check_mode + self.results.operation_type = OperationType.UPDATE + + if self.check_mode: + self.log.info(f"Check mode: would deploy {len(policy_ids)} policies") + self.results.response_current = { + "RETURN_CODE": 200, + "MESSAGE": "OK (check_mode)", + "DATA": {}, + } + self.results.result_current = {"success": True, "found": True} + self.results.diff_current = { + "action": "deploy", + "policy_ids": policy_ids, + } + self.results.register_api_call() + return True + + push_body = PolicyIds(policy_ids=policy_ids) + + ep = EpManagePolicyActionsPushConfigPost() + ep.fabric_name = self.fabric_name + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + # NOTE: pushConfig does NOT accept ticketId per manage.json spec + + data = self.nd.request(ep.path, ep.verb, push_body.to_request_dict()) + + # Inspect 207 body for per-policy failures + failed_policies = [] + if isinstance(data, dict): + for p in data.get("policies", []): + if p.get("status") == "failed": + failed_policies.append(p) + + deploy_success = len(failed_policies) == 0 + + if failed_policies: + failed_msgs = [f"{p.get('policyId', '?')}: {p.get('message', 'unknown error')}" for p in failed_policies] + self.log.error( + f"pushConfig failed for {len(failed_policies)} policy(ies): " + + "; ".join(failed_msgs) + ) + + self.results.response_current = self.nd.rest_send.response_current + self.results.result_current = { + "success": deploy_success, + "found": True, + "changed": deploy_success, + } + self.results.diff_current = { + "action": "deploy", + "policy_ids": policy_ids, + "deploy_success": deploy_success, + "failed_policies": [p.get("policyId") for p in failed_policies], + } + self.results.register_api_call() + return deploy_success + + # ========================================================================= + # API Helpers (low-level CRUD) + # ========================================================================= + + def _api_create_policy(self, want: Dict) -> Optional[str]: + """Create a single policy via POST and return the created policy ID. + + Args: + want: The want dict with all policy fields. + + Returns: + Created policy ID string, or None if creation failed. + + Raises: + NDModuleError: If the API returns a 207 Multi-Status with a + per-policy failure (e.g., switch not in fabric). + """ + self.log.info( + f"Creating policy: template={want.get('templateName')}, " + f"switch={want.get('switchId')}" + ) + policy_model = PolicyCreate( + switch_id=want["switchId"], + template_name=want["templateName"], + entity_type="switch", + entity_name="SWITCH", + description=want.get("description", ""), + priority=want.get("priority", 500), + source=want.get("source", ""), + template_inputs=want.get("templateInputs"), + ) + bulk = PolicyCreateBulk(policies=[policy_model]) + payload = bulk.to_request_dict() + + ep = EpManagePoliciesPost() + ep.fabric_name = self.fabric_name + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + if self.ticket_id: + ep.endpoint_params.ticket_id = self.ticket_id + + data = self.nd.request(ep.path, ep.verb, payload) + + # Parse response to get policy ID + created_policies = data.get("policies", []) if isinstance(data, dict) else [] + if created_policies: + first = created_policies[0] + # Check for per-policy failure in 207 Multi-Status responses. + # The controller returns status="failed" for each policy that + # could not be created (e.g., switch not in fabric). + if first.get("status") == "failed": + error_msg = first.get("message", "Policy creation failed") + self.log.error( + f"Policy creation failed for " + f"template={want.get('templateName')}, " + f"switch={want.get('switchId')}: {error_msg}" + ) + raise NDModuleError( + msg=f"Policy creation failed: {error_msg}", + status=207, + request_payload=payload, + response_payload=data, + ) + created_id = first.get("policyId") + self.log.info(f"Created policy: {created_id}") + return created_id + self.log.warning("Create API returned no policy ID") + return None + + def _api_update_policy(self, want: Dict, have: Dict, policy_id: str) -> None: + """Update an existing policy via PUT. + + For templateInputs, merge user-specified keys on top of the + controller's existing values. This prevents accidentally + wiping template inputs when the user only wants to change + description or priority. + + Args: + want: The want dict with desired policy fields. + have: The existing policy dict from the controller. + policy_id: The policy ID to update. + """ + self.log.info(f"Updating policy: {policy_id}") + merged_inputs = dict(have.get("templateInputs") or {}) + for k, v in (want.get("templateInputs") or {}).items(): + merged_inputs[k] = v + self.log.debug(f"Merged templateInputs: {len(merged_inputs)} keys") + + update_model = PolicyUpdate( + switch_id=want["switchId"], + template_name=want.get("templateName", have.get("templateName")), + entity_type="switch", + entity_name="SWITCH", + description=want.get("description", ""), + priority=want.get("priority", 500), + source=want.get("source", have.get("source", "")), + template_inputs=merged_inputs, + ) + payload = update_model.to_request_dict() + + ep = EpManagePoliciesPut() + ep.fabric_name = self.fabric_name + ep.policy_id = policy_id + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + if self.ticket_id: + ep.endpoint_params.ticket_id = self.ticket_id + + self.nd.request(ep.path, ep.verb, payload) + + def _api_mark_delete(self, policy_ids: List[str]) -> None: + """Mark policies for deletion via POST /policyActions/markDelete. + + Args: + policy_ids: List of policy IDs to mark-delete. + """ + self.log.info(f"Marking {len(policy_ids)} policies for deletion: {policy_ids}") + body = PolicyIds(policy_ids=policy_ids) + + ep = EpManagePolicyActionsMarkDeletePost() + ep.fabric_name = self.fabric_name + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + if self.ticket_id: + ep.endpoint_params.ticket_id = self.ticket_id + + self.nd.request(ep.path, ep.verb, body.to_request_dict()) + + def _api_remove_policies(self, policy_ids: List[str]) -> None: + """Hard-delete policies via POST /policyActions/remove. + + Args: + policy_ids: List of policy IDs to remove from NDFC. + """ + self.log.info(f"Removing {len(policy_ids)} policies: {policy_ids}") + body = PolicyIds(policy_ids=policy_ids) + + ep = EpManagePolicyActionsRemovePost() + ep.fabric_name = self.fabric_name + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + if self.ticket_id: + ep.endpoint_params.ticket_id = self.ticket_id + + self.nd.request(ep.path, ep.verb, body.to_request_dict()) + + def _api_delete_policy(self, policy_id: str) -> None: + """Delete a single policy via DELETE /policies/{policyId}. + + Used for PYTHON content-type templates (e.g. ``switch_freeform``) + that cannot go through the markDelete flow, and for cleaning up + stale markDeleted policies. + + Args: + policy_id: Policy ID to delete (e.g., "POLICY-12345"). + """ + self.log.info(f"Deleting individual policy: {policy_id}") + + ep = EpManagePoliciesDelete() + ep.fabric_name = self.fabric_name + ep.policy_id = policy_id + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + if self.ticket_id: + ep.endpoint_params.ticket_id = self.ticket_id + + self.nd.request(ep.path, ep.verb) + + # ========================================================================= + # Results Helper + # ========================================================================= + + def _register_result( + self, + action: str, + operation_type: OperationType, + return_code: int, + message: str, + success: bool, + found: bool, + diff: Dict, + data: Any = None, + state: Optional[str] = None, + ) -> None: + """Register a single task result into the Results aggregator. + + Convenience wrapper to avoid repeating the same boilerplate + for every action/state combination. + + Args: + action: Action label (e.g., "policy_create", "policy_query"). + operation_type: OperationType enum value. + return_code: HTTP return code (or -1 for errors). + message: Human-readable message. + success: Whether the operation succeeded. + found: Whether the policy was found. + diff: Diff payload dict. + data: Optional response data. + state: Override state (defaults to self.state). + """ + self.results.action = action + self.results.state = state or self.state + self.results.check_mode = self.check_mode + self.results.operation_type = operation_type + self.results.response_current = { + "RETURN_CODE": return_code, + "MESSAGE": message, + "DATA": data if data is not None else {}, + } + result_dict = {"success": success, "found": found} + if not success: + result_dict["changed"] = False + self.results.result_current = result_dict + self.results.diff_current = diff + self.results.register_api_call() diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py new file mode 100644 index 00000000..c6516948 --- /dev/null +++ b/plugins/modules/nd_policy.py @@ -0,0 +1,683 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_policy +version_added: "1.0.0" +short_description: Manages policies on Nexus Dashboard Fabric Controller (NDFC). +description: +- Supports creating, updating, deleting, querying, and deploying policies based on templates. +- Supports C(merged) state for idempotent policy management. +- Supports C(deleted) state for removing policies from NDFC and optionally from switches. +- Supports C(query) state for retrieving existing policy information. +- When O(use_desc_as_key=true), policies are identified by their description instead of policy ID. +- B(Atomic behavior) — the entire task is treated as a single transaction. + If any validation check fails (e.g., missing or duplicate descriptions), the module + aborts B(before) making any changes to the controller. +- When O(use_desc_as_key=true), every O(config[].description) B(must) be non-empty and + unique per switch within the playbook. The module also fails if duplicate descriptions + are found on the NDFC controller itself (created outside of this playbook). This ensures + unambiguous policy matching. To manage policies with non-unique descriptions, use + O(use_desc_as_key=false) and reference policies by policy ID. +- Policies and switches are specified separately in the O(config) list. Global policies + apply to all switches listed in the C(switch) entry. Per-switch policy overrides can + be specified using the C(policies) suboption inside each switch entry (only when + O(use_desc_as_key=false)). Per-switch policies override global policies with the + same template name for that switch. +- B(Update behavior) — when O(use_desc_as_key=false) and a template name is given, + existing policies are never updated in-place. A new policy is always created. To update + a specific policy, provide its policy ID (C(POLICY-xxxxx)) as the O(config[].name). + When O(use_desc_as_key=true), the description uniquely identifies the policy, so + in-place updates are supported. +author: +- L Nikhil Sri Krishna +options: + fabric_name: + description: + - The name of the fabric containing the target switches. + type: str + required: true + aliases: [ fabric ] + config: + description: + - A list of dictionaries containing policy and switch information. + - Policy entries define the template, description, priority, and template inputs. + - A separate C(switch) entry lists the target switches and optional per-switch policy overrides. + - All global policies (entries without C(switch) key) are applied to every switch listed + in the C(switch) entry. Per-switch policies (specified under C(switch[].policies)) + override global policies with the same template name for that particular switch + (only when O(use_desc_as_key=false); when O(use_desc_as_key=true) per-switch + policies are simply merged with global policies). + type: list + elements: dict + required: true + suboptions: + name: + description: + - This can be one of the following. + - B(Template Name) — a name identifying the template (e.g., C(switch_freeform), C(feature_enable)). + Note that a template name can be used by multiple policies and hence does not identify a policy uniquely. + - B(Policy ID) — a unique ID identifying a policy (e.g., C(POLICY-121110)). + Policy ID B(must) be used for modifying existing policies when O(use_desc_as_key=false), + since template names cannot uniquely identify a policy. + - For C(query) and C(deleted) states, this is optional. When omitted, all policies + on the specified switch are returned/deleted. + type: str + description: + description: + - Description of the policy. + - When O(use_desc_as_key=true), this is used as the unique identifier for the policy + and B(must) be non-empty and unique per switch. The module fails atomically if + duplicate descriptions are detected in the playbook or on the NDFC controller. + type: str + default: "" + priority: + description: + - Priority of the policy. + - Valid range is 1-2000. + type: int + default: 500 + create_additional_policy: + description: + - A flag indicating if a policy is to be created even if an identical policy already exists. + - When set to V(true), a new duplicate policy is created regardless of whether a matching one exists. + - When set to V(false), duplicate creation is skipped if an identical policy already exists. + - Only relevant when O(use_desc_as_key=false) and O(config[].name) is a template name. + type: bool + default: true + template_inputs: + description: + - Dictionary of name/value pairs passed to the policy template. + - The required inputs depend on the template specified in O(config[].name). + type: dict + default: {} + switch: + description: + - A list of switches and optional per-switch policy overrides. + - All switches in this list will be deployed with the global policies defined + at the top level of O(config). Per-switch policy overrides can be specified + using the C(policies) suboption. + type: list + elements: dict + suboptions: + serial_number: + description: + - Serial number of the target switch (e.g., C(FDO25031SY4)). + - The alias C(ip) is kept for backward compatibility and may be a + switch management IP or hostname. The module resolves that value + to the switch serial number before calling policy APIs. + type: str + required: true + aliases: [ ip ] + policies: + description: + - A list of policies specific to this switch that override global policies + with the same template name (when O(use_desc_as_key=false)). + - When O(use_desc_as_key=true), per-switch policies are simply merged with + global policies rather than overriding by template name. + type: list + elements: dict + default: [] + suboptions: + name: + description: + - Template name or policy ID, same semantics as the top-level O(config[].name). + type: str + required: true + description: + description: + - Description of the policy. + type: str + default: "" + priority: + description: + - Priority of the policy. + type: int + default: 500 + create_additional_policy: + description: + - A flag indicating if a policy is to be created even if an identical policy already exists. + type: bool + default: true + template_inputs: + description: + - Dictionary of name/value pairs passed to the policy template. + type: dict + default: {} + use_desc_as_key: + description: + - When set to V(true), the policy description is used as the unique key for matching. + - When set to V(false), the template name (or policy ID if name starts with C(POLICY-)) is used. + - When V(true), every O(config[].description) must be non-empty (for C(merged) and C(deleted) states) + and unique per switch within the playbook. The module will B(fail immediately) if duplicate + C(description + switch) combinations are found in the playbook config or on the NDFC controller. + - This atomic-fail behavior ensures no partial changes are made when descriptions are ambiguous. + type: bool + default: false + deploy: + description: + - When set to V(true), policies are deployed to devices after create/update/delete operations. + - For C(merged) state, this triggers a pushConfig action for the affected policy IDs. + - For C(deleted) state, this triggers C(markDelete) → C(pushConfig) → C(remove) to remove + config from switches and then hard-delete the policy records from the controller. + - For C(deleted) with O(deploy=false), only C(markDelete) is performed on the controller. + Policy records remain marked for deletion (with negative priority) until a subsequent + run with O(deploy=true) or manual intervention. + - B(Exception) — C(switch_freeform) policies are always deleted via a direct C(DELETE) + API call regardless of the O(deploy) setting, because the C(markDelete) operation + is not supported for this template. + type: bool + default: true + ticket_id: + description: + - Change Control Ticket ID to associate with mutation operations. + - Required when Change Control is enabled on the NDFC controller. + type: str + cluster_name: + description: + - Target cluster name in a multi-cluster deployment. + type: str + state: + description: + - Use C(merged) to create or update policies. + - Use C(deleted) to delete policies. + - For C(deleted) with O(deploy=true), the module performs + C(markDelete) → C(pushConfig) → C(remove). + - For C(deleted) with O(deploy=false), only C(markDelete) is performed on the controller. + Policy records remain marked for deletion (with negative priority) until a subsequent + run with O(deploy=true) or manual intervention. + - B(Exception) — C(switch_freeform) policies skip the C(markDelete) flow entirely + and are removed via a direct C(DELETE) API call regardless of the O(deploy) setting. + - Use C(query) to retrieve existing policies without making changes. + type: str + choices: [ merged, deleted, query ] + default: merged +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +seealso: +- name: Cisco NDFC Policy Management + description: Understanding switch policy management on NDFC 4.x. +notes: +- When O(use_desc_as_key=false) and O(config[].name) is a template name (not a policy ID), + existing policies are B(never) updated in-place. The module always creates a new policy. + This is because multiple policies can share the same template name, making it ambiguous + which policy to update. To update a specific policy, use its policy ID (C(POLICY-xxxxx)). +- When O(use_desc_as_key=true), the description uniquely identifies the policy per switch, + so in-place updates B(are) supported. If the template name changes, the old policy is + deleted and a new one is created. +- C(switch_freeform) policies do not support the C(markDelete) API. They are always + removed via a direct C(DELETE) API call, regardless of the O(deploy) setting. +""" + +EXAMPLES = r""" +# NOTE: In the following create task, policies template_101, template_102, and template_103 +# are deployed on switch2, whereas policies template_104 and template_105 are the only +# policies installed on switch1 (per-switch override). + +- name: Create different policies with per-switch overrides + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: merged + deploy: true + config: + - name: template_101 + create_additional_policy: false + priority: 101 + + - name: template_102 + create_additional_policy: false + description: "102 - No priority given" + + - name: template_103 + create_additional_policy: false + description: "Both description and priority given" + priority: 500 + + - switch: + - serial_number: "{{ switch1 }}" + policies: + - name: template_104 + create_additional_policy: false + - name: template_105 + create_additional_policy: false + - serial_number: "{{ switch2 }}" + +# CREATE POLICY (including template inputs) + +- name: Create policy including required template inputs + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + config: + - name: switch_freeform + create_additional_policy: false + priority: 101 + template_inputs: + CONF: | + feature lacp + + - switch: + - serial_number: "{{ switch1 }}" + +# MODIFY POLICY (using policy ID) + +# NOTE: Since there can be multiple policies with the same template name, policy-id MUST be used +# to modify a particular policy when use_desc_as_key is false. + +- name: Modify policies using policy IDs + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: merged + deploy: true + config: + - name: POLICY-101101 + create_additional_policy: false + priority: 101 + + - name: POLICY-102102 + create_additional_policy: false + description: "Updated description" + + - switch: + - serial_number: "{{ switch1 }}" + +# UPDATE using description as key + +- name: Use description as key to update + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + use_desc_as_key: true + config: + - name: feature_enable + description: "Enable LACP" + priority: 100 + template_inputs: + featureName: lacp + + - switch: + - serial_number: "{{ switch1 }}" + state: merged + +# Use description as key with per-switch policies + +- name: Create policies with description as key + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + use_desc_as_key: true + config: + - name: switch_freeform + create_additional_policy: false + description: "policy_radius" + template_inputs: + CONF: | + radius-server host 10.1.1.2 key 7 "ljw3976!" authentication accounting + - switch: + - serial_number: "{{ switch1 }}" + policies: + - name: switch_freeform + create_additional_policy: false + priority: 101 + description: "feature bfd" + template_inputs: + CONF: | + feature bfd + - name: switch_freeform + create_additional_policy: false + priority: 102 + description: "feature bash-shell" + template_inputs: + CONF: | + feature bash-shell + - serial_number: "{{ switch2 }}" + - serial_number: "{{ switch3 }}" + +# DELETE POLICY + +- name: Delete policies using template name + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: deleted + config: + - name: template_101 + - name: template_102 + - name: template_103 + - switch: + - serial_number: "{{ switch1 }}" + - serial_number: "{{ switch2 }}" + +- name: Delete policies using policy-id + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: deleted + config: + - name: POLICY-101101 + - name: POLICY-102102 + - switch: + - serial_number: "{{ switch1 }}" + +- name: Delete all policies on switches + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: deleted + config: + - switch: + - serial_number: "{{ switch1 }}" + - serial_number: "{{ switch2 }}" + +- name: Delete policies without deploying (mark for deletion only) + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: deleted + deploy: false + config: + - name: template_101 + - switch: + - serial_number: "{{ switch1 }}" + +# NOTE: switch_freeform policies are always directly deleted +# regardless of the deploy setting. + +- name: Delete switch_freeform policies (direct DELETE) + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: deleted + config: + - name: switch_freeform + - switch: + - serial_number: "{{ switch1 }}" + +- name: Delete policies using description as key + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + use_desc_as_key: true + state: deleted + config: + - name: switch_freeform + description: "Enable LACP" + - switch: + - serial_number: "{{ switch1 }}" + +# QUERY + +- name: Query all policies from specified switches + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: query + config: + - switch: + - serial_number: "{{ switch1 }}" + - serial_number: "{{ switch2 }}" + +- name: Query policies matching template names + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: query + config: + - name: template_101 + - name: template_102 + - switch: + - serial_number: "{{ switch1 }}" + +- name: Query policies using policy-ids + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: query + config: + - name: POLICY-101101 + - name: POLICY-102102 + - switch: + - serial_number: "{{ switch1 }}" +""" + +RETURN = r""" +changed: + description: Whether any changes were made. + returned: always + type: bool + sample: false +failed: + description: Whether the operation failed. + returned: always + type: bool + sample: false +before: + description: + - List of policy snapshots B(before) the module made any changes. + - For C(merged) state, contains the existing policy state prior to create/update. + - For C(deleted) state, contains the policies that were deleted. + - For C(query) state, this is an empty list. + returned: always + type: list + elements: dict +after: + description: + - List of policy snapshots B(after) the module completed. + - For C(merged) state, contains the new/updated policy state. + - For C(deleted) state, this is an empty list (policies were removed). + - For C(query) state, contains the queried policies. + returned: always + type: list + elements: dict +proposed: + description: + - List of proposed policy changes derived from the playbook config. + - Only returned when O(output_level) is set to V(info) or V(debug). + returned: when output_level is info or debug + type: list + elements: dict +diff: + description: List of differences between desired and existing state. + returned: always + type: list + elements: dict +response: + description: List of controller responses. + returned: always + type: list + elements: dict +result: + description: List of operation results. + returned: always + type: list + elements: dict +metadata: + description: List of operation metadata. + returned: always + type: list + elements: dict +""" + +import copy +import logging + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log +from ansible_collections.cisco.nd.plugins.module_utils.nd_policy_resources import NDPolicyModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule, + NDModuleError, + nd_argument_spec, +) +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + + +# ============================================================================= +# Main +# ============================================================================= +def main(): + """Main entry point for the nd_policy module.""" + + # Per-switch policy suboptions (used inside switch[].policies) + switch_policy_spec = dict( + name=dict(type="str", required=True), + description=dict(type="str", default=""), + priority=dict(type="int", default=500), + create_additional_policy=dict(type="bool", default=True), + template_inputs=dict(type="dict", default={}), + ) + + # Switch list suboptions + switch_spec = dict( + serial_number=dict(type="str", required=True, aliases=["ip"]), + policies=dict(type="list", elements="dict", default=[], options=switch_policy_spec), + ) + + # Top-level config entry suboptions + config_spec = dict( + name=dict(type="str"), + description=dict(type="str", default=""), + priority=dict(type="int", default=500), + create_additional_policy=dict(type="bool", default=True), + template_inputs=dict(type="dict", default={}), + switch=dict(type="list", elements="dict", options=switch_spec), + ) + + argument_spec = nd_argument_spec() + argument_spec.update( + fabric_name=dict(type="str", required=True, aliases=["fabric"]), + config=dict(type="list", elements="dict", required=True, options=config_spec), + use_desc_as_key=dict(type="bool", default=False), + deploy=dict(type="bool", default=True), + ticket_id=dict(type="str"), + cluster_name=dict(type="str"), + state=dict(type="str", default="merged", choices=["merged", "deleted", "query"]), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + # Initialize logging + try: + log_config = Log() + log_config.commit() + log = logging.getLogger("nd.nd_policy") + except ValueError as error: + module.fail_json(msg=str(error)) + + # Get parameters + state = module.params.get("state") + output_level = module.params.get("output_level") + + if not module.params.get("config"): + module.fail_json( + msg=f"'config' element is mandatory for state '{state}'." + ) + + # Initialize NDModule (REST client) + try: + nd = NDModule(module) + except Exception as error: + module.fail_json(msg=f"Failed to initialize NDModule: {str(error)}") + + # Initialize Results + results = Results() + results.state = state + results.check_mode = module.check_mode + results.action = f"policy_{state}" + + try: + log.info(f"Starting nd_policy module: state={state}") + + # Create NDPolicyModule — all business logic lives here + policy_module = NDPolicyModule( + nd=nd, + results=results, + logger=log, + ) + + # Resolve switch IPs/hostnames → serial numbers + translated_input = policy_module.resolve_switch_identifiers( + copy.deepcopy(module.params["config"]), + ) + + # Flatten multi-switch config into one entry per (policy, switch) + translated_config = NDPolicyModule.translate_config( + translated_input, + module.params.get("use_desc_as_key"), + ) + + # Validate translated config + policy_module.validate_translated_config(translated_config) + + # Override module.params["config"] with the flat config + module.params["config"] = translated_config + policy_module.config = translated_config + + # Manage state for merged, query, deleted + log.info(f"Managing state: {state}") + policy_module.manage_state() + + # Exit with results + log.info(f"State management completed successfully. Changed: {results.changed}") + policy_module.exit_json() + + except NDModuleError as error: + log.error(f"NDModule error: {error.msg}") + + try: + results.response_current = nd.rest_send.response_current + results.result_current = nd.rest_send.result_current + except (AttributeError, ValueError): + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "DATA": error.response_payload if error.response_payload else {}, + } + results.result_current = {"success": False, "found": False} + + results.diff_current = {} + results.register_api_call() + results.build_final_result() + + if output_level == "debug": + results.final_result["error_details"] = error.to_dict() + + log.error(f"Module failed: {results.final_result}") + module.fail_json(msg=error.msg, **results.final_result) + + except Exception as error: + import traceback + tb_str = traceback.format_exc() + + log.error(f"Unexpected error during module execution: {str(error)}") + log.error(f"Error type: {type(error).__name__}") + + try: + results.response_current = { + "RETURN_CODE": -1, + "MESSAGE": f"Unexpected error: {str(error)}", + "DATA": {}, + } + results.result_current = {"success": False, "found": False} + results.diff_current = {} + results.register_api_call() + results.build_final_result() + + fail_kwargs = results.final_result + except Exception: + fail_kwargs = {} + + if output_level == "debug": + fail_kwargs["traceback"] = tb_str + + module.fail_json(msg=f"{type(error).__name__}: {str(error)}", **fail_kwargs) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py new file mode 100644 index 00000000..cdac9979 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py @@ -0,0 +1,237 @@ +# Copyright: (c) 2026, Cisco Systems + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for manage_config_templates.py + +Tests the ND Manage Config Templates endpoint classes. +""" + +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.v1.manage.manage_config_templates import ( + ConfigTemplateEndpointParams, + EpManageConfigTemplateParametersGet, +) +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: ConfigTemplateEndpointParams +# ============================================================================= + + +def test_manage_config_templates_00010(): + """ + # Summary + + Verify ConfigTemplateEndpointParams default values + + ## Test + + - cluster_name defaults to None + + ## Classes and Methods + + - ConfigTemplateEndpointParams.__init__() + """ + with does_not_raise(): + params = ConfigTemplateEndpointParams() + assert params.cluster_name is None + + +def test_manage_config_templates_00020(): + """ + # Summary + + Verify ConfigTemplateEndpointParams generates query string + + ## Test + + - to_query_string() includes clusterName when set + + ## Classes and Methods + + - ConfigTemplateEndpointParams.to_query_string() + """ + with does_not_raise(): + params = ConfigTemplateEndpointParams(cluster_name="cluster1") + result = params.to_query_string() + assert result == "clusterName=cluster1" + + +def test_manage_config_templates_00030(): + """ + # Summary + + Verify ConfigTemplateEndpointParams returns empty when no params set + + ## Test + + - to_query_string() returns empty string + + ## Classes and Methods + + - ConfigTemplateEndpointParams.to_query_string() + """ + params = ConfigTemplateEndpointParams() + assert params.to_query_string() == "" + + +def test_manage_config_templates_00040(): + """ + # Summary + + Verify ConfigTemplateEndpointParams rejects extra fields + + ## Test + + - Extra fields cause validation error (extra="forbid") + + ## Classes and Methods + + - ConfigTemplateEndpointParams.__init__() + """ + with pytest.raises(ValueError): + ConfigTemplateEndpointParams(bogus="bad") + + +# ============================================================================= +# Test: EpManageConfigTemplateParametersGet +# ============================================================================= + + +def test_manage_config_templates_00100(): + """ + # Summary + + Verify EpManageConfigTemplateParametersGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManageConfigTemplateParametersGet.__init__() + - EpManageConfigTemplateParametersGet.class_name + - EpManageConfigTemplateParametersGet.verb + """ + with does_not_raise(): + instance = EpManageConfigTemplateParametersGet() + assert instance.class_name == "EpManageConfigTemplateParametersGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_manage_config_templates_00110(): + """ + # Summary + + Verify EpManageConfigTemplateParametersGet raises ValueError when template_name is not set + + ## Test + + - Accessing path raises ValueError when template_name is None + + ## Classes and Methods + + - EpManageConfigTemplateParametersGet.path + """ + instance = EpManageConfigTemplateParametersGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_config_templates_00120(): + """ + # Summary + + Verify EpManageConfigTemplateParametersGet path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageConfigTemplateParametersGet.path + """ + with does_not_raise(): + instance = EpManageConfigTemplateParametersGet() + instance.template_name = "switch_freeform" + result = instance.path + assert result == "/api/v1/manage/configTemplates/switch_freeform/parameters" + + +def test_manage_config_templates_00130(): + """ + # Summary + + Verify EpManageConfigTemplateParametersGet path with different template names + + ## Test + + - path correctly interpolates template_name + + ## Classes and Methods + + - EpManageConfigTemplateParametersGet.path + """ + with does_not_raise(): + instance = EpManageConfigTemplateParametersGet() + instance.template_name = "feature_enable" + result = instance.path + assert result == "/api/v1/manage/configTemplates/feature_enable/parameters" + + +def test_manage_config_templates_00140(): + """ + # Summary + + Verify EpManageConfigTemplateParametersGet path with clusterName + + ## Test + + - path includes clusterName in query string when set + + ## Classes and Methods + + - EpManageConfigTemplateParametersGet.path + """ + with does_not_raise(): + instance = EpManageConfigTemplateParametersGet() + instance.template_name = "switch_freeform" + instance.endpoint_params.cluster_name = "cluster1" + result = instance.path + assert result == ( + "/api/v1/manage/configTemplates/switch_freeform/parameters?clusterName=cluster1" + ) + + +def test_manage_config_templates_00150(): + """ + # Summary + + Verify EpManageConfigTemplateParametersGet template_name rejects empty string + + ## Test + + - template_name with empty string raises validation error (min_length=1) + + ## Classes and Methods + + - EpManageConfigTemplateParametersGet.__init__() + """ + with pytest.raises(ValueError): + EpManageConfigTemplateParametersGet(template_name="") diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policies.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policies.py new file mode 100644 index 00000000..e57b0419 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policies.py @@ -0,0 +1,683 @@ +# Copyright: (c) 2026, Cisco Systems + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for manage_policies.py + +Tests the ND Manage Policies endpoint classes. +""" + +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.v1.manage.manage_policies import ( + EpManagePoliciesDelete, + EpManagePoliciesGet, + EpManagePoliciesPost, + EpManagePoliciesPut, + PoliciesGetEndpointParams, + PolicyMutationEndpointParams, +) +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: PoliciesGetEndpointParams +# ============================================================================= + + +def test_manage_policies_00010(): + """ + # Summary + + Verify PoliciesGetEndpointParams default values + + ## Test + + - cluster_name defaults to None + + ## Classes and Methods + + - PoliciesGetEndpointParams.__init__() + """ + with does_not_raise(): + params = PoliciesGetEndpointParams() + assert params.cluster_name is None + + +def test_manage_policies_00020(): + """ + # Summary + + Verify PoliciesGetEndpointParams generates query string with cluster_name + + ## Test + + - to_query_string() includes clusterName when set + + ## Classes and Methods + + - PoliciesGetEndpointParams.to_query_string() + """ + with does_not_raise(): + params = PoliciesGetEndpointParams(cluster_name="cluster1") + result = params.to_query_string() + assert result == "clusterName=cluster1" + + +def test_manage_policies_00030(): + """ + # Summary + + Verify PoliciesGetEndpointParams returns empty string when no params set + + ## Test + + - to_query_string() returns empty string when cluster_name is None + + ## Classes and Methods + + - PoliciesGetEndpointParams.to_query_string() + """ + params = PoliciesGetEndpointParams() + assert params.to_query_string() == "" + + +def test_manage_policies_00040(): + """ + # Summary + + Verify PoliciesGetEndpointParams rejects extra fields + + ## Test + + - Extra fields cause validation error (extra="forbid") + + ## Classes and Methods + + - PoliciesGetEndpointParams.__init__() + """ + with pytest.raises(ValueError): + PoliciesGetEndpointParams(bogus="bad") + + +# ============================================================================= +# Test: PolicyMutationEndpointParams +# ============================================================================= + + +def test_manage_policies_00050(): + """ + # Summary + + Verify PolicyMutationEndpointParams default values + + ## Test + + - cluster_name defaults to None + - ticket_id defaults to None + + ## Classes and Methods + + - PolicyMutationEndpointParams.__init__() + """ + with does_not_raise(): + params = PolicyMutationEndpointParams() + assert params.cluster_name is None + assert params.ticket_id is None + + +def test_manage_policies_00060(): + """ + # Summary + + Verify PolicyMutationEndpointParams generates query string with both params + + ## Test + + - to_query_string() includes clusterName and ticketId when both are set + + ## Classes and Methods + + - PolicyMutationEndpointParams.to_query_string() + """ + with does_not_raise(): + params = PolicyMutationEndpointParams(cluster_name="cluster1", ticket_id="MyTicket1234") + result = params.to_query_string() + assert "clusterName=cluster1" in result + assert "ticketId=MyTicket1234" in result + + +def test_manage_policies_00070(): + """ + # Summary + + Verify PolicyMutationEndpointParams ticket_id pattern validation + + ## Test + + - ticket_id rejects values not matching ^[a-zA-Z][a-zA-Z0-9_-]+$ + + ## Classes and Methods + + - PolicyMutationEndpointParams.__init__() + """ + with pytest.raises(ValueError): + PolicyMutationEndpointParams(ticket_id="123-invalid") + + +def test_manage_policies_00075(): + """ + # Summary + + Verify PolicyMutationEndpointParams ticket_id max length validation + + ## Test + + - ticket_id rejects values longer than 64 characters + + ## Classes and Methods + + - PolicyMutationEndpointParams.__init__() + """ + with pytest.raises(ValueError): + PolicyMutationEndpointParams(ticket_id="A" * 65) + + +# ============================================================================= +# Test: EpManagePoliciesGet +# ============================================================================= + + +def test_manage_policies_00100(): + """ + # Summary + + Verify EpManagePoliciesGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManagePoliciesGet.__init__() + - EpManagePoliciesGet.class_name + - EpManagePoliciesGet.verb + """ + with does_not_raise(): + instance = EpManagePoliciesGet() + assert instance.class_name == "EpManagePoliciesGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_manage_policies_00110(): + """ + # Summary + + Verify EpManagePoliciesGet raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManagePoliciesGet.path + """ + instance = EpManagePoliciesGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policies_00120(): + """ + # Summary + + Verify EpManagePoliciesGet path without query params + + ## Test + + - path returns correct base endpoint path + + ## Classes and Methods + + - EpManagePoliciesGet.path + """ + with does_not_raise(): + instance = EpManagePoliciesGet() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policies" + + +def test_manage_policies_00130(): + """ + # Summary + + Verify EpManagePoliciesGet path with policy_id returns single-policy path + + ## Test + + - path includes policyId segment when policy_id is set + + ## Classes and Methods + + - EpManagePoliciesGet.path + """ + with does_not_raise(): + instance = EpManagePoliciesGet() + instance.fabric_name = "my-fabric" + instance.policy_id = "POLICY-12345" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policies/POLICY-12345" + + +def test_manage_policies_00140(): + """ + # Summary + + Verify EpManagePoliciesGet path with Lucene filter parameters + + ## Test + + - path includes filter and max in query string when Lucene params are set + + ## Classes and Methods + + - EpManagePoliciesGet.path + """ + with does_not_raise(): + instance = EpManagePoliciesGet() + instance.fabric_name = "my-fabric" + instance.lucene_params.filter = "switchId:FDO123 AND templateName:switch_freeform" + instance.lucene_params.max = 100 + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/my-fabric/policies?") + assert "max=100" in result + assert "filter=" in result + + +def test_manage_policies_00150(): + """ + # Summary + + Verify EpManagePoliciesGet path with clusterName query param + + ## Test + + - path includes clusterName in query string when set + + ## Classes and Methods + + - EpManagePoliciesGet.path + """ + with does_not_raise(): + instance = EpManagePoliciesGet() + instance.fabric_name = "my-fabric" + instance.endpoint_params.cluster_name = "cluster1" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/my-fabric/policies?") + assert "clusterName=cluster1" in result + + +def test_manage_policies_00160(): + """ + # Summary + + Verify EpManagePoliciesGet path with combined endpoint and Lucene params + + ## Test + + - path includes both clusterName and Lucene params + + ## Classes and Methods + + - EpManagePoliciesGet.path + """ + with does_not_raise(): + instance = EpManagePoliciesGet() + instance.fabric_name = "my-fabric" + instance.endpoint_params.cluster_name = "cluster1" + instance.lucene_params.max = 50 + instance.lucene_params.sort = "policyId:asc" + result = instance.path + assert "clusterName=cluster1" in result + assert "max=50" in result + assert "sort=" in result + + +# ============================================================================= +# Test: EpManagePoliciesPost +# ============================================================================= + + +def test_manage_policies_00200(): + """ + # Summary + + Verify EpManagePoliciesPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManagePoliciesPost.__init__() + - EpManagePoliciesPost.class_name + - EpManagePoliciesPost.verb + """ + with does_not_raise(): + instance = EpManagePoliciesPost() + assert instance.class_name == "EpManagePoliciesPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_manage_policies_00210(): + """ + # Summary + + Verify EpManagePoliciesPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManagePoliciesPost.path + """ + instance = EpManagePoliciesPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policies_00220(): + """ + # Summary + + Verify EpManagePoliciesPost path without query params + + ## Test + + - path returns correct base endpoint path + + ## Classes and Methods + + - EpManagePoliciesPost.path + """ + with does_not_raise(): + instance = EpManagePoliciesPost() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policies" + + +def test_manage_policies_00230(): + """ + # Summary + + Verify EpManagePoliciesPost path with clusterName and ticketId + + ## Test + + - path includes clusterName and ticketId in query string when set + + ## Classes and Methods + + - EpManagePoliciesPost.path + """ + with does_not_raise(): + instance = EpManagePoliciesPost() + instance.fabric_name = "my-fabric" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "MyTicket1234" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/my-fabric/policies?") + assert "clusterName=cluster1" in result + assert "ticketId=MyTicket1234" in result + + +# ============================================================================= +# Test: EpManagePoliciesPut +# ============================================================================= + + +def test_manage_policies_00300(): + """ + # Summary + + Verify EpManagePoliciesPut basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is PUT + + ## Classes and Methods + + - EpManagePoliciesPut.__init__() + - EpManagePoliciesPut.class_name + - EpManagePoliciesPut.verb + """ + with does_not_raise(): + instance = EpManagePoliciesPut() + assert instance.class_name == "EpManagePoliciesPut" + assert instance.verb == HttpVerbEnum.PUT + + +def test_manage_policies_00310(): + """ + # Summary + + Verify EpManagePoliciesPut raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManagePoliciesPut.path + """ + instance = EpManagePoliciesPut() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policies_00320(): + """ + # Summary + + Verify EpManagePoliciesPut raises ValueError when policy_id is not set + + ## Test + + - Accessing path raises ValueError when policy_id is None + + ## Classes and Methods + + - EpManagePoliciesPut.path + """ + instance = EpManagePoliciesPut() + instance.fabric_name = "my-fabric" + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policies_00330(): + """ + # Summary + + Verify EpManagePoliciesPut path with fabric_name and policy_id + + ## Test + + - path returns correct endpoint path with policyId + + ## Classes and Methods + + - EpManagePoliciesPut.path + """ + with does_not_raise(): + instance = EpManagePoliciesPut() + instance.fabric_name = "my-fabric" + instance.policy_id = "POLICY-12345" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policies/POLICY-12345" + + +def test_manage_policies_00340(): + """ + # Summary + + Verify EpManagePoliciesPut path with query params + + ## Test + + - path includes clusterName and ticketId in query string when set + + ## Classes and Methods + + - EpManagePoliciesPut.path + """ + with does_not_raise(): + instance = EpManagePoliciesPut() + instance.fabric_name = "my-fabric" + instance.policy_id = "POLICY-12345" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "MyTicket1234" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/my-fabric/policies/POLICY-12345?") + assert "clusterName=cluster1" in result + assert "ticketId=MyTicket1234" in result + + +# ============================================================================= +# Test: EpManagePoliciesDelete +# ============================================================================= + + +def test_manage_policies_00400(): + """ + # Summary + + Verify EpManagePoliciesDelete basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is DELETE + + ## Classes and Methods + + - EpManagePoliciesDelete.__init__() + - EpManagePoliciesDelete.class_name + - EpManagePoliciesDelete.verb + """ + with does_not_raise(): + instance = EpManagePoliciesDelete() + assert instance.class_name == "EpManagePoliciesDelete" + assert instance.verb == HttpVerbEnum.DELETE + + +def test_manage_policies_00410(): + """ + # Summary + + Verify EpManagePoliciesDelete raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManagePoliciesDelete.path + """ + instance = EpManagePoliciesDelete() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policies_00420(): + """ + # Summary + + Verify EpManagePoliciesDelete raises ValueError when policy_id is not set + + ## Test + + - Accessing path raises ValueError when policy_id is None + + ## Classes and Methods + + - EpManagePoliciesDelete.path + """ + instance = EpManagePoliciesDelete() + instance.fabric_name = "my-fabric" + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policies_00430(): + """ + # Summary + + Verify EpManagePoliciesDelete path with fabric_name and policy_id + + ## Test + + - path returns correct endpoint path with policyId + + ## Classes and Methods + + - EpManagePoliciesDelete.path + """ + with does_not_raise(): + instance = EpManagePoliciesDelete() + instance.fabric_name = "my-fabric" + instance.policy_id = "POLICY-12345" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policies/POLICY-12345" + + +def test_manage_policies_00440(): + """ + # Summary + + Verify EpManagePoliciesDelete path with query params + + ## Test + + - path includes clusterName and ticketId in query string when set + + ## Classes and Methods + + - EpManagePoliciesDelete.path + """ + with does_not_raise(): + instance = EpManagePoliciesDelete() + instance.fabric_name = "my-fabric" + instance.policy_id = "POLICY-12345" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "MyTicket1234" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/my-fabric/policies/POLICY-12345?") + assert "clusterName=cluster1" in result + assert "ticketId=MyTicket1234" in result diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policy_actions.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policy_actions.py new file mode 100644 index 00000000..109f6d9b --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policy_actions.py @@ -0,0 +1,442 @@ +# Copyright: (c) 2026, Cisco Systems + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for manage_policy_actions.py + +Tests the ND Manage Policy Actions endpoint classes. +""" + +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.v1.manage.manage_policy_actions import ( + EpManagePolicyActionsMarkDeletePost, + EpManagePolicyActionsPushConfigPost, + EpManagePolicyActionsRemovePost, + PolicyActionMutationEndpointParams, + PolicyPushConfigEndpointParams, +) +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: PolicyActionMutationEndpointParams +# ============================================================================= + + +def test_manage_policy_actions_00010(): + """ + # Summary + + Verify PolicyActionMutationEndpointParams default values + + ## Test + + - cluster_name defaults to None + - ticket_id defaults to None + + ## Classes and Methods + + - PolicyActionMutationEndpointParams.__init__() + """ + with does_not_raise(): + params = PolicyActionMutationEndpointParams() + assert params.cluster_name is None + assert params.ticket_id is None + + +def test_manage_policy_actions_00020(): + """ + # Summary + + Verify PolicyActionMutationEndpointParams generates query string + + ## Test + + - to_query_string() includes clusterName and ticketId when both set + + ## Classes and Methods + + - PolicyActionMutationEndpointParams.to_query_string() + """ + with does_not_raise(): + params = PolicyActionMutationEndpointParams( + cluster_name="cluster1", ticket_id="MyTicket1234" + ) + result = params.to_query_string() + assert "clusterName=cluster1" in result + assert "ticketId=MyTicket1234" in result + + +def test_manage_policy_actions_00030(): + """ + # Summary + + Verify PolicyActionMutationEndpointParams returns empty when no params set + + ## Test + + - to_query_string() returns empty string + + ## Classes and Methods + + - PolicyActionMutationEndpointParams.to_query_string() + """ + params = PolicyActionMutationEndpointParams() + assert params.to_query_string() == "" + + +# ============================================================================= +# Test: PolicyPushConfigEndpointParams +# ============================================================================= + + +def test_manage_policy_actions_00040(): + """ + # Summary + + Verify PolicyPushConfigEndpointParams default values + + ## Test + + - cluster_name defaults to None + + ## Classes and Methods + + - PolicyPushConfigEndpointParams.__init__() + """ + with does_not_raise(): + params = PolicyPushConfigEndpointParams() + assert params.cluster_name is None + + +def test_manage_policy_actions_00050(): + """ + # Summary + + Verify PolicyPushConfigEndpointParams generates query string + + ## Test + + - to_query_string() includes clusterName when set + + ## Classes and Methods + + - PolicyPushConfigEndpointParams.to_query_string() + """ + with does_not_raise(): + params = PolicyPushConfigEndpointParams(cluster_name="cluster1") + result = params.to_query_string() + assert result == "clusterName=cluster1" + + +def test_manage_policy_actions_00055(): + """ + # Summary + + Verify PolicyPushConfigEndpointParams rejects extra fields (no ticketId) + + ## Test + + - Extra fields like ticket_id cause validation error (extra="forbid") + + ## Classes and Methods + + - PolicyPushConfigEndpointParams.__init__() + """ + with pytest.raises(ValueError): + PolicyPushConfigEndpointParams(ticket_id="ShouldFail") + + +# ============================================================================= +# Test: EpManagePolicyActionsMarkDeletePost +# ============================================================================= + + +def test_manage_policy_actions_00100(): + """ + # Summary + + Verify EpManagePolicyActionsMarkDeletePost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManagePolicyActionsMarkDeletePost.__init__() + - EpManagePolicyActionsMarkDeletePost.class_name + - EpManagePolicyActionsMarkDeletePost.verb + """ + with does_not_raise(): + instance = EpManagePolicyActionsMarkDeletePost() + assert instance.class_name == "EpManagePolicyActionsMarkDeletePost" + assert instance.verb == HttpVerbEnum.POST + + +def test_manage_policy_actions_00110(): + """ + # Summary + + Verify EpManagePolicyActionsMarkDeletePost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManagePolicyActionsMarkDeletePost.path + """ + instance = EpManagePolicyActionsMarkDeletePost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policy_actions_00120(): + """ + # Summary + + Verify EpManagePolicyActionsMarkDeletePost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManagePolicyActionsMarkDeletePost.path + """ + with does_not_raise(): + instance = EpManagePolicyActionsMarkDeletePost() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policyActions/markDelete" + + +def test_manage_policy_actions_00130(): + """ + # Summary + + Verify EpManagePolicyActionsMarkDeletePost path with query params + + ## Test + + - path includes clusterName and ticketId in query string + + ## Classes and Methods + + - EpManagePolicyActionsMarkDeletePost.path + """ + with does_not_raise(): + instance = EpManagePolicyActionsMarkDeletePost() + instance.fabric_name = "my-fabric" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "MyTicket1234" + result = instance.path + assert result.startswith( + "/api/v1/manage/fabrics/my-fabric/policyActions/markDelete?" + ) + assert "clusterName=cluster1" in result + assert "ticketId=MyTicket1234" in result + + +# ============================================================================= +# Test: EpManagePolicyActionsPushConfigPost +# ============================================================================= + + +def test_manage_policy_actions_00200(): + """ + # Summary + + Verify EpManagePolicyActionsPushConfigPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManagePolicyActionsPushConfigPost.__init__() + - EpManagePolicyActionsPushConfigPost.class_name + - EpManagePolicyActionsPushConfigPost.verb + """ + with does_not_raise(): + instance = EpManagePolicyActionsPushConfigPost() + assert instance.class_name == "EpManagePolicyActionsPushConfigPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_manage_policy_actions_00210(): + """ + # Summary + + Verify EpManagePolicyActionsPushConfigPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManagePolicyActionsPushConfigPost.path + """ + instance = EpManagePolicyActionsPushConfigPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policy_actions_00220(): + """ + # Summary + + Verify EpManagePolicyActionsPushConfigPost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManagePolicyActionsPushConfigPost.path + """ + with does_not_raise(): + instance = EpManagePolicyActionsPushConfigPost() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policyActions/pushConfig" + + +def test_manage_policy_actions_00230(): + """ + # Summary + + Verify EpManagePolicyActionsPushConfigPost path with clusterName only + + ## Test + + - path includes only clusterName (no ticketId per manage.json spec) + + ## Classes and Methods + + - EpManagePolicyActionsPushConfigPost.path + """ + with does_not_raise(): + instance = EpManagePolicyActionsPushConfigPost() + instance.fabric_name = "my-fabric" + instance.endpoint_params.cluster_name = "cluster1" + result = instance.path + assert result == ( + "/api/v1/manage/fabrics/my-fabric/policyActions/pushConfig?clusterName=cluster1" + ) + + +# ============================================================================= +# Test: EpManagePolicyActionsRemovePost +# ============================================================================= + + +def test_manage_policy_actions_00300(): + """ + # Summary + + Verify EpManagePolicyActionsRemovePost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManagePolicyActionsRemovePost.__init__() + - EpManagePolicyActionsRemovePost.class_name + - EpManagePolicyActionsRemovePost.verb + """ + with does_not_raise(): + instance = EpManagePolicyActionsRemovePost() + assert instance.class_name == "EpManagePolicyActionsRemovePost" + assert instance.verb == HttpVerbEnum.POST + + +def test_manage_policy_actions_00310(): + """ + # Summary + + Verify EpManagePolicyActionsRemovePost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManagePolicyActionsRemovePost.path + """ + instance = EpManagePolicyActionsRemovePost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_manage_policy_actions_00320(): + """ + # Summary + + Verify EpManagePolicyActionsRemovePost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManagePolicyActionsRemovePost.path + """ + with does_not_raise(): + instance = EpManagePolicyActionsRemovePost() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/policyActions/remove" + + +def test_manage_policy_actions_00330(): + """ + # Summary + + Verify EpManagePolicyActionsRemovePost path with query params + + ## Test + + - path includes clusterName and ticketId in query string + + ## Classes and Methods + + - EpManagePolicyActionsRemovePost.path + """ + with does_not_raise(): + instance = EpManagePolicyActionsRemovePost() + instance.fabric_name = "my-fabric" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "MyTicket1234" + result = instance.path + assert result.startswith( + "/api/v1/manage/fabrics/my-fabric/policyActions/remove?" + ) + assert "clusterName=cluster1" in result + assert "ticketId=MyTicket1234" in result From d68a2b4d4bcd4c31c943c64cd872634c5c4ed37c Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Wed, 25 Mar 2026 13:26:08 +0530 Subject: [PATCH 51/61] feat(nd_policy): pydantic input validation and translate_config optimization --- .../models/manage_policies/__init__.py | 11 + .../models/manage_policies/config_models.py | 224 ++++++++++++++++++ plugins/module_utils/nd_policy_resources.py | 169 +++++++------ plugins/modules/nd_policy.py | 17 ++ 4 files changed, 335 insertions(+), 86 deletions(-) create mode 100644 plugins/module_utils/models/manage_policies/config_models.py diff --git a/plugins/module_utils/models/manage_policies/__init__.py b/plugins/module_utils/models/manage_policies/__init__.py index bc63e129..15d8fa10 100644 --- a/plugins/module_utils/models/manage_policies/__init__.py +++ b/plugins/module_utils/models/manage_policies/__init__.py @@ -37,6 +37,13 @@ PolicyIds, ) +# --- Config (playbook input) models --- +from .config_models import ( # noqa: F401 + PlaybookPolicyConfig, + PlaybookSwitchEntry, + PlaybookSwitchPolicyConfig, +) + __all__ = [ # Enums @@ -48,4 +55,8 @@ "PolicyUpdate", # Action models "PolicyIds", + # Config (playbook input) models + "PlaybookPolicyConfig", + "PlaybookSwitchEntry", + "PlaybookSwitchPolicyConfig", ] \ No newline at end of file diff --git a/plugins/module_utils/models/manage_policies/config_models.py b/plugins/module_utils/models/manage_policies/config_models.py new file mode 100644 index 00000000..d5cd23c9 --- /dev/null +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Pydantic models for validating Ansible playbook input (user-facing config). + +These models validate user input **before** any API calls or config translation. +They enforce constraints from the OpenAPI spec (manage.json ``createBasePolicy`` +schema) at the playbook boundary so errors are caught early with clear messages. + +Schema constraints (source: manage.json createBasePolicy): + - priority: integer, min=1, max=2000, default=500 + - description: string, maxLength=255 + - templateName: string, maxLength=255 + +Usage in nd_policy.py main():: + + from .models.manage_policies.config_models import PlaybookPolicyConfig + + for idx, entry in enumerate(module.params["config"]): + PlaybookPolicyConfig.model_validate( + entry, + context={"state": state, "use_desc_as_key": use_desc_as_key}, + ) +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from typing import Any, ClassVar, Dict, List, Optional + +from pydantic import ValidationInfo, model_validator +from typing_extensions import Self + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + + +# ============================================================================ +# Per-switch policy override (switch[].policies[] entry) +# ============================================================================ + + +class PlaybookSwitchPolicyConfig(NDNestedModel): + """Validates a per-switch policy override entry. + + Corresponds to ``config[].switch[].policies[]`` in the playbook. + + OpenAPI constraints applied: + - name: maxLength=255 (templateName) + - description: maxLength=255 + - priority: 1–2000 (createBasePolicy.priority) + """ + + identifiers: ClassVar[List[str]] = [] + + name: str = Field( + ..., + min_length=1, + max_length=255, + description="Template name or policy ID (e.g., 'switch_freeform', 'POLICY-12345')", + ) + description: str = Field( + default="", + max_length=255, + description="Policy description (max 255 characters)", + ) + priority: int = Field( + default=500, + ge=1, + le=2000, + description="Policy priority (1–2000, default 500)", + ) + create_additional_policy: bool = Field( + default=True, + description="Create a new policy even if an identical one already exists", + ) + template_inputs: Optional[Dict[str, Any]] = Field( + default_factory=dict, + description="Name/value pairs passed to the policy template", + ) + + +# ============================================================================ +# Switch list entry (config[].switch[] entry) +# ============================================================================ + + +class PlaybookSwitchEntry(NDNestedModel): + """Validates a switch entry within the config. + + Corresponds to ``config[].switch[]`` in the playbook. + """ + + identifiers: ClassVar[List[str]] = [] + + serial_number: str = Field( + ..., + min_length=1, + alias="serial_number", + description="Switch serial number, management IP, or hostname", + ) + policies: Optional[List[PlaybookSwitchPolicyConfig]] = Field( + default_factory=list, + description="Per-switch policy overrides", + ) + + +# ============================================================================ +# Top-level config entry (config[] entry) +# ============================================================================ + + +class PlaybookPolicyConfig(NDNestedModel): + """Validates a top-level config entry from the Ansible playbook. + + Corresponds to ``config[]`` in the playbook. Supports two kinds of + entries: + + 1. **Policy entry** — has ``name`` (template name or policy ID) plus + optional description, priority, template_inputs. + 2. **Switch entry** — has ``switch`` list only (no ``name``). Used to + declare which switches receive the global policies. + + Context-aware validation (pass via ``model_validate(..., context={})``: + - ``state``: The module state (merged, deleted, query). + - ``use_desc_as_key``: Whether descriptions are used as unique keys. + + OpenAPI constraints applied: + - name: maxLength=255 (templateName) + - description: maxLength=255 + - priority: 1–2000 (createBasePolicy.priority) + """ + + identifiers: ClassVar[List[str]] = [] + + name: Optional[str] = Field( + default=None, + max_length=255, + description="Template name or policy ID", + ) + description: str = Field( + default="", + max_length=255, + description="Policy description (max 255 characters)", + ) + priority: int = Field( + default=500, + ge=1, + le=2000, + description="Policy priority (1–2000, default 500)", + ) + create_additional_policy: bool = Field( + default=True, + description="Create a new policy even if an identical one already exists", + ) + template_inputs: Optional[Dict[str, Any]] = Field( + default_factory=dict, + description="Name/value pairs passed to the policy template", + ) + switch: Optional[List[PlaybookSwitchEntry]] = Field( + default=None, + description="List of target switches with optional per-switch policy overrides", + ) + + @model_validator(mode="after") + def validate_state_requirements(self, info: ValidationInfo) -> Self: + """Apply state-aware validation using context. + + When ``context={"state": "merged", "use_desc_as_key": True}`` is + passed to ``model_validate()``: + + - **merged + policy entry**: ``name`` is required. + - **use_desc_as_key + merged/deleted + template name**: ``description`` + must be non-empty. + + Switch-only entries (``name`` is None, ``switch`` is present) skip + these checks since they only declare target switches. + """ + ctx = info.context or {} if info else {} + state = ctx.get("state") + use_desc_as_key = ctx.get("use_desc_as_key", False) + + # Switch-only entry — no policy fields to validate + if self.name is None and self.switch is not None: + return self + + # For merged state, name is required on policy entries + if state == "merged" and self.name is None and self.switch is None: + raise ValueError( + "'name' (template name or policy ID) is required for " + "state=merged. Provide a template name like 'switch_freeform' " + "or a policy ID like 'POLICY-12345'." + ) + + # When use_desc_as_key=true, description must not be empty for + # template-name entries (not policy IDs) in merged/deleted states. + if ( + use_desc_as_key + and state in ("merged", "deleted") + and self.name + and not self.name.startswith("POLICY-") + and not self.description + ): + raise ValueError( + f"'description' cannot be empty when use_desc_as_key=true " + f"and name is a template name ('{self.name}'). " + f"Provide a unique description for each policy " + f"or set use_desc_as_key=false." + ) + + return self + + +__all__ = [ + "PlaybookPolicyConfig", + "PlaybookSwitchEntry", + "PlaybookSwitchPolicyConfig", +] diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index a35ee538..ab1b0a56 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -206,90 +206,99 @@ def translate_config(config, use_desc_as_key): switch dicts, each with ``serial_number`` and optional ``policies``. This function: - 1. Pops the switch entry from the config. - 2. For each switch, adds its serial number to every global policy. - 3. If a switch has per-switch ``policies``, those override (by template - name) the global policies for that switch (when - ``use_desc_as_key=false``). When ``use_desc_as_key=true``, - per-switch policies are simply merged with global policies. - 4. Returns a flat list where each dict has a ``switch`` key with a + 1. Separates global policy entries from the switch entry (non-destructive). + 2. Collects per-switch overrides keyed by ``(template_name, switch_sn)``. + 3. For each (global_policy, switch) pair, emits either the override + (when ``use_desc_as_key=false`` and a same-name override exists) + or the global. When ``use_desc_as_key=true``, both are emitted. + 4. Appends per-switch-only policies (overrides whose template name + doesn't appear in any global). + 5. Returns a flat list where each dict has a ``switch`` key with a single serial number string. + The input ``config`` list is **not** mutated. + Args: - config: The raw config list from the playbook (will be mutated). + config: The raw config list from the playbook. use_desc_as_key: Whether descriptions are used as unique keys. Returns: Flat list of policy dicts, each with a ``switch`` (serial number) key. """ - if config is None: + if not config: return [] - # Find the switch entry (the dict that has a "switch" key containing a list) - pos = next( - (index for index, d in enumerate(config) if "switch" in d and isinstance(d["switch"], list)), - None, - ) + # ── Step 1: Separate globals from the switch entry ────────────── + global_policies = [] + switch_entry = None + for entry in config: + if isinstance(entry.get("switch"), list): + switch_entry = entry + else: + global_policies.append(entry) - if pos is None: + # No switch entry → nothing to target + if switch_entry is None: return config - sw_dict = config.pop(pos) - global_policies = config + switches = switch_entry["switch"] + if not switches: + return [] - override_config = [] - for sw in sw_dict["switch"]: + # ── Step 2: Extract switch serial numbers and per-switch overrides ── + # + # overrides_by_switch: {sn: [policy_dict, ...]} + # override_names: {sn: {template_name, ...}} (for fast lookup) + switch_serials = [] + overrides_by_switch = {} + override_names = {} + + for sw in switches: sn = sw.get("serial_number") or sw.get("ip", "") + switch_serials.append(sn) if sw.get("policies"): - for pol in sw["policies"]: - entry = copy.deepcopy(pol) - entry["switch"] = sn - override_config.append(entry) - - for cfg in global_policies: - if "switch" not in cfg or not isinstance(cfg["switch"], list): - if "switch" not in cfg or cfg["switch"] is None: - cfg["switch"] = [] - elif isinstance(cfg["switch"], str): - cfg["switch"] = [cfg["switch"]] - if sn not in cfg["switch"]: - cfg["switch"].append(sn) - - # Switch-only: no global policies AND no per-switch overrides → generate - # a bare entry per switch for query/deleted. - if not global_policies and not override_config: - return [{"switch": sw.get("serial_number") or sw.get("ip", "")} for sw in sw_dict["switch"]] - - # Flatten: when use_desc_as_key is false, per-switch policies override - # global policies with the same template name for that switch. - if global_policies and not use_desc_as_key: - updated_config = [] - for ovr_cfg in override_config: - for cfg in global_policies: - if cfg.get("name") == ovr_cfg.get("name"): - ovr_sw = ovr_cfg["switch"] - if isinstance(cfg.get("switch"), list) and ovr_sw in cfg["switch"]: - cfg["switch"].remove(ovr_sw) - if ovr_cfg not in updated_config: - updated_config.append(ovr_cfg) - for cfg in global_policies: - if isinstance(cfg.get("switch"), list) and cfg["switch"]: - updated_config.append(cfg) - flat_config = updated_config - else: - flat_config = list(global_policies) + override_config + overrides_by_switch[sn] = sw["policies"] + override_names[sn] = {p.get("name") for p in sw["policies"]} + else: + overrides_by_switch[sn] = [] + override_names[sn] = set() + + # ── Step 3: No globals and no overrides → bare switch entries ─── + if not global_policies and not any(overrides_by_switch.values()): + return [{"switch": sn} for sn in switch_serials] - # Expand multi-switch global policies into one entry per switch + # ── Step 4: Build the flat result in one pass ─────────────────── result = [] - for cfg in flat_config: - if isinstance(cfg.get("switch"), list): - for sw in cfg["switch"]: - entry = copy.deepcopy(cfg) - entry["switch"] = sw - result.append(entry) - else: - result.append(cfg) + global_names = {g.get("name") for g in global_policies} + + for sn in switch_serials: + sn_override_names = override_names.get(sn, set()) + sn_overrides = overrides_by_switch.get(sn, []) + + # 4a: Emit global policies for this switch. + # When use_desc_as_key=false, skip globals whose template + # name is overridden for this switch. + for g in global_policies: + gname = g.get("name") + if not use_desc_as_key and gname in sn_override_names: + # Overridden for this switch — skip the global + continue + entry = copy.deepcopy(g) + entry["switch"] = sn + result.append(entry) + + # 4b: Emit per-switch overrides for this switch. + # When use_desc_as_key=false, only overrides whose name + # matches a global were "replacements" (handled above by + # skipping the global). Overrides with names NOT in + # globals are "extras" — always emitted. + # When use_desc_as_key=true, all overrides are emitted + # (globals were already emitted above, both coexist). + for ovr in sn_overrides: + entry = copy.deepcopy(ovr) + entry["switch"] = sn + result.append(entry) return result @@ -416,22 +425,20 @@ def validate_translated_config(self, translated_config): """Validate the translated (flat) config before handing it to manage_state. Checks performed: - - ``name`` is required for each entry when state=merged. - Every entry must have a ``switch`` serial number. + Note: + Field-level validation (name required, priority range, description + length, etc.) is handled by ``PlaybookPolicyConfig`` Pydantic + models before translation. This method only checks post- + translation invariants. + Args: translated_config: Flat config list from ``translate_config()``. Raises: Calls ``module.fail_json`` on validation failure. """ - if self.state == "merged": - for idx, entry in enumerate(translated_config): - if not entry.get("name"): - self.module.fail_json( - msg=f"config[{idx}].name is required when state=merged." - ) - for idx, entry in enumerate(translated_config): if not entry.get("switch"): self.module.fail_json( @@ -505,22 +512,12 @@ def _validate_config(self) -> None: if name and self._is_policy_id(name): continue if not name: - # Switch-only: valid for query/deleted (no description needed) continue - # Check 1: description must not be empty when use_desc_as_key=true - # and a template name is given (merged or deleted state). - if self.state in ("merged", "deleted") and not description: - self.module.fail_json( - msg=( - f"config[{idx}]: description cannot be empty when " - f"use_desc_as_key=true and name is a template name " - f"('{name}'). Provide a unique description for each " - f"policy or set use_desc_as_key=false." - ) - ) + # Note: description-empty check for use_desc_as_key=true is now + # handled by PlaybookPolicyConfig Pydantic validation at input time. - # Check 2: description + switch must be unique within the playbook. + # Cross-entry uniqueness: description + switch must be unique. if description: key = f"{description}|{switch}" desc_switch_counts[key] = desc_switch_counts.get(key, 0) + 1 diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index c6516948..ba9fde48 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -513,6 +513,8 @@ nd_argument_spec, ) from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import PlaybookPolicyConfig # ============================================================================= @@ -601,6 +603,21 @@ def main(): logger=log, ) + # Pydantic input validation — fail fast on bad user input + use_desc_as_key = module.params.get("use_desc_as_key", False) + validation_context = {"state": state, "use_desc_as_key": use_desc_as_key} + for idx, entry in enumerate(module.params["config"]): + try: + PlaybookPolicyConfig.model_validate(entry, context=validation_context) + except ValidationError as ve: + module.fail_json( + msg=f"Input validation failed for config[{idx}]: {ve}" + ) + except ValueError as ve: + module.fail_json( + msg=f"Input validation failed for config[{idx}]: {ve}" + ) + # Resolve switch IPs/hostnames → serial numbers translated_input = policy_module.resolve_switch_identifiers( copy.deepcopy(module.params["config"]), From 477f9d198146dabd16fd2d851eba6143a697f465 Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Wed, 25 Mar 2026 15:00:23 +0530 Subject: [PATCH 52/61] feat(nd_policy): simplify arg_spec, bulk APIs, consistent docstrings --- .../models/manage_policies/config_models.py | 47 +- plugins/module_utils/nd_policy_resources.py | 685 ++++++++++++------ plugins/modules/nd_policy.py | 85 +-- 3 files changed, 509 insertions(+), 308 deletions(-) diff --git a/plugins/module_utils/models/manage_policies/config_models.py b/plugins/module_utils/models/manage_policies/config_models.py index d5cd23c9..b1dc1f68 100644 --- a/plugins/module_utils/models/manage_policies/config_models.py +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -95,6 +95,7 @@ class PlaybookSwitchEntry(NDNestedModel): """Validates a switch entry within the config. Corresponds to ``config[].switch[]`` in the playbook. + Accepts ``serial_number`` or the backward-compatible alias ``ip``. """ identifiers: ClassVar[List[str]] = [] @@ -102,7 +103,6 @@ class PlaybookSwitchEntry(NDNestedModel): serial_number: str = Field( ..., min_length=1, - alias="serial_number", description="Switch serial number, management IP, or hostname", ) policies: Optional[List[PlaybookSwitchPolicyConfig]] = Field( @@ -110,6 +110,25 @@ class PlaybookSwitchEntry(NDNestedModel): description="Per-switch policy overrides", ) + @model_validator(mode="before") + @classmethod + def accept_ip_alias(cls, values: Any) -> Any: + """Accept ``ip`` as a backward-compatible alias for ``serial_number``. + + If the user provides ``ip`` but not ``serial_number``, copy the value + so validation succeeds on the canonical field name. + + Args: + values: Raw input dict before field validation. + + Returns: + The (possibly mutated) input dict with ``serial_number`` set. + """ + if isinstance(values, dict): + if "serial_number" not in values and "ip" in values: + values["serial_number"] = values["ip"] + return values + # ============================================================================ # Top-level config entry (config[] entry) @@ -181,6 +200,15 @@ def validate_state_requirements(self, info: ValidationInfo) -> Self: Switch-only entries (``name`` is None, ``switch`` is present) skip these checks since they only declare target switches. + + Args: + info: Pydantic ``ValidationInfo`` carrying the context dict. + + Returns: + The validated model instance (``self``). + + Raises: + ValueError: If required fields are missing for the given state. """ ctx = info.context or {} if info else {} state = ctx.get("state") @@ -216,6 +244,23 @@ def validate_state_requirements(self, info: ValidationInfo) -> Self: return self + @classmethod + def get_argument_spec(cls) -> Dict[str, Any]: + """Return the Ansible argument spec for nd_policy. + + Returns: + Dict suitable for passing to ``AnsibleModule(argument_spec=...)``. + """ + return dict( + fabric_name=dict(type="str", required=True, aliases=["fabric"]), + config=dict(type="list", elements="dict", required=True), + use_desc_as_key=dict(type="bool", default=False), + deploy=dict(type="bool", default=True), + ticket_id=dict(type="str"), + cluster_name=dict(type="str"), + state=dict(type="str", default="merged", choices=["merged", "deleted", "query"]), + ) + __all__ = [ "PlaybookPolicyConfig", diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index ab1b0a56..433a8583 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -68,6 +68,8 @@ NDModuleError, ) from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import PlaybookPolicyConfig # ============================================================================= @@ -76,7 +78,14 @@ def _looks_like_ip(value): - """Return True if *value* looks like a dotted-quad IPv4 address.""" + """Return True if *value* looks like a dotted-quad IPv4 address. + + Args: + value: String to check. + + Returns: + True if the value matches a dotted-quad IPv4 pattern, False otherwise. + """ parts = value.split(".") if len(parts) == 4: return all(p.isdigit() and 0 <= int(p) <= 255 for p in parts) @@ -89,6 +98,13 @@ def _needs_resolution(value): Serial numbers are alphanumeric strings (e.g. ``FDO25031SY4``). IPs look like dotted quads. Hostnames contain dots or look like FQDNs. If the value is already a serial number we can skip the fabric API call. + + Args: + value: Switch identifier string to inspect. + + Returns: + True if the value looks like an IP or hostname, False if it + appears to be a serial number already. """ if not value: return False @@ -135,6 +151,9 @@ def __init__( nd: NDModule instance (wraps the Ansible module and REST client). results: Results aggregation instance for task output. logger: Optional logger; defaults to ``nd.NDPolicyModule``. + + Returns: + None. """ self.log = logger or logging.getLogger("nd.NDPolicyModule") self.nd = nd @@ -151,6 +170,11 @@ def __init__( self.cluster_name = self.module.params.get("cluster_name") self.check_mode = self.module.check_mode + if not self.config: + self.module.fail_json( + msg=f"'config' element is mandatory for state '{self.state}'." + ) + # Template parameter cache: {templateName: [param_dict, ...]} # Populated lazily by _fetch_template_params() to avoid # redundant API calls when multiple config entries share the @@ -172,6 +196,9 @@ def exit_json(self) -> None: Merges the ``Results`` aggregation and the before/after/proposed snapshot lists, then delegates to ``exit_json`` or ``fail_json``. + + Returns: + None. """ self.results.build_final_result() final = self.results.final_result @@ -404,6 +431,9 @@ def _query_fabric_switches(self): Uses RestSend save_settings/restore_settings to temporarily force check_mode=False so that this read-only GET always hits the controller, even when the module is running in Ansible check mode. + + Returns: + List of switch record dicts from the fabric inventory API. """ path = f"{BasePath.nd_manage_fabrics(self.fabric_name, 'switches')}?max=10000" @@ -436,6 +466,9 @@ def validate_translated_config(self, translated_config): Args: translated_config: Flat config list from ``translate_config()``. + Returns: + None. + Raises: Calls ``module.fail_json`` on validation failure. """ @@ -449,23 +482,86 @@ def validate_translated_config(self, translated_config): # Public API - State Management # ========================================================================= + def validate_and_prepare_config(self) -> None: + """Validate, normalize, resolve, and flatten the playbook config. + + Full pipeline executed before state dispatch: + 1. **Pydantic validation** — each ``config[]`` entry is validated + against ``PlaybookPolicyConfig``. Also applies defaults + (priority=500, description="", etc.) since the nested arg_spec + options were removed in favour of Pydantic. + 2. **Resolve switch identifiers** — IPs/hostnames → serial numbers + via a fabric inventory API call. + 3. **Translate config** — flatten the two-level (globals + switch + entry) structure into one dict per (policy, switch). + 4. **Validate translated config** — ensure every entry has a switch. + + After this method, ``self.config`` and ``module.params["config"]`` + contain the flat, validated, ready-to-process list. + + Returns: + None. + """ + self.log.info("Validating and preparing config") + + # Step 1: Pydantic validation + normalization + validation_context = {"state": self.state, "use_desc_as_key": self.use_desc_as_key} + normalized_config = [] + for idx, entry in enumerate(self.config): + try: + validated = PlaybookPolicyConfig.model_validate(entry, context=validation_context) + normalized_config.append(validated.model_dump(by_alias=False, exclude_none=False)) + except ValidationError as ve: + self.module.fail_json( + msg=f"Input validation failed for config[{idx}]: {ve}" + ) + except ValueError as ve: + self.module.fail_json( + msg=f"Input validation failed for config[{idx}]: {ve}" + ) + self.config = normalized_config + self.module.params["config"] = normalized_config + + # Step 2: Resolve switch IPs/hostnames → serial numbers + resolved_config = self.resolve_switch_identifiers( + copy.deepcopy(self.config), + ) + + # Step 3: Flatten multi-switch config into one entry per (policy, switch) + translated_config = self.translate_config( + resolved_config, + self.use_desc_as_key, + ) + + # Step 4: Validate translated config + self.validate_translated_config(translated_config) + + # Update config references + self.config = translated_config + self.module.params["config"] = translated_config + def manage_state(self) -> None: """Main entry point for state management. - Reads ``self.state`` and delegates to the appropriate handler: + Validates, normalizes, and prepares the config, then dispatches + to the appropriate handler: - **merged** - create / update / skip policies - **query** - read-only lookup - **deleted** - deploy=true: markDelete → pushConfig → remove - deploy=false: markDelete only - Before dispatching to the state handler, ``_validate_config()`` is - called to perform upfront validation. When ``use_desc_as_key=true`` - the entire task is treated as an atomic unit — any validation + The entire task is treated as an atomic unit — any validation failure aborts the run before any changes are made. + + Returns: + None. """ self.log.info(f"Managing state: {self.state}") - # Upfront validation — hard-fail before any API mutations + # Full config pipeline: pydantic → resolve → translate → validate + self.validate_and_prepare_config() + + # Upfront cross-entry validation — hard-fail before any API mutations self._validate_config() if self.state == "merged": @@ -482,18 +578,23 @@ def manage_state(self) -> None: # ========================================================================= def _validate_config(self) -> None: - """Validate the playbook config before any API calls are made. - - When ``use_desc_as_key=true``: - 1. Every config entry for ``merged`` / ``deleted`` states **must** - have a non-empty ``description`` (unless ``name`` is a policy ID - or ``name`` is omitted for switch-only operations). - 2. The ``description + switch`` combination must be unique across - all config entries within the playbook. Duplicate pairs would - lead to ambiguous matching at the controller and are rejected. - - These checks ensure the entire task fails atomically before - making any changes, rather than partially executing. + """Validate cross-entry invariants before any API calls are made. + + When ``use_desc_as_key=true``, the ``description + switch`` + combination must be unique across all config entries within + the playbook. Duplicate pairs would lead to ambiguous matching + at the controller and are rejected. + + Note: + Per-entry checks (name required, description non-empty, + priority range, max-length, etc.) are handled by + ``PlaybookPolicyConfig`` Pydantic validation in + ``validate_and_prepare_config()``. This method only + validates cross-entry constraints that Pydantic cannot + enforce because it sees one entry at a time. + + Returns: + None. """ if not self.use_desc_as_key: return @@ -514,9 +615,6 @@ def _validate_config(self) -> None: if not name: continue - # Note: description-empty check for use_desc_as_key=true is now - # handled by PlaybookPolicyConfig Pydantic validation at input time. - # Cross-entry uniqueness: description + switch must be unique. if description: key = f"{description}|{switch}" @@ -545,7 +643,11 @@ def _validate_config(self) -> None: # ========================================================================= def _handle_merged_state(self) -> None: - """Handle state=merged: create, update, or skip policies.""" + """Handle state=merged: create, update, or skip policies. + + Returns: + None. + """ self.log.debug("ENTER: _handle_merged_state()") self.log.info("Handling merged state") self.log.debug(f"Config entries: {len(self.config)}") @@ -639,7 +741,11 @@ def _handle_merged_state(self) -> None: self.log.debug("EXIT: _handle_merged_state()") def _handle_query_state(self) -> None: - """Handle state=query: read-only lookup of policies.""" + """Handle state=query: read-only lookup of policies. + + Returns: + None. + """ self.log.debug("ENTER: _handle_query_state()") self.log.info("Handling query state") self.log.debug(f"Config entries: {len(self.config)}") @@ -677,7 +783,11 @@ def _handle_query_state(self) -> None: self.log.debug("EXIT: _handle_query_state()") def _handle_deleted_state(self) -> None: - """Handle state=deleted: remove policies from NDFC.""" + """Handle state=deleted: remove policies from NDFC. + + Returns: + None. + """ self.log.debug("ENTER: _handle_deleted_state()") self.log.info("Handling deleted state") self.log.debug(f"Config entries: {len(self.config)}") @@ -720,7 +830,14 @@ def _handle_deleted_state(self) -> None: @staticmethod def _is_policy_id(name: str) -> bool: - """Return True if name looks like a policy ID (starts with POLICY-).""" + """Return True if name looks like a policy ID (starts with POLICY-). + + Args: + name: Policy name or ID string to check. + + Returns: + True if the name starts with ``POLICY-``, False otherwise. + """ return name.upper().startswith("POLICY-") @staticmethod @@ -731,6 +848,13 @@ def _build_lucene_filter(**kwargs: Any) -> str: _build_lucene_filter(switchId="FDO123", templateName="feature_enable") # Returns: "switchId:FDO123 AND templateName:feature_enable" + + Args: + **kwargs: Key-value pairs to include in the Lucene filter. + None values are skipped. + + Returns: + Lucene filter string with terms joined by ``AND``. """ parts = [] for key, value in kwargs.items(): @@ -753,6 +877,10 @@ def _policies_differ(want: Dict, have: Dict) -> Dict: - entityType, entityName, createTimestamp, updateTimestamp - generatedConfig, markDeleted + Args: + want: Desired policy state dict. + have: Existing policy dict from the controller. + Returns: Dict with changed fields, or empty dict if identical. """ @@ -1243,6 +1371,9 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: - Case C: use_desc_as_key=true, templateName given → switchId + description - Case D: Switch-only (no templateName or policyId) → all policies on switch + Args: + want: Want dict produced by ``_build_want``. + Returns: Tuple of (have_list, error_msg). """ @@ -1304,6 +1435,9 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: f"Case C: Lookup by switchId={want['switchId']} + " f"description='{want_desc}'" ) + # For merged/deleted states, Pydantic already enforces description + # non-empty. This guard is the backstop for query state, where + # Pydantic intentionally skips the check. if not want_desc: self.log.warning("Case C: description is required but not provided") return [], "description is required when use_desc_as_key=true and name is a template name" @@ -1352,6 +1486,10 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: def _get_diff_merged_single(self, want: Dict, have_list: List[Dict]) -> Dict: """Compute the diff and determine the action for a single config entry. + Args: + want: Desired policy state dict. + have_list: Matching policies from the controller. + Returns: Dict with keys: action, want, have, diff, policy_id, error_msg. """ @@ -1516,7 +1654,17 @@ def _get_diff_merged_single(self, want: Dict, have_list: List[Dict]) -> Dict: # ========================================================================= def _execute_merged(self, diff_results: List[Dict]) -> List[str]: - """Execute the computed actions for all config entries. + """Execute the computed actions for all config entries using bulk APIs. + + Instead of making one API call per entry, this method collects all + create/update/delete_and_create entries into batches and executes + them with minimal API calls: + + 1. Register skip/fail results immediately (no API call). + 2. Collect ``delete_and_create`` removals → single bulk remove. + 3. Collect all creates (``create`` + ``delete_and_create``) → + single bulk POST via ``_api_bulk_create_policies``. + 4. Execute updates individually (PUT has no bulk API). Args: diff_results: List of diff result dicts from _get_diff_merged_single. @@ -1528,20 +1676,24 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self.log.debug(f"Processing {len(diff_results)} diff entries") policy_ids_to_deploy = [] + # Batches for bulk execution + # Each item is (diff_entry_index, diff_entry) to preserve ordering + create_batch: List[Dict] = [] + update_batch: List[Dict] = [] + delete_and_create_batch: List[Dict] = [] + + # ── Phase 1: Classify entries, register skip/fail immediately ─── for diff_entry in diff_results: action = diff_entry["action"] want = diff_entry["want"] have = diff_entry["have"] - policy_id = diff_entry["policy_id"] - field_diff = diff_entry["diff"] error_msg = diff_entry["error_msg"] self.log.info( - f"Executing action={action} for " + f"Classifying action={action} for " f"{want.get('templateName', want.get('policyId', 'unknown'))}" ) - # --- FAIL --- if action == "fail": self._proposed.append(want) self._register_result( @@ -1555,12 +1707,11 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: ) continue - # --- SKIP --- if action == "skip": self._proposed.append(want) if have: self._before.append(have) - self._after.append(have) # unchanged + self._after.append(have) diff_payload = {"action": action, "want": want} if error_msg: diff_payload["warning"] = error_msg @@ -1576,183 +1727,223 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: ) continue - # --- CREATE --- if action == "create": - self._proposed.append(want) - if self.check_mode: - self._after.append(want) # would-be state - self._register_result( - action="policy_create", - operation_type=OperationType.CREATE, - return_code=200, - message="OK (check_mode)", - success=True, - found=False, - diff={"action": action, "want": want, "diff": field_diff}, - ) - continue + create_batch.append(diff_entry) + continue - created_id = None - try: - created_id = self._api_create_policy(want) - except NDModuleError as create_err: - self.log.error(f"Create failed: {create_err.msg}") - self._register_result( - action="policy_create", - operation_type=OperationType.CREATE, - return_code=create_err.status or -1, - message=create_err.msg, - data=create_err.response_payload or {}, - success=False, - found=False, - diff={"action": "fail", "want": want, "error": create_err.msg}, - ) - continue + if action == "update": + update_batch.append(diff_entry) + continue - if created_id: - policy_ids_to_deploy.append(created_id) + if action == "delete_and_create": + delete_and_create_batch.append(diff_entry) + continue - self._after.append({**want, "policyId": created_id}) + self.log.info( + f"Batch summary: create={len(create_batch)}, " + f"update={len(update_batch)}, " + f"delete_and_create={len(delete_and_create_batch)}" + ) + # ── Phase 2: Check mode — register all as would-be changes ────── + if self.check_mode: + for diff_entry in create_batch: + want = diff_entry["want"] + self._proposed.append(want) + self._after.append(want) self._register_result( action="policy_create", operation_type=OperationType.CREATE, return_code=200, - message="OK", + message="OK (check_mode)", success=True, found=False, - diff={ - "action": action, - "before": None, - "after": {**want, "policyId": created_id}, - "want": want, - "diff": field_diff, - "created_policy_id": created_id, - }, + diff={"action": "create", "want": want, "diff": diff_entry["diff"]}, ) - continue - # --- UPDATE --- - if action == "update": + for diff_entry in update_batch: + want, have = diff_entry["want"], diff_entry["have"] self._proposed.append(want) self._before.append(have) - if self.check_mode: - self._after.append({**have, **want}) - self._register_result( - action="policy_update", - operation_type=OperationType.UPDATE, - return_code=200, - message="OK (check_mode)", - success=True, - found=True, - diff={ - "action": action, - "before": have, - "after": {**have, **want}, - "want": want, - "have": have, - "diff": field_diff, - "policy_id": policy_id, - }, - ) - continue - - self._api_update_policy(want, have, policy_id) - policy_ids_to_deploy.append(policy_id) - - after_merged = {**have, **want, "policyId": policy_id} - self._after.append(after_merged) - + self._after.append({**have, **want}) self._register_result( action="policy_update", operation_type=OperationType.UPDATE, return_code=200, - message="OK", + message="OK (check_mode)", success=True, found=True, diff={ - "action": action, - "before": have, - "after": after_merged, - "want": want, - "have": have, - "diff": field_diff, - "policy_id": policy_id, + "action": "update", "before": have, + "after": {**have, **want}, "want": want, + "have": have, "diff": diff_entry["diff"], + "policy_id": diff_entry["policy_id"], }, ) - continue - # --- DELETE_AND_CREATE --- - if action == "delete_and_create": + for diff_entry in delete_and_create_batch: + want, have = diff_entry["want"], diff_entry["have"] self._proposed.append(want) self._before.append(have) - if self.check_mode: - self._after.append(want) # would-be replacement + self._after.append(want) + self._register_result( + action="policy_replace", + operation_type=OperationType.UPDATE, + return_code=200, + message="OK (check_mode)", + success=True, + found=True, + diff={ + "action": "delete_and_create", "before": have, + "after": want, "want": want, "have": have, + "diff": diff_entry["diff"], + "delete_policy_id": diff_entry["policy_id"], + }, + ) + + self.log.info("Check mode: all batches registered") + self.log.debug("EXIT: _execute_merged()") + return policy_ids_to_deploy + + # ── Phase 3: Execute delete_and_create removals (bulk) ────────── + if delete_and_create_batch: + remove_ids = [ + d["policy_id"] for d in delete_and_create_batch if d["policy_id"] + ] + if remove_ids: + self.log.info( + f"Bulk removing {len(remove_ids)} old policies " + f"for delete_and_create: {remove_ids}" + ) + self._api_remove_policies(remove_ids) + + # ── Phase 4: Bulk create (create + delete_and_create) ─────────── + all_create_entries = create_batch + delete_and_create_batch + if all_create_entries: + want_list = [d["want"] for d in all_create_entries] + self.log.info(f"Bulk creating {len(want_list)} policies") + + try: + created_ids = self._api_bulk_create_policies(want_list) + except NDModuleError as bulk_err: + self.log.error(f"Bulk create failed entirely: {bulk_err.msg}") + for diff_entry in all_create_entries: + want = diff_entry["want"] + action_label = "policy_replace" if diff_entry["action"] == "delete_and_create" else "policy_create" + self._proposed.append(want) + if diff_entry.get("have"): + self._before.append(diff_entry["have"]) + self._register_result( + action=action_label, + operation_type=OperationType.CREATE, + return_code=bulk_err.status or -1, + message=bulk_err.msg, + data=bulk_err.response_payload or {}, + success=False, + found=False, + diff={"action": "fail", "want": want, "error": bulk_err.msg}, + ) + all_create_entries = [] # Skip per-entry registration below + created_ids = [] + + # Register per-entry results from bulk response + for idx, diff_entry in enumerate(all_create_entries): + want = diff_entry["want"] + have = diff_entry.get("have") + field_diff = diff_entry["diff"] + is_replace = diff_entry["action"] == "delete_and_create" + + created_id = created_ids[idx] if idx < len(created_ids) else None + per_policy_error = None + + # created_id is None when the per-policy response had status=failed + if created_id is None: + per_policy_error = f"Policy creation failed for {want.get('templateName')} on {want.get('switchId')}" + + self._proposed.append(want) + if have: + self._before.append(have) + + if per_policy_error: + action_label = "policy_replace" if is_replace else "policy_create" + self._register_result( + action=action_label, + operation_type=OperationType.CREATE, + return_code=207, + message=per_policy_error, + success=False, + found=False, + diff={"action": "fail", "want": want, "error": per_policy_error}, + ) + continue + + policy_ids_to_deploy.append(created_id) + self._after.append({**want, "policyId": created_id}) + + if is_replace: self._register_result( action="policy_replace", operation_type=OperationType.UPDATE, return_code=200, - message="OK (check_mode)", + message="OK", success=True, found=True, diff={ - "action": action, + "action": "delete_and_create", "before": have, - "after": want, - "want": want, - "have": have, - "diff": field_diff, - "delete_policy_id": policy_id, + "after": {**want, "policyId": created_id}, + "want": want, "have": have, "diff": field_diff, + "deleted_policy_id": diff_entry["policy_id"], + "created_policy_id": created_id, }, ) - continue - - # Step 1: Delete the old policy - self._api_remove_policies([policy_id]) - - # Step 2: Create the new policy - created_id = None - try: - created_id = self._api_create_policy(want) - except NDModuleError as create_err: - self.log.error(f"Re-create after delete failed: {create_err.msg}") + else: self._register_result( - action="policy_replace", - operation_type=OperationType.UPDATE, - return_code=create_err.status or -1, - message=f"Deleted policy {policy_id} but re-create failed: {create_err.msg}", - data=create_err.response_payload or {}, - success=False, + action="policy_create", + operation_type=OperationType.CREATE, + return_code=200, + message="OK", + success=True, found=False, - diff={"action": "fail", "want": want, "have": have, - "deleted_policy_id": policy_id, "error": create_err.msg}, + diff={ + "action": "create", + "before": None, + "after": {**want, "policyId": created_id}, + "want": want, "diff": field_diff, + "created_policy_id": created_id, + }, ) - continue - if created_id: - policy_ids_to_deploy.append(created_id) + # ── Phase 5: Execute updates (PUT has no bulk API) ────────────── + for diff_entry in update_batch: + want = diff_entry["want"] + have = diff_entry["have"] + policy_id = diff_entry["policy_id"] + field_diff = diff_entry["diff"] - self._after.append({**want, "policyId": created_id}) + self._proposed.append(want) + self._before.append(have) - self._register_result( - action="policy_replace", - operation_type=OperationType.UPDATE, - return_code=200, - message="OK", - success=True, - found=True, - diff={ - "action": action, - "before": have, - "after": {**want, "policyId": created_id}, - "want": want, - "have": have, - "diff": field_diff, - "deleted_policy_id": policy_id, - "created_policy_id": created_id, - }, - ) - continue + self._api_update_policy(want, have, policy_id) + policy_ids_to_deploy.append(policy_id) + + after_merged = {**have, **want, "policyId": policy_id} + self._after.append(after_merged) + + self._register_result( + action="policy_update", + operation_type=OperationType.UPDATE, + return_code=200, + message="OK", + success=True, + found=True, + diff={ + "action": "update", + "before": have, "after": after_merged, + "want": want, "have": have, "diff": field_diff, + "policy_id": policy_id, + }, + ) self.log.info(f"Merged execute complete: {len(policy_ids_to_deploy)} policies to deploy") self.log.debug("EXIT: _execute_merged()") @@ -1765,6 +1956,10 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: def _get_diff_query_single(self, want: Dict, have_list: List[Dict]) -> Dict: """Compute the query result for a single config entry. + Args: + want: Desired query filter dict. + have_list: Matching policies from the controller. + Returns: Dict with keys: action, want, policies, match_count, warning, error_msg. """ @@ -1796,15 +1991,9 @@ def _get_diff_query_single(self, want: Dict, have_list: List[Dict]) -> Dict: # Q-10 to Q-13: Template name given, use_desc_as_key=true if self.use_desc_as_key: + # Note: description-empty is already caught by _build_have Case C + # which returns error_msg before this method runs. want_desc = want.get("description", "") - if not want_desc: - # Q-10: description required but not given - result["action"] = "fail" - result["error_msg"] = ( - "description is required when use_desc_as_key=true " - "and name is a template name." - ) - return result if match_count == 0: result["action"] = "not_found" @@ -1838,6 +2027,12 @@ def _execute_query(self, diff_results: List[Dict]) -> None: """Register results for all query config entries. Query state never makes changes — ``changed`` is always ``false``. + + Args: + diff_results: List of diff result dicts from ``_get_diff_query_single``. + + Returns: + None. """ self.log.debug("ENTER: _execute_query()") self.log.debug(f"Processing {len(diff_results)} query entries") @@ -1918,6 +2113,10 @@ def _execute_query(self, diff_results: List[Dict]) -> None: def _get_diff_deleted_single(self, want: Dict, have_list: List[Dict]) -> Dict: """Compute the delete result for a single config entry. + Args: + want: Desired delete filter dict. + have_list: Matching policies from the controller. + Returns: Dict with keys: action, want, policies, policy_ids, match_count, warning, error_msg. @@ -1963,15 +2162,9 @@ def _get_diff_deleted_single(self, want: Dict, have_list: List[Dict]) -> Dict: # D-9 to D-12: Template name given, use_desc_as_key=true if self.use_desc_as_key: + # Note: description-empty is already caught by Pydantic + # (state=deleted) and _build_have Case C upstream. want_desc = want.get("description", "") - if not want_desc: - # D-9: description required but not given - result["action"] = "fail" - result["error_msg"] = ( - "description is required when use_desc_as_key=true " - "and name is a template name." - ) - return result if match_count == 0: result["action"] = "skip" @@ -2013,6 +2206,12 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: - deploy=true: markDelete → pushConfig → remove (3-step) - deploy=false: markDelete only (1-step) - PYTHON-type: direct DELETE (1-step, regardless of deploy) + + Args: + diff_results: List of diff result dicts from ``_get_diff_deleted_single``. + + Returns: + None. """ self.log.debug("ENTER: _execute_deleted()") self.log.debug(f"Processing {len(diff_results)} delete entries") @@ -2442,34 +2641,46 @@ def _deploy_policies( # API Helpers (low-level CRUD) # ========================================================================= - def _api_create_policy(self, want: Dict) -> Optional[str]: - """Create a single policy via POST and return the created policy ID. + def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Optional[str]]: + """Create multiple policies via a single bulk POST. + + Builds one ``PolicyCreateBulk`` containing all entries and sends + a single POST request. The controller returns a per-policy + response in the same order as the request. Args: - want: The want dict with all policy fields. + want_list: List of want dicts, each with all policy fields. Returns: - Created policy ID string, or None if creation failed. + List of created policy ID strings (same length as want_list). + Entries that failed are ``None``. Raises: - NDModuleError: If the API returns a 207 Multi-Status with a - per-policy failure (e.g., switch not in fabric). + NDModuleError: If the entire API call fails (e.g., network error). + Per-policy failures within a 207 response are returned as + ``None`` in the list and do NOT raise. """ - self.log.info( - f"Creating policy: template={want.get('templateName')}, " - f"switch={want.get('switchId')}" - ) - policy_model = PolicyCreate( - switch_id=want["switchId"], - template_name=want["templateName"], - entity_type="switch", - entity_name="SWITCH", - description=want.get("description", ""), - priority=want.get("priority", 500), - source=want.get("source", ""), - template_inputs=want.get("templateInputs"), - ) - bulk = PolicyCreateBulk(policies=[policy_model]) + if not want_list: + return [] + + self.log.info(f"Bulk creating {len(want_list)} policies") + + policy_models = [] + for want in want_list: + policy_models.append( + PolicyCreate( + switch_id=want["switchId"], + template_name=want["templateName"], + entity_type="switch", + entity_name="SWITCH", + description=want.get("description", ""), + priority=want.get("priority", 500), + source=want.get("source", ""), + template_inputs=want.get("templateInputs"), + ) + ) + + bulk = PolicyCreateBulk(policies=policy_models) payload = bulk.to_request_dict() ep = EpManagePoliciesPost() @@ -2481,31 +2692,36 @@ def _api_create_policy(self, want: Dict) -> Optional[str]: data = self.nd.request(ep.path, ep.verb, payload) - # Parse response to get policy ID + # Parse per-policy results from the 207 response. + # The controller returns policies in the same order as sent. created_policies = data.get("policies", []) if isinstance(data, dict) else [] - if created_policies: - first = created_policies[0] - # Check for per-policy failure in 207 Multi-Status responses. - # The controller returns status="failed" for each policy that - # could not be created (e.g., switch not in fabric). - if first.get("status") == "failed": - error_msg = first.get("message", "Policy creation failed") - self.log.error( - f"Policy creation failed for " - f"template={want.get('templateName')}, " - f"switch={want.get('switchId')}: {error_msg}" - ) - raise NDModuleError( - msg=f"Policy creation failed: {error_msg}", - status=207, - request_payload=payload, - response_payload=data, - ) - created_id = first.get("policyId") - self.log.info(f"Created policy: {created_id}") - return created_id - self.log.warning("Create API returned no policy ID") - return None + created_ids: List[Optional[str]] = [] + + for idx, want in enumerate(want_list): + if idx < len(created_policies): + entry = created_policies[idx] + if entry.get("status") == "failed": + error_msg = entry.get("message", "Policy creation failed") + self.log.error( + f"Bulk create: policy {idx} failed — " + f"template={want.get('templateName')}, " + f"switch={want.get('switchId')}: {error_msg}" + ) + created_ids.append(None) + else: + pid = entry.get("policyId") + self.log.info(f"Bulk create: policy {idx} created — {pid}") + created_ids.append(pid) + else: + self.log.warning(f"Bulk create: no response entry for policy {idx}") + created_ids.append(None) + + self.log.info( + f"Bulk create complete: " + f"{sum(1 for x in created_ids if x)} succeeded, " + f"{sum(1 for x in created_ids if x is None)} failed" + ) + return created_ids def _api_update_policy(self, want: Dict, have: Dict, policy_id: str) -> None: """Update an existing policy via PUT. @@ -2519,6 +2735,9 @@ def _api_update_policy(self, want: Dict, have: Dict, policy_id: str) -> None: want: The want dict with desired policy fields. have: The existing policy dict from the controller. policy_id: The policy ID to update. + + Returns: + None. """ self.log.info(f"Updating policy: {policy_id}") merged_inputs = dict(have.get("templateInputs") or {}) @@ -2553,6 +2772,9 @@ def _api_mark_delete(self, policy_ids: List[str]) -> None: Args: policy_ids: List of policy IDs to mark-delete. + + Returns: + None. """ self.log.info(f"Marking {len(policy_ids)} policies for deletion: {policy_ids}") body = PolicyIds(policy_ids=policy_ids) @@ -2571,6 +2793,9 @@ def _api_remove_policies(self, policy_ids: List[str]) -> None: Args: policy_ids: List of policy IDs to remove from NDFC. + + Returns: + None. """ self.log.info(f"Removing {len(policy_ids)} policies: {policy_ids}") body = PolicyIds(policy_ids=policy_ids) @@ -2593,6 +2818,9 @@ def _api_delete_policy(self, policy_id: str) -> None: Args: policy_id: Policy ID to delete (e.g., "POLICY-12345"). + + Returns: + None. """ self.log.info(f"Deleting individual policy: {policy_id}") @@ -2637,6 +2865,9 @@ def _register_result( diff: Diff payload dict. data: Optional response data. state: Override state (defaults to self.state). + + Returns: + None. """ self.results.action = action self.results.state = state or self.state diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index ba9fde48..4f1ae4a3 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -501,7 +501,6 @@ elements: dict """ -import copy import logging from ansible.module_utils.basic import AnsibleModule @@ -513,7 +512,6 @@ nd_argument_spec, ) from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import PlaybookPolicyConfig @@ -523,41 +521,8 @@ def main(): """Main entry point for the nd_policy module.""" - # Per-switch policy suboptions (used inside switch[].policies) - switch_policy_spec = dict( - name=dict(type="str", required=True), - description=dict(type="str", default=""), - priority=dict(type="int", default=500), - create_additional_policy=dict(type="bool", default=True), - template_inputs=dict(type="dict", default={}), - ) - - # Switch list suboptions - switch_spec = dict( - serial_number=dict(type="str", required=True, aliases=["ip"]), - policies=dict(type="list", elements="dict", default=[], options=switch_policy_spec), - ) - - # Top-level config entry suboptions - config_spec = dict( - name=dict(type="str"), - description=dict(type="str", default=""), - priority=dict(type="int", default=500), - create_additional_policy=dict(type="bool", default=True), - template_inputs=dict(type="dict", default={}), - switch=dict(type="list", elements="dict", options=switch_spec), - ) - argument_spec = nd_argument_spec() - argument_spec.update( - fabric_name=dict(type="str", required=True, aliases=["fabric"]), - config=dict(type="list", elements="dict", required=True, options=config_spec), - use_desc_as_key=dict(type="bool", default=False), - deploy=dict(type="bool", default=True), - ticket_id=dict(type="str"), - cluster_name=dict(type="str"), - state=dict(type="str", default="merged", choices=["merged", "deleted", "query"]), - ) + argument_spec.update(PlaybookPolicyConfig.get_argument_spec()) module = AnsibleModule( argument_spec=argument_spec, @@ -572,15 +537,6 @@ def main(): except ValueError as error: module.fail_json(msg=str(error)) - # Get parameters - state = module.params.get("state") - output_level = module.params.get("output_level") - - if not module.params.get("config"): - module.fail_json( - msg=f"'config' element is mandatory for state '{state}'." - ) - # Initialize NDModule (REST client) try: nd = NDModule(module) @@ -588,6 +544,8 @@ def main(): module.fail_json(msg=f"Failed to initialize NDModule: {str(error)}") # Initialize Results + state = module.params.get("state") + output_level = module.params.get("output_level") results = Results() results.state = state results.check_mode = module.check_mode @@ -603,41 +561,8 @@ def main(): logger=log, ) - # Pydantic input validation — fail fast on bad user input - use_desc_as_key = module.params.get("use_desc_as_key", False) - validation_context = {"state": state, "use_desc_as_key": use_desc_as_key} - for idx, entry in enumerate(module.params["config"]): - try: - PlaybookPolicyConfig.model_validate(entry, context=validation_context) - except ValidationError as ve: - module.fail_json( - msg=f"Input validation failed for config[{idx}]: {ve}" - ) - except ValueError as ve: - module.fail_json( - msg=f"Input validation failed for config[{idx}]: {ve}" - ) - - # Resolve switch IPs/hostnames → serial numbers - translated_input = policy_module.resolve_switch_identifiers( - copy.deepcopy(module.params["config"]), - ) - - # Flatten multi-switch config into one entry per (policy, switch) - translated_config = NDPolicyModule.translate_config( - translated_input, - module.params.get("use_desc_as_key"), - ) - - # Validate translated config - policy_module.validate_translated_config(translated_config) - - # Override module.params["config"] with the flat config - module.params["config"] = translated_config - policy_module.config = translated_config - - # Manage state for merged, query, deleted - log.info(f"Managing state: {state}") + # manage_state handles the full pipeline: + # pydantic validation → resolve switches → translate → validate → dispatch policy_module.manage_state() # Exit with results From 87d740a36b390f833890adf5598449dc296bb984 Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Thu, 26 Mar 2026 19:32:46 +0530 Subject: [PATCH 53/61] Harden 207 response handling, fix delete_and_create flow, surface NDFC error messages --- .../v1/manage/manage_switch_actions.py | 195 +++++ .../models/manage_policies/policy_actions.py | 51 ++ plugins/module_utils/nd_policy_resources.py | 819 +++++++++++++++--- 3 files changed, 941 insertions(+), 124 deletions(-) create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py diff --git a/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py new file mode 100644 index 00000000..dd686d59 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Cisco Systems +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ND Manage Switch Actions endpoint models. + +This module contains endpoint definitions for switch-level action +operations in the ND Manage API. + +Endpoints covered: +- POST /fabrics/{fabricName}/switchActions/deploy - Deploy config to switches +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +__author__ = "L Nikhil Sri Krishna" + +from typing import Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) + + +# ============================================================================ +# Query parameter classes +# ============================================================================ + + +class SwitchDeployEndpointParams(EndpointQueryParams): + """ + # Summary + + Query parameters for the switchActions/deploy endpoint. + + ## Description + + Per manage.json, ``POST /fabrics/{fabricName}/switchActions/deploy`` + accepts ``forceShowRun`` and ``clusterName`` as optional query params. + + ## Parameters + + - force_show_run → forceShowRun (boolean) + - cluster_name → clusterName (string) + """ + + model_config = ConfigDict(extra="forbid") + + force_show_run: Optional[bool] = Field( + default=None, + description=( + "If true, Config compliance fetches the latest running config " + "from the device. If false, uses the cached version." + ), + ) + cluster_name: Optional[str] = Field( + default=None, + min_length=1, + description="Target cluster name for multi-cluster deployments", + ) + + +# ============================================================================ +# Base class for /fabrics/{fabricName}/switchActions/{action} +# ============================================================================ + + +class _EpManageSwitchActionsBase(FabricNameMixin, NDEndpointBaseModel): + """ + Base class for Switch Actions endpoints. + + Provides the shared path prefix: + ``/api/v1/manage/fabrics/{fabricName}/switchActions`` + """ + + model_config = ConfigDict(extra="forbid") + + @property + def _base_path(self) -> str: + if not self.fabric_name: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.path("fabrics", self.fabric_name, "switchActions") + + +# ============================================================================ +# POST /fabrics/{fabricName}/switchActions/deploy +# ============================================================================ + + +class EpManageSwitchActionsDeployPost(_EpManageSwitchActionsBase): + """ + # Summary + + Switch Config Deploy Endpoint + + ## Description + + Deploy the fabric configuration to specific switches. + + Unlike ``/actions/configDeploy`` (which deploys to ALL switches in + a fabric), this endpoint targets only the switches whose serial + numbers are provided in the request body. + + ## Path + + ``/api/v1/manage/fabrics/{fabricName}/switchActions/deploy`` + + ## Verb + + POST + + ## Query Parameters + + - ``forceShowRun`` (bool, optional) — Fetch latest running config first + - ``clusterName`` (str, optional) — Target cluster in multi-cluster setups + + ## Request Body + + ```json + { + "switchIds": ["FOC21373AFA", "FVT93126SKE"] + } + ``` + + ## Response + + 207 Multi-Status on success: + ```json + { + "status": "Configuration deployment completed for [FOC21373AFA]." + } + ``` + + ## Usage + + ```python + ep = EpManageSwitchActionsDeployPost() + ep.fabric_name = "MyFabric" + path = ep.path # /api/v1/manage/.../switchActions/deploy + verb = ep.verb # POST + # body: {"switchIds": ["serial1", "serial2"]} + ``` + """ + + class_name: Literal["EpManageSwitchActionsDeployPost"] = Field( + default="EpManageSwitchActionsDeployPost", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: SwitchDeployEndpointParams = Field( + default_factory=SwitchDeployEndpointParams, + 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 = f"{self._base_path}/deploy" + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base}?{query_string}" + return base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/models/manage_policies/policy_actions.py b/plugins/module_utils/models/manage_policies/policy_actions.py index 9374475a..75ea8ac1 100644 --- a/plugins/module_utils/models/manage_policies/policy_actions.py +++ b/plugins/module_utils/models/manage_policies/policy_actions.py @@ -36,6 +36,57 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +class SwitchIds(NDNestedModel): + """ + Request body model for switch-level deploy action. + + ## Description + + Used for ``POST /fabrics/{fabricName}/switchActions/deploy``. + Contains a list of switch serial numbers to deploy config to. + + ## Request Body Schema (from manage.json) + + ```json + { + "switchIds": ["FOC21373AFA", "FVT93126SKE"] + } + ``` + + ## Usage + + ```python + body = SwitchIds(switch_ids=["FOC21373AFA", "FVT93126SKE"]) + payload = body.to_request_dict() + # {"switchIds": ["FOC21373AFA", "FVT93126SKE"]} + ``` + """ + + identifiers: ClassVar[List[str]] = [] + + switch_ids: List[str] = Field( + default_factory=list, + min_length=1, + alias="switchIds", + description="List of switch serial numbers to deploy config to", + ) + + @field_validator("switch_ids") + @classmethod + def validate_switch_ids(cls, v: List[str]) -> List[str]: + """Validate that all switch IDs are non-empty strings.""" + if not v: + raise ValueError("switch_ids must contain at least one switch ID") + for sid in v: + if not isinstance(sid, str) or not sid.strip(): + raise ValueError(f"Invalid switch ID: {sid!r}. Must be a non-empty string.") + return v + + def to_request_dict(self) -> Dict[str, Any]: + """Convert to API request dictionary with camelCase keys.""" + return self.to_payload() + + class PolicyIds(NDNestedModel): """ Request body model for policy bulk actions. diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index 433a8583..e7e26ca6 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -53,6 +53,9 @@ EpManagePolicyActionsPushConfigPost, EpManagePolicyActionsRemovePost, ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_switch_actions import ( + EpManageSwitchActionsDeployPost, +) from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_base import ( PolicyCreate, ) @@ -62,6 +65,7 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_actions import ( PolicyIds, + SwitchIds, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( NDModule, @@ -1805,31 +1809,256 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self.log.debug("EXIT: _execute_merged()") return policy_ids_to_deploy - # ── Phase 3: Execute delete_and_create removals (bulk) ────────── + # ── Phase 3: Execute delete_and_create removals ───────────────── + # + # We must fully remove old policies BEFORE creating replacements. + # This follows the same delete logic as _execute_deleted: + # + # 1. markDelete → try for all old policies + # 2. PYTHON-type fallback → direct DELETE /policies/{policyId} + # 3. deploy=true → pushConfig (markDeleted) or switchActions/deploy + # (direct-deleted) to push config removal to the switch + # 4. remove → hard-delete markDeleted policy records + # + # If the old policy's config isn't removed from the switch first, + # the old template's config lines will remain on the device even + # after the new template is deployed (different templates produce + # different config — the new one won't negate the old one). + # + # If any removal fails, we must NOT create a replacement for that + # entry — otherwise we'd create a duplicate. + remove_failed_ids: set = set() if delete_and_create_batch: remove_ids = [ d["policy_id"] for d in delete_and_create_batch if d["policy_id"] ] if remove_ids: self.log.info( - f"Bulk removing {len(remove_ids)} old policies " + f"Phase 3: Removing {len(remove_ids)} old policies " f"for delete_and_create: {remove_ids}" ) - self._api_remove_policies(remove_ids) - # ── Phase 4: Bulk create (create + delete_and_create) ─────────── - all_create_entries = create_batch + delete_and_create_batch - if all_create_entries: - want_list = [d["want"] for d in all_create_entries] - self.log.info(f"Bulk creating {len(want_list)} policies") + # Build policy→switch map for switchActions/deploy + dac_switch_map: Dict[str, str] = {} + for d in delete_and_create_batch: + pid = d.get("policy_id", "") + have = d.get("have") or {} + sw = have.get("switchId", d.get("want", {}).get("switchId", "")) + if pid and sw: + dac_switch_map[pid] = sw + + # Step 3a: Attempt markDelete for all old policies + self.log.info( + f"Phase 3a: markDelete for {len(remove_ids)} old policies" + ) + mark_delete_data = self._api_mark_delete(remove_ids) + + mark_succeeded = [] + mark_failed_python = [] + mark_failed_other = [] + + if isinstance(mark_delete_data, dict): + policies_response = mark_delete_data.get("policies", []) + failed_ids_set: set = set() + for p in policies_response: + pid = p.get("policyId", "") + status = str(p.get("status", "")).lower() + if status != "success": + failed_ids_set.add(pid) + msg = p.get("message", "") + if "content type PYTHON" in msg: + mark_failed_python.append(pid) + self.log.info( + f"markDelete failed for {pid} " + "(PYTHON content type) — will use " + "direct DELETE" + ) + else: + mark_failed_other.append(pid) + self.log.error( + f"markDelete failed for {pid} " + f"(status={p.get('status')!r}): {msg}" + ) + + mark_succeeded = [ + pid for pid in remove_ids if pid not in failed_ids_set + ] + + if not policies_response and remove_ids: + self.log.warning( + "markDelete returned empty 'policies' list — " + "treating all as succeeded (ambiguous response)" + ) + mark_succeeded = list(remove_ids) + else: + self.log.warning( + "markDelete returned non-dict response — " + "treating all as succeeded" + ) + mark_succeeded = list(remove_ids) + + self.log.info( + f"Phase 3a results: {len(mark_succeeded)} markDeleted, " + f"{len(mark_failed_python)} PYTHON-type, " + f"{len(mark_failed_other)} other failures" + ) + + # Track truly failed (non-PYTHON) as remove failures + remove_failed_ids.update(mark_failed_other) + + # Step 3b: Direct DELETE for PYTHON-type policies + if mark_failed_python: + self.log.info( + f"Phase 3b: Direct DELETE for " + f"{len(mark_failed_python)} PYTHON-type policies" + ) + direct_deleted = [] + for pid in mark_failed_python: + try: + self._api_delete_policy(pid) + direct_deleted.append(pid) + except Exception: # noqa: BLE001 + self.log.error( + f"Direct DELETE also failed for {pid}" + ) + remove_failed_ids.add(pid) + + # Deploy to affected switches to push config removal + if direct_deleted and self.deploy: + affected_switches = list({ + dac_switch_map[pid] + for pid in direct_deleted + if pid in dac_switch_map + }) + if affected_switches: + self.log.info( + f"Phase 3b: switchActions/deploy for " + f"{len(affected_switches)} switch(es)" + ) + self._api_deploy_switches(affected_switches) + + # Step 3c: pushConfig for markDeleted policies (deploy=true) + if mark_succeeded and self.deploy: + self.log.info( + f"Phase 3c: pushConfig for " + f"{len(mark_succeeded)} markDeleted policies" + ) + deploy_success = self._deploy_policies( + mark_succeeded, state="merged" + ) + if not deploy_success: + self.log.error( + "pushConfig failed during delete_and_create — " + "old policy config may not be removed from switch" + ) + + # Step 3d: remove markDeleted policy records + if mark_succeeded: + self.log.info( + f"Phase 3d: remove {len(mark_succeeded)} " + f"markDeleted policy records" + ) + remove_data = self._api_remove_policies(mark_succeeded) + rm_ok, rm_fail = self._inspect_207_policies(remove_data) + + if not rm_ok and not rm_fail and mark_succeeded: + self.log.warning( + "remove returned no per-policy results — " + "treating as success (ambiguous response)" + ) + + if rm_fail: + for p in rm_fail: + pid = p.get("policyId", "") + if pid: + remove_failed_ids.add(pid) + fail_msgs = [ + f"{p.get('policyId', '?')}: " + f"{p.get('message', 'unknown')}" + for p in rm_fail + ] + self.log.error( + f"remove failed for {len(rm_fail)} policy(ies): " + + "; ".join(fail_msgs) + ) + + # ── Phase 4: Bulk create ──────────────────────────────────────── + # + # We issue SEPARATE bulk create calls for pure creates vs + # delete_and_create replacements. This is important because: + # + # - Pure creates are safe to fail: no data loss, user re-runs. + # - DAC replacements have already deleted the old policy in + # Phase 3. If the create fails, the policy is ORPHANED + # (old one gone, new one not created). Keeping them in a + # separate call prevents a pure-create failure from causing + # a bulk 4xx/5xx that takes down DAC entries with it. + # + # Within each batch, per-policy 207 failures are handled + # individually — a single policy failure does not affect others + # in the same batch. + # + # NOTE: The orphan risk for DAC entries is inherent — NDFC has + # no atomic "replace policy" API. dcnm_policy has the same + # limitation. Re-running the playbook will re-create the + # policy (it will be seen as "not found" → create). + + # Filter out DAC entries whose old policy failed to be removed + eligible_dac = [] + for d in delete_and_create_batch: + if d["policy_id"] in remove_failed_ids: + want = d["want"] + self._proposed.append(want) + if d.get("have"): + self._before.append(d["have"]) + self._register_result( + action="policy_replace", + operation_type=OperationType.UPDATE, + return_code=207, + message=( + f"Cannot replace policy: removal of old policy " + f"{d['policy_id']} failed. Skipping create to " + f"avoid duplicates." + ), + success=False, + found=True, + diff={ + "action": "replace_failed", + "want": want, + "have": d.get("have"), + "error": f"Old policy {d['policy_id']} removal failed", + "failed_policy_id": d["policy_id"], + }, + ) + else: + eligible_dac.append(d) + + for batch_label, batch_entries in [ + ("create", create_batch), + ("replace", eligible_dac), + ]: + if not batch_entries: + continue + + want_list = [d["want"] for d in batch_entries] + self.log.info( + f"Bulk creating {len(want_list)} policies " + f"(batch={batch_label})" + ) try: created_ids = self._api_bulk_create_policies(want_list) except NDModuleError as bulk_err: - self.log.error(f"Bulk create failed entirely: {bulk_err.msg}") - for diff_entry in all_create_entries: + self.log.error( + f"Bulk {batch_label} failed entirely: {bulk_err.msg}" + ) + for diff_entry in batch_entries: want = diff_entry["want"] - action_label = "policy_replace" if diff_entry["action"] == "delete_and_create" else "policy_create" + action_label = ( + "policy_replace" + if diff_entry["action"] == "delete_and_create" + else "policy_create" + ) self._proposed.append(want) if diff_entry.get("have"): self._before.append(diff_entry["have"]) @@ -1841,31 +2070,45 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: data=bulk_err.response_payload or {}, success=False, found=False, - diff={"action": "fail", "want": want, "error": bulk_err.msg}, + diff={ + "action": "fail", + "want": want, + "error": bulk_err.msg, + }, ) - all_create_entries = [] # Skip per-entry registration below - created_ids = [] + continue # Skip per-entry registration for this batch # Register per-entry results from bulk response - for idx, diff_entry in enumerate(all_create_entries): + for idx, diff_entry in enumerate(batch_entries): want = diff_entry["want"] have = diff_entry.get("have") field_diff = diff_entry["diff"] is_replace = diff_entry["action"] == "delete_and_create" - created_id = created_ids[idx] if idx < len(created_ids) else None + entry_result = ( + created_ids[idx] if idx < len(created_ids) + else {"policy_id": None, "ndfc_error": "No response entry from NDFC"} + ) + created_id = entry_result["policy_id"] + ndfc_error = entry_result["ndfc_error"] per_policy_error = None - # created_id is None when the per-policy response had status=failed + # created_id is None when per-policy response had status!=success if created_id is None: - per_policy_error = f"Policy creation failed for {want.get('templateName')} on {want.get('switchId')}" + per_policy_error = ( + f"Policy creation failed for " + f"{want.get('templateName')} on " + f"{want.get('switchId')}: {ndfc_error}" + ) self._proposed.append(want) if have: self._before.append(have) if per_policy_error: - action_label = "policy_replace" if is_replace else "policy_create" + action_label = ( + "policy_replace" if is_replace else "policy_create" + ) self._register_result( action=action_label, operation_type=OperationType.CREATE, @@ -1873,7 +2116,11 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: message=per_policy_error, success=False, found=False, - diff={"action": "fail", "want": want, "error": per_policy_error}, + diff={ + "action": "fail", + "want": want, + "error": per_policy_error, + }, ) continue @@ -1924,7 +2171,29 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self._proposed.append(want) self._before.append(have) - self._api_update_policy(want, have, policy_id) + try: + self._api_update_policy(want, have, policy_id) + except NDModuleError as update_err: + self.log.error( + f"Update failed for {policy_id}: {update_err.msg}" + ) + self._register_result( + action="policy_update", + operation_type=OperationType.UPDATE, + return_code=update_err.status or -1, + message=update_err.msg, + data=update_err.response_payload or {}, + success=False, + found=True, + diff={ + "action": "update_failed", + "want": want, "have": have, "diff": field_diff, + "policy_id": policy_id, + "error": update_err.msg, + }, + ) + continue + policy_ids_to_deploy.append(policy_id) after_merged = {**have, **want, "policyId": policy_id} @@ -2222,6 +2491,9 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Map policy ID → templateName so Phase B can route switch_freeform # policies through a direct DELETE instead of markDelete. policy_template_map: Dict[str, str] = {} + # Map policy ID → switchId so we know which switches to deploy after + # direct DELETE of PYTHON-type policies. + policy_switch_map: Dict[str, str] = {} for diff_entry in diff_results: action = diff_entry["action"] @@ -2269,7 +2541,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: message="Policy not found — already absent", success=True, found=False, - diff={"action": action, "want": want}, + diff={"action": action, "want": want, "before": None, "after": None}, ) continue @@ -2282,12 +2554,15 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: self._before.extend(policies) # what existed before deletion all_policy_ids_to_delete.extend(policy_ids) - # Track templateName per policy for direct-delete routing + # Track templateName and switchId per policy for p in policies: pid = p.get("policyId", "") tname = p.get("templateName", "") + sw = p.get("switchId", "") if pid: policy_template_map[pid] = tname + if sw: + policy_switch_map[pid] = sw # Collect switch IDs for result tracking for p in policies: @@ -2300,6 +2575,8 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: diff_payload = { "action": action, "want": want, + "before": policies, + "after": None, "policy_ids": policy_ids, "match_count": match_count, } @@ -2321,9 +2598,10 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: diff_payload = { "action": action, "want": want, + "before": policies, + "after": None, "policy_ids": policy_ids, "match_count": match_count, - "policies": policies, } if warning: diff_payload["warning"] = warning @@ -2356,80 +2634,154 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: ) # --------------------------------------------------------------------- - # Split policies into two buckets: + # Delete strategy: markDelete-first with automatic fallback # - # 1. PYTHON content-type templates (e.g. switch_freeform): - # markDelete FAILS for these ("Policies with content type PYTHON - # or without generated config can't be mark deleted"). - # Use direct DELETE /policies/{policyId} instead. - # NDFC may leave a ghost switch_freeform_config (markDeleted, - # negative priority) which gets cleaned up on next - # deploy/recalculate — no explicit cleanup needed. - # This matches the dcnm_policy approach (line 1149). + # Rather than trying to predict which templates are PYTHON content-type + # upfront, we send ALL policies through markDelete and inspect the + # 207 Multi-Status response for per-policy failures. Any policy that + # fails with "content type PYTHON" is automatically retried via + # direct DELETE /policies/{policyId}. # - # 2. Everything else (TEMPLATE_CLI, e.g. feature_enable): - # Normal markDelete → pushConfig → remove flow. + # This is more robust than maintaining a hardcoded set of template + # names, since the content type is an NDFC-internal property that + # varies across templates and NDFC versions. # --------------------------------------------------------------------- - DIRECT_DELETE_TEMPLATES = {"switch_freeform"} - direct_delete_ids = [ - pid for pid in unique_policy_ids - if policy_template_map.get(pid, "") in DIRECT_DELETE_TEMPLATES - ] - normal_delete_ids = [ - pid for pid in unique_policy_ids - if pid not in set(direct_delete_ids) - ] + # Step 1: Attempt markDelete for all policies + self.log.info( + f"{'Step 1/3' if self.deploy else 'Step 1/1'}: " + f"markDelete for {len(unique_policy_ids)} policies" + ) + mark_delete_data = self._api_mark_delete(unique_policy_ids) + + # Inspect 207 response for per-policy results + mark_succeeded = [] + mark_failed = [] + mark_failed_python = [] # Failed specifically due to PYTHON content type + + if isinstance(mark_delete_data, dict): + policies_response = mark_delete_data.get("policies", []) + # Build a set of policy IDs that explicitly failed. + # Any status that is NOT "success" (case-insensitive) is + # treated as failure (defensive against future values + # and potential case variations like "SUCCESS"). + failed_ids = set() + for p in policies_response: + pid = p.get("policyId", "") + status = str(p.get("status", "")).lower() + if status != "success": + failed_ids.add(pid) + msg = p.get("message", "") + if "content type PYTHON" in msg: + mark_failed_python.append(pid) + self.log.info( + f"markDelete failed for {pid} (PYTHON content type) " + "— will retry via direct DELETE" + ) + else: + mark_failed.append(pid) + self.log.error( + f"markDelete failed for {pid} " + f"(status={p.get('status')!r}): {msg}" + ) + + # Policies not in the failed set are considered successful + mark_succeeded = [pid for pid in unique_policy_ids if pid not in failed_ids] + + # Warn if NDFC returned empty policies list (ambiguous) + if not policies_response and unique_policy_ids: + self.log.warning( + "markDelete returned empty 'policies' list for " + f"{len(unique_policy_ids)} policy IDs — " + "treating all as succeeded (ambiguous response)" + ) + mark_succeeded = list(unique_policy_ids) + else: + # No structured response — assume all succeeded (pre-existing behavior) + self.log.warning( + "markDelete returned non-dict response — " + "treating all as succeeded" + ) + mark_succeeded = list(unique_policy_ids) self.log.info( - f"Delete routing: {len(direct_delete_ids)} direct-DELETE " - f"(PYTHON-type), {len(normal_delete_ids)} markDelete flow" + f"markDelete results: {len(mark_succeeded)} succeeded, " + f"{len(mark_failed_python)} failed (PYTHON-type, will retry), " + f"{len(mark_failed)} failed (other errors)" ) - # ----- Direct DELETE for PYTHON-type policies ----- - if direct_delete_ids: + # Register markDelete result + if mark_succeeded: + self._register_result( + action="policy_mark_delete", + state="deleted", + operation_type=OperationType.DELETE, + return_code=200, + message=f"Marked {len(mark_succeeded)} policies for deletion", + success=True, + found=True, + diff={ + "action": "mark_delete", + "policy_ids": mark_succeeded, + }, + ) + + if mark_failed: + self._register_result( + action="policy_mark_delete", + state="deleted", + operation_type=OperationType.DELETE, + return_code=207, + message=( + f"markDelete failed for {len(mark_failed)} policy(ies): " + f"{mark_failed}" + ), + success=False, + found=True, + diff={ + "action": "mark_delete_failed", + "policy_ids": mark_failed, + }, + ) + + # Step 1b: Fallback — direct DELETE for PYTHON-type policies + if mark_failed_python: self.log.info( - f"Direct-deleting {len(direct_delete_ids)} PYTHON-type " - f"policies: {direct_delete_ids}" + f"Falling back to direct DELETE for {len(mark_failed_python)} " + f"PYTHON-type policies: {mark_failed_python}" ) deleted_direct = [] failed_direct = [] - for pid in direct_delete_ids: + for pid in mark_failed_python: try: self._api_delete_policy(pid) deleted_direct.append(pid) except Exception: # noqa: BLE001 - self.log.error(f"Direct DELETE failed for {pid}") + self.log.error(f"Direct DELETE also failed for {pid}") failed_direct.append(pid) if deleted_direct: - msg = ( - f"Directly deleted {len(deleted_direct)} " - f"switch_freeform policy(ies). " - "Note: NDFC may leave a residual " - "switch_freeform_config policy in markDeleted " - "state which is cleaned up automatically on " - "the next deploy or recalculate cycle." - ) - if self.deploy: - msg += ( - " deploy=true was requested but does not " - "apply to switch_freeform — these policies " - "are removed via direct DELETE without " - "pushConfig." - ) + tpl_names = list({ + policy_template_map.get(pid, "unknown") + for pid in deleted_direct + }) self._register_result( action="policy_direct_delete", state="deleted", operation_type=OperationType.DELETE, return_code=200, - message=msg, + message=( + f"Directly deleted {len(deleted_direct)} PYTHON-type " + f"policy(ies) ({', '.join(tpl_names)}). " + "These templates use content type PYTHON and cannot " + "be markDeleted — direct DELETE is used instead." + ), success=True, found=True, diff={ "action": "direct_delete", "policy_ids": deleted_direct, - "template": "switch_freeform", + "templates": tpl_names, }, ) if failed_direct: @@ -2450,36 +2802,96 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: }, ) - # ----- Normal markDelete → pushConfig → remove for the rest ----- + # Deploy to affected switches so NDFC pushes the config removal + # to the devices. Direct DELETE removes the policy record but + # the device still has the running config until we deploy. + if deleted_direct and self.deploy: + affected_switches = list({ + policy_switch_map[pid] + for pid in deleted_direct + if pid in policy_switch_map + }) + if affected_switches: + self.log.info( + f"Deploying config to {len(affected_switches)} switch(es) " + f"after direct DELETE: {affected_switches}" + ) + deploy_data = self._api_deploy_switches(affected_switches) + + # Inspect switchActions/deploy response. + # + # The /fabrics/{fabricName}/switchActions/deploy endpoint + # has a DIFFERENT response shape from policy actions. + # Per the OpenAPI spec it returns a single object: + # {"status": "Configuration deployment completed for [...]"} + # + # In practice NDFC may return an empty body {} with 207. + # This endpoint does NOT use the per-item + # policyBaseGeneralResponse schema, so we cannot use + # _inspect_207_policies() here. + # + # We inspect the top-level "status" string: + # - Present and contains "completed" → success + # - Present but other text → log warning, treat as success + # - Missing (empty body {}) → ambiguous, log warning, + # treat as success (NDFC often returns {} on success) + deploy_ok = True + if isinstance(deploy_data, dict) and deploy_data: + status_str = deploy_data.get("status", "") + if status_str: + self.log.info( + f"switchActions/deploy status: {status_str}" + ) + else: + self.log.warning( + "switchActions/deploy returned non-empty body " + f"but no 'status' field: {deploy_data}" + ) + else: + self.log.warning( + "switchActions/deploy returned empty body — " + "treating as success (NDFC commonly returns {} " + "for this endpoint)" + ) + + self._register_result( + action="policy_switch_deploy", + state="deleted", + operation_type=OperationType.DELETE, + return_code=207, + message=( + f"Deployed config to {len(affected_switches)} " + f"switch(es) to push removal of directly-deleted " + f"PYTHON-type policies" + ), + success=deploy_ok, + found=True, + diff={ + "action": "switch_deploy", + "switch_ids": affected_switches, + "policy_ids": deleted_direct, + "deploy_success": deploy_ok, + }, + ) + + # If nothing succeeded at all, bail out + if not mark_succeeded and not mark_failed_python: + self.log.info("No policies were successfully deleted — done") + self.log.debug("EXIT: _execute_deleted()") + return + + # Only mark_succeeded policies continue through the + # pushConfig → remove flow below. If none succeeded via + # markDelete, there's nothing left for pushConfig/remove. + normal_delete_ids = mark_succeeded if not normal_delete_ids: self.log.info( - "No TEMPLATE_CLI policies to process through " - "markDelete flow — done" + "No policies were successfully markDeleted — " + "skipping pushConfig/remove" ) self.log.debug("EXIT: _execute_deleted()") return - # Step 1: markDelete - self.log.info( - f"{'Step 1/3' if self.deploy else 'Step 1/1'}: " - f"markDelete for {len(normal_delete_ids)} policies" - ) - self._api_mark_delete(normal_delete_ids) - - self._register_result( - action="policy_mark_delete", - state="deleted", - operation_type=OperationType.DELETE, - return_code=200, - message=f"Marked {len(normal_delete_ids)} policies for deletion", - success=True, - found=True, - diff={ - "action": "mark_delete", - "policy_ids": normal_delete_ids, - }, - ) - # Step 2 (deploy=true only): pushConfig deploy_success = True if self.deploy: @@ -2530,19 +2942,52 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: self.log.info( f"Step 3/3: remove {len(normal_delete_ids)} policies" ) - self._api_remove_policies(normal_delete_ids) + remove_data = self._api_remove_policies(normal_delete_ids) + + # Inspect 207 response for per-policy failures + rm_ok, rm_fail = self._inspect_207_policies(remove_data) + remove_success = len(rm_fail) == 0 + + # Warn if NDFC returned no per-policy detail at all + if not rm_ok and not rm_fail and normal_delete_ids: + self.log.warning( + f"remove returned no per-policy results for " + f"{len(normal_delete_ids)} policy IDs — treating as success " + "(ambiguous response)" + ) + + if rm_fail: + fail_msgs = [ + f"{p.get('policyId', '?')}: {p.get('message', 'unknown')}" + for p in rm_fail + ] + self.log.error( + f"remove failed for {len(rm_fail)} policy(ies): " + + "; ".join(fail_msgs) + ) self._register_result( action="policy_remove", state="deleted", operation_type=OperationType.DELETE, - return_code=200, - message=f"Removed {len(normal_delete_ids)} policies", - success=True, + return_code=200 if remove_success else 207, + message=( + f"Removed {len(normal_delete_ids)} policies" + if remove_success + else ( + f"Remove partially failed: " + f"{len(rm_ok)} succeeded, {len(rm_fail)} failed" + ) + ), + success=remove_success, found=True, diff={ "action": "remove", "policy_ids": normal_delete_ids, + "remove_success": remove_success, + "failed_policies": [ + p.get("policyId") for p in rm_fail + ], }, ) @@ -2607,11 +3052,15 @@ def _deploy_policies( data = self.nd.request(ep.path, ep.verb, push_body.to_request_dict()) # Inspect 207 body for per-policy failures - failed_policies = [] - if isinstance(data, dict): - for p in data.get("policies", []): - if p.get("status") == "failed": - failed_policies.append(p) + succeeded_policies, failed_policies = self._inspect_207_policies(data) + + # Warn if NDFC returned no per-policy detail at all + if not succeeded_policies and not failed_policies and policy_ids: + self.log.warning( + f"pushConfig returned no per-policy results for " + f"{len(policy_ids)} policy IDs — treating as success " + "(ambiguous response)" + ) deploy_success = len(failed_policies) == 0 @@ -2637,11 +3086,77 @@ def _deploy_policies( self.results.register_api_call() return deploy_success + # ========================================================================= + # 207 Multi-Status Response Inspection + # ========================================================================= + + @staticmethod + def _inspect_207_policies( + data: Any, + key: str = "policies", + ) -> Tuple[List[Dict], List[Dict]]: + """Inspect a 207 Multi-Status response for per-item success/failure. + + NDFC returns HTTP 207 for most bulk policy actions (create, + markDelete, pushConfig, remove). The response body contains + a list of per-item results under a top-level key (``policies``), + each with a required ``status`` field (``"success"`` or + ``"failed"``) and an optional ``message`` field. + + The per-item schema is ``policyBaseGeneralResponse``:: + + { + "status": "success" | "failed", # REQUIRED + "message": "...", # optional + "policyId": "POLICY-...", # optional + "entityName": "SWITCH", # optional + "entityType": "switch", # optional + "templateName": "...", # optional + "switchId": "FDO..." # optional + } + + An item is considered **failed** when ``status`` is anything + other than ``"success"`` (defensive against future values + like ``"error"``, ``"warning"``, or ``"partial"``). + + Comparison is **case-insensitive**: ``"success"``, + ``"SUCCESS"``, and ``"Success"`` are all treated as success. + + If the response body is empty (``{}``) or does not contain + the expected key, both returned lists will be empty. The + caller should treat this as an ambiguous result (NDFC did + not report per-item status) and decide accordingly. + + Args: + data: Response DATA dict from NDFC (or None/non-dict). + key: Top-level key holding the items list. + ``"policies"`` for policy action endpoints. + + Returns: + Tuple of (succeeded, failed) lists of per-item dicts. + """ + if not isinstance(data, dict): + return [], [] + items = data.get(key, []) + if not isinstance(items, list): + return [], [] + succeeded = [] + failed = [] + for item in items: + status = str(item.get("status", "")).lower() + if status == "success": + succeeded.append(item) + else: + # Any non-"success" status is treated as failure. + # Known values: "failed", "warning". + failed.append(item) + return succeeded, failed + # ========================================================================= # API Helpers (low-level CRUD) # ========================================================================= - def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Optional[str]]: + def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: """Create multiple policies via a single bulk POST. Builds one ``PolicyCreateBulk`` containing all entries and sends @@ -2652,13 +3167,17 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Optional[str] want_list: List of want dicts, each with all policy fields. Returns: - List of created policy ID strings (same length as want_list). - Entries that failed are ``None``. + List of dicts (same length as want_list), each with:: + + { + "policy_id": str or None, # created ID, None on failure + "ndfc_error": str or None, # NDFC error message on failure + } Raises: NDModuleError: If the entire API call fails (e.g., network error). - Per-policy failures within a 207 response are returned as - ``None`` in the list and do NOT raise. + Per-policy failures within a 207 response are returned + with ``policy_id=None`` and do NOT raise. """ if not want_list: return [] @@ -2695,33 +3214,35 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Optional[str] # Parse per-policy results from the 207 response. # The controller returns policies in the same order as sent. created_policies = data.get("policies", []) if isinstance(data, dict) else [] - created_ids: List[Optional[str]] = [] + results: List[Dict] = [] for idx, want in enumerate(want_list): if idx < len(created_policies): entry = created_policies[idx] - if entry.get("status") == "failed": - error_msg = entry.get("message", "Policy creation failed") + entry_status = str(entry.get("status", "")).lower() + if entry_status != "success": + ndfc_msg = entry.get("message", "Policy creation failed") self.log.error( - f"Bulk create: policy {idx} failed — " + f"Bulk create: policy {idx} failed " + f"(status={entry.get('status')!r}) — " f"template={want.get('templateName')}, " - f"switch={want.get('switchId')}: {error_msg}" + f"switch={want.get('switchId')}: {ndfc_msg}" ) - created_ids.append(None) + results.append({"policy_id": None, "ndfc_error": ndfc_msg}) else: pid = entry.get("policyId") self.log.info(f"Bulk create: policy {idx} created — {pid}") - created_ids.append(pid) + results.append({"policy_id": pid, "ndfc_error": None}) else: self.log.warning(f"Bulk create: no response entry for policy {idx}") - created_ids.append(None) + results.append({"policy_id": None, "ndfc_error": "No response entry from NDFC"}) self.log.info( f"Bulk create complete: " - f"{sum(1 for x in created_ids if x)} succeeded, " - f"{sum(1 for x in created_ids if x is None)} failed" + f"{sum(1 for r in results if r['policy_id'])} succeeded, " + f"{sum(1 for r in results if r['policy_id'] is None)} failed" ) - return created_ids + return results def _api_update_policy(self, want: Dict, have: Dict, policy_id: str) -> None: """Update an existing policy via PUT. @@ -2767,14 +3288,26 @@ def _api_update_policy(self, want: Dict, have: Dict, policy_id: str) -> None: self.nd.request(ep.path, ep.verb, payload) - def _api_mark_delete(self, policy_ids: List[str]) -> None: + def _api_mark_delete(self, policy_ids: List[str]) -> Dict: """Mark policies for deletion via POST /policyActions/markDelete. + NDFC returns HTTP 207 Multi-Status with per-policy results. + Policies with content type PYTHON (e.g. ``switch_freeform``, + ``Ext_VRF_Lite_SVI``) will fail with:: + + "Policies with content type PYTHON or without generated + config can't be mark deleted." + + The caller must inspect the returned dict for per-policy + failures and fall back to direct DELETE for those. + Args: policy_ids: List of policy IDs to mark-delete. Returns: - None. + Response DATA dict from NDFC. Typically contains a + ``policies`` list with per-policy ``status`` and + ``message`` fields. """ self.log.info(f"Marking {len(policy_ids)} policies for deletion: {policy_ids}") body = PolicyIds(policy_ids=policy_ids) @@ -2786,16 +3319,23 @@ def _api_mark_delete(self, policy_ids: List[str]) -> None: if self.ticket_id: ep.endpoint_params.ticket_id = self.ticket_id - self.nd.request(ep.path, ep.verb, body.to_request_dict()) + data = self.nd.request(ep.path, ep.verb, body.to_request_dict()) + return data if isinstance(data, dict) else {} - def _api_remove_policies(self, policy_ids: List[str]) -> None: + def _api_remove_policies(self, policy_ids: List[str]) -> Dict: """Hard-delete policies via POST /policyActions/remove. + NDFC returns HTTP 207 Multi-Status with per-policy results. + The caller should inspect the returned dict for per-policy + ``status: "failed"`` entries. + Args: policy_ids: List of policy IDs to remove from NDFC. Returns: - None. + Response DATA dict from NDFC. Typically contains a + ``policies`` list with per-policy ``status`` and + ``message`` fields. """ self.log.info(f"Removing {len(policy_ids)} policies: {policy_ids}") body = PolicyIds(policy_ids=policy_ids) @@ -2807,7 +3347,8 @@ def _api_remove_policies(self, policy_ids: List[str]) -> None: if self.ticket_id: ep.endpoint_params.ticket_id = self.ticket_id - self.nd.request(ep.path, ep.verb, body.to_request_dict()) + data = self.nd.request(ep.path, ep.verb, body.to_request_dict()) + return data if isinstance(data, dict) else {} def _api_delete_policy(self, policy_id: str) -> None: """Delete a single policy via DELETE /policies/{policyId}. @@ -2834,6 +3375,36 @@ def _api_delete_policy(self, policy_id: str) -> None: self.nd.request(ep.path, ep.verb) + def _api_deploy_switches(self, switch_ids: List[str]) -> dict: + """Deploy fabric config to specific switches. + + Used after direct DELETE of PYTHON content-type policies to push + the config removal to the actual devices. Unlike ``pushConfig`` + (which operates on policy IDs), this endpoint operates on switch + serial numbers. + + API: ``POST /fabrics/{fabricName}/switchActions/deploy`` + + Args: + switch_ids: List of switch serial numbers to deploy to. + + Returns: + Response DATA dict from NDFC. Typically contains a ``status`` + field like ``"Configuration deployment completed for [...]"``. + """ + self.log.info( + f"Deploying config to {len(switch_ids)} switch(es): {switch_ids}" + ) + body = SwitchIds(switch_ids=switch_ids) + + ep = EpManageSwitchActionsDeployPost() + ep.fabric_name = self.fabric_name + if self.cluster_name: + ep.endpoint_params.cluster_name = self.cluster_name + + data = self.nd.request(ep.path, ep.verb, body.to_request_dict()) + return data if isinstance(data, dict) else {} + # ========================================================================= # Results Helper # ========================================================================= From 2eb468425d3f4eb6fff6773eb45c6c9efd8ae4bb Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Tue, 31 Mar 2026 19:30:58 +0530 Subject: [PATCH 54/61] Implemented gathered state for nd_policy module --- .../v1/manage/manage_config_templates.py | 2 +- .../endpoints/v1/manage/manage_policies.py | 2 +- .../v1/manage/manage_policy_actions.py | 2 +- .../v1/manage/manage_switch_actions.py | 2 +- .../models/manage_policies/config_models.py | 6 +- .../models/manage_policies/policy_actions.py | 4 +- plugins/module_utils/nd_policy_resources.py | 364 +++++++++++++++++- plugins/modules/nd_policy.py | 62 ++- 8 files changed, 415 insertions(+), 29 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py index dcf02d47..ecdd390a 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # 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/manage_policies.py b/plugins/module_utils/endpoints/v1/manage/manage_policies.py index 1ba84a50..853678b4 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_policies.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_policies.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # 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/manage_policy_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py index 7ae6bd24..e2b81bfa 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # 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/manage_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py index dd686d59..5f783287 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ diff --git a/plugins/module_utils/models/manage_policies/config_models.py b/plugins/module_utils/models/manage_policies/config_models.py index b1dc1f68..3a2b0a51 100644 --- a/plugins/module_utils/models/manage_policies/config_models.py +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) +# Copyright: (c) 2026, Cisco Systems # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -253,12 +253,12 @@ def get_argument_spec(cls) -> Dict[str, Any]: """ return dict( fabric_name=dict(type="str", required=True, aliases=["fabric"]), - config=dict(type="list", elements="dict", required=True), + config=dict(type="list", elements="dict"), use_desc_as_key=dict(type="bool", default=False), deploy=dict(type="bool", default=True), ticket_id=dict(type="str"), cluster_name=dict(type="str"), - state=dict(type="str", default="merged", choices=["merged", "deleted", "query"]), + state=dict(type="str", default="merged", choices=["merged", "deleted", "query", "gathered"]), ) diff --git a/plugins/module_utils/models/manage_policies/policy_actions.py b/plugins/module_utils/models/manage_policies/policy_actions.py index 75ea8ac1..f934792b 100644 --- a/plugins/module_utils/models/manage_policies/policy_actions.py +++ b/plugins/module_utils/models/manage_policies/policy_actions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) +# Copyright: (c) 2026, Cisco Systems # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -25,8 +25,6 @@ __metaclass__ = type # pylint: enable=invalid-name -__author__ = "L Nikhil Sri Krishna" - from typing import Any, ClassVar, Dict, List from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index e7e26ca6..907003b4 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """ @@ -30,8 +30,6 @@ __metaclass__ = type # pylint: enable=invalid-name -__author__ = "L Nikhil Sri Krishna" - import copy import logging import re @@ -175,9 +173,13 @@ def __init__( self.check_mode = self.module.check_mode if not self.config: - self.module.fail_json( - msg=f"'config' element is mandatory for state '{self.state}'." - ) + if self.state != "gathered": + self.module.fail_json( + msg=f"'config' element is mandatory for state '{self.state}'." + ) + # For gathered without config, initialise to empty list so + # downstream code can iterate safely. + self.config = [] # Template parameter cache: {templateName: [param_dict, ...]} # Populated lazily by _fetch_template_params() to avoid @@ -190,6 +192,7 @@ def __init__( self._before: List[Dict] = [] self._after: List[Dict] = [] self._proposed: List[Dict] = [] + self._gathered: List[Dict] = [] self.log.info( f"Initialized NDPolicyModule for fabric: {self.fabric_name}, state: {self.state}" @@ -211,6 +214,10 @@ def exit_json(self) -> None: final["before"] = self._before final["after"] = self._after + # Attach gathered output when gathered state produces results + if self._gathered: + final["gathered"] = self._gathered + # Only expose proposed at info/debug output levels output_level = self.module.params.get("output_level", "normal") if output_level in ("debug", "info"): @@ -259,6 +266,51 @@ def translate_config(config, use_desc_as_key): if not config: return [] + # ── Step 0: Detect gathered / self-contained format ───────────── + # + # ``state=gathered`` returns each policy with its own embedded + # ``switch`` list (e.g., ``[{"serial_number": "FDO..."}]``). + # This makes the output directly usable as ``config:`` in a + # new ``state=merged`` task ("copy-paste round-trip"). + # + # Detect this format: every entry that has a ``name`` also + # has a ``switch`` list. If so, flatten each entry by + # extracting the serial number from its embedded switch list + # and return immediately — no global/switch separation needed. + has_self_contained = False + all_self_contained = True + for entry in config: + if entry.get("name") and isinstance(entry.get("switch"), list): + has_self_contained = True + elif entry.get("name"): + all_self_contained = False + + if has_self_contained and all_self_contained: + result = [] + for entry in config: + flat = copy.deepcopy(entry) + sw_list = flat.get("switch", []) + if isinstance(sw_list, list) and sw_list: + sn = sw_list[0].get("serial_number") or sw_list[0].get("ip", "") + flat["switch"] = sn + # Gathered output contains both ``name`` (template name) + # and ``policy_id`` (e.g. ``POLICY-28440``). When + # ``policy_id`` is present, promote it to ``name`` so + # that merged state updates the existing policy in-place + # by ID. The template name is preserved alongside for + # readability. + # + # If the user wants to create FRESH copies (new IDs) + # instead of updating, they simply remove the + # ``policy_id`` lines from the gathered output before + # feeding it back — ``name`` will remain as the template + # name and trigger a create. + policy_id = flat.pop("policy_id", None) + if policy_id: + flat["name"] = policy_id + result.append(flat) + return result + # ── Step 1: Separate globals from the switch entry ────────────── global_policies = [] switch_entry = None @@ -429,7 +481,7 @@ def _resolve(identifier): return config - def _query_fabric_switches(self): + def _query_fabric_switches(self) -> List[Dict]: """Query all switches for the fabric and return raw switch records. Uses RestSend save_settings/restore_settings to temporarily force @@ -439,7 +491,7 @@ def _query_fabric_switches(self): Returns: List of switch record dicts from the fabric inventory API. """ - path = f"{BasePath.nd_manage_fabrics(self.fabric_name, 'switches')}?max=10000" + path = f"{BasePath.path('fabrics', self.fabric_name, 'switches')}?max=10000" rest_send = self.nd._get_rest_send() rest_send.save_settings() @@ -562,6 +614,14 @@ def manage_state(self) -> None: """ self.log.info(f"Managing state: {self.state}") + # Gathered state: skip the full config pipeline when config is empty + if self.state == "gathered": + if self.config: + # With config: validate & prepare, then gather matching policies + self.validate_and_prepare_config() + self._handle_gathered_state() + return + # Full config pipeline: pydantic → resolve → translate → validate self.validate_and_prepare_config() @@ -828,6 +888,282 @@ def _handle_deleted_state(self) -> None: self._execute_deleted(diff_results) self.log.debug("EXIT: _handle_deleted_state()") + # ========================================================================= + # Gathered State + # ========================================================================= + + def _handle_gathered_state(self) -> None: + """Handle state=gathered: export existing policies as playbook-ready config. + + Two modes: + - **With config** — ``self.config`` is non-empty. For each config + entry, look up matching policies (same logic as query) and + convert each match into a playbook-compatible config dict. + - **Without config** — ``self.config`` is empty. Fetch *all* + policies on the fabric and convert them. + + The converted output is stored in ``self._gathered`` and surfaced + in the module return under the ``gathered`` key. + + Returns: + None. + """ + self.log.debug("ENTER: _handle_gathered_state()") + self.log.info("Handling gathered state") + + policies: List[Dict] = [] + + if self.config: + # --- With config: query matching policies per entry --- + self.log.info(f"Gathered with config: {len(self.config)} entries") + for config_entry in self.config: + want = self._build_want(config_entry, state="query") + have_list, error_msg = self._build_have(want) + + if error_msg: + self.log.warning(f"Gathered: build_have error: {error_msg}") + self._register_result( + action="policy_gathered", + state="gathered", + operation_type=OperationType.QUERY, + return_code=-1, + message=error_msg, + success=False, + found=False, + diff={"action": "fail", "want": want, "error": error_msg}, + ) + continue + + policies.extend(have_list) + else: + # --- Without config: fetch every policy on every switch --- + self.log.info("Gathered without config: fetching all fabric switches") + switches = self._get_fabric_switches() + if not switches: + self.log.warning("No switches found in fabric") + self._register_result( + action="policy_gathered", + state="gathered", + operation_type=OperationType.QUERY, + return_code=200, + message="No switches found in fabric", + success=True, + found=False, + diff={"action": "not_found"}, + ) + self.log.debug("EXIT: _handle_gathered_state()") + return + + for switch_sn in switches: + self.log.debug(f"Gathering policies for switch {switch_sn}") + lucene = self._build_lucene_filter(switchId=switch_sn) + switch_policies = self._query_policies(lucene, include_mark_deleted=False) + self.log.info( + f"Found {len(switch_policies)} policies on switch {switch_sn}" + ) + policies.extend(switch_policies) + + if not policies: + self.log.info("Gathered: no policies found") + self._register_result( + action="policy_gathered", + state="gathered", + operation_type=OperationType.QUERY, + return_code=200, + message="No policies found", + success=True, + found=False, + diff={"action": "not_found", "match_count": 0}, + ) + self.log.debug("EXIT: _handle_gathered_state()") + return + + # De-duplicate by policyId (multiple config entries might match + # the same underlying policy). + seen_ids: set = set() + unique_policies: List[Dict] = [] + for pol in policies: + pid = pol.get("policyId") + if pid and pid in seen_ids: + continue + if pid: + seen_ids.add(pid) + unique_policies.append(pol) + + self.log.info(f"Gathered {len(unique_policies)} unique policies (from {len(policies)} total)") + + # Convert each policy to playbook-ready config + for policy in unique_policies: + config_entry = self._policy_to_config(policy) + self._gathered.append(config_entry) + + self._register_result( + action="policy_gathered", + state="gathered", + operation_type=OperationType.QUERY, + return_code=200, + message=f"Gathered {len(self._gathered)} policies", + data=self._gathered, + success=True, + found=True, + diff={"action": "gathered", "match_count": len(self._gathered)}, + ) + + self.log.debug("EXIT: _handle_gathered_state()") + + def _get_fabric_switches(self) -> List[str]: + """Fetch all switch serial numbers in the current fabric. + + Delegates to ``_query_fabric_switches()`` for the API call and + extracts serial numbers from the raw switch records. + + Returns: + List of serial number strings. + """ + self.log.debug("ENTER: _get_fabric_switches()") + + try: + records = self._query_fabric_switches() + except Exception as exc: + self.log.warning(f"Failed to fetch fabric switches: {exc}") + return [] + + switches = [] + for sw in records: + sn = sw.get("serialNumber") or sw.get("switchId") or sw.get("switchDbID") + if sn: + switches.append(sn) + + self.log.info(f"Found {len(switches)} switches in fabric '{self.fabric_name}'") + self.log.debug(f"EXIT: _get_fabric_switches() -> {switches}") + return switches + + def _policy_to_config(self, policy: Dict) -> Dict: + """Convert a controller policy dict to a playbook-compatible config entry. + + The output format matches what ``state=merged`` expects, so the + user can copy-paste the gathered output directly into a playbook. + + Internal template input keys (e.g., ``FABRIC_NAME``, ``POLICY_ID``, + ``SERIAL_NUMBER``) are stripped via ``_clean_template_inputs()``. + + Args: + policy: Raw policy dict from the NDFC API. + + Returns: + Dict with keys: name, policy_id, switch, description, priority, + template_inputs, create_additional_policy. + """ + template_name = policy.get("templateName", "") + policy_id = policy.get("policyId", "") + description = policy.get("description", "") + priority = policy.get("priority", 500) + switch_id = policy.get("switchId") or policy.get("serialNumber", "") + + # Parse templateInputs — stored as a JSON-encoded string or dict + raw_inputs = policy.get("templateInputs") or policy.get("nvPairs") or {} + if isinstance(raw_inputs, str): + import json + try: + raw_inputs = json.loads(raw_inputs) + except (json.JSONDecodeError, ValueError): + self.log.warning( + f"Failed to parse templateInputs for {policy_id}: {raw_inputs!r}" + ) + raw_inputs = {} + + # Clean internal keys from template inputs + cleaned_inputs = self._clean_template_inputs(template_name, raw_inputs) + + config_entry = { + "name": template_name, + "policy_id": policy_id, + "switch": [{"serial_number": switch_id}], + "description": description, + "priority": priority, + "template_inputs": cleaned_inputs, + "create_additional_policy": False, + } + + self.log.debug(f"Converted policy {policy_id} to config: {config_entry}") + return config_entry + + def _clean_template_inputs( + self, template_name: str, raw_inputs: Dict[str, Any] + ) -> Dict[str, Any]: + """Remove system-injected keys from template inputs. + + Fetches the template's parameter definitions via the + configTemplates API and keeps **only** the keys that appear in + the template's parameter list. Any key in ``raw_inputs`` that + is not a declared template parameter (e.g. ``FABRIC_NAME``, + ``POLICY_ID``, ``SERIAL_NUMBER``) is an NDFC-injected system + key and is stripped. + + If the template parameter fetch fails, falls back to a + hardcoded set of known NDFC-injected keys and emits a warning. + + Args: + template_name: Template name for fetching parameter definitions. + raw_inputs: Raw ``templateInputs`` dict from the controller. + + Returns: + Cleaned dict with only actual template parameter keys. + """ + self.log.debug( + f"ENTER: _clean_template_inputs(template={template_name}, " + f"keys={list(raw_inputs.keys())})" + ) + + params = self._fetch_template_params(template_name) + + if params: + # Build set of ALL parameter names declared in the template. + # Only keys present in this set are real template inputs; + # everything else is NDFC-injected and should be stripped. + template_param_names: set = set() + for p in params: + name = p.get("name") + if name: + template_param_names.add(name) + + self.log.debug( + f"Template '{template_name}': {len(template_param_names)} " + f"declared params: {sorted(template_param_names)}" + ) + + cleaned = { + k: v for k, v in raw_inputs.items() + if k in template_param_names + } + + stripped = set(raw_inputs.keys()) - template_param_names + if stripped: + self.log.debug( + f"Stripped {len(stripped)} non-template keys: {sorted(stripped)}" + ) + else: + # Fallback: strip commonly known NDFC-injected keys + _KNOWN_INTERNAL_KEYS = { + "FABRIC_NAME", "POLICY_ID", "POLICY_DESC", "PRIORITY", + "SERIAL_NUMBER", "SECENTITY", "SECENTTYPE", "SOURCE", + "MARK_DELETED", "SWITCH_DB_ID", "POLICY_GROUP_ID", + } + self.log.warning( + f"Could not fetch template params for '{template_name}'. " + f"Falling back to hardcoded internal keys: {sorted(_KNOWN_INTERNAL_KEYS)}" + ) + cleaned = { + k: v for k, v in raw_inputs.items() + if k not in _KNOWN_INTERNAL_KEYS + } + + self.log.debug( + f"EXIT: _clean_template_inputs() -> {len(cleaned)} keys " + f"(removed {len(raw_inputs) - len(cleaned)})" + ) + return cleaned + # ========================================================================= # Helpers: Classification & Filtering # ========================================================================= @@ -1439,8 +1775,8 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: f"Case C: Lookup by switchId={want['switchId']} + " f"description='{want_desc}'" ) - # For merged/deleted states, Pydantic already enforces description - # non-empty. This guard is the backstop for query state, where + # For merged/deleted states, Pydantic enforces that description + # is non-empty. This guard covers query/gathered states where # Pydantic intentionally skips the check. if not want_desc: self.log.warning("Case C: description is required but not provided") @@ -1999,9 +2335,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: # in the same batch. # # NOTE: The orphan risk for DAC entries is inherent — NDFC has - # no atomic "replace policy" API. dcnm_policy has the same - # limitation. Re-running the playbook will re-create the - # policy (it will be seen as "not found" → create). + # no atomic "replace policy" API. Re-running the playbook + # will re-create the policy (it will be seen as + # "not found" → create). # Filter out DAC entries whose old policy failed to be removed eligible_dac = [] @@ -2902,7 +3238,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: normal_delete_ids, state="deleted" ) - # deploy=false legacy behavior: stop after markDelete + # deploy=false: stop after markDelete if not self.deploy: self.log.info( "Deploy=false: skipping pushConfig/remove; " diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index 4f1ae4a3..47bb256c 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, annotations, division, print_function @@ -9,7 +9,7 @@ # pylint: disable=invalid-name __metaclass__ = type # pylint: enable=invalid-name - +__copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." __author__ = "L Nikhil Sri Krishna" ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} @@ -20,10 +20,12 @@ version_added: "1.0.0" short_description: Manages policies on Nexus Dashboard Fabric Controller (NDFC). description: -- Supports creating, updating, deleting, querying, and deploying policies based on templates. +- Supports creating, updating, deleting, querying, gathering, and deploying policies based on templates. - Supports C(merged) state for idempotent policy management. - Supports C(deleted) state for removing policies from NDFC and optionally from switches. - Supports C(query) state for retrieving existing policy information. +- Supports C(gathered) state for exporting existing policies as playbook-compatible config. + The gathered output can be copy-pasted directly into a playbook for use with C(merged) state. - When O(use_desc_as_key=true), policies are identified by their description instead of policy ID. - B(Atomic behavior) — the entire task is treated as a single transaction. If any validation check fails (e.g., missing or duplicate descriptions), the module @@ -55,6 +57,9 @@ config: description: - A list of dictionaries containing policy and switch information. + - Required for C(merged), C(deleted), and C(query) states. + - Optional for C(gathered) state. When omitted with C(gathered), all policies on all + fabric switches are exported. When provided, only matching policies are exported. - Policy entries define the template, description, priority, and template inputs. - A separate C(switch) entry lists the target switches and optional per-switch policy overrides. - All global policies (entries without C(switch) key) are applied to every switch listed @@ -64,7 +69,6 @@ policies are simply merged with global policies). type: list elements: dict - required: true suboptions: name: description: @@ -203,8 +207,13 @@ - B(Exception) — C(switch_freeform) policies skip the C(markDelete) flow entirely and are removed via a direct C(DELETE) API call regardless of the O(deploy) setting. - Use C(query) to retrieve existing policies without making changes. + - Use C(gathered) to export existing policies as playbook-compatible config. + When O(config) is provided, only matching policies are exported. + When O(config) is omitted, all policies on all fabric switches are exported. + The output under the C(gathered) return key can be used directly as O(config) + in a subsequent C(merged) task. type: str - choices: [ merged, deleted, query ] + choices: [ merged, deleted, query, gathered ] default: merged extends_documentation_fragment: - cisco.nd.modules @@ -441,6 +450,28 @@ - name: POLICY-102102 - switch: - serial_number: "{{ switch1 }}" + +- name: Gather all policies on all fabric switches (no config needed) + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: gathered + register: all_policies + +- name: Gather only switch_freeform policies on a specific switch + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: gathered + config: + - name: switch_freeform + - switch: + - serial_number: "{{ switch1 }}" + register: freeform_policies + +- name: Use gathered output to re-create policies on another fabric + cisco.nd.nd_policy: + fabric_name: "{{ target_fabric }}" + state: merged + config: "{{ all_policies.gathered }}" """ RETURN = r""" @@ -472,6 +503,27 @@ returned: always type: list elements: dict +gathered: + description: + - List of policies exported as playbook-compatible config dicts. + - Each entry contains C(name) (template name), C(policy_id), C(switch), + C(description), C(priority), C(template_inputs), and C(create_additional_policy). + - Internal template input keys (e.g., C(FABRIC_NAME), C(POLICY_ID)) are + stripped so the output can be used directly as O(config) in a C(merged) task. + - Only returned when O(state=gathered). + returned: when state is gathered + type: list + elements: dict + sample: + - name: switch_freeform + policy_id: POLICY-28240 + switch: + - serial_number: FDO29080NBU + description: my freeform policy + priority: 500 + template_inputs: + CONF: "interface loopback100\n description gathered" + create_additional_policy: false proposed: description: - List of proposed policy changes derived from the playbook config. From 365fd595ddb3553d900b650cce98135da4d15e66 Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Wed, 1 Apr 2026 11:00:57 +0530 Subject: [PATCH 55/61] Removed query state from nd_policy module --- .../models/manage_policies/config_models.py | 6 +- plugins/module_utils/nd_policy_resources.py | 251 ++---------------- plugins/modules/nd_policy.py | 45 +--- 3 files changed, 23 insertions(+), 279 deletions(-) diff --git a/plugins/module_utils/models/manage_policies/config_models.py b/plugins/module_utils/models/manage_policies/config_models.py index 3a2b0a51..b46a2973 100644 --- a/plugins/module_utils/models/manage_policies/config_models.py +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -147,7 +147,7 @@ class PlaybookPolicyConfig(NDNestedModel): declare which switches receive the global policies. Context-aware validation (pass via ``model_validate(..., context={})``: - - ``state``: The module state (merged, deleted, query). + - ``state``: The module state (merged, deleted, gathered). - ``use_desc_as_key``: Whether descriptions are used as unique keys. OpenAPI constraints applied: @@ -258,7 +258,7 @@ def get_argument_spec(cls) -> Dict[str, Any]: deploy=dict(type="bool", default=True), ticket_id=dict(type="str"), cluster_name=dict(type="str"), - state=dict(type="str", default="merged", choices=["merged", "deleted", "query", "gathered"]), + state=dict(type="str", default="merged", choices=["merged", "deleted", "gathered"]), ) diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index 907003b4..a98144b4 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -8,7 +8,7 @@ Provides all business logic for switch policy management on NDFC 4.x: - Policy CRUD (create, read, update, delete) - - Idempotency diff calculation for merged, query, deleted states + - Idempotency diff calculation for merged, deleted states - Deploy (pushConfig) orchestration - Conditional delete flow: deploy=true → markDelete → pushConfig → remove @@ -123,7 +123,7 @@ class NDPolicyModule: Provides policy-specific operations on top of NDModule: - Query and match existing policies (Lucene + post-filtering) - - Idempotent diff calculation across 16 merged / 13 query / 16 deleted cases + - Idempotent diff calculation across 16 merged / 16 deleted cases - Create, update, delete_and_create actions - Bulk deploy via pushConfig - Conditional delete flow: @@ -602,7 +602,6 @@ def manage_state(self) -> None: Validates, normalizes, and prepares the config, then dispatches to the appropriate handler: - **merged** - create / update / skip policies - - **query** - read-only lookup - **deleted** - deploy=true: markDelete → pushConfig → remove - deploy=false: markDelete only @@ -630,8 +629,6 @@ def manage_state(self) -> None: if self.state == "merged": self._handle_merged_state() - elif self.state == "query": - self._handle_query_state() elif self.state == "deleted": self._handle_deleted_state() else: @@ -804,48 +801,6 @@ def _handle_merged_state(self) -> None: self.log.debug("EXIT: _handle_merged_state()") - def _handle_query_state(self) -> None: - """Handle state=query: read-only lookup of policies. - - Returns: - None. - """ - self.log.debug("ENTER: _handle_query_state()") - self.log.info("Handling query state") - self.log.debug(f"Config entries: {len(self.config)}") - - # Phase 1: Build want and have for each config entry - diff_results = [] - for config_entry in self.config: - want = self._build_want(config_entry, state="query") - have_list, error_msg = self._build_have(want) - - if error_msg: - self.log.error(f"Build have failed: {error_msg}") - diff_results.append({ - "action": "fail", - "want": want, - "policies": [], - "match_count": 0, - "warning": None, - "error_msg": error_msg, - }) - continue - - self.log.debug( - f"Found {len(have_list)} existing policies for " - f"{want.get('templateName', want.get('policyId', 'switch-only'))}" - ) - - # Phase 2: Compute query result - diff_entry = self._get_diff_query_single(want, have_list) - diff_results.append(diff_entry) - - # Phase 3: Register results - self.log.info(f"Computed {len(diff_results)} query results") - self._execute_query(diff_results) - self.log.debug("EXIT: _handle_query_state()") - def _handle_deleted_state(self) -> None: """Handle state=deleted: remove policies from NDFC. @@ -897,7 +852,7 @@ def _handle_gathered_state(self) -> None: Two modes: - **With config** — ``self.config`` is non-empty. For each config - entry, look up matching policies (same logic as query) and + entry, look up matching policies and convert each match into a playbook-compatible config dict. - **Without config** — ``self.config`` is empty. Fetch *all* policies on the fabric and convert them. @@ -917,7 +872,7 @@ def _handle_gathered_state(self) -> None: # --- With config: query matching policies per entry --- self.log.info(f"Gathered with config: {len(self.config)} entries") for config_entry in self.config: - want = self._build_want(config_entry, state="query") + want = self._build_want(config_entry, state="gathered") have_list, error_msg = self._build_have(want) if error_msg: @@ -1265,7 +1220,7 @@ def _query_policies_raw( Returns **all** matching policies including ``markDeleted`` and internal (``source != ""``) entries. Callers that need the raw - list (cleanup routines, query-state display) should use this + list (cleanup routines, gathered-state export) should use this directly. For idempotency checks use ``_query_policies()`` which filters out stale records. @@ -1307,8 +1262,8 @@ def _query_policies( - **markDeleted** — when ``include_mark_deleted=False`` (default), policies pending deletion are excluded so they don't interfere with idempotency checks. When ``True``, they are kept and - annotated with ``_markDeleted_stale: True`` so callers (e.g. - query state) can surface the status to the user. + annotated with ``_markDeleted_stale: True`` so callers can + surface the status to the user. - **source != ""** — internal NDFC sub-policies are always excluded; they are artefacts that cause false duplicate matches. @@ -1357,8 +1312,8 @@ def _query_policy_by_id( By default, policies marked for deletion (``markDeleted=True``) are treated as non-existent because they are pending removal - and cannot be updated. When ``include_mark_deleted=True`` (used - by query state), they are returned with an annotation so the + and cannot be updated. When ``include_mark_deleted=True``, + they are returned with an annotation so the caller can surface the status. Args: @@ -1419,12 +1374,12 @@ def _build_want(self, config_entry: Dict, state: str = "merged") -> Dict: """Translate a single user config entry to the API-compatible want dict. For merged state, ``name`` is required and all fields are included. - For query/deleted state, ``name`` is optional — when omitted, only + For gathered/deleted state, ``name`` is optional — when omitted, only ``switchId`` is set, which means "return all policies on this switch". Args: config_entry: Single dict from the user's config list. - state: Module state ("merged", "query", or "deleted"). + state: Module state ("merged", "gathered", or "deleted"). Returns: Dict with camelCase keys matching the API schema. @@ -1453,7 +1408,7 @@ def _build_want(self, config_entry: Dict, state: str = "merged") -> Dict: want["priority"] = config_entry.get("priority", 500) want["templateInputs"] = config_entry.get("template_inputs") or {} else: - # For query/deleted state, only include description if provided + # For gathered/deleted state, only include description if provided description = config_entry.get("description", "") if description: want["description"] = description @@ -1719,10 +1674,8 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: """ self.log.debug("ENTER: _build_have()") - # For query state, include markDeleted policies (annotated) so the - # user sees the full picture. For merged/deleted, exclude them to - # avoid false idempotency matches. - incl_md = self.state == "query" + # Exclude markDeleted policies to avoid false idempotency matches. + incl_md = False # Case A: Policy ID given directly if "policyId" in want: @@ -1776,7 +1729,7 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: f"description='{want_desc}'" ) # For merged/deleted states, Pydantic enforces that description - # is non-empty. This guard covers query/gathered states where + # is non-empty. This guard covers gathered state where # Pydantic intentionally skips the check. if not want_desc: self.log.warning("Case C: description is required but not provided") @@ -1798,23 +1751,6 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: f"Exact description match: {len(exact_matches)} of {len(policies)}" ) - # For query state with use_desc_as_key=true, also filter by templateName - # so the user can narrow results to a specific template. - # For merged/deleted states, do NOT filter by templateName — the diff - # logic handles template mismatches (e.g. Case 15: delete_and_create). - if self.state == "query": - template_name = want.get("templateName") - if template_name: - pre_count = len(exact_matches) - exact_matches = [ - p for p in exact_matches - if p.get("templateName") == template_name - ] - self.log.debug( - f"Post-filtered by templateName={template_name}: " - f"{len(exact_matches)} of {pre_count}" - ) - self.log.info(f"Case C matched {len(exact_matches)} policies") self.log.debug("EXIT: _build_have()") return exact_matches, None @@ -2554,163 +2490,6 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self.log.debug("EXIT: _execute_merged()") return policy_ids_to_deploy - # ========================================================================= - # Diff: Query State (13 cases) - # ========================================================================= - - def _get_diff_query_single(self, want: Dict, have_list: List[Dict]) -> Dict: - """Compute the query result for a single config entry. - - Args: - want: Desired query filter dict. - have_list: Matching policies from the controller. - - Returns: - Dict with keys: action, want, policies, match_count, warning, error_msg. - """ - result = { - "action": None, - "want": want, - "policies": have_list, - "match_count": len(have_list), - "warning": None, - "error_msg": None, - } - - match_count = len(have_list) - - # Q-1, Q-2: Policy ID given - if "policyId" in want: - result["action"] = "found" if match_count >= 1 else "not_found" - return result - - # Switch-only: No name given → return everything found on this switch - if "templateName" not in want: - result["action"] = "found" if match_count >= 1 else "not_found" - return result - - # Q-3 to Q-9: Template name given, use_desc_as_key=false - if not self.use_desc_as_key: - result["action"] = "found" if match_count > 0 else "not_found" - return result - - # Q-10 to Q-13: Template name given, use_desc_as_key=true - if self.use_desc_as_key: - # Note: description-empty is already caught by _build_have Case C - # which returns error_msg before this method runs. - want_desc = want.get("description", "") - - if match_count == 0: - result["action"] = "not_found" - return result - - if match_count == 1: - result["action"] = "found" - return result - - # Q-13: Multiple matches — return all with a warning. - # Query is read-only so there's no risk of ambiguous mutation. - # The warning alerts the user that descriptions aren't unique. - result["action"] = "found" - result["warning"] = ( - f"Multiple policies ({match_count}) found with description " - f"'{want_desc}' on switch {want.get('switchId')}. " - "Descriptions should be unique per switch when " - "use_desc_as_key=true." - ) - return result - - # Should not reach here - result["action"] = "not_found" - return result - - # ========================================================================= - # Execute: Query State - # ========================================================================= - - def _execute_query(self, diff_results: List[Dict]) -> None: - """Register results for all query config entries. - - Query state never makes changes — ``changed`` is always ``false``. - - Args: - diff_results: List of diff result dicts from ``_get_diff_query_single``. - - Returns: - None. - """ - self.log.debug("ENTER: _execute_query()") - self.log.debug(f"Processing {len(diff_results)} query entries") - - for diff_entry in diff_results: - action = diff_entry["action"] - want = diff_entry["want"] - policies = diff_entry["policies"] - match_count = diff_entry["match_count"] - warning = diff_entry["warning"] - error_msg = diff_entry["error_msg"] - - self.log.debug( - f"Query action={action} for " - f"{want.get('templateName', want.get('policyId', 'switch-only'))}, " - f"match_count={match_count}" - ) - - if action == "fail": - self.log.warning(f"Query failed: {error_msg}") - self._proposed.append(want) - self._register_result( - action="policy_query", - state="query", - operation_type=OperationType.QUERY, - return_code=-1, - message=error_msg, - success=False, - found=False, - diff={"action": action, "want": want, "error": error_msg}, - ) - continue - - if action == "not_found": - self._proposed.append(want) - self._register_result( - action="policy_query", - state="query", - operation_type=OperationType.QUERY, - return_code=200, - message="Not found", - success=True, - found=False, - diff={"action": action, "want": want, "policies": [], "match_count": 0}, - ) - continue - - if action == "found": - self._proposed.append(want) - self._after.extend(policies) # query: "after" = what exists now - diff_payload = { - "action": action, - "want": want, - "policies": policies, - "match_count": match_count, - } - if warning: - diff_payload["warning"] = warning - self._register_result( - action="policy_query", - state="query", - operation_type=OperationType.QUERY, - return_code=200, - message="OK", - data=policies, - success=True, - found=True, - diff=diff_payload, - ) - continue - - self.log.debug("EXIT: _execute_query()") - # ========================================================================= # Diff: Deleted State (16 cases) # ========================================================================= diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index 47bb256c..495a7bdd 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -20,10 +20,9 @@ version_added: "1.0.0" short_description: Manages policies on Nexus Dashboard Fabric Controller (NDFC). description: -- Supports creating, updating, deleting, querying, gathering, and deploying policies based on templates. +- Supports creating, updating, deleting, gathering, and deploying policies based on templates. - Supports C(merged) state for idempotent policy management. - Supports C(deleted) state for removing policies from NDFC and optionally from switches. -- Supports C(query) state for retrieving existing policy information. - Supports C(gathered) state for exporting existing policies as playbook-compatible config. The gathered output can be copy-pasted directly into a playbook for use with C(merged) state. - When O(use_desc_as_key=true), policies are identified by their description instead of policy ID. @@ -57,7 +56,7 @@ config: description: - A list of dictionaries containing policy and switch information. - - Required for C(merged), C(deleted), and C(query) states. + - Required for C(merged) and C(deleted) states. - Optional for C(gathered) state. When omitted with C(gathered), all policies on all fabric switches are exported. When provided, only matching policies are exported. - Policy entries define the template, description, priority, and template inputs. @@ -78,8 +77,8 @@ - B(Policy ID) — a unique ID identifying a policy (e.g., C(POLICY-121110)). Policy ID B(must) be used for modifying existing policies when O(use_desc_as_key=false), since template names cannot uniquely identify a policy. - - For C(query) and C(deleted) states, this is optional. When omitted, all policies - on the specified switch are returned/deleted. + - For C(deleted) state, this is optional. When omitted, all policies + on the specified switch are deleted. type: str description: description: @@ -206,14 +205,13 @@ run with O(deploy=true) or manual intervention. - B(Exception) — C(switch_freeform) policies skip the C(markDelete) flow entirely and are removed via a direct C(DELETE) API call regardless of the O(deploy) setting. - - Use C(query) to retrieve existing policies without making changes. - Use C(gathered) to export existing policies as playbook-compatible config. When O(config) is provided, only matching policies are exported. When O(config) is omitted, all policies on all fabric switches are exported. The output under the C(gathered) return key can be used directly as O(config) in a subsequent C(merged) task. type: str - choices: [ merged, deleted, query, gathered ] + choices: [ merged, deleted, gathered ] default: merged extends_documentation_fragment: - cisco.nd.modules @@ -420,37 +418,6 @@ - switch: - serial_number: "{{ switch1 }}" -# QUERY - -- name: Query all policies from specified switches - cisco.nd.nd_policy: - fabric_name: "{{ fabric_name }}" - state: query - config: - - switch: - - serial_number: "{{ switch1 }}" - - serial_number: "{{ switch2 }}" - -- name: Query policies matching template names - cisco.nd.nd_policy: - fabric_name: "{{ fabric_name }}" - state: query - config: - - name: template_101 - - name: template_102 - - switch: - - serial_number: "{{ switch1 }}" - -- name: Query policies using policy-ids - cisco.nd.nd_policy: - fabric_name: "{{ fabric_name }}" - state: query - config: - - name: POLICY-101101 - - name: POLICY-102102 - - switch: - - serial_number: "{{ switch1 }}" - - name: Gather all policies on all fabric switches (no config needed) cisco.nd.nd_policy: fabric_name: "{{ fabric_name }}" @@ -490,7 +457,6 @@ - List of policy snapshots B(before) the module made any changes. - For C(merged) state, contains the existing policy state prior to create/update. - For C(deleted) state, contains the policies that were deleted. - - For C(query) state, this is an empty list. returned: always type: list elements: dict @@ -499,7 +465,6 @@ - List of policy snapshots B(after) the module completed. - For C(merged) state, contains the new/updated policy state. - For C(deleted) state, this is an empty list (policies were removed). - - For C(query) state, contains the queried policies. returned: always type: list elements: dict From e6058f6593d92c5d5bfeae077a5f011761a642cb Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Wed, 1 Apr 2026 12:42:36 +0530 Subject: [PATCH 56/61] Add GatheredPolicy model and refactor gathered state handler --- .../models/manage_policies/__init__.py | 7 + .../models/manage_policies/config_models.py | 9 +- .../models/manage_policies/gathered_models.py | 187 +++++++++++ plugins/module_utils/nd_policy_resources.py | 308 ++++++++++-------- plugins/modules/nd_policy.py | 17 +- 5 files changed, 394 insertions(+), 134 deletions(-) create mode 100644 plugins/module_utils/models/manage_policies/gathered_models.py diff --git a/plugins/module_utils/models/manage_policies/__init__.py b/plugins/module_utils/models/manage_policies/__init__.py index 15d8fa10..62072a36 100644 --- a/plugins/module_utils/models/manage_policies/__init__.py +++ b/plugins/module_utils/models/manage_policies/__init__.py @@ -37,6 +37,11 @@ PolicyIds, ) +# --- Gathered (read) models --- +from .gathered_models import ( # noqa: F401 + GatheredPolicy, +) + # --- Config (playbook input) models --- from .config_models import ( # noqa: F401 PlaybookPolicyConfig, @@ -55,6 +60,8 @@ "PolicyUpdate", # Action models "PolicyIds", + # Gathered (read) models + "GatheredPolicy", # Config (playbook input) models "PlaybookPolicyConfig", "PlaybookSwitchEntry", diff --git a/plugins/module_utils/models/manage_policies/config_models.py b/plugins/module_utils/models/manage_policies/config_models.py index b46a2973..67200fe5 100644 --- a/plugins/module_utils/models/manage_policies/config_models.py +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -38,8 +38,9 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, ) -from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel - +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import ( + NDNestedModel, +) # ============================================================================ # Per-switch policy override (switch[].policies[] entry) @@ -258,7 +259,9 @@ def get_argument_spec(cls) -> Dict[str, Any]: deploy=dict(type="bool", default=True), ticket_id=dict(type="str"), cluster_name=dict(type="str"), - state=dict(type="str", default="merged", choices=["merged", "deleted", "gathered"]), + state=dict( + type="str", default="merged", choices=["merged", "deleted", "gathered"] + ), ) diff --git a/plugins/module_utils/models/manage_policies/gathered_models.py b/plugins/module_utils/models/manage_policies/gathered_models.py new file mode 100644 index 00000000..c32caadc --- /dev/null +++ b/plugins/module_utils/models/manage_policies/gathered_models.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Read-model for ``state=gathered`` output. + +``GatheredPolicy`` is a lightweight model that represents a policy as +returned by the NDFC API, keyed by ``policyId``. It is used exclusively +by ``_handle_gathered_state()`` for: + + - Deserialising raw API response dicts via ``from_response()`` + - De-duplicating policies via ``NDConfigCollection`` (keyed by ``policy_id``) + - Serialising to playbook-compatible config via ``to_gathered_config()`` + +This model is separate from ``PolicyCreate`` because: + + - It uses ``policy_id`` as the single identifier (unique per policy), + whereas ``PolicyCreate`` uses a composite key for write operations. + - It carries read-only fields (``policy_id``) that are not part of the + create/update payload. + - The ``to_gathered_config()`` output format must match the playbook + ``config[]`` schema exactly for copy-paste round-trips. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type + +__author__ = "L Nikhil Sri Krishna" + +import json +import logging +from typing import Any, ClassVar, Dict, List, Literal, Optional, Set + +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 + + +log = logging.getLogger("nd.GatheredPolicy") + + +class GatheredPolicy(NDBaseModel): + """Read-model for a policy returned by the NDFC API. + + Keyed by ``policy_id`` for ``NDConfigCollection`` dedup. + + Fields mirror the NDFC policy response keys (camelCase aliases) + that are needed for gathered output. Extra API response keys + (``generatedConfig``, ``markDeleted``, ``createTimestamp``, etc.) + are silently dropped by ``model_config.extra = "ignore"`` inherited + from ``NDBaseModel``. + """ + + # --- NDBaseModel ClassVars --- + identifiers: ClassVar[List[str]] = ["policy_id"] + identifier_strategy: ClassVar[Optional[Literal[ + "single", "composite", "hierarchical", "singleton" + ]]] = "single" + exclude_from_diff: ClassVar[Set[str]] = set() + + # Fields excluded from config output (internal / not user-facing) + config_exclude_fields: ClassVar[Set[str]] = { + "entity_type", "entity_name", "source", + "secondary_entity_name", "secondary_entity_type", + } + + # --- Fields --- + policy_id: str = Field( + ..., + alias="policyId", + description="Controller-assigned policy ID (e.g., POLICY-28440)", + ) + switch_id: str = Field( + ..., + alias="switchId", + description="Switch serial number", + ) + template_name: str = Field( + default="", + alias="templateName", + description="Name of the policy template", + ) + description: Optional[str] = Field( + default="", + description="Policy description", + ) + priority: Optional[int] = Field( + default=500, + description="Policy priority (1-2000)", + ) + entity_type: Optional[str] = Field( + default=None, + alias="entityType", + description="Entity type (switch, configProfile, interface)", + ) + entity_name: Optional[str] = Field( + default=None, + alias="entityName", + description="Entity name", + ) + source: Optional[str] = Field( + default=None, + description="Policy source", + ) + template_inputs: Optional[Dict[str, Any]] = Field( + default=None, + alias="templateInputs", + description="Template input parameters", + ) + secondary_entity_name: Optional[str] = Field( + default=None, + alias="secondaryEntityName", + ) + secondary_entity_type: Optional[str] = Field( + default=None, + alias="secondaryEntityType", + ) + + @classmethod + def from_api_policy(cls, policy: Dict[str, Any]) -> "GatheredPolicy": + """Create a GatheredPolicy from a raw NDFC API policy dict. + + Handles the ``templateInputs`` field which may be a JSON-encoded + string in the API response. Parses it into a dict before model + validation. + + Also handles the ``nvPairs`` alias that some API responses use + instead of ``templateInputs``. + + Args: + policy: Raw policy dict from the NDFC API. + + Returns: + A validated ``GatheredPolicy`` instance. + """ + data = dict(policy) + + # Normalise templateInputs: may be a JSON string or absent + raw_inputs = data.get("templateInputs") or data.get("nvPairs") or {} + if isinstance(raw_inputs, str): + try: + raw_inputs = json.loads(raw_inputs) + except (json.JSONDecodeError, ValueError): + log.warning( + f"Failed to parse templateInputs for " + f"{data.get('policyId', '?')}: {raw_inputs!r}" + ) + raw_inputs = {} + data["templateInputs"] = raw_inputs + + # Ensure switchId is present (some responses use serialNumber) + if "switchId" not in data and "serialNumber" in data: + data["switchId"] = data["serialNumber"] + + return cls.from_response(data) + + def to_gathered_config(self) -> Dict[str, Any]: + """Convert to the playbook-compatible gathered config format. + + The output dict matches what ``state=merged`` expects so the user + can copy-paste gathered output directly into a playbook. + + Output keys: + - name: template name + - policy_id: controller-assigned policy ID + - switch: [{serial_number: ...}] + - description: policy description + - priority: policy priority + - template_inputs: cleaned template inputs + - create_additional_policy: always False for gathered + + Returns: + Dict in playbook config format. + """ + return { + "name": self.template_name, + "policy_id": self.policy_id, + "switch": [{"serial_number": self.switch_id}], + "description": self.description or "", + "priority": self.priority or 500, + "template_inputs": self.template_inputs or {}, + "create_additional_policy": False, + } diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index a98144b4..78bd49dd 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -36,7 +36,9 @@ from typing import Any, Dict, List, Optional, Tuple from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_config_templates import ( EpManageConfigTemplateParametersGet, ) @@ -70,9 +72,12 @@ NDModuleError, ) from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError -from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import PlaybookPolicyConfig - +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ValidationError, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import ( + PlaybookPolicyConfig, +) # ============================================================================= # Module-level helpers (stateless, used by NDPolicyModule) @@ -413,7 +418,11 @@ def resolve_switch_identifiers(self, config): switch_value = entry.get("switch") if isinstance(switch_value, list): for switch_entry in switch_value: - val = switch_entry.get("serial_number") or switch_entry.get("ip") or "" + val = ( + switch_entry.get("serial_number") + or switch_entry.get("ip") + or "" + ) if _needs_resolution(val): needs_lookup.add(val) elif isinstance(switch_value, str) and _needs_resolution(switch_value): @@ -450,7 +459,9 @@ def _resolve(identifier): if isinstance(switch_value, list): for switch_entry in switch_value: - original = switch_entry.get("serial_number") or switch_entry.get("ip") + original = switch_entry.get("serial_number") or switch_entry.get( + "ip" + ) if not _needs_resolution(original): continue resolved = _resolve(original) @@ -561,12 +572,19 @@ def validate_and_prepare_config(self) -> None: self.log.info("Validating and preparing config") # Step 1: Pydantic validation + normalization - validation_context = {"state": self.state, "use_desc_as_key": self.use_desc_as_key} + validation_context = { + "state": self.state, + "use_desc_as_key": self.use_desc_as_key, + } normalized_config = [] for idx, entry in enumerate(self.config): try: - validated = PlaybookPolicyConfig.model_validate(entry, context=validation_context) - normalized_config.append(validated.model_dump(by_alias=False, exclude_none=False)) + validated = PlaybookPolicyConfig.model_validate( + entry, context=validation_context + ) + normalized_config.append( + validated.model_dump(by_alias=False, exclude_none=False) + ) except ValidationError as ve: self.module.fail_json( msg=f"Input validation failed for config[{idx}]: {ve}" @@ -692,8 +710,7 @@ def _validate_config(self) -> None: msg=( "Duplicate description+switch combinations found in the " "playbook config (use_desc_as_key=true requires each " - "description to be unique per switch): " - + "; ".join(duplicates) + "description to be unique per switch): " + "; ".join(duplicates) ) ) @@ -731,28 +748,32 @@ def _handle_merged_state(self) -> None: + "; ".join(validation_errors) ) self.log.error(error_msg) - diff_results.append({ - "action": "fail", - "want": want, - "have": None, - "diff": None, - "policy_id": None, - "error_msg": error_msg, - }) + diff_results.append( + { + "action": "fail", + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": error_msg, + } + ) continue have_list, error_msg = self._build_have(want) if error_msg: self.log.error(f"Build have failed: {error_msg}") - diff_results.append({ - "action": "fail", - "want": want, - "have": None, - "diff": None, - "policy_id": None, - "error_msg": error_msg, - }) + diff_results.append( + { + "action": "fail", + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": error_msg, + } + ) continue # Phase 2: Compute diff @@ -819,15 +840,17 @@ def _handle_deleted_state(self) -> None: if error_msg: self.log.error(f"Build have failed: {error_msg}") - diff_results.append({ - "action": "fail", - "want": want, - "policies": [], - "policy_ids": [], - "match_count": 0, - "warning": None, - "error_msg": error_msg, - }) + diff_results.append( + { + "action": "fail", + "want": want, + "policies": [], + "policy_ids": [], + "match_count": 0, + "warning": None, + "error_msg": error_msg, + } + ) continue # Phase 2: Compute delete result @@ -912,7 +935,9 @@ def _handle_gathered_state(self) -> None: for switch_sn in switches: self.log.debug(f"Gathering policies for switch {switch_sn}") lucene = self._build_lucene_filter(switchId=switch_sn) - switch_policies = self._query_policies(lucene, include_mark_deleted=False) + switch_policies = self._query_policies( + lucene, include_mark_deleted=False + ) self.log.info( f"Found {len(switch_policies)} policies on switch {switch_sn}" ) @@ -945,7 +970,9 @@ def _handle_gathered_state(self) -> None: seen_ids.add(pid) unique_policies.append(pol) - self.log.info(f"Gathered {len(unique_policies)} unique policies (from {len(policies)} total)") + self.log.info( + f"Gathered {len(unique_policies)} unique policies (from {len(policies)} total)" + ) # Convert each policy to playbook-ready config for policy in unique_policies: @@ -1019,6 +1046,7 @@ def _policy_to_config(self, policy: Dict) -> Dict: raw_inputs = policy.get("templateInputs") or policy.get("nvPairs") or {} if isinstance(raw_inputs, str): import json + try: raw_inputs = json.loads(raw_inputs) except (json.JSONDecodeError, ValueError): @@ -1087,10 +1115,7 @@ def _clean_template_inputs( f"declared params: {sorted(template_param_names)}" ) - cleaned = { - k: v for k, v in raw_inputs.items() - if k in template_param_names - } + cleaned = {k: v for k, v in raw_inputs.items() if k in template_param_names} stripped = set(raw_inputs.keys()) - template_param_names if stripped: @@ -1100,17 +1125,24 @@ def _clean_template_inputs( else: # Fallback: strip commonly known NDFC-injected keys _KNOWN_INTERNAL_KEYS = { - "FABRIC_NAME", "POLICY_ID", "POLICY_DESC", "PRIORITY", - "SERIAL_NUMBER", "SECENTITY", "SECENTTYPE", "SOURCE", - "MARK_DELETED", "SWITCH_DB_ID", "POLICY_GROUP_ID", + "FABRIC_NAME", + "POLICY_ID", + "POLICY_DESC", + "PRIORITY", + "SERIAL_NUMBER", + "SECENTITY", + "SECENTTYPE", + "SOURCE", + "MARK_DELETED", + "SWITCH_DB_ID", + "POLICY_GROUP_ID", } self.log.warning( f"Could not fetch template params for '{template_name}'. " f"Falling back to hardcoded internal keys: {sorted(_KNOWN_INTERNAL_KEYS)}" ) cleaned = { - k: v for k, v in raw_inputs.items() - if k not in _KNOWN_INTERNAL_KEYS + k: v for k, v in raw_inputs.items() if k not in _KNOWN_INTERNAL_KEYS } self.log.debug( @@ -1203,7 +1235,10 @@ def _policies_differ(want: Dict, have: Dict) -> Dict: want_val = str(want_inputs[key]) have_val = str(have_inputs.get(key, "")) if want_val != have_val: - input_diff[key] = {"want": want_inputs[key], "have": have_inputs.get(key)} + input_diff[key] = { + "want": want_inputs[key], + "have": have_inputs.get(key), + } if input_diff: diff["templateInputs"] = input_diff @@ -1213,9 +1248,7 @@ def _policies_differ(want: Dict, have: Dict) -> Dict: # API Query Helpers # ========================================================================= - def _query_policies_raw( - self, lucene_filter: Optional[str] = None - ) -> List[Dict]: + def _query_policies_raw(self, lucene_filter: Optional[str] = None) -> List[Dict]: """Query policies from the controller using GET /policies (unfiltered). Returns **all** matching policies including ``markDeleted`` and @@ -1384,7 +1417,9 @@ def _build_want(self, config_entry: Dict, state: str = "merged") -> Dict: Returns: Dict with camelCase keys matching the API schema. """ - self.log.debug(f"Building want for state={state}, name={config_entry.get('name')}") + self.log.debug( + f"Building want for state={state}, name={config_entry.get('name')}" + ) want = { "switchId": config_entry["switch"], @@ -1398,7 +1433,9 @@ def _build_want(self, config_entry: Dict, state: str = "merged") -> Dict: want["templateName"] = name # Per-entry create_additional_policy flag (carried on want dict) - want["create_additional_policy"] = config_entry.get("create_additional_policy", True) + want["create_additional_policy"] = config_entry.get( + "create_additional_policy", True + ) # For merged state, include all payload fields if state == "merged": @@ -1680,7 +1717,9 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # Case A: Policy ID given directly if "policyId" in want: self.log.debug(f"Case A: Direct policy ID lookup: {want['policyId']}") - policy = self._query_policy_by_id(want["policyId"], include_mark_deleted=incl_md) + policy = self._query_policy_by_id( + want["policyId"], include_mark_deleted=incl_md + ) if policy: self.log.info(f"Policy {want['policyId']} found") return [policy], None @@ -1692,7 +1731,9 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: self.log.debug(f"Case D: Switch-only lookup for {want['switchId']}") lucene = self._build_lucene_filter(switchId=want["switchId"]) policies = self._query_policies(lucene, include_mark_deleted=incl_md) - self.log.info(f"Found {len(policies)} policies on switch {want['switchId']}") + self.log.info( + f"Found {len(policies)} policies on switch {want['switchId']}" + ) return policies, None # Case B: use_desc_as_key=false, search by switchId + templateName @@ -1712,8 +1753,7 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: if want_desc: pre_filter_count = len(policies) policies = [ - p for p in policies - if (p.get("description", "") or "") == want_desc + p for p in policies if (p.get("description", "") or "") == want_desc ] self.log.debug( f"Post-filtered by description: {len(policies)} of {pre_filter_count}" @@ -1733,7 +1773,10 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # Pydantic intentionally skips the check. if not want_desc: self.log.warning("Case C: description is required but not provided") - return [], "description is required when use_desc_as_key=true and name is a template name" + return ( + [], + "description is required when use_desc_as_key=true and name is a template name", + ) lucene = self._build_lucene_filter( switchId=want["switchId"], @@ -1744,8 +1787,7 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # IMPORTANT: Lucene does tokenized matching, not exact match. # Post-filter to ensure exact description match. exact_matches = [ - p for p in policies - if (p.get("description", "") or "") == want_desc + p for p in policies if (p.get("description", "") or "") == want_desc ] self.log.debug( f"Exact description match: {len(exact_matches)} of {len(policies)}" @@ -2050,9 +2092,12 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: success=True, found=True, diff={ - "action": "update", "before": have, - "after": {**have, **want}, "want": want, - "have": have, "diff": diff_entry["diff"], + "action": "update", + "before": have, + "after": {**have, **want}, + "want": want, + "have": have, + "diff": diff_entry["diff"], "policy_id": diff_entry["policy_id"], }, ) @@ -2070,8 +2115,11 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: success=True, found=True, diff={ - "action": "delete_and_create", "before": have, - "after": want, "want": want, "have": have, + "action": "delete_and_create", + "before": have, + "after": want, + "want": want, + "have": have, "diff": diff_entry["diff"], "delete_policy_id": diff_entry["policy_id"], }, @@ -2190,18 +2238,18 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self._api_delete_policy(pid) direct_deleted.append(pid) except Exception: # noqa: BLE001 - self.log.error( - f"Direct DELETE also failed for {pid}" - ) + self.log.error(f"Direct DELETE also failed for {pid}") remove_failed_ids.add(pid) # Deploy to affected switches to push config removal if direct_deleted and self.deploy: - affected_switches = list({ - dac_switch_map[pid] - for pid in direct_deleted - if pid in dac_switch_map - }) + affected_switches = list( + { + dac_switch_map[pid] + for pid in direct_deleted + if pid in dac_switch_map + } + ) if affected_switches: self.log.info( f"Phase 3b: switchActions/deploy for " @@ -2314,16 +2362,13 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: want_list = [d["want"] for d in batch_entries] self.log.info( - f"Bulk creating {len(want_list)} policies " - f"(batch={batch_label})" + f"Bulk creating {len(want_list)} policies " f"(batch={batch_label})" ) try: created_ids = self._api_bulk_create_policies(want_list) except NDModuleError as bulk_err: - self.log.error( - f"Bulk {batch_label} failed entirely: {bulk_err.msg}" - ) + self.log.error(f"Bulk {batch_label} failed entirely: {bulk_err.msg}") for diff_entry in batch_entries: want = diff_entry["want"] action_label = ( @@ -2358,8 +2403,12 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: is_replace = diff_entry["action"] == "delete_and_create" entry_result = ( - created_ids[idx] if idx < len(created_ids) - else {"policy_id": None, "ndfc_error": "No response entry from NDFC"} + created_ids[idx] + if idx < len(created_ids) + else { + "policy_id": None, + "ndfc_error": "No response entry from NDFC", + } ) created_id = entry_result["policy_id"] ndfc_error = entry_result["ndfc_error"] @@ -2378,9 +2427,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self._before.append(have) if per_policy_error: - action_label = ( - "policy_replace" if is_replace else "policy_create" - ) + action_label = "policy_replace" if is_replace else "policy_create" self._register_result( action=action_label, operation_type=OperationType.CREATE, @@ -2411,7 +2458,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: "action": "delete_and_create", "before": have, "after": {**want, "policyId": created_id}, - "want": want, "have": have, "diff": field_diff, + "want": want, + "have": have, + "diff": field_diff, "deleted_policy_id": diff_entry["policy_id"], "created_policy_id": created_id, }, @@ -2428,7 +2477,8 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: "action": "create", "before": None, "after": {**want, "policyId": created_id}, - "want": want, "diff": field_diff, + "want": want, + "diff": field_diff, "created_policy_id": created_id, }, ) @@ -2446,9 +2496,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: try: self._api_update_policy(want, have, policy_id) except NDModuleError as update_err: - self.log.error( - f"Update failed for {policy_id}: {update_err.msg}" - ) + self.log.error(f"Update failed for {policy_id}: {update_err.msg}") self._register_result( action="policy_update", operation_type=OperationType.UPDATE, @@ -2459,7 +2507,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: found=True, diff={ "action": "update_failed", - "want": want, "have": have, "diff": field_diff, + "want": want, + "have": have, + "diff": field_diff, "policy_id": policy_id, "error": update_err.msg, }, @@ -2480,13 +2530,18 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: found=True, diff={ "action": "update", - "before": have, "after": after_merged, - "want": want, "have": have, "diff": field_diff, + "before": have, + "after": after_merged, + "want": want, + "have": have, + "diff": field_diff, "policy_id": policy_id, }, ) - self.log.info(f"Merged execute complete: {len(policy_ids_to_deploy)} policies to deploy") + self.log.info( + f"Merged execute complete: {len(policy_ids_to_deploy)} policies to deploy" + ) self.log.debug("EXIT: _execute_merged()") return policy_ids_to_deploy @@ -2656,7 +2711,12 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: message="Policy not found — already absent", success=True, found=False, - diff={"action": action, "want": want, "before": None, "after": None}, + diff={ + "action": action, + "want": want, + "before": None, + "after": None, + }, ) continue @@ -2686,7 +2746,9 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: all_switch_ids.append(sw) if self.check_mode: - self.log.info(f"Check mode: would delete {len(policy_ids)} policy(ies)") + self.log.info( + f"Check mode: would delete {len(policy_ids)} policy(ies)" + ) diff_payload = { "action": action, "want": want, @@ -2814,8 +2876,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: else: # No structured response — assume all succeeded (pre-existing behavior) self.log.warning( - "markDelete returned non-dict response — " - "treating all as succeeded" + "markDelete returned non-dict response — " "treating all as succeeded" ) mark_succeeded = list(unique_policy_ids) @@ -2876,10 +2937,9 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: failed_direct.append(pid) if deleted_direct: - tpl_names = list({ - policy_template_map.get(pid, "unknown") - for pid in deleted_direct - }) + tpl_names = list( + {policy_template_map.get(pid, "unknown") for pid in deleted_direct} + ) self._register_result( action="policy_direct_delete", state="deleted", @@ -2921,11 +2981,13 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # to the devices. Direct DELETE removes the policy record but # the device still has the running config until we deploy. if deleted_direct and self.deploy: - affected_switches = list({ - policy_switch_map[pid] - for pid in deleted_direct - if pid in policy_switch_map - }) + affected_switches = list( + { + policy_switch_map[pid] + for pid in deleted_direct + if pid in policy_switch_map + } + ) if affected_switches: self.log.info( f"Deploying config to {len(affected_switches)} switch(es) " @@ -2954,9 +3016,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: if isinstance(deploy_data, dict) and deploy_data: status_str = deploy_data.get("status", "") if status_str: - self.log.info( - f"switchActions/deploy status: {status_str}" - ) + self.log.info(f"switchActions/deploy status: {status_str}") else: self.log.warning( "switchActions/deploy returned non-empty body " @@ -3010,12 +3070,8 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Step 2 (deploy=true only): pushConfig deploy_success = True if self.deploy: - self.log.info( - f"Step 2/3: pushConfig for {len(normal_delete_ids)} policies" - ) - deploy_success = self._deploy_policies( - normal_delete_ids, state="deleted" - ) + self.log.info(f"Step 2/3: pushConfig for {len(normal_delete_ids)} policies") + deploy_success = self._deploy_policies(normal_delete_ids, state="deleted") # deploy=false: stop after markDelete if not self.deploy: @@ -3054,9 +3110,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: return # Step 3: remove — hard-delete policy records from NDFC - self.log.info( - f"Step 3/3: remove {len(normal_delete_ids)} policies" - ) + self.log.info(f"Step 3/3: remove {len(normal_delete_ids)} policies") remove_data = self._api_remove_policies(normal_delete_ids) # Inspect 207 response for per-policy failures @@ -3077,8 +3131,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: for p in rm_fail ] self.log.error( - f"remove failed for {len(rm_fail)} policy(ies): " - + "; ".join(fail_msgs) + f"remove failed for {len(rm_fail)} policy(ies): " + "; ".join(fail_msgs) ) self._register_result( @@ -3100,9 +3153,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: "action": "remove", "policy_ids": normal_delete_ids, "remove_success": remove_success, - "failed_policies": [ - p.get("policyId") for p in rm_fail - ], + "failed_policies": [p.get("policyId") for p in rm_fail], }, ) @@ -3180,7 +3231,10 @@ def _deploy_policies( deploy_success = len(failed_policies) == 0 if failed_policies: - failed_msgs = [f"{p.get('policyId', '?')}: {p.get('message', 'unknown error')}" for p in failed_policies] + failed_msgs = [ + f"{p.get('policyId', '?')}: {p.get('message', 'unknown error')}" + for p in failed_policies + ] self.log.error( f"pushConfig failed for {len(failed_policies)} policy(ies): " + "; ".join(failed_msgs) @@ -3350,7 +3404,9 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: results.append({"policy_id": pid, "ndfc_error": None}) else: self.log.warning(f"Bulk create: no response entry for policy {idx}") - results.append({"policy_id": None, "ndfc_error": "No response entry from NDFC"}) + results.append( + {"policy_id": None, "ndfc_error": "No response entry from NDFC"} + ) self.log.info( f"Bulk create complete: " @@ -3507,9 +3563,7 @@ def _api_deploy_switches(self, switch_ids: List[str]) -> dict: Response DATA dict from NDFC. Typically contains a ``status`` field like ``"Configuration deployment completed for [...]"``. """ - self.log.info( - f"Deploying config to {len(switch_ids)} switch(es): {switch_ids}" - ) + self.log.info(f"Deploying config to {len(switch_ids)} switch(es): {switch_ids}") body = SwitchIds(switch_ids=switch_ids) ep = EpManageSwitchActionsDeployPost() diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index 495a7bdd..2f3e7610 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -12,7 +12,11 @@ __copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." __author__ = "L Nikhil Sri Krishna" -ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} DOCUMENTATION = r""" --- @@ -483,7 +487,7 @@ - name: switch_freeform policy_id: POLICY-28240 switch: - - serial_number: FDO29080NBU + - serial_number: FDO25031SY4 description: my freeform policy priority: 500 template_inputs: @@ -522,14 +526,18 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log -from ansible_collections.cisco.nd.plugins.module_utils.nd_policy_resources import NDPolicyModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_policy_resources import ( + NDPolicyModule, +) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( NDModule, NDModuleError, nd_argument_spec, ) from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results -from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import PlaybookPolicyConfig +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import ( + PlaybookPolicyConfig, +) # ============================================================================= @@ -612,6 +620,7 @@ def main(): except Exception as error: import traceback + tb_str = traceback.format_exc() log.error(f"Unexpected error during module execution: {str(error)}") From 81730b9806a7f7bcbf756e43dcf39279dff5bc1d Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Wed, 1 Apr 2026 16:27:24 +0530 Subject: [PATCH 57/61] fix: clean template inputs using blocklist instead of allowlist --- plugins/module_utils/nd_policy_resources.py | 510 ++++++++++---------- plugins/modules/nd_policy.py | 148 ++++-- 2 files changed, 369 insertions(+), 289 deletions(-) diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index 78bd49dd..761b9f6d 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -33,12 +33,10 @@ import copy import logging import re -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, ClassVar, Dict, List, Optional, Tuple from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( - BasePath, -) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_config_templates import ( EpManageConfigTemplateParametersGet, ) @@ -59,6 +57,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_base import ( PolicyCreate, ) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.gathered_models import ( + GatheredPolicy, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import ( + NDConfigCollection, +) from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_crud import ( PolicyCreateBulk, PolicyUpdate, @@ -72,12 +76,9 @@ NDModuleError, ) from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - ValidationError, -) -from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import ( - PlaybookPolicyConfig, -) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import PlaybookPolicyConfig + # ============================================================================= # Module-level helpers (stateless, used by NDPolicyModule) @@ -179,17 +180,15 @@ def __init__( if not self.config: if self.state != "gathered": - self.module.fail_json( + raise NDModuleError( msg=f"'config' element is mandatory for state '{self.state}'." ) # For gathered without config, initialise to empty list so # downstream code can iterate safely. self.config = [] - # Template parameter cache: {templateName: [param_dict, ...]} - # Populated lazily by _fetch_template_params() to avoid - # redundant API calls when multiple config entries share the - # same template. + # Template parameter cache used by _validate_template_inputs(). + # Keyed by templateName; populated lazily by _fetch_template_params(). self._template_params_cache: Dict[str, List[Dict]] = {} # Before/after snapshot lists — populated during _execute_* methods. @@ -418,11 +417,7 @@ def resolve_switch_identifiers(self, config): switch_value = entry.get("switch") if isinstance(switch_value, list): for switch_entry in switch_value: - val = ( - switch_entry.get("serial_number") - or switch_entry.get("ip") - or "" - ) + val = switch_entry.get("serial_number") or switch_entry.get("ip") or "" if _needs_resolution(val): needs_lookup.add(val) elif isinstance(switch_value, str) and _needs_resolution(switch_value): @@ -459,14 +454,12 @@ def _resolve(identifier): if isinstance(switch_value, list): for switch_entry in switch_value: - original = switch_entry.get("serial_number") or switch_entry.get( - "ip" - ) + original = switch_entry.get("serial_number") or switch_entry.get("ip") if not _needs_resolution(original): continue resolved = _resolve(original) if resolved is None: - self.module.fail_json( + raise NDModuleError( msg=( f"Unable to resolve switch identifier '{original}' to a serial number " f"in fabric '{self.fabric_name}'. Provide a valid switch serial_number, " @@ -481,7 +474,7 @@ def _resolve(identifier): continue resolved = _resolve(switch_value) if resolved is None: - self.module.fail_json( + raise NDModuleError( msg=( f"Unable to resolve switch identifier '{switch_value}' to a serial number " f"in fabric '{self.fabric_name}'. Provide a valid switch serial_number, " @@ -537,11 +530,11 @@ def validate_translated_config(self, translated_config): None. Raises: - Calls ``module.fail_json`` on validation failure. + NDModuleError: If any entry is missing a switch serial number. """ for idx, entry in enumerate(translated_config): if not entry.get("switch"): - self.module.fail_json( + raise NDModuleError( msg=f"config[{idx}]: every policy entry must have a switch serial number after translation." ) @@ -555,8 +548,7 @@ def validate_and_prepare_config(self) -> None: Full pipeline executed before state dispatch: 1. **Pydantic validation** — each ``config[]`` entry is validated against ``PlaybookPolicyConfig``. Also applies defaults - (priority=500, description="", etc.) since the nested arg_spec - options were removed in favour of Pydantic. + (priority=500, description="", etc.) 2. **Resolve switch identifiers** — IPs/hostnames → serial numbers via a fabric inventory API call. 3. **Translate config** — flatten the two-level (globals + switch @@ -572,27 +564,20 @@ def validate_and_prepare_config(self) -> None: self.log.info("Validating and preparing config") # Step 1: Pydantic validation + normalization - validation_context = { - "state": self.state, - "use_desc_as_key": self.use_desc_as_key, - } + validation_context = {"state": self.state, "use_desc_as_key": self.use_desc_as_key} normalized_config = [] for idx, entry in enumerate(self.config): try: - validated = PlaybookPolicyConfig.model_validate( - entry, context=validation_context - ) - normalized_config.append( - validated.model_dump(by_alias=False, exclude_none=False) - ) + validated = PlaybookPolicyConfig.model_validate(entry, context=validation_context) + normalized_config.append(validated.model_dump(by_alias=False, exclude_none=False)) except ValidationError as ve: - self.module.fail_json( + raise NDModuleError( msg=f"Input validation failed for config[{idx}]: {ve}" - ) + ) from ve except ValueError as ve: - self.module.fail_json( + raise NDModuleError( msg=f"Input validation failed for config[{idx}]: {ve}" - ) + ) from ve self.config = normalized_config self.module.params["config"] = normalized_config @@ -650,7 +635,7 @@ def manage_state(self) -> None: elif self.state == "deleted": self._handle_deleted_state() else: - self.module.fail_json(msg=f"Unsupported state: {self.state}") + raise NDModuleError(msg=f"Unsupported state: {self.state}") # ========================================================================= # Upfront Validation @@ -706,11 +691,12 @@ def _validate_config(self) -> None: if count > 1 ] if duplicates: - self.module.fail_json( + raise NDModuleError( msg=( "Duplicate description+switch combinations found in the " "playbook config (use_desc_as_key=true requires each " - "description to be unique per switch): " + "; ".join(duplicates) + "description to be unique per switch): " + + "; ".join(duplicates) ) ) @@ -748,32 +734,28 @@ def _handle_merged_state(self) -> None: + "; ".join(validation_errors) ) self.log.error(error_msg) - diff_results.append( - { - "action": "fail", - "want": want, - "have": None, - "diff": None, - "policy_id": None, - "error_msg": error_msg, - } - ) - continue - - have_list, error_msg = self._build_have(want) - - if error_msg: - self.log.error(f"Build have failed: {error_msg}") - diff_results.append( - { + diff_results.append({ "action": "fail", "want": want, "have": None, "diff": None, "policy_id": None, "error_msg": error_msg, - } - ) + }) + continue + + have_list, error_msg = self._build_have(want) + + if error_msg: + self.log.error(f"Build have failed: {error_msg}") + diff_results.append({ + "action": "fail", + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": error_msg, + }) continue # Phase 2: Compute diff @@ -840,17 +822,15 @@ def _handle_deleted_state(self) -> None: if error_msg: self.log.error(f"Build have failed: {error_msg}") - diff_results.append( - { - "action": "fail", - "want": want, - "policies": [], - "policy_ids": [], - "match_count": 0, - "warning": None, - "error_msg": error_msg, - } - ) + diff_results.append({ + "action": "fail", + "want": want, + "policies": [], + "policy_ids": [], + "match_count": 0, + "warning": None, + "error_msg": error_msg, + }) continue # Phase 2: Compute delete result @@ -935,9 +915,7 @@ def _handle_gathered_state(self) -> None: for switch_sn in switches: self.log.debug(f"Gathering policies for switch {switch_sn}") lucene = self._build_lucene_filter(switchId=switch_sn) - switch_policies = self._query_policies( - lucene, include_mark_deleted=False - ) + switch_policies = self._query_policies(lucene, include_mark_deleted=False) self.log.info( f"Found {len(switch_policies)} policies on switch {switch_sn}" ) @@ -958,25 +936,49 @@ def _handle_gathered_state(self) -> None: self.log.debug("EXIT: _handle_gathered_state()") return - # De-duplicate by policyId (multiple config entries might match - # the same underlying policy). - seen_ids: set = set() - unique_policies: List[Dict] = [] + # De-duplicate by policyId using NDConfigCollection. + # GatheredPolicy uses policyId as its single identifier, so + # adding a policy with a duplicate policyId is silently skipped. + gathered_collection = NDConfigCollection(model_class=GatheredPolicy) + skipped = 0 for pol in policies: pid = pol.get("policyId") - if pid and pid in seen_ids: + if not pid: + self.log.warning("Skipping policy without policyId in gathered results") + skipped += 1 continue - if pid: - seen_ids.add(pid) - unique_policies.append(pol) + try: + model = GatheredPolicy.from_api_policy(pol) + except Exception as exc: + self.log.warning( + f"Failed to parse policy {pid} for gathered output: {exc}" + ) + skipped += 1 + continue + # NDConfigCollection.add() raises ValueError on duplicate key; + # use get() first to skip duplicates gracefully. + if gathered_collection.get(pid) is not None: + self.log.debug(f"Gathered: skipping duplicate policy {pid}") + skipped += 1 + continue + gathered_collection.add(model) self.log.info( - f"Gathered {len(unique_policies)} unique policies (from {len(policies)} total)" + f"Gathered {len(gathered_collection)} unique policies " + f"(from {len(policies)} total, {skipped} skipped)" ) - # Convert each policy to playbook-ready config - for policy in unique_policies: - config_entry = self._policy_to_config(policy) + # Convert each policy to playbook-ready config, applying + # _clean_template_inputs to strip NDFC-injected keys. + for model in gathered_collection: + config_entry = model.to_gathered_config() + # Clean template inputs using the template parameter API + template_name = config_entry.get("name", "") + raw_inputs = config_entry.get("template_inputs") or {} + if template_name and raw_inputs: + config_entry["template_inputs"] = self._clean_template_inputs( + template_name, raw_inputs + ) self._gathered.append(config_entry) self._register_result( @@ -1046,7 +1048,6 @@ def _policy_to_config(self, policy: Dict) -> Dict: raw_inputs = policy.get("templateInputs") or policy.get("nvPairs") or {} if isinstance(raw_inputs, str): import json - try: raw_inputs = json.loads(raw_inputs) except (json.JSONDecodeError, ValueError): @@ -1071,80 +1072,57 @@ def _policy_to_config(self, policy: Dict) -> Dict: self.log.debug(f"Converted policy {policy_id} to config: {config_entry}") return config_entry + # NDFC system-injected keys present in templateInputs that are + # NOT real template parameters. Stripped from gathered output so + # the result can be fed directly into state=merged. + _SYSTEM_INJECTED_KEYS: ClassVar[frozenset] = frozenset({ + "FABRIC_NAME", + "MARK_DELETED", + "POLICY_DESC", + "POLICY_GROUP_ID", + "POLICY_ID", + "PRIORITY", + "SECENTITY", + "SECENTTYPE", + "SERIAL_NUMBER", + "SOURCE", + "SWITCH_DB_ID", + }) + def _clean_template_inputs( self, template_name: str, raw_inputs: Dict[str, Any] ) -> Dict[str, Any]: """Remove system-injected keys from template inputs. - Fetches the template's parameter definitions via the - configTemplates API and keeps **only** the keys that appear in - the template's parameter list. Any key in ``raw_inputs`` that - is not a declared template parameter (e.g. ``FABRIC_NAME``, - ``POLICY_ID``, ``SERIAL_NUMBER``) is an NDFC-injected system - key and is stripped. - - If the template parameter fetch fails, falls back to a - hardcoded set of known NDFC-injected keys and emits a warning. + Strips keys listed in ``_SYSTEM_INJECTED_KEYS`` and keeps + everything else as a real template variable. Args: - template_name: Template name for fetching parameter definitions. + template_name: Template name (for logging context). raw_inputs: Raw ``templateInputs`` dict from the controller. Returns: - Cleaned dict with only actual template parameter keys. + Cleaned dict with system-injected keys removed. """ self.log.debug( f"ENTER: _clean_template_inputs(template={template_name}, " f"keys={list(raw_inputs.keys())})" ) - params = self._fetch_template_params(template_name) - - if params: - # Build set of ALL parameter names declared in the template. - # Only keys present in this set are real template inputs; - # everything else is NDFC-injected and should be stripped. - template_param_names: set = set() - for p in params: - name = p.get("name") - if name: - template_param_names.add(name) + cleaned = {} + stripped_keys = [] + for k, v in raw_inputs.items(): + if k in self._SYSTEM_INJECTED_KEYS: + stripped_keys.append(k) + else: + cleaned[k] = v + if stripped_keys: self.log.debug( - f"Template '{template_name}': {len(template_param_names)} " - f"declared params: {sorted(template_param_names)}" + f"Stripped {len(stripped_keys)} system-injected keys: " + f"{sorted(stripped_keys)}" ) - cleaned = {k: v for k, v in raw_inputs.items() if k in template_param_names} - - stripped = set(raw_inputs.keys()) - template_param_names - if stripped: - self.log.debug( - f"Stripped {len(stripped)} non-template keys: {sorted(stripped)}" - ) - else: - # Fallback: strip commonly known NDFC-injected keys - _KNOWN_INTERNAL_KEYS = { - "FABRIC_NAME", - "POLICY_ID", - "POLICY_DESC", - "PRIORITY", - "SERIAL_NUMBER", - "SECENTITY", - "SECENTTYPE", - "SOURCE", - "MARK_DELETED", - "SWITCH_DB_ID", - "POLICY_GROUP_ID", - } - self.log.warning( - f"Could not fetch template params for '{template_name}'. " - f"Falling back to hardcoded internal keys: {sorted(_KNOWN_INTERNAL_KEYS)}" - ) - cleaned = { - k: v for k, v in raw_inputs.items() if k not in _KNOWN_INTERNAL_KEYS - } - self.log.debug( f"EXIT: _clean_template_inputs() -> {len(cleaned)} keys " f"(removed {len(raw_inputs) - len(cleaned)})" @@ -1167,15 +1145,52 @@ def _is_policy_id(name: str) -> bool: """ return name.upper().startswith("POLICY-") - @staticmethod - def _build_lucene_filter(**kwargs: Any) -> str: + # Characters that have special meaning in Lucene query syntax. + # Values containing any of these must be quoted/escaped to prevent + # query parsing errors or unintended wildcard/boolean behaviour. + _LUCENE_SPECIAL_CHARS = set(r'+-!(){}[]^"~*?:\/ &|') + + @classmethod + def _escape_lucene_value(cls, value: str) -> str: + """Escape a value for safe inclusion in a Lucene filter term. + + If the value contains any Lucene special characters or + whitespace, it is wrapped in double-quotes with internal + backslashes and double-quotes escaped. Plain alphanumeric + values are returned unmodified. + + Args: + value: Raw string value. + + Returns: + Lucene-safe string, possibly double-quoted. + """ + s = str(value) + if not s: + return s + needs_quoting = any(ch in cls._LUCENE_SPECIAL_CHARS for ch in s) + if not needs_quoting: + return s + # Escape backslashes first, then double-quotes, then wrap. + escaped = s.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + @classmethod + def _build_lucene_filter(cls, **kwargs: Any) -> str: """Build a Lucene filter string from keyword arguments. + Values containing Lucene special characters are automatically + escaped/quoted so that descriptions like ``"policy: enable"`` + do not break the query syntax. + Example:: _build_lucene_filter(switchId="FDO123", templateName="feature_enable") # Returns: "switchId:FDO123 AND templateName:feature_enable" + _build_lucene_filter(description="policy: enable (v2)") + # Returns: 'description:"policy: enable (v2)"' + Args: **kwargs: Key-value pairs to include in the Lucene filter. None values are skipped. @@ -1186,7 +1201,7 @@ def _build_lucene_filter(**kwargs: Any) -> str: parts = [] for key, value in kwargs.items(): if value is not None: - parts.append(f"{key}:{value}") + parts.append(f"{key}:{cls._escape_lucene_value(str(value))}") return " AND ".join(parts) @staticmethod @@ -1232,13 +1247,15 @@ def _policies_differ(want: Dict, have: Dict) -> Dict: have_inputs = have.get("templateInputs") or {} input_diff = {} for key in want_inputs: - want_val = str(want_inputs[key]) - have_val = str(have_inputs.get(key, "")) + # Normalize both sides to lowercase strings to handle: + # - Python bool True → "True" vs NDFC string "true" + # - Python int 100 → "100" vs NDFC string "100" + # Also strip trailing whitespace/newlines to avoid false + # diffs from multiline template inputs (e.g., CONF blocks). + want_val = str(want_inputs[key]).strip().lower() + have_val = str(have_inputs.get(key, "")).strip().lower() if want_val != have_val: - input_diff[key] = { - "want": want_inputs[key], - "have": have_inputs.get(key), - } + input_diff[key] = {"want": want_inputs[key], "have": have_inputs.get(key)} if input_diff: diff["templateInputs"] = input_diff @@ -1248,7 +1265,9 @@ def _policies_differ(want: Dict, have: Dict) -> Dict: # API Query Helpers # ========================================================================= - def _query_policies_raw(self, lucene_filter: Optional[str] = None) -> List[Dict]: + def _query_policies_raw( + self, lucene_filter: Optional[str] = None + ) -> List[Dict]: """Query policies from the controller using GET /policies (unfiltered). Returns **all** matching policies including ``markDeleted`` and @@ -1417,9 +1436,7 @@ def _build_want(self, config_entry: Dict, state: str = "merged") -> Dict: Returns: Dict with camelCase keys matching the API schema. """ - self.log.debug( - f"Building want for state={state}, name={config_entry.get('name')}" - ) + self.log.debug(f"Building want for state={state}, name={config_entry.get('name')}") want = { "switchId": config_entry["switch"], @@ -1433,9 +1450,7 @@ def _build_want(self, config_entry: Dict, state: str = "merged") -> Dict: want["templateName"] = name # Per-entry create_additional_policy flag (carried on want dict) - want["create_additional_policy"] = config_entry.get( - "create_additional_policy", True - ) + want["create_additional_policy"] = config_entry.get("create_additional_policy", True) # For merged state, include all payload fields if state == "merged": @@ -1717,9 +1732,7 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # Case A: Policy ID given directly if "policyId" in want: self.log.debug(f"Case A: Direct policy ID lookup: {want['policyId']}") - policy = self._query_policy_by_id( - want["policyId"], include_mark_deleted=incl_md - ) + policy = self._query_policy_by_id(want["policyId"], include_mark_deleted=incl_md) if policy: self.log.info(f"Policy {want['policyId']} found") return [policy], None @@ -1731,9 +1744,7 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: self.log.debug(f"Case D: Switch-only lookup for {want['switchId']}") lucene = self._build_lucene_filter(switchId=want["switchId"]) policies = self._query_policies(lucene, include_mark_deleted=incl_md) - self.log.info( - f"Found {len(policies)} policies on switch {want['switchId']}" - ) + self.log.info(f"Found {len(policies)} policies on switch {want['switchId']}") return policies, None # Case B: use_desc_as_key=false, search by switchId + templateName @@ -1753,7 +1764,8 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: if want_desc: pre_filter_count = len(policies) policies = [ - p for p in policies if (p.get("description", "") or "") == want_desc + p for p in policies + if (p.get("description", "") or "") == want_desc ] self.log.debug( f"Post-filtered by description: {len(policies)} of {pre_filter_count}" @@ -1773,10 +1785,7 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # Pydantic intentionally skips the check. if not want_desc: self.log.warning("Case C: description is required but not provided") - return ( - [], - "description is required when use_desc_as_key=true and name is a template name", - ) + return [], "description is required when use_desc_as_key=true and name is a template name" lucene = self._build_lucene_filter( switchId=want["switchId"], @@ -1787,7 +1796,8 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # IMPORTANT: Lucene does tokenized matching, not exact match. # Post-filter to ensure exact description match. exact_matches = [ - p for p in policies if (p.get("description", "") or "") == want_desc + p for p in policies + if (p.get("description", "") or "") == want_desc ] self.log.debug( f"Exact description match: {len(exact_matches)} of {len(policies)}" @@ -1952,7 +1962,7 @@ def _get_diff_merged_single(self, want: Dict, have_list: List[Dict]) -> Dict: # Case 16: Multiple matches → hard FAIL (ambiguous) # Abort the entire task atomically — no partial changes. - self.module.fail_json( + raise NDModuleError( msg=( f"Multiple policies ({match_count}) found with description " f"'{want.get('description')}' on switch {want.get('switchId')}. " @@ -2092,12 +2102,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: success=True, found=True, diff={ - "action": "update", - "before": have, - "after": {**have, **want}, - "want": want, - "have": have, - "diff": diff_entry["diff"], + "action": "update", "before": have, + "after": {**have, **want}, "want": want, + "have": have, "diff": diff_entry["diff"], "policy_id": diff_entry["policy_id"], }, ) @@ -2115,11 +2122,8 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: success=True, found=True, diff={ - "action": "delete_and_create", - "before": have, - "after": want, - "want": want, - "have": have, + "action": "delete_and_create", "before": have, + "after": want, "want": want, "have": have, "diff": diff_entry["diff"], "delete_policy_id": diff_entry["policy_id"], }, @@ -2238,18 +2242,18 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self._api_delete_policy(pid) direct_deleted.append(pid) except Exception: # noqa: BLE001 - self.log.error(f"Direct DELETE also failed for {pid}") + self.log.error( + f"Direct DELETE also failed for {pid}" + ) remove_failed_ids.add(pid) # Deploy to affected switches to push config removal if direct_deleted and self.deploy: - affected_switches = list( - { - dac_switch_map[pid] - for pid in direct_deleted - if pid in dac_switch_map - } - ) + affected_switches = list({ + dac_switch_map[pid] + for pid in direct_deleted + if pid in dac_switch_map + }) if affected_switches: self.log.info( f"Phase 3b: switchActions/deploy for " @@ -2362,13 +2366,16 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: want_list = [d["want"] for d in batch_entries] self.log.info( - f"Bulk creating {len(want_list)} policies " f"(batch={batch_label})" + f"Bulk creating {len(want_list)} policies " + f"(batch={batch_label})" ) try: created_ids = self._api_bulk_create_policies(want_list) except NDModuleError as bulk_err: - self.log.error(f"Bulk {batch_label} failed entirely: {bulk_err.msg}") + self.log.error( + f"Bulk {batch_label} failed entirely: {bulk_err.msg}" + ) for diff_entry in batch_entries: want = diff_entry["want"] action_label = ( @@ -2403,12 +2410,8 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: is_replace = diff_entry["action"] == "delete_and_create" entry_result = ( - created_ids[idx] - if idx < len(created_ids) - else { - "policy_id": None, - "ndfc_error": "No response entry from NDFC", - } + created_ids[idx] if idx < len(created_ids) + else {"policy_id": None, "ndfc_error": "No response entry from NDFC"} ) created_id = entry_result["policy_id"] ndfc_error = entry_result["ndfc_error"] @@ -2427,7 +2430,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: self._before.append(have) if per_policy_error: - action_label = "policy_replace" if is_replace else "policy_create" + action_label = ( + "policy_replace" if is_replace else "policy_create" + ) self._register_result( action=action_label, operation_type=OperationType.CREATE, @@ -2458,9 +2463,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: "action": "delete_and_create", "before": have, "after": {**want, "policyId": created_id}, - "want": want, - "have": have, - "diff": field_diff, + "want": want, "have": have, "diff": field_diff, "deleted_policy_id": diff_entry["policy_id"], "created_policy_id": created_id, }, @@ -2477,8 +2480,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: "action": "create", "before": None, "after": {**want, "policyId": created_id}, - "want": want, - "diff": field_diff, + "want": want, "diff": field_diff, "created_policy_id": created_id, }, ) @@ -2496,7 +2498,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: try: self._api_update_policy(want, have, policy_id) except NDModuleError as update_err: - self.log.error(f"Update failed for {policy_id}: {update_err.msg}") + self.log.error( + f"Update failed for {policy_id}: {update_err.msg}" + ) self._register_result( action="policy_update", operation_type=OperationType.UPDATE, @@ -2507,9 +2511,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: found=True, diff={ "action": "update_failed", - "want": want, - "have": have, - "diff": field_diff, + "want": want, "have": have, "diff": field_diff, "policy_id": policy_id, "error": update_err.msg, }, @@ -2530,18 +2532,13 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: found=True, diff={ "action": "update", - "before": have, - "after": after_merged, - "want": want, - "have": have, - "diff": field_diff, + "before": have, "after": after_merged, + "want": want, "have": have, "diff": field_diff, "policy_id": policy_id, }, ) - self.log.info( - f"Merged execute complete: {len(policy_ids_to_deploy)} policies to deploy" - ) + self.log.info(f"Merged execute complete: {len(policy_ids_to_deploy)} policies to deploy") self.log.debug("EXIT: _execute_merged()") return policy_ids_to_deploy @@ -2616,7 +2613,7 @@ def _get_diff_deleted_single(self, want: Dict, have_list: List[Dict]) -> Dict: # D-12: Multiple matches → hard FAIL (ambiguous) # Abort the entire task atomically — do not silently delete # multiple policies when descriptions should be unique. - self.module.fail_json( + raise NDModuleError( msg=( f"Multiple policies ({match_count}) found with description " f"'{want_desc}' on switch {want.get('switchId')}. " @@ -2711,12 +2708,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: message="Policy not found — already absent", success=True, found=False, - diff={ - "action": action, - "want": want, - "before": None, - "after": None, - }, + diff={"action": action, "want": want, "before": None, "after": None}, ) continue @@ -2746,9 +2738,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: all_switch_ids.append(sw) if self.check_mode: - self.log.info( - f"Check mode: would delete {len(policy_ids)} policy(ies)" - ) + self.log.info(f"Check mode: would delete {len(policy_ids)} policy(ies)") diff_payload = { "action": action, "want": want, @@ -2876,7 +2866,8 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: else: # No structured response — assume all succeeded (pre-existing behavior) self.log.warning( - "markDelete returned non-dict response — " "treating all as succeeded" + "markDelete returned non-dict response — " + "treating all as succeeded" ) mark_succeeded = list(unique_policy_ids) @@ -2937,9 +2928,10 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: failed_direct.append(pid) if deleted_direct: - tpl_names = list( - {policy_template_map.get(pid, "unknown") for pid in deleted_direct} - ) + tpl_names = list({ + policy_template_map.get(pid, "unknown") + for pid in deleted_direct + }) self._register_result( action="policy_direct_delete", state="deleted", @@ -2981,13 +2973,11 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # to the devices. Direct DELETE removes the policy record but # the device still has the running config until we deploy. if deleted_direct and self.deploy: - affected_switches = list( - { - policy_switch_map[pid] - for pid in deleted_direct - if pid in policy_switch_map - } - ) + affected_switches = list({ + policy_switch_map[pid] + for pid in deleted_direct + if pid in policy_switch_map + }) if affected_switches: self.log.info( f"Deploying config to {len(affected_switches)} switch(es) " @@ -3016,7 +3006,9 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: if isinstance(deploy_data, dict) and deploy_data: status_str = deploy_data.get("status", "") if status_str: - self.log.info(f"switchActions/deploy status: {status_str}") + self.log.info( + f"switchActions/deploy status: {status_str}" + ) else: self.log.warning( "switchActions/deploy returned non-empty body " @@ -3070,8 +3062,12 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Step 2 (deploy=true only): pushConfig deploy_success = True if self.deploy: - self.log.info(f"Step 2/3: pushConfig for {len(normal_delete_ids)} policies") - deploy_success = self._deploy_policies(normal_delete_ids, state="deleted") + self.log.info( + f"Step 2/3: pushConfig for {len(normal_delete_ids)} policies" + ) + deploy_success = self._deploy_policies( + normal_delete_ids, state="deleted" + ) # deploy=false: stop after markDelete if not self.deploy: @@ -3110,7 +3106,9 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: return # Step 3: remove — hard-delete policy records from NDFC - self.log.info(f"Step 3/3: remove {len(normal_delete_ids)} policies") + self.log.info( + f"Step 3/3: remove {len(normal_delete_ids)} policies" + ) remove_data = self._api_remove_policies(normal_delete_ids) # Inspect 207 response for per-policy failures @@ -3131,7 +3129,8 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: for p in rm_fail ] self.log.error( - f"remove failed for {len(rm_fail)} policy(ies): " + "; ".join(fail_msgs) + f"remove failed for {len(rm_fail)} policy(ies): " + + "; ".join(fail_msgs) ) self._register_result( @@ -3153,7 +3152,9 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: "action": "remove", "policy_ids": normal_delete_ids, "remove_success": remove_success, - "failed_policies": [p.get("policyId") for p in rm_fail], + "failed_policies": [ + p.get("policyId") for p in rm_fail + ], }, ) @@ -3231,10 +3232,7 @@ def _deploy_policies( deploy_success = len(failed_policies) == 0 if failed_policies: - failed_msgs = [ - f"{p.get('policyId', '?')}: {p.get('message', 'unknown error')}" - for p in failed_policies - ] + failed_msgs = [f"{p.get('policyId', '?')}: {p.get('message', 'unknown error')}" for p in failed_policies] self.log.error( f"pushConfig failed for {len(failed_policies)} policy(ies): " + "; ".join(failed_msgs) @@ -3404,9 +3402,7 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: results.append({"policy_id": pid, "ndfc_error": None}) else: self.log.warning(f"Bulk create: no response entry for policy {idx}") - results.append( - {"policy_id": None, "ndfc_error": "No response entry from NDFC"} - ) + results.append({"policy_id": None, "ndfc_error": "No response entry from NDFC"}) self.log.info( f"Bulk create complete: " @@ -3563,7 +3559,9 @@ def _api_deploy_switches(self, switch_ids: List[str]) -> dict: Response DATA dict from NDFC. Typically contains a ``status`` field like ``"Configuration deployment completed for [...]"``. """ - self.log.info(f"Deploying config to {len(switch_ids)} switch(es): {switch_ids}") + self.log.info( + f"Deploying config to {len(switch_ids)} switch(es): {switch_ids}" + ) body = SwitchIds(switch_ids=switch_ids) ep = EpManageSwitchActionsDeployPost() diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index 2f3e7610..447ec029 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -39,10 +39,12 @@ unambiguous policy matching. To manage policies with non-unique descriptions, use O(use_desc_as_key=false) and reference policies by policy ID. - Policies and switches are specified separately in the O(config) list. Global policies - apply to all switches listed in the C(switch) entry. Per-switch policy overrides can - be specified using the C(policies) suboption inside each switch entry (only when - O(use_desc_as_key=false)). Per-switch policies override global policies with the - same template name for that switch. + (entries without a C(switch) key) apply to every switch listed in the C(switch) entry. + Per-switch policy overrides can be specified using the C(policies) suboption inside each + switch entry (only when O(use_desc_as_key=false)). A per-switch override whose template + name matches a global policy B(replaces) that global for the switch. Per-switch entries + with template names that do not match any global are treated as B(additional) policies for + that switch. - B(Update behavior) — when O(use_desc_as_key=false) and a template name is given, existing policies are never updated in-place. A new policy is always created. To update a specific policy, provide its policy ID (C(POLICY-xxxxx)) as the O(config[].name). @@ -65,11 +67,12 @@ fabric switches are exported. When provided, only matching policies are exported. - Policy entries define the template, description, priority, and template inputs. - A separate C(switch) entry lists the target switches and optional per-switch policy overrides. - - All global policies (entries without C(switch) key) are applied to every switch listed - in the C(switch) entry. Per-switch policies (specified under C(switch[].policies)) - override global policies with the same template name for that particular switch - (only when O(use_desc_as_key=false); when O(use_desc_as_key=true) per-switch - policies are simply merged with global policies). + - All global policies (entries without a C(switch) key) are applied to every switch listed + in the C(switch) entry. When O(use_desc_as_key=false), a per-switch policy whose + template name matches a global policy B(replaces) that global for the particular switch; + per-switch entries with different template names are B(added) alongside the globals. + When O(use_desc_as_key=true), per-switch policies are simply merged with global + policies (no replacement by name). type: list elements: dict suboptions: @@ -103,7 +106,9 @@ - A flag indicating if a policy is to be created even if an identical policy already exists. - When set to V(true), a new duplicate policy is created regardless of whether a matching one exists. - When set to V(false), duplicate creation is skipped if an identical policy already exists. - - Only relevant when O(use_desc_as_key=false) and O(config[].name) is a template name. + - Most relevant when O(use_desc_as_key=false) and O(config[].name) is a template name. + Also applies when O(config[].name) is a policy ID — if V(true) and no diff exists, + the module creates a new copy of the policy (with a new ID) instead of skipping. type: bool default: true template_inputs: @@ -115,9 +120,14 @@ switch: description: - A list of switches and optional per-switch policy overrides. - - All switches in this list will be deployed with the global policies defined - at the top level of O(config). Per-switch policy overrides can be specified - using the C(policies) suboption. + - Every switch in this list receives all global policies defined at the top level + of O(config). If a switch also has a C(policies) suboption, those per-switch + entries interact with the globals as follows (when O(use_desc_as_key=false)). + - A per-switch policy whose template name B(matches) a global policy B(replaces) + that global for the switch (e.g., to change priority or template inputs). + - A per-switch policy whose template name does B(not) match any global is + B(added) as an extra policy for the switch alongside the globals. + - If a switch has B(no) C(policies) suboption, it receives all globals unchanged. type: list elements: dict suboptions: @@ -132,10 +142,12 @@ aliases: [ ip ] policies: description: - - A list of policies specific to this switch that override global policies - with the same template name (when O(use_desc_as_key=false)). + - A list of per-switch policies. When O(use_desc_as_key=false), any entry + whose template name matches a global policy B(replaces) that global for + this switch. Entries with template names not found in the globals are + B(added) as extra policies for this switch. - When O(use_desc_as_key=true), per-switch policies are simply merged with - global policies rather than overriding by template name. + global policies (no name-based replacement). type: list elements: dict default: [] @@ -184,9 +196,12 @@ - For C(deleted) with O(deploy=false), only C(markDelete) is performed on the controller. Policy records remain marked for deletion (with negative priority) until a subsequent run with O(deploy=true) or manual intervention. - - B(Exception) — C(switch_freeform) policies are always deleted via a direct C(DELETE) - API call regardless of the O(deploy) setting, because the C(markDelete) operation - is not supported for this template. + - B(Exception) — C(switch_freeform) and other PYTHON content-type policies do not + support the C(markDelete) API. The module automatically detects this and falls back + to a direct C(DELETE) API call to remove the policy record from the controller. + When O(deploy=true), a C(switchActions/deploy) is also performed to push the + config removal to the switch. When O(deploy=false), the policy record is removed + from the controller but the running config remains on the switch until the next deploy. type: bool default: true ticket_id: @@ -207,8 +222,12 @@ - For C(deleted) with O(deploy=false), only C(markDelete) is performed on the controller. Policy records remain marked for deletion (with negative priority) until a subsequent run with O(deploy=true) or manual intervention. - - B(Exception) — C(switch_freeform) policies skip the C(markDelete) flow entirely - and are removed via a direct C(DELETE) API call regardless of the O(deploy) setting. + - B(Exception) — C(switch_freeform) and other PYTHON content-type policies cannot + be markDeleted. The module attempts C(markDelete), detects the failure, and + automatically falls back to a direct C(DELETE) API call. When O(deploy=true), + a C(switchActions/deploy) is performed afterward to push config removal to + the switch. When O(deploy=false), the policy record is removed from the + controller but the running config remains on the switch. - Use C(gathered) to export existing policies as playbook-compatible config. When O(config) is provided, only matching policies are exported. When O(config) is omitted, all policies on all fabric switches are exported. @@ -231,16 +250,64 @@ - When O(use_desc_as_key=true), the description uniquely identifies the policy per switch, so in-place updates B(are) supported. If the template name changes, the old policy is deleted and a new one is created. -- C(switch_freeform) policies do not support the C(markDelete) API. They are always - removed via a direct C(DELETE) API call, regardless of the O(deploy) setting. +- C(switch_freeform) and other PYTHON content-type policies do not support the + C(markDelete) API. The module automatically detects this and falls back to a + direct C(DELETE) API call. When O(deploy=true), C(switchActions/deploy) is + performed to push config removal to the switch. When O(deploy=false), only the + policy record is removed from the controller. """ EXAMPLES = r""" -# NOTE: In the following create task, policies template_101, template_102, and template_103 -# are deployed on switch2, whereas policies template_104 and template_105 are the only -# policies installed on switch1 (per-switch override). +# EXAMPLE 1 — Per-switch extra policies (no name overlap with globals) +# +# NOTE: Three global policies (template_101, template_102, template_103) are defined. +# switch1 also has per-switch policies template_104 and template_105. Since those +# names do not match any global, they are ADDED alongside the globals. +# +# Result: +# switch1: template_101, template_102, template_103, template_104, template_105 +# switch2: template_101, template_102, template_103 + +- name: Create policies with per-switch extras + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: merged + deploy: true + config: + - name: template_101 # This must be a valid template name + create_additional_policy: false # Do not create a policy if it already exists + priority: 101 -- name: Create different policies with per-switch overrides + - name: template_102 # This must be a valid template name + create_additional_policy: false # Do not create a policy if it already exists + description: "102 - No priority given" + + - name: template_103 # This must be a valid template name + create_additional_policy: false # Do not create a policy if it already exists + description: "Both description and priority given" + priority: 500 + + - switch: + - serial_number: "{{ switch1 }}" + policies: + - name: template_104 # Different name → added alongside globals + create_additional_policy: false + - name: template_105 # Different name → added alongside globals + create_additional_policy: false + - serial_number: "{{ switch2 }}" + +# EXAMPLE 2 — Per-switch override (same template name replaces the global) +# +# NOTE: Three global policies (template_101, template_102, template_103) are defined. +# switch1 overrides template_101 with a different priority and adds template_104. +# Since template_101 matches a global, the global version is REPLACED for switch1. +# template_104 does not match any global, so it is ADDED. +# +# Result: +# switch1: template_101 (priority 999), template_102, template_103, template_104 +# switch2: template_101 (priority 101), template_102, template_103 + +- name: Create policies with per-switch override cisco.nd.nd_policy: fabric_name: "{{ fabric_name }}" state: merged @@ -262,9 +329,10 @@ - switch: - serial_number: "{{ switch1 }}" policies: - - name: template_104 + - name: template_101 # Same name as global → REPLACES it for switch1 create_additional_policy: false - - name: template_105 + priority: 999 + - name: template_104 # Different name → ADDED alongside globals create_additional_policy: false - serial_number: "{{ switch2 }}" @@ -399,10 +467,13 @@ - switch: - serial_number: "{{ switch1 }}" -# NOTE: switch_freeform policies are always directly deleted -# regardless of the deploy setting. +# NOTE: switch_freeform policies use a direct DELETE fallback since +# markDelete is not supported for PYTHON content-type templates. +# When deploy=true, switchActions/deploy pushes config removal +# to the switch. When deploy=false, only the policy record is +# removed from the controller. -- name: Delete switch_freeform policies (direct DELETE) +- name: Delete switch_freeform policies (direct DELETE fallback) cisco.nd.nd_policy: fabric_name: "{{ fabric_name }}" state: deleted @@ -443,6 +514,12 @@ fabric_name: "{{ target_fabric }}" state: merged config: "{{ all_policies.gathered }}" + +- name: Use gathered output to delete those exact policies by policy ID + cisco.nd.nd_policy: + fabric_name: "{{ fabric_name }}" + state: deleted + config: "{{ all_policies.gathered }}" """ RETURN = r""" @@ -478,7 +555,12 @@ - Each entry contains C(name) (template name), C(policy_id), C(switch), C(description), C(priority), C(template_inputs), and C(create_additional_policy). - Internal template input keys (e.g., C(FABRIC_NAME), C(POLICY_ID)) are - stripped so the output can be used directly as O(config) in a C(merged) task. + stripped so the output can be used directly as O(config) in a C(merged) + or C(deleted) task. When used with C(merged), the C(policy_id) field is + promoted to O(config[].name) so the existing policy is updated in-place. + When used with C(deleted), the same promotion deletes the exact policies + by ID. To create fresh copies instead of updating, remove the C(policy_id) + lines from the gathered output before feeding it back. - Only returned when O(state=gathered). returned: when state is gathered type: list From da019d5d28dfc1baf71822ec3f2ca263e0ac3330 Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Wed, 1 Apr 2026 19:03:59 +0530 Subject: [PATCH 58/61] Clean up comments: rename endpoint files, remove NDFC/manage.json references --- .../v1/manage/manage_config_templates.py | 2 +- ...policies.py => manage_fabrics_policies.py} | 4 +- ...ns.py => manage_fabrics_policy_actions.py} | 6 +- ...ns.py => manage_fabrics_switch_actions.py} | 2 +- .../models/manage_policies/config_models.py | 4 +- .../models/manage_policies/enums.py | 2 +- .../models/manage_policies/gathered_models.py | 10 +-- .../models/manage_policies/policy_actions.py | 12 +-- .../models/manage_policies/policy_base.py | 4 +- .../models/manage_policies/policy_crud.py | 6 +- plugins/module_utils/nd_policy_resources.py | 90 +++++++++---------- plugins/modules/nd_policy.py | 16 ++-- ...dpoints_api_v1_manage_fabrics_policies.py} | 2 +- ...s_api_v1_manage_fabrics_policy_actions.py} | 4 +- 14 files changed, 83 insertions(+), 81 deletions(-) rename plugins/module_utils/endpoints/v1/manage/{manage_policies.py => manage_fabrics_policies.py} (98%) rename plugins/module_utils/endpoints/v1/manage/{manage_policy_actions.py => manage_fabrics_policy_actions.py} (97%) rename plugins/module_utils/endpoints/v1/manage/{manage_switch_actions.py => manage_fabrics_switch_actions.py} (98%) rename tests/unit/module_utils/endpoints/{test_endpoints_api_v1_manage_policies.py => test_endpoints_api_v1_manage_fabrics_policies.py} (99%) rename tests/unit/module_utils/endpoints/{test_endpoints_api_v1_manage_policy_actions.py => test_endpoints_api_v1_manage_fabrics_policy_actions.py} (98%) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py index ecdd390a..99381c03 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py @@ -52,7 +52,7 @@ class ConfigTemplateEndpointParams(EndpointQueryParams): ## Description - Per manage.json, the GET /configTemplates/{templateName}/parameters + Per the ND API specification, the GET /configTemplates/{templateName}/parameters endpoint accepts ``clusterName`` as a query parameter. ## Parameters diff --git a/plugins/module_utils/endpoints/v1/manage/manage_policies.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py similarity index 98% rename from plugins/module_utils/endpoints/v1/manage/manage_policies.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py index 853678b4..7031181a 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_policies.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py @@ -63,7 +63,7 @@ class PoliciesGetEndpointParams(EndpointQueryParams): ## Description - Per manage.json, the GET /policies endpoint accepts only ``clusterName`` + Per the ND API specification, the GET /policies endpoint accepts only ``clusterName`` as a named query parameter. Lucene filtering (filter, max, offset, sort) is handled separately via ``LuceneQueryParams``. @@ -89,7 +89,7 @@ class PolicyMutationEndpointParams(EndpointQueryParams): ## Description - Per manage.json, the following mutation endpoints accept + Per the ND API specification, the following mutation endpoints accept ``clusterName`` and ``ticketId``: - POST /policies diff --git a/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py similarity index 97% rename from plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py index e2b81bfa..344a5b4c 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_policy_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py @@ -57,7 +57,7 @@ class PolicyActionMutationEndpointParams(EndpointQueryParams): ## Description - Per manage.json, the following policy action endpoints accept + Per the ND API specification, the following policy action endpoints accept ``clusterName`` and ``ticketId``: - POST /policyActions/markDelete @@ -93,7 +93,7 @@ class PolicyPushConfigEndpointParams(EndpointQueryParams): ## Description - Per manage.json, ``POST /policyActions/pushConfig`` accepts only + Per the ND API specification, ``POST /policyActions/pushConfig`` accepts only ``clusterName`` (no ``ticketId``). ## Parameters @@ -230,7 +230,7 @@ class EpManagePolicyActionsPushConfigPost(_EpManagePolicyActionsBase): ## Note - pushConfig does NOT accept ``ticketId`` per manage.json spec. + pushConfig does NOT accept ``ticketId`` per the ND API specification. ## Request Body Example diff --git a/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py similarity index 98% rename from plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py index 5f783287..1640a94c 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py @@ -55,7 +55,7 @@ class SwitchDeployEndpointParams(EndpointQueryParams): ## Description - Per manage.json, ``POST /fabrics/{fabricName}/switchActions/deploy`` + Per the ND API specification, ``POST /fabrics/{fabricName}/switchActions/deploy`` accepts ``forceShowRun`` and ``clusterName`` as optional query params. ## Parameters diff --git a/plugins/module_utils/models/manage_policies/config_models.py b/plugins/module_utils/models/manage_policies/config_models.py index 67200fe5..bb92d21e 100644 --- a/plugins/module_utils/models/manage_policies/config_models.py +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -7,10 +7,10 @@ """Pydantic models for validating Ansible playbook input (user-facing config). These models validate user input **before** any API calls or config translation. -They enforce constraints from the OpenAPI spec (manage.json ``createBasePolicy`` +They enforce constraints from the ND API specification (``createBasePolicy`` schema) at the playbook boundary so errors are caught early with clear messages. -Schema constraints (source: manage.json createBasePolicy): +Schema constraints (source: ND API specification, createBasePolicy): - priority: integer, min=1, max=2000, default=500 - description: string, maxLength=255 - templateName: string, maxLength=255 diff --git a/plugins/module_utils/models/manage_policies/enums.py b/plugins/module_utils/models/manage_policies/enums.py index d0794ff0..163ebb98 100644 --- a/plugins/module_utils/models/manage_policies/enums.py +++ b/plugins/module_utils/models/manage_policies/enums.py @@ -6,7 +6,7 @@ """Enumerations for Policy Operations. -Extracted from OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +Extracted from the ND API specification for Nexus Dashboard Manage APIs. """ from __future__ import absolute_import, division, print_function diff --git a/plugins/module_utils/models/manage_policies/gathered_models.py b/plugins/module_utils/models/manage_policies/gathered_models.py index c32caadc..ad6f89e5 100644 --- a/plugins/module_utils/models/manage_policies/gathered_models.py +++ b/plugins/module_utils/models/manage_policies/gathered_models.py @@ -7,7 +7,7 @@ """Read-model for ``state=gathered`` output. ``GatheredPolicy`` is a lightweight model that represents a policy as -returned by the NDFC API, keyed by ``policyId``. It is used exclusively +returned by the ND API, keyed by ``policyId``. It is used exclusively by ``_handle_gathered_state()`` for: - Deserialising raw API response dicts via ``from_response()`` @@ -44,11 +44,11 @@ class GatheredPolicy(NDBaseModel): - """Read-model for a policy returned by the NDFC API. + """Read-model for a policy returned by the ND API. Keyed by ``policy_id`` for ``NDConfigCollection`` dedup. - Fields mirror the NDFC policy response keys (camelCase aliases) + Fields mirror the ND policy response keys (camelCase aliases) that are needed for gathered output. Extra API response keys (``generatedConfig``, ``markDeleted``, ``createTimestamp``, etc.) are silently dropped by ``model_config.extra = "ignore"`` inherited @@ -122,7 +122,7 @@ class GatheredPolicy(NDBaseModel): @classmethod def from_api_policy(cls, policy: Dict[str, Any]) -> "GatheredPolicy": - """Create a GatheredPolicy from a raw NDFC API policy dict. + """Create a GatheredPolicy from a raw ND API policy dict. Handles the ``templateInputs`` field which may be a JSON-encoded string in the API response. Parses it into a dict before model @@ -132,7 +132,7 @@ def from_api_policy(cls, policy: Dict[str, Any]) -> "GatheredPolicy": instead of ``templateInputs``. Args: - policy: Raw policy dict from the NDFC API. + policy: Raw policy dict from the ND API. Returns: A validated ``GatheredPolicy`` instance. diff --git a/plugins/module_utils/models/manage_policies/policy_actions.py b/plugins/module_utils/models/manage_policies/policy_actions.py index f934792b..7663b979 100644 --- a/plugins/module_utils/models/manage_policies/policy_actions.py +++ b/plugins/module_utils/models/manage_policies/policy_actions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Cisco Systems +# Copyright: (c) 2026, L Nikhil Sri Krishna (@nisaikri) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -14,9 +14,9 @@ - POST /api/v1/manage/fabrics/{fabricName}/policyActions/pushConfig - POST /api/v1/manage/fabrics/{fabricName}/policyActions/remove -## Schema origin (manage.json) +## Schema origin -- ``PolicyIds`` ← ``policyActions`` schema +- ``PolicyIds`` ← ``policyActions`` request body schema per ND API specification """ from __future__ import absolute_import, annotations, division, print_function @@ -25,6 +25,8 @@ __metaclass__ = type # pylint: enable=invalid-name +__author__ = "L Nikhil Sri Krishna" + from typing import Any, ClassVar, Dict, List from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( @@ -43,7 +45,7 @@ class SwitchIds(NDNestedModel): Used for ``POST /fabrics/{fabricName}/switchActions/deploy``. Contains a list of switch serial numbers to deploy config to. - ## Request Body Schema (from manage.json) + ## Request Body Schema ```json { @@ -100,7 +102,7 @@ class PolicyIds(NDNestedModel): - POST /api/v1/manage/fabrics/{fabricName}/policyActions/pushConfig - POST /api/v1/manage/fabrics/{fabricName}/policyActions/remove - ## Request Body Schema (from manage.json) + ## Request Body Schema ```json { diff --git a/plugins/module_utils/models/manage_policies/policy_base.py b/plugins/module_utils/models/manage_policies/policy_base.py index 5c3c049c..fe31c2cd 100644 --- a/plugins/module_utils/models/manage_policies/policy_base.py +++ b/plugins/module_utils/models/manage_policies/policy_base.py @@ -13,7 +13,7 @@ The ``PolicyEntityType`` enum is in ``enums.py``. -## Schema origin (manage.json) +## Schema origin - ``PolicyCreate`` ← ``createPolicy`` (extends ``createBasePolicy``) """ @@ -47,7 +47,7 @@ class PolicyCreate(NDBaseModel): ## Description - Based on ``createPolicy`` schema from manage.json which extends + Based on ``createPolicy`` schema from the ND API specification which extends ``createBasePolicy``. ## API Endpoint diff --git a/plugins/module_utils/models/manage_policies/policy_crud.py b/plugins/module_utils/models/manage_policies/policy_crud.py index 2de3fdd4..46c40748 100644 --- a/plugins/module_utils/models/manage_policies/policy_crud.py +++ b/plugins/module_utils/models/manage_policies/policy_crud.py @@ -11,7 +11,7 @@ ``PolicyUpdate`` (update a single policy). Both depend on the base ``PolicyCreate`` model defined in ``policy_base``. -## Schema origin (manage.json) +## Schema origin - ``PolicyCreateBulk`` ← wraps a list of ``createPolicy`` - ``PolicyUpdate`` ← ``policyPut`` (identical to ``createPolicy``) @@ -113,7 +113,7 @@ class PolicyUpdate(PolicyCreate): ## Description - Based on ``policyPut`` schema from manage.json which extends ``createPolicy``. + Based on ``policyPut`` schema from the ND API specification which extends ``createPolicy``. Inherits all fields from ``PolicyCreate``. ## API Endpoint @@ -144,4 +144,4 @@ class PolicyUpdate(PolicyCreate): """ # All fields inherited from PolicyCreate - # policyPut schema is identical to createPolicy per manage.json + # policyPut schema is identical to createPolicy per the ND API specification diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index 761b9f6d..8cd31cb6 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -6,7 +6,7 @@ """ ND Policy Resource Module. -Provides all business logic for switch policy management on NDFC 4.x: +Provides all business logic for switch policy management on ND: - Policy CRUD (create, read, update, delete) - Idempotency diff calculation for merged, deleted states - Deploy (pushConfig) orchestration @@ -40,18 +40,18 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_config_templates import ( EpManageConfigTemplateParametersGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_policies import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_policies import ( EpManagePoliciesDelete, EpManagePoliciesGet, EpManagePoliciesPost, EpManagePoliciesPut, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_policy_actions import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_policy_actions import ( EpManagePolicyActionsMarkDeletePost, EpManagePolicyActionsPushConfigPost, EpManagePolicyActionsRemovePost, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_switch_actions import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switch_actions import ( EpManageSwitchActionsDeployPost, ) from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.policy_base import ( @@ -805,7 +805,7 @@ def _handle_merged_state(self) -> None: self.log.debug("EXIT: _handle_merged_state()") def _handle_deleted_state(self) -> None: - """Handle state=deleted: remove policies from NDFC. + """Handle state=deleted: remove policies from ND. Returns: None. @@ -969,7 +969,7 @@ def _handle_gathered_state(self) -> None: ) # Convert each policy to playbook-ready config, applying - # _clean_template_inputs to strip NDFC-injected keys. + # _clean_template_inputs to strip ND-injected keys. for model in gathered_collection: config_entry = model.to_gathered_config() # Clean template inputs using the template parameter API @@ -1032,7 +1032,7 @@ def _policy_to_config(self, policy: Dict) -> Dict: ``SERIAL_NUMBER``) are stripped via ``_clean_template_inputs()``. Args: - policy: Raw policy dict from the NDFC API. + policy: Raw policy dict from the ND API. Returns: Dict with keys: name, policy_id, switch, description, priority, @@ -1072,7 +1072,7 @@ def _policy_to_config(self, policy: Dict) -> Dict: self.log.debug(f"Converted policy {policy_id} to config: {config_entry}") return config_entry - # NDFC system-injected keys present in templateInputs that are + # ND system-injected keys present in templateInputs that are # NOT real template parameters. Stripped from gathered output so # the result can be fed directly into state=merged. _SYSTEM_INJECTED_KEYS: ClassVar[frozenset] = frozenset({ @@ -1248,8 +1248,8 @@ def _policies_differ(want: Dict, have: Dict) -> Dict: input_diff = {} for key in want_inputs: # Normalize both sides to lowercase strings to handle: - # - Python bool True → "True" vs NDFC string "true" - # - Python int 100 → "100" vs NDFC string "100" + # - Python bool True → "True" vs ND string "true" + # - Python int 100 → "100" vs ND string "100" # Also strip trailing whitespace/newlines to avoid false # diffs from multiline template inputs (e.g., CONF blocks). want_val = str(want_inputs[key]).strip().lower() @@ -1316,7 +1316,7 @@ def _query_policies( with idempotency checks. When ``True``, they are kept and annotated with ``_markDeleted_stale: True`` so callers can surface the status to the user. - - **source != ""** — internal NDFC sub-policies are always + - **source != ""** — internal ND sub-policies are always excluded; they are artefacts that cause false duplicate matches. @@ -1335,7 +1335,7 @@ def _query_policies( result: List[Dict] = [] excluded = 0 for p in raw: - # Always exclude internal NDFC sub-policies (source != "") + # Always exclude internal ND sub-policies (source != "") if p.get("source", "") != "": excluded += 1 continue @@ -1481,7 +1481,7 @@ def _fetch_template_params(self, template_name: str) -> List[Dict]: template incur only one API call. Args: - template_name: The NDFC template name (e.g., ``switch_freeform``). + template_name: The ND template name (e.g., ``switch_freeform``). Returns: List of parameter dicts, each with at minimum ``name``, @@ -1968,7 +1968,7 @@ def _get_diff_merged_single(self, want: Dict, have_list: List[Dict]) -> Dict: f"'{want.get('description')}' on switch {want.get('switchId')}. " "Cannot determine which policy to update when " "use_desc_as_key=true. Remove the duplicate policies from " - "NDFC or use a policy ID directly." + "the controller or use a policy ID directly." ) ) @@ -2322,7 +2322,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: # individually — a single policy failure does not affect others # in the same batch. # - # NOTE: The orphan risk for DAC entries is inherent — NDFC has + # NOTE: The orphan risk for DAC entries is inherent — ND has # no atomic "replace policy" API. Re-running the playbook # will re-create the policy (it will be seen as # "not found" → create). @@ -2411,10 +2411,10 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: entry_result = ( created_ids[idx] if idx < len(created_ids) - else {"policy_id": None, "ndfc_error": "No response entry from NDFC"} + else {"policy_id": None, "nd_error": "No response entry from ND"} ) created_id = entry_result["policy_id"] - ndfc_error = entry_result["ndfc_error"] + nd_error = entry_result["nd_error"] per_policy_error = None # created_id is None when per-policy response had status!=success @@ -2422,7 +2422,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: per_policy_error = ( f"Policy creation failed for " f"{want.get('templateName')} on " - f"{want.get('switchId')}: {ndfc_error}" + f"{want.get('switchId')}: {nd_error}" ) self._proposed.append(want) @@ -2619,7 +2619,7 @@ def _get_diff_deleted_single(self, want: Dict, have_list: List[Dict]) -> Dict: f"'{want_desc}' on switch {want.get('switchId')}. " "Descriptions must be unique per switch when " "use_desc_as_key=true. Remove the duplicate policies from " - "NDFC or use a policy ID directly." + "the controller or use a policy ID directly." ) ) @@ -2810,8 +2810,8 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # direct DELETE /policies/{policyId}. # # This is more robust than maintaining a hardcoded set of template - # names, since the content type is an NDFC-internal property that - # varies across templates and NDFC versions. + # names, since the content type is an ND-internal property that + # varies across templates and ND versions. # --------------------------------------------------------------------- # Step 1: Attempt markDelete for all policies @@ -2855,7 +2855,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Policies not in the failed set are considered successful mark_succeeded = [pid for pid in unique_policy_ids if pid not in failed_ids] - # Warn if NDFC returned empty policies list (ambiguous) + # Warn if ND returned empty policies list (ambiguous) if not policies_response and unique_policy_ids: self.log.warning( "markDelete returned empty 'policies' list for " @@ -2969,7 +2969,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: }, ) - # Deploy to affected switches so NDFC pushes the config removal + # Deploy to affected switches so ND pushes the config removal # to the devices. Direct DELETE removes the policy record but # the device still has the running config until we deploy. if deleted_direct and self.deploy: @@ -2992,7 +2992,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Per the OpenAPI spec it returns a single object: # {"status": "Configuration deployment completed for [...]"} # - # In practice NDFC may return an empty body {} with 207. + # In practice ND may return an empty body {} with 207. # This endpoint does NOT use the per-item # policyBaseGeneralResponse schema, so we cannot use # _inspect_207_policies() here. @@ -3001,7 +3001,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # - Present and contains "completed" → success # - Present but other text → log warning, treat as success # - Missing (empty body {}) → ambiguous, log warning, - # treat as success (NDFC often returns {} on success) + # treat as success (ND often returns {} on success) deploy_ok = True if isinstance(deploy_data, dict) and deploy_data: status_str = deploy_data.get("status", "") @@ -3017,7 +3017,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: else: self.log.warning( "switchActions/deploy returned empty body — " - "treating as success (NDFC commonly returns {} " + "treating as success (ND commonly returns {} " "for this endpoint)" ) @@ -3105,7 +3105,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: self.log.debug("EXIT: _execute_deleted()") return - # Step 3: remove — hard-delete policy records from NDFC + # Step 3: remove — hard-delete policy records from ND self.log.info( f"Step 3/3: remove {len(normal_delete_ids)} policies" ) @@ -3115,7 +3115,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: rm_ok, rm_fail = self._inspect_207_policies(remove_data) remove_success = len(rm_fail) == 0 - # Warn if NDFC returned no per-policy detail at all + # Warn if ND returned no per-policy detail at all if not rm_ok and not rm_fail and normal_delete_ids: self.log.warning( f"remove returned no per-policy results for " @@ -3214,14 +3214,14 @@ def _deploy_policies( ep.fabric_name = self.fabric_name if self.cluster_name: ep.endpoint_params.cluster_name = self.cluster_name - # NOTE: pushConfig does NOT accept ticketId per manage.json spec + # NOTE: pushConfig does NOT accept ticketId per ND API specification data = self.nd.request(ep.path, ep.verb, push_body.to_request_dict()) # Inspect 207 body for per-policy failures succeeded_policies, failed_policies = self._inspect_207_policies(data) - # Warn if NDFC returned no per-policy detail at all + # Warn if ND returned no per-policy detail at all if not succeeded_policies and not failed_policies and policy_ids: self.log.warning( f"pushConfig returned no per-policy results for " @@ -3264,7 +3264,7 @@ def _inspect_207_policies( ) -> Tuple[List[Dict], List[Dict]]: """Inspect a 207 Multi-Status response for per-item success/failure. - NDFC returns HTTP 207 for most bulk policy actions (create, + ND returns HTTP 207 for most bulk policy actions (create, markDelete, pushConfig, remove). The response body contains a list of per-item results under a top-level key (``policies``), each with a required ``status`` field (``"success"`` or @@ -3291,11 +3291,11 @@ def _inspect_207_policies( If the response body is empty (``{}``) or does not contain the expected key, both returned lists will be empty. The - caller should treat this as an ambiguous result (NDFC did + caller should treat this as an ambiguous result (ND did not report per-item status) and decide accordingly. Args: - data: Response DATA dict from NDFC (or None/non-dict). + data: Response DATA dict from ND (or None/non-dict). key: Top-level key holding the items list. ``"policies"`` for policy action endpoints. @@ -3338,7 +3338,7 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: { "policy_id": str or None, # created ID, None on failure - "ndfc_error": str or None, # NDFC error message on failure + "nd_error": str or None, # ND error message on failure } Raises: @@ -3388,21 +3388,21 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: entry = created_policies[idx] entry_status = str(entry.get("status", "")).lower() if entry_status != "success": - ndfc_msg = entry.get("message", "Policy creation failed") + nd_msg = entry.get("message", "Policy creation failed") self.log.error( f"Bulk create: policy {idx} failed " f"(status={entry.get('status')!r}) — " f"template={want.get('templateName')}, " - f"switch={want.get('switchId')}: {ndfc_msg}" + f"switch={want.get('switchId')}: {nd_msg}" ) - results.append({"policy_id": None, "ndfc_error": ndfc_msg}) + results.append({"policy_id": None, "nd_error": nd_msg}) else: pid = entry.get("policyId") self.log.info(f"Bulk create: policy {idx} created — {pid}") - results.append({"policy_id": pid, "ndfc_error": None}) + results.append({"policy_id": pid, "nd_error": None}) else: self.log.warning(f"Bulk create: no response entry for policy {idx}") - results.append({"policy_id": None, "ndfc_error": "No response entry from NDFC"}) + results.append({"policy_id": None, "nd_error": "No response entry from ND"}) self.log.info( f"Bulk create complete: " @@ -3458,7 +3458,7 @@ def _api_update_policy(self, want: Dict, have: Dict, policy_id: str) -> None: def _api_mark_delete(self, policy_ids: List[str]) -> Dict: """Mark policies for deletion via POST /policyActions/markDelete. - NDFC returns HTTP 207 Multi-Status with per-policy results. + ND returns HTTP 207 Multi-Status with per-policy results. Policies with content type PYTHON (e.g. ``switch_freeform``, ``Ext_VRF_Lite_SVI``) will fail with:: @@ -3472,7 +3472,7 @@ def _api_mark_delete(self, policy_ids: List[str]) -> Dict: policy_ids: List of policy IDs to mark-delete. Returns: - Response DATA dict from NDFC. Typically contains a + Response DATA dict from ND. Typically contains a ``policies`` list with per-policy ``status`` and ``message`` fields. """ @@ -3492,15 +3492,15 @@ def _api_mark_delete(self, policy_ids: List[str]) -> Dict: def _api_remove_policies(self, policy_ids: List[str]) -> Dict: """Hard-delete policies via POST /policyActions/remove. - NDFC returns HTTP 207 Multi-Status with per-policy results. + ND returns HTTP 207 Multi-Status with per-policy results. The caller should inspect the returned dict for per-policy ``status: "failed"`` entries. Args: - policy_ids: List of policy IDs to remove from NDFC. + policy_ids: List of policy IDs to remove from ND. Returns: - Response DATA dict from NDFC. Typically contains a + Response DATA dict from ND. Typically contains a ``policies`` list with per-policy ``status`` and ``message`` fields. """ @@ -3556,7 +3556,7 @@ def _api_deploy_switches(self, switch_ids: List[str]) -> dict: switch_ids: List of switch serial numbers to deploy to. Returns: - Response DATA dict from NDFC. Typically contains a ``status`` + Response DATA dict from ND. Typically contains a ``status`` field like ``"Configuration deployment completed for [...]"``. """ self.log.info( diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index 447ec029..97196b53 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -22,11 +22,11 @@ --- module: nd_policy version_added: "1.0.0" -short_description: Manages policies on Nexus Dashboard Fabric Controller (NDFC). +short_description: Manages policies on Nexus Dashboard. description: - Supports creating, updating, deleting, gathering, and deploying policies based on templates. - Supports C(merged) state for idempotent policy management. -- Supports C(deleted) state for removing policies from NDFC and optionally from switches. +- Supports C(deleted) state for removing policies from ND and optionally from switches. - Supports C(gathered) state for exporting existing policies as playbook-compatible config. The gathered output can be copy-pasted directly into a playbook for use with C(merged) state. - When O(use_desc_as_key=true), policies are identified by their description instead of policy ID. @@ -35,7 +35,7 @@ aborts B(before) making any changes to the controller. - When O(use_desc_as_key=true), every O(config[].description) B(must) be non-empty and unique per switch within the playbook. The module also fails if duplicate descriptions - are found on the NDFC controller itself (created outside of this playbook). This ensures + are found on the ND controller itself (created outside of this playbook). This ensures unambiguous policy matching. To manage policies with non-unique descriptions, use O(use_desc_as_key=false) and reference policies by policy ID. - Policies and switches are specified separately in the O(config) list. Global policies @@ -92,7 +92,7 @@ - Description of the policy. - When O(use_desc_as_key=true), this is used as the unique identifier for the policy and B(must) be non-empty and unique per switch. The module fails atomically if - duplicate descriptions are detected in the playbook or on the NDFC controller. + duplicate descriptions are detected in the playbook or on the ND controller. type: str default: "" priority: @@ -183,7 +183,7 @@ - When set to V(false), the template name (or policy ID if name starts with C(POLICY-)) is used. - When V(true), every O(config[].description) must be non-empty (for C(merged) and C(deleted) states) and unique per switch within the playbook. The module will B(fail immediately) if duplicate - C(description + switch) combinations are found in the playbook config or on the NDFC controller. + C(description + switch) combinations are found in the playbook config or on the ND controller. - This atomic-fail behavior ensures no partial changes are made when descriptions are ambiguous. type: bool default: false @@ -207,7 +207,7 @@ ticket_id: description: - Change Control Ticket ID to associate with mutation operations. - - Required when Change Control is enabled on the NDFC controller. + - Required when Change Control is enabled on the ND controller. type: str cluster_name: description: @@ -240,8 +240,8 @@ - cisco.nd.modules - cisco.nd.check_mode seealso: -- name: Cisco NDFC Policy Management - description: Understanding switch policy management on NDFC 4.x. +- name: Cisco ND Policy Management + description: Understanding switch policy management on ND. notes: - When O(use_desc_as_key=false) and O(config[].name) is a template name (not a policy ID), existing policies are B(never) updated in-place. The module always creates a new policy. diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policies.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py similarity index 99% rename from tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policies.py rename to tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py index e57b0419..8f68ed89 100644 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policies.py +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py @@ -15,7 +15,7 @@ # pylint: enable=invalid-name import pytest -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_policies import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_policies import ( EpManagePoliciesDelete, EpManagePoliciesGet, EpManagePoliciesPost, diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policy_actions.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py similarity index 98% rename from tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policy_actions.py rename to tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py index 109f6d9b..07713fbd 100644 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_policy_actions.py +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py @@ -15,7 +15,7 @@ # pylint: enable=invalid-name import pytest -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_policy_actions import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_policy_actions import ( EpManagePolicyActionsMarkDeletePost, EpManagePolicyActionsPushConfigPost, EpManagePolicyActionsRemovePost, @@ -330,7 +330,7 @@ def test_manage_policy_actions_00230(): ## Test - - path includes only clusterName (no ticketId per manage.json spec) + - path includes only clusterName (no ticketId per ND API specification) ## Classes and Methods From 85b772b81514a19198ce1ad3404392d853de0c5a Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Wed, 1 Apr 2026 20:08:41 +0530 Subject: [PATCH 59/61] Remove unnecessary models/__init__.py, trim verbose comments --- plugins/module_utils/models/__init__.py | 1 - plugins/module_utils/nd_policy_resources.py | 13 ++----------- 2 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 plugins/module_utils/models/__init__.py diff --git a/plugins/module_utils/models/__init__.py b/plugins/module_utils/models/__init__.py deleted file mode 100644 index 7c68785e..00000000 --- a/plugins/module_utils/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index 8cd31cb6..1ab66314 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -270,17 +270,8 @@ def translate_config(config, use_desc_as_key): if not config: return [] - # ── Step 0: Detect gathered / self-contained format ───────────── - # - # ``state=gathered`` returns each policy with its own embedded - # ``switch`` list (e.g., ``[{"serial_number": "FDO..."}]``). - # This makes the output directly usable as ``config:`` in a - # new ``state=merged`` task ("copy-paste round-trip"). - # - # Detect this format: every entry that has a ``name`` also - # has a ``switch`` list. If so, flatten each entry by - # extracting the serial number from its embedded switch list - # and return immediately — no global/switch separation needed. + # Detect gathered output format: every named entry already has its own + # embedded switch list. Flatten directly — no global/switch separation needed. has_self_contained = False all_self_contained = True for entry in config: From 380692c0b69920ec054c22e7e7eea5771ea3e4a2 Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Mon, 6 Apr 2026 11:54:02 +0530 Subject: [PATCH 60/61] fix: skip type validation for empty template inputs, add templateInputs logging, remove RETURN docstring --- plugins/module_utils/nd_policy_resources.py | 14 ++++ plugins/modules/nd_policy.py | 82 +-------------------- 2 files changed, 15 insertions(+), 81 deletions(-) diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index 1ab66314..8063aeab 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -1606,6 +1606,10 @@ def _validate_template_inputs( # ------------------------------------------------------------------ # Check 3: Basic type validation (soft checks) + # Empty strings are treated as "not set" — the controller accepts + # them for optional fields, so we skip validation for them. This + # is especially important for the gathered → merged roundtrip + # where the controller returns "" for unset optional parameters. # ------------------------------------------------------------------ for user_key, user_val in template_inputs.items(): pdef = param_map.get(user_key) @@ -1615,6 +1619,10 @@ def _validate_template_inputs( ptype = (pdef.get("parameterType") or "").lower() val_str = str(user_val) + # Skip type validation for empty/blank values — they mean "not set" + if val_str.strip() == "": + continue + if ptype == "boolean": if val_str.lower() not in ("true", "false"): errors.append( @@ -3360,6 +3368,11 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: bulk = PolicyCreateBulk(policies=policy_models) payload = bulk.to_request_dict() + self.log.info( + f"Bulk create payload templateInputs: " + f"{[{k: v for k, v in (w.get('templateInputs') or {}).items()} for w in want_list]}" + ) + ep = EpManagePoliciesPost() ep.fabric_name = self.fabric_name if self.cluster_name: @@ -3423,6 +3436,7 @@ def _api_update_policy(self, want: Dict, have: Dict, policy_id: str) -> None: for k, v in (want.get("templateInputs") or {}).items(): merged_inputs[k] = v self.log.debug(f"Merged templateInputs: {len(merged_inputs)} keys") + self.log.info(f"Update payload templateInputs for {policy_id}: {merged_inputs}") update_model = PolicyUpdate( switch_id=want["switchId"], diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index 97196b53..aec2cead 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -522,87 +522,7 @@ config: "{{ all_policies.gathered }}" """ -RETURN = r""" -changed: - description: Whether any changes were made. - returned: always - type: bool - sample: false -failed: - description: Whether the operation failed. - returned: always - type: bool - sample: false -before: - description: - - List of policy snapshots B(before) the module made any changes. - - For C(merged) state, contains the existing policy state prior to create/update. - - For C(deleted) state, contains the policies that were deleted. - returned: always - type: list - elements: dict -after: - description: - - List of policy snapshots B(after) the module completed. - - For C(merged) state, contains the new/updated policy state. - - For C(deleted) state, this is an empty list (policies were removed). - returned: always - type: list - elements: dict -gathered: - description: - - List of policies exported as playbook-compatible config dicts. - - Each entry contains C(name) (template name), C(policy_id), C(switch), - C(description), C(priority), C(template_inputs), and C(create_additional_policy). - - Internal template input keys (e.g., C(FABRIC_NAME), C(POLICY_ID)) are - stripped so the output can be used directly as O(config) in a C(merged) - or C(deleted) task. When used with C(merged), the C(policy_id) field is - promoted to O(config[].name) so the existing policy is updated in-place. - When used with C(deleted), the same promotion deletes the exact policies - by ID. To create fresh copies instead of updating, remove the C(policy_id) - lines from the gathered output before feeding it back. - - Only returned when O(state=gathered). - returned: when state is gathered - type: list - elements: dict - sample: - - name: switch_freeform - policy_id: POLICY-28240 - switch: - - serial_number: FDO25031SY4 - description: my freeform policy - priority: 500 - template_inputs: - CONF: "interface loopback100\n description gathered" - create_additional_policy: false -proposed: - description: - - List of proposed policy changes derived from the playbook config. - - Only returned when O(output_level) is set to V(info) or V(debug). - returned: when output_level is info or debug - type: list - elements: dict -diff: - description: List of differences between desired and existing state. - returned: always - type: list - elements: dict -response: - description: List of controller responses. - returned: always - type: list - elements: dict -result: - description: List of operation results. - returned: always - type: list - elements: dict -metadata: - description: List of operation metadata. - returned: always - type: list - elements: dict -""" +RETURN = r"" import logging From 57a06b40e0dc6e57f734dc97002fb5096ff4c849 Mon Sep 17 00:00:00 2001 From: L Nikhil Sri Krishna Date: Tue, 7 Apr 2026 15:39:19 +0530 Subject: [PATCH 61/61] Fix nd_policy sanity issues and format policy files --- .../module_utils/common/pydantic_compat.py | 12 +- .../v1/manage/manage_config_templates.py | 1 - .../v1/manage/manage_fabrics_policies.py | 2 - .../manage/manage_fabrics_policy_actions.py | 1 - .../manage/manage_fabrics_switch_actions.py | 6 +- .../models/manage_policies/__init__.py | 3 +- .../models/manage_policies/config_models.py | 19 +- .../models/manage_policies/enums.py | 1 - .../models/manage_policies/gathered_models.py | 19 +- .../models/manage_policies/policy_base.py | 1 - .../models/manage_policies/policy_crud.py | 1 - plugins/module_utils/nd_policy_resources.py | 677 +++++------------- plugins/modules/nd_policy.py | 7 +- ...ndpoints_api_v1_manage_config_templates.py | 7 +- ...ndpoints_api_v1_manage_fabrics_policies.py | 13 +- ...ts_api_v1_manage_fabrics_policy_actions.py | 23 +- 16 files changed, 234 insertions(+), 559 deletions(-) diff --git a/plugins/module_utils/common/pydantic_compat.py b/plugins/module_utils/common/pydantic_compat.py index b26559d2..08f4e1db 100644 --- a/plugins/module_utils/common/pydantic_compat.py +++ b/plugins/module_utils/common/pydantic_compat.py @@ -44,6 +44,7 @@ BeforeValidator, ConfigDict, Field, + ValidationInfo, PydanticExperimentalWarning, StrictBool, SecretStr, @@ -69,6 +70,7 @@ BeforeValidator, ConfigDict, Field, + ValidationInfo, PydanticExperimentalWarning, StrictBool, SecretStr, @@ -117,8 +119,10 @@ def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-nam return kwargs # Fallback: Field that does nothing - def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name + def Field(*args, **kwargs) -> Any: # pylint: disable=unused-argument,invalid-name """Pydantic Field fallback when pydantic is not available.""" + if args: + return args[0] if "default_factory" in kwargs: return kwargs["default_factory"]() return kwargs.get("default") @@ -191,6 +195,12 @@ def __init__(self, message="A custom error occurred."): def __str__(self): return f"ValidationError: {self.message}" + class ValidationInfo: + """Pydantic ValidationInfo fallback when pydantic is not available.""" + + def __init__(self, context=None, **kwargs): # pylint: disable=unused-argument + self.context = context + # 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.""" diff --git a/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py index 99381c03..7bbf8895 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py @@ -38,7 +38,6 @@ NDEndpointBaseModel, ) - # ============================================================================ # Query parameter classes # ============================================================================ diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py index 7031181a..7490e9b8 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py @@ -31,7 +31,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( FabricNameMixin, PolicyIdMixin, - TicketIdMixin, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( CompositeQueryParams, @@ -49,7 +48,6 @@ NDEndpointBaseModel, ) - # ============================================================================ # Query parameter classes # ============================================================================ diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py index 344a5b4c..cfb2b433 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py @@ -43,7 +43,6 @@ NDEndpointBaseModel, ) - # ============================================================================ # Query parameter classes # ============================================================================ diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py index 1640a94c..6f8d2dde 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py @@ -41,7 +41,6 @@ NDEndpointBaseModel, ) - # ============================================================================ # Query parameter classes # ============================================================================ @@ -68,10 +67,7 @@ class SwitchDeployEndpointParams(EndpointQueryParams): force_show_run: Optional[bool] = Field( default=None, - description=( - "If true, Config compliance fetches the latest running config " - "from the device. If false, uses the cached version." - ), + description=("If true, Config compliance fetches the latest running config " "from the device. If false, uses the cached version."), ) cluster_name: Optional[str] = Field( default=None, diff --git a/plugins/module_utils/models/manage_policies/__init__.py b/plugins/module_utils/models/manage_policies/__init__.py index 62072a36..ead0afdf 100644 --- a/plugins/module_utils/models/manage_policies/__init__.py +++ b/plugins/module_utils/models/manage_policies/__init__.py @@ -49,7 +49,6 @@ PlaybookSwitchPolicyConfig, ) - __all__ = [ # Enums "PolicyEntityType", @@ -66,4 +65,4 @@ "PlaybookPolicyConfig", "PlaybookSwitchEntry", "PlaybookSwitchPolicyConfig", -] \ No newline at end of file +] diff --git a/plugins/module_utils/models/manage_policies/config_models.py b/plugins/module_utils/models/manage_policies/config_models.py index bb92d21e..865c3ade 100644 --- a/plugins/module_utils/models/manage_policies/config_models.py +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -32,11 +32,10 @@ from typing import Any, ClassVar, Dict, List, Optional -from pydantic import ValidationInfo, model_validator -from typing_extensions import Self - from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( Field, + ValidationInfo, + model_validator, ) from ansible_collections.cisco.nd.plugins.module_utils.models.nested import ( NDNestedModel, @@ -189,7 +188,7 @@ class PlaybookPolicyConfig(NDNestedModel): ) @model_validator(mode="after") - def validate_state_requirements(self, info: ValidationInfo) -> Self: + def validate_state_requirements(self, info: ValidationInfo) -> "PlaybookPolicyConfig": """Apply state-aware validation using context. When ``context={"state": "merged", "use_desc_as_key": True}`` is @@ -229,13 +228,7 @@ def validate_state_requirements(self, info: ValidationInfo) -> Self: # When use_desc_as_key=true, description must not be empty for # template-name entries (not policy IDs) in merged/deleted states. - if ( - use_desc_as_key - and state in ("merged", "deleted") - and self.name - and not self.name.startswith("POLICY-") - and not self.description - ): + if use_desc_as_key and state in ("merged", "deleted") and self.name and not self.name.startswith("POLICY-") and not self.description: raise ValueError( f"'description' cannot be empty when use_desc_as_key=true " f"and name is a template name ('{self.name}'). " @@ -259,9 +252,7 @@ def get_argument_spec(cls) -> Dict[str, Any]: deploy=dict(type="bool", default=True), ticket_id=dict(type="str"), cluster_name=dict(type="str"), - state=dict( - type="str", default="merged", choices=["merged", "deleted", "gathered"] - ), + state=dict(type="str", default="merged", choices=["merged", "deleted", "gathered"]), ) diff --git a/plugins/module_utils/models/manage_policies/enums.py b/plugins/module_utils/models/manage_policies/enums.py index 163ebb98..37b72e29 100644 --- a/plugins/module_utils/models/manage_policies/enums.py +++ b/plugins/module_utils/models/manage_policies/enums.py @@ -16,7 +16,6 @@ from enum import Enum from typing import List, Union - # ============================================================================= # ENUMS - Extracted from OpenAPI Schema components/schemas # ============================================================================= diff --git a/plugins/module_utils/models/manage_policies/gathered_models.py b/plugins/module_utils/models/manage_policies/gathered_models.py index ad6f89e5..32f6fd60 100644 --- a/plugins/module_utils/models/manage_policies/gathered_models.py +++ b/plugins/module_utils/models/manage_policies/gathered_models.py @@ -39,9 +39,10 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel - log = logging.getLogger("nd.GatheredPolicy") +# pylint: disable=logging-fstring-interpolation + class GatheredPolicy(NDBaseModel): """Read-model for a policy returned by the ND API. @@ -57,15 +58,16 @@ class GatheredPolicy(NDBaseModel): # --- NDBaseModel ClassVars --- identifiers: ClassVar[List[str]] = ["policy_id"] - identifier_strategy: ClassVar[Optional[Literal[ - "single", "composite", "hierarchical", "singleton" - ]]] = "single" + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" exclude_from_diff: ClassVar[Set[str]] = set() # Fields excluded from config output (internal / not user-facing) config_exclude_fields: ClassVar[Set[str]] = { - "entity_type", "entity_name", "source", - "secondary_entity_name", "secondary_entity_type", + "entity_type", + "entity_name", + "source", + "secondary_entity_name", + "secondary_entity_type", } # --- Fields --- @@ -145,10 +147,7 @@ def from_api_policy(cls, policy: Dict[str, Any]) -> "GatheredPolicy": try: raw_inputs = json.loads(raw_inputs) except (json.JSONDecodeError, ValueError): - log.warning( - f"Failed to parse templateInputs for " - f"{data.get('policyId', '?')}: {raw_inputs!r}" - ) + log.warning(f"Failed to parse templateInputs for " f"{data.get('policyId', '?')}: {raw_inputs!r}") raw_inputs = {} data["templateInputs"] = raw_inputs diff --git a/plugins/module_utils/models/manage_policies/policy_base.py b/plugins/module_utils/models/manage_policies/policy_base.py index fe31c2cd..5dbdb334 100644 --- a/plugins/module_utils/models/manage_policies/policy_base.py +++ b/plugins/module_utils/models/manage_policies/policy_base.py @@ -35,7 +35,6 @@ from .enums import PolicyEntityType - # ============================================================================ # Policy Create Model (base for all CRUD body models) # ============================================================================ diff --git a/plugins/module_utils/models/manage_policies/policy_crud.py b/plugins/module_utils/models/manage_policies/policy_crud.py index 46c40748..e300f240 100644 --- a/plugins/module_utils/models/manage_policies/policy_crud.py +++ b/plugins/module_utils/models/manage_policies/policy_crud.py @@ -35,7 +35,6 @@ PolicyCreate, ) - # ============================================================================ # Policy Create Bulk Model # ============================================================================ diff --git a/plugins/module_utils/nd_policy_resources.py b/plugins/module_utils/nd_policy_resources.py index 8063aeab..0c4c14f0 100644 --- a/plugins/module_utils/nd_policy_resources.py +++ b/plugins/module_utils/nd_policy_resources.py @@ -26,7 +26,7 @@ from __future__ import absolute_import, annotations, division, print_function -# pylint: disable=invalid-name +# pylint: disable=invalid-name,logging-fstring-interpolation,logging-not-lazy,f-string-without-interpolation,unnecessary-comprehension,implicit-str-concat __metaclass__ = type # pylint: enable=invalid-name @@ -79,7 +79,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import PlaybookPolicyConfig - # ============================================================================= # Module-level helpers (stateless, used by NDPolicyModule) # ============================================================================= @@ -180,9 +179,7 @@ def __init__( if not self.config: if self.state != "gathered": - raise NDModuleError( - msg=f"'config' element is mandatory for state '{self.state}'." - ) + raise NDModuleError(msg=f"'config' element is mandatory for state '{self.state}'.") # For gathered without config, initialise to empty list so # downstream code can iterate safely. self.config = [] @@ -198,9 +195,7 @@ def __init__( self._proposed: List[Dict] = [] self._gathered: List[Dict] = [] - self.log.info( - f"Initialized NDPolicyModule for fabric: {self.fabric_name}, state: {self.state}" - ) + self.log.info(f"Initialized NDPolicyModule for fabric: {self.fabric_name}, state: {self.state}") def exit_json(self) -> None: """Build final result from all registered tasks and exit. @@ -525,9 +520,7 @@ def validate_translated_config(self, translated_config): """ for idx, entry in enumerate(translated_config): if not entry.get("switch"): - raise NDModuleError( - msg=f"config[{idx}]: every policy entry must have a switch serial number after translation." - ) + raise NDModuleError(msg=f"config[{idx}]: every policy entry must have a switch serial number after translation.") # ========================================================================= # Public API - State Management @@ -562,13 +555,9 @@ def validate_and_prepare_config(self) -> None: validated = PlaybookPolicyConfig.model_validate(entry, context=validation_context) normalized_config.append(validated.model_dump(by_alias=False, exclude_none=False)) except ValidationError as ve: - raise NDModuleError( - msg=f"Input validation failed for config[{idx}]: {ve}" - ) from ve + raise NDModuleError(msg=f"Input validation failed for config[{idx}]: {ve}") from ve except ValueError as ve: - raise NDModuleError( - msg=f"Input validation failed for config[{idx}]: {ve}" - ) from ve + raise NDModuleError(msg=f"Input validation failed for config[{idx}]: {ve}") from ve self.config = normalized_config self.module.params["config"] = normalized_config @@ -676,18 +665,13 @@ def _validate_config(self) -> None: desc_switch_counts[key] = desc_switch_counts.get(key, 0) + 1 # Report all duplicates at once - duplicates = [ - f"description='{k.split('|')[0]}', switch='{k.split('|')[1]}'" - for k, count in desc_switch_counts.items() - if count > 1 - ] + duplicates = [f"description='{k.split('|')[0]}', switch='{k.split('|')[1]}'" for k, count in desc_switch_counts.items() if count > 1] if duplicates: raise NDModuleError( msg=( "Duplicate description+switch combinations found in the " "playbook config (use_desc_as_key=true requires each " - "description to be unique per switch): " - + "; ".join(duplicates) + "description to be unique per switch): " + "; ".join(duplicates) ) ) @@ -716,45 +700,41 @@ def _handle_merged_state(self) -> None: template_name = want.get("templateName") template_inputs = want.get("templateInputs") or {} if template_name and not self._is_policy_id(template_name): - validation_errors = self._validate_template_inputs( - template_name, template_inputs - ) + validation_errors = self._validate_template_inputs(template_name, template_inputs) if validation_errors: - error_msg = ( - f"Template input validation failed for '{template_name}': " - + "; ".join(validation_errors) - ) + error_msg = f"Template input validation failed for '{template_name}': " + "; ".join(validation_errors) self.log.error(error_msg) - diff_results.append({ - "action": "fail", - "want": want, - "have": None, - "diff": None, - "policy_id": None, - "error_msg": error_msg, - }) + diff_results.append( + { + "action": "fail", + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": error_msg, + } + ) continue have_list, error_msg = self._build_have(want) if error_msg: self.log.error(f"Build have failed: {error_msg}") - diff_results.append({ - "action": "fail", - "want": want, - "have": None, - "diff": None, - "policy_id": None, - "error_msg": error_msg, - }) + diff_results.append( + { + "action": "fail", + "want": want, + "have": None, + "diff": None, + "policy_id": None, + "error_msg": error_msg, + } + ) continue # Phase 2: Compute diff diff_entry = self._get_diff_merged_single(want, have_list) - self.log.debug( - f"Diff result for {want.get('templateName', want.get('policyId', 'unknown'))}: " - f"action={diff_entry['action']}" - ) + self.log.debug(f"Diff result for {want.get('templateName', want.get('policyId', 'unknown'))}: " f"action={diff_entry['action']}") diff_results.append(diff_entry) self.log.info(f"Computed {len(diff_results)} diff results") @@ -813,23 +793,22 @@ def _handle_deleted_state(self) -> None: if error_msg: self.log.error(f"Build have failed: {error_msg}") - diff_results.append({ - "action": "fail", - "want": want, - "policies": [], - "policy_ids": [], - "match_count": 0, - "warning": None, - "error_msg": error_msg, - }) + diff_results.append( + { + "action": "fail", + "want": want, + "policies": [], + "policy_ids": [], + "match_count": 0, + "warning": None, + "error_msg": error_msg, + } + ) continue # Phase 2: Compute delete result diff_entry = self._get_diff_deleted_single(want, have_list) - self.log.debug( - f"Delete diff for {want.get('templateName', want.get('policyId', 'switch-only'))}: " - f"action={diff_entry['action']}" - ) + self.log.debug(f"Delete diff for {want.get('templateName', want.get('policyId', 'switch-only'))}: " f"action={diff_entry['action']}") diff_results.append(diff_entry) # Phase 3: Execute delete actions @@ -907,9 +886,7 @@ def _handle_gathered_state(self) -> None: self.log.debug(f"Gathering policies for switch {switch_sn}") lucene = self._build_lucene_filter(switchId=switch_sn) switch_policies = self._query_policies(lucene, include_mark_deleted=False) - self.log.info( - f"Found {len(switch_policies)} policies on switch {switch_sn}" - ) + self.log.info(f"Found {len(switch_policies)} policies on switch {switch_sn}") policies.extend(switch_policies) if not policies: @@ -941,9 +918,7 @@ def _handle_gathered_state(self) -> None: try: model = GatheredPolicy.from_api_policy(pol) except Exception as exc: - self.log.warning( - f"Failed to parse policy {pid} for gathered output: {exc}" - ) + self.log.warning(f"Failed to parse policy {pid} for gathered output: {exc}") skipped += 1 continue # NDConfigCollection.add() raises ValueError on duplicate key; @@ -954,10 +929,7 @@ def _handle_gathered_state(self) -> None: continue gathered_collection.add(model) - self.log.info( - f"Gathered {len(gathered_collection)} unique policies " - f"(from {len(policies)} total, {skipped} skipped)" - ) + self.log.info(f"Gathered {len(gathered_collection)} unique policies " f"(from {len(policies)} total, {skipped} skipped)") # Convert each policy to playbook-ready config, applying # _clean_template_inputs to strip ND-injected keys. @@ -967,9 +939,7 @@ def _handle_gathered_state(self) -> None: template_name = config_entry.get("name", "") raw_inputs = config_entry.get("template_inputs") or {} if template_name and raw_inputs: - config_entry["template_inputs"] = self._clean_template_inputs( - template_name, raw_inputs - ) + config_entry["template_inputs"] = self._clean_template_inputs(template_name, raw_inputs) self._gathered.append(config_entry) self._register_result( @@ -1039,12 +1009,11 @@ def _policy_to_config(self, policy: Dict) -> Dict: raw_inputs = policy.get("templateInputs") or policy.get("nvPairs") or {} if isinstance(raw_inputs, str): import json + try: raw_inputs = json.loads(raw_inputs) except (json.JSONDecodeError, ValueError): - self.log.warning( - f"Failed to parse templateInputs for {policy_id}: {raw_inputs!r}" - ) + self.log.warning(f"Failed to parse templateInputs for {policy_id}: {raw_inputs!r}") raw_inputs = {} # Clean internal keys from template inputs @@ -1066,23 +1035,23 @@ def _policy_to_config(self, policy: Dict) -> Dict: # ND system-injected keys present in templateInputs that are # NOT real template parameters. Stripped from gathered output so # the result can be fed directly into state=merged. - _SYSTEM_INJECTED_KEYS: ClassVar[frozenset] = frozenset({ - "FABRIC_NAME", - "MARK_DELETED", - "POLICY_DESC", - "POLICY_GROUP_ID", - "POLICY_ID", - "PRIORITY", - "SECENTITY", - "SECENTTYPE", - "SERIAL_NUMBER", - "SOURCE", - "SWITCH_DB_ID", - }) - - def _clean_template_inputs( - self, template_name: str, raw_inputs: Dict[str, Any] - ) -> Dict[str, Any]: + _SYSTEM_INJECTED_KEYS: ClassVar[frozenset] = frozenset( + { + "FABRIC_NAME", + "MARK_DELETED", + "POLICY_DESC", + "POLICY_GROUP_ID", + "POLICY_ID", + "PRIORITY", + "SECENTITY", + "SECENTTYPE", + "SERIAL_NUMBER", + "SOURCE", + "SWITCH_DB_ID", + } + ) + + def _clean_template_inputs(self, template_name: str, raw_inputs: Dict[str, Any]) -> Dict[str, Any]: """Remove system-injected keys from template inputs. Strips keys listed in ``_SYSTEM_INJECTED_KEYS`` and keeps @@ -1095,10 +1064,7 @@ def _clean_template_inputs( Returns: Cleaned dict with system-injected keys removed. """ - self.log.debug( - f"ENTER: _clean_template_inputs(template={template_name}, " - f"keys={list(raw_inputs.keys())})" - ) + self.log.debug(f"ENTER: _clean_template_inputs(template={template_name}, " f"keys={list(raw_inputs.keys())})") cleaned = {} stripped_keys = [] @@ -1109,15 +1075,9 @@ def _clean_template_inputs( cleaned[k] = v if stripped_keys: - self.log.debug( - f"Stripped {len(stripped_keys)} system-injected keys: " - f"{sorted(stripped_keys)}" - ) + self.log.debug(f"Stripped {len(stripped_keys)} system-injected keys: " f"{sorted(stripped_keys)}") - self.log.debug( - f"EXIT: _clean_template_inputs() -> {len(cleaned)} keys " - f"(removed {len(raw_inputs) - len(cleaned)})" - ) + self.log.debug(f"EXIT: _clean_template_inputs() -> {len(cleaned)} keys " f"(removed {len(raw_inputs) - len(cleaned)})") return cleaned # ========================================================================= @@ -1256,9 +1216,7 @@ def _policies_differ(want: Dict, have: Dict) -> Dict: # API Query Helpers # ========================================================================= - def _query_policies_raw( - self, lucene_filter: Optional[str] = None - ) -> List[Dict]: + def _query_policies_raw(self, lucene_filter: Optional[str] = None) -> List[Dict]: """Query policies from the controller using GET /policies (unfiltered). Returns **all** matching policies including ``markDeleted`` and @@ -1342,15 +1300,10 @@ def _query_policies( result.append(p) - self.log.debug( - f"After filtering: {len(result)} policies " - f"(excluded {excluded}, include_mark_deleted={include_mark_deleted})" - ) + self.log.debug(f"After filtering: {len(result)} policies " f"(excluded {excluded}, include_mark_deleted={include_mark_deleted})") return result - def _query_policy_by_id( - self, policy_id: str, include_mark_deleted: bool = False - ) -> Optional[Dict]: + def _query_policy_by_id(self, policy_id: str, include_mark_deleted: bool = False) -> Optional[Dict]: """Query a single policy by its ID. By default, policies marked for deletion (``markDeleted=True``) @@ -1382,21 +1335,14 @@ def _query_policy_by_id( # policy is not found, e.g. {'code': 404, 'message': '...not found'}. # Only treat the response as a valid policy if it contains a policyId. if "policyId" not in data: - self.log.info( - f"Policy {policy_id} not found (response has no policyId: " - f"{data.get('message', data.get('code', 'unknown'))})" - ) + self.log.info(f"Policy {policy_id} not found (response has no policyId: " f"{data.get('message', data.get('code', 'unknown'))})") return None if data.get("markDeleted", False): if include_mark_deleted: data["_markDeleted_stale"] = True - self.log.info( - f"Policy {policy_id} is marked for deletion (included with annotation)" - ) + self.log.info(f"Policy {policy_id} is marked for deletion (included with annotation)") return data - self.log.info( - f"Policy {policy_id} is marked for deletion, treating as not found" - ) + self.log.info(f"Policy {policy_id} is marked for deletion, treating as not found") return None self.log.debug(f"Policy {policy_id} found") return data @@ -1483,10 +1429,7 @@ def _fetch_template_params(self, template_name: str) -> List[Dict]: self.log.debug(f"ENTER: _fetch_template_params(template_name={template_name})") if template_name in self._template_params_cache: - self.log.debug( - f"Template params cache hit for '{template_name}': " - f"{len(self._template_params_cache[template_name])} params" - ) + self.log.debug(f"Template params cache hit for '{template_name}': " f"{len(self._template_params_cache[template_name])} params") return self._template_params_cache[template_name] ep = EpManageConfigTemplateParametersGet() @@ -1495,10 +1438,7 @@ def _fetch_template_params(self, template_name: str) -> List[Dict]: try: data = self.nd.request(ep.path, ep.verb) except Exception as exc: - self.log.warning( - f"Failed to fetch template '{template_name}' parameters: {exc}. " - "Skipping template input validation." - ) + self.log.warning(f"Failed to fetch template '{template_name}' parameters: {exc}. " "Skipping template input validation.") self._template_params_cache[template_name] = [] return [] @@ -1509,19 +1449,12 @@ def _fetch_template_params(self, template_name: str) -> List[Dict]: params = [] self._template_params_cache[template_name] = params - self.log.info( - f"Fetched {len(params)} parameter definitions for template '{template_name}'" - ) - self.log.debug( - f"Template '{template_name}' param names: " - f"{[p.get('name') for p in params]}" - ) + self.log.info(f"Fetched {len(params)} parameter definitions for template '{template_name}'") + self.log.debug(f"Template '{template_name}' param names: " f"{[p.get('name') for p in params]}") self.log.debug(f"EXIT: _fetch_template_params()") return params - def _validate_template_inputs( - self, template_name: str, template_inputs: Dict[str, Any] - ) -> List[str]: + def _validate_template_inputs(self, template_name: str, template_inputs: Dict[str, Any]) -> List[str]: """Validate user-provided templateInputs against the template schema. Performs three checks: @@ -1544,10 +1477,7 @@ def _validate_template_inputs( List of validation error message strings. Empty list means all inputs are valid. """ - self.log.debug( - f"ENTER: _validate_template_inputs(template={template_name}, " - f"input_keys={list(template_inputs.keys())})" - ) + self.log.debug(f"ENTER: _validate_template_inputs(template={template_name}, " f"input_keys={list(template_inputs.keys())})") params = self._fetch_template_params(template_name) if not params: @@ -1572,10 +1502,7 @@ def _validate_template_inputs( else: param_map[name] = p - self.log.debug( - f"Template '{template_name}': {len(param_map)} user params, " - f"{len(internal_names)} internal params ({sorted(internal_names)})" - ) + self.log.debug(f"Template '{template_name}': {len(param_map)} user params, " f"{len(internal_names)} internal params ({sorted(internal_names)})") # ------------------------------------------------------------------ # Check 1: Unknown keys (skip internal params — they are allowed @@ -1585,10 +1512,7 @@ def _validate_template_inputs( user_facing_names = set(param_map.keys()) for user_key in template_inputs: if user_key not in valid_names: - errors.append( - f"Unknown templateInput key '{user_key}' for template " - f"'{template_name}'. Valid keys: {sorted(user_facing_names)}" - ) + errors.append(f"Unknown templateInput key '{user_key}' for template " f"'{template_name}'. Valid keys: {sorted(user_facing_names)}") # ------------------------------------------------------------------ # Check 2: Missing required parameters @@ -1599,10 +1523,7 @@ def _validate_template_inputs( has_default = default_val is not None and str(default_val).strip() != "" if not is_optional and not has_default and pname not in template_inputs: - errors.append( - f"Required templateInput '{pname}' (type={pdef.get('parameterType', '?')}) " - f"is missing for template '{template_name}'" - ) + errors.append(f"Required templateInput '{pname}' (type={pdef.get('parameterType', '?')}) " f"is missing for template '{template_name}'") # ------------------------------------------------------------------ # Check 3: Basic type validation (soft checks) @@ -1625,46 +1546,31 @@ def _validate_template_inputs( if ptype == "boolean": if val_str.lower() not in ("true", "false"): - errors.append( - f"templateInput '{user_key}' for template '{template_name}' " - f"expects boolean (true/false), got '{val_str}'" - ) + errors.append(f"templateInput '{user_key}' for template '{template_name}' " f"expects boolean (true/false), got '{val_str}'") elif ptype == "integer": try: int(val_str) except ValueError: - errors.append( - f"templateInput '{user_key}' for template '{template_name}' " - f"expects integer, got '{val_str}'" - ) + errors.append(f"templateInput '{user_key}' for template '{template_name}' " f"expects integer, got '{val_str}'") elif ptype == "long": try: int(val_str) except ValueError: - errors.append( - f"templateInput '{user_key}' for template '{template_name}' " - f"expects long integer, got '{val_str}'" - ) + errors.append(f"templateInput '{user_key}' for template '{template_name}' " f"expects long integer, got '{val_str}'") elif ptype == "float": try: float(val_str) except ValueError: - errors.append( - f"templateInput '{user_key}' for template '{template_name}' " - f"expects float, got '{val_str}'" - ) + errors.append(f"templateInput '{user_key}' for template '{template_name}' " f"expects float, got '{val_str}'") elif ptype in ("ipv4address", "ipaddress"): # Basic IPv4 check ipv4_pattern = r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$" if not re.match(ipv4_pattern, val_str): - errors.append( - f"templateInput '{user_key}' for template '{template_name}' " - f"expects IPv4 address (e.g., 192.168.1.1), got '{val_str}'" - ) + errors.append(f"templateInput '{user_key}' for template '{template_name}' " f"expects IPv4 address (e.g., 192.168.1.1), got '{val_str}'") elif ptype == "ipv4addresswithsubnet": ipv4_subnet_pattern = r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$" @@ -1677,10 +1583,7 @@ def _validate_template_inputs( elif ptype == "macaddress": mac_pattern = r"^([0-9a-fA-F]{4}\.){2}[0-9a-fA-F]{4}$|^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$" if not re.match(mac_pattern, val_str): - errors.append( - f"templateInput '{user_key}' for template '{template_name}' " - f"expects MAC address, got '{val_str}'" - ) + errors.append(f"templateInput '{user_key}' for template '{template_name}' " f"expects MAC address, got '{val_str}'") elif ptype == "enum": # If metaProperties contains 'validValues', check against them @@ -1690,20 +1593,12 @@ def _validate_template_inputs( # validValues format is typically "val1,val2,val3" valid_values = [v.strip() for v in valid_values_str.split(",")] if val_str not in valid_values: - errors.append( - f"templateInput '{user_key}' for template '{template_name}' " - f"expects one of {valid_values}, got '{val_str}'" - ) + errors.append(f"templateInput '{user_key}' for template '{template_name}' " f"expects one of {valid_values}, got '{val_str}'") if errors: - self.log.warning( - f"Template input validation found {len(errors)} errors " - f"for template '{template_name}': {errors}" - ) + self.log.warning(f"Template input validation found {len(errors)} errors " f"for template '{template_name}': {errors}") else: - self.log.debug( - f"Template input validation passed for template '{template_name}'" - ) + self.log.debug(f"Template input validation passed for template '{template_name}'") self.log.debug("EXIT: _validate_template_inputs()") return errors @@ -1748,10 +1643,7 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # Case B: use_desc_as_key=false, search by switchId + templateName if not self.use_desc_as_key: - self.log.debug( - f"Case B: Lookup by switchId={want['switchId']} + " - f"templateName={want['templateName']}" - ) + self.log.debug(f"Case B: Lookup by switchId={want['switchId']} + " f"templateName={want['templateName']}") lucene = self._build_lucene_filter( switchId=want["switchId"], templateName=want["templateName"], @@ -1762,23 +1654,15 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: want_desc = want.get("description", "") if want_desc: pre_filter_count = len(policies) - policies = [ - p for p in policies - if (p.get("description", "") or "") == want_desc - ] - self.log.debug( - f"Post-filtered by description: {len(policies)} of {pre_filter_count}" - ) + policies = [p for p in policies if (p.get("description", "") or "") == want_desc] + self.log.debug(f"Post-filtered by description: {len(policies)} of {pre_filter_count}") self.log.info(f"Case B matched {len(policies)} policies") return policies, None # Case C: use_desc_as_key=true, search by switchId + description want_desc = want.get("description", "") or "" - self.log.debug( - f"Case C: Lookup by switchId={want['switchId']} + " - f"description='{want_desc}'" - ) + self.log.debug(f"Case C: Lookup by switchId={want['switchId']} + " f"description='{want_desc}'") # For merged/deleted states, Pydantic enforces that description # is non-empty. This guard covers gathered state where # Pydantic intentionally skips the check. @@ -1794,13 +1678,8 @@ def _build_have(self, want: Dict) -> Tuple[List[Dict], Optional[str]]: # IMPORTANT: Lucene does tokenized matching, not exact match. # Post-filter to ensure exact description match. - exact_matches = [ - p for p in policies - if (p.get("description", "") or "") == want_desc - ] - self.log.debug( - f"Exact description match: {len(exact_matches)} of {len(policies)}" - ) + exact_matches = [p for p in policies if (p.get("description", "") or "") == want_desc] + self.log.debug(f"Exact description match: {len(exact_matches)} of {len(policies)}") self.log.info(f"Case C matched {len(exact_matches)} policies") self.log.debug("EXIT: _build_have()") @@ -1888,10 +1767,7 @@ def _get_diff_merged_single(self, want: Dict, have_list: List[Dict]) -> Dict: if match_count == 0: # Case 7: Policy ID not found → SKIP result["action"] = "skip" - result["error_msg"] = ( - f"Policy {want['policyId']} not found. " - "Cannot create a policy with a specific ID." - ) + result["error_msg"] = f"Policy {want['policyId']} not found. " "Cannot create a policy with a specific ID." return result have = have_list[0] @@ -2016,10 +1892,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: have = diff_entry["have"] error_msg = diff_entry["error_msg"] - self.log.info( - f"Classifying action={action} for " - f"{want.get('templateName', want.get('policyId', 'unknown'))}" - ) + self.log.info(f"Classifying action={action} for " f"{want.get('templateName', want.get('policyId', 'unknown'))}") if action == "fail": self._proposed.append(want) @@ -2066,11 +1939,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: delete_and_create_batch.append(diff_entry) continue - self.log.info( - f"Batch summary: create={len(create_batch)}, " - f"update={len(update_batch)}, " - f"delete_and_create={len(delete_and_create_batch)}" - ) + self.log.info(f"Batch summary: create={len(create_batch)}, " f"update={len(update_batch)}, " f"delete_and_create={len(delete_and_create_batch)}") # ── Phase 2: Check mode — register all as would-be changes ────── if self.check_mode: @@ -2101,9 +1970,12 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: success=True, found=True, diff={ - "action": "update", "before": have, - "after": {**have, **want}, "want": want, - "have": have, "diff": diff_entry["diff"], + "action": "update", + "before": have, + "after": {**have, **want}, + "want": want, + "have": have, + "diff": diff_entry["diff"], "policy_id": diff_entry["policy_id"], }, ) @@ -2121,8 +1993,11 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: success=True, found=True, diff={ - "action": "delete_and_create", "before": have, - "after": want, "want": want, "have": have, + "action": "delete_and_create", + "before": have, + "after": want, + "want": want, + "have": have, "diff": diff_entry["diff"], "delete_policy_id": diff_entry["policy_id"], }, @@ -2152,14 +2027,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: # entry — otherwise we'd create a duplicate. remove_failed_ids: set = set() if delete_and_create_batch: - remove_ids = [ - d["policy_id"] for d in delete_and_create_batch if d["policy_id"] - ] + remove_ids = [d["policy_id"] for d in delete_and_create_batch if d["policy_id"]] if remove_ids: - self.log.info( - f"Phase 3: Removing {len(remove_ids)} old policies " - f"for delete_and_create: {remove_ids}" - ) + self.log.info(f"Phase 3: Removing {len(remove_ids)} old policies " f"for delete_and_create: {remove_ids}") # Build policy→switch map for switchActions/deploy dac_switch_map: Dict[str, str] = {} @@ -2171,9 +2041,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: dac_switch_map[pid] = sw # Step 3a: Attempt markDelete for all old policies - self.log.info( - f"Phase 3a: markDelete for {len(remove_ids)} old policies" - ) + self.log.info(f"Phase 3a: markDelete for {len(remove_ids)} old policies") mark_delete_data = self._api_mark_delete(remove_ids) mark_succeeded = [] @@ -2191,33 +2059,18 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: msg = p.get("message", "") if "content type PYTHON" in msg: mark_failed_python.append(pid) - self.log.info( - f"markDelete failed for {pid} " - "(PYTHON content type) — will use " - "direct DELETE" - ) + self.log.info(f"markDelete failed for {pid} " "(PYTHON content type) — will use " "direct DELETE") else: mark_failed_other.append(pid) - self.log.error( - f"markDelete failed for {pid} " - f"(status={p.get('status')!r}): {msg}" - ) + self.log.error(f"markDelete failed for {pid} " f"(status={p.get('status')!r}): {msg}") - mark_succeeded = [ - pid for pid in remove_ids if pid not in failed_ids_set - ] + mark_succeeded = [pid for pid in remove_ids if pid not in failed_ids_set] if not policies_response and remove_ids: - self.log.warning( - "markDelete returned empty 'policies' list — " - "treating all as succeeded (ambiguous response)" - ) + self.log.warning("markDelete returned empty 'policies' list — " "treating all as succeeded (ambiguous response)") mark_succeeded = list(remove_ids) else: - self.log.warning( - "markDelete returned non-dict response — " - "treating all as succeeded" - ) + self.log.warning("markDelete returned non-dict response — " "treating all as succeeded") mark_succeeded = list(remove_ids) self.log.info( @@ -2231,79 +2084,46 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: # Step 3b: Direct DELETE for PYTHON-type policies if mark_failed_python: - self.log.info( - f"Phase 3b: Direct DELETE for " - f"{len(mark_failed_python)} PYTHON-type policies" - ) + self.log.info(f"Phase 3b: Direct DELETE for " f"{len(mark_failed_python)} PYTHON-type policies") direct_deleted = [] for pid in mark_failed_python: try: self._api_delete_policy(pid) direct_deleted.append(pid) except Exception: # noqa: BLE001 - self.log.error( - f"Direct DELETE also failed for {pid}" - ) + self.log.error(f"Direct DELETE also failed for {pid}") remove_failed_ids.add(pid) # Deploy to affected switches to push config removal if direct_deleted and self.deploy: - affected_switches = list({ - dac_switch_map[pid] - for pid in direct_deleted - if pid in dac_switch_map - }) + affected_switches = list({dac_switch_map[pid] for pid in direct_deleted if pid in dac_switch_map}) if affected_switches: - self.log.info( - f"Phase 3b: switchActions/deploy for " - f"{len(affected_switches)} switch(es)" - ) + self.log.info(f"Phase 3b: switchActions/deploy for " f"{len(affected_switches)} switch(es)") self._api_deploy_switches(affected_switches) # Step 3c: pushConfig for markDeleted policies (deploy=true) if mark_succeeded and self.deploy: - self.log.info( - f"Phase 3c: pushConfig for " - f"{len(mark_succeeded)} markDeleted policies" - ) - deploy_success = self._deploy_policies( - mark_succeeded, state="merged" - ) + self.log.info(f"Phase 3c: pushConfig for " f"{len(mark_succeeded)} markDeleted policies") + deploy_success = self._deploy_policies(mark_succeeded, state="merged") if not deploy_success: - self.log.error( - "pushConfig failed during delete_and_create — " - "old policy config may not be removed from switch" - ) + self.log.error("pushConfig failed during delete_and_create — " "old policy config may not be removed from switch") # Step 3d: remove markDeleted policy records if mark_succeeded: - self.log.info( - f"Phase 3d: remove {len(mark_succeeded)} " - f"markDeleted policy records" - ) + self.log.info(f"Phase 3d: remove {len(mark_succeeded)} " f"markDeleted policy records") remove_data = self._api_remove_policies(mark_succeeded) rm_ok, rm_fail = self._inspect_207_policies(remove_data) if not rm_ok and not rm_fail and mark_succeeded: - self.log.warning( - "remove returned no per-policy results — " - "treating as success (ambiguous response)" - ) + self.log.warning("remove returned no per-policy results — " "treating as success (ambiguous response)") if rm_fail: for p in rm_fail: pid = p.get("policyId", "") if pid: remove_failed_ids.add(pid) - fail_msgs = [ - f"{p.get('policyId', '?')}: " - f"{p.get('message', 'unknown')}" - for p in rm_fail - ] - self.log.error( - f"remove failed for {len(rm_fail)} policy(ies): " - + "; ".join(fail_msgs) - ) + fail_msgs = [f"{p.get('policyId', '?')}: " f"{p.get('message', 'unknown')}" for p in rm_fail] + self.log.error(f"remove failed for {len(rm_fail)} policy(ies): " + "; ".join(fail_msgs)) # ── Phase 4: Bulk create ──────────────────────────────────────── # @@ -2338,11 +2158,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: action="policy_replace", operation_type=OperationType.UPDATE, return_code=207, - message=( - f"Cannot replace policy: removal of old policy " - f"{d['policy_id']} failed. Skipping create to " - f"avoid duplicates." - ), + message=(f"Cannot replace policy: removal of old policy " f"{d['policy_id']} failed. Skipping create to " f"avoid duplicates."), success=False, found=True, diff={ @@ -2364,24 +2180,15 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: continue want_list = [d["want"] for d in batch_entries] - self.log.info( - f"Bulk creating {len(want_list)} policies " - f"(batch={batch_label})" - ) + self.log.info(f"Bulk creating {len(want_list)} policies " f"(batch={batch_label})") try: created_ids = self._api_bulk_create_policies(want_list) except NDModuleError as bulk_err: - self.log.error( - f"Bulk {batch_label} failed entirely: {bulk_err.msg}" - ) + self.log.error(f"Bulk {batch_label} failed entirely: {bulk_err.msg}") for diff_entry in batch_entries: want = diff_entry["want"] - action_label = ( - "policy_replace" - if diff_entry["action"] == "delete_and_create" - else "policy_create" - ) + action_label = "policy_replace" if diff_entry["action"] == "delete_and_create" else "policy_create" self._proposed.append(want) if diff_entry.get("have"): self._before.append(diff_entry["have"]) @@ -2408,30 +2215,21 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: field_diff = diff_entry["diff"] is_replace = diff_entry["action"] == "delete_and_create" - entry_result = ( - created_ids[idx] if idx < len(created_ids) - else {"policy_id": None, "nd_error": "No response entry from ND"} - ) + entry_result = created_ids[idx] if idx < len(created_ids) else {"policy_id": None, "nd_error": "No response entry from ND"} created_id = entry_result["policy_id"] nd_error = entry_result["nd_error"] per_policy_error = None # created_id is None when per-policy response had status!=success if created_id is None: - per_policy_error = ( - f"Policy creation failed for " - f"{want.get('templateName')} on " - f"{want.get('switchId')}: {nd_error}" - ) + per_policy_error = f"Policy creation failed for " f"{want.get('templateName')} on " f"{want.get('switchId')}: {nd_error}" self._proposed.append(want) if have: self._before.append(have) if per_policy_error: - action_label = ( - "policy_replace" if is_replace else "policy_create" - ) + action_label = "policy_replace" if is_replace else "policy_create" self._register_result( action=action_label, operation_type=OperationType.CREATE, @@ -2462,7 +2260,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: "action": "delete_and_create", "before": have, "after": {**want, "policyId": created_id}, - "want": want, "have": have, "diff": field_diff, + "want": want, + "have": have, + "diff": field_diff, "deleted_policy_id": diff_entry["policy_id"], "created_policy_id": created_id, }, @@ -2479,7 +2279,8 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: "action": "create", "before": None, "after": {**want, "policyId": created_id}, - "want": want, "diff": field_diff, + "want": want, + "diff": field_diff, "created_policy_id": created_id, }, ) @@ -2497,9 +2298,7 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: try: self._api_update_policy(want, have, policy_id) except NDModuleError as update_err: - self.log.error( - f"Update failed for {policy_id}: {update_err.msg}" - ) + self.log.error(f"Update failed for {policy_id}: {update_err.msg}") self._register_result( action="policy_update", operation_type=OperationType.UPDATE, @@ -2510,7 +2309,9 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: found=True, diff={ "action": "update_failed", - "want": want, "have": have, "diff": field_diff, + "want": want, + "have": have, + "diff": field_diff, "policy_id": policy_id, "error": update_err.msg, }, @@ -2531,8 +2332,11 @@ def _execute_merged(self, diff_results: List[Dict]) -> List[str]: found=True, diff={ "action": "update", - "before": have, "after": after_merged, - "want": want, "have": have, "diff": field_diff, + "before": have, + "after": after_merged, + "want": want, + "have": have, + "diff": field_diff, "policy_id": policy_id, }, ) @@ -2670,11 +2474,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: warning = diff_entry["warning"] error_msg = diff_entry["error_msg"] - self.log.debug( - f"Delete action={action} for " - f"{want.get('templateName', want.get('policyId', 'switch-only'))}, " - f"policy_ids={policy_ids}" - ) + self.log.debug(f"Delete action={action} for " f"{want.get('templateName', want.get('policyId', 'switch-only'))}, " f"policy_ids={policy_ids}") # --- FAIL --- if action == "fail": @@ -2694,10 +2494,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # --- SKIP --- if action == "skip": - self.log.info( - f"Policy not found for deletion: " - f"{want.get('templateName', want.get('policyId', 'switch-only'))}" - ) + self.log.info(f"Policy not found for deletion: " f"{want.get('templateName', want.get('policyId', 'switch-only'))}") self._proposed.append(want) self._register_result( action="policy_deleted", @@ -2713,9 +2510,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # --- DELETE / DELETE_ALL --- if action in ("delete", "delete_all"): - self.log.info( - f"Collecting {len(policy_ids)} policy(ies) for deletion: {policy_ids}" - ) + self.log.info(f"Collecting {len(policy_ids)} policy(ies) for deletion: {policy_ids}") self._proposed.append(want) self._before.extend(policies) # what existed before deletion all_policy_ids_to_delete.extend(policy_ids) @@ -2785,19 +2580,13 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Phase B: Execute bulk API calls (skip if check_mode or nothing to delete) if self.check_mode or not all_policy_ids_to_delete: - self.log.info( - "Skipping bulk delete: " - f"{'check_mode' if self.check_mode else 'no policies to delete'}" - ) + self.log.info("Skipping bulk delete: " f"{'check_mode' if self.check_mode else 'no policies to delete'}") self.log.debug("EXIT: _execute_deleted()") return # Deduplicate policy IDs (same policy could match multiple config entries) unique_policy_ids = list(dict.fromkeys(all_policy_ids_to_delete)) - self.log.info( - f"Total policies to delete: {len(unique_policy_ids)} " - f"(deduplicated from {len(all_policy_ids_to_delete)})" - ) + self.log.info(f"Total policies to delete: {len(unique_policy_ids)} " f"(deduplicated from {len(all_policy_ids_to_delete)})") # --------------------------------------------------------------------- # Delete strategy: markDelete-first with automatic fallback @@ -2814,10 +2603,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # --------------------------------------------------------------------- # Step 1: Attempt markDelete for all policies - self.log.info( - f"{'Step 1/3' if self.deploy else 'Step 1/1'}: " - f"markDelete for {len(unique_policy_ids)} policies" - ) + self.log.info(f"{'Step 1/3' if self.deploy else 'Step 1/1'}: " f"markDelete for {len(unique_policy_ids)} policies") mark_delete_data = self._api_mark_delete(unique_policy_ids) # Inspect 207 response for per-policy results @@ -2840,16 +2626,10 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: msg = p.get("message", "") if "content type PYTHON" in msg: mark_failed_python.append(pid) - self.log.info( - f"markDelete failed for {pid} (PYTHON content type) " - "— will retry via direct DELETE" - ) + self.log.info(f"markDelete failed for {pid} (PYTHON content type) " "— will retry via direct DELETE") else: mark_failed.append(pid) - self.log.error( - f"markDelete failed for {pid} " - f"(status={p.get('status')!r}): {msg}" - ) + self.log.error(f"markDelete failed for {pid} " f"(status={p.get('status')!r}): {msg}") # Policies not in the failed set are considered successful mark_succeeded = [pid for pid in unique_policy_ids if pid not in failed_ids] @@ -2857,17 +2637,12 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Warn if ND returned empty policies list (ambiguous) if not policies_response and unique_policy_ids: self.log.warning( - "markDelete returned empty 'policies' list for " - f"{len(unique_policy_ids)} policy IDs — " - "treating all as succeeded (ambiguous response)" + "markDelete returned empty 'policies' list for " f"{len(unique_policy_ids)} policy IDs — " "treating all as succeeded (ambiguous response)" ) mark_succeeded = list(unique_policy_ids) else: # No structured response — assume all succeeded (pre-existing behavior) - self.log.warning( - "markDelete returned non-dict response — " - "treating all as succeeded" - ) + self.log.warning("markDelete returned non-dict response — " "treating all as succeeded") mark_succeeded = list(unique_policy_ids) self.log.info( @@ -2898,10 +2673,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: state="deleted", operation_type=OperationType.DELETE, return_code=207, - message=( - f"markDelete failed for {len(mark_failed)} policy(ies): " - f"{mark_failed}" - ), + message=(f"markDelete failed for {len(mark_failed)} policy(ies): " f"{mark_failed}"), success=False, found=True, diff={ @@ -2912,10 +2684,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Step 1b: Fallback — direct DELETE for PYTHON-type policies if mark_failed_python: - self.log.info( - f"Falling back to direct DELETE for {len(mark_failed_python)} " - f"PYTHON-type policies: {mark_failed_python}" - ) + self.log.info(f"Falling back to direct DELETE for {len(mark_failed_python)} " f"PYTHON-type policies: {mark_failed_python}") deleted_direct = [] failed_direct = [] for pid in mark_failed_python: @@ -2927,10 +2696,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: failed_direct.append(pid) if deleted_direct: - tpl_names = list({ - policy_template_map.get(pid, "unknown") - for pid in deleted_direct - }) + tpl_names = list({policy_template_map.get(pid, "unknown") for pid in deleted_direct}) self._register_result( action="policy_direct_delete", state="deleted", @@ -2956,10 +2722,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: state="deleted", operation_type=OperationType.DELETE, return_code=-1, - message=( - f"Direct DELETE failed for {len(failed_direct)} " - f"policy(ies): {failed_direct}" - ), + message=(f"Direct DELETE failed for {len(failed_direct)} " f"policy(ies): {failed_direct}"), success=False, found=True, diff={ @@ -2972,16 +2735,9 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # to the devices. Direct DELETE removes the policy record but # the device still has the running config until we deploy. if deleted_direct and self.deploy: - affected_switches = list({ - policy_switch_map[pid] - for pid in deleted_direct - if pid in policy_switch_map - }) + affected_switches = list({policy_switch_map[pid] for pid in deleted_direct if pid in policy_switch_map}) if affected_switches: - self.log.info( - f"Deploying config to {len(affected_switches)} switch(es) " - f"after direct DELETE: {affected_switches}" - ) + self.log.info(f"Deploying config to {len(affected_switches)} switch(es) " f"after direct DELETE: {affected_switches}") deploy_data = self._api_deploy_switches(affected_switches) # Inspect switchActions/deploy response. @@ -3005,31 +2761,18 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: if isinstance(deploy_data, dict) and deploy_data: status_str = deploy_data.get("status", "") if status_str: - self.log.info( - f"switchActions/deploy status: {status_str}" - ) + self.log.info(f"switchActions/deploy status: {status_str}") else: - self.log.warning( - "switchActions/deploy returned non-empty body " - f"but no 'status' field: {deploy_data}" - ) + self.log.warning("switchActions/deploy returned non-empty body " f"but no 'status' field: {deploy_data}") else: - self.log.warning( - "switchActions/deploy returned empty body — " - "treating as success (ND commonly returns {} " - "for this endpoint)" - ) + self.log.warning("switchActions/deploy returned empty body — " "treating as success (ND commonly returns {} " "for this endpoint)") self._register_result( action="policy_switch_deploy", state="deleted", operation_type=OperationType.DELETE, return_code=207, - message=( - f"Deployed config to {len(affected_switches)} " - f"switch(es) to push removal of directly-deleted " - f"PYTHON-type policies" - ), + message=(f"Deployed config to {len(affected_switches)} " f"switch(es) to push removal of directly-deleted " f"PYTHON-type policies"), success=deploy_ok, found=True, diff={ @@ -3051,38 +2794,25 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # markDelete, there's nothing left for pushConfig/remove. normal_delete_ids = mark_succeeded if not normal_delete_ids: - self.log.info( - "No policies were successfully markDeleted — " - "skipping pushConfig/remove" - ) + self.log.info("No policies were successfully markDeleted — " "skipping pushConfig/remove") self.log.debug("EXIT: _execute_deleted()") return # Step 2 (deploy=true only): pushConfig deploy_success = True if self.deploy: - self.log.info( - f"Step 2/3: pushConfig for {len(normal_delete_ids)} policies" - ) - deploy_success = self._deploy_policies( - normal_delete_ids, state="deleted" - ) + self.log.info(f"Step 2/3: pushConfig for {len(normal_delete_ids)} policies") + deploy_success = self._deploy_policies(normal_delete_ids, state="deleted") # deploy=false: stop after markDelete if not self.deploy: - self.log.info( - "Deploy=false: skipping pushConfig/remove; " - "policies remain marked for deletion" - ) + self.log.info("Deploy=false: skipping pushConfig/remove; " "policies remain marked for deletion") self.log.debug("EXIT: _execute_deleted()") return # If pushConfig failed, do NOT proceed to remove. if not deploy_success: - self.log.error( - "pushConfig failed — aborting remove. " - "Policies remain in markDeleted state." - ) + self.log.error("pushConfig failed — aborting remove. " "Policies remain in markDeleted state.") self._register_result( action="policy_deploy_abort", state="deleted", @@ -3105,9 +2835,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: return # Step 3: remove — hard-delete policy records from ND - self.log.info( - f"Step 3/3: remove {len(normal_delete_ids)} policies" - ) + self.log.info(f"Step 3/3: remove {len(normal_delete_ids)} policies") remove_data = self._api_remove_policies(normal_delete_ids) # Inspect 207 response for per-policy failures @@ -3117,20 +2845,12 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: # Warn if ND returned no per-policy detail at all if not rm_ok and not rm_fail and normal_delete_ids: self.log.warning( - f"remove returned no per-policy results for " - f"{len(normal_delete_ids)} policy IDs — treating as success " - "(ambiguous response)" + f"remove returned no per-policy results for " f"{len(normal_delete_ids)} policy IDs — treating as success " "(ambiguous response)" ) if rm_fail: - fail_msgs = [ - f"{p.get('policyId', '?')}: {p.get('message', 'unknown')}" - for p in rm_fail - ] - self.log.error( - f"remove failed for {len(rm_fail)} policy(ies): " - + "; ".join(fail_msgs) - ) + fail_msgs = [f"{p.get('policyId', '?')}: {p.get('message', 'unknown')}" for p in rm_fail] + self.log.error(f"remove failed for {len(rm_fail)} policy(ies): " + "; ".join(fail_msgs)) self._register_result( action="policy_remove", @@ -3140,10 +2860,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: message=( f"Removed {len(normal_delete_ids)} policies" if remove_success - else ( - f"Remove partially failed: " - f"{len(rm_ok)} succeeded, {len(rm_fail)} failed" - ) + else (f"Remove partially failed: " f"{len(rm_ok)} succeeded, {len(rm_fail)} failed") ), success=remove_success, found=True, @@ -3151,9 +2868,7 @@ def _execute_deleted(self, diff_results: List[Dict]) -> None: "action": "remove", "policy_ids": normal_delete_ids, "remove_success": remove_success, - "failed_policies": [ - p.get("policyId") for p in rm_fail - ], + "failed_policies": [p.get("policyId") for p in rm_fail], }, ) @@ -3222,20 +2937,13 @@ def _deploy_policies( # Warn if ND returned no per-policy detail at all if not succeeded_policies and not failed_policies and policy_ids: - self.log.warning( - f"pushConfig returned no per-policy results for " - f"{len(policy_ids)} policy IDs — treating as success " - "(ambiguous response)" - ) + self.log.warning(f"pushConfig returned no per-policy results for " f"{len(policy_ids)} policy IDs — treating as success " "(ambiguous response)") deploy_success = len(failed_policies) == 0 if failed_policies: failed_msgs = [f"{p.get('policyId', '?')}: {p.get('message', 'unknown error')}" for p in failed_policies] - self.log.error( - f"pushConfig failed for {len(failed_policies)} policy(ies): " - + "; ".join(failed_msgs) - ) + self.log.error(f"pushConfig failed for {len(failed_policies)} policy(ies): " + "; ".join(failed_msgs)) self.results.response_current = self.nd.rest_send.response_current self.results.result_current = { @@ -3368,10 +3076,7 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: bulk = PolicyCreateBulk(policies=policy_models) payload = bulk.to_request_dict() - self.log.info( - f"Bulk create payload templateInputs: " - f"{[{k: v for k, v in (w.get('templateInputs') or {}).items()} for w in want_list]}" - ) + self.log.info(f"Bulk create payload templateInputs: " f"{[{k: v for k, v in (w.get('templateInputs') or {}).items()} for w in want_list]}") ep = EpManagePoliciesPost() ep.fabric_name = self.fabric_name @@ -3409,9 +3114,7 @@ def _api_bulk_create_policies(self, want_list: List[Dict]) -> List[Dict]: results.append({"policy_id": None, "nd_error": "No response entry from ND"}) self.log.info( - f"Bulk create complete: " - f"{sum(1 for r in results if r['policy_id'])} succeeded, " - f"{sum(1 for r in results if r['policy_id'] is None)} failed" + f"Bulk create complete: " f"{sum(1 for r in results if r['policy_id'])} succeeded, " f"{sum(1 for r in results if r['policy_id'] is None)} failed" ) return results @@ -3564,9 +3267,7 @@ def _api_deploy_switches(self, switch_ids: List[str]) -> dict: Response DATA dict from ND. Typically contains a ``status`` field like ``"Configuration deployment completed for [...]"``. """ - self.log.info( - f"Deploying config to {len(switch_ids)} switch(es): {switch_ids}" - ) + self.log.info(f"Deploying config to {len(switch_ids)} switch(es): {switch_ids}") body = SwitchIds(switch_ids=switch_ids) ep = EpManageSwitchActionsDeployPost() diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py index aec2cead..06cb2ded 100644 --- a/plugins/modules/nd_policy.py +++ b/plugins/modules/nd_policy.py @@ -6,7 +6,7 @@ from __future__ import absolute_import, annotations, division, print_function -# pylint: disable=invalid-name +# pylint: disable=invalid-name,logging-fstring-interpolation __metaclass__ = type # pylint: enable=invalid-name __copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." @@ -51,7 +51,7 @@ When O(use_desc_as_key=true), the description uniquely identifies the policy, so in-place updates are supported. author: -- L Nikhil Sri Krishna +- L Nikhil Sri Krishna (@nisaikri) options: fabric_name: description: @@ -240,8 +240,7 @@ - cisco.nd.modules - cisco.nd.check_mode seealso: -- name: Cisco ND Policy Management - description: Understanding switch policy management on ND. +- module: cisco.nd.nd_rest notes: - When O(use_desc_as_key=false) and O(config[].name) is a template name (not a policy ID), existing policies are B(never) updated in-place. The module always creates a new policy. diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py index cdac9979..6b7c2ce2 100644 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py @@ -24,7 +24,6 @@ does_not_raise, ) - # ============================================================================= # Test: ConfigTemplateEndpointParams # ============================================================================= @@ -150,7 +149,7 @@ def test_manage_config_templates_00110(): """ instance = EpManageConfigTemplateParametersGet() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_config_templates_00120(): @@ -214,9 +213,7 @@ def test_manage_config_templates_00140(): instance.template_name = "switch_freeform" instance.endpoint_params.cluster_name = "cluster1" result = instance.path - assert result == ( - "/api/v1/manage/configTemplates/switch_freeform/parameters?clusterName=cluster1" - ) + assert result == ("/api/v1/manage/configTemplates/switch_freeform/parameters?clusterName=cluster1") def test_manage_config_templates_00150(): diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py index 8f68ed89..1e685473 100644 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py @@ -28,7 +28,6 @@ does_not_raise, ) - # ============================================================================= # Test: PoliciesGetEndpointParams # ============================================================================= @@ -237,7 +236,7 @@ def test_manage_policies_00110(): """ instance = EpManagePoliciesGet() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policies_00120(): @@ -402,7 +401,7 @@ def test_manage_policies_00210(): """ instance = EpManagePoliciesPost() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policies_00220(): @@ -496,7 +495,7 @@ def test_manage_policies_00310(): """ instance = EpManagePoliciesPut() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policies_00320(): @@ -516,7 +515,7 @@ def test_manage_policies_00320(): instance = EpManagePoliciesPut() instance.fabric_name = "my-fabric" with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policies_00330(): @@ -612,7 +611,7 @@ def test_manage_policies_00410(): """ instance = EpManagePoliciesDelete() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policies_00420(): @@ -632,7 +631,7 @@ def test_manage_policies_00420(): instance = EpManagePoliciesDelete() instance.fabric_name = "my-fabric" with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policies_00430(): diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py index 07713fbd..7270b117 100644 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py @@ -27,7 +27,6 @@ does_not_raise, ) - # ============================================================================= # Test: PolicyActionMutationEndpointParams # ============================================================================= @@ -69,9 +68,7 @@ def test_manage_policy_actions_00020(): - PolicyActionMutationEndpointParams.to_query_string() """ with does_not_raise(): - params = PolicyActionMutationEndpointParams( - cluster_name="cluster1", ticket_id="MyTicket1234" - ) + params = PolicyActionMutationEndpointParams(cluster_name="cluster1", ticket_id="MyTicket1234") result = params.to_query_string() assert "clusterName=cluster1" in result assert "ticketId=MyTicket1234" in result @@ -202,7 +199,7 @@ def test_manage_policy_actions_00110(): """ instance = EpManagePolicyActionsMarkDeletePost() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policy_actions_00120(): @@ -246,9 +243,7 @@ def test_manage_policy_actions_00130(): instance.endpoint_params.cluster_name = "cluster1" instance.endpoint_params.ticket_id = "MyTicket1234" result = instance.path - assert result.startswith( - "/api/v1/manage/fabrics/my-fabric/policyActions/markDelete?" - ) + assert result.startswith("/api/v1/manage/fabrics/my-fabric/policyActions/markDelete?") assert "clusterName=cluster1" in result assert "ticketId=MyTicket1234" in result @@ -298,7 +293,7 @@ def test_manage_policy_actions_00210(): """ instance = EpManagePolicyActionsPushConfigPost() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policy_actions_00220(): @@ -341,9 +336,7 @@ def test_manage_policy_actions_00230(): instance.fabric_name = "my-fabric" instance.endpoint_params.cluster_name = "cluster1" result = instance.path - assert result == ( - "/api/v1/manage/fabrics/my-fabric/policyActions/pushConfig?clusterName=cluster1" - ) + assert result == ("/api/v1/manage/fabrics/my-fabric/policyActions/pushConfig?clusterName=cluster1") # ============================================================================= @@ -391,7 +384,7 @@ def test_manage_policy_actions_00310(): """ instance = EpManagePolicyActionsRemovePost() with pytest.raises(ValueError): - _ = instance.path + instance.path def test_manage_policy_actions_00320(): @@ -435,8 +428,6 @@ def test_manage_policy_actions_00330(): instance.endpoint_params.cluster_name = "cluster1" instance.endpoint_params.ticket_id = "MyTicket1234" result = instance.path - assert result.startswith( - "/api/v1/manage/fabrics/my-fabric/policyActions/remove?" - ) + assert result.startswith("/api/v1/manage/fabrics/my-fabric/policyActions/remove?") assert "clusterName=cluster1" in result assert "ticketId=MyTicket1234" in result