diff --git a/.github/workflows/ansible-test.yml b/.github/workflows/ansible-test.yml index 21cbabf7d..8a91e4174 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: diff --git a/plugins/module_utils/__init__.py b/plugins/module_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/common/__init__.py b/plugins/module_utils/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py new file mode 100644 index 000000000..0c53c2c24 --- /dev/null +++ b/plugins/module_utils/common/exceptions.py @@ -0,0 +1,149 @@ +# 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) +""" +# 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 + +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) + + +class NDStateMachineError(Exception): + """ + Raised when NDStateMachine is failing. + """ + + pass diff --git a/plugins/module_utils/common/log.py b/plugins/module_utils/common/log.py new file mode 100644 index 000000000..f43d9018e --- /dev/null +++ b/plugins/module_utils/common/log.py @@ -0,0 +1,459 @@ +# 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 + +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/common/pydantic_compat.py b/plugins/module_utils/common/pydantic_compat.py new file mode 100644 index 000000000..08f4e1dbc --- /dev/null +++ b/plugins/module_utils/common/pydantic_compat.py @@ -0,0 +1,298 @@ +# 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) + +# 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 + +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, + ValidationInfo, + PydanticExperimentalWarning, + StrictBool, + SecretStr, + ValidationError, + field_serializer, + model_serializer, + field_validator, + model_validator, + validator, + computed_field, + FieldSerializationInfo, + SerializationInfo, + ) + + 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, + ValidationInfo, + 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(*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") + + # 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}" + + 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.""" + + 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 + + +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", + "SecretStr", + "ValidationError", + "field_serializer", + "model_serializer", + "field_validator", + "model_validator", + "require_pydantic", + "validator", + "computed_field", + "FieldSerializationInfo", + "SerializationInfo", +] diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 10de9edf8..f5bfd977d 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,7 +5,25 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type +from typing import Dict +from types import MappingProxyType +from copy import deepcopy + + +class NDConstantMapping: + def __init__(self, data: Dict): + self.data = data + self.new_dict = deepcopy(data) + 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 + + def get_original_data(self): + return list(self.data.keys()) + OBJECT_TYPES = { "tenant": "OST_TENANT", @@ -157,6 +173,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"} diff --git a/plugins/module_utils/endpoints/__init__.py b/plugins/module_utils/endpoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/endpoints/base.py b/plugins/module_utils/endpoints/base.py new file mode 100644 index 000000000..c3d7f4e1f --- /dev/null +++ b/plugins/module_utils/endpoints/base.py @@ -0,0 +1,137 @@ +# 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) +""" +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 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 +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +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 + """ + + # NOTE: function to set endpoints attribute fields from identifiers -> acts as the bridge between Models and Endpoints for API Request Orchestration + def set_identifiers(self, identifier: IdentifierKey = None): + pass diff --git a/plugins/module_utils/endpoints/enums.py b/plugins/module_utils/endpoints/enums.py new file mode 100644 index 000000000..92ae57835 --- /dev/null +++ b/plugins/module_utils/endpoints/enums.py @@ -0,0 +1,47 @@ +# 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 endpoints. +""" + +from __future__ import absolute_import, division, print_function + +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" diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py new file mode 100644 index 000000000..a59819c7d --- /dev/null +++ b/plugins/module_utils/endpoints/mixins.py @@ -0,0 +1,98 @@ +# 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, 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 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.""" + + 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 000000000..2cddd97d2 --- /dev/null +++ b/plugins/module_utils/endpoints/query_params.py @@ -0,0 +1,322 @@ +# 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..ea3b1f4bf --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/aaa_local_users.py @@ -0,0 +1,201 @@ +# 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 + +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 /api/v1/infra/aaa/localUsers 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 = EpInfraAaaLocalUsersGet() + path = request.path + verb = request.verb + + # Get specific local user + request = EpInfraAaaLocalUsersGet() + 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 = EpInfraAaaLocalUsersPost() + 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 = EpInfraAaaLocalUsersPut() + 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 = EpInfraAaaLocalUsersDelete() + 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/base_path.py b/plugins/module_utils/endpoints/v1/infra/base_path.py new file mode 100644 index 000000000..0db15ae9d --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/base_path.py @@ -0,0 +1,78 @@ +# 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 000000000..c0a7b6ca3 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/clusterhealth_config.py @@ -0,0 +1,117 @@ +# 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 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 000000000..ef5afd6c3 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/clusterhealth_status.py @@ -0,0 +1,136 @@ +# 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 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 000000000..6fff91590 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra/login.py @@ -0,0 +1,80 @@ +# 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 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 000000000..e69de29bb 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 000000000..52bb4e567 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/base_path.py @@ -0,0 +1,78 @@ +# 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/endpoints/v1/manage/manage_config_templates.py b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.py new file mode 100644 index 000000000..7bbf88957 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_config_templates.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) + +""" +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 the ND API specification, 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_fabrics_policies.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py new file mode 100644 index 000000000..7490e9b8f --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policies.py @@ -0,0 +1,424 @@ +# -*- 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) + +""" +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, +) +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 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``. + + ## 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 the ND API specification, 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_fabrics_policy_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py new file mode 100644 index 000000000..cfb2b433f --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_policy_actions.py @@ -0,0 +1,327 @@ +# -*- 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) + +""" +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 the ND API specification, 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 the ND API specification, ``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 the ND API specification. + + ## 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/endpoints/v1/manage/manage_fabrics_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py new file mode 100644 index 000000000..6f8d2dde4 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switch_actions.py @@ -0,0 +1,191 @@ +# -*- 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) + +""" +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 the ND API specification, ``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/enums.py b/plugins/module_utils/enums.py new file mode 100644 index 000000000..83f1f76d2 --- /dev/null +++ b/plugins/module_utils/enums.py @@ -0,0 +1,153 @@ +# 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 + +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/plugins/module_utils/logging_config.json b/plugins/module_utils/logging_config.json new file mode 100644 index 000000000..e87ddf051 --- /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/plugins/module_utils/models/base.py b/plugins/module_utils/models/base.py new file mode 100644 index 000000000..a62a12b12 --- /dev/null +++ b/plugins/module_utils/models/base.py @@ -0,0 +1,225 @@ +# 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 + +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 +from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset + + +class NDBaseModel(BaseModel, ABC): + """ + Base model for all Nexus Dashboard API objects. + + 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). + """ + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + arbitrary_types_allowed=True, + extra="ignore", + ) + + # --- Identifier Configuration --- + + identifiers: ClassVar[Optional[List[str]]] = None + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + + # --- Serialization Configuration --- + + exclude_from_diff: ClassVar[Set[str]] = set() + unwanted_keys: ClassVar[List] = [] + + # 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): + super().__init_subclass__(**kwargs) + + # Skip enforcement for nested models + 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'. " 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 'identifier_strategy'. " f"Example: identifier_strategy: ClassVar[...] = 'single'") + + # --- Core Serialization --- + + def _build_payload_nested(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Apply payload_nested_fields: pull specified fields out of the top-level + dict and group them under their declared parent key. + """ + 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 (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) + + # --- Identifier Access --- + + def get_identifier_value(self) -> Optional[Union[str, int, Tuple[Any, ...]]]: + """ + 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 + """ + strategy = self.identifier_strategy + + 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 strategy == "composite": + values = [] + missing = [] + for field in self.identifiers: + value = getattr(self, field, None) + if value is None: + missing.append(field) + values.append(value) + if missing: + raise ValueError(f"Composite identifier fields {missing} are None. " f"All required: {self.identifiers}") + return tuple(values) + + 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}") + + else: + 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": + """ + Merge another model's non-None values into this instance. + Recursively merges nested NDBaseModel fields. + + 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_name, value in other: + if value is None: + continue + + current = getattr(self, field_name) + if isinstance(current, NDBaseModel) and isinstance(value, NDBaseModel): + current.merge(value) + else: + setattr(self, field_name, value) + + return self diff --git a/plugins/module_utils/models/local_user/local_user.py b/plugins/module_utils/models/local_user/local_user.py new file mode 100644 index 000000000..6d4960f34 --- /dev/null +++ b/plugins/module_utils/models/local_user/local_user.py @@ -0,0 +1,228 @@ +# 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 + +from typing import List, Dict, Any, Optional, ClassVar, Literal +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + SecretStr, + model_serializer, + field_serializer, + field_validator, + model_validator, + 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 + +USER_ROLES_MAPPING = NDConstantMapping( + { + "fabric_admin": "fabric-admin", + "observer": "observer", + "super_admin": "super-admin", + "support_engineer": "support-engineer", + "approver": "approver", + "designer": "designer", + } +) + + +class LocalUserSecurityDomainModel(NDNestedModel): + """ + Security domain with assigned roles for a local user. + + Canonical form (config): {"name": "all", "roles": ["observer", "support_engineer"]} + API payload form: {"all": {"roles": ["observer", "support-engineer"]}} + """ + + name: str = Field(alias="name") + roles: Optional[List[str]] = Field(default=None, alias="roles") + + @model_serializer() + def serialize(self, info: SerializationInfo) -> Any: + mode = (info.context or {}).get("mode", "payload") + + 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}} + + +class LocalUserModel(NDBaseModel): + """ + Local user configuration for Nexus Dashboard. + + Identifier: login_id (single) + + 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 --- + + identifiers: ClassVar[Optional[List[str]]] = ["login_id"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + + # --- 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 --- + + 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") + 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") + + # --- 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_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.model_dump(context=info.context)) + return {"domains": domains_dict} + + # --- Validators (Deserialization) --- + + @model_validator(mode="before") + @classmethod + 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 + + 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 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 + + # Already normalized (from Ansible config) + if isinstance(value, list): + return value + + # API response format + if isinstance(value, dict) and "domains" in value: + 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 + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict: + 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=USER_ROLES_MAPPING.get_original_data(), + ), + ), + aliases=["domains"], + ), + remote_id_claim=dict(type="str"), + remote_user_authorization=dict(type="bool"), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted"], + ), + ) 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 000000000..ead0afdfd --- /dev/null +++ b/plugins/module_utils/models/manage_policies/__init__.py @@ -0,0 +1,68 @@ +# -*- 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, +) + +# --- Gathered (read) models --- +from .gathered_models import ( # noqa: F401 + GatheredPolicy, +) + +# --- Config (playbook input) models --- +from .config_models import ( # noqa: F401 + PlaybookPolicyConfig, + PlaybookSwitchEntry, + PlaybookSwitchPolicyConfig, +) + +__all__ = [ + # Enums + "PolicyEntityType", + # Base models + "PolicyCreate", + # CRUD models + "PolicyCreateBulk", + "PolicyUpdate", + # Action models + "PolicyIds", + # Gathered (read) models + "GatheredPolicy", + # Config (playbook input) models + "PlaybookPolicyConfig", + "PlaybookSwitchEntry", + "PlaybookSwitchPolicyConfig", +] 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 000000000..865c3ade0 --- /dev/null +++ b/plugins/module_utils/models/manage_policies/config_models.py @@ -0,0 +1,263 @@ +# -*- 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 ND API specification (``createBasePolicy`` +schema) at the playbook boundary so errors are caught early with clear messages. + +Schema constraints (source: ND API specification, 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 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, +) + +# ============================================================================ +# 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. + Accepts ``serial_number`` or the backward-compatible alias ``ip``. + """ + + identifiers: ClassVar[List[str]] = [] + + serial_number: str = Field( + ..., + min_length=1, + description="Switch serial number, management IP, or hostname", + ) + policies: Optional[List[PlaybookSwitchPolicyConfig]] = Field( + default_factory=list, + 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) +# ============================================================================ + + +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, gathered). + - ``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) -> "PlaybookPolicyConfig": + """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. + + 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") + 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 + + @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"), + 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", "gathered"]), + ) + + +__all__ = [ + "PlaybookPolicyConfig", + "PlaybookSwitchEntry", + "PlaybookSwitchPolicyConfig", +] 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 000000000..37b72e296 --- /dev/null +++ b/plugins/module_utils/models/manage_policies/enums.py @@ -0,0 +1,89 @@ +# -*- 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 the ND API specification for Nexus Dashboard Manage APIs. +""" + +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/gathered_models.py b/plugins/module_utils/models/manage_policies/gathered_models.py new file mode 100644 index 000000000..32f6fd60d --- /dev/null +++ b/plugins/module_utils/models/manage_policies/gathered_models.py @@ -0,0 +1,186 @@ +# -*- 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 ND 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") + +# pylint: disable=logging-fstring-interpolation + + +class GatheredPolicy(NDBaseModel): + """Read-model for a policy returned by the ND API. + + Keyed by ``policy_id`` for ``NDConfigCollection`` dedup. + + 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 + 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 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 + validation. + + Also handles the ``nvPairs`` alias that some API responses use + instead of ``templateInputs``. + + Args: + policy: Raw policy dict from the ND 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/models/manage_policies/policy_actions.py b/plugins/module_utils/models/manage_policies/policy_actions.py new file mode 100644 index 000000000..7663b979b --- /dev/null +++ b/plugins/module_utils/models/manage_policies/policy_actions.py @@ -0,0 +1,183 @@ +# -*- 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 + +- ``PolicyIds`` ← ``policyActions`` request body schema per ND API specification +""" + +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 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 + + ```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. + + ## 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 + + ```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 000000000..5dbdb3347 --- /dev/null +++ b/plugins/module_utils/models/manage_policies/policy_base.py @@ -0,0 +1,174 @@ +# -*- 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 + +- ``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 the ND API specification 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 000000000..e300f240a --- /dev/null +++ b/plugins/module_utils/models/manage_policies/policy_crud.py @@ -0,0 +1,146 @@ +# -*- 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 + +- ``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 the ND API specification 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 the ND API specification diff --git a/plugins/module_utils/models/nested.py b/plugins/module_utils/models/nested.py new file mode 100644 index 000000000..c3af1d716 --- /dev/null +++ b/plugins/module_utils/models/nested.py @@ -0,0 +1,18 @@ +# 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 + +from typing import List, ClassVar +from ansible_collections.cisco.nd.plugins.module_utils.models.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 03ffc85fe..50a5eeb2a 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 @@ -18,7 +14,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 +68,27 @@ def cmp(a, b): def issubset(subset, superset): - """Recurse through nested dictionary and compare entries""" - - # Both objects are the same object - if subset is superset: - return True + """Recurse through a nested dictionary and check if it is a subset of another.""" - # 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 +181,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,7 +235,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) @@ -324,6 +293,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 +491,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_argument_specs.py b/plugins/module_utils/nd_argument_specs.py index 7ef10d048..798ca90f3 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 new file mode 100644 index 000000000..832cc1329 --- /dev/null +++ b/plugins/module_utils/nd_config_collection.py @@ -0,0 +1,214 @@ +# 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 + +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. + """ + + def __init__(self, model_class: NDBaseModel, items: Optional[List[NDBaseModel]] = None): + """ + Initialize collection. + """ + self._model_class: NDBaseModel = model_class + + # Dual storage + self._items: List[NDBaseModel] = [] + self._index: Dict[IdentifierKey, int] = {} + + if items: + for item in items: + self.add(item) + + def _extract_key(self, item: NDBaseModel) -> 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 Operations + + def add(self, item: NDBaseModel) -> 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[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: NDBaseModel) -> 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: NDBaseModel) -> NDBaseModel: + """ + 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 + else: + merged = existing.merge(item) + self.replace(merged) + return merged + + 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: NDBaseModel) -> 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" + + is_subset = existing.get_diff(new_item) + + return "no_diff" if is_subset else "changed" + + def get_diff_collection(self, other: "NDConfigCollection") -> bool: + """ + Check if two collections differ. + """ + if not isinstance(other, NDConfigCollection): + raise TypeError("Argument must be NDConfigCollection") + + if len(self) != len(other): + return True + + for item in other: + if self.get_diff_config(item) != "no_diff": + return True + + for key in self.keys(): + if other.get(key) is None: + return True + + return False + + def get_diff_identifiers(self, other: "NDConfigCollection") -> 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) + + # Collection Operations + + def __len__(self) -> int: + """Return number of items.""" + return len(self._items) + + def __iter__(self): + """Iterate over items.""" + return iter(self._items) + + def keys(self) -> List[IdentifierKey]: + """Get all identifier keys.""" + return list(self._index.keys()) + + def copy(self) -> "NDConfigCollection": + """Create deep copy of collection.""" + return NDConfigCollection(model_class=self._model_class, items=deepcopy(self._items)) + + # Collection Serialization + + 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] + + @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 NDConfigCollection(model_class=model_class, items=items) + + @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 NDConfigCollection(model_class=model_class, items=items) diff --git a/plugins/module_utils/nd_output.py b/plugins/module_utils/nd_output.py new file mode 100644 index 000000000..09759b966 --- /dev/null +++ b/plugins/module_utils/nd_output.py @@ -0,0 +1,65 @@ +# 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 + +from typing import Dict, Any, Optional, List, Union +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection + + +class NDOutput: + 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] = [] + self._diff: Union[NDConfigCollection, List] = [] + self._proposed: Union[NDConfigCollection, List] = [] + self._logs: List = [] + self._extra: Dict[str, Any] = {} + + 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 + + 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"] = self._logs + + 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_policy_resources.py b/plugins/module_utils/nd_policy_resources.py new file mode 100644 index 000000000..0c4c14f06 --- /dev/null +++ b/plugins/module_utils/nd_policy_resources.py @@ -0,0 +1,3330 @@ +# -*- 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) + +""" +ND Policy Resource Module. + +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 + - 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,logging-fstring-interpolation,logging-not-lazy,f-string-without-interpolation,unnecessary-comprehension,implicit-str-concat +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import logging +import re +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.manage_config_templates import ( + EpManageConfigTemplateParametersGet, +) +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_fabrics_policy_actions import ( + EpManagePolicyActionsMarkDeletePost, + EpManagePolicyActionsPushConfigPost, + EpManagePolicyActionsRemovePost, +) +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 ( + 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, +) +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, + 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 + +# ============================================================================= +# Module-level helpers (stateless, used by NDPolicyModule) +# ============================================================================= + + +def _looks_like_ip(value): + """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) + 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. + + 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 + 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 / 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``. + + Returns: + None. + """ + 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 + + if not self.config: + if self.state != "gathered": + 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 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. + # Merged into exit_json output so the caller sees what changed. + 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}") + + 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``. + + Returns: + None. + """ + self.results.build_final_result() + final = self.results.final_result + + # Attach before/after snapshots + 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"): + 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. 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. + 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 not config: + return [] + + # 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: + 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 + for entry in config: + if isinstance(entry.get("switch"), list): + switch_entry = entry + else: + global_policies.append(entry) + + # No switch entry → nothing to target + if switch_entry is None: + return config + + switches = switch_entry["switch"] + if not switches: + return [] + + # ── 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"): + 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] + + # ── Step 4: Build the flat result in one pass ─────────────────── + result = [] + 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 + + 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: + 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, " + "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: + 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, " + "management IP, or hostname from the fabric inventory." + ) + ) + entry["switch"] = resolved + + return config + + 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 + 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.path('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: + - 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()``. + + Returns: + None. + + Raises: + NDModuleError: If any entry is missing a switch serial number. + """ + 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.") + + # ========================================================================= + # 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.) + 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: + 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 + 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. + + Validates, normalizes, and prepares the config, then dispatches + to the appropriate handler: + - **merged** - create / update / skip policies + - **deleted** - deploy=true: markDelete → pushConfig → remove + - deploy=false: markDelete only + + 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}") + + # 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() + + # Upfront cross-entry validation — hard-fail before any API mutations + self._validate_config() + + if self.state == "merged": + self._handle_merged_state() + elif self.state == "deleted": + self._handle_deleted_state() + else: + raise NDModuleError(msg=f"Unsupported state: {self.state}") + + # ========================================================================= + # Upfront Validation + # ========================================================================= + + def _validate_config(self) -> None: + """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 + + 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: + continue + + # 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 + + # 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: + 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) + ) + ) + + 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. + + Returns: + None. + """ + 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_deleted_state(self) -> None: + """Handle state=deleted: remove policies from ND. + + Returns: + None. + """ + 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()") + + # ========================================================================= + # 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 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="gathered") + 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 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 not pid: + self.log.warning("Skipping policy without policyId in gathered results") + skipped += 1 + continue + 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(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. + 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( + 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 ND 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 + + # 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]: + """Remove system-injected keys from template inputs. + + Strips keys listed in ``_SYSTEM_INJECTED_KEYS`` and keeps + everything else as a real template variable. + + Args: + template_name: Template name (for logging context). + raw_inputs: Raw ``templateInputs`` dict from the controller. + + 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())})") + + 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"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)})") + return cleaned + + # ========================================================================= + # Helpers: Classification & Filtering + # ========================================================================= + + @staticmethod + def _is_policy_id(name: str) -> bool: + """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-") + + # 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. + + Returns: + Lucene filter string with terms joined by ``AND``. + """ + parts = [] + for key, value in kwargs.items(): + if value is not None: + parts.append(f"{key}:{cls._escape_lucene_value(str(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 + + Args: + want: Desired policy state dict. + have: Existing policy dict from the controller. + + 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: + # Normalize both sides to lowercase strings to handle: + # - 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() + 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)} + 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, gathered-state export) 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 can + surface the status to the user. + - **source != ""** — internal ND 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 ND 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``, + 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 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", "gathered", 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 gathered/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 ND 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) + # 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) + if not pdef: + continue # Already flagged as unknown above + + 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(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 + + Args: + want: Want dict produced by ``_build_want``. + + Returns: + Tuple of (have_list, error_msg). + """ + self.log.debug("ENTER: _build_have()") + + # Exclude markDeleted policies to avoid false idempotency matches. + incl_md = False + + # 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}'") + # For merged/deleted states, Pydantic enforces that description + # 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") + 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)}") + + 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. + + 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. + """ + 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. + raise NDModuleError( + 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 " + "the controller 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 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. + + 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 = [] + + # 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"] + error_msg = diff_entry["error_msg"] + + self.log.info(f"Classifying action={action} for " f"{want.get('templateName', want.get('policyId', 'unknown'))}") + + 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 + + if action == "skip": + self._proposed.append(want) + if have: + self._before.append(have) + self._after.append(have) + 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 + + if action == "create": + create_batch.append(diff_entry) + continue + + if action == "update": + update_batch.append(diff_entry) + continue + + if action == "delete_and_create": + 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)}") + + # ── 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 (check_mode)", + success=True, + found=False, + diff={"action": "create", "want": want, "diff": diff_entry["diff"]}, + ) + + for diff_entry in update_batch: + want, have = diff_entry["want"], diff_entry["have"] + self._proposed.append(want) + self._before.append(have) + 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": "update", + "before": have, + "after": {**have, **want}, + "want": want, + "have": have, + "diff": diff_entry["diff"], + "policy_id": diff_entry["policy_id"], + }, + ) + + for diff_entry in delete_and_create_batch: + want, have = diff_entry["want"], diff_entry["have"] + self._proposed.append(want) + self._before.append(have) + 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 ───────────────── + # + # 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"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] = {} + 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 — ND has + # 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 = [] + 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 {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" + 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, + }, + ) + continue # Skip per-entry registration for this batch + + # Register per-entry results from bulk response + 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" + + 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}" + + 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", + success=True, + found=True, + diff={ + "action": "delete_and_create", + "before": have, + "after": {**want, "policyId": created_id}, + "want": want, + "have": have, + "diff": field_diff, + "deleted_policy_id": diff_entry["policy_id"], + "created_policy_id": created_id, + }, + ) + else: + self._register_result( + action="policy_create", + operation_type=OperationType.CREATE, + return_code=200, + message="OK", + success=True, + found=False, + diff={ + "action": "create", + "before": None, + "after": {**want, "policyId": created_id}, + "want": want, + "diff": field_diff, + "created_policy_id": 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._proposed.append(want) + self._before.append(have) + + 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} + 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()") + return policy_ids_to_deploy + + # ========================================================================= + # 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. + + 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. + """ + 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: + # Note: description-empty is already caught by Pydantic + # (state=deleted) and _build_have Case C upstream. + want_desc = want.get("description", "") + + 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. + raise NDModuleError( + 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 " + "the controller 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) + + 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") + + # 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] = {} + # 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"] + 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, "before": None, "after": None}, + ) + 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 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: + 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, + "before": policies, + "after": None, + "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, + "before": policies, + "after": None, + "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="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)})") + + # --------------------------------------------------------------------- + # Delete strategy: markDelete-first with automatic fallback + # + # 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}. + # + # This is more robust than maintaining a hardcoded set of template + # names, since the content type is an ND-internal property that + # varies across templates and ND versions. + # --------------------------------------------------------------------- + + # 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 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)" + ) + 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"markDelete results: {len(mark_succeeded)} succeeded, " + f"{len(mark_failed_python)} failed (PYTHON-type, will retry), " + f"{len(mark_failed)} failed (other errors)" + ) + + # 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"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: + try: + self._api_delete_policy(pid) + deleted_direct.append(pid) + except Exception: # noqa: BLE001 + self.log.error(f"Direct DELETE also failed for {pid}") + failed_direct.append(pid) + + if 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", + operation_type=OperationType.DELETE, + return_code=200, + 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, + "templates": tpl_names, + }, + ) + 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, + }, + ) + + # 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: + 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 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. + # + # 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 (ND 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 (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"), + 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 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") + + # deploy=false: 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 ND + 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 + rm_ok, rm_fail = self._inspect_207_policies(remove_data) + remove_success = len(rm_fail) == 0 + + # 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)" + ) + + 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 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], + }, + ) + + 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 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 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)") + + 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 + + # ========================================================================= + # 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. + + 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 + ``"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 (ND did + not report per-item status) and decide accordingly. + + Args: + data: Response DATA dict from ND (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[Dict]: + """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_list: List of want dicts, each with all policy fields. + + Returns: + List of dicts (same length as want_list), each with:: + + { + "policy_id": str or None, # created ID, None on failure + "nd_error": str or None, # ND 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 + with ``policy_id=None`` and do NOT raise. + """ + 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() + + 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: + 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 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 [] + results: List[Dict] = [] + + for idx, want in enumerate(want_list): + if idx < len(created_policies): + entry = created_policies[idx] + entry_status = str(entry.get("status", "")).lower() + if entry_status != "success": + 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')}: {nd_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, "nd_error": None}) + else: + self.log.warning(f"Bulk create: no response entry for policy {idx}") + 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" + ) + return results + + 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. + + Returns: + None. + """ + 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") + self.log.info(f"Update payload templateInputs for {policy_id}: {merged_inputs}") + + 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]) -> Dict: + """Mark policies for deletion via POST /policyActions/markDelete. + + 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:: + + "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: + Response DATA dict from ND. 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) + + 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 + + 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]) -> Dict: + """Hard-delete policies via POST /policyActions/remove. + + 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 ND. + + Returns: + Response DATA dict from ND. 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) + + 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 + + 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}. + + 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"). + + Returns: + None. + """ + 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) + + 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 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}") + 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 + # ========================================================================= + + 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). + + Returns: + None. + """ + 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/module_utils/nd_state_machine.py b/plugins/module_utils/nd_state_machine.py new file mode 100644 index 000000000..fb812c33e --- /dev/null +++ b/plugins/module_utils/nd_state_machine.py @@ -0,0 +1,165 @@ +# 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 + +from typing import Type +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: + """ + Generic State Machine for Nexus Dashboard. + """ + + def __init__(self, module: AnsibleModule, model_orchestrator: Type[NDBaseOrchestrator]): + """ + Initialize the ND State Machine. + """ + self.module = module + self.nd_module = NDModule(self.module) + + # Operation tracking + self.output = NDOutput(output_level=module.params.get("output_level", "normal")) + + # Configuration + self.model_orchestrator = model_orchestrator(sender=self.nd_module) + self.model_class = self.model_orchestrator.model_class + self.state = self.module.params["state"] + + # Initialize collections + try: + response_data = self.model_orchestrator.query_all() + # State of configuration objects in ND before change execution + self.before = NDConfigCollection.from_api_response(response_data=response_data, model_class=self.model_class) + # State of current configuration objects in ND during change execution + self.existing = self.before.copy() + # 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.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 Exception as e: + raise NDStateMachineError(f"Initialization failed: {str(e)}") from e + + # State Management (core function) + def manage_state(self) -> None: + """ + Manage state according to desired configuration. + """ + # Execute state operations + if self.state in ["merged", "replaced", "overridden"]: + self._manage_create_update_state() + + if self.state == "overridden": + self._manage_override_deletions() + + elif self.state == "deleted": + self._manage_delete_state() + + else: + raise NDStateMachineError(f"Invalid state: {self.state}") + + def _manage_create_update_state(self) -> None: + """ + Handle merged/replaced/overridden states. + """ + for proposed_item in self.proposed: + # Extract identifier + identifier = proposed_item.get_identifier_value() + try: + # Determine diff status + diff_status = self.existing.get_diff_config(proposed_item) + + # No changes needed + if diff_status == "no_diff": + continue + + # Prepare final config based on state + if self.state == "merged": + # Merge with existing + final_item = self.existing.merge(proposed_item) + else: + # Replace or create + if diff_status == "changed": + self.existing.replace(proposed_item) + else: + self.existing.add(proposed_item) + final_item = proposed_item + + # Execute API operation + if diff_status == "changed": + if not self.module.check_mode: + self.model_orchestrator.update(final_item) + elif diff_status == "new": + if not self.module.check_mode: + self.model_orchestrator.create(final_item) + self.sent.add(final_item) + + # Log operation + self.output.assign(after=self.existing) + + except Exception as e: + error_msg = f"Failed to process {identifier}: {e}" + if not self.module.params.get("ignore_errors", False): + raise NDStateMachineError(error_msg) from e + + def _manage_override_deletions(self) -> None: + """ + Delete items not in proposed config (for overridden state). + """ + diff_identifiers = self.before.get_diff_identifiers(self.proposed) + + for identifier in diff_identifiers: + try: + existing_item = self.existing.get(identifier) + if not existing_item: + continue + + # Execute delete + if not self.module.check_mode: + self.model_orchestrator.delete(existing_item) + + # Remove from collection + self.existing.delete(identifier) + + # Log deletion + self.output.assign(after=self.existing) + + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + if not self.module.params.get("ignore_errors", False): + raise NDStateMachineError(error_msg) from e + + 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: + continue + + # Execute delete + if not self.module.check_mode: + self.model_orchestrator.delete(existing_item) + + # Remove from collection + self.existing.delete(identifier) + + # Log deletion + self.output.assign(after=self.existing) + + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + if not self.module.params.get("ignore_errors", False): + raise NDStateMachineError(error_msg) from e diff --git a/plugins/module_utils/nd_v2.py b/plugins/module_utils/nd_v2.py new file mode 100644 index 000000000..a622d77f4 --- /dev/null +++ b/plugins/module_utils/nd_v2.py @@ -0,0 +1,311 @@ +# 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 + +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/ndi.py b/plugins/module_utils/ndi.py index 37e7ec56f..6ff912aa7 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 641e675ca..a367e3c59 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 new file mode 100644 index 000000000..be790125b --- /dev/null +++ b/plugins/module_utils/orchestrators/base.py @@ -0,0 +1,75 @@ +# 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 + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel, ConfigDict +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, Generic[ModelType]): + model_config = ConfigDict( + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + arbitrary_types_allowed=True, + ) + + model_class: ClassVar[Type[NDBaseModel]] = NDBaseModel + + # NOTE: if not defined by subclasses, return an error as they are required + 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 + + # NOTE: Generic CRUD API operations for simple endpoints with single identifier (e.g. "api/v1/infra/aaa/LocalUsers/{loginID}") + 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: ModelType, **kwargs) -> ResponseType: + try: + api_endpoint = self.update_endpoint() + api_endpoint.set_identifiers(model_instance.get_identifier_value()) + 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: ModelType, **kwargs) -> ResponseType: + try: + api_endpoint = self.delete_endpoint() + api_endpoint.set_identifiers(model_instance.get_identifier_value()) + 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 + + 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()) + 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 + + 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) + 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 new file mode 100644 index 000000000..e95a3003f --- /dev/null +++ b/plugins/module_utils/orchestrators/local_user.py @@ -0,0 +1,39 @@ +# 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 + +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 +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 ( + EpInfraAaaLocalUsersPost, + EpInfraAaaLocalUsersPut, + EpInfraAaaLocalUsersDelete, + EpInfraAaaLocalUsersGet, +) + + +class LocalUserOrchestrator(NDBaseOrchestrator[LocalUserModel]): + model_class: ClassVar[Type[NDBaseModel]] = LocalUserModel + + 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: + 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/orchestrators/types.py b/plugins/module_utils/orchestrators/types.py new file mode 100644 index 000000000..415526c79 --- /dev/null +++ b/plugins/module_utils/orchestrators/types.py @@ -0,0 +1,9 @@ +# 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 + +from typing import Any, Union, List, Dict + +ResponseType = Union[List[Dict[str, Any]], Dict[str, Any], None] diff --git a/plugins/module_utils/rest/__init__.py b/plugins/module_utils/rest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/rest/protocols/__init__.py b/plugins/module_utils/rest/protocols/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..ab658c99f --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_handler.py @@ -0,0 +1,137 @@ +# 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 + +# 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 000000000..30a81b972 --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_validation.py @@ -0,0 +1,195 @@ +# 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 + +# 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 000000000..df9f4d1b3 --- /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 + + +# 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 + +# 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 000000000..f0f30b948 --- /dev/null +++ b/plugins/module_utils/rest/response_handler_nd.py @@ -0,0 +1,399 @@ +# 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 + +# 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 000000000..e69de29bb 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 000000000..a59537895 --- /dev/null +++ b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py @@ -0,0 +1,265 @@ +# 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 + +# 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 000000000..7631b0dd2 --- /dev/null +++ b/plugins/module_utils/rest/rest_send.py @@ -0,0 +1,818 @@ +# 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 000000000..faee00dca --- /dev/null +++ b/plugins/module_utils/rest/results.py @@ -0,0 +1,1176 @@ +# 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 000000000..b5ed9b856 --- /dev/null +++ b/plugins/module_utils/rest/sender_nd.py @@ -0,0 +1,320 @@ +# 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 + +# 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/plugins/module_utils/types.py b/plugins/module_utils/types.py new file mode 100644 index 000000000..b0056d5a2 --- /dev/null +++ b/plugins/module_utils/types.py @@ -0,0 +1,9 @@ +# 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 + +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 new file mode 100644 index 000000000..7d05e4af0 --- /dev/null +++ b/plugins/module_utils/utils.py @@ -0,0 +1,78 @@ +# 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 + +from copy import deepcopy +from typing import Any, Dict, List, Union + + +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 + + +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 + + +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 diff --git a/plugins/modules/nd_api_key.py b/plugins/modules/nd_api_key.py index c00428a96..1a3e48232 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 new file mode 100644 index 000000000..4f1ff1976 --- /dev/null +++ b/plugins/modules/nd_local_user.py @@ -0,0 +1,209 @@ +# 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 + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_local_user +version_added: "1.6.0" +short_description: Manage local users on Cisco Nexus Dashboard +description: +- Manage local users on Cisco Nexus Dashboard (ND). +- It supports creating, updating, and deleting local users. +author: +- Gaspard Micol (@gmicol) +options: + config: + description: + - The list of the local users to configure. + type: list + elements: dict + required: True + 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.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""" +- 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_domains: + - name: all + state: merged + +- name: Update local user + cisco.nd.nd_local_user: + config: + - email: updateduser@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 + 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 +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 + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update(LocalUserModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + + try: + # Initialize StateMachine + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=LocalUserOrchestrator, + ) + + # Manage state + nd_state_machine.manage_state() + + module.exit_json(**nd_state_machine.output.format()) + + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}", **nd_state_machine.output.format()) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/nd_policy.py b/plugins/modules/nd_policy.py new file mode 100644 index 000000000..06cb2ded2 --- /dev/null +++ b/plugins/modules/nd_policy.py @@ -0,0 +1,652 @@ +#!/usr/bin/python +# -*- 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) + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name,logging-fstring-interpolation +__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", +} + +DOCUMENTATION = r""" +--- +module: nd_policy +version_added: "1.0.0" +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 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. +- 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 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 + (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). + When O(use_desc_as_key=true), the description uniquely identifies the policy, so + in-place updates are supported. +author: +- L Nikhil Sri Krishna (@nisaikri) +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. + - 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. + - A separate C(switch) entry lists the target switches and optional per-switch policy overrides. + - 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: + 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(deleted) state, this is optional. When omitted, all policies + on the specified switch are 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 ND 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. + - 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: + 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. + - 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: + 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 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 (no name-based replacement). + 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 ND 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) 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: + description: + - Change Control Ticket ID to associate with mutation operations. + - Required when Change Control is enabled on the ND 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) 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. + 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, gathered ] + default: merged +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +seealso: +- 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. + 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) 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""" +# 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: 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 + 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_101 # Same name as global → REPLACES it for switch1 + create_additional_policy: false + priority: 999 + - name: template_104 # Different name → ADDED alongside globals + 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 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 fallback) + 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 }}" + +- 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 }}" + +- 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"" + +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 +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_policies.config_models import ( + PlaybookPolicyConfig, +) + + +# ============================================================================= +# Main +# ============================================================================= +def main(): + """Main entry point for the nd_policy module.""" + + argument_spec = nd_argument_spec() + argument_spec.update(PlaybookPolicyConfig.get_argument_spec()) + + 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)) + + # 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 + state = module.params.get("state") + output_level = module.params.get("output_level") + 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, + ) + + # manage_state handles the full pipeline: + # pydantic validation → resolve switches → translate → validate → dispatch + 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/requirements.txt b/requirements.txt index 514632d1e..cc6b2c4bb 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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/config.yml b/tests/config.yml new file mode 100644 index 000000000..7cf024ab0 --- /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/integration/network-integration.requirements.txt b/tests/integration/network-integration.requirements.txt index 514632d1e..cc6b2c4bb 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 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 000000000..c76e22c37 --- /dev/null +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -0,0 +1,1115 @@ +# Test code for the ND modules +# Copyright: (c) 2026, 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: &clean_all_local_users + <<: *nd_info + config: + - login_id: ansible_local_user + - login_id: ansible_local_user_2 + - login_id: ansible_local_user_3 + state: deleted + + +# --- 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 + 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_merged_create_local_users + +- name: Create local users with full and minimum configuration (merged state - normal mode) + cisco.nd.nd_local_user: + <<: *create_local_user_merged_state + register: nm_merged_create_local_users + +- name: Asserts for local users merged state creation tasks + ansible.builtin.assert: + that: + - 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" + +# 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 + 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 + check_mode: true + register: cm_merged_update_local_user_2 + +- name: Update all ansible_local_user_2's attributes except password (merge state - normal mode) + cisco.nd.nd_local_user: + <<: *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: 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: 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: 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 + roles: super_admin + remote_id_claim: "" + remote_user_authorization: false + state: replaced + check_mode: true + register: cm_replace_local_user + +- 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 + - 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.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.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 + +- 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 + config: + - login_id: ansible_local_user + state: deleted + check_mode: true + register: cm_delete_local_user + +- name: Delete local user (normal mode) + cisco.nd.nd_local_user: + <<: *delete_local_user + register: nm_delete_local_user + +- name: Delete local user again (idempotency test) + cisco.nd.nd_local_user: + <<: *delete_local_user + register: nm_delete_local_user_again + +- name: 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.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.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.proposed == nm_delete_local_user.proposed + +# --- CLEAN UP --- + +- name: Ensure local users do not exist + cisco.nd.nd_local_user: + <<: *clean_all_local_users diff --git a/tests/sanity/requirements.txt b/tests/sanity/requirements.txt index 8ea87eb95..2bc68e74a 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/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/module_utils/__init__.py b/tests/unit/module_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/module_utils/common_utils.py b/tests/unit/module_utils/common_utils.py new file mode 100644 index 000000000..f25c31eb2 --- /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.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 + +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 000000000..ce9d1e8d7 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_model.py @@ -0,0 +1,237 @@ +# 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 Literal + +import pytest + +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 000000000..e25c4a4a6 --- /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 000000000..07fdd892c --- /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 000000000..f122d29a5 --- /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_aaa_local_users.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa_local_users.py new file mode 100644 index 000000000..71cfd9b66 --- /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" 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 000000000..e4a3be8e8 --- /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 000000000..b3b88a1bb --- /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_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 000000000..6b7c2ce2e --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_config_templates.py @@ -0,0 +1,234 @@ +# 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_fabrics_policies.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py new file mode 100644 index 000000000..1e6854733 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policies.py @@ -0,0 +1,682 @@ +# 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_fabrics_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_fabrics_policy_actions.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py new file mode 100644 index 000000000..7270b1170 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_policy_actions.py @@ -0,0 +1,433 @@ +# 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_fabrics_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 ND API specification) + + ## 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 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 000000000..03500336c --- /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 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 000000000..88aa460a7 --- /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 000000000..ec5a84d39 --- /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 000000000..d58397dfb --- /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 000000000..e96aad70c --- /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 000000000..7060e8c09 --- /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 000000000..f3250dbcf --- /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 000000000..5f5a8500d --- /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 000000000..a7d3609f1 --- /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 000000000..4b8d7f47f --- /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.rest.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.rest.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.rest.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.rest.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.rest.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.rest.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.rest.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.rest.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") diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt new file mode 100644 index 000000000..98907e9af --- /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 diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py new file mode 100644 index 000000000..96c113460 --- /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()